本文摘取自TerryLee(李会军)老师的设计模式系列文章,版权归TerryLee,仅供个人学习参考。转载请标明原作者TerryLee。部分示例代码来自DoFactory。
概述
面向对象的思想很好地解决了抽象性的问题,一般也不会出现性能上的问题。但是在某些情况下,对象的数量可能会太多,从而导致了运行时的代价。那么我们如何去避免大量细粒度的对象,同时又不影响客户程序使用面向对象的方式进行操作?
本文试图通过一个简单的字符处理的例子,运用重构的手段,一步步带你走进Flyweight模式,在这个过程中我们一同思考、探索、权衡,通过比较而得出好的实现方式,而不是给你最终的一个完美解决方案。
意图
运用共享技术有效地支持大量细粒度的对象。
UML
图1Flyweight模式UML图
参与者
这个模式涉及的类或对象:
-
Flyweight
-
声明一个接口,细粒度对象(flyweight)通过其获取及操作外部状态。
-
ConcreteFlyweight
-
实现Flyweight接口,为可选的外部状态增加存储空间。一个ConcreteFlyWeight对象必须是可共享的。其存储的任何状态必须是外部的,即这个状态必须独立于ConcreteFlyweight对象的上下文。
-
UnsharedConcreteFlyweight
-
不是所有的Flyweight的子类都需要被共享。Flyweight接口支持共享,但不强制。在flyweight对象结构中的某些层次常有UnsharedConcreteFlyweight对象将ConcreteFlyweight对象作为子对象(如Row与Column类之间的关系)。
-
FlyweightFactory
-
创建并管理flyweight对象。
-
确保flyweight被适当的共享。当一个客户端请求一个flyweight,FlyweightFactory对象将返回一个已存在的对象,如果没有flyweight已创建,则创建新的并返回。
-
Client
-
维护到flyweight对象的引用
-
计算或存储flyweight的外部状态
适用性
享元模式的目的是高效的共享大量细粒度的对象。被共享的享元对象是不可变的,即,由于它们代表的特征被其它对象共享所以不能被改变。例如,文字处理程序中的字符行或公用交换电话网络程序中的数字接收器。你常会在工具类程序(文字处理程序,图形程序,网络应用)找到享元。它们很少用于数据驱动的商业类型程序中。
当系统满足以下所有的条件时,可以考虑使用享元模式:
-
一个应用程序使用了大量的对象,由于使用大量的对象,造成很大的内存开销。
-
这些对象的状态中的大部分都可以变为外部状态。
-
如果删除对象的外部状态,那么可以用相对较少的共享对象取代很多组对象。
-
软件系统不依赖于这些对象的身份,换言之,这些对象是不需要被分辨的。
最后,使用享元模式需要维护一个记录了系统已有的所有享元的表,而这需要耗费资源。因此,应当在有足够多的享元实例可供共享时才值得使用享元模式。
DoFactory GoF代码
这个例子展示了使用享元模式将相对少量的一些对象提供给不同的客户端共享使用多次。
// Flyweight pattern // Structural example using System; using System.Collections; namespace DoFactory.GangOfFour.Flyweight.Structural { // MainApp test application class MainApp { static void Main() { // Arbitrary extrinsic state int extrinsicstate = 22; FlyweightFactory factory = new FlyweightFactory(); // Work with different flyweight instances Flyweight fx = factory.GetFlyweight("X"); fx.Operation(--extrinsicstate); Flyweight fy = factory.GetFlyweight("Y"); fy.Operation(--extrinsicstate); Flyweight fz = factory.GetFlyweight("Z"); fz.Operation(--extrinsicstate); UnsharedConcreteFlyweight fu = new UnsharedConcreteFlyweight(); fu.Operation(--extrinsicstate); // Wait for user Console.ReadKey(); } } // "FlyweightFactory" class FlyweightFactory { private Hashtable flyweights = new Hashtable(); // Constructor public FlyweightFactory() { flyweights.Add("X", new ConcreteFlyweight()); flyweights.Add("Y", new ConcreteFlyweight()); flyweights.Add("Z", new ConcreteFlyweight()); } public Flyweight GetFlyweight(string key) { return ((Flyweight)flyweights[key]); } } // "Flyweight" abstract class Flyweight { public abstract void Operation(int extrinsicstate); } // "ConcreteFlyweight" class ConcreteFlyweight : Flyweight { public override void Operation(int extrinsicstate) { Console.WriteLine("ConcreteFlyweight: " + extrinsicstate); } } // "UnsharedConcreteFlyweight" class UnsharedConcreteFlyweight : Flyweight { public override void Operation(int extrinsicstate) { Console.WriteLine("UnsharedConcreteFlyweight: " + extrinsicstate); } } }
这段代码展示了一个潜在的有多个章节的文档共享使用数量较少的章节对象多次的例子。
这个例子中涉及到的类与享元模式中标准的类对应关系如下:
-
Flyweight – Character
-
ConcreteFlyweight – CharacterA, CharacterB, ..., CharacterZ
-
UnsharedConcreteFlyweight – not used
-
FlyweightFactory – CharacterFactory
-
Client - FlyweightApp
// Flyweight pattern // Real World example using System; using System.Collections.Generic; namespace DoFactory.GangOfFour.Flyweight.RealWorld { // MainApp test application class MainApp { static void Main() { // Build a document with text string document = "AAZZBBZB"; char[] chars = document.ToCharArray(); CharacterFactory factory = new CharacterFactory(); // extrinsic state int pointSize = 10; // For each character use a flyweight object foreach (char c in chars) { pointSize++; Character character = factory.GetCharacter(c); character.Display(pointSize); } // Wait for user Console.ReadKey(); } } // "FlyweightFactory" class CharacterFactory { private Dictionary<char, Character> _characters = new Dictionary<char, Character>(); public Character GetCharacter(char key) { // Uses "lazy initialization" Character character = null; if (_characters.ContainsKey(key)) { character = _characters[key]; } else { switch (key) { case 'A': character = new CharacterA(); break; case 'B': character = new CharacterB(); break; //... case 'Z': character = new CharacterZ(); break; } _characters.Add(key, character); } return character; } } // "Flyweight" abstract class Character { protected char symbol; protected int width; protected int height; protected int ascent; protected int descent; protected int pointSize; public abstract void Display(int pointSize); } // "ConcreteFlyweight" class CharacterA : Character { // Constructor public CharacterA() { this.symbol = 'A'; this.height = 100; this.width = 120; this.ascent = 70; this.descent = 0; } public override void Display(int pointSize) { this.pointSize = pointSize; Console.WriteLine(this.symbol + " (pointsize " + this.pointSize + ")"); } } // "ConcreteFlyweight" class CharacterB : Character { // Constructor public CharacterB() { this.symbol = 'B'; this.height = 100; this.width = 140; this.ascent = 72; this.descent = 0; } public override void Display(int pointSize) { this.pointSize = pointSize; Console.WriteLine(this.symbol + " (pointsize " + this.pointSize + ")"); } } // ... C, D, E, etc. // "ConcreteFlyweight" class CharacterZ : Character { // Constructor public CharacterZ() { this.symbol = 'Z'; this.height = 100; this.width = 100; this.ascent = 68; this.descent = 0; } public override void Display(int pointSize) { this.pointSize = pointSize; Console.WriteLine(this.symbol + " (pointsize " + this.pointSize + ")"); } } }
享元模式很难用于商业应用程序的开发。然而,当许多拥有相似状态的对象被创建时作为一种内存管理技术很有价值。.NET内部使用享元处理编译时声明的且有相同顺序字符的字符串。无状态的flyweight引用相同的存储不可变字符串的内存位置。
在为.NET优化的代码示例中,如展示的那样Flyweight常与Factory模式一起使用。为.NET优化代码中使用一个泛型字典集合来存储并快速获取内存中的轻量字符对象。泛型集合增强了代码的类型安全。
// Flyweight pattern // .NETOptimized example using System; using System.Collections.Generic; namespace DoFactory.GangOfFour.Flyweight.NETOptimized { class MainApp { static void Main() { // Build a document with text string document = "AAZZBBZB"; char[] chars = document.ToCharArray(); var factory = new CharacterFactory(); // extrinsic state int pointSize = 10; // For each character use a flyweight object foreach (char c in chars) { var character = factory[c]; character.Display(++pointSize); } // Wait for user Console.ReadKey(); } } // "FlyweightFactory" class CharacterFactory { private Dictionary<char, Character> _characters = new Dictionary<char, Character>(); // Character indexer public Character this[char key] { get { // Uses "lazy initialization" -- i.e. only create when needed. Character character = null; if (_characters.ContainsKey(key)) { character = _characters[key]; } else { // Instead of a case statement with 26 cases (characters). // First, get qualified class name, then dynamically create instance string name = this.GetType().Namespace + "." + "Character" + key.ToString(); character = (Character)Activator.CreateInstance (Type.GetType(name)); } return character; } } } // "Flyweight" class Character { protected char symbol; protected int width; protected int height; protected int ascent; protected int descent; public void Display(int pointSize) { Console.WriteLine(this.symbol + " (pointsize " + pointSize + ")"); } } // "ConcreteFlyweight" class CharacterA : Character { // Constructor public CharacterA() { this.symbol = 'A'; this.height = 100; this.width = 120; this.ascent = 70; this.descent = 0; } } // "ConcreteFlyweight" class CharacterB : Character { // Constructor public CharacterB() { this.symbol = 'B'; this.height = 100; this.width = 140; this.ascent = 72; this.descent = 0; } } // ... C, D, E, etc. // "ConcreteFlyweight" class CharacterZ : Character { // Constructor public CharacterZ() { this.symbol = 'Z'; this.height = 100; this.width = 100; this.ascent = 68; this.descent = 0; } } }
Flyweight模式解说
Flyweight在拳击比赛中指最轻量级,即"蝇量级",这里翻译为"享元",可以理解为共享元对象(细粒度对象)的意思。提到Flyweight模式一般都会用编辑器例子来说明,这里也不例外,但我会尝试着通过重构来看待Flyweight模式。考虑这样一个字处理软件,它需要处理的对象可能有单个的字符,由字符组成的段落以及整篇文档,根据面向对象的设计思想和Composite模式,不管是字符还是段落,文档都应该作为单个的对象去看待,这里只考虑单个的字符,不考虑段落及文档等对象,于是可以很容易的得到下面的结构图:
图2.文档对象的基本结构图
示意性实现代码:
// "Charactor" public abstract class Charactor { //Fields protected char _symbol; protected int _width; protected int _height; protected int _ascent; protected int _descent; protected int _pointSize; //Method public abstract void Display(); } // "CharactorA" public class CharactorA : Charactor { // Constructor public CharactorA() { this._symbol = 'A'; this._height = 100; this._width = 120; this._ascent = 70; this._descent = 0; this._pointSize = 12; } //Method public override void Display() { Console.WriteLine(this._symbol); } } // "CharactorB" public class CharactorB : Charactor { // Constructor public CharactorB() { this._symbol = 'B'; this._height = 100; this._width = 140; this._ascent = 72; this._descent = 0; this._pointSize = 10; } //Method public override void Display() { Console.WriteLine(this._symbol); } } // "CharactorC" public class CharactorC : Charactor { // Constructor public CharactorC() { this._symbol = 'C'; this._height = 100; this._width = 160; this._ascent = 74; this._descent = 0; this._pointSize = 14; } //Method public override void Display() { Console.WriteLine(this._symbol); } }
好了,现在看到的这段代码可以说是很好地符合了面向对象的思想,但是同时我们也为此付出了沉重的代价,那就是性能上的开销,可以想象,在一篇文档中,字符的数量远不止几百个这么简单,可能上千上万,内存中就同时存在了上千上万个Character对象,这样的内存开销是可想而知的。进一步分析可以发现,虽然我们需要的Character实例非常多,这些实例之间只不过是状态不同而已,也就是说这些实例的状态数量是很少的。所以我们并不需要这么多的独立的Character实例,而只需要为每一种Character状态创建一个实例,让整个字符处理软件共享这些实例就可以了。看这样一幅示意图:
图3.将字符的状态独立出来从而实现字符对象的共享
现在我们看到的A,B,C三个字符是共享的,也就是说如果文档中任何地方需要这三个字符,只需要使用共享的这三个实例就可以了。然而我们发现单纯的这样共享也是有问题的。虽然文档中的用到了很多的A字符,虽然字符的symbol等是相同的,它可以共享;但是它们的pointSize却是不相同的,即字符在文档中的大小是不相同的,这个状态不可以共享。为解决这个问题,首先我们将不可共享的状态从类里面剔除出去,即去掉pointSize这个状态(只是暂时的J),类结构图如下所示:
图4.为添加享元模式对文档模型进行改进
示意性实现代码:
// "Charactor" public abstract class Charactor { //Fields protected char _symbol; protected int _width; protected int _height; protected int _ascent; protected int _descent; //Method public abstract void Display(); } // "CharactorA" public class CharactorA : Charactor { // Constructor public CharactorA() { this._symbol = 'A'; this._height = 100; this._width = 120; this._ascent = 70; this._descent = 0; } //Method public override void Display() { Console.WriteLine(this._symbol); } } // "CharactorB" public class CharactorB : Charactor { // Constructor public CharactorB() { this._symbol = 'B'; this._height = 100; this._width = 140; this._ascent = 72; this._descent = 0; } //Method public override void Display() { Console.WriteLine(this._symbol); } } // "CharactorC" public class CharactorC : Charactor { // Constructor public CharactorC() { this._symbol = 'C'; this._height = 100; this._width = 160; this._ascent = 74; this._descent = 0; } //Method public override void Display() { Console.WriteLine(this._symbol); } }
好,现在类里面剩下的状态都可以共享了,下面我们要做的工作就是控制Character类的创建过程,即如果已经存在了"A"字符这样的实例,就不需要再创建,直接返回实例;如果没有,则创建一个新的实例。如果把这项工作交给Character类,即Character类在负责它自身职责的同时也要负责管理Character实例的管理工作,这在一定程度上有可能违背类的单一职责原则,因此,需要一个单独的类来做这项工作,引入CharacterFactory类,结构图如下:
图5.引入CharacterFactory后的文档结构图
示意性实现代码:
// "CharactorFactory" public class CharactorFactory { // Fields private Hashtable charactors = new Hashtable(); // Constructor public CharactorFactory() { charactors.Add("A", new CharactorA()); charactors.Add("B", new CharactorB()); charactors.Add("C", new CharactorC()); } // Method public Charactor GetCharactor(string key) { Charactor charactor = charactors[key] as Charactor; if (charactor == null) { switch (key) { case "A": charactor = new CharactorA(); break; case "B": charactor = new CharactorB(); break; case "C": charactor = new CharactorC(); break; // } charactors.Add(key, charactor); } return charactor; } }
到这里已经完全解决了可以共享的状态(这里很丑陋的一个地方是出现了switch语句,但这可以通过别的办法消除,为了简单期间我们先保持这种写法)。下面的工作就是处理刚才被我们剔除出去的那些不可共享的状态,因为虽然将那些状态移除了,但是Character对象仍然需要这些状态,被我们剥离后这些对象根本就无法工作,所以需要将这些状态外部化。首先会想到一种比较简单的解决方案就是对于不能共享的那些状态,不需要去在Character类中设置,而直接在客户程序代码中进行设置,类结构图如下:
图6.为解决无法共享的状态的修改后的结构图
示意性实现代码:
public class Program { public static void Main() { Charactor ca = new CharactorA(); Charactor cb = new CharactorB(); Charactor cc = new CharactorC(); //显示字符 //设置字符的大小ChangeSize(); } public void ChangeSize() { //在这里设置字符的大小 } }
按照这样的实现思路,可以发现如果有多个客户端程序使用的话,会出现大量的重复性的逻辑,用重构的术语来说是出现了代码的坏味道,不利于代码的复用和维护;另外把这些状态和行为移到客户程序里面破坏了封装性的原则。再次转变我们的实现思路,可以确定的是这些状态仍然属于Character对象,所以它还是应该出现在Character类中,对于不同的状态可以采取在客户程序中通过参数化的方式传入。类结构图如下:
图7.改进型解决无法共享的状态的结构图
示意性实现代码:
// "Charactor" public abstract class Charactor { //Fields protected char _symbol; protected int _width; protected int _height; protected int _ascent; protected int _descent; protected int _pointSize; //Method public abstract void SetPointSize(int size); public abstract void Display(); } // "CharactorA" public class CharactorA : Charactor { // Constructor public CharactorA() { this._symbol = 'A'; this._height = 100; this._width = 120; this._ascent = 70; this._descent = 0; } //Method public override void SetPointSize(int size) { this._pointSize = size; } public override void Display() { Console.WriteLine(this._symbol + "pointsize:" + this._pointSize); } } // "CharactorB" public class CharactorB : Charactor { // Constructor public CharactorB() { this._symbol = 'B'; this._height = 100; this._width = 140; this._ascent = 72; this._descent = 0; } //Method public override void SetPointSize(int size) { this._pointSize = size; } public override void Display() { Console.WriteLine(this._symbol + "pointsize:" + this._pointSize); } } // "CharactorC" public class CharactorC : Charactor { // Constructor public CharactorC() { this._symbol = 'C'; this._height = 100; this._width = 160; this._ascent = 74; this._descent = 0; } //Method public override void SetPointSize(int size) { this._pointSize = size; } public override void Display() { Console.WriteLine(this._symbol + "pointsize:" + this._pointSize); } } // "CharactorFactory" public class CharactorFactory { // Fields private Hashtable charactors = new Hashtable(); // Constructor public CharactorFactory() { charactors.Add("A", new CharactorA()); charactors.Add("B", new CharactorB()); charactors.Add("C", new CharactorC()); } // Method public Charactor GetCharactor(string key) { Charactor charactor = charactors[key] as Charactor; if (charactor == null) { switch (key) { case "A": charactor = new CharactorA(); break; case "B": charactor = new CharactorB(); break; case "C": charactor = new CharactorC(); break; // } charactors.Add(key, charactor); } return charactor; } } public class Program { public static void Main() { CharactorFactory factory = new CharactorFactory(); // Charactor "A" CharactorA ca = (CharactorA)factory.GetCharactor("A"); ca.SetPointSize(12); ca.Display(); // Charactor "B" CharactorB cb = (CharactorB)factory.GetCharactor("B"); ca.SetPointSize(10); ca.Display(); // Charactor "C" CharactorC cc = (CharactorC)factory.GetCharactor("C"); ca.SetPointSize(14); ca.Display(); } }
可以看到这样的实现明显优于第一种实现思路。好了,到这里我们就通过Flyweight模式实现了优化资源的这样一个目的。在这个过程中,还有如下几点需要说明:
-
引入CharacterFactory是个关键,在这里创建对象已经不是new一个Character对象那么简单,而必须用工厂方法封装起来。
-
在这个例子中把Character对象作为Flyweight对象是否准确值的考虑,这里只是为了说明Flyweight模式,至于在实际应用中,哪些对象需要作为Flyweight对象是要经过很好的计算得知,而绝不是凭空臆想。
-
区分内外部状态很重要,这是享元对象能做到享元的关键所在。
到这里,其实我们的讨论还没有结束。有人可能会提出如下问题,享元对象(Character)在这个系统中相对于每一个内部状态而言它是唯一的,这跟单件模式有什么区别呢?这个问题已经很好回答了,那就是单件类是不能直接被实例化的,而享元类是可以被实例化的。事实上在这里面真正被设计为单件的应该是享元工厂(不是享元)类,因为如果创建很多个享元工厂的实例,那我们所做的一切努力都是白费的,并没有减少对象的个数。修改后的类结构图如下:
图8.最终完成的使用享元模式的文档结构图
示意性实现代码:
// "CharactorFactory" public class CharactorFactory { // Fields private Hashtable charactors = new Hashtable(); private CharactorFactory instance; // Constructor private CharactorFactory() { charactors.Add("A", new CharactorA()); charactors.Add("B", new CharactorB()); charactors.Add("C", new CharactorC()); } // Property public CharactorFactory Instance { get { if (instance != null) { instance = new CharactorFactory(); } return instance; } } // Method public Charactor GetCharactor(string key) { Charactor charactor = charactors[key] as Charactor; if (charactor == null) { switch (key) { case "A": charactor = new CharactorA(); break; case "B": charactor = new CharactorB(); break; case "C": charactor = new CharactorC(); break; // } charactors.Add(key, charactor); } return charactor; } }
.NET框架中的Flyweight
Flyweight更多时候是一种底层的设计模式,在我们的实际应用程序中使用的并不是很多。在.NET内部的对于不可变字符串管理技术就是运用了Flyweight模式来最小化内存使用。可以想象,如果每次执行string s1 = "abcd"操作,都创建一个新的字符串对象的话,内存的开销会很大。所以.NET中如果第一次创建了这样的一个字符串对象s1,下次再创建相同的字符串s2时只是把它的引用指向"abcd",这样就实现了"abcd"在内存中的共享。可以通过下面一个简单的程序来演示s1和s2的引用是否一致:
public class Program { public static void Main(string[] args) { string s1 = "abcd"; string s2 = "abcd"; Console.WriteLine(Object.ReferenceEquals(s1, s2)); Console.ReadLine(); } }
可以看到,输出的结果为True。但是大家要注意的是如果再有一个字符串s3,它的初始值为"ab",再对它进行操作s3 = s3 + "cd",这时虽然s1和s3的值相同,但是它们的引用是不同的。关于String的详细情况大家可以参考SDK,这里不再讨论了。
效果及实现要点
享元模式可以避免大量非常相似类的开销。在程序设计中,有时需要生成大量细粒度的类实例来表示数据。如果能发现这些实例除了几个参数外基本上都是相同的,有时就能够受大幅度地减少需要实例化的类的数量。如果能把那些参数移到类实例的外面,在方法调用时将它们传进来,就可以通过共享大幅度地减少单个实例的数目。
-
面向对象很好的解决了抽象性的问题,但是作为一个运行在机器中的程序实体,我们需要考虑对象的代价问题。Flyweight设计模式主要解决面向对象的代价问题,一般不触及面向对象的抽象性问题。
-
Flyweight采用对象共享的做法来降低系统中对象的个数,从而降低细粒度对象给系统带来的内存压力。在具体实现方面,要注意对象状态的处理。
-
享元模式的优点在于它大幅度地降低内存中对象的数量。但是,它做到这一点所付出的代价也是很高的:享元模式使得系统更加复杂。为了使对象可以共享,需要将一些状态外部化,这使得程序的逻辑复杂化。另外它将享元对象的状态外部化,而读取外部状态使得运行时间稍微变长。
总结
Flyweight模式解决的是由于大量的细粒度对象所造成的内存开销的问题,它在实际的开发中并不常用,但是作为底层的提升性能的一种手段却很有效。