SRP – 简单的构建块

在本节中,我们将探讨第一个原则,即单一职责原则(SRP)。我们将在所有部分中使用一个代码示例。这将阐明每个原则如何应用于面向对象(OO)设计。我们将看一个经典的 OO 设计示例:绘制形状。下图是用统一建模语言(UML)描述的设计概览,展示了本章中提供的代码:

image 2025 01 12 15 08 22 859
Figure 1. Figure 7.1 – UML diagram for shapes code

此图显示了本章 GitHub 文件夹中可用的 Java 代码的概览。我们将使用代码的特定部分来说明如何使用每个 SOLID 原则来创建此设计。

UML图

UML 由 Grady Booch、Ivar Jacobson 和 James Rumbaugh 于 1995 年创建。UML 是一种在高层面上可视化 OO 设计的方式。前面的图是一个 UML 类图。UML 提供了许多其他类型的有用图。您可以在 https://www.packtpub.com/product/uml-2-0-in-action-a-project-basedtutorial/9781904811558 了解更多信息。

SRP 指导我们将代码分解为封装我们解决方案的单个方面的部分。也许这是一个技术方面的性质——比如读取数据库表——或者也许是一个业务规则。无论哪种方式,我们将不同的方面分成不同的代码部分。每段代码负责一个细节,这就是 SRP 名称的由来。另一种看待这一点的方式是,一段代码应该只有一个更改的理由。让我们在接下来的部分中探讨为什么这是一个优势。

过多的责任让代码更难处理

一个常见的编程错误是将过多的职责合并到一个代码块中。如果我们有一个类可以生成超文本标记语言(HTML),执行业务规则,并从数据库表中获取数据,那么这个类将有三个更改的理由。每当这些领域中的一个需要更改时,我们就有可能做出影响其他两个方面的代码更改。这个技术术语是代码 高度耦合。这导致一个领域的更改波及并影响其他领域。

我们可以将其可视化为下图中的代码块 A

image 2025 01 12 15 10 14 170
Figure 2. Figure 7.2 – Single component: multiple reasons to change

A 处理三件事,因此对其中任何一项的更改都意味着 A 的更改。为了改进这一点,我们应用 SRP 并将负责创建 HTML、应用业务规则和访问数据库的代码分离出来。现在,这三个代码块——A、B 和 C——每个只有一个更改的理由。更改任何一个代码块都不应导致更改波及到其他块。

我们可以在下图中将其可视化:

image 2025 01 12 15 11 26 627
Figure 3. Figure 7.3 – Multiple components: one reason to change

每个代码块处理一件事,并且只有一个更改的理由。我们可以看到,SRP 的作用是限制未来代码更改的范围。它还使得在大型代码库中更容易找到代码,因为它是逻辑组织的。

应用 SRP 还带来其他好处,如下所示:

  • 代码重用的能力

  • 简化的未来维护

代码重用的能力

代码重用长期以来一直是软件工程的目标。从头开始创建软件需要时间,耗费金钱,并且阻止软件工程师做其他事情。如果我们创建了一些通常有用的东西,那么尽可能地在其他地方再次使用它是有意义的。当我们创建了大型的、特定于应用程序的软件片段时,就会出现障碍。它们高度专业化的事实意味着它们只能在原始上下文中使用。

通过创建更小、更通用的软件组件,我们将能够在不同的上下文中再次使用它们。组件旨在实现的范围越小,我们就越有可能无需修改即可重用它。如果我们有一个小函数或类只做一件事,那么在我们的代码库中重用它就变得容易。它甚至可能最终成为我们可以在多个项目中重用的框架或库的一部分。

SRP 并不保证代码将可重用,但它确实旨在减少任何代码片段的作用范围。这种将代码视为一系列构建块的方式,每个构建块都完成整体任务的一小部分,更有可能产生可重用的组件。

简化未来的维护

在我们编写代码时,我们意识到我们不仅仅是为了现在解决问题而编写代码,而且还在编写可能在未来被重新审视的代码。这可能是由团队中的其他人完成的,也可能是由我们自己完成的。我们希望使未来的工作尽可能简单。为了实现这一点,我们需要保持我们的代码良好工程化——使其安全且易于以后使用。

重复的代码是维护的一个问题——它使未来的代码更改复杂化。如果我们复制并粘贴一段代码三次,比如说,当时我们正在做的事情对我们来说似乎非常明显。我们有一个概念需要在三个地方实现,所以我们粘贴了三次。但是当我们再次阅读代码时,那个思维过程已经丢失了。它只是读取为三个不相关的代码片段。我们通过复制和粘贴失去了工程信息。我们将需要对该代码进行逆向工程,以确定有三个地方需要更改。

反例 – 违反 SRP 的形状代码

为了看到应用 SRP 的价值,让我们考虑一段不使用它的代码。以下代码片段有一个形状列表,当我们调用 draw() 方法时,所有形状都会被绘制:

public class Shapes {
    private final List<Shape> allShapes = new ArrayList<>();

    public void add(Shape s) {
        allShapes.add(s);
    }

    public void draw(Graphics g) {
        for (Shape s : allShapes) {
            switch (s.getType()) {
                case "textbox":
                    var t = (TextBox) s;
                    g.drawText(t.getText());
                    break;
                case "rectangle":
                    var r = (Rectangle) s;
                    for (int row = 0; row < r.getHeight(); row++) {
                        g.drawLine(0, r.getWidth());
                    }
            }
        }
    }
}

我们可以看到这段代码有四个职责,如下所示:

  • 使用 add() 方法管理形状列表

  • 使用 draw() 方法绘制列表中的所有形状

  • switch 语句中知道每种形状的类型

  • case 语句中具有绘制每种形状类型的实现细节

如果我们想添加一种新的形状类型——例如三角形——那么我们需要更改这段代码。这将使其变得更长,因为我们需要在一个新的 case 语句中添加有关如何绘制形状的细节。这使得代码更难阅读。该类还需要有新的测试。

我们可以更改这段代码以使添加新类型的形状更容易吗?当然可以。让我们应用 SRP 并进行重构。

应用 SRP 简化未来的维护

我们将通过小步骤重构这段代码以应用 SRP。首先要做的是将如何绘制每种形状的知识移出这个类,如下所示:

package shapes;

import java.util.ArrayList;
import java.util.List;

public class Shapes {
    private final List<Shape> allShapes = new ArrayList<>();

    public void add(Shape s) {
        allShapes.add(s);
    }

    public void draw(Graphics g) {
        for (Shape s : allShapes) {
            switch (s.getType()) {
                case "textbox":
                    var t = (TextBox) s;
                    t.draw(g);
                    break;
                case "rectangle":
                    var r = (Rectangle) s;
                    r.draw(g);
            }
        }
    }
}

曾经在 case 语句块中的代码已被移动到形状类中。让我们以 Rectangle 类为例看看变化——你可以在以下代码片段中看到变化:

public class Rectangle {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public void draw(Graphics g) {
        for (int row = 0; row < height; row++) {
            g.drawHorizontalLine(width);
        }
    }
}

我们可以看到 Rectangle 类现在只有一个职责,即知道如何绘制矩形。它不做其他任何事情。它必须更改的唯一原因是如果我们需要更改矩形的绘制方式。这不太可能,意味着我们现在有一个稳定的抽象。换句话说,Rectangle 类是一个我们可以依赖的构建块。它不太可能改变。

如果我们检查我们重构后的 Shapes 类,我们会看到它也有所改进。它少了一个职责,因为我们将它移到了 TextBoxRectangle 类中。它现在更易于阅读,也更易于测试。

SRP

做一件事并做好。代码块只有一个更改的理由。

还可以进行更多的改进。我们看到 Shapes 类保留了它的 switch 语句,并且每个 case 语句看起来都是重复的。它们都做同样的事情,即在形状类上调用 draw() 方法。我们可以通过完全替换 switch 语句来改进这一点——但这必须等到下一节,我们将在那里介绍 DIP

在我们这样做之前,让我们思考一下 SRP 如何应用于我们的测试代码本身。

组织测试以具有单一责任

SRP 也有助于我们组织测试。每个测试应该只测试一件事。也许这是一个单一的快乐路径或一个单一的边界条件。这使得定位任何故障变得更简单。我们找到失败的测试,因为它只涉及我们代码的一个方面,所以很容易找到缺陷所在的代码。每个测试只做一个断言的建议自然由此而来。

用不同配置分离测试

有时,一组对象可以以多种不同的方式排列以协作。如果我们为每个配置编写一个测试,这个组的测试通常会更好。我们最终会得到多个更易于处理的小测试。

这是将 SRP 应用于该组对象的每个配置并通过为每个特定配置编写一个测试来捕获的示例。

我们已经看到 SRP 如何帮助我们为代码创建简单的构建块,这些构建块更易于测试和更易于使用。下一个强大的 SOLID 原则是 DIP。这是一个管理复杂性的非常强大的工具。