面向对象的设计原则-SOLID
单一职责原则(Single responsibility principle,SRP)
简介
就一个类而言,应该仅有一个引起它变化的原因。
如果一个类承担的职责过多,就等于把这些职责耦合在了一起,一个职责变化可能会抑制或消弱类完成其他职责的能力,这种耦合会导致脆弱的设计,当变化发生时,设计会遭到意想不到的破坏。
图 1 所示,Retangle 类具有两个方法,一个方法用来绘制矩形,一个方法用来计算矩形面积。有两个应用程序使用 Rectangle 类,只会使用 Rectangle 类计算矩形面积的方法,另外一个应用程序只会在屏幕上绘制矩形。
图1 多于一个的职责
Rectangle 类具有两个职责,违反了 SRP。带来的问题,首先,必须在 ComputationalGeometryApplication 中包含 绘制矩形 代码;其次,如果 GraphicalApplication 的改变由于一些原因导致 Rectangle 类的改变,这个改变需要重新构建、测试并部署 ComputationalGeometryApplication,否则 ComputationalGeometryApplication 可能会以不可预测的方式出现问题。
一个较好的设计是把这两个职责拆分到两个不同的类中,如图 2 所示,将Rectangle中计算逻辑移到 GeometricRectangle 类中,现在 GraphicalApplication 的改变不会对 ComputationalGeometryApplication 造成影响。
图2 分离的职责
SRP中,职责定义为「变化的原因」,如果有多于一个动机可以改变一个类,那么这个类就具有多于一个职责。程序 1 的 Modem 接口,接口声明的 4 个函数确实是 Modem 所具有的功能。但 Modem 接口有两个职责,dial 和 hangup 是用来连接管理,send 和 recv 是用来数据通信。
interface Modem {
void dial(String pno);
void hangup();
void send(char c);
void recv();
}
这两个两个职责是否应该分开呢?这依赖于程序变化的方式。如果应用程序变化会影响连接函数的签名,那么调用 send 和 recv 函数的类都需要重新测试、编译和部署,这样的设计就具有僵化性。这种情况下,这两个职责应该分开,如图 3 所示,这样就避免了客户应用程序和这两个职责耦合在一起了。
图3 分离的Modem接口
如果应用程序的变化方式总是导致这两个职责同时变化,那么就不必将这两个职责拆分开,拆分了之后会增加不必要的复杂性。
因此,「变化」只有「变化」实际发生时才具有意义,如果没有变化的征兆,那么去应用SRP活着其他原则都是不明智的。
单一职责原则(SRP)是其他原则的基础,是SOLID中最简单的原则,也是最难正确运用的原则,软件设计中许多工作就是发现职责并把这些职责互相分离。
开放-封闭原则(Open-Closed principle,OCP)
软件实体(类、模块、函数等)应该是可以扩展的,并且是不可修改的。
如果程序中一处改动就会产生连锁反应,导致一系列相关模块的改动,那么这样的设计是僵化的。OCP 建议对僵化的程序进行重构,如果正确的运用了 OCP,那么进行改动时,只需要添加代码,而不必改动已经正常运行的代码。
遵循开放-封闭原则而设计出的模块具有两个特征:
- 对于扩展开放(open for extension)。 模块的行为是可扩展的,当需求改变时,可以对模块进行扩展,使其具有满足那些改变的新行为。
- 对于修改关闭(closed for modification)。对模块进行扩展时,不必改动模块的源代码。
这两个特征好像是互相矛盾的,通常扩展模块行为的方法就是修改模块的源代码。怎样才能不改动模块源代码的情况下去更改模块的行为呢?关键是运用抽象。
抽象基类或接口可以描述一组行为,派生类或实现类可以继承基类或接口来实现接口的行为。模块可以依赖抽象基类或接口,由于模块依赖固定的抽象基类或接口,所以对于模块的更改可以是关闭的,通过从基类或接口来实现派生类或实现类,也可以扩展模块的行为。
图 4 即不开放有不封闭的 Client
图 4 展示了一个简单的不遵循 OCP 的设计,Client 类和 Server 类都是具体类,client 类依赖 Server 类,如果 Client 对象需要依赖其他的服务器对象,则需要把 Client 类中依赖 Server 类的地方更改给新的服务器类。
图 5 strategy模式,即开放又封闭的 Client
图 5 展示了遵循 OCP 的设计,Client 类依赖 IClient 接口,Server 类实现 IClient 声明的方法,如果 Client 对象需要依赖另外的服务器时,只需要从 IClient 接口重新实现一个新的类,无需修改 Client 类。
图 6 template method 模式,即开放又封闭的基类
图 6 展示了另一个可选的结构,Policy 类是一个抽象类,声明了公有的 policyFunction 方法,函数在子类中实现,这样可以通过从 Policy 类派生出新类的方式,对 Policy 中的行为进行扩展。
图 5 和图 6 两种模式是实现 OCP 的常用方法,通过这两种方法,可以将模块的通用部分和可能会改动的部分分离开来。
一般而言,无论模块多么「封闭」,都会存在一些无法对之封闭的变化,没有对所有情况都贴切的模型,设计人员必须对其设计的模块要对哪种变化封闭作出选择,必须先根据经验猜测出最有可能发生变化的种类,然后构造抽象来隔离变化。设计人员需要了解程序的用户和应用领域,以此来判断各种变化的可能性,让设计对于最有可能发生变化遵循 OCP。
通常很难判断变化的可能性,而且遵循 OCP 的代价也是高昂的,创建抽象需要花费精力和时间,抽象也增加了设计的复杂性。比起过渡设计而带来的不必要的复杂性来说,在变化发生时才应用 OCP 原则重构程序的方式可能会更好。
OCP 是面向对象设计的核心所在,遵循这个原则可以带来灵活性、可重用性以及可维护性,然而对程序中的每个部分都肆意的进行抽象同样不可取。正确的做法是,开放人员应该仅仅对程序中表现出频繁变化的那部分做抽象,拒绝不成熟的抽象和创建抽象一样重要。
里氏替代原则(Liskov substitution principle,LSP)
LSP 是关于基类继承(inheritance)的原则。Barbara Liskov 指出,若对于每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得对于所有针对 T 编写的程序 P 中,用 o1 替换 o2 后,程序 P 的行为功能不变,那么 S 是 T 的子类型。对里氏替代原则的简单解释是: 子类型(subtype)必须能够替换掉他们的基类型(base type)。
继承是 IS-A 的关系,如果一个新类型的对象和一个已知类型的对象之间满足 IS-A 关系,那么这个新对象的类应该是从这个已知对象的类派生的。
一般来说,正方形是一个(IS-A)矩形,因此 Square 类可以作为 Rectangle 类的子类,如图 7。
图 7 Square 类是 Rectangle 类的子类
这样的设计可能会存在一些问题,首先 Square 类并不需要同时具有 setWidth 和 setHeight 方法,而且对于 Square 类来说 width 和 height 应该始终是相同的,更为严重的是,对于程序 2 的测试用例来说,如果把 Square 作为测试的参数是错误的。
void test(Rectangle r) {
r.setWidth(4);
r.setWidth(5);
assert(r.area() == 20);
}
一个模型,如果孤立的看,并不具有真正意义上的有效性,模型的有效性是通过模型的使用者来表现的。例如,如果孤立的看,图 7 的模型是有效的,但是从模型的使用者来说,图 7 这个模型是有问题的,在考虑一个设计是否恰当时,不能完全孤立的看设计,必须要在设计的使用者的视角来审视这个设计。
对于图 7 的设计这来说,正方形可以是长方形,但对于使用这来说,Square 对象 绝对不是 Rectangle 对象,因为 Square 对象的行为方式和使用者所期望的 Rectangle 对象的行为方式不相容,从行为方式的角度来看,Square 不是 Rectangle,对象的行为方式才是软件设计真正需要关注的问题。LSP清晰的指出,面向对象设计的 IS-A 关系是就行为方式而言的,行为方式才是使用者所需要的。
依赖倒置原则(Dependency Inversion principle,DIP)
高层模块不应该依赖低层模块,二者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。
许多的传统软件开发方法,比如结构化分析和设计,总是倾向于创建一些高层模块依赖低层模块、业务逻辑依赖低层细节的软件结构,实际上些传统的开发方法的目的之一是要定义程序层次的结构,该层次结构描述了高层模块怎样调用低层模块。一个良好的面向对象的程序,其依赖层次结构相对于传统的过程式方法设计来说就是被「倒置」了。
如果高层模块直接依赖了低层模块,那么低层模块的改动就会直接影响高层模块,这种情形是非常荒谬的,本应该是高层模块去影响低层的细节实现模块,包含业务逻辑的高层模块应该优先并独立于包含细节的低层模块。而且如果高层模块依赖了低层模块,会导致重用高层模块难以被重用。所以高层模块不应该直接依赖于低层模块。
传统的分析和设计方法的知道思想是,「所有结构良好的面向对象架构都具有清晰的层次定义,每个层次通过一个定义良好的、受控的接口向外提供一组内聚的服务」。根据这个思想可能设计出类似于图 8 的接口,高层的 Policy Layer 依赖了低层的 Mechanism Layer,而 Mechanism Layer 又依赖了更细节的 Utility Layer。这样的设计使得高层模块 Policy Layer 对于其下一直到 Utility Layer 的改动都是敏感的。
图 8 简单的层次化方案
图 9 展示了依赖关系倒置的模型,每个较高层次 Policy Layer 和 Mechanism Layer 为其需要的服务声明了抽象接口,较低的层次 Mechanism Layer 和 Utility 实现了较高层次模块的接口,高层类通过抽象接口来使用下一层,这样高层不依赖低层,低层反而依赖于在高层中声明的抽象接口。依赖关系被倒置了,接口所有权也倒置了,我们通常认为工具库应该拥有自己的接口,但使用了 DIP 后,客户拥有抽象接口,而服务这需要从这些抽象接口中派生。
图 9 倒置的依赖关系
Hollywood 原则 —「don’t call us, we’ll call you」,低层模块实现了高层模块中声明的接口,通过接口所有权倒置, Mechanism Layer 和 Utility 的任何改动都不会再影响 Policy Layer,而且 Policy Layer 可以在实现了 IPolicyService 的任何上下文中重用。
对于 DIP 稍微简单的解释— 「依赖于抽象」。即不依赖于具体类,程序中的所有依赖关系都应该终止于抽象类或接口。任何变量都不应该持有一个指向具体类的指针或引用,任何类都不应该从具体类派生,任何派生类都不应该覆写基类中已实现的方法。
凡事皆有例外,程序中有时候必须要创建具体类的派生类;而且依赖具体但是稳定的类也不会造成什么问题。比如,Java 中直接依赖 String 类就不会造成什么问题。
然而我们我们程序中的大多数具体类都不是稳定的,我们不应该依赖于不稳定的具体类,通过抽象接口隐藏不稳定的具体类,可以隔离不稳定性。
图 10 展示了使用 Button 控制 Lamp 对象的模型,Button 接收 Poll 消息,然后向调用 Lamp 的 turnOn 或 turnOff 方法。
图 10 不成熟的 Button 和 Lamp 模型
图 10 模型对应的代码如 程序 3。Button 类直接依赖 Lamp 类,这个依赖关系意味着当 Lamp 类改动时 Button 类会受影响,而且要重用 Button 来控制另外的设备是不可能的。程序中高层和低层没有实现分离,抽象和具体也没有分离,高层依赖了低层模块,抽象依赖了具体细节。
public class Button {
private Lamp lamp;
public void poll() {
if (/* some condition */) {
lamp.turnOn();
}
}
}
通过倒置 Lamp 对象的依赖关系,得到图 11 的设计,Button 现在和 ButtonService 接口关联,ButtonService 声明了一些方法,Button 可以使用 ButtonService 的方法开启或关闭一些设备,Lamp 实现了 ButtonService 接口,这样 Lamp 不再被 Button 直接依赖,而且 Button 可以控制任何实现了 ButtonService 接口的设备。
图 11 对 Lamp 应用依赖倒置原则
面向对象的程序设计倒置了依赖关系,细节和高层模块都依赖于抽象,并且常常是接口使用方提供服务接口,即接口所有权也倒置了。首相和细节彼此隔离,代码也更容易维护。
接口隔离原则(Interface-Segregation principles,ISP)
不应该强迫客户程序依赖于它们不使用的方法。
接口隔离原则用来处理「胖接口」的缺点。如果接口不是内聚的,就表示该接口是「胖接口」。「胖接口」可以分解成多组方法,每组方法服务于一组不同的客户程序。
如果强迫客户程序依赖于它们不使用的方法,那么客户程序就可能会由于这些未使用的方法的改变而变更,无意中导致了所有客户程序之间的耦合。换句话说,如果一个客户程序依赖于一个包含它不使用的方法的类,但是其他客户程序却要使用这个方法,那么当其他客户要求这个类改变时,就会影响到这个客户程序。我们希望尽可能的避免这种耦合,因此需要分离接口。
设计一个安全系统,有一些 Door 对象,可以被加锁和解锁,并且 Door 对象知道自己的开关状态;设计一个 TimeDoor 对象,如果们开着时间过长,则发出警告声。所以 TimeDoor 对象需要和定时器 Timer交互。定时器 Timer 如程序 4。
public class Timer {
void register(int timeout, TimeClient client) {
// ...
}
}
public interface TimeClient {
void timeout();
}
怎么将 TimeClient 和 TimeDoor 联系起来呢?图 12 展示了一个易于理解的方案,让 Door 派生自 TimeClient,这样 TimeDoor 就自然的可以注册到 Timer 中,并接收到 Timeout 消息。
图 12
图 12 方案的主要问题是,Door 类需要依赖于 TimeClient 了,并不是所有的 Door 都需要定时功能,如果存在无需定时的 Door,那么在新的 Door 中需要提供 timeout 方法的退化实现。而且其他的 Door 也不需要 TimeClient 对象,但是依然需要引入 TimeClient。这样 Door 具有了不必要的复杂性以及不必要的重复,Door 被污染了。
Door 和 TimeClient 接口是被不同的客户程序使用的,Timer 使用 TimeClient,TimeDoor 使用 Door,既然客户程序是分离的,那么接口也应该保持分离。
一种分离方式是创建一个 TimeClient的派生类 DoorTimeAdapter,依赖 TimeDoor,如 图13 所示。DoorTimeAdapter 注册到 Timer,当 Timer 对象发送 timeout 消息给 DoorTimeAdapter 时,DoorTimeAdapter 把这个消息委托给 TimeDoor。这个方案避免了 Door 和 Timer 之间的耦合,Timer 的改动不会影响到 Door 的使用者,DoorTimeAdapter 会将 TimeDoor 转换成 TimeClient,TimeDoor 也无需实现 TimeClient 的方法。
图 13
但是这种 adapter 的处理方式有些复杂,我们可以让 TimeDoor 同时派生自 TimeClient 和 Door,如图 14 所示,这样 Timer 和 Door 也可以做到解藕。通常我们会优先选择这个方案。
图 14
「胖接口」会导致客户程序之间产生不必要的耦合关系,「胖接口」的改动可能会影响所有客户程序。客户程序应该仅仅依赖他们实际需要的方法,可以通过把「胖接口」分解为多个接口来实现客户程序和不需要的方法间的解藕,并使客户程序之间互不依赖。