S(单一职责原则)ingle Responsibility Principle
修改一个类的原因只能有一个。
尽量让每个类只负责软件中的一个功能,并将该功能完全封装在该类中。
这条原则的主要目的是减少复杂度。你不需要费尽心机地去构思如何仅用 200 行代码来实现复杂设计,可以使用十几个清晰的方法。
当程序规模不断扩大后,问题才会逐渐显现出来。到了某个时候,类会变得过于庞大,以至于你无法记住细节。查找代码将变得非常缓慢。
还有一点:如果类负责的东西太多,其中任何一件事发生改变时,你都必须对类进行修改。而修改时可能改动类中并不希望改动的部分。
示例
我们有几个理由来对雇员类进行修改。第一个理由与该类的主要工作(管理雇员数据)有关。但还有另一个理由:时间表报告的格式可能会随着时间而改变,从而使你需要对类中的代码进行修改。
解决方法是将与打印时间表报告相关的行为移动到一个单独的类中。这个改变让你能将其他与报告相关的内容移动到一个新的类中。
O(开闭原则)pen/Closed Principle
对于扩展,类应该是“开放”的;对于修改,类则应 是“封闭”的。
本原则的主要理念是在实现新功能时能保持已有代码不变。
如果对一个类进行扩展,可以创建子类并对其做任何事情(如新增方法或成员变量、重写基类行为等),那么它就是开放的。有些编程语言允许通过关键字(例如 final)限制对于类的进一步扩展,类就不再是“开放”的了。如果某个类其接口已明确定义且以后不会修改,那么该类就是封闭(你可以称之为完整)的。
如果一个类已经完成开发、测试和审核工作,而且属于某个框架或者可被其他类的代码直接使用的话,对其代码进行修 改就是有风险的。你可以创建一个子类并重写原始类的部分内容以完成不同的行为,而不是直接对原始类的代码进行修改。
示例
电商程序中包含一个计算运输费用的订单类,该类中所有运输方法都以硬编码的方式实现。如果需要添加新的运输方式,就必须承担对订单类造成破坏的可能风险。
可以通过应用策略模式来解决这个问题。首先将运输方法抽取到拥有同样接口的不同类中。
现在,当需要实现一个新的运输方式时, 你可以通过扩展 Shipping 接口来新建一个类, 无需修改任何订单类的代码。
此外,根据单一职责原则,这个解决方案能够让你将运输时间的计算代码移动到与其相关度更高的类中。
L(里氏替换原则)iskov Substitution Principle
扩展一个类时, 应该要能在不修改客户端代码的情况下将子类的对象作为父类对象进行传递。
这意味着子类必须保持与父类行为的兼容。在重写一个方法时,你要对基类行为进行扩展,而不是将其完全替换。
替换原则是用于预测子类是否与代码兼容,以及是否能与其超类协作的一组检查。这一概念在开发程序库和框架时非常重要,因为其中的类将会在他人的代码中使用——你是无法直接访问和修改这些代码的。
替代原则包含一组对子类的形式要求:
- 子类方法的参数类型必须与其超类的参数类型相匹配或更加抽象。让我们来看一个例子。
- 假设某个类有个方法用于给猫咪喂食: feed(Cat c) 。客户端代码总是会将“猫”对象传递给该方法。
好的方式:假如你创建了一个子类并重写了前面的方法, 使其能够给任何“动物(‘猫’的超类)”喂食: feed(Animal c) 。如果现在你将一个子类对象而非超类对象传递给客户端代码,程序仍将正常工作。该方法可用于给任何动物喂食,因此它仍然可以用于给传递给客户 端的任何“猫”喂食。不好的方式: 你创建了另一个子类且限制喂食方法仅接受 “孟 加 拉 猫(‘猫’ 的 子 类)”: feed(BengalCat c) 。如果你用它来替代链接在某个对象中的原始类,客户端中会发生什么呢?由于该方法只能对特 殊种类的猫进行喂食,因此无法为传递给客户端的普通猫提供服务,从而将破坏所有相关的功能。
- 子类方法的返回值类型必须与超类方法的返回值类型或是其子类别相匹配。对于返回值类型的要求与对于参数类型的要求相反。
- 假如你的一个类中有一个方法 buyCat(): Cat 。 客户端代码执行该方法后的预期返回结果是任意类型的“猫”。
好的方式:子类将该方法重写为: buyCat(): BengalCat 。 客户端将获得一只“孟加拉猫”,自然它也是一只“猫”, 因此一切正常。不好的方式: 子类将该方法重写为: buyCat(): Animal 。 现在客户端代码将会出错,因为它获得的是自己未知的动物种类,不适用于为一只“猫”而设计的结构。
- 子类中的方法不应抛出基础方法预期之外的异常类型。换句话说,异常类型必须与基础方法能抛出的异常或是其子类别相匹配。客户端代码的 try-catch 代码块针对的是基础方法可能抛出的异常类型。预期之外的异常可能会穿透客户端的防御代码,从而使整个应用崩溃。
- 对于绝大部分现代编程语言, 特别是静态类型的编程语言,这些规则已内置于其中。如果违反了这些规则,你将无法对程序进行编译。
- 子类不应该加强其前置条件。例如,基类的方法有一个 int 类型的参数。如果子类重写该方法时,要求传递给该方法的参数值必须为正数(否则抛出异常),这就是加强了前置条件。客户端代码之前将负数传递给该方法时程序 能够正常运行,但现在使用子类的对象时会使程序出错。
- 子类不能削弱其后置条件。假如你的某个类中有个方法在接收到返回值后关闭所有数据库连接。你创建了一个子类并对其进行了修改,使数据库保持连接以便重用。但客户端可能对你的意图一无所知。由于它认为该方法会关闭所有的连接,因此可能会在调用该方法后就马上关闭程序,使得无用的数据库连接对系统造成“污染”。
- 超类的不变量必须保留。不变量是让对象有意义的条件。例如,猫的不变量是有四条腿、一条尾巴和能喵喵叫等。不变量让人疑惑的地方在于它们既可通过接口契约或方法内的一组断言来明确定义,又可暗含在特定的单元测试和客户代码预期中。 不变量的规则是最容易违反的,因为你可能会误解或没有意识到一个复杂类中的所有不变量。因此,扩展一个类的最安全做法是引入新的成员变量和方法,而不要去招惹超类中已有的成员。当然在实际中,这并非总是可行。
- 子类不能修改超类中私有成员变量的值。有些编程语言允许通过反射机制来访问类的私有成员。还有一些语言(Python 和 JavaScript)没有对私有成员进行任何保护。
示例
只读文件子类中的 save 保存方法会在被调用时抛出一个异常。 基础方法则没有这个限制。如果没有在保存前检查文档类型,客户端代码将会出错。
代码也将违反开闭原则,因为客户端代码将依赖于具体的文档类。如果你引入了新的文档子类,则需要修改客户端代码 才能对其进行支持。
你可以通过重新设计类层次结构来解决这个问题:一个子类必须扩展其超类的行为,因此只读文档变成了层次结构中的 基类。可写文件现在变成了子类,对基类进行扩展并添加了保存行为。
I(接口隔离原则)nterface Segregation Principle
客户端不应被强迫依赖于其不使用的方法。
尽量缩小接口的范围,使得客户端的类不必实现其不需要的行为。
示例
假如你创建了一个程序库,它能让程序方便地与多种云计算供应商进行整合。尽管最初版本仅支持阿里云服务,但它也 覆盖了一套完整的云服务和功能。
假设所有云服务供应商都与阿里云一样提供相同种类的功能。但当你着手为其他供应商提供支持时,程序库中绝大部分的接口会显得过于宽泛。其他云服务供应商没有提供部分方法所描述的功能。
尽管你仍然可以去实现这些方法并放入一些桩代码,但这绝不是优良的解决方案。更好的方法是将接口拆分为多个部分。能够实现原始接口的类现在只需改为实现多个精细的接口即可。其他类则可仅实现对自己有意义的接口。
D(依赖倒置原则)ependency Inversion Principle
高层次的类不应该依赖于低层次的类。两者都应该依赖于抽象接口。抽象接口不应依赖于具体实现。具体实现应该依赖于抽象接口。
通常在设计软件时,你可以辨别出不同层次的类。
- 低层次的类实现基础操作(例如磁盘操作、传输网络数据和 连接数据库等)。
- 高层次类包含复杂业务逻辑以指导低层次类执行特定操作。
有时人们会先设计低层次的类,然后开发高层次的类。如果采用这种方式,业务逻辑类可能会更依赖于低层原语类。
依赖倒置原则建议改变这种依赖方式。
- 最好使用业务术语来对高层次类依赖的低层次操作接口进行描述。 例如,业务逻辑应该调用名为 openReport(file) 的方法, 而不是 openFile(x)、readBytes(n)等一系列方法。 这些接口被视为是高层次的。
- 现在你可基于这些接口创建高层次类,而不是基于低层次的具体类。这要比原始的依赖关系灵活很多。
- 一旦低层次的类实现了这些接口,它们将依赖于业务逻辑层,从而倒置了原始的依赖关系。
依赖倒置原则通常和开闭原则共同发挥作用:你无需修改已 有类就能用不同的业务逻辑类扩展低层次的类。
示例
高层次的预算报告类使用低层次的数据库类来读取和保存其数据。这意味着低层次类中的任何改变(例如当数据库服务器发布新版本时)都可能会影响到高层次的类,但高层次的类不应关注数据存储的细节。
要解决这个问题,你可以创建一个描述读写操作的高层接口,并让报告类使用该接口代替低层次的类。然后你可以修改或扩展低层次的原始类来实现业务逻辑声明的读写接口。
其结果是原始的依赖关系被倒置:现在低层次的类依赖于高层次的抽象。