【设计模式】结构型模式其六: 享元模式

260 阅读10分钟

享元模式

什么是享元模式

image.png

它的基本思想是:使用共享实现对对象的有效支持。

享元模式通过共享已经存在的对象,来有效地支持大量细粒度的对象。

为什么需要享元模式

如果一个软件系统在运行时所创建的相同或相似对象数量太多,将导致运行代价过高,带来系统资源浪费、性能下降等问题。 如何避免系统中出现大量相同或相似的对象,同时又不影响客户端程序通过面向对象的方式对这些对象进行操作呢?

答案是使用享元模式

享元模式:通过共享技术实现相同或相似对象的重用 享元池(Flyweight Pool):存储共享实例对象的地方

image.png

内部状态(Intrinsic State):存储在享元对象内部并且不会随环境改变而改变的状态,内部状态可以共享(例如:字符的内容)

外部状态(Extrinsic State):随环境改变而改变的、不可以共享的状态。享元对象的外部状态通常由客户端保存,并在享元对象被创建之后,需要使用的时候再传入到享元对象内部。一个外部状态与另一个外部状态之间是相互独立的(例如:字符的颜色和大小)

享元模式的定义

享元模式:运用共享技术有效地支持大量细粒度对象的复用。

又称为轻量级模式

要求能够被共享的对象必须是细粒度对象

享元模式的结构

image.png

享元模式包含以下4个角色:
Flyweight(抽象享元类)
ConcreteFlyweight(具体享元类)
UnsharedConcreteFlyweight(非共享具体享元类)
FlyweightFactory(享元工厂类)

享元模式的实现

原理:
具有相同内部状态的对象存储在享元池中享元池中的对象是可以实现共享的
需要的时候将对象从享元池中取出,即可实现对象的复用
通过向取出的对象注入不同的外部状态,可以得到一系列相似的对象,而这些对象在内存中实际上只存储一份


看到这,应该大致了解是个什么东西,但还是很难去使用它。

因此,从实例来学习

实例学习

问题: 某软件公司要开发一个围棋软件,其界面效果如下图所示。 该软件公司开发人员通过对围棋软件进行分析发现,在图中,围棋棋盘中包含大量的黑子和白子,它们的形状、大小都一模一样,只是出现的位置不同而已。如果将每一个棋子都作为一个独立的对象存储在内存中,将导致该围棋软件在运行时所需内存空间较大,如何降低运行代价、提高系统性能是需要解决的一个问题。为了解决该问题,现使用享元模式来设计该围棋软件的棋子对象。

image.png

抽象享元类

// 抽象享元类: 用于对共享对象的抽象,如果享元对象很简单,则可以不抽象。

//围棋棋子类:抽象享元类
public abstract class IgoChessman {
   public abstract String getColor();

   public void display() {
      System.out.println("棋子颜色:" + this.getColor()); 
   }
}

具体享元类

//黑色棋子类:具体享元类

public class BlackIgoChessman extends IgoChessman {
   public String getColor() {
      return "黑色";
   }  
}

//白色棋子类:具体享元类

public class WhiteIgoChessman extends IgoChessman {
   public String getColor() {
      return "白色";
   }
}

享元工厂类

//围棋棋子工厂类:享元工厂类,使用单例模式进行设计。

为什么需要使用单例模式,因为我们不需要很多工厂,一个就能完成我们的需求,不用频繁的新建与销毁。

  1. 高效性:享元模式的目标是减少内存占用和提高性能,单例模式可以实现这一点。
  2. 线程安全:如果享元工厂是一个单例,那么它在多线程下也是线程安全的
  3. 简单化:有些场景下,确实只需要一个享元工厂实例。使用单例可以简化设计
  4. 资源占用:单例模式只需要一个对象,相对来说资源占用更少。

还用了简单工厂模式,根据参数来输出具体产品

public class IgoChessmanFactory {
   // 饿汉式单例 
   private static IgoChessmanFactory instance = new IgoChessmanFactory();
   private static Hashtable ht; //使用Hashtable来存储享元对象,充当享元池

   // 私有化构造函数
   // 创建黑棋与白棋
   // 放进hashtable
   private IgoChessmanFactory() {
      ht = new Hashtable();
      IgoChessman black,white;
      black = new BlackIgoChessman();
      ht.put("b",black);
      white = new WhiteIgoChessman();
      ht.put("w",white);
   }

   //返回享元工厂类的唯一实例
   public static IgoChessmanFactory getInstance() {
      return instance;
   }

    //通过key来获取存储在Hashtable中的享元对象
   public static IgoChessman getIgoChessman(String color) {
      return (IgoChessman)ht.get(color); 
   }
}

客户端

public class Client {
   public static void main(String args[]) {
      IgoChessman black1,black2,white1,white2;
      IgoChessmanFactory factory;
        
        //获取享元工厂对象
      factory = IgoChessmanFactory.getInstance();

        //通过享元工厂获取三颗黑子
      black1 = factory.getIgoChessman("b");
      black2 = factory.getIgoChessman("b");
      System.out.println("判断两颗黑子是否相同:" + (black1==black2));

        //通过享元工厂获取两颗白子
      white1 = factory.getIgoChessman("w");
      white2 = factory.getIgoChessman("w");
      System.out.println("判断两颗白子是否相同:" + (white1==white2));

        //显示棋子
      black1.display();
      black2.display();
      white1.display();
      white2.display();
   }
}

类图

image.png

输出

判断两颗黑子是否相同:true
判断两颗白子是否相同:true
棋子颜色:黑色
棋子颜色:黑色
棋子颜色:白色
棋子颜色:白色

输出分析: 享元工厂创建的同一类对象为同一个。

进一步需求

上面我们完成了不同颜色棋子的生成,如何让相同的黑子或者白子能够多次重复显示但位于一个棋盘的不同地方?

解决方案:将棋子的位置定义为棋子的一个外部状态,在需要时再进行设置。

让抽象享元类的方法具有显示位置的功能,但这个算是外部状态,因此我们可以将外部状态通过方法参数传入

享元模式有两种方式管理外部状态:

  1. 通过函数参数传入:
    在调用享元对象的操作时,将外部状态作为参数传入

    这意味着每次调用时都需要传入外部状态

  2. 将外部状态作为享元类的属性:
    外部状态作为享元类的属性,在创建享元对象时传入,之后该对象就包含这个外部状态。

两种方式各有优缺点:

通过参数传入优点:

  • 不需要存储外部状态,对象本身无状态,更轻量化
  • 外部状态可以动态变化,每次调用可以传入不同的值

通过属性的优点:

  • 避免了每次调用都传入参数,更简洁
  • 外部状态变化时,只需更新属性值即可。

综上所述,在选择时需要考虑具体情况:

  • 如果外部状态变化频繁,请选用函数参数传入。
  • 如果外部状态变化不太频繁,选用属性方式更优雅。在需要更新外部状态时,直接设置属性值即可

改造代码:

添加一个外部坐标类

//坐标类:外部状态类

public class Coordinates {
  private int x;
  private int y;
  public Coordinates(int x,int y) {
     this.x = x;
     this.y = y;
  }
  public int getX() {
     return this.x;
  }
  
  public void setX(int x) {
     this.x = x;
  }
  public int getY() {
     return this.y;
  }
  
  public void setY(int y) {
     this.y = y;
  }
} 

改造抽象享元类

// 围棋棋子类:抽象享元类

// 将外部的状态类 坐标类 作为属性传入方法display

public abstract class IgoChessman {
   public abstract String getColor();
   public void display(Coordinates coord){
      System.out.println("棋子颜色:" + this.getColor() + ",棋子位置:" + coord.getX() + "," + coord.getY() ); 
   }
}

客户端调用修改

public class Client {
   public static void main(String args[]) {
      IgoChessman black1,black2,black3,white1,white2;
      IgoChessmanFactory factory;
        
        //获取享元工厂对象
      factory = IgoChessmanFactory.getInstance();

        //通过享元工厂获取三颗黑子
      black1 = factory.getIgoChessman("b");
      black2 = factory.getIgoChessman("b");
      black3 = factory.getIgoChessman("b");
      System.out.println("判断两颗黑子是否相同:" + (black1==black2));

        //通过享元工厂获取两颗白子
      white1 = factory.getIgoChessman("w");
      white2 = factory.getIgoChessman("w");
      System.out.println("判断两颗白子是否相同:" + (white1==white2));

        //显示棋子,同时设置棋子的坐标位置
      black1.display(new Coordinates(1,2));
      black2.display(new Coordinates(3,4));
      black3.display(new Coordinates(1,3));
      white1.display(new Coordinates(2,5));
      white2.display(new Coordinates(2,4));
   }
}

输出:

判断两颗黑子是否相同:true
判断两颗白子是否相同:true
棋子颜色:黑色,棋子位置:1,2
棋子颜色:黑色,棋子位置:3,4
棋子颜色:黑色,棋子位置:1,3
棋子颜色:白色,棋子位置:2,5
棋子颜色:白色,棋子位置:2,4

分析: 我们是使用参数的形式注入外部状态,所以每次调用都必须传入状态。

单纯享元类

上面我们所写的享元类仅仅是单纯的,它的所有享元类都可以共享

复合享元模式

复合享元模式(Flyweight Composite Pattern)是享元模式的扩展,它可以解决享元对象间的组合问题。

在享元模式中,享元对象间是独立的,但实际应用中,享元对象往往需要组合在一起使用。复合享元模式允许:

  • 享元对象之间存在树状的包含关系。
  • 树枝上的享元对象共享他们的享元部分。
  • 树叶的享元对象拥有自己的内部状态。

主要包含以下部分:

  • 组合对象:
    组合对象有两种,叶对象和分支对象。

  • 叶对象(Leaf):
    叶对象只包含自己的内部状态,没有子对象。

  • 分支对象(Composite):
    分支对象既包含享元部分,又包含自己的子享元对象。

  • 享元类:
    所有享元对象共享的部分。

image.png

具体就是继承抽象享元类,同时维护一个抽象享元类列表,将其作为自己的子享元类

Java String

Java 的 String 类里也有字符串常量池, 它工厂的hashmapkeyvalue分别是值和引用。

因此

String s1 = new String("abc");
String s2 = new String("abc");

System.out.println(s1 == s2); // false

String s3 = "abc";
String s4 = "abc";

System.out.println(s3 == s4); // true
  • 当使用new String()创建字符串对象时,即使内容相同,也会创建两个不同的对象实例。
  • 这两个对象实例虽然包含相同的字符序列"abc",但它们是两个不同的String对象,存储在不同的内存地址中。
  • 因此,s1 和 s2 不相等,指向的是两个不同的String实例。
  • 只有通过常量方式直接赋值字符串时(String s = "abc") ,它才会检查常量池,复用同一个对象引用。

模式优点

  • 可以减少内存中对象的数量,使得相同或者相似的对象在内存中只保存一份,从而可以节约系统资源,提高系统性能
  • 外部状态相对独立,而且不会影响其内部状态,从而使得享元对象可以在不同的环境中被共享

模式缺点

  • 使得系统变得复杂,需要分离出内部状态和外部状态,这使得程序的逻辑复杂化
  • 为了使对象可以共享,享元模式需要将享元对象的部分状态外部化,而读取外部状态将使得运行时间变长

适用环境

模式适用环境

  • 一个系统有大量相同或者相似的对象,造成内存的大量耗费
  • 对象的大部分状态都可以外部化,可以将这些外部状态传入对象中
  • 在使用享元模式时需要维护一个存储享元对象的享元池,而这需要耗费一定的系统资源。因此,在需要多次重复使用享元对象时才值得使用享元模式