依赖反转是否必要以确保调用者与被调用者的解耦?

2 投票
2 回答
68 浏览
提问于 2025-04-14 17:31

我正在通过一些简单但具体的代码和类(用Python实现)来理解依赖倒置原则(DIP),这些内容来自这个教程。我将其总结一下(加上我自己的评论和理解),这样你就不用费力去看整个内容了。

基本上,我们正在构建一个货币转换器应用程序,在这个过程中,我们将主要的应用逻辑与货币转换器本身分开。代码(一些注释和文档字符串是我自己的)如下。

代码片段 1
#!/usr/bin/env python3
# encoding: utf-8

"""Currency converter application using some exchange API."""
class FXConverter:
    """The converter class."""
    def convert(self, from_currency, to_currency, amount):
        """
        Core method of the class. Assume the magic number 1.2 is from some API like 
        Oanda
        """
        print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
        return amount * 1.2


class App:
    """The Application"""
    def start(self):
        """The main method to create and invoke the converter object."""
        converter = FXConverter()
        converter.convert('EUR', 'USD', 100)


if __name__ == '__main__':
    app = App()
    app.start()

现在,教程中提到(直接引用)

将来,如果外汇的API发生变化,代码就会出问题。此外,如果你想使用不同的API,你需要修改App类。

所以他们提出了这个方案。

代码片段 2
#!/usr/bin/env python3
# encoding: utf-8

"""Currency converter application using dependency inversion."""
from abc import ABC


class CurrencyConverter(ABC):
    def convert(self, from_currency, to_currency, amount) -> float:
        pass

class FXConverter(CurrencyConverter):
    def convert(self, from_currency, to_currency, amount) -> float:
        print('Converting currency using FX API')
        print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
        return amount * 1.2 # The tutorial seems to have a typo here. 

class App:
    def __init__(self, converter: CurrencyConverter):
        self.converter = converter

    def start(self):
        self.converter.convert('EUR', 'USD', 100)

if __name__ == '__main__':
    converter = FXConverter()
    app = App(converter)
    app.start()

问题

对我来说,这段引用似乎不太成立,这也正是我无法理解DIP的原因。即使FXConverter使用了不同的交换API(假设用Bloomberg而不是Oanda),难道这个变化不会局限于convert方法吗?只要convert方法保持签名不变,

convert(str, str, float)->float # The strings must be valid currency names 

App.start应该不会有问题。保持这个有效方法签名的必要性是

  • 在DIP版本中也没有被消除。
  • 在像Rust或C++这样的更安全的类型语言中,自动得到保障。在更严格的语言中,我可能会列出可能的货币范围,以确保字符串变量不是随意的,比如US$£等。

这就是为什么我看不出DIP如何能更好地实现解耦,因为我们实际上需要的是遵循像静态类型语言那样的函数/方法签名。

一般来说,当A调用B(对象方法或函数等)时,我们能否假设

  • A不知道B的内部工作原理
  • B不知道A对结果做了什么

这样所期望的解耦就能自动得到保障吗?

2 个回答

0

这个解释确实有点让人困惑。

如果你实现的 FXConverter 发生了变化,而 App 不在乎,即使没有依赖注入(DI)也没关系。

但是如果 FXConverter 的接口发生了变化,App 就会出问题,不管有没有依赖注入。

依赖注入的好处在于,你可以把 FXConverter 换成其他的类,比如改进版的 GXConverter,而不需要改动 App 的代码。这就是依赖注入的核心思想。

如果没有依赖注入,App 自己去获取转换器,这样就会依赖于具体的实现。

一个典型的需要更换实现的例子是测试。在测试中,你可以用一些模拟类来替代真实的类(这里用“模拟”这个词比较宽泛),这些模拟类可能会提供固定的结果,记录调用情况,或者使用简化的算法。

1

首先要注意,你的两个代码片段并不完全相同。第二个片段把 converter 绑定到了 self 上。虽然这只是一个小差别,但你要明白,这并不影响主要观点。它们都可以用不同的方式实现(可以绑定到 self 也可以不绑定),而解耦和依赖注入的概念依然是一样的。话虽如此,在构造函数中将依赖项绑定到 self 是依赖注入中的一种常见做法。

第二个差别是,第一个代码片段在内部构建了 FXConverter。这会导致一些问题,因为现在 FXConverter 成为了内部细节,这样就无法优雅地用其他东西替换它。除非你对 AppFXConverter 的所有细节都了如指掌。这就是为什么它们现在是“耦合”的原因。依赖项应该被传递,而不是在内部构建。

但这还不是重点。考虑一下第二个片段,但稍微修改一下构造函数:

def __init__(self, converter: FXConverter):
    self.converter = converter

所以我现在声明 converterFXConverter。这会导致同样的问题。构造函数的签名不允许传递不同的实现,你仍然和 FxConverter 耦合在一起,它是一个具体的实现。

在所有这些代码片段中,App 实际上依赖于 CurrencyConverter 接口或抽象类。这是可以的。依赖于接口和/或具体签名的代码是正常的,但依赖于具体实现的代码就不好了。

所以,问题的关键在于 converter 变量,而不是真正的 .convert('EUR', 'USD', 100) 调用。


举个例子。

一个具体的好处是:假设你想为 App 编写自动测试。但是转换器做了一些复杂的事情,比如它需要网络调用来获取当前的汇率。你不想在测试中进行这样的调用,这会显著减慢一切,尤其是当你想测试大约 10000 个案例时。而且,使用外部资源的测试不一定是确定性的。

更重要的是:你不想知道 FxConverter 实际上做了什么。我的意思是,最终你是关心的,但在测试时你并不关心。你不想考虑它是否在内部进行 HTTP 调用。这对 App 来说并不重要。App 只关心输入和输出,而不关心 FxConverter 的内部实现。此外,如果需要,性能可以在后期进行调整。

那么你能做什么呢?使用第一个片段(或者我修改过的构造函数),你无法做任何事情,除非你了解 App 的内部实现,并知道如何硬编码模拟(实际上是 鸭子类型)内部以使其正常工作。但这样你就泄露了实现细节——每一个小的内部变化都可能影响测试。例如,假设 App 添加了缓存。你现在必须修改测试。因为有些模拟会检查 .convert() 调用的次数,而这不幸的是是一种非常流行的测试方式。即使这可能只是一个不影响输入/输出的优化。

你不会相信我有多少次因为对实现做了一个小改动而不得不重写数十个测试,而这个改动根本没有影响输入/输出。引用库尔茨上校的话:“恐怖……恐怖……”

现在使用第二个片段,你可以编写自己的 CurrencyConverter 实现。一个在内存中操作的、确定性的实现。然后你用这个实现实例化 App。构造函数声明接受任何 CurrencyConverter 的实现,所以它声明可以很好地与测试实现一起工作。现在你可以运行测试,一切都按预期工作。甚至,如果你不想麻烦测试实现,你还可以传递模拟作为 CurrencyConverter。关键是你不再局限于修补。

当然,最终你仍然需要集成测试,以确保具体实现不仅做它们声称的事情,而且也能很好地协同工作。不过你无论如何都需要这样的测试。而且集成测试通常需要很多时间。但是以解耦的方式构建代码可以提高编码效率。例如,你可以每天快速运行 500 次初步测试,而集成测试只在每周一次(例如在最终构建时)进行,并且可以高信心地认为它们会通过。

顺便说一下,这种测试方式在其他对模拟支持较差(或根本没有支持)的语言中也是必要的,比如大多数强类型语言。

这就是为什么我看不出 DIP 如何有助于更好的解耦,当我们实际上需要的是遵循静态类型语言中的函数/方法签名?

是的,这正是 DIP 的核心:遵循签名,而不是具体实现:

耦合的变体: A 调用 B。所以 A 依赖于 B。

解耦的变体: A 依赖并调用接口 C,B 实现了 C,并作为依赖传递给 A。A 和 B 都依赖于抽象接口 C,而不是彼此。

关键是接口本身不做任何事情,它们只是声明,丰富的签名。具体实现才是执行操作的。最好不要将其作为依赖。

最后,语言是否是静态类型并不重要。相同的事情也适用于 C++(传递抽象类而不是具体类)或 Rust(传递特征而不是结构体)。你不会经常看到低级语言(如 C++ 或 Rust)使用依赖注入,因为(运行时)依赖注入并不是免费的。它需要虚拟调用(这通常会破坏内联等优化),还需要一些管理。性能损失非常小,但不是零。在高级应用(比如网络服务器)中,这种损失不会被注意到。但当你使用 C++ 或 Rust 时,你可能是在编写需要性能的东西,而你对此非常在意,并希望尽可能榨取每一分性能。这正是这些语言的设计初衷。尽管如此,在非性能关键的部分使用依赖注入仍然是可以接受且更可取的。

撰写回答