面向对象软件设计原则之SOLID

253 阅读13分钟

SOLID 原则由著名的计算机科学家 Robert C. Martin(又名 Uncle Bob)在 2000 年的论文中首次提出。但 SOLID 首字母缩略词后来由 Michael Feathers 引入。

Robert C. Martin 还是畅销书 Clean Code 和 Clean Architecture 的作者,并且是“敏捷联盟”的参与者之一。

因此,所有这些干净的编码、面向对象的体系结构和设计模式的概念都以某种方式相互关联和互补也就不足为奇了

通常来说,要想构建一个好的软件系统,应该从写整洁的代码开始做起。毕竟,如果建筑所使用的砖头质量不佳,那么架构所能起到的作用也会很有限。反之亦然,如果建筑的架构设计不佳,那么其所用的砖头质量再好也没有用。这就是SOLID设计原则所要解决的问题。 其主要目标是:

“创建可理解、可读和可测试的代码,供许多开发人员协作处理。”

SOLID 是根据各原则的首字母排列组合而来,来看一下 SOLID 分别是指什么:

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface segragation Principle
  • Dependency Inversion Principle

SRP(Single Responsibility Principle)单一职责原则

SRPSOLID五大设计原则中最容易被误解的一个。也许是名字的原因,很多程序员根据SRP这个名字想当然地认为这个原则就是指:每个模块都应该只做一件事。但这其实是函数或方法的设计原则,即确保每个函数都只实现一个功能。

SRP最开始的原则是:每一个软件模块都应该有且仅有一个原因被修改。 这里的软件模块大多数情况下是指一个类文件。

在经过多年迭代之后,最终 Uncle Bob 在 《Clean Arhitechture》中定义为:任何一个软件模块都应该只对一类行为者负责。比如日志模块只对程序员或 CTO 负责,比如商品详情模块应该只对买家卖家复负责。

遵循单一原则很重要,如果同一个模块因为不同的原因(行为者)去编辑同一个模块则会增加出 bug 的概率。

另外,在版本管理上,如果我们遵循 SRP,那就你能减少合并冲突,因为更改模块的原因减少了,并且即使存在冲突也会比较容易解决。

具体怎么理解?我们先来看一个反例。

反面案例

如图某个员工管理模块 Employee 有三个方法,calculatePay(),reportHours(),save(),这三个方法分别对应三个行为者:

  • calculate() 是由财务部门使用

  • reportHours() 是由人力部门使用

  • save() 则是由程序员自己或技术部门负责

在一个模块Employee内将多个行为者的业务耦合在一起,则有可能导致某一个行为人的决策会影响到其他人。比如 calculatePay()reportHours() 方法一般都会计算工时,程序员为了减少重复代码通常会将该逻辑单独抽到一个方法内,如:regularHours()

如果此时财务部门决定修改工时的计算方法,而人力部门不需要修改,行为人的逻辑发生了变化。这时候接到接到需求的程序员,没有负责过此前的业务,便修改了 **regularHours()** 方法,没有注意到其他地方也有依赖。或者他认为以前逻辑一样,现在修改也应该修改。最终修改代码,然后测试。因为只修改了财务部门的业务,所以测试通过。上线之后人力团队使用发现有问题了。

这种改了某一个地方的代码但是却引起其他地方bug的现象,相信不少人都遇到过。这类问题的根源之一便是不同行为者的逻辑、依赖被耦合了。而 SRP 强调这类代码一定要被分开。

如何解决

知道问题原因,解决就比较容易了。最简单直接的一种解决方案则是,将 Employee 数据和行为分开,每个行为者都只有它自己的模块

大家可能会觉得你一个类就只有一个方法,没有必要这样。但这边仅仅是一个示例,实际情况的业务都会比较复杂,没一个公开方法都有可能包含多个私有方法。

总而言之,上面的每一个类都分别容纳了一组作用于相同作用域的函数,而在该作用域之外,它们各自的私有函数是互相不可见的。

OCP(Open Close Principle)开闭原则

遵循开闭原则设计出来的模块具有两个基本特征:

  • 对于扩展是开放的(Open for extension):模块的行为可以扩展,当应用的需求改变时,可以对模块进行扩展,以满足新的需求。
  • 对于更改是封闭的(Closed for modification):对模块行为扩展时,不必改动模块的源代码。

修改意味着更改现有类的代码,扩展意味着添加新功能。

所以这个原则想说的是:应该能够在不触及模块的现有代码的情况下添加新功能。这是因为每当我们修改现有代码时,我们都会冒着产生潜在错误的风险。因此,如果可能的话,我们应该避免接触经过测试且可靠的(大部分)生产代码。

那么,如何在不修改模块源代码的情况下去修改它的行为呢?或者怎样才能在无需对模块进行改动的情况下就改变它的功能呢?

实现开闭原则的关键在于抽象化。抽象化的具体实现就是使用抽象类或接口。

举一个示例,假如一个电商应用需要设计物流快递模块,那我们需要一个快递公司,假如目前只有一家(SFExpress类),然后快递公司需要按照体积、重量运送不同的货物,假如现在有两种(Fruit类、Phone类),还需要一个管理模块(Delivery类)

根据开闭原则,对扩展开放对修改关闭,我们需要抽象出两个接口,分别是快递(Express)、货物(Goods),具体的实现都在它们的子类中实现,如下图:

OCP.png 如上图,我们可以看到 Delivery 类依赖的是 Express、Goods 接口,而不是依赖具体实现。所以在需求发生变更,如需要更改计费逻辑或变更快递公司时,Delivery 类都不会被影响,这样便实现了对修改关闭。

在实际开发中,我们其实很难一次性识别到所有可变化、固定的业务逻辑,所以 OCP 在实际开发中可能会处于一个持续识别的过程。我们会持续识别类中可能需要变化的地方,把它们封装成抽象类或者接口,从而将变化点与不需要变化的代码分离。

里氏替换原则(Liskov Substitution Principle)

里氏替换原则是由Barbara Liskov女士于1988年提出的:

如果S是T的子类型,对于S类型的任意对象,如果将他们看作是T类型的对象,则对象的行为也理应与期望的行为一致。

上面提到的 Robert Martin 在后来总结为:子类型(subtype)必须能够替换掉他们的基类型(base type), 这个是更简明的一种表述。

举个例子大家可能就立刻明白是什么意思。Java 语言中,如果一个方法参数为 List 类型,那么是不是这个参数接收任何 List 的子类,如 ArrayListLinkedList,都不会影响代码逻辑。这就是上面说的子类能替换掉基类的意思。

fun example(list:List<String>){
  for (it in list){
    println(it)
  }
}

这段代码我相信大家都能理解, 这里的 list:List<String>参数可以接收任意 List 的子类型,并且我们都知道,任何子类调用 List 接口的方法,都会与接口中的描述保持一致。这就是与期望行为表现一致

里氏替换原则有一个经典的反例:正方形不是长方形。在现实生活或数学中,正方形一定属于长方形,只不过它的长宽相等。于是我们得到如下的类图:

为什么要重写 setLength()、setWidth(),因为正方形需要同时设置宽高,所以重写了 Rect 的方法。Rect 的代码比较清楚就不贴了,这里贴一下 Square 类的代码。

class Square:Rect() {
  override fun setLength(l: Float) {
    super.setLength(l)
    super.setWidth(l)

  }

  override fun setWidth(w: Float) {
    super.setWidth(w)
    super.setLength(w)
  }
}

假如有这样一段代码逻辑,判断 Rect 是否等于 200:

fun test(rect: Rect): Boolean {
  rect.setLength(20f)
  rect.setWidth(10f)
  return rect.area() == 200f
}

这段代码如果我们传入 Rect 类型,则测试 OK,如果我们传入的是 Square 类型,则结果就不是 200 了,测试不通过。所以此处就不能用 Square 来代替 Rect,如果替换了则运行结果与期望不一致。我明年就能得出结论:这个设计违反了里氏替换原则,他们的继承关系不成立,正方形不是长方形。

那么该如何设计它们的关系呢?很简单,我们可以把长方形和正方形共有的行为抽到一个接口或抽象类中,然后分别去实现这个接口或抽象类,如图:

所以面向对象在设计继承关系时,需要考虑的不是它们的现实关系,而需要关注它们的行为。同样的例子还有”鸵鸟不是鸟“。在生物学上鸵鸟绝对是鸟,但是它与多数鸟还是有一些行为差异的,比如说鸵鸟不会飞。那如果程序上,需要关注到鸟的飞行行为,那么会飞的鸟与鸵鸟之间就不应该有继承关系。

与其他软件设计原则不一样,像 SRP、OCP,如果不遵守只是程序维护麻烦了些,不优雅。而如果违反里氏替换原则,那么你的程序很有可能会有bug。

如何避免违反里氏替换原则

  • 在做抽象设计时,不能只从现实概念出发,更需要关注模型的行为。我们要能确保,子类能包含父类所有行为,并且尽量不重写父类的行为。
  • 子类需要严格按照父类的方法定义去实现。如父类、接口在定义方法时,规定了方法功能作用、异常行为等等,子类必须要按照这些定义去实现,不能自我发挥。

接口隔离原则(Interface Segregation Principle)

在软件工程领域,接口隔离原则(ISP)规定不应强迫客户端依赖它不使用的方法。ISP将非常大的接口拆分为更小和更具体的接口,以便客户端只需知道它们感兴趣的方法。这种缩小的接口也称为角色接口。ISP旨在使系统分离,从而更容易重构,更改和重新部署。ISP是面向对象设计的五个SOLID原则之一,类似于GRASP的高内聚原则

白话就是接口范围要尽量小,客户端可以通过多实现来扩大实现范围。

ISP 应该是 SOLID 最简单的一个了,即使违反了该原则影响不像其他的几个那么糟。

一个简单的类图大家就能明白说的是什么了:

isp.png

依赖反转原则(Dependency Inversion Principle)

这个原则的一般思想既简单又重要: 提供复杂逻辑的高级模块应该易于重用,并且不受提供实用功能的低级模块的变化的影响。为了实现这一点,您需要引入一个抽象,将高级模块和低级模块彼此解耦。

基于这个想法,Robert C. Martin 对依赖倒置原则的定义由两部分组成:

  • 高级模块不应依赖于低级模块。两者都应该依赖于抽象。
  • 抽象不应依赖于细节。细节应该取决于抽象。

DIP 的核心是通过抽象出高级和低级组件之间的交互来颠倒它们之间的经典依赖关系。其中,高层级、低层级、抽象的概念为:

  • 高层级:客户端、服务消费者、方法调用者(变动较少)
  • 低层级:服务端、服务提供者、方法实现者(易变动,通常需要扩展)
  • 抽象:交互行为、流程、业务模型(稳定,可重用)

如果不遵守 DIP 会怎么样?

我们来看一个示例

上图大家可以不把这两个类图看成类,可以把它们当成模块。一个高层级 Vehicle 模块依赖低层级 Engine 模块,这种依赖应该是比较常见的。
  • 优点是结构简单,实现难度小、方便。
  • 缺点则是耦合度高,当低层级发生变化时,高层级很有可能也需要变化。高层级模块高度依赖低层级,后面会很难扩展。

那基于 DIP 可以怎样优化?

如上图,高层级模块按照它的需要定义抽象,低层级实现这个抽象。此时低层级依赖高层级模块,依赖反转了

首先,Vehicle 模块依赖的是接口,那必然是降低了耦合,提高了可扩展性。其次,依赖反转后,Vehicle 完全不依赖 Engine,Vehicle 模块可重用度更高,更稳定。

为什么依赖倒置原则很重要

当我们为应用程序编写代码时,我们可能会将 logic 拆分为多个模块。尽管如此,这将导致代码具有依赖性。DIP 背后的一个动机是防止我们依赖经常变化的模块。具体类经常更改,而抽象和接口更改要少得多。例如,修复错误、代码重新编译或合并不同分支等作将变得更加容易。

那么,我们应该一直使用 DIP 吗?应尽可能遵循该原则。但是,我们不应该每次都为 class 创建一个接口。尽管如此,如果我们想管理包依赖项,DIP 是我们应该采用并深入理解的一个原则。

总结

SOLID开发原则为实现“创建可理解、可读和可测试的代码,供许多开发人员协作处理”这个目标,给开发人员提供了宝贵的指导方向。在实际开发中,大家可以灵活运用这些规则,让程序高内聚、低耦合、易测试。希望本文能帮助你理解 SOLID 原则,谢谢!