细说 S.O.L.I.D

S.O.L.I.D 五原则(以下简称五原则):

  • 单一功能原则
  • 开放封闭原则
  • 里氏替换原则
  • 接口隔离原则
  • 依赖反转原则

image_1bqcv642t1bj01a041g0udcae4l9.png-40kB

S.O.L.I.D 做为面向对象编程和面向对象设计的五个基本原则,在面向对象的软件开发史上做为“最佳实践”指导着人们开发出代码清晰可读、容易维护和扩展的系统。

S.O.L.I.DRobert C. Martin (Uncle Bob) 总结提出。

Code rot

image_1bu3f0pemstn1htf3bl1v9l157719.png-257.2kB
什么样的代码是烂代码呢?如何识别将来可能会演变成烂代码的代码呢?

  1. 刚性:小改变导致整个系统重写
  2. 脆弱性:一个模块的改变导致其他不相关模块行为变得不当,想象一下一台汽车因为电台的变动影响到了车窗的使用
  3. 不可迁移性:一个模块的内部组件不能提取到新环境使用,原因是组件间的耦合与强依赖。规避策略是将中央抽象与 low-level 细节分离
  4. 粘性:当构建和测试难以进行,需要花费大量的时间执行(例如:在充值处做了小改动,也需要花费时间去测试游戏各个环节能否正常运行)

人们主要能体会到软件中的两类价值:

  1. 帮助人们更好地做某件事,提高生产力,降低时间与金钱支出
  2. 软件的行为表现:满足现阶段的用户需求,同时能频繁地变动以满足用户的需求变更,且没有 Bug 与崩溃。

第一类价值的保证靠的的团队的产品等角色的发挥,而第二类价值的保证则需要工程师的软件架构设计来保证了,而 S.O.L.I.D 是应用最为广泛也最为基础的设计指导原则。


单一功能原则 - RSP

解释

一个类或类似的结构只做一件事,只因为一个原因而发生变动

详细阐述

类的一切数据与方法都应该与这个单一的职责有关,即凝聚力。但这并不意味着你的类只能包含一个方法或属性。

优点

  1. 降低类的复杂度
  2. 提高可维护性
  3. 提高可读性
  4. 降低需求变化带来的风险

示例

1
2
3
4
5
6
// Not good, **3** responsibility
class Employee {
public Pay calculatePay() {...} # 1) calculation logic
public void save() {...} # 2) database logic
public String describeEmployee() {...} # 3) reporting logic
}

Modem 类的设计改进:
image_1bu4tvefb1bf415tk15pf1ufd18eq1m.png-9.4kB => image_1bu4tvpgf1nfi2giv6bqjd1apb23.png-28.1kB


开放封闭原则 - OCP

解释

软件中的对象(类,模块,函数等等)对扩展开放,对修改封闭。

详细阐述

现有代码应该只在下面三种情况进行 修改

  1. 现有代码里有 Bug 或错误
  2. 现有代码实现的需求发生了变动(例如之前确定的算法或逻辑发生了改变)

下面情况不应该需要改变现有代码,而能通过继承多态来进行 扩展 即可变更软件的行为:

  1. 以前做过可预测的同类需求。例如在员工管理系统中需要新增一个员工类型,支付系统需要新增一种支付方式

通常的实现方法:对抽象编程,而不对具体编程;使用抽象类或接口,而不是使用具体的类;新功能的的增加通过实现继承抽象类或实现接口,而非改变已有代码来完成。

只有在将会发生的变化是可预测的情况下,OCP 才会有所帮助,所以你应当只有在类似的变化已经发生过的情况下使用它。

优点

  1. 扩展新功能时不容易引入新 Bug
  2. 新的功能添加无需对全部代码进行代码审查、单元测试
  3. 代码稳定性
  4. 解耦,增加弹性

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Not Good
/**
* 不难预料,将来可能支持除现金之外的收款方式
* 这里直接写死成 acceptCash
*/
void checkOut(Receipt receipt) {
Money total = Money.zero;
for (item : items) {
total += item.getPrice();
receipt.addItem(item);
}
Payment p = acceptCash(total);
receipt.addPayment(p);
}

// 将来需要新增一种收款方式,可能会被改成这样
void checkOut(Receipt receipt) {
Money total = Money.zero;
for (item : items) {
total += item.getPrice();
receipt.addItem(item);
}
Payment p;
if (credit)
p = acceptCredit(total);
else
p = acceptCash(total);
receipt.addPayment(p);
}
1
2
3
4
5
6
7
8
9
10
11
12
// Good
public interface PaymentMethod {void acceptPayment(Money total);}

void checkOut(Receipt receipt, PaymentMethod pm) {
Money total = Money.zero;
for (item : items) {
total += item.getPrice();
receipt.addItem(item);
}
Payment p = pm.acceptPayment(total);
receipt.addPayment(p);
}

里氏替换原则 - LSP

解释

任何基类可以出现的地方,子类一定可以出现。所有子类需要按照其基本类别的方式进行操作。子类可以扩展父类的功能,但不能改变父类原有的行为预期。

详细阐述

Liskov 提出的关于继承的原则:继承必须确保超类中所拥有的属性与方法在子类中仍然拥有

Robert C. Martin 提出了更抽象的简化:子类必须能够替换成他们的基类。这也是里氏替换原则的精髓。

LSP 是继承复用的基石,只有当子类可以替换基类,软件单位的功能不受影响时,基类才能真正的被复用,而子类也可以在基类的基础上增加新的行为。

一般来说,如果超类的子类型做了超类的客户端所不期望的事情,那么这违背了 LSP。设想一个派生类抛出一个异常,超类不抛出,或者如果一个派生类有一些意想不到的副作用。

更具有指导意义的详细规范:

  1. 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。
  2. 子类中可以增加自己特有的方法。
  3. 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  4. 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

优点

  1. 里氏替换原则是实现开放封闭原则的具体规范
  2. 见开放封闭原则的优缺点

示例

链接


接口隔离原则 - ISP

解释

一个类不应该被强制依赖它不需要的接口成员。多个小的、特定的、具有内聚性的客户端接口要好于单个宽泛用途的、内聚性差的接口。

详细阐述

与单一职责原则的不同点:

  1. 单一职责原则注重的是对象的职责;而接口隔离原则注重对接口依赖的隔离
  2. 单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口,主要针对抽象,针对程序整体框架的构建

接口的粒度需要我们自己去把控,接口的粒度过小会造成接口数量过多,设计复杂化;接口粒度过大,就很容易违背接口隔离原则。

另见下方示例

优点

  1. 灵活性
  2. 避免接口臃肿

示例

1
2
3
4
5
6
7
8
9
10
11
12
// Not Good
public interface Messenger {
askForCard();
tellInvalidCard();
askForPin();
tellInvalidPin();
tellCardWasSiezed();
askForAccount();
tellNotEnoughMoneyInAccount();
tellAmountDeposited();
tellBalance();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Good
public interface LoginMessenger {
askForCard();
tellInvalidCard();
askForPin();
tellInvalidPin();
}

public interface WithdrawalMessenger {
tellNotEnoughMoneyInAccount();
askForFeeConfirmation();
}

publc class EnglishMessenger implements LoginMessenger, WithdrawalMessenger {
...
}

依赖反转原则 - DIP

解释

  1. 高层模块不应该依赖低层模块,所有模块都应该依赖抽象
  2. 抽象不应该依赖细节/具体
  3. 细节/具体应该依赖抽象

详细阐述

什么是依赖?

不是我自身的,却是我需要的,都是我所依赖的。
我若依赖你,我就不能离开你。

什么是高层模块?什么是低层模块?

类 A 直接依赖于类 B,假如要将类 A 修改为依赖类 C,则必须通过修改类 A 的代码来达成。这种场景下,类 A 一般是高层模块,负责复杂的业务逻辑。类 B 和 C 是底层模块,负责基本的原子操作。

什么是抽象?什么是细节?

抽象:抽象类或接口
细节:具体的实现类或实现方法

这里的依赖是什么?反转后的依赖变成了什么?

这里的依赖原本指高层模块对低层模块的依赖,倒置的依赖演变成实现类对接口或抽象类的依赖

反转怎么理解?

由于低层组件是对高层组件接口的具体实现,因此低层组件包的编译是依赖于高层组件的,这颠倒了传统的依赖关系。

优点

  1. 松散耦合
  2. 多人开发,提取模块的抽象,可以进行多人并行开发
  3. 违背了依赖反转原则,那么开放封闭原则也就无法实行

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 模块 A
public interface Reader { char getchar(); }
public interface Writer { void putchar(char c)}

class CharCopier {
void copy(Reader reader, Writer writer) {
int c;
while ((c = reader.getchar()) != EOF) {
writer.putchar();
}
}
}

// 模块 B
public Keyboard implements Reader {...}
public Printer implements Writer {…}

// 应用
...
new CharCopier(new Keyboard(), new Writer());

或见 链接

说明

依赖倒置原则的核心就是 面向接口编程的思想,尽量对每个实现类都提取抽象和公共接口形成接口或抽象类,依赖于抽象而不要依赖于具体实现。但是这个原则也是 5 个设计原则中最难以实现的了,如果没有实现这个原则,那么也就意味着开闭原则(对扩展开放,对修改关闭)也无法实现。

依赖倒置原则或者说面向接口编程的思想催生了许多经典的设计模式,欲细究可自行深入研究面向接口编程。


参考资料:
Object-oriented design principles and the 5 ways of creating SOLID applications
http://www.cnblogs.com/hellojava/category/431379.html

FuChee wechat
扫一扫,关注我