在学习设计模式的过程中,实践需与理论相结合才能更好地举一反三,灵活运用。设计模式到底是什么?它是对整个软件系统的拆分,组装,并决定模块间关系以及如何互动、通信的某种模式。究其本质,设计模式就是以语言特性(面向对象三大特性)为硬件基础,再加持六大设计原则的灵魂组合而总结出的一系列套路,本篇要讲地就是灵魂。
单一职责
我们知道功能完备的软件系统是复杂的,系统的拆分与模块化是不可或缺的,而面向对象是以类来划分模块边界的,也就是说每个类都代表着一个功能角色模块,其职责应该是单一的,不是自己分内的事不应该负责,这就是单一职责原则。
举个例子,灯泡一定是可以亮和灭的,我们定义一个灯泡类并且包含“功率属性”以及“通电”和“断电”两个功能方法,这便是对灯泡的封装,一对大括号“{}”定义了其类模块的边界。
虽然说我的领域我做主,但绝不可肆意妄为。比如现在客户要求这个灯泡可以闪烁的霓虹灯效果,我们该怎样实现?直接在电灯类里再封装一堆逻辑电路控制其闪烁,比如新加一个flash()方法,并不停来回调用通电断电?这显然是错误的,灯泡就是灯泡,它只能亮和灭,能不能闪烁不是灯泡的职责,既然进行分类,就不要不伦不类。所以我们需要把闪烁控制电路独立出来,它们之间的通信应该通过接口去调用,划清界限,各司其职,这才是类封装的意义。
单一职责原则规定,对任何类的修改只能有一个原因,这是由罗伯特·C·马丁(Robert C. Martin)提出的,这是什么意思呢?例如我们的灯泡类,它的职责就是照明,与其无关的一切修改动机都不予考虑。所以说灯泡绝不能封装与其本身职责不相干的功能,这样就保证其职责的单一性原则,类与类之间有明确的职责划分,同时也保持一种协作的关系,分与合,对立与统一的辩证关系。最典型的例子例如我们之前讲过的”责任链模式“中环环相扣审批人的职责范围就是很好的例子,各顾各的、不管闲事,职责的单一性保证了类的高内聚、低耦合,如此便提高了代码的易读性、易维护性、易测试性、易复用性等等。
开闭原则
这个原则听起来完全不知所云,其实它是简化命名,其中“开”指的是对扩展是开放的,”闭“则指的是对修改是关闭。通俗来讲就是不要修改已有的代码,而是去写新的代码。这对于已经上线并稳定运行的软件项目来说更为重要,修改代码的代价是巨大的,小小一个修改有可能会造成整个系统瘫痪,因为其可能会波及到的地方变得不可预知,难以估量。
举个简单的例子,我们有一个笔类用来画画,它有一个很简单的draw方法。这时业务扩展,需要画各种颜色的画,难道我们继续修改这个笔类的draw方法去接受颜色参数并加入大量逻辑判断吗?如果后期又需要水彩、水墨、油画等等颜料效果就需要没完没了的对笔进行代码修改,大量的逻辑代码会堆积在这个类中,就像拆开封装的机器壳子对内部电路二次修改,各种导线焊点杂乱无章、臃肿不堪。
造成这种局面肯定是系统设计上的问题,我们要对其重新审视,对笔类进行抽象,定义好一个绘画行为draw(),但具体怎样画不应予以关心。如此便建立了软件体系的高层抽象,如果后期要进行扩展,那么去添加新类并继承我们的高层抽象即可,各种笔保证了各自的特性,你画你的,我画我的。所以说开闭原则是通过抽象去实现的,高层的泛化保证了底层实现的多态化扩展。
在访问者模式中对资源模块的访问就是非常典型的应用,我们对资源内部并没有进行逻辑修改,不应该为了附加的功能而修改资源,而是新建访问者模块中去实现这些附加功能,今后对系统扩展时我们只需添加新的资源类与访问者类实现即可,系统模式一旦完美确立就不再修改现有代码,这就是对扩展的开放,对修改的关闭,添加加比修改好;反之假如系统升级要牵扯进来大量的代码修改则说明这个设计是失败的,是违反开闭原则的。其实开闭原则的例子不胜枚举,对抽象的大量运用奠定了系统的可复用性、可扩展性的基石,增加了系统的稳定性,读者还需要自己揣摩、体会。
里氏替换
里氏替换原则最早由Barbara Liskov提出的设计模式规范,里氏一词便来源于其姓氏Liskov,而”替换“指的是父子类的可替换性。此原则指出是任何父类出现的地方子类一定也可以出现,换个角度讲也就是说一个优秀的设计中有引用父类的地方,一定可以用子类进行替换。其实面向对象设计语言的特性”继承与多态“正是为此而生,而我们在设计的时候一定要考虑到这一点,写框架代码的时候要面向抽象编程,而不是深入到具体子类中去,这样才能保证子类多态的可能性。
假设我们定义有这么一个类“禽类”,给它加一个飞翔方法fly(),于是客户端可以自由自在地调用其飞翔方法。不巧某天需要鸵鸟加入禽类的行列,可惜地是鸵鸟并不会飞,这下就闹得整个鸡飞狗跳,此时客户端就不能调用禽类的飞翔方法了,因为这个禽类有可能就是鸵鸟,这就违反了里氏替换原则。我们意识到最初的设计一定是有问题的,因为不是所有“禽类”都会“飞”,所以对于禽类不该有飞翔方法。
我们这里提供一种思路做重构,把禽类的飞翔方法抽离出去给一个接口Flyable,这样鸵鸟依旧可以继承禽类,对于其他可以飞的鸟则是继承禽类并实现Flyable接口。这样一来,客户端如果用的是禽类,那一定是鸟而绝不是兽,但不一定能飞,比如是鸵鸟或者火鸡;而如果用的是Flyable那它就必然能飞,也许是蝙蝠(兽类)甚至可以是飞机,这些子类一定是在其基类定义范围内可以随意替换而不引起任何系统问题。
之前我们讲过的策略模式就是很好的例子,比如我们要使用电脑要进行文档录入,电脑会依赖抽象USB接口去读取数据,至于具体接入什么录入设备它不必关心,可以是键盘手工录入,或是扫描仪录入图像,只要是兼容USB接口的设备就可以兼容,这就实现了多种USB设备的里氏替换,让系统功能模块可灵活替换,可向外延申扩展,这样的系统才是有设计的,活起来有灵魂的。
接口隔离
接口隔离(分离)指的是对高层接口的独立、分化,客户端对类的依赖应该基于最小接口,而不应该依赖不需要的接口。简单来说就是定义接口的时候尽量往小定义,不要定义成全能型的,什么都能,什么都会,最好是一个接口只对应一个角色职能。
假设我们要定义一个动物高层接口,我们开始思考,区别于植物,动物一定是能跑的,而且能叫,于是我们定义一个移动方法,和一个发声方法。然后动物们都开始实现这俩方法了,猫跑并喵喵叫;鸟飞并吱吱叫,看似很合理其实不然,有一天兔子蹦蹦跳跳可就是不会叫,但不得不默默地加个哑巴空方法。
这时就需要反思,接口定义的行为太多了,这些行为定义完全可以拆分开为两个独立接口“可移动接口”与“可发声接口”,这时兔子可以只依赖可移动接口了,而猫则可以依赖一个全新的“又可跑又可发声”的接口,显而易见此接口是从那两个独立出来的接口继承来的。
接口隔离原则要求我们对接口尽可能地细粒度化,小接口总比大接口要好。比如我们都知道Runnable接口,它只要求实现类完成run方法即可,不会把不相干的行为也给加进来,所以它只是定义至其力所能及的范围,点到为止。其实接口隔离原则与单一职责原则如出一辙,只不过是对高层行为能力的细粒度化规范,这非常好理解,分开的容易合起来,但合起来的就不好分开了,请记住,分开容易合起来难。接口隔离原则能很好地避免接口被设计地过于臃肿,轻量化接口更不会造成对实现类的污染,使系统模块间依赖变得更加松散、灵活。
依赖倒置
依赖倒置是指出只依赖抽象而不依赖具体实现,从而达到降低客户端对其他模块耦合的目的。我们知道客户端类要访问另一个类,传统做法是直接访问其方法,这就导致对实现类的强耦合,而依赖倒置的做法是反其道而行,间接地访问实现类的高层抽象,依赖高层比依赖底层实现要灵活得多,这也印证了我们在里氏替换里提到的”针对抽象编程“。
举个例子,公司CEO制定新一年的策略及目标,为提高产出效率决定年底要上线一套全新的OA办公自动化软件。那么CEO作为客户端要怎么实施这个计划?发动基层程序员们并调用他们的研发方法吗?我想世界上没有以这种方式管理公司的CEO吧,作为高层领导一定是调用高层抽象,大手一挥调用IT部门接口的work方法并传入目标即可。至于这个work方法的实现是公司程序员去研发写代码实现,或是找外包公司项目承包,甚至是直接找成熟的产品直接购买,CEO完全不必操心,这时就达到了与具体实现类解耦的目的,不合适还可以随意灵活替换,这就是把“依赖底层”倒置为“依赖高层”的好处。
我们在做开发的时候常常会从高层往底层写代码,例如从业务逻辑层的时候我们大可不必过多关心数据源是什么,是文件还是数据库,是MySQL还是Oracle。所以我们可以调用数据访问接口,而其实现类可以暂且不写或者写一个模拟实现类用来单元测试,甚至可以交给熟悉的同事并行开发,只要是定义了良好的接口规范就不必关心底层实现细节,依赖高层抽象,不依赖底层具像,这就是依赖倒置原则的核心思想,从具象到抽象的倒置。
迪米特法则
迪米特法则或者被称为最少知识原则主要是通过最小化各模块间的通信、而割裂模块间千丝万缕的不必要联系,以达到松耦合的目的。迪米特法则提出一个模块对其他模块要知之甚少、拒绝陌生人、只和熟人交谈,否则对一个类的变动将引发蝴蝶效应般的连锁反应,这会波及到大范围的变动,系统可维护性差。
举个例子,我们买了一台游戏机,它像一给黑盒子内部集成了非常复杂的电路以及各种电子元件并且对外开放了手柄控制接口,这便是一个完美的封装。对于我们用户来说只需要用手柄操作就可以了,至于其内部的那些磁盘载入、内存读取、CPU指令接收、显卡显示等等我们是完全陌生的,也并不会去直接调用,这就是用户的正确使用方法。
再以门面模式为例,我们如果去办理一项业务,在业务大厅里要排队、填表、递交、盖章等等。这么一来我们就得了解每个窗口所需哪些材料,怎么样的办理流程,对于一个从来没办理过这个业务的人来说一定会来来回回折腾。对于这种陌生的事务处理应该交给专业的接待员这个”门面“来解决,这个角色与导游有异曲同工之秒,我们只需简单地把材料递交给他们就行了,我们只和”门面“通信,至于门面怎样去走的流程我们知之甚少,这是门面封装好的内部事务,我们更没有必要去亲自处理。
此外还有像中介模式、适配器模式等等都好像是给陌生人搭桥一样的松耦合典范。系统模块应该隐藏内部机制,大门一定要紧锁,防止陌生人随意访问,而对外只暴露适度地接口,这样才能保证模块间的最少知识通信,切勿越级汇报,禁止跨界、干涉他人内务,让模块间调用变得”傻瓜化“,即开即用,使模块间降低耦合性,提高软件系统的可维护性、可扩展性。
常道
软件设计绝不能不切实际而刻板生硬地套用模式,其实有时并不适用,也许本来几个类就可以解决的的需求非要拆成几十个角色类,结果适得其反,很简单的一个系统搞得臃肿不堪,生搬硬套的设计模式反倒变成一种鸡肋。其实各设计模式之间都是有共通之处的,有些看起来十分类似但又能解决不同的问题,套路当然有类似之处了,即便作为灵魂的设计原则也隐隐约约有着千丝万缕的关联,其实他们往往是相辅相成、互相印证的。所以我们不必过度纠结,把他们机械式地分门别类、划清界限。需求虽然是多变的,但一个系统不可能不做修改就满足所有变化,我们需根据当下以及可以预估的未来变更运用恰当的模式,适可而止,以不变应万变才不至于过度设计,模式泛滥。
或许工作多年后我们忘掉了那些设计模式的定义,又或者把模式名字混淆了,其实对设计模式的思想真谛来说,它叫什么名就显得不那么重要了,正所谓“道可道,非常道;名可名, 非常名。”,在实际应用中能快速解决当下问题才是最务实的工作态度。直到有一天,我们设计出的系统也许用到了某个模式的变种,又或许是几个设计模式巧妙地组合运用,被问及运用了何种设计模式时答曰“无名”。
当我们突招式的牵绊,概念的划分边界变得模糊,你中有我我中有你的套路变得浑然一体,这才是真正做到了得心应手、挥洒自如。真正的高手一定是手中无剑,心中有剑,达到无剑胜有剑,无招胜有招的最高境界。
原文参考公众号【Java知音】