文章

软件设计原则与SOLID原则

软件设计原则与SOLID原则

本文介绍软件设计原则与 SOLID 原则。

软件设计原则与SOLID原则

1 优秀设计的特征

1.1 代码复用

代码复用是减少开发成本时最常用的方式之一。其意图非常明显:与其反复从头开发,不如在新对象中重用已有代码。

1.2 扩展性

变化是程序员生命中唯一不变的事情。 因此在设计程序架构时,所有有经验的开发者会尽量选择支持未来任何可能变更的方式。

2 设计原则

2.1 封装变化的内容

找到程序中的变化内容并将其与不变的内容区分开,该原则的主要目的是将变更造成的影响最小化。

封装包括:

  • 方法层面的封装:将复杂的逻辑抽取到一个单独的方法中,并对原始方法隐藏该逻辑。
  • 类层面的封装:将一些相关联的变量和方法抽取到一个新类中会让程序更加清晰和简洁。

2.2 面向接口编程

面向接口进行开发,而不是面向实现;依赖于抽象类型,而不是具体类。

2.3 组合优于继承

继承可能是类之间最明显、最简便的代码复用方式。如果你有两个代码相同的类, 就可以为它们创建一个通用的基类,然后将相似的代码移动到其中。

不过,继承这件事通常只有在程序中已包含大量类,且修改任何东西都非常困难时才会引起关注。下面就是此类问题的清单:

  • 子类不能减少超类的接口。你必须实现父类中所有的抽象方法,即使它们没什么用。
  • 在重写方法时,你需要确保新行为与其基类中的版本兼容。这一点很重要,因为子类的所有对象都可能被传递给以超类对象为参数的任何代码,相信你不会希望这些代码崩溃的。
  • 继承打破了超类的封装,因为子类拥有访问父类内部详细内容的权限。此外还可能会有相反的情况出现,那就是程序员为了进一步扩展的方便而让超类知晓子类的内部详细内容。
  • 子类与超类紧密耦合。超类中的任何修改都可能会破坏子类的功能。
  • 通过继承复用代码可能导致平行继承体系的产生。继承通常仅发生在一个维度中。只要出现了两个以上的维度,你就必须创建数量巨大的类组合,从而使类层次结构膨胀到不可思议的程度。

组合是代替继承的一种方法。继承代表类之间的“是”关系(汽车交通工具),而组合则代表“有”关系(汽车一个引擎)。

2.3.1 继承

继承在多个维度上扩展一个类(汽车类型 × 引擎类型 × 驾驶类型),可能导致子类组合的数量爆炸。

2.3.2 组合

组合将不同“维度”的功能抽取到各自的类层次结构中。

3 SOLID原则

  • Single Responsibility Principle:单一职责原则
  • Open Closed Principle:开闭原则
  • Liskov Substitution Principle:里氏替换原则
  • Law of Demeter:迪米特法则
  • Interface Segregation Principle:接口隔离原则
  • Dependence Inversion Principle:依赖倒置原则

把这六个原则的首字母联合起来(两个 L 算做一个)就是 SOLID (solid,稳定的),其代表的含义就是这六个原则结合使用的好处:建立稳定、灵活、健壮的设计。

3.1 单一职责原则(SRP)

There should never be more than one reason for a class to change.

一个类应该只有一个发生变化的原因

尽量让每个类只负责软件中的一个功能,并将该功能完全封装(你也可称之为隐藏)在该类中。

3.1.1 实现

我们有几个理由来对 雇员 Employee 类进行修改。第一个理由与该类的主要工作(管理雇员数据)有关。但还有另一个理由:时间表报告的格式可能会随着时间而改变,从而使你需要对类中的代码进行修改。

解决该问题的方法是将与打印时间表报告相关的行为移动到一个单独的类中。这个改变让你能将其他与报告相关的内容移动到一个新的类中。

3.1.2 总结

  • 代码的粒度降低了,类的复杂度降低了。
  • 可读性提高了,每个类的职责都很明确,可读性自然更好。
  • 可维护性提高了,可读性提高了,一旦出现 bug ,自然更容易找到他问题所在。
  • 改动代码所消耗的资源降低了,更改的风险也降低了。

3.2 开闭原则(OCP)

Software entities like classes, modules and functions should be open for extension but closed for modification.

一个软件实体,如类、模块和函数应该对扩展开放,对修改关闭

本原则的主要理念是在实现新功能时能保持已有代码不变。

  • 如果你可以对一个类进行扩展,可以创建它的子类并对其做任何事情(如新增方法或成员变量、重写基类行为等),那么它就是开放的。

  • 如果某个类已做好了充分的准备并可供其他类使用的话(即其接口已明确定义且以后不会修改),那么该类就是封闭(你可以称之为完整)的。

3.2.1 实现

你的电子商务程序中包含一个计算运输费用的订单(Order)类,该类中所有运输方法都以硬编码的方式实现。如果你需要添加一个新的运输方式,那就必须承担对 订单 类造成破坏的可能风险来对其进行修改。

你可以通过应用策略模式来解决这个问题。首先将运输方法抽取到拥有同样接口的不同类中。

3.2.2 总结

  • 开闭原则非常著名,只要是做面向对象编程的,在开发时都会提及开闭原则。
  • 开闭原则是最基础的一个原则,前面介绍的5个原则都是开闭原则的具体形态,而开闭原则才是其精神领袖。

  • 开闭原则提高了复用性,以及可维护性。

3.3 里氏替换原则(LSP)

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

所有引用基类的地方必须能透明地使用其子类的对象

这意味着子类必须保持与父类行为的兼容。在重写一个方法时,你要对基类行为进行扩展,而不是将其完全替换。

3.3.1 实现

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

3.3.2 总结

优点:

  1. 子类拥有父类的所有方法和属性,从而可以减少创建类的工作量。
  2. 提高了代码的重用性。
  3. 提高了代码的扩展性,子类不但拥有了父类的所有功能,还可以添加自己的功能。

缺点:

  1. 继承是侵入性的。只要继承,就必须拥有父类的属性和方法。
  2. 降低代码的灵活性。子类会多一些父类的约束。
  3. 增强了耦合性。当父类的常量、变量、方法被修改时,需要考虑子类的修改。

3.4 迪米特法则(LOD)

Talk only to your immediate friends and not to strangers

只与你的直接朋友交谈,不跟“陌生人”说话

如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。

3.4.1 实现

  • 就是类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象。
  • 一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。所以,我们开发中尽量不要对外公布太多public方法和非静态的public变量,尽量内敛。

  • 如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。

3.4.2 总结

  • 降低了类之间的耦合度,提高了模块的相对独立性。
  • 由于亲合度降低,从而提高了类的可复用率和系统的扩展性。

3.5 接口隔离原则(ISP)

Clients should not be forced to depend upon interfaces that they don`t use. The dependency of one class to another one should depend on the smallest possible.

客户端不应该依赖它不需要的接口。

类间的依赖关系应该建立在最小的接口上。

3.5.1 实现

假设所有云服务供应商都与阿里云一样提供相同种类的功能。但当你着手为其他供应商提供支持时,程序库中绝大部分的接口会显得过于宽泛。其他云服务供应商没有提供部分方法所描述的功能。

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

3.5.2 总结

  • 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
  • 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
  • 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
  • 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
  • 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。

3.6 依赖倒置原则(DIP)

High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.

上层模块不应该依赖底层模块,它们都应该依赖于抽象。 抽象不应该依赖于细节,细节应该依赖于抽象。

3.6.1 实现

在本例中,高层次的预算报告类(BudgetReport)使用低层次的数据库类(MySQLDatabase)来读取和保存其数据。这意味着低层次类中的任何改变(例如当数据库服务器发布新版本时)都可能会影响到高层次的类,但高层次的类不应关注数据存储的细节。

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

3.6.2 总结

依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。

  • 模块间的依赖通过抽象发生,实现类之间不直接发生依赖关系,其依赖关系是通过接口或抽象类产生的;
  • 接口或抽象类不依赖于实现类;
  • 实现类依赖接口或抽象类。

参考

[1] https://www.yuque.com/tomocat/txc11h/smpv9w#dc8a3404

[2] https://www.jianshu.com/p/3268264ae581

[3] https://zhuanlan.zhihu.com/p/110130347

本文由作者按照 CC BY 4.0 进行授权