【译】什么是SOLID原则(第1部分)

5,173 阅读11分钟

翻译自:What’s the deal with the SOLID principles?(part 1)

即使你是一个初级开发人员,你也可能听说过 SOLID 原则。它们无处不在。工作面试时,你也许听过这样的问题:“你是如何评估代码质量的呢?又是如何区分代码的好坏呢?”一般的答案类似这样:“我尽量保持文件足够小。当文件变得很大时,我将移动部分代码到其他文件中。”。最糟糕的答案之一是:“我看到了就知道了。”当你用此类的描述回答之后,面试官通常会问:“你听说过 SOLID 原则吗?”。

那么,什么是 SOLID 原则呢?它为什么会被经常提到呢?维基百科 - SOLID 是这样描述的:“在面向对象的计算机编程中,术语 SOLID 是对五大软件设计原则的助记缩写,这五个原则旨在让软件设计更易理解、更灵活、更易于维护。它与 GRASP 软件设计原则并无关系。这些软件设计原则是 Robert C. Martin 提出的众多原则中的一个子集。虽然它们适用于任何面向对象的设计,但是 SOLID 原则也可以形成诸如敏捷开发或者自适应软件开发等方法论的核心理念。Martin 在 2000年 的论文《设计原则与设计模式》中介绍了 SOLID 原则的理论。”。

如果正确地遵循了 SOLID 原则,那么它将引导你写出维护性和可测试性更好的高质量代码。如果需求变更,你也可以轻松地修改代码(与不遵守 SOLID 的代码相比)。我认为向你提及这些内容是非常重要的:这些原则关注的是可维护性、可测试性以及和人类思维相关的更高质代码,而不是你为之编码的计算机。工作在以性能为主的领域的开发者各自有着不同的编程方法。由于在我们生活的世界里,人工时间成本比机器时间成本更昂贵,所以大多数开发者都不在高性能需求的领域里工作,而且他们常常被鼓励去使用面向对象的编程方法。在这种情况下,大部分开发者都能很好地应用 SOLID 原则。

从本文开始,我将按照单词首字母缩写的顺序尽力向你解释每一个原则。为了简洁起见,我会把这个主题分为 3 个部分。本文是我将向你介绍的前两个原则的第 1 部分。好了,让我们从字母 S 开始吧。

S

SOLID 中的 “S” 表示的是单一职责原则(Single Responsibility Principle,简称 SRP),它是最容易理解也可能是最容易让人忽视的一个原则。此原则意味着一个类应该只做且只能做一件事情。如果一个类未能达到它的目的,那么你就可以看着它并说“你做了一件事……”。

举例来说,假如我们需要从网络上获取一些 JSON 数据,然后解析它,并把结果保存在本地数据库中。根据我们正在编码的平台,这种工作可以使用为数不多的代码来实现。由于代码量不多,我们可能会想把所有的逻辑全部扔到一个类中。但是,根据单一职责原则,这将会是一个糟糕的做法。我们可以清楚地区分 3 个不同的职责:从网络上获取 JSON 数据,解析数据,保存解析的结果到数据库中。基于此,我们应该有 3 个类。

第 1 个类应该只处理网络。我们给它提供一个 URL,然后接收 JSON 数据或者在出现问题时,收到一个错误信息。

第 2 个类应该只解析它接收到的 JSON 数据并以相应的格式返回结果。

第 3 个类应该以相应的格式接收 JSON 数据,并把它保存在本地数据库中。

为什么非要这么麻烦呢?通过这样分离代码,我们能获得什么好处呢?其中一个好处就是可测试性。对于网络请求类,我们可以使用一个测试的 URL 分别在请求成功和发生错误的测试用例下来观察它的正确行为。为了测试 JSON 模块,我们可以提供一个模拟的 JSON 数据,然后查看它生成的正确数据。同样的测试原则也适用于数据库类(提供模拟数据,在模拟的数据库上测试结果)。

有了这些测试,如果我们的程序出了问题,我们可以运行测试并查看问题发生在哪个地方。可能是服务器上的某些部分发生了改变,导致我们接收了有损数据。或者数据是正常的,但是我们在 JSON 解析模块中遗漏了什么,致使我们不能正确地解析数据。又或者可能我们正尝试插入数据的数据库中不存在某个列。通过这些测试,我们不必猜测问题出在哪个地方。看到了问题所在,我们就努力地去解决它。

除了可测试性,我们还拥抱了模块化。如果项目需求变更,服务器返回数据的格式是 XML 或者其他的自定义格式而非 JSON,那么我们所要做的就是编写一个处理数据解析的新模块,然后用这个新的代替 JSON 模块。或者可能因为一些奇葩的理由,上述两者我们都需要,而网络模块再根据一些规则调用正确的模块。

如果我们有一些本地的 JSON 文件需要解析并将解析的数据发给其他模块时该怎么办呢?那么,我们可以把本地的 JSON 发送给我们的解析模块,然后获取结果并把它用在需要的地方。如果我们需要以 Flat 的格式(译者注:Flat File)而不是数据库的形式本地保存数据呢?同样没问题,我们可以用一个新的模块来替换数据库模块。

如你所见,这个看似简单的原则有很多优势。通过遵守这个原则,我们已经能够想象的到我们的代码库在可维护性方面会有重大改进。

O

字母“O”表示的是开闭原则( Open-Closed Principle,简称 OCP)。常言道,我们的类应该对扩展开放,对修改关闭。什么意思呢?我的理解是,我们应该以插件式的方式来编写类和模块。如果我们需要额外的功能,我们不应该修改类,而是能够嵌入一个提供这个额外功能的不同类。为了解释我的理解,我将使用一个经典的计算器示例。这个计算器在最开始只能执行两种运算:加法和减法。计算器类看起来像下面这样(本文中的代码不是用特定语言编写的):

class Calculator {
  public float add(float a, float b) {
    return a + b
  }
  public float subtract(float a, float b) {
    return a — b
  }
}

我们像下面这样使用这个类:

Calculator calculator = new Calculator()

float sum = calculator.add(10, 2) //the value of sum is 12
float diff = calculator.subtract(10, 2) //the value of diff is 8

现在,我们假设客户希望为这个计算器添加乘法功能。为了添加这个额外的功能,我们必须编辑计算器类并添加乘法方法:

public float multiply(float a, float b) {
  return a * b
}

如果需求又一次改变,客户又需要除法,sincospow以及众多的其他数学函数,我们不得不一次又一次编辑这个类来添加这些需求。根据开闭原则,这并不是一个明智的做法。因为这意味着我们的类可以修改。我们需要让它屏蔽修改,而对扩展开放,那么我们该怎么做呢?

首先,我们定义一个名为 Operation 的接口,这个接口只有一个名为 compute 的方法:

interface Operation {
  float compute(float a, float b)
}

之后,我们可以通过实现 Operation 接口来创建操作类(本文中提供的大多数示例也可以通过继承和抽象类来完成,但我更喜欢使用接口)。为了重建简单的计算器示例,我们将编写加法和减法类:

class Addition implements Operation {
  public float compute(float a, float b) {
    return a + b
  }
}
class Subtraction implements Operation {
  public float compute(float a, float b) {
    return a — b
  }
}

我们的新计算器类只有一个名叫 calculate 的方法,在这个方法中,我们可以传递操作数与操作类:

class Calculator {
  public float calculate(float a, float b, Operation operation) {
    return operation.compute(a, b)
  }
}

我们将像下面这样使用我们的新类:

Calculator calculator = new Calculator()

Addition addition = new Addition()
Subtraction subtraction = new Subtraction()

float sum = calculator.calculate(10, 2, addition) //the value of sum is 12
float diff = calculator.calculate(10, 2, subtraction) //the value of diff is 8

现在如果我们需要添加乘法,我们将创建这样的一个乘法运算类:

class Multiplication implements Operation {
  public float compute(float a, float b) {
    return a * b
  }
}

然后通过添加以下内容在上面的示例中使用它:

Multiplication multiplication = new Multiplication()
float prod = calculator.calculate(10, 2, multiplication) // the value of prod is 20

我们终于可以说我们的计算器类对修改关闭,对扩展开放了。看一下这个简单的例子,你可能会说将这些额外的方法添加到原始的计算器类中也没什么大问题,还有就是可能更好的实现也就意味着编写更多的代码。诚然,在这个简单的情景中,我更赞同你的说法。但是,在现实生活里的复杂情景下,遵守开闭原则编码将大有裨益。也许你需要为每个新功能添加远不止那三个方法,也许这些方法非常复杂。然而通过遵循开闭原则,我们可以用不同的类外化新的功能。它将有助于我们以及他人更好地理解我们的代码,而这主要是因为我们必须专注于较小的代码块而不是滚动无休止的文件。

为了更好地可视化这个概念,我们可以把计算器类视为第三方库的一部分,并且无法访问其源码。好的实现就是编写它的善良的人们遵守了开闭原则,使它对扩展开放。因此,我们可以使用自己的代码扩展其功能,并轻松地在我们的项目中使用它。

如果这听起来仍让人犯傻,那就这样想吧:你刚刚为客户编写了一个很棒的软件,它完成了客户想要的一切。你尽最大能力编写了所有的内容,并且代码质量令人惊叹。数周后,客户想要新的功能。为了实现它们,你必须潜心投入到你的漂亮代码中,修改各种文件。这样做,有可能代码质量会受到影响,特别是在截止日期紧张时。如果你已经为你的代码编写了测试(这也是你应该做的),那么这些修改可能会破坏一些测试,你还必须修改这些测试。

这与遵守了开闭原则编写的代码形成了鲜明的对比。要实现新功能,你只需编写新代码即可。旧代码保持不变。你所有的旧测试仍然有效。因为我们不是生活在一个完美的世界中,所以在某些跑偏的情况下,你可能仍然会对旧代码的某些部分进行细微的更改,但这与非开闭原则带来的修改相比则可以忽略不计。

除此之外,遵循开闭原则的编码方式还能让你在心理上获得极大的愉悦体验。其中一个就是,你只需要编写新代码,而无须为了实现新功能对你引以为傲的代码痛下杀手。通过为新功能编写新代码,而不是修改旧代码,高涨的团队士气将随之而来。这可以提高生产效率,从而减少工作压力,改善生活质量。

我希望你能看到这个原则的重要性。不过令人沮丧的是,在一个真实的项目中,主要是由于缺乏魔法水晶球的能力,我们很难预见未来以及应该如何、在哪里应用这个原则。但是,知道了开闭原则的确有助于在需求来临时识别出可能的用例。在一开始的实现中,当客户想要给这个计算器添加乘法和除法功能时,我们随手将这两个方法添加到了 Calculator 类中。接下来,当他还要 sincos 时,我们也许会对自己说:“等会儿……”。等待过后,我们开始重构代码以适配开闭原则来避免将来可能遇到的麻烦。现在,当客户还想要 tanpow 以及其他功能时,我们早就搞定了。

……

你可以在 什么是SOLID原则(第2部分) 阅读下两个 SOLID 原则。如果你喜欢这篇文章,你可以在我们的 官方站点 上找到更多信息。