设计模式的圣经
提起设计模式,就不得不提《设计模式——可复用面向对象软件的基础》这本经典著作。1995年,GOF(Gang Of Four),也就是Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides这四个人,合作出版了《Design Patterns: Elements of Reusable Object-Oriented Software》一书,被奉为设计模式的“圣经”。
该书描述了23种经典的面向对象设计模式,确立了模式在软件设计中的地位,开创了一种新的面向对象设计思潮。从此,参与设计模式研究的人数呈现爆炸性的增长。
设计模式简介
□ 设计模式描述了在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该解决方案而不必重复劳动。
□ 设计模式实际上就是类与相互通信的对象之间的组织关系,包括它们的角色、职责、协作方式等各个方面。
□ 设计模式通常和面向对象编程结合起来使用。面向对象设计模式是“好的面向对象设计”,所谓“好的面向对象设计”是指那些可以满足 “应对变化,提高复用”的设计。
□ 现代软件设计的特征是:需求频繁变化。设计模式的要点是:寻找变化点,然后在变化点处应用设计模式,从而来更好地应对需求的变化。
软件的复杂性
软件的复杂性是一个很宽泛的概念,任何使软件难以理解、难以修改、难以维护的东西,都属于软件的复杂性。软件复杂的根本原因是:变化。这里的变化,包括:客户需求的变化、技术平台的变化、开发团队的变化、市场环境的变化等等。
IBM院士、IBM研究院软件工程首席科学家Grady Booch曾经说过下面这段话,有助于我们理解软件的复杂性和需求变化的随意性。
“建筑商从来不会去想给一栋已建好的100层高的楼房底下再新修一个小地下室——这样做花费极大而且注定要失败。然而令人惊奇的是,软件系统的用户在要求作出类似改变时却不会仔细考虑,而且他们认为这只是需要简单编程的事。”
解决复杂性
面向对象设计模式最大的优势在于:抵御变化。为什么能抵御变化呢?原因在于其遵循了以下的设计原则。
单一职责原则:一个类只负责一个职责,只有一个引起它变化的原因。
开放封闭原则:对于功能扩展是开放的,对于修改是封闭的。
里斯替换原则:子类可以替换基类,继承必须确保超类所拥有的性质在子类中仍然成立。
依赖倒置原则:高层次的模块不应该依赖于低层次的模块,它们都应该依赖于抽象。抽象不应该依赖于具体,具体应该依赖于抽象。
接口隔离原则:一个类对另一个类的依赖应该建立在最小的接口上。
迪米特法则:一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。
封装变化点:使用封装来创建对象之间的分界层,让设计者可以在分界层的一侧进行修改,而不会对另一侧产生不良的影响,从而实现层次间的松耦合。
优先使用对象组合,而不是类继承:类继承通常为“白箱复用”,对象组合通常为“黑箱复用”。继承在某种程度上破坏了封装性,子类父类耦合度高。而对象组合则只要求被组合的对象具有良好定义的接口,耦合度低。
下面,我们通过4个具体的案例及其分析过程,来理解设计模式。
案例1
在面向对象系统的分析与设计过程中,经常会遇到这样一种情况:对于某一个业务逻辑(算法实现),在不同的对象中有不同的细节实现, 但是逻辑(算法)的整体操作结构和框架是稳定的。如何在确保整体操作结构稳定的前提下,来灵活应对不同的细节实现呢?
解决该问题,主要有以下三种思路:
1、封装多个不同类,各实现各的(最多把共同的部分拎出来)—— 不使用设计模式。
2、采用继承和多态的方式实现 —— 模板方法(Template Method)模式。
3、采用组合和委托的方式实现 —— 策略(Strategy)模式。
□ 模板方法模式
定义一个操作中的算法的骨架 (稳定),而将一些步骤延迟(变化)到子类中。 Template Method使得子类可以不改变(复用)一个算法的结构即可重定义(override 重写)该算法的某些特定步骤。
模板方法模式使用虚函数的多态性提供了灵活的扩展点,是代码复用方面的基本实现结构。但继承的强制性约束关系也让其有不足的地方,比如:ConcreteClass1中实现的原语方法Primitive1(),是不能被别的类复用的。(可类比:到银行办业务)
□ 策略模式
定义一系列算法,把它们一个个封装起来,并且使它们可互相替换(变化)。该模式使得算法可独立于使用它的客户程序(稳定)而变化(扩展,子类化)。
策略模式将逻辑(算法)封装到一个类(Context)里面,通过组合的方式将具体算法的实现在组合对象中实现,再通过委托的方式将抽象接口的实现委托给组合对象实现。策略模式可以在运行时方便地根据需要在各个算法之间进行切换,同时也解耦了稳定部分和变化部分,变化部分很容易被其他类进行复用。缺点是:多了一个类和一些接口;当变化部分ConcreteStrategy需要使用稳定部分Context中的内部数据和方法时,不太方便。(可类比:旅游出行)
案例2
在已经封装了一些类和接口的情况下,我们往往会发现,如何使用这些类和接口有时也会成为一个不大不小的问题。原因在于,某些接口之间直接的依赖常常会带来很多问题,甚至根本无法实现。举几个例子:
1、系统内部存在很多子系统,每个子系统都有各自的类和接口,客户程序使用时,需要调用各个子系统中的接口才能去完成某一项功能。随着客户程序和各子系统的演化,这种过多的耦合面临很多变化的挑战。如何简化客户程序和系统间的交互接口,并将客户程序的演化与内部子系统的变化之间的依赖相互解耦?
2、由于应用环境的变化,有时候需要将一些现存的类和接口放到新的环境中去使用,但新环境要求的接口是现存接口所不满足的。如何既能利用现有接口的良好实现,同时又能满足新的应用环境?
3、在面向对象系统中,直接创建某些类的对象会给使用者或系统结构带来很多麻烦。比如:有一个图片对象的类,创建它时,会去磁盘或网络加载图片,如果显示的图片数量非常多,创建的开销是非常大的。如何在不修改已有类和接口的情况下,来管理和控制这些对象特有的复杂性呢?
上述的三个问题,都属于接口隔离的范畴,分别为:
1、在高层提供统一的接口,内部去调用各子系统的接口 —— 外观(Facade)模式。
2、采用继承和组合的方式实现一个适配器类,用于适配新老接口 —— 适配器(Adapter)模式。
3、采用组合和委托的方式实现一个代理类 —— 代理(Proxy)模式。
□ 外观模式
为子系统中的一组接口提供一个一致(稳定)的界面。Facade模式定义了一个高级接口,这个接口使得子系统更加容易使用。
外观模式不仅简化了整个系统的接口,对于客户程序和内部子系统来说,还达到了一种解耦的效果 —— 内部子系统的任何变化不会影响到Facade接口的变化。(可类比:到政府机构办事)
□ 适配器模式
将一个类的接口转换成客户希望的另一个接口。Adapter模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
适配器模式主要应用于希望复用一些现存的类,但接口又与新的使用环境的要求不一致的情况,在遗留代码复用、类库迁移等方面非常有用。(可类比:笔记本的电源适配器、SD卡读卡器)
□ 代理模式
为其他对象提供一种代理以控制对这个对象的访问。
代理模式通过增加一层间接层的方法,有效解决了面向对象系统中直接使用某些对象带来的问题。这些不好直接使用的对象主要包括:开销大的对象、网络上的对象、需要进行权限控制的对象等。(可类比:房产中介)
案例3
常常有一些对象在内部具有特定的数据结构,如果让客户程序依赖这些特定的数据结构,将导致客户程序与这些对象产生极大的耦合。存在以下两种通用情形:
1、对象内部包含一个容器,客户程序想要访问容器内的信息时,有两种方式:一是直接把容器通过接口返回给客户程序,由客户程序去遍历;二是对象自身提供接口遍历容器内的每个元素。第一种方式耦合性太高,容器结构的变化会导致客户程序的变化。第二种方式虽然能够工作,但不够通用,所有容器对象都需要提供一套类似的接口。如何在不暴露内部数据结构的同时,又能优雅透明地访问其中的元素呢?
2、对象内部是一个树状结构,如何通过接口去访问对象内的树节点?
上述两个问题,均与数据结构有关。
1、在客户程序与聚合对象之间插入一个迭代器,由迭代器专门负责遍历工作,这分离了聚合对象与其遍历行为,对客户也隐藏了其内部细节 —— 迭代器(Iterator)模式。
2、使用继承的方式实现叶子类和组合类,叶子类和组合类派生自一个公共的基类 —— 组合(Composite)模式。
□ 迭代器模式
提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露(稳定)该对象的内部表示。修改遍历方式时,不需要修改聚合对象,只需要修改迭代器即可。
迭代器模式为遍历不同的集合结构提供了一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作。实际开发时,迭代器可能需要访问聚合对象的内部数据结构,因此聚合对象需要将迭代器设置为友元。(可类比:流水线上的快递包裹)
□ 组合模式
将对象组合成树形结构以表示“部分 - 整体”的层次结构。Composite使得用户对单个对象和组合对象的使用具有一致性(稳定)。
组合模式采用树形结构将一对多的关系转换为一对一的关系,使得客户程序可以一致地处理叶子对象和组合对象。组合模式去耦合后,客户程序将只依赖于纯粹的抽象接口,从而更能应对变化。(可类比:目录和文件)
案例4
在软件的开发过程中,随着需求的不断变化和增加,类的个数也会不断变多,最终会导致类的急剧膨胀。
1、当需要为一个已经定义好的类添加新的职责时,通常情况下我们会定义一个新类继承已定义好的类。添加的职责越多,定义的新类就会越多,最终导致很深层次的继承关系和数不清的类。如果考虑到各种职责的组合,则会导致更多子类的膨胀和重复代码的产生。如何使对象职责的扩展能够根据需要来动态地实现,同时避免带来的子类膨胀问题?
2、当一个类有一个变化的维度时,我们会派生出两个类。当一个类有两个变化的维度时,我们会派生出四个类。变化的维度越多,派生出的类也会越多。如何利用面向对象技术来使得类型可以轻松地沿着两个乃至多个维度变化,而不引入额外的复杂度?
如何避免需求变化和增加导致的复杂性呢?可以考虑使用单一职责的设计原则。
1、通过组合的方式给一个对象添加新的职责,原始的类不需要修改和派生,添加新的装饰类即可 —— 装饰(Decorator)模式。
2、将系统分为两个独立的部分:抽象部分和实现部分,两个部分可以独立地进行修改,然后通过组合的方式进行桥接 —— 桥接(Bridge)模式。
□ 装饰模式
动态(组合)地给一个对象增加一些额外的职责。就增加功能而言,Decorator模式比生成子类(继承)更为灵活(消除重复代码 & 减少子类个数)。
装饰模式通过组合而非继承的方式,实现了在运行时动态扩展对象职责的能力,而且可以根据需要扩展多个职责,避免了使用继承带来的灵活性差和多子类衍生问题。Decorator类在接口上表现为is-a Component的继承关系,即Decorator类继承了Component类所具有的接口。但在实现上又表现为has-a Component的组合关系,即Decorator类又使用了另外一个Component类。(可类比:杂粮饼)
□ 桥接模式
将抽象部分(业务功能)与实现部分(平台实现)分离,使它们都可以独立地变化。
桥接模式使用“对象间的组合关系”解耦了抽象和实现之间固有的绑定关系,使得抽象和实现可以沿着各自的维度来变化。Bridge模式有时候类似于多继承方案,但是多继承方案往往违背单一职责原则,复用性比较差,Bridge模式是比多继承方案更好的解决方法。(可类比:蜡笔和毛笔)
总结
□ 使用设计模式时,一定要区分需求的稳定部分和可变部分。一个软件必然有稳定部分,这个部分就是核心业务逻辑。如果核心业务逻辑发生变化,软件就没有存在的必要,核心业务逻辑是我们需要固化的。对于可变的部分,需要判断可能发生变化的程度来确定设计策略和设计风险。
□ 考虑你的设计中什么可能会发生变化,考虑你允许什么发生变化而不让这一变化导致重新设计。设计模式的核心在于发现变化点,并封装之。另外,一种可变性不应散落在代码的很多角落,一种可变性也不应当与另一种可变性混合在一起。
□ 在实际工作中,很少会规定必须使用哪些设计模式,这样只会带来限制和条条框框。不能为了使用设计模式而去做架构,而是有了做架构的需求后,发现它符合某一类设计模式的结构,再将两者结合。
□ 设计模式要活学活用,不要生搬硬套。死记硬背是没用的,还要从实践中理解。想要游刃有余地使用设计模式,需要打下牢固的程序设计语言基础,夯实自己的编程思想,积累大量的时间经验,提高开发能力。
□ 设计模式从来都不是单个设计模式独立使用的。在实际应用中,通常多个设计模式混合使用,你中有我,我中有你。
□ 软件开发是一项实践工作,最直接的方法就是编程。没有从来不下棋却熟悉定式的围棋高手。掌握设计模式是水到渠成的事情,不要强求。随着理论和实践的不断积累,可能会“渐悟”或者“顿悟”。
□ 设计模式解决的是设计不足的问题,但同时也要避免设计过度。一定要牢记简洁原则,要知道设计模式是为了使设计简单,而不是更复杂。如果引入设计模式使得设计变得复杂,只能说我们把简单问题复杂化了,问题本身不需要设计模式。
□ 设计模式的应用不宜先入为主,一上来就使用设计模式是对设计模式的最大误用。没有一步到位的设计模式。敏捷软件开发实践提倡的“Refactoring to Patterns”(重构获得模式),是目前普遍公认的最好的使用设计模式的方法。
□ 或许有的人会说,我们不需要设计模式,我们的系统很小,设计模式会束缚我们的实现。实际上,设计模式体现的是一种思想,而思想则是指导行为的一切。理解和掌握了设计模式,并不是说记牢了23种或更多的设计场景和解决策略,实际接受的是一种思想的熏陶和洗礼。等这种思想融入到了你的思想中后,你就会不自觉地使用这种思想去进行你的设计和开发,而这才是最重要的。
推荐书籍