SOLID原则是面向对象设计和编程的5个核心原则。
1 SRP - 单一职责原则
定义
任何一个模块都应该有且仅有一个被修改的原因。
该设计原则是基于康威定律的一个推论--一个软件系统的最佳结构高度依赖于开发这个系统的组织的内部结构。系统的用户或者使用者就是该设计原则中所指的“被修改的原因”。也可以这样描述:任何一个软件模块都应该只对一个用户(user)或系统利益相关者(stakeholder)负责。用户和系统利益相关者在用词上不准确。可以将希望对系统进行的变更相似的人进行分类,成为行为者。SRP就变成:任何一个软件模块都只对某一类行为者负责。
如果一个类承担的职责过多,就等于把这些职责耦合在一起。一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意想不到的破坏。
什么是职责
在SRP中,职责被定义为“变化的原因”。如果有多于一个动机去改变一个类,那么这个类就有多于一个的职责。多个职责是否应该被分开呢?这依赖于应用程序变化的方式。如果应用程序的变化只影响一个职责,应该被分离。如果应用程序的变化总是导致多个职责同时变化,就不必分离它们。
变化的轴线仅当变化实际发生时才具有真正的意义。如果没有征兆,就去应用SRP或者任何其他任何原则,都是不明智的。
分离耦合的职责
多角色对象会实现多个角色接口,或许是不希望的,但是或许是必要的。可以把多角色对象看作一个杂凑物(kludge)。然而,请注意所有的依赖关系都与它无关。谁也不需要依赖于它。除了main外,谁也不知道它的存在。多角色对象是一个不可分割的完整组件,但对外提供不同的服务。不同类型的客户使用不同的服务,完全无需知道其它服务的存在,也无需知道背后是谁再为自己提供服务。
2 OCP - 开发封闭原则
描述
设计良好的软件应该应该易于扩展,同时抗拒修改。
遵循开发封闭原则设计出的模块有两个主要特征:
1 对扩展是开放的
这意味着模块的行为是可以扩展的。当应用的需求改变时,可以对模块进行扩展,使其具有那些满足改变的新行为。即可以通过扩展改变模块的功能。
2 对于修改是封闭的
对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。
关键是抽象
遵循OCP
一般而言,无论模块是多么的封闭,都会存在一些无法封闭的变化。没有对于所有的情况都贴切的模型。设计人员必须对模块应该应对哪种变化做出选择,必须先预测出最有可能发生的变化方向,然后构造抽象来隔离变化。这需要设计人员具备一些从经验中获得的预测能力。这并不容易做到。大多数情况下都会预测失败。
同时,遵循OCP的代价也是昂贵的。创建正确的抽象是需要花费时间和精力的。同时,这些抽象也增加了软件设计的复杂性。开发人员有能力处理的复杂性也是有限的。我们把OCP的应用限定在可能发生的变化上。
应用OCP的时机
过早的应用OCP,会使软件具有不必要的复杂性。为了防止这些不必要的复杂性,我们会允许自己被愚弄一次。在最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后的同类变化。简而言之,我们愿意被第一颗子弹击中,然后确保自己不会被同一支枪发出的其他子弹击中。
更早发现变化的手段
变化发生的越早越快,对我们越有利。希望在开发工作展开不久就知道可能发生的变化。查明可能发生的变化所等待的时间越长,要创建正确的抽象就越困难。
刺激变化的手段:
a. 首先编写测试。测试描绘了系统的一种使用方法。在一个具有可测试性的系统发生变化时,我们可以坦然对之。因为我们已经构建了使系统可测试的抽象。
b. 使用更短的迭代周期开发。
c. 在加入基础结构前就开发特性,并且经常性地把这些特性展示给相关人员。
d. 首先开发最重要的特性。
e. 尽早的,经常性的发布软件。尽可能快的,尽可能频繁的把软件展示给客户和使用人员。
3 LSP - 里氏替换原则
描述
子类型(subtype)必须能够替换掉它们的基类型(base type)。
如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。
LSP是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的规则。
IS-A是关于行为的
对象的行为方式才是软件真正关注的问题。OOD中的IS-A关系是就行为方式而言的,行为方式是可以进行合理假设的,是客户程序所依赖的。长方形&正方形问题就是很好的例子。
基于契约设计
契约是通过为每个方法声明的前置条件和后置条件来指定的。要是一个方法得以执行,前置条件必须为真。执行完毕后,该方法要保证后置条件为真。客户代码的编写者可以通过契约获取可以依赖的行为方式。
当通过基类的接口使用对象时,用户只知道积累的前置条件和后置条件。派生类必须接受基类可以接受的一切。
C++/java没有此特性。
启发式规则和习惯用法
有一些简单的启发规则可以提供违反LSP的提示。这些规则都和以某种方式从基类中去除功能有关。完成功能少于基类的派生类通常是不能替换基类的,因此就违反了LSP。
派生类中的退化函数:存在退化函数并不总是违反了LSP,但是值得注意一下。
从派生类中抛出异常:在派生类的方法中添加了基类不会抛出的异常。要么使用者可以处理这种异常,要么派生类不应该抛出这种异常。
4 ISP - 接口隔离原则
描述
不应该强迫客户依赖于他们不用的方法。
该原则主要告诫设计师应该在设计中避免不必要的依赖。如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着由于这些未使用的方法变化所带来的变更。这无意中导致了所有客户程序之间的耦合。
类接口与对象接口
假设一个具有两个独立的接口,由两个独立的客户所使用的对象。因为实现这两个接口要操作同样的数据,所以这两个接口必须在同一个对象中实现。那么怎样才能遵循ISP呢?
方法一:使用委托分离接口。创建一个派生自某个接口的对象,并把该对象的请求委托给另一个接口的对象。
方法二:使用多重继承分离接口。创建一个同时实现多个接口的对象,各个客户程序通过分离的角色接口使用同一个对象。优先选择这个解决方案。
5 DIP - 依赖反转原则
描述
a. 高层模块不应该依赖于底层模块,二者都应该依赖于抽象。
b. 抽象不应该依赖于细节,细节应该依赖于抽象。
该设计原则指出高层策略性的代码不应该依赖实现低层细节的代码。
倒置的含义:结构化设计倾向于高层模块依赖于底层模块,依赖关系和控制流是一致的。而设计良好的面向对象程序的依赖结构与传统的结构化设计是相反的,即所谓的“倒置”。
倒置的接口所有权
高层模块为它所需要的服务声明一个抽象接口,低层模块实现抽象接口。这里的倒置不仅仅是依赖关系的倒置,也是接口所有权的倒置。通常认为工具库应该拥有自己的接口。但是应用DIP时,往往是客户拥有抽象接口,而服务者从接口派生。