DIP – 隐藏不相关的细节

在本节中,我们将学习 DIP 如何允许我们将代码拆分为可以相互独立更改的单独组件。然后我们将看到这如何自然地导致 SOLID 中的 OCP 部分。

依赖倒置DI)意味着我们编写的代码依赖于抽象,而不是细节。与此相反的是有两个代码块,一个依赖于另一个的详细实现。对一个块的更改将导致另一个块的更改。为了在实践中看到这个问题是什么样子,让我们回顾一个反例。以下代码片段从我们在应用 SRP 后离开的 Shapes 类开始:

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);
            }
        }
    }
}

这段代码在维护 Shape 对象列表并绘制它们方面工作得很好。问题是它知道太多关于它应该绘制的形状类型的信息。draw() 方法具有一个你可以看到的对象类型切换。这意味着如果关于应该绘制哪些类型的形状有任何变化,那么这段代码也必须更改。如果我们想向系统中添加一个新的 Shape,那么我们必须修改这个 switch 语句和相关的 TDD 测试代码。

一个类知道另一个类的技术术语是它们之间存在依赖关系。Shapes 类依赖于 TextBoxRectangle 类。我们可以在以下 UML 类图中直观地表示这一点:

image 2025 01 12 15 31 35 032
Figure 1. Figure 7.4 – Depending on the details

我们可以看到 Shapes 类直接依赖于 RectangleTextBox 类的细节。这由 UML 类图中箭头的方向表示。拥有这些依赖关系使得使用 Shapes 类更加困难,原因如下:

  • 我们必须更改 Shapes 类以添加一种新的形状

  • 具体类(如 Rectangle)的任何更改都将导致此代码更改

  • Shapes 类将变得更长且更不易读

  • 我们将得到更多的测试用例

  • 每个测试用例将与具体类(如 Rectangle)耦合

这是一种非常程序化的方法来创建一个处理多种形状的类。它通过做太多事情并知道每种形状对象的太多细节而违反了 SRPShapes 类依赖于具体类(如 RectangleTextBox)的细节,这直接导致了上述问题。

幸运的是,有更好的方法。我们可以使用接口的力量来改进这一点,使 Shapes 类不依赖于这些细节。这称为 DI。接下来让我们看看那是什么样子。

将依赖注入应用于形状代码

我们可以通过应用前一章中描述的依赖倒置原则(DIP)来改进形状代码。让我们向我们的 Shape 接口添加一个 draw() 方法,如下所示:

package shapes;

public interface Shape {
    void draw(Graphics g);
}

这个接口是每个形状具有的单一职责的抽象。每个形状必须知道在我们调用 draw() 方法时如何绘制自己。下一步是使我们的具体形状类实现这个接口。

让我们以 Rectangle 类为例。你可以在这里看到:

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

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

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

我们现在已经将多态性的 OO 概念引入了我们的形状类中。这打破了 Shapes 类对了解 RectangleTextBox 类的依赖。Shapes 类现在只依赖于 Shape 接口。它不再需要知道每个形状的类型。

我们可以将 Shapes 类重构为如下所示:

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

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

    public void draw(Graphics graphics) {
        all.forEach(shape -> shape.draw(graphics));
    }
}

这次重构完全移除了 switch 语句和 getType() 方法,使代码更易于理解和测试。如果我们添加一种新的形状,Shapes 类不再需要更改。我们已经打破了对形状类细节的依赖。

一个小的重构将我们传递给 draw() 方法的 Graphics 参数移动到一个字段中,并在构造函数中初始化,如下面的代码片段所示:

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

    public Shapes(Graphics graphics) {
        this.graphics = graphics;
    }

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

    public void draw() {
        all.forEach(shape -> shape.draw(graphics));
    }
}

这就是 DIP 在工作。我们在 Shape 接口中创建了一个抽象。Shapes 类是这个抽象的消费者。实现该接口的类是提供者。两组类都只依赖于抽象;它们不依赖于彼此内部的细节。Shapes 类中没有对 Rectangle 类的引用,Rectangle 类中也没有对 Shapes 类的引用。我们可以在下面的 UML 类图中看到这种依赖关系的反转——看看依赖箭头的方向与图 7.4 相比如何变化:

image 2025 01 12 15 36 16 045
Figure 2. Figure 7.5 – Inverting dependencies

在这个版本的 UML 图中,描述类之间依赖关系的箭头指向相反的方向。依赖关系已经被反转——因此,这个原则的名称。我们的 Shapes 类现在依赖于我们的抽象,即 Shape 接口。Rectangle 类和 TextBox 类的具体实现也是如此。我们已经反转了依赖图并将箭头倒置。DI 完全解耦了类之间的关系,因此非常强大。当我们看第 8 章《测试替身——存根和模拟》时,我们将看到这如何导致 TDD 测试的一个关键技术。

DIP

使代码依赖于抽象而不是细节。

我们已经看到 DIP 是我们用来简化代码的一个主要工具。它允许我们编写处理接口的代码,然后使用该代码与实现该接口的任何具体类。这引发了一个问题:我们可以编写一个实现接口但不能正确工作的类吗?这是我们下一节的主题。