感谢大家的支持,本系列第2篇在此推出。朋友们还可关注我的新浪微博“同济王阳”,上面会定期分享一些本人所关注的技术热点资讯、博文、视频、国外教程等内容,相信我们品味还是比较一致的哦!
1 概述
1.1 设计模式概述
设计模式属于面向对象软件设计方法论的范畴。
我们知道,面向对象的概念是在过程化程序设计出现软件危机的时代背景中被提出的。在那时,这属于一个崭新的事物。在80年代末、90年代初,不仅我们现在认为的面向对象设计方法的集大成者——.NET平台的BCL类库和Java平台的JDK类库连影子都找不到,甚至随便找一个面向对象的代码都很困难。那时的软件开发者不了解面向对象的设计方法,即使纯粹为沾上面向对象而写一些代码,其设计之拙劣也很难体现出面向对象的优势,和今天的面向对象新手情况类似。我们可以体会诞生于那时的MFC等今天仍在广泛使用的类库与后来的BCL类库在设计思想上的差异。毫无疑问,后者的设计是更加精良的。造成这种进化的原因,就是关于设计模式的广泛讨论和普遍接受。
四人组(GoF)并不是首先提出模式和进行模式研究的人,然而他们在1994年推出的著作Design Patterns: Elements of Reusable Object-Oriented Software(中文名:《设计模式:可复用面向对象软件的基础》)被奉为设计模式领域的经典。书中归纳总结了23种设计模式,告诉人们如何设计出灵活的面向对象软件架构,发挥面向对象的优势,实现代码可复用。“可复用”的终极形式是API。今天我们能够用上Boost、BCL、JDK甚至Cocoa,设计模式功不可没。
1.2 参考资料
本教程主要综合以下资料中的观点和我公司开发实践写成:
- 《设计模式之禅》,秦小波,机械工业出版社
- 《.NET与设计模式》,甄镭,电子工业出版社
- Design Patterns: Elements of Reusable Object-Oriented Software, GoF(四人组),中文名:《设计模式:可复用面向对象软件的基础》
- Design Patterns Explained,中文名:《设计模式解析》
- C# 3.0 Design Patterns
这些资料可以用作延伸阅读材料。
2 面向对象设计的六大原则
在面向对象程序设计领域有一个SOLID原则,是由罗伯特·C·马丁在21世纪早期引入的指代面向对象编程和面向对象设计的五个基本原则(单一功能、开闭原则、里氏替换、接口隔离以及依赖倒置)的记忆术首字母缩略字,合起来正好是“坚固的”的意思。当这些原则被一起应用时,它们使得一个程序员开发一个容易进行软件维护和扩展的系统变得更加可能。另外还有一个“迪米特法则”,与SOLID合称“六大原则”。
2.1 单一职责原则(Single Responsibility Principle, SRP)
There should never be more than one reason for a class to change.
不应有一个以上的原因引起类的变化。
要求一个类只有一个职责。基于这个原则,现实开发中的许多类需要拆分,这与“小类小函数”的说法是一致的。
小函数的好处有:
- 合并重复代码,便于维护
- 增加函数层级,便于调试
小类的好处可以比照小函数。试想一种极端情况:新手初学C#往往不知道怎样使用类,拉了一堆控件,纷纷双击添加事件处理器,整个程序写了几千行,都在Form1.cs一个文件里,到最后根本找不到每个函数的位置,这和面向过程的编程没什么两样。
在Java和ActionScript中要求每个类放在单独文件中。在C#和VB.NET中虽然没有这种要求,但是不妨作为一种好的代码风格执行。项目较大时用文件夹组织代码文件。
C++中的多重继承与本原则是矛盾的。因此,要避免使用。
Scala中的trait怎么办呢?trait一词意为“特质”,不妨想象两个类的职责都是“瓷器活”,但其中一个有“金刚钻”特质。显然,不管是金刚钻还是什么钻,只要用来干瓷器活,改变不了职责的本质,即恰当使用trait不违反单一职责原则。但是,如果变成“卖金刚钻”,就另当别论了,此时trait的使用就不合适了。
2.2 开放-封闭原则(Open Closed Principle, OCP)
Software entities like classes, modules and functions should be opened for extension but closed for modifications.
软件实体应该对扩展开放,对修改关闭。
开闭原则是面向对象编程的基础性原则。之前的5个原则都是开闭原则的具体形态。开闭原则可以提高代码的复用性和可维护性。
有的人会提出疑问,说只要接口不变,一个模块的内部实现怎样修改又有什么关系呢?
问题的关键在于面向对象编程的局限性。以函数为例,一个函数往往并不是接收参数、返回结果那么简单,即使在面向对象编程中,我们也往往会利用函数的“副作用”——事实上,所有的void函数不都是这样吗?尽管.NET等现代开发规范建议尽量把void函数改为返回新值的函数,并且建议不使用输出型参数,但是在面向对象范式中这并不总是可行。在这种情况下,仅仅接口不变是不能保证系统正常工作的。
在更多情况下,我们拿到了一个第三方库,需要用到我们的系统中。即使这个库是开源的,我们也不应去修改其源码。应当在库的基础上通过继承、设计模式等手段进行扩展。这样,对于将来库的升级更新,我们的系统才能轻松应对。
2.3 里氏替换原则(Liskov Substitution Principle, LSP)
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
所有引用基类的地方必须能透明地使用其子类的对象。
初学者往往搞不清基类和派生类的关系问题。他们会问:基类和派生类到底是谁包含谁?
这个问题的前提是错误的。基类和派生类之间不是包含与被包含的关系,二者是共性与个性,一般与特殊的关系。
如果一定要回答前述问题,我只能从表面形式上给出一个描述。从实例数量上看,基类比子类对象多,因为基类对象数是所有子类对象数的总和。从复杂性上看,子类比基类有更多的属性与功能,因为子类首先是基类,必须包含基类已有的全部特性。
在C#等现代面向对象语言中,基类变量可以指向子类实例,反之则不行。当且仅当基类变量指向子类实例时,可以通过引用转换将基类变量赋值给子类变量。“子类”又称“运行时类型”。在面向对象编程中通过使用编译时基类类型指向运行时子类类型来实现多态。
在.NET和Java等现代面向对象平台中,使用“接口”类型来表示一个抽象基类的接口形式。从一个接口类型继承,称为“实现接口”。
复杂类型(这里指利用泛型由已知类型组合出来的新类型,如List
派生类重写基类方法时,也遵从这句话,输入类型范围可以放大(使用基类,逆变),输出类型范围可以缩小(使用子类,协变)。
2.4 接口隔离原则(Interface Segregation Principle, ISP)
The dependency of one class to another one should depend on the smallest possible interface.
类间的依赖关系应该建立在最小的接口上。
另一种表述是:客户端不应依赖它不需要的接口。
这个原则针对的是这样一种情况:定义了一个很大的接口,包含10个以上方法。问题是,客户端调用接口完成一项功能,需要同时调用这么多方法吗?
事实上,这样的大接口往往可以拆分成若干小接口,这与单一职责原则呼应。类在实现接口时,可以从这些接口中选择需要的部分。按照.NET开发规范建议,一个接口的成员数量不宜超过3个。
2.5 依赖倒置原则(Dependence Inversion Principle, DIP)
High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.
高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
另外一种通俗的表述为:针对接口编程,不要针对实现编程。
这个原则是6大原则里最难实现的一个。它要求:
- 每个类都要有接口或抽象类
- 变量的表面类型尽量是接口或抽象类
- 任何类都不应该从具体类派生
- 尽量不要重写基类的方法
按照这个原则编写的程序毫无疑问具有很大灵活性,但是在小团队中会对工作量和程序员本身的水平提出巨大挑战。在大型团队中,有专门的架构师来制定系统接口作为并行开发的各代码组之间的契约,这样高层组无需等待低层组完成实现,所有代码组同时针对契约进行开发。
然而,此原则所蕴含的思想是值得学习的。即使在小团队开发中,特定的场景也可以使用。
2.6 最少知识原则(Least Knowledge Principle, LKP)
也称作“迪米特法则”(Law of Demeter)。
Objects should have least possible knowledge about each other.
一个对象应该对其他对象有最少的了解。
3 设计模式选讲
3.1 设计模式总览
23种设计模式按照功能特点可以分为创建型模式、结构型模式、行为型模式3类,而按照作用对象又包括类模式和对象模式2种,见下表。
设计模式的中心思想是解耦(Decouple)。解耦就是解除耦合,具体来说是使得软件系统的各个模块实现高内聚、低耦合。这样,在系统维护时,着眼点只需落在各个模块上,处理一个模块时,对其他模块造成的影响尽量小,从而降低维护的难度。
以下我们选讲一些常见的模式。在此,我们不打算给大家看每个模式的代码。我们总是以四人组原著中的模式定义(中英文)作为开头,随后给出模式的类图(类图来自O’Reilly的C# 3.0 Design Patterns一书,可参看),接着是一段笔者对此模式的理解和感悟。这样安排的理由很简单,博文不是教科书或者工具书,无法代替系统的学习和精确的查阅,学习和查阅的功能应该转去教科书和工具书。博文的目的就是提供一种印象,就像同事之间随意聊天间获取资讯。每个人都会获取独一无二的印象,并在之后选择性地进行深入了解。
3.2 单例模式(Singleton)
Ensure a class has only one instance, and provide a global point of access to it.
确保某一个类只有一个实例,自行实例化并向整个系统提供这个实例。
单例模式比较简单,也很经典,就让我们来看一下代码。
public class SingletonClass { public static SingletonClass _singleton = new SingletonClass(); // 限制从外部创建对象 private SingletonClass() { } // 全局访问结点 public static SingletonClass Singleton { get { return _singleton; } } }
单例模式用于系统中独一无二事物的抽象。例如,三维程序中的场景管理器,程序中的一个菜单面板。这些内容如果重复创建,首先从逻辑上说不通。即使你用其他手段解决了逻辑问题,比如你保证只有一个变量,但是每次用的时候为了刷新去重新创建,也是一种浪费资源的表现。
在.NET框架的许多API中,经常把对“单例”的访问进一步简化为静态访问。
3.3 工厂模式(Factory)
工厂模式几乎是最如雷贯耳的设计模式,然而理解它并不是那么容易。工厂模式是简单工厂、工厂方法、抽象工厂3个设计模式的非正式总称,其中后两者属于23种设计模式,而简单工厂不是。
工厂模式的目的是避免直接实例化一个类,从而降低功能逻辑代码对实现的耦合。这体现了“依赖倒置原则”。
工厂模式的应用实例常见于数据库语境中。数据库存在多种提供者,开发人员不希望自己的业务代码依赖具体的数据库,如SQL Server,Oracle,MySQL等,但是代码中创建的对象必然是实实在在的某种确定数据库的对象。工厂模式就用在这里。
.NET中有很多名称叫XxxFactory的类,名称暗示其使用了工厂模式。
3.4 建造者模式(Builder)
Separate the construction of a complex object from its representation so that the same construction process can create different representation.
将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。
每个.NET程序员都应当熟悉这个模式,回想一下StringBuilder这个类,正如名称所暗示的,其就是建造者模式的一个绝佳范例。String类是一个简单而昂贵的类,为了解决昂贵的初始化问题,引入建造者模式,用复杂但高效的内部算法完成创建字符串,再转化为String类。
另一个典型的适用建造者模式的情景是三维网格。Mesh的数据结构很简单,就是一个点集和一个面集。然而,构造这个Mesh的过程可以非常复杂,也有多种构建方法可以选择,各种方法之间还可以依次叠加组合。有了建造者模式,通过另一个MeshBuilder类来表示Mesh的创建过程,创建好后每次调用ToMesh方法就可以得到一个Mesh。
3.5 模板方法模式(Template Method)
Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.
定义一个操作中的算法的框架,而将一些步骤延迟到子类中。模板方法模式使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
模板方法模式的语义类似于“自顶向下”“测试驱动开发(TDD)”,它们的关注点都在上层代码,要求在编写上层代码时,基于接口编程;而底层代码会根据情况有不同的接口实现。作为设计模式,模板方法要求按照继承的方法来定义底层代码。
各平台、各框架中的回调机制就是典型的模板方法模式。例如在Android开发中,我们在子类中重写各种回调函数,实际上就是在Android给出的大的代码模板中加入我们自己的代码。代码模板已经决定了各回调代码的执行顺序、协作关系。
而在.NET中,实际上对模板方法模式有了发展和简化。.NET引入了事件的语法(而不仅仅是概念),这使得不需要为每一种具体方法定义一个子类,只需向模板方法(事件)挂接委托就可以了。从而大大减少了类型膨胀。
3.6 原型模式(Prototype)
Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.
用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新对象。
四人组提出原型模式的原意是使得C++能够动态地创建对象,而不需要知道对象类型的具体细节。近年来有的书上将原型模式解释为通过内存流复制对象从而获得高性能。
我们知道C++中拷贝一个对象是很方便的,而在Java和C#中却几乎是一个不可能的任务,除非每个成员字段的类型都实现了克隆接口,或者每个成员字段都标记为Serializable。显然只有后者通过序列化技术拷贝对象才具有高性能。
原型模式的一个应用是创建构建过程复杂的对象。例如StringBuilder的例子,当我们把几十万个字符串拼接在一起并转成String后,如果想复制一个,就没必要再用一次StringBuilder了吧!直接String.Copy()就行了。再例如,我们有一个三维对象,现在需要创建它的镜像,并希望镜像后可以独立编辑。由于源对象是如何建立的已经无从得知,只能用克隆的办法。
说到原型就不能不提“基于原型的面向对象语言”JavaScript。该语言通过原型链来提高创建对象的效率。
反射(Reflection)是面向对象领域的一个重要技术,它使程序有了运行时操作自身的能力,包括获取每个类型的详细信息、动态创建对象、运行时绑定方法等。在CLR之前,JVM已经提供了反射功能,为CLR的设计提供了很好的借鉴。反射支持根据Type动态创建对象,据此有人提出了“反射工厂”模式。
3.7 中介者模式(Mediator)
Define an object that encapsulates how a set of objects interact. Mediator promotes loose coupling by keeping objects from referring to each other explicitly, and it lets you vary their interaction independently.
用一个中介对象封装一系列的对象交互。中介者模式使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
中介者模式最典型的应用是在界面框架(如Windows Forms和WPF)中。窗体类就是这个中介者。窗体中包含了大量的控件。界面逻辑的本质就是用一个控件去操作另一个控件。如果我们要对每一对可能发生关系的控件都分别编写代码,程序中将多出很多类,并且相互关系复杂到一种境界。而在界面框架实际中,我们的全部代码均写在继承的窗体类这个中介者中。任何控件想要改变其他控件,必须引发一个事件。我们知道,事件本质上就是委托变量,引发事件就是让变量里的函数执行。而控件的事件里保存了在窗体类中定义的事件处理函数的引用,引发事件相当于控件调用了中介者的成员函数。
中介者模式减少了类间的依赖,把原有一对多的关系变成了一对一的关系,降低了耦合。
前面讲过初学者的Form1.cs往往很大的例子,这说明,中介者模式使得中介者类变得臃肿、复杂。
委托是.NET平台上的一大法宝。我们知道C#最初从Java获得灵感,然而CLR上的委托机制让C#如鱼得水,愈发强大,发展出了优雅灵活的lambda语法,到今天对Java 8进行了反哺。委托被解释为“类型安全的函数指针”。然而与C++函数指针相比委托的强大之处还体现在:
- 可动态挂接、断开多个函数。
- 使得函数作为头等值(first-class value)有了可能。
- 可直接异步执行,多线程编程中应用广泛。
- 支持函数中定义函数、实现闭包。
- 属于平台(CLR)功能而不是语言功能或者类库功能。
3.8 命令模式(Command)
Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
将一个请求封装成一个对象,从而让你使用不同的请求把客户端参数化,对请求排队或者记录请求日志,可以提供命令的撤销和恢复功能。
WPF的命令机制实现了命令模式。
public interface ICommand { // Events event EventHandler CanExecuteChanged; // Methods bool CanExecute(object parameter); void Execute(object parameter); }
从中可以看到,命令模式使得调用方和执行方解耦。
某软件API中强制使用WPF的ICommand接口。这使得习惯于直接向按钮添加事件处理器的程序员感到不适应——原本写一个函数的事情现在需要写一个类。由此可以看出,命令模式使得类型数迅速膨胀。
但是我们可以实现一些通用的命令类。例如我实现了这样一个命令类,用于执行一个函数,代码如下。
public class MyCommand : System.Windows.Input.ICommand { public bool CanExecute(object parameter) { return true; } public event EventHandler CanExecuteChanged; public void Execute(object parameter) { Executable(parameter); } public Action<object> Executable = x => { }; }
在调用代码中这样使用:
MyCommand cmd = new MyCommand { Executable = x => MessageBox.Show("Hello") }; button.CommandHandler = cmd;
这实际上是模板方法模式的思想。结合模板方法模式可以减少Command子类膨胀的问题。
3.9 责任链模式(Chain of Responsibility)
Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handle it.
使多个对象都有机会处理请求,从而避免了请求发送者和接受者的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。
责任链模式的典型例子是WPF中的路由事件(Routed Event)机制。路由事件分为冒泡(Bubbling)事件和隧道(Tunneling)事件两种。任何一个控件引发了路由事件,都会沿着可视树(Visual Tree)进行传递,冒泡事件是从引发者向上传到根元素,隧道事件是从根元素向下传到引发者。途经每一个控件结点都会引发相应事件,除非中途在某结点标记事件已经处理。
3.10 装饰模式(Decorator)
Attach additional responsibilities to an object dynamically keeping the same interface. Decorators provide a flexible alternative to subclassing for extending functionality.
动态地给一个对象添加额外职责,并保持接口不变。装饰模式在扩展功能上比继承更灵活。
继承的基类不可能到运行时才确定。然而,对象可以到运行时决定从哪个装饰类获得额外功能。
3.11 策略模式(Strategy)
Define a family of algorithms, encapsulate each one, and make them interchangeable.
定义一组算法,将每个算法都封装起来,并且使它们之间可以互换。
在.NET开发中,策略模式可以方便地用委托来实现,从而大大减少类的个数。事实上,由于委托是“类型”,具体的策略函数是这个类型的对象,不同的对象可以保存在集合中,做成一个“规则库”或者“约束库”,比原始的基于类的策略模式更具灵活性。
3.12 适配器模式(Adaptor)
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.
将一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够一起工作。
例如,关于几何点,AutoCAD中是Point3d,DirectX中是Vector3,OpenNURBS中是On3fPoint,其他几何库中还有其他形式。如果想同时使用,就要用适配器模式。
3.13 迭代器模式(Iterator)
Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation.
提供一种方法访问一个容器对象中各个元素,而又不需暴露该对象的内部细节。
在现代编程语言如Java和C#中,迭代器模式作为语言功能提供以至于我们习以为常。据此有人建议从23种设计模式中删除迭代器模式。
让我们来考察C#中的迭代器模式。我们习惯于使用foreach循环,其实这得益于BCL中的IEnumerable和IEnumerator两个接口。数组、列表、字典等数据结构为我们实现了这两个接口(也就是实现了迭代器模式),于是我们就可以直接使用foreach循环和LINQ查询了。当我们自己的类需要迭代时,也大可不必真的去实现这两个接口,直接返回或组合一个集合就行了。不过在CLR引入泛型之前,确实有很多框架通过实现接口来为每一种类型创建迭代器,现在看来很臃肿。WPF就是一个例子。
CLR中的泛型与C++中的模板表面上看起来非常类似,但两者之间其实存在一些重要的差别。
- C++模板主要用来处理算法中数据类型的问题,例如同一套算法应用int、float、double类型的问题。而CLR泛型主要用来处理有关数据结构、函数编程以及指定基类语境下子类相互替换的问题。BCL中最常见的泛型应用就是IEnumerable, List, Tuple, Func, Action之类了。前三个是数据结构,后两个是函数式编程,这些都是通用算法,不加泛型约束,不涉及调用泛型变量的成员。而一旦涉及调用成员,必须限制基类类型,因为C#会执行类型检查。
- C++模板不执行类型检查,CLR泛型会严格限制类型。你无法用C#写出C++模板常见的用法:写一个加法类,可以分别对int、float、double做加法。因为,“T类型”没有定义加法运算符,而你又无法找到一个定义了加法运算符的int、float、double的共同基类可以作为约束。但是VB.NET可以做到(并非不执行类型检查,而是编译器篡改了你的代码,调用一个运行时助手类,可以用反射技术调用加法运算符),C#4.0中用dynamic也可以做到(不执行类型检查,由DLR动态执行)。
- CLR4.0支持泛型的协变和逆变,C++模板没有类似功能。
- CLR函数需要以Type类对象作为参数时,可以用泛型参数写成泛型函数。
3.14 组合模式(Composite)
Compose objects into tree structure to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
将对象组合成树状结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
在BCL的LINQ to XML类库(System.Xml.Linq命名空间)中,XML文档结构是这样表示的:
在WPF API中,随处可见如下结构:
3.15 观察者模式(Observer)
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
定义对象间的一种一对多的依赖关系,使得一个对象改变状态,则所有依赖它的对象都会得到通知并被自动更新。
广泛应用于数据绑定。
3.16 门面模式(Façade)
Provide a unified interface to a set of interfaces in a subsystem. Façade defines a higher-level interface that makes the subsystem easier to use.
要求一个子系统的外部与其内部的通信必须通过一个统一的对象进行。门面模式提供一个高层次的接口,使得子系统更易于使用。
门面模式在API设计中应用广泛。实践证明,最易于使用的API形式是“基本数据结构+分类组织的静态方法”的模式。客户代码总是希望以一种简洁的形式,最好是一个函数调用就能完成功能。显然,“一个函数”的背后,对应API内部的许多工作,包括创建N个类的对象、复杂的交互等等,但是客户代码仅仅通过一个门面就可以完成所有工作。
ObjectARX作为AutoCAD的API,对普通开发者是不甚友好的,为了完成简单的任务常常需要反复操作大量对象。为此笔者开发了AutoCADCodePack,现已在CodePlex上开源,用分类组织的静态方法包装了常见任务(组织成Draw/Modify/QuickSelection/CustomDictionary/Algorithm/Interaction等模块),又通过基本数据结构的流转保留了必要的灵活性。这个CodePack大大提高了开发效率和代码可维护性。使用CodePack平均能减少一半的代码行数。
3.17 解释器模式(Interpretor)
Given a language, define a representation for its grammar along with a interpreter that uses the representation to interpret sentences in the language.
给定一门语言,定义它的文法的一种表示,并定义一个解释器,使用该表示来解释语言中的句子。
- 某制图软件中,用断面字符串表示道路横断面的配置,字符串用正则表达式解析。
- 游戏引擎中的材质脚本让的使用者倍感方便。
- Windows API中关于多媒体的Winmm.dll中有一个超牛的函数,接收一个字符串命令,囊括了多媒体的方方面面。
- Qt、WPF以及Android分别以C++、.NET和Java为开发语言,而它们使用XML定义界面。
显然,解释器模式对你代码的“下家”是有利的,对你则是噩梦。如果你需要处理加减乘除表达式,你可以选择IronPython/IronRuby等开源脚本引擎。
3.18 享元模式(Flyweight)
Use sharing to support large numbers of fine-grained objects efficiently.
使用共享对象可有效支持大量的细粒度对象。
习惯了面向对象后,你可能会觉得任何东西都可以理所当然地用对象表示,全然没有意识到潜在的问题。事实上,当你的对象需要成千上万时,很可能出现内存溢出。
在三维程序中,一个场景中可能存在大量三维对象,但不是所有对象都是几何不同的。事实上,人们想尽办法用单一模型去表示尽可能多的物体。例如游戏中大量的NPC可能使用同一模型,只是他们可能穿着不同颜色的衣服,身高略有扰动,活动在场景的不同区域,等等。
享元模式将对象的状态分为内部状态和外部状态。内部状态是可以共享的,外部状态可以随环境改变。
3.19 桥接模式(Bridge)
Decouple an abstraction from its implementation so that the two can vary independently.
将抽象和实现解耦,使得两者可以独立变化。
这个模式把抽象和实现的继承关系改为组合。
4 最佳实践
4.1 .NET技术语境下的设计模式新情况
4.1.1 枚举数与迭代器
在C#中,foreach循环就是一个我们习以为常的语言级别的迭代器模式实现。.NET开发规范建议,尽可能用foreach循环代替for循环,而尽可能用查询表达式代替循环。
越来越多的观点和开发技术支持以声明式的语法操作集合元素。虽然在底层所有元素仍需过程式迭代,但是习惯于在集合层面考虑问题(如以对集合的map操作代替循环)显然有助于开发者提高解决问题的能力,也有利于迎接并行计算的到来(虽然底层都是迭代,但map与循环的区别是前者迫使开发者做到每次迭代互不依赖)。典型的实例包括MATLAB中将接收标量参数的函数应用于矩阵、C#和VB.NET中的LINQ以及各种函数式语言或支持函数式风格的语言中有关集合的map语法。
4.1.2 委托类型
委托类型对行为型模式的影响是深远的。采用委托可进一步实践用组合代替继承的思路,行为型模式中的一些依赖关系被进一步消除。
- 模板方法:用委托动态实现方法组合。
- 观察者:用事件委托实现通信。
- 中介者:用委托去除中介者和各组件的耦合关系。
- 策略:用委托代替子类表示策略。
4.1.3 反射技术
反射技术对创建型模式有影响。因为创建型模式致力于解决对象创建时的类型细节耦合,使得创建对象可以运行时动态地进行,而不是编译时决定。而反射正是一种运行时操作类型的解决方案。
4.2 有所为,有所不为
4.2.1 模式的适用性
使用模式是自然而然的事情,多数情况下不使用是因为不需要,问题的复杂度还未达到模式的语境和作用力的要求。我们是为设计而使用模式,而不是为使用模式而设计。
在不恰当的场合使用设计模式会造成设计过度,反而降低设计的质量。一个完整的模式包括3个部分:相关的语境(Context)、与语境相关的作用力(Force)系统、问题的解决方案(Solution)。如果认为设计模式仅仅是解决方案,就会造成模式的滥用(Abuse)。只有语境和作用力完全符合时,模式中的解决方案才是最优解。在作用力平衡被打破,解决方案可能就不再适用。
4.2.2 使用模式的代价
在四人组的原作中,对每一种模式都给出了详细的优点和缺点分析。从总体来看,使用设计模式的必须付出的代价包括:
- 对象过多
- 更复杂的装配关系
- 测试难度加大
- 程序结构复杂
4.2.3 模式的局限性
设计模式不是法则。它不是必须遵守的,而是代表了一定条件下的一种权衡的结果、最优解。使用模式必须付出一定代价,当然在大多数情况下这种代价是可以接受的,于是才有了模式一说。
设计模式不能提高开发速度。至少其关注的中心不是开发速度,很多情况下会降低开发速度,即使正确使用了模式。从一个开发周期、一个单元模块来看,使用模式提高了复杂度,降低了可维护性。(然而从全生命周期、整个架构来看,设计模式拥抱了变化,更清晰、可维护性更高)
4.3 模式指南
4.3.1 考虑使用模式
当你的项目出现了以下问题之一时,就要考虑重构,可能有模式可用:
- 无法进行单元测试
- 需求的变动总是导致代码的修改
- 有重复代码存在
- 继承层次过多
- 隐藏的依赖过多
4.3.2 使用了模式要告诉别人
设计规范要求:在给类命名时要体现所使用的设计模式,在注释中也要注明。
这样做的好处是,对熟悉设计模式的人员一眼就能明白代码的用意,对不熟悉的人员看到注释只需查阅相关模式也能较快理解。毕竟,使用模式使代码局部更复杂难懂。
例如,StringBuilder暗示此类使用了建造者模式。