设计模式的SOLID五条原则

SOLID原则是在罗伯特·马丁的著作《敏捷软件开发:原则丶模式与实践》中首次提出的。SOLID原则是让软件设计更易于理解,更加灵活和更易于维护的五个原则的简称。这五个原则分别是:
单一职责原则(Single Responsibility Principle)
开闭原则(Open/Closed Principle)
里氏替换原则(Siskov Substitution Principle)
接口隔离原则(Interface Segregation Principle)
依赖反转原则(Dependency Inversion Principle)

单一职责原则(SRP)

单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是这样的:A class or module should have a single reponsibility。把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)
单一职责原则的定义描述非常简单,也不难理解。一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。
比如,一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。

还有就是:当程序规模不断扩大、 变更不断增加后, 真实问题才会逐渐显现出来。 到了某个时候, 类会变得过于庞大, 以至于你无法记住其细节。 查找代码将变得非常缓慢, 你必须浏览整个类, 甚至整个程序才能找到需要的东西。“程序中实体的数量会让你的大脑堆栈过载, 你会感觉自己对代码失去了控制。 如果类负责的东西太多, 那么当其中任何一件事发生改变时, 你都必须对类进行修改。 而在进行修改时, 你就有可能改动类中自己并不希望改动的部分。这个时候就应该考虑是否将某些类分割成几部分。一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。

如何判断类的职责是否足够单一?
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

  • 类中的代码行数、函数或者属性过多;
  • 类依赖的其他类过多,或者依赖类的其他类过多;
  • 私有方法过多;比较难给类起一个合适的名字;
  • 类中大量的方法都是集中操作类中的某几个属性。

类的职责是否设计得越单一越好?
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

开闭原则(OCP)

开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。你可以理解为我们在添加新功能的时候,应该是在已有的代码上进行扩展(新增类,模块,方法等),而不是去修改原有的代码(类,模块,方法等),关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
举个例子:在订单类order中我们有一个getShoppingCost()方法用来计算订单的运输费用,如下:

如果我们的需求上增加了新的运算方式,那么我们就需要在原有的方法上进行修改。我们对结构进行修改,将运送方式Shipping使用接口实现,如下。

通过扩展 运输方式Shipping接口来新建一个类, 无需修改任何 订单类的代码。 当用户在 UI 中选择这种运输方式时,订单类客户端代码会将订单链接到新类的运输方式对象。
如何做到“对扩展开放、修改关闭”?
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。

里氏替换原则(SSP)

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。这个原则最早是在 1986 年由 Barbara Liskov 提出,他是这么描述这条原则的:

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。

在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则,英文原话是这样的:

Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。

我们综合两者的描述,将这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
也就是说子类必须保持与父类行为的兼容。 在重写一个方法时, 你要对基类行为进行扩展, 而不是将其完全替换。
替换原则是用于预测子类是否与代码兼容, 以及是否能与其超类对象协作的一组检查。 这一概念在开发程序库和框架时非常重要, 因为其中的类将会在他人的代码中使用——你是无法直接访问和修改这些代码的

替换原则对子类(特别是方法)的一些要求:

  1. 子类方法的参数类型必须与其超类的参数类型相匹配或更加抽象。听上去让人迷惑? 让我们来看一个例子。
    假设某个类有个方法用于给猫咪喂食: feed(Cat c) 。 客户端代码总是会将 “猫 (cat)” 对象传递给该方法。

    **好的方式**: 假如你创建了一个子类并重写了前面的方法, 使其能够给任何 “动物 (animal, 即 ‘猫’ 的超类)” 喂食:  feed(Animal c) 。 如果现在你将一个子类对象而非超类对象传递给客户端代码, 程序仍将正常工作。 该方法可用于给任何动物喂食, 因此它仍然可以用于给传递给客户端的任何 “猫” 喂食。
    
    **不好的方式**: 你创建了另一个子类且限制喂食方法仅接受 “孟加拉猫 (`BengalCat`, 一个 ‘猫’ 的子类)”: feed(BengalCat c) 。 如果你用它来替代链接在某个对象中的原始类, 客户端中会发生什么呢? 由于该方法只能对特殊种类的猫进行喂食, 因此无法为传递给客户端的普通猫提供服务, 从而将破坏所有相关的功能。
    
  2. 子类方法的返回值类型必须与超类方法的返回值类型或是其子类别相匹配。 正如你所看到的, 对于返回值类型的要求与对于参数类型的要求相反。
    假如你的一个类中有一个方法 buyCat(): Cat 。 客户端代码执行该方法后的预期返回结果是任意类型的 “猫”。”

    **好的方式**: 子类将该方法重写为: buyCat(): BengalCat 。 客户端将获得一只 “孟加拉猫”, 自然它也是一只 “猫”, 因此一切正常。
    **不好的方式**: 子类将该方法重写为: buyCat(): Animal 。 现在客户端代码将会出错, 因为它获得的是自己未知的动物种类 (短吻鳄? 熊?), 不适用于为一只 “猫” 而设计的结构。
    
  3. 子类中的方法不应抛出基础方法预期之外的异常类型。 换句话说, 异常类型必须与基础方法能抛出的异常或是其子类别相匹配。 这条规则源于一个事实: 客户端代码的 try-catch代码块针对的是基础方法可能抛出的异常类型。 因此, 预期之外的异常可能会穿透客户端的防御代码, 从而使整个应用崩溃。
  4. 子类不应该加强其前置条件。 例如, 基类的方法有一个 int类型的参数。 如果子类重写该方法时, 要求传递给该方法的参数值必须为正数 (如果该值为负则抛出异常), 这就是加强了前置条件。 客户端代码之前将负数传递给该方法时程序能够正常运行, 但现在使用子类的对象时会使程序出错。
  5. 子类不能削弱其后置条件。 假如你的某个类中有个方法需要使用数据库, 该方法应该在接收到返回值后关闭所有活跃的数据库连接。你创建了一个子类并对其进行了修改, 使得数据库保持连接以便重用。 但客户端可能对你的意图一无所知。 由于它认为该方法会关闭所有的连接, 因此可能会在调用该方法后就马上关闭程序, 使得无用的数据库连接对系统造成 “污染”。<!--more-->
  6. 超类的不变量必须保留。 这很可能是所有规则中最不 “形式” 的一条。 不变量是让对象有意义的条件。 例如, 猫的不变量是有四条腿、 一条尾巴和能够喵喵叫等。 不变量让人疑惑的地方在于它们既可通过接口契约或方法内的一组断言来明确定义, 又可暗含在特定的单元测试和客户代码预期中。
    不变量的规则是最容易违反的, 因为你可能会误解或没有意识到一个复杂类中的所有不变量。 因此, 扩展一个类的最安全做法是引入新的成员变量和方法, 而不要去招惹超类中已有的成员。 当然在实际中, 这并非总是可行。

接口隔离原则(ISP)

接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为 ISP。Robert Martin 在 SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
根据接口隔离原则, 你必须将 “臃肿” 的方法拆分为多个颗粒度更小的具体方法。 客户端必须仅实现其实际需要的方法。 否则, 对于 “臃肿” 接口的修改可能会导致程序出错, 即使客户端根本没有使用修改后的方法。
继承只允许类拥有一个超类, 但是它并不限制类可同时实现的接口的数量。 因此, 你不需要将大量无关的类塞进单个接口。 你可将其拆分为更精细的接口, 如有需要可在单个类中实现所有接口, 某些类也可只实现其中的一个接口。
示例:
假如你创建了一个程序库, 它能让程序方便地与多种云计算供应商进行整合。 尽管最初版本仅支持阿里云服务, 但它也覆盖了一套完整的云服务和功能。
假设所有云服务供应商都与阿里云一样提供相同种类的功能。 但当你着手为其他供应商提供支持时, 程序库中绝大部分的接口会显得过于宽泛。 其他云服务供应商没有提供部分方法所描述的功能。

如上图所示并不是所有的接口方法都需要去实现,尽管我们仍然可以去实现这些方法并放入一些桩代码, 但这绝不是优良的解决方案。 更好的方法是将接口拆分为多个部分。 能够实现原始接口的类现在只需改为实现多个精细的接口即可。 其他类则可仅实现对自己有意义的接口。

如上图,修改后我们将一个复杂的接口拆分为一组粒度更小的接口。避免了实现类和调用类,依赖不需要的接口方法。
接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

依赖反转原则(DIP)

依赖反转原则。依赖反转原则的英文翻译是 Dependency Inversion Principle,缩写为 DIP。中文翻译有时候也叫依赖倒置原则。
为了追本溯源,我先给出这条原则最原汁原味的英文描述:

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

我们将它翻译成中文,大概意思就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

  • 低层次的模块实现基础操作 (例如磁盘操作、 传输网络数据和连接数据库等)。
  • 高层次模块包含复杂业务逻辑以指导低层次类执行特定操作。

简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。
有时我们会先设计低层次的类, 然后才会开发高层次的类。 当你在新系统上开发原型产品时, 这种情况很常见。 由于低层次的东西还没有实现或不确定, 你甚至无法确定高层次类能实现哪些功能。 如果采用这种方式, 业务逻辑类可能会更依赖于低层原语类。依赖反转原则建议改变这种依赖方式。
示例:
假如我们有一个高层次的预算报告类 (BudgetReport) 使用低层次的数据库类 (MySQLDatabase) 来读取和保存其数据。 这意味着低层次类中的任何改变 (例如当数据库服务器发布新版本时) 都可能会影响到高层次的类, 但高层次的类不应关注数据存储的细节。

在上图中:高层次的类依赖于低层次的类。要解决这个问题, 你可以创建一个描述读写操作的高层接口, 并让报告类使用该接口代替低层次的类。 然后你可以修改或扩展低层次的原始类来实现业务逻辑声明的读写接口。如下图:

修改后,低层次的类依赖于高层次的抽象。
参考文章:
对于单一职责原则,如何判定某个类的职责是否够“单一”?
如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?
里式替换(LSP)跟多态有何区别?哪些代码违背了LSP?
接口隔离原则有哪三种应用?原则中的“接口”该如何理解?
控制反转、依赖反转、依赖注入,这三者有何区别和联系?
参考书籍:
深入设计模式

Last modification:July 27th, 2020 at 02:33 pm