设计模式之道
在面向对象的软件设计中,人们经常会遇到一些重复出现的问题。为降低软件模块的耦合性,提高软件的灵活性、兼容性、可复用性、可维护性与可扩展性,人们从宏观到微观对各种软件系统进行拆分、抽象、组装,确立模块间的交互关系,最终通过归纳、总结,将一些软件模式沉淀下来成为通用的解决方案,这就是设计模式的由来与发展。
踏着前人的足迹,我们以各种生动的实例切入主题,并基于设计模式进行了大量的代码实战,一步一步直到解决问题,在不知不觉中已经完成了23种设计模式的学习。通过回顾与总结,我们会发现这些模式之间多多少少有一些相似之处,或为变体进化,或是升级增强,稍做修改就能应用于不同的场景,但不管如何变化,其实都是围绕着“设计原则”这个内核展开。正如功夫修炼一般,万变不离其宗,无论“套路”(设计模式)如何发展、演变,都离不开对“内功”(设计原则)的依赖,要做到“内外兼修”,我们就必须掌握软件设计的基本原则。
设计模式是以语言特性(面向对象三大特性)为“硬件基础”,再加上软件设计原则的“灵魂”而总结出的一系列软件模式。一般地,这些“灵魂”原则可被归纳为5种,分别是单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则,它们通常被合起来简称为“S.O.L.I.D”原则,也是最为流行的一套面向对象软件设计法则。最后我们再附加上迪米特法则,简称“LoD”。接下来我们将依次研究这六大原则。
单一职责
我们知道,一套功能完备的软件系统可能是非常复杂的。既然要利用好面向对象的思想,那么对一个大系统的拆分、模块化是不可或缺的软件设计步骤。面向对象以“类”来划分模块边界,再以“方法”来分隔其功能。我们可以将某业务功能划归到一个类中,也可以拆分为几个类分别实现,但是不管对其负责的业务范围大小做怎样的权衡与调整,这个类的角色职责应该是单一的,或者其方法所完成的功能也应该是单一的。总之,不是自己分内之事绝不该负责,这就是单一职责原则(Single Responsibility Principle)
举个简单的例子,鞋子是用来穿的,其主要意义就是为人的脚部提供保护、保暖的功能;电话的功能是用来通话的,保证人们可以远程通信。鞋子与电话完全是两类东西,它们应该各司其职。然而有人为了省事可能会把这两个类合并为一个类,变成一只能打电话的鞋子,这就造成了图25-1所示的尴尬场景,打电话时要脱掉鞋子,打完电话再穿回去。这时我们就可以下结论,既能当鞋又能当电话的设计是违反单一职责原则的。
图25-1 不伦不类的产品设计
再举个深入一些的例子。灯泡是用来照明的,我们可以定义一个灯泡类并包含“功率”等属性,以及“通电”和“断电”两个功能方法。在一对大括号“{}”的包裹下划分出类模块的边界,这便是对灯泡类的封装,与外界划清了界限。虽然说我的领域我做主,但绝不可肆意妄为地对其功能进行增强,比如客户要求这个灯泡可以闪烁产生霓虹灯效果,我们该怎样实现呢?直接在灯泡类里封装一堆逻辑电路控制其闪烁,如新加一个flash()方法,并不停调用通电方法与断电方法。这显然是错误的,灯泡就是灯泡,它只能亮和灭,闪烁不是灯泡的职责。既然已经分门别类,就不要不伦不类。所以我们需要把闪烁控制电路独立出来,灯泡与闪烁之间的通信应该通过接口去实现,从而划清界限,各司其职,这样类封装才变得有意义。
单一职责原则由罗伯特·C.马丁(Robert C. Martin)提出,其中规定对任何类的修改只能有一个原因。例如之前的例子灯泡类,它的职责就是照明,那么对其进行的修改只能有与“照明功能”相关这样一个原因,否则不予考虑,这样才能确保类职责的单一性原则。同时,类与类之间虽有着明确的职责划分,但又一起合作完成任务,它们保持着一种“对立且统一”的辩证关系。以最典型的“责任链模式”为例,其环环相扣的每个节点都“各扫门前雪”,这种清晰的职责范围划分就是单一职责原则的最佳实践。符合单一职责原则的设计能使类具备“高内聚性”,让单个模块变得“简单”“易懂”,如此才能增强代码的可读性与可复用性,并提高系统的易维护性与易测试性。
开闭原则
开闭原则(Open/Closed Principle),乍一听来不知所云,其实它是简化命名,其中“开”指的是对扩展开放,而“闭”则指的是对修改关闭。简单来讲就是不要修改已有的代码,而要去编写新的代码。这对于已经上线并运行稳定的软件项目尤为重要。修改代码的代价是巨大的,小小一个修改有可能会造成整个系统瘫痪,因为其可能会波及的地方是不可预知的,这给测试工作也带来了很大的挑战。
举个例子,我们设计了一个集成度很高的计算机主板,各种部件如CPU、内存、硬盘一应俱全,该有的都已集成了,大而全的设计看似不需要再进行扩展了。然而当用户需要安装一个摄像头的时候,我们不得不拆开机箱对内部电路进行二次修改,并加装摄像头。在满足用户的各种需求后,主板会被修改得面目全非,各种导线焊点杂乱无章,如图25-2所示,“大而全”的模块堆叠让主板变得臃肿不堪,这就违反了开闭原则。
图25-2 反复修改的电路
经过反思,我们会后悔当初设计主板的时候为什么不预留好接口,不然用户就能自由地扩展外设了,想用什么就接入什么,如用户可以购入摄像头、U盘等外设并插入主板的USB接口,而主板则被封装于机箱中,不再需要做任何更改,这便是对扩展的开放,以及对修改的关闭。
再来看一个绘画的例子。我们定义一个画笔类,并加上一个很简单的绘画方法draw()。这时由于业务扩展,画家接到了彩图的订单,这时我们决定修改这个画笔类的绘画方法draw(),接受颜色参数并加入判断逻辑以切换颜色,这让画笔类看起来非常丰满,功能非常强大,让画家觉得很满意。然而,当后期又需要水彩、水墨、油画等颜料效果时,我们要不断地对画笔类进行代码修改,大量的逻辑代码会堆积在这个类中,混乱不堪。造成这种情况必然是软件设计的问题。我们对违反开闭原则的画笔类重新审视,由于绘画方法draw()是一直在扩展、多变的,因此我们不能将其硬编码,而应抽象化绘画行为接口draw()。画笔类的抽象化或接口化使其不必操心具体的绘画行为,因为这些都可以交给子类实现完成,如黑色蜡笔、红色铅笔,或是毛笔、油画笔等。如此一来,高层抽象与底层实现的结构体系便建立起来了,若后期再需要进行扩展,那么去添加新类并继承高层抽象即可,各种画笔保持各自的绘画特性,那么画出来的笔触效果就会各有不同。所以说符合开闭原则的设计,一定要通过抽象去实现,高层抽象的泛化保证了底层实现的多态化扩展,而不需要对现有系统做反复修改。
里氏替换
里氏替换原则(Liskov Substitution Principle)是由芭芭拉·利斯科夫(Barbara Liskov)提出的软件设计规范,里氏一词便来源于其姓氏Liskov,而“替换”则指的是父类与子类的可替换性。此原则指的是在任何父类出现的地方子类也一定可以出现,也就是说一个优秀的软件设计中有引用父类的地方,一定也可以替换为其子类。其实面向对象设计语言的特性“继承与多态”正是为此而生。我们在设计的时候一定要充分利用这一特性,写框架代码的时候要面向接口编程,而不是深入到具体子类中去,这样才能保证子类多态替换的可能性。
假设我们定义一个“禽类”,给它加一个飞翔方法fly(),我们就可以自由地继承禽类衍生出各种鸟儿,并轻松自如地调用其飞翔方法。如果某天需要鸵鸟加入禽类的行列,鸵鸟可以继承禽类,这没有任何问题,但鸵鸟不会飞,那么飞翔方法fly()就显得多余了,而且在所有禽类出现的地方无法用鸵鸟进行替换,这便违反了里氏替换原则。如图25-3所示,不是所有禽类都能飞,也不是所有兽类都只能走。
图25-3 不会飞的禽类
经过反思,我们意识到最初的设计是有问题的,因为“禽类”与“飞翔”并无必然关系,所以对于禽类不应该定义飞翔方法fly()。接着,我们对高层抽象进行重构,把禽类的飞翔方法fly()抽离出去并单独定义一个飞翔接口Flyable,对于有飞翔能力的鸟儿可以继承禽类并同时实现飞翔接口,而对于鸵鸟则依然继承禽类,但不用去实现飞翔接口。再比如蝙蝠不是鸟儿但可以飞,那么它应该继承自兽类,并实现飞翔接口。这样一来,是否是鸟儿取决于是否继承自禽类,而能不能飞要取决于是否实现了飞翔接口。所有禽类出现的地方我们都可以用子类进行替换,所有飞翔接口出现的地方则可以被替换为其实现,如蝙蝠、蜜蜂,甚至是飞机。所以优秀的软件设计一定要有合理的定义与规划,这样才能容许软件可扩展,使任何子类实现都能在其高层抽象的定义范围内自由替换,且不引发任何系统问题。
我们讲过的策略模式就是很好的例子。例如我们要使用计算机进行文档录入,计算机会依赖抽象USB接口去读取数据,至于具体接入什么录入设备,计算机不必关心,可以是手动键盘录入,也可以是扫描仪录入图像,只要是兼容USB接口的设备就可以对接。这便实现了多种USB设备的里氏替换,让系统功能模块可以灵活替换,功能无限扩展,这种可替换、可延伸的软件系统才是有灵魂的设计。
接口隔离
接口隔离原则(Interface Segregation Principle)指的是对高层接口的独立、分化,客户端对类的依赖基于最小接口,而不依赖不需要的接口。简单来说,就是切勿将接口定义成全能型的,否则实现类就必须神通广大,这样便丧失了子类实现的灵活性,降低了系统的向下兼容性。反之,定义接口的时候应该尽量拆分成较小的粒度,往往一个接口只对应一个职能。
假设现在我们需要定义一个动物类的高层接口,为了区别于植物,动物一定是能够移动的,并且是能够发声的,我们决定定义一个动物接口并包含“移动”与“发声”两个接口方法。于是,动物们都纷纷沿用这个动物接口并实现这两个方法,例如猫咪上蹿下跳并且喵喵地叫;狗来回跑并且汪汪地叫;鸟儿在天上飞并且叽叽喳喳地叫。这一切看似合理,但兔子蹦蹦跳跳可是一般不发声,最后不得不加个哑巴似的空方法实现。如图25-4所示,兔子从外部看来确实长着嘴巴但不能发声,如此实现毫无意义。
显然,问题出在高层接口的设计上。“动物”接口定义的行为过于宽泛,它们应该被拆分开来,独立为“可移动的”与“可发声的”两个接口。此时兔子便可以只实现可移动接口了,而猫咪则可以同时实现这两个接口,或者干脆实现两个接口合起来的“又可移动又可发声”的全新子接口,如此细分的接口设计便能让子类达到灵活匹配的目的。
图25-4 不会发声的兔子
接口隔离原则要求我们对接口尽可能地细粒度化,拆分开的接口总比整合的接口灵活,例如我们常用的Runnable接口,它只要求实现类完成run()方法,而不会把不相干的行为牵扯进来。其实接口隔离原则与单一职责原则如出一辙,只不过前者是对高层行为能力的一种单一职责规范,这非常好理解,分开的容易合起来,但合起来的就不容易分开了。接口隔离原则能很好地避免了过度且臃肿的接口设计,轻量化的接口不会造成对实现类的污染,使系统模块的组装变得更加灵活。
依赖倒置
我们知道,面向对象中的依赖是类与类之间的一种关系,如H(高层)类要调用L(底层)类的方法,我们就说H类依赖L类。依赖倒置原则(Dependency Inversion Principle)指高层模块不依赖底层模块,也就是说高层模块只依赖上层抽象,而不直接依赖具体的底层实现,从而达到降低耦合的目的。如上面提到的H与L的依赖关系必然会导致它们的强耦合,也许L任何细枝末节的变动都可能影响H,这是一种非常死板的设计。而依赖倒置的做法则是反其道而行,我们可以创建L的上层抽象A,然后H即可通过抽象A间接地访问L,那么高层H不再依赖底层L,而只依赖上层抽象A。这样一来系统会变得更加松散,这也印证了我们在“里氏替换原则”中所提到的“面向接口编程”,以达到替换底层实现的目的。
举个例子,公司总经理制订了下一年度的目标与计划,为了提高办公效率,总经理决定年底要上线一套全新的办公自动化软件。那么总经理作为发起方该如何实施这个计划呢?直接发动基层程序员并调用他们的研发方法吗?我想世界上没有以这种方式管理公司的领导吧。公司高层一定会发动IT部门的上层抽象去执行,如图25-5所示,调用IT部门经理的work方法并传入目标即可,至于这个work方法的具体实现者也许是架构师甲,也可能是程序员乙,总经理也许根本不认识他们,这就达到了公司高层与底层员工实现解耦的目的。这就是将“高层依赖底层”倒置为“底层依赖高层”的好处。
图25-5 IT部门组织架构
我们在做开发的时候,常常会从高层向底层编写代码,例如编写业务逻辑层的时候我们不必过度关心数据源的类型,如文件或数据库,MySQL或Oracle,这些问题对处于高层的业务逻辑来说毫无意义。我们要做的只是简单地调用数据访问层接口,而其接口实现可以暂且不写,若是要单元测试则可以写一个简单的模拟实现类,甚至可以并行开发,交给其他同事去实现。这一切的前提是必须定义良好的上层抽象及接口规范,因为实现底层的时候必须依赖上层的标准,传统观念上的依赖方向被反转,高层业务逻辑与底层数据访问彻底解耦,这便是依赖倒置原则的意义所在。
迪米特法则
迪米特法则(law of Demeter)也被称为最少知识原则,它提出一个模块对其他模块应该知之甚少,或者说模块之间应该彼此保持陌生,甚至意识不到对方的存在,以此最小化、简单化模块间的通信,并达到松耦合的目的。反之,模块之间若存在过多的关联,那么一个很小的变动则可能会引发蝴蝶效应般的连锁反应,最终会波及大范围的系统变动。我们说,缺乏良好封装性的系统模块是违反迪米特法则的,牵一发动全身的设计使系统的扩展与维护变得举步维艰。
举个例子,我们买了一台游戏机,主机内部集成了非常复杂的电路及电子元件,这些对外部来说完全是不可见的,就像一个黑盒子。虽然我们看不到黑盒子的内部构造与工作原理,但它向外部开放了控制接口,让我们可以接上手柄对其进行访问,这便构成了一个完美的封装,如图25-6所示
除了封装起来的黑盒子主机,手柄是另一个封装好的模块,它们之间的通信只是通过一根线来传递信号,至于主机内部的各种复杂逻辑,手柄一无所知。例如主机内部的磁盘载入、内存读写、CPU指令执行等操作,手柄并非直接访问这些主机中的部件,它对主机的所有认知限制在接口所能接收的信号的范围,这便符合了迪米特法则。
之前我们学过的“门面模式”就是极好的范例。例如我们去某单位办理一项业务,来到业务大厅一脸茫然,各种填表、盖章等复杂的办理流程让人一头雾水,有可能来回折腾几个小时。假若有一个提供快速通道服务的“门面”办理窗口,那么我们只需简单地把材料递交过去就可以了,“办理人“与“门面”保持最简单的通信,对于门面里面发生的事情,办理人则知之甚少,更没有必要去亲力亲为。
要设计出符合迪米特法则的软件,切勿跨越红线,干涉他人内务。系统模块一定要最大程度地隐藏内部逻辑,大门一定要紧锁,防止陌生人随意访问,而对外只适可而止地暴露最简单的接口,让模块间的通信趋向“简单化”“傻瓜化”
设计的最高境界
在面向对象软件系统中,优秀的设计模式一定不能违反设计原则,恰当的设计模式能使软件系统的结构变得更加合理,让软件模块间的耦合度大大降低,从而提升系统的灵活性与扩展性,使我们可以在保证最小改动或者不做改动的前提下,通过增加模块的方式对系统功能进行增强。相较于简单的代码堆叠,设计模式能让系统以一种更为优雅的方式解决现实问题,并有能力应对不断扩展的需求。
随着业务需求的变动,系统设计并不是一成不变的。在设计原则的指导下,我们可以对设计模式进行适度地改造、组合,这样才能应对各种复杂的业务场景。然而,设计模式绝不可以被滥用,以免陷入“为了设计而设计”的误区,导致过度设计。例如一个相对简单的系统功能也许只需要几个类就能够实现,但设计者生搬硬套各种设计模式,拆分出几十个模块,如图25-7所示,结果适得其反,不切实际的模式堆砌反而会造成系统性能瓶颈,变成一种拖累
图25-7 过度设计
世界上并不存在无所不能的设计,而且任何事物都有其两面性,任何一种设计模式都有其优缺点,所以对设计模式的运用一定要适可而止,否则会使系统臃肿不堪。满足目前需求,并在未来可预估业务范围内的设计才是最合理的设计。当然,在系统不能满足需求时我们还可以做出适当的重构,这样的设计才是切合实际的。
虽然不同的设计模式是为了解决不同的问题,但它们之间有很多类似且相通的地方,即便作为“灵魂本质”的设计原则之间也有着千丝万缕的关联,它们往往是相辅相成、互相印证的,所以我们不必过分纠结,避免机械式地将它们分门别类、划清界限。在工作中,我们一定要合理地利用设计模式去解决目前以及可以预见的未来所面临的问题,并基于设计原则,不断反复思考与总结。直到有一天,我们可能会忘记这些设计模式的名字,突破了“招式”和“套路”的牵绊,最终达到一种融会贯通的状态,各种“组合拳”信手拈来、运用自如。当各种模式在我们的设计中变得“你中有我,我中有你”时,才达到了不拘泥于任何形式的境界。