1. 程式人生 > 實用技巧 >設計模式學習筆記(十四):享元模式

設計模式學習筆記(十四):享元模式

1 概述

1.1 引言

當一個系統中執行時的產生的物件太多,會帶來效能下降等問題,比如一個文字字串存在大量重複字元,如果每一個字元都用一個單獨的物件表示,將會佔用較多記憶體空間。

那麼該如何避免出現大量相同或相似的物件,同時又不影響客戶端以面向物件的方式操作呢?

享元模式正為解決這一問題而生,通過共享技術實現相同或相似物件的重用,在邏輯上每一個出現的字元都有一個物件與之對
應,但是物理上卻共享一個享元物件。

在享元模式中,儲存共享例項的地方稱為享元池,可以針對每一個不同的字元建立一個享元物件,放置於享元池中,需要時取
出,示意圖如下:

1.2 內部狀態與外部狀態

享元模式以共享的方式高效地支援大量細粒度物件的重用,能做到共享的關鍵是區分了內部狀態以及外部狀態。

  • 內部狀態:儲存在享元物件內部並且不會隨環境改變而改變,內部狀態可以共享,例如字元的內容,字元a永遠是字元a,不會變為字元b
  • 外部狀態:能夠隨環境改變而改變,不可以共享的狀態,通常由客戶端儲存,並在享元物件被建立之後,需要使用的時候再傳入到享元物件內部。外部狀態之間通常是相互獨立的,比如字元的顏色,字號,字型等,可以獨立變化,沒有影響,客戶端在使用時將外部狀態注入到享元物件中

正因為區分了內部狀態以及外部狀態,可以將具有相同內部狀態的物件儲存在享元池中,享元池的物件是可以實現共享的,需要的時候從中取出,實現物件的複用。通過向取出的物件注入不同的外部狀態,可以得到一系列相似的物件,而這些物件實際上只儲存一份。

1.3 定義

享元模式:運用共享技術有效地支援大量細粒度物件的複用。

系統只使用少量的物件,而這些物件都很相似,狀態變化很小,可以實現物件的多次複用。由於享元模式要求能夠共享的物件必須是細粒度物件,因此又叫輕量級模式,是一種物件結構型模式。

1.4 結構圖

享元模式一般結合工廠模式一起使用,結構圖如下:

1.5 角色

  • Flyweights(抽象享元類):通常是一個介面或者抽象類,在抽象享元類中聲明瞭具體享元類公共的方法,這些方法可以向外界提供享元物件的內部資料(內部狀態),同時也可以通過這些方法來設定外部資料(外部狀態)
  • ConcreteFlyweight(具體享元類):實現/繼承抽象共享類,例項稱為共享物件,在具體享元類中為內部狀態提供了儲存空間,通常可以結合單例模式來設計具體享元類
  • UnsharedConcreteFlyweight(非共享具體享元類):並不是所有的抽象享元子類都需要被共享,不能被共享的子類可設計為非共享具體享元類,當需要一個非具體享元物件時可以直接例項化建立
  • FlyweightFactory(享元工廠類):享元工廠類用於建立並管理享元物件,針對抽象享元類程式設計,將具體享元物件儲存於享元池中。一般使用鍵值對集合(比如Java中的HashMap)作為享元池,當客戶端獲取享元物件時,首先判斷是否存在,存在則從集合中取出並返回,不存在則建立新具體享元的例項,儲存於享元池中並返回新例項

2 典型實現

2.1 步驟

  • 定義抽象享元類:將抽象享元類定義為介面或者抽象類,宣告業務方法
  • 定義具體享元類:繼承或實現抽象享元,實現其中的業務方法,同時使用單例模式設計,確保每個具體享元類提供唯一的享元物件
  • (可選)定義非共享具體享元類:繼承或實現抽象享元類,不使用單例模式設計,每次客戶端獲取都會返回一個新例項
  • 定義享元工廠類:通常使用一個鍵值對集合作為享元池,根據鍵值返回對應的具體享元物件或非共享具體享元物件

2.2 抽象享元類

這裡使用介面實現,包含一個opeartion業務方法:

interface Flyweight
{
    void operation(String extrinsicState);
}

2.3 具體享元類

簡單設計兩個列舉單例的具體享元類:

enum ConcreteFlyweight1 implements Flyweight
{
    INSTANCE("INTRINSIC STATE 1");
    private String intrinsicState;
    private ConcreteFlyweight1(String intrinsicState)
    {
        this.intrinsicState = intrinsicState;
    }

    @Override
    public void operation(String extrinsicState)
    {
        System.out.println("具體享元操作");
        System.out.println("內部狀態:"+intrinsicState);
        System.out.println("外部狀態:"+extrinsicState);
    }
}

enum ConcreteFlyweight2 implements Flyweight
{
    INSTANCE("INTRINSIC STATE 2");
    private String intrinsicState;
    private ConcreteFlyweight2(String intrinsicState)
    {
        this.intrinsicState = intrinsicState;
    }

    @Override
    public void operation(String extrinsicState)
    {
        System.out.println("具體享元操作");
        System.out.println("內部狀態:"+intrinsicState);
        System.out.println("外部狀態:"+extrinsicState);
    }
}

2.4 非共享具體享元類

兩個簡單的非共享具體享元類,不是列舉單例類:

class UnsharedConcreteFlyweight1 implements Flyweight
{
    @Override
    public void operation(String extrinsicState)
    {
        System.out.println("非共享具體享元操作");
        System.out.println("外部狀態:"+extrinsicState);
    }
}

class UnsharedConcreteFlyweight2 implements Flyweight
{
    @Override
    public void operation(String extrinsicState)
    {
        System.out.println("非共享具體享元操作");
        System.out.println("外部狀態:"+extrinsicState);
    }
}

2.5 享元工廠類

為了方便客戶端以及工廠管理具體享元以及非共享具體享元,首先建立兩個列舉類作為享元池的鍵:

enum Key { KEY1,KEY2 }
enum UnsharedKey { KEY1,KEY2 }

這裡的工廠類使用了列舉單例:

enum Factory
{
    INSTANCE;
    private Map<Key,Flyweight> map = new HashMap<>();
    public Flyweight get(Key key)
    {
        if(map.containsKey(key))
            return map.get(key);
        switch(key)
        {
            case KEY1:    
                map.put(key, ConcreteFlyweight1.INSTANCE);
                return ConcreteFlyweight1.INSTANCE;
            case KEY2:
                map.put(key, ConcreteFlyweight2.INSTANCE);
                return ConcreteFlyweight2.INSTANCE;
            default:
                return null;
        }
    }

    public Flyweight get(UnsharedKey key)
    {
        switch(key)
        {
            case KEY1:
                return new UnsharedConcreteFlyweight1();
            case KEY2:
                return new UnsharedConcreteFlyweight2();
            default:
                return null;
        }
    }
}

使用HashMap<String,Flyweight>作為享元池:

  • 對於具體享元類,根據鍵值判斷享元池中是否存在具體享元物件,如果存在直接返回,如果不存在把具體享元的單例存入享元池,並返回該單例
  • 對於非共享具體享元類,由於是“非共享”,不需要把例項物件儲存於享元池中,每次呼叫直接返回新例項

2.6 反射簡化

如果具體享元物件變多,工廠類的get()中的switch會變得很長,這時候可以將鍵值類以及工廠類的get()改進以簡化程式碼,例如在上面的基礎上又增加了兩個具體享元類:

enum ConcreteFlyweight3 implements Flyweight {...}
enum ConcreteFlyweight4 implements Flyweight {...}

這樣工廠類的switch需要增加兩個Key

switch(key)
{
    case KEY1:    
        map.put(key, ConcreteFlyweight1.INSTANCE);
        return ConcreteFlyweight1.INSTANCE;
    case KEY2:
        map.put(key, ConcreteFlyweight2.INSTANCE);
        return ConcreteFlyweight2.INSTANCE;
    case KEY3:
        map.put(key, ConcreteFlyweight3.INSTANCE);
        return ConcreteFlyweight3.INSTANCE;
    case KEY4:
        map.put(key, ConcreteFlyweight4.INSTANCE);
        return ConcreteFlyweight4.INSTANCE;
    default:
        return null;
}

可以利用具體享元類的命名方式進行簡化,這裡使用了順序編號1,2,3,4...的方式,因此,利用反射獲取對應的類後直接獲取其中的單例物件:

public Flyweight get(Key key)
{
    if(map.containsKey(key))
        return map.get(key);
    try
    {
        Class<?> cls = Class.forName("ConcreteFlyweight"+key.code());
        Flyweight flyweight = (Flyweight)(cls.getField("INSTANCE").get(null));
        map.put(key,flyweight);
        return flyweight;
    }
    catch(Exception e)
    {
        e.printStackTrace();
        return null;
    }
}

在此之前需要修改一下Key類:

enum Key
{
    KEY1(1),KEY2(2),KEY3(3),KEY4(4);
    private int code;
    private Key(int code)
    {
        this.code = code;
    }
    public int code()
    {
        return code;
    }
}

增加一個code欄位,作為區分每一個具體享元的標誌。
對於非共享具體享元類似,首先修改UnsharedKey,同理新增code欄位:

enum UnsharedKey
{
    KEY1(1),KEY2(2),KEY3(3),KEY4(4);
    private int code;
    private UnsharedKey(int code)
    {
        this.code = code;
    }
    public int code()
    {
        return code;
    }
}

接著修改get方法:

public Flyweight get(UnsharedKey key)
{
    try
    {
        Class<?> cls = Class.forName("UnsharedConcreteFlyweight"+key.code());
        return (Flyweight)(cls.newInstance());
    }
    catch(Exception e)
    {
        e.printStackTrace();
        return null;
    }
}

由於筆者使用的是OpenJDK11,其中newInstance被標記為過時了:


因此使用如下方式代替直接使用newInstance()

return (Flyweight)(cls.getDeclaredConstructor().newInstance());

區別如下:

  • newInstance:直接呼叫無參構造方法
  • getDeclaredConstructor().newInstance()getDeclaredConstructor()會根據傳入的引數搜尋該類的構造方法並返回,沒有引數就返回該類的無參構造方法,接著呼叫newInstance進行例項化

3 例項

圍棋棋子的設計:一個棋盤中含有大量相同的黑白棋子,只是出現的位置不一樣,使用享元模式對棋子進行設計。

  • 抽象享元類:IgoChessman介面(如果想要具體享元類為列舉單例的話必須是介面,使用其他方式實現單例可以為抽象類),包含getColor以及display方法
  • 具體享元類:BlackChessman+WhiteChessman,列舉單例類
  • 非共享具體享元類:無
  • 享元工廠類:Factory,列舉單例類,包含簡單的get作為獲取具體享元的方法,加上了white以及balck簡單封裝,在構造方法中初始化享元池

程式碼如下:

//抽象享元介面
interface IgoChessman
{
    Color getColor();
    void display();
}

//具體享元列舉單例類
enum BlackChessman implements IgoChessman
{
    INSTANCE;
    
    @Override
    public Color getColor()
    {
        return Color.BLACK;
    }

    @Override
    public void display()
    {
        System.out.println("棋子顏色"+getColor().color());
    }
}

//具體享元列舉單例類
enum WhiteChessman implements IgoChessman
{
    INSTANCE;
    
    @Override
    public Color getColor()
    {
        return Color.WHITE;
    }

    @Override
    public void display()
    {
        System.out.println("棋子顏色"+getColor().color());
    }
}

//享元工廠列舉單例類
enum Factory
{
    INSTANCE;
    //HashMap<Color,IgoChessman>作為享元池
    private Map<Color,IgoChessman> map = new HashMap<>();
    private Factory()
    {
    	//構造方法中直接初始化享元池
        map.put(Color.WHITE, WhiteChessman.INSTANCE);
        map.put(Color.BLACK, BlackChessman.INSTANCE);
    }
    public IgoChessman get(Color color)
    {
    	//由於在構造方法中已經初始化,如果不存在可以返回null或者新增新例項到享元池並返回,這裡選擇了返回null
        if(!map.containsKey(color))
            return null;
        return (IgoChessman)map.get(color);
    }
    //簡單封裝
    public IgoChessman white()
    {
        return get(Color.WHITE);
    }
    public IgoChessman black()
    {
        return get(Color.BLACK);
    }
}

enum Color
{
    WHITE("白色"),BLACK("黑色");
    private String color;
    private Color(String color)
    {
        this.color = color;
    }
    public String color()
    {
        return color;
    }
}

在初始化享元池時,如果具體享元類過多可以使用反射簡化,不需要手動逐個put

private Factory()
{
	map.put(Color.WHITE, WhiteChessman.INSTANCE);
	map.put(Color.BLACK, BlackChessman.INSTANCE);
}

根據列舉值陣列,結合ListforEach,逐個利用陣列中的值獲取對應的類,進而獲取例項:

private Factory()
{
    List.of(Color.values()).forEach(t->
    {
        String className = t.name().substring(0,1)+t.name().substring(1).toLowerCase()+"Chessman";
        try
        {
            map.put(t,(IgoChessman)(Class.forName(className).getField("INSTANCE").get(null)));
        }
        catch(Exception e)
        {
            e.printStackTrace();
            map.put(t,null);
        }    
    });
}

測試:

public static void main(String[] args) 
{
    Factory factory = Factory.INSTANCE;
    IgoChessman white1 = factory.white();
    IgoChessman white2 = factory.white();
    white1.display();
    white2.display();
    System.out.println(white1 == white2);

    IgoChessman black1 = factory.black();
    IgoChessman black2 = factory.black();
    black1.display();
    black2.display();
    System.out.println(black1 == black2);
}

4 加入外部狀態

通過上面的方式已經能夠實現黑白棋子的共享了,但是還有一個問題沒有解決,就是如何將相同的黑白棋子放置於不同的棋盤位置上?

解決辦法也不難,增加一個座標類Coordinates,呼叫display時作為要放置的座標引數傳入函式。

首先增加一個座標類:

class Coordinates
{
    private int x;
    private int y;    
    public Coordinates(int x,int y)
    {
        this.x = x;
        this.y = y;
    }
	//setter+getter...
}

接著需要修改抽象享元介面,在display中加入Coordinates引數:

interface IgoChessman
{
    Color getColor();
    void display(Coordinates coordinates);
}

然後修改具體享元類即可:

enum BlackChessman implements IgoChessman
{
    INSTANCE;
    
    @Override
    public Color getColor()
    {
        return Color.BLACK;
    }

    @Override
    public void display(Coordinates coordinates)
    {
        System.out.println("棋子顏色"+getColor().color());
        System.out.println("顯示座標:");
        System.out.println("橫座標"+coordinates.getX());
        System.out.println("縱座標"+coordinates.getY());
    }
}

對於客戶端,建立享元物件的程式碼無須修改,只需修改呼叫了display的地方,傳入Coordinates引數即可:

IgoChessman white1 = factory.white();
IgoChessman white2 = factory.white();
white1.display(new Coordinates(1, 2));
white2.display(new Coordinates(2, 3));

5 單純享元模式與複合享元模式

5.1 單純享元模式

標準的享元模式既可以包含具體享元類,也包含非共享具體享元類。
但是在單純享元模式中,所有的具體享元類都是共享的,也就是不存在非共享具體享元類。
比如上面棋子的例子,黑白棋子作為具體享元類都是共享的,不存在非共享具體享元類。

5.2 複合享元模式

將一些單純享元物件進行使用組合模式加以組合還可以形成複合享元物件,這樣的複合享元物件本身不能共享,但是它們可以分解為單純享元物件,而後者可以共享。
通過複合享元模式可以確保複合享元類所包含的每個單純享元類都具有相同的外部狀態,而這些單純享元的內部狀態可以不一樣,比如,上面棋子的例子中:

  • 黑棋子是單純享元
  • 白棋子也是單純享元
  • 這兩個單純享元的內部狀態不同(顏色不同)
  • 但是可以設定相同的外部狀態(比如設定為棋盤上同一位置,但是這樣沒有什麼實際意義,或者設定顯示為同一大小)

例子如下,首先在抽象享元中新增一個以int為引數的display

interface IgoChessman
{
    Color getColor();
    void display(int size);
}

在具體享元實現即可:

enum BlackChessman implements IgoChessman
{
    INSTANCE;
    
    @Override
    public Color getColor()
    {
        return Color.BLACK;
    }

    @Override
    public void display(int size)
    {
        System.out.println("棋子顏色"+getColor().color());
        System.out.println("棋子大小"+size);
    }
}

接著新增複合享元類,裡面包含一個HashMap儲存所有具體享元:

enum Chessmans implements IgoChessman
{
    INSTANCE;
    private Map<Color,IgoChessman> map = new HashMap<>();

    public void add(IgoChessman chessman)
    {
        map.put(chessman.getColor(),chessman);
    }

    @Override
    public Color getColor()
    {
        return null;
    }

    @Override
    public void display(int size)
    {
        map.forEach((k,v)->v.display(size));
    }
}

display中,實際上是遍歷了HashMap,給每一個具體享元的display傳入相同的引數。
測試:

public static void main(String[] args) {
    Factory factory = Factory.INSTANCE;
    IgoChessman white = factory.white();
    IgoChessman black = factory.black();
    Chessmans chessmans = Chessmans.INSTANCE;
    chessmans.add(white);
    chessmans.add(black);
    chessmans.display(30);
}

輸出:

這樣內部狀態不同(顏色不同)的兩個具體享元類(黑白棋)就被複合享元類(Chessmans)設定為具有相同的外部狀態(顯示大小30)。

6 補充說明

  • 與其他模式聯用:享元模式通常需要與其他模式聯用,比如工廠模式(享元工廠),單例模式(具體享元列舉單例),組合模式(複合享元模式)
  • JDK中的享元模式:JDK中的String使用了享元模式。大家都知道String是不可變類,對於類似String a = "123"這種宣告方式,會建立一個值為"123"的享元物件,下次使用"123"時從享元池獲取,在修改享元物件時,比如a += "1",先將原有物件複製一份,然後在新物件上進行修改,這種機制叫做"Copy On Write"。基本思路是,一開始大家都在共享內容,當某人需要修改時,把內容複製出去形成一個新內容並修改

7 主要優點

  • 降低記憶體消耗:享元模式可以極大地減少記憶體中物件的數量,使得相同或相似物件在記憶體中只儲存一份,從而節約系統資源,提供系統性能
  • 外部狀態獨立:享元模式外部狀態相對獨立,不會影響到內部狀態,從而使得享元物件可以在不同環境中被共享

8 主要缺點

  • 增加複雜度:享元模式使得系統變複雜,需要分離出內部狀態以及外部狀態,使得程式邏輯複雜化
  • 執行時間變長:為了使物件可以共享,享元模式需要將享元物件的部分狀態外部化,而讀取外部狀態使得執行時間變長

9 適用場景

  • 一個系統有大量相似或相同物件,造成大量記憶體浪費
  • 物件的大部分狀態都可以外部化,可以將這些外部狀態傳入物件中
  • 由於需要維護享元池,造成一定的資源開銷,因此在需要真正多次重複使用享元物件時才值得使用享元模式

10 總結