软件设计模式中应该遵守的SOLID原则

  《敏捷软件开发:原则、模式与实践》和可能又是一本极易被埋没的面向对象软件设计的经典之作,本人也是在闲暇时候逛论坛偶然看见有人倾力推荐后才主动了解到它的,而之所以说容易被埋没,是因为这本书的名字很容易让别人感觉是一本专门讲授敏捷开发的知识,而大家心里其实都很清楚:敏捷开发在中国这种大量民工级别的程序员参与开发的形式下是很难实施的。曾记得在2012年我在公司实习的时候,公司总部就部署各个分舵组织学习Agile和看板这些开发和管理技术,不过形式上走过场之后就没有然后了,根本无法实施。
  言归正题,这本书还是说了不少敏捷开发、测试驱动开发的相关知识和案例演示,但目前为止最吸引我的是里面罗列的面向对象软件设计原则和各种设计模式的灵活运用,总体来说比GoF的经典《设计模式》要容易消化接收一些,书中的样例很多都是使用C++实现的,尽管也有一些是使用Java实现的,不过这两种语言基本同根,所以理解起来也不是特别的费劲。总体评价是:这是一本很值得慢慢研磨品味的书。
  下面的内容是介绍面向对象软件设计中所需要遵守的五个原则,他们有时候也被统称为S.O.L.I.D原则,分别是:单一功能原则、开闭原则、里氏替换原则、接口隔离原则和依赖翻转原则。如果说设计模式是软件开发中小巧灵活、功能实用的瑞士军刀,那么这里的软件设计原则就是军刀的灵魂。而且Uncle Bob也在书中说到,经验丰富的开发者遇到一个场景的时候很可能立马熟悉的设计模式就复现在脑海中了,其实有时候也不必一定要寻找某个设计模式来套当前的项目,而是朝着软件的需求方向,按照面向对象软件设计的原则去不断重构、演化项目的代码,那么最后的可能会发现模块自然而然地就会演化接近某一个设计模式了,毕竟那些设计模式也是从实践中提炼出来的,实践开发中也应该回归设计模式。

一、单一职责原则 (Single Responsibility Principle, SRP)

  就一个类而言应该仅有一个引起它变化的原因。
  因为类的职责会随着需求的变化而发生变化,同时每一个职责都是变化的一个轴线,如果一个类承担的职责过多,那么这些职责就会被耦合在一起,一个职责的变化可能会削弱甚至抑制这个类完成其他职责的能力。
  在SRP中将“引起变化的原因”定义为职责。在现实中,我们通常习惯于以组的形式(而不是变化的原因)去考虑职责进行归类,比如通常会将Modem的所有接口组合起来放在一起,因为他们都和Modem相关,不过这个Modem具有连接管理、数据通信两个职责,后续会因为两者中的任意一个发生变化都,则所有依赖这两个职责中的任何一个都迫使我们需要进行代码审查、测试验证、部署等操作,在用户的角度看来这两个职责被耦合在了一起。不过该原则需要预先推断出变化的情况,如果不会发生变化,或者应用程序的变化总是导致两个职责同时发生变化,那么就没有必要分离他们了。
  还有一种违反SRP的是经常变动的职责和不会频繁(甚至不会)变动的模块混合在一起的情形,比如业务模块和持久化子系统就是典型的变化频率和原因不相同的模块,这个时候就推荐使用FACADE或者PROXY模式重构分离两个职责。

二、开放-封闭原则 (Open–closed Principle, OCP)

  软件中的实体(类、模块、函数等)应该是可以扩展的,但是不可以修改的。
  OCP原则就是建议在对系统有修改的情况下对系统进行重构,使得以后对系统再有进行类似改动需求的时候,通常就只需要添加新的代码,而不用改动已经正常运行的代码就可以完成。这一原则意味着模块对于扩展是开放的,但是对于更改是封闭的。
  在C++中可以通过抽象基类创建出固定并且能描述一组任意个可能行为的接口,这一组任意个可能的行为则表现为可能的派生类。让模块依赖于一个抽象体,那么它对于更改就是关闭的;同时通过从这个抽象体产生新的派生类,就可以扩展该模块的功能。这里模块作为抽象类的客户,其耦合关系要比抽象类与派生类的关系更密切,所以抽象类通常以客户相关的方式来进行命名,比如ClientInterface。
  除了上面使用派生类和虚函数的机制之外,还可以使用TemplateMethod模式来实现满足OCP的原则。这种结构中提供实现了某种策略的共有函数并调用某些抽象接口完成某些功能,这些抽象接口也是策略类本身的一部分,然后通过从策略类产生派生类的方式,就可以在派生类中对策略类中指定的抽象接口进行实现或者扩展。
  上面两种方式都是让代码满足OCP原则的常用方法,可以把一个功能的通用部分和实现细节清晰地分离出来。
  如果软件开发的代码充斥着大量的if/else或者switch的情况,并且增加一个功能或实例的时候需要在代码很多地方做几乎类似的修改,那么这种代码很有可能违反了OCP。通常在开始开发的时候对于这种修改可能没有很强的洞察力,不过一旦发生第一次修改需求的时候(只接受一次愚弄),就需要考虑这种变化是不是会以类似的方式经常变动,如果是就对其执行抽象隔离的重构。我们总应该对程序中呈现出频繁变化的那些部分作出抽象,拒绝不成熟的抽象。

三、Liskov替换原则 (Liskov Substitution Principle, LSP)

  子类型在程序中必须能够替换掉他们的基类型。
  比如函数f接收类型B的指针(pointer)或引用(reference)作为参数,如果将B的派生类D的指针或者引用传递给f会导致错误的行为,那么该设计就违反了LSP原则。违反LSP的代码通常会在程序中使用RTTI机制,通过显式if/else语句来确定一个对象的类型,以便选择针对该类型的正确行为。这种代码需要在使用的地方知道所有派生类的类型,而且产生新派生类型的时候都要关注甚至修改相应的代码,显然是无法维护甚至出错的。
  LSP原则使得子类型可以替换基类,才使得使用基类的模块在无需修改的情况下就可以进行扩展,因此LSP也是使OCP成为可能的重要原则之一。导致违反LSP原则的主要原因是面向对象设计中”IS-A”的含义过于宽泛,对于一个自相容的设计未必和所有的用户程序相容,所以后续从客户的角度来观察,就会发现他们不满足”IS-A”的惯性而导致的问题。所以如果只是孤立的来看,很多设计可能是合理的,但是模型的有效性只能通过它的客户程序来表现。
  因为用户的行为是无法预知的,解决上述问题可以用所谓的基于契约设计(Design by Contract)来显式的规定针对该类的契约,那么客户代码就可以依据契约确定该类可以依赖的行为。契约通过为每个方法声明前置条件和后置条件来指定,一个方法要得以执行则前置条件必须为真,执行完毕后要保证后置条件也为真;当基于契约设计用于派生类层次的时候,在重新声明派生类的例程中,只能使用相等或者更弱的前置条件来替换原始前置条件,只能使用相等或者更强的后置条件来替换原始的后置条件,解释说来:派生类对象不能期望用户遵从比基类更强的前置条件,派生类必须接收基类可以接受的一切,同时派生类的行为方式和输出不能违反基类已经确立的任何限制,基类用户不应该被派生类的输出所扰乱,所以就是宽进严出
  另外一种情况是在派生类抛出了基类中没有抛出的异常类型,这也是违反LSP原则的行为。

四、接口隔离原则 (Interface Segregation Principle, ISP)

  不应该强迫客户依赖于它不使用的方法。
  如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着因为这些未使用方法的改变而带来的变更。
  在开发中当需要向某个类增加新功能的时候,通常都会直接继承某个功能的接口,而在接口覆盖实现中可以方便的操作这个类的所有数据,所以这也是最简便的实现方式。不过一旦B类继承某个功能类F,则F的变更就会影响到B类,而添加这个功能只给B的少数派生类带来好处的话,那么我们就认为B类的接口被污染了,而且随着更多的功能以这种方式能添加到这个类,接口污染的情况就会越来越严重,整个类接口也越来越“胖”了。而且针对B越来越胖的接口,如果子类不需要使用这些功能的话,就需要提供一个缺省或者退化的实现,这违反了LSP原则;同时任何一个功能发生变更,其他没有使用该功能的子类也都会受到影响。
  其实,一个对象的客户不必一定通过该对象的接口去访问它,也可以通过委托或者通过该对象的基类去访问它。这里还是以TimedDoor为例:
  使用委托分离接口
  通常通过创建一个DoorTimeAdapter类继承自功能类TimerClient,同时在构造函数中持有TimedDoor类的指针或者引用,这样在TimeOut覆盖实现中将请求转发委托给TimedDoor类的实现就可以了,如果需要多个定时器,则创建多个DoorTimeAdaptor对象并依次调用Timer::Register就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TimedDoor: public Door {
public:
virtual void DoorTimeOut(int timeOutId);
}

class DoorTimeAdaptor: public TimerClient {
public:
DoorTimerAdapter(TimedDoor& theDoor) : itsTimedDoor(theDoor) {}
virtual void TimeOut(int timeOutId) {
itsTimedDoor.DoorTimeOut(timeOutId);
}
private:
TimedDoor& itsTimedDoor;
}

  使用多重继承分离接口

1
2
3
4
class TimedDoor: public Door, public TimerClient {
public:
virtual void DoorTimeOut(int timeoutID);
}

  通过这种多继承,TimedDoor既可以像Door一样被客户端使用,也可以像TimerCient一样被客户端使用,而且TimedDoor类中可以灵活的注册任意数目的定时器,推荐优先使用这种方法。
  客户程序应该只依赖于它们实际调用的方法,通过把胖类的接口分解为多个特定于客户程序的接口,就可以实现这个目标。每个特定于客户程序的接口仅仅声明它的特定客户或者客户组所调用的那些函数,接着胖类就可以依次继承所有特定于客户程序的接口并实现他们,而客户程序和他们没有调用的方法的依赖关系就被隔离了。

五、依赖倒置原则 (Dependency Inversion Principle, DIP)

  a. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
  b. 抽象不应该依赖于细节,细节应该依赖于抽象。
  在传统的软件设计者通常的结构化分析和设计方法,总是倾向于创建一些高层模块依赖于低层模块、策略依赖于细节的软件结构,这些方法就是要定义子程序的层次结构,这个层次结构描述了高层模块如何调用低层模块。
  但是高层模块包含了应用程序中重要的策略选择和业务模型,所以高层模块应该优先并独立于包含实现细节的模块,否则对低层的改动就需要高层模块依次作出改动,那么整个层次结构就会是易变的。相反,如果高层不依赖于低层模块,那么高层就非常容易的被重用,就很容易形成可重用的框架(framework),促成在变化面前富有弹性的代码。
  正确的设计方法是每个较高层次的模块都为它所需要的服务声明一个抽象接口,较低层次模块实现这些抽象接口,较高层次通过该抽象接口使用下一层次,这样高层就不依赖于低层次了,而低层反而依赖于高层中声明的抽象服务接口。这里的倒置不仅仅是依赖关系的倒置,更是接口所有权的倒置,因为当使用DIP的时候,通常是客户定义拥有抽象接口,而他们的服务者则从这些抽象接口派生并实现之。
  程序中的依赖关系都应该终止于抽象类或者接口,意味着:任何变量都不应该持有一个指向具体类的指针或者引用;任何类都不应该从具体类派生;任何方法都不应该覆写它任何基类中已经实现的方法。不过上述原则是针对于经常变动类的情况,如果一个具体类不太会改变,且也不会创建一系列类似的派生类,那么依赖于这种稳定类也不会产生什么不良影响。
  上述依赖于接口的方法都是使用C++的虚函数机制来实现抽象隔离的,其实C++还具有使用模板的方法来达到静态形式的多态性。通过模板参数的形式,同样可以实现依赖关系的倒置,而且这种方式性能好、没有动态多态性的额外开销。不过通过模板实现的多态性不支持在运行时候更改模板参数类型,而且产生新的类型就需要重新编译部署对应模块,建议除非有严格的速度性能要求,否则优先使用基于虚函数的动态多态性。
  如果程序的依赖关系是倒置的,那么正是面向对象设计的标识,否则就是过程化的设计方式了。

PS:之前的软件开发基本都是单体开发形式,项目和代码的规模都是十分庞大的,所以这类设计原则和设计模式对于整个项目的维护是十分重要的。但是现代的软件开发越来越讲求微服务、逻辑拆分,因此之前的大型项目演变到现在基本都是几个甚至几十个小型项目组合实现的,相对来说小型项目无论是更改、甚至是重构都方便一些了,至少感觉现在很多项目都是讲求上线速度,而项目设计本身显得越来越次要了的感觉。
  不过话说回来,需求的更改最终还是要有项目的开发和维护人员来实现,所以不要低估软件设计的重要性,善用软件设计就是善待我们自己!
  后面有时间再研究这本书中对设计模式的运用经验。

本文完!

参考