David 是本书的技术审阅,这两篇关于控制反转的优秀文章是从他的博客交叉发布的,您可以在那里找到更多精彩内容。
在之前的文章中,我们学习了如何将控制反转可视化如下
B
插入到 A
中。A
提供了一种机制供 B
执行此操作——但除此之外,A
不需要了解关于 B
的任何信息。
该图提供了该机制的高级视图,但这实际上是如何实现的呢?
控制反转的模式
更接近代码结构,我们可以使用这种强大的模式
这是控制反转的基本形态。在您可能熟悉或不熟悉的符号中,包含了抽象、实现和接口的概念。这些概念对于理解我们将要采用的技术都非常重要。让我们确保我们理解当应用于 Python 时它们意味着什么。
抽象、实现和接口——在 Python 中
考虑以下三个 Python 类
class Animal:
def speak(self):
raise NotImplementedError
class Cat(Animal):
def speak(self):
print("Meow.")
class Dog(Animal):
def speak(self):
print("Woof.")
在这个例子中,Animal
是一个抽象:它声明了它的 speak
方法,但它不打算被运行(由 NotImplementedError
标识)。
然而,Cat
和 Dog
是实现:它们都实现了 speak
方法,各自以自己的方式实现。
speak
方法可以被认为是接口:其他代码可能与这些类交互的通用方式。
类的这种关系通常像这样绘制,用一个开放的箭头表示 Cat
和 Dog
是 Animal
的具体实现。
多态和鸭子类型
由于 Cat
和 Dog
实现了共享接口,我们可以与任何一个类交互,而无需知道它是哪个类
def make_animal_speak(animal):
animal.speak()
make_animal_speak(Cat())
make_animal_speak(Dog())
make_animal_speak
函数不需要知道关于猫或狗的任何信息;它只需要知道如何与动物的抽象概念交互。与对象交互而无需知道其具体类型,仅需知道其接口,这被称为“多态性”。
当然,在 Python 中,我们实际上并不需要基类
class Cat:
def speak(self):
print("Meow.")
class Dog:
def speak(self):
print("Woof.")
即使 Cat
和 Dog
没有继承 Animal
,它们仍然可以传递给 make_animal_speak
,并且一切都会正常工作。这种在没有对象显式声明接口的情况下与其交互的非正式能力被称为“鸭子类型”。
我们不限于类;函数也可以以这种方式使用
def notify_by_email(customer, event):
...
def notify_by_text_message(customer, event):
...
for notify in (notify_by_email, notify_by_text_message):
notify(customer, event)
我们甚至可以使用 Python 模块
import email
import text_message
for notification_method in (email, text_message):
notification_method.notify(customer, event)
无论共享接口是以正式的、面向对象的方式体现,还是更隐式地体现,我们都可以将接口和实现之间的分离概括如下
这种分离将赋予我们很大的力量,我们现在将看到。
再次审视该模式
让我们再次审视控制反转模式。
为了在 A
和 B
之间反转控制,我们在设计中添加了两个东西。
第一个是 <<B>>
。我们将其分离为抽象(A
将继续依赖并了解它),与其实现(A
对此一无所知)。
然而,软件将需要以某种方式确保使用 B
来代替其抽象。因此,我们需要一些编排代码,它既了解 A
又了解 B
,并将它们最终链接在一起。我称之为 main
。
现在是时候看看我们可以用来做到这一点的技术了。
技术一:依赖注入
依赖注入是指一段代码允许调用代码控制其依赖项。
让我们从以下尚不支持依赖注入的函数开始
# hello_world.py
def hello_world():
print("Hello, world.")
这个函数从顶层函数调用,如下所示
# main.py
from hello_world import hello_world
if __name__ == "__main__":
hello_world()
hello_world
有一个我们感兴趣的依赖项:内置函数 print
。我们可以像这样绘制这些依赖项的图
第一步是识别 print
实现的抽象。我们可以简单地将其视为一个输出它被提供的消息的函数——我们称之为 output_function
。
现在,我们调整 hello_world
,使其支持注入 output_function
的实现。请击鼓...
# hello_world.py
def hello_world(output_function):
output_function("Hello, world.")
我们所做的只是允许它接收输出函数作为参数。然后,编排代码通过参数传入 print
函数
# main.py
import hello_world
if __name__ == "__main__":
hello_world.hello_world(output_function=print)
就这样。它不能再简单了,对吗?在这个例子中,我们注入了一个可调用对象,但其他实现可能期望一个类、一个实例甚至一个模块。
通过非常少的代码,我们将依赖项从 hello_world
中移出,移到了顶层函数中
请注意,虽然没有正式声明的抽象 output_function
,但该概念隐式存在,因此我已将其包含在图中。
技术二:注册表
注册表是一个存储,一段代码从中读取以决定如何表现,它可能被系统的其他部分写入。注册表比依赖注入需要更多的机制。
它们有两种形式:配置和订阅者
配置注册表
配置注册表被填充一次,且仅一次。一段代码使用它来允许从外部配置其行为。
虽然这比依赖注入需要更多的机制,但它不需要太多
# hello_world.py
config = {}
def hello_world():
output_function = config["OUTPUT_FUNCTION"]
output_function("Hello, world.")
为了完成这幅图景,以下是如何从外部配置它的方法
# main.py
import hello_world
hello_world.config["OUTPUT_FUNCTION"] = print
if __name__ == "__main__":
hello_world.hello_world()
在这种情况下,该机制只是一个字典,它是从模块外部写入的。在真实世界的系统中,我们可能需要稍微更复杂的配置系统(例如,使其不可变是一个好主意)。但从本质上讲,任何键值存储都可以。
与依赖注入一样,输出函数的实现已被提升出来,因此 hello_world
不再依赖它。
订阅者注册表
与配置注册表(应仅填充一次)相反,订阅者注册表可能会被系统的不同部分任意次数地填充。
让我们开发我们的超简单示例以使用此模式。我们不想说“Hello, world”,而是想问候任意数量的人:“Hello, John。”、“Hello, Martha。”等等。系统的其他部分应该能够将人员添加到我们应该问候的列表。
# hello_people.py
people = []
def hello_people():
for person in people:
print(f"Hello, {person}.")
# john.py
import hello_people
hello_people.people.append("John")
# martha.py
import hello_people
hello_people.people.append("Martha")
与配置注册表一样,存在一个可以从外部写入的存储。但它不是一个字典,而是一个列表。此列表通常在启动时由分散在系统各处的其他组件填充。当时机成熟时,代码会逐个处理每个项目。
这个系统的图表将是
请注意,在这种情况下,main
不需要知道注册表——相反,是系统中其他地方的订阅者写入它。
订阅事件
使用订阅者注册表的常见原因是允许系统的其他部分对在一个地方发生的事件做出反应,而无需该地方直接调用它们。这通常通过 观察者模式(又名发布/订阅)来解决。
我们可以用与上面类似的方式实现这一点,除了将可调用对象添加到列表之外,我们还可以添加字符串
# hello_world.py
subscribers = []
def hello_world():
print("Hello, world.")
for subscriber in subscribers:
subscriber()
# log.py
import hello_world
def write_to_log():
...
hello_world.subscribers.append(write_to_log)
技术三:猴子补丁
我们的最后一项技术,猴子补丁,与其他技术非常不同,因为它没有使用上面描述的控制反转模式。
如果我们的 hello_world
函数没有实现任何用于注入其输出函数的钩子,我们可以用不同的东西猴子补丁内置的 print
函数
# main.py
import hello_world
from print_twice import print_twice
hello_world.print = print_twice
if __name__ == "__main__":
hello_world.hello_world()
猴子补丁有其他形式。您可以随心所欲地操作在其他地方定义的某个不幸的类——更改属性、交换其他方法,以及通常对其执行您喜欢的任何操作。
选择技术
鉴于这三种技术,您应该选择哪一种,以及何时选择?
何时使用猴子补丁
滥用 Python 动态能力的代码可能极其难以理解或维护。问题在于,如果您正在阅读猴子补丁代码,您没有任何线索告诉您它在其他地方被操纵。
猴子补丁应该保留给绝望的时刻,在这些时刻您无法更改您正在修补的代码,并且真正、真正不切实际地做任何其他事情。
与其使用猴子补丁,不如使用其他控制反转技术之一要好得多。这些技术公开了一个 API,该 API 正式提供了其他代码可用于更改行为的钩子,这更容易推理和预测。
一个合法的例外是测试,您可以在其中使用 unittest.mock.patch
。这是猴子补丁,但它是测试代码时操纵依赖项的一种务实方法。即使这样,有些人也认为这样的测试是一种代码异味。
何时使用依赖注入
如果您的依赖项在运行时发生更改,您将需要依赖注入。它的替代方案注册表最好保持不变。您不希望更改注册表中的内容,除非在应用程序启动时。
json.dumps
是标准库中的一个很好的例子,它使用了依赖注入。它将 Python 对象序列化为 JSON 字符串,但如果默认编码不支持您尝试序列化的内容,它允许您传入自定义编码器类。
即使您不需要依赖项更改,如果您想要一种真正简单的方法来覆盖依赖项,并且不想要额外的配置机制,那么依赖注入也是一种很好的技术。
但是,如果您必须多次注入相同的依赖项,您可能会发现您的代码变得相当笨拙和重复。如果您只需要在调用堆栈的深处使用依赖项,并且不得不将其传递给许多函数,也可能发生这种情况。
何时使用注册表
如果依赖项可以在启动时固定,则注册表是一个不错的选择。虽然您可以使用依赖注入代替,但注册表是将配置与控制流代码分开的好方法。
当您需要将某些内容配置为单个值时,请使用配置注册表。如果已经有配置系统到位(例如,如果您正在使用具有提供全局配置的方法的框架),那么需要设置的额外机制就更少了。这方面的一个很好的例子是 Django 的 ORM,它围绕不同的数据库引擎提供了一个 Python API。ORM 不依赖于任何一个数据库引擎;相反,您配置您的项目以通过 Django 的配置系统使用特定的数据库引擎。
对于发布/订阅,或者当您依赖任意数量的值时,请使用订阅者注册表。Django 信号(一种发布/订阅机制)使用了这种模式。Django 中一个相当不同的用例是它的管理站点。这使用订阅者注册表来允许不同的数据库表向其注册,从而在 UI 中公开 CRUD 接口。
配置注册表可以代替订阅者注册表用于配置,例如,列表——如果您喜欢在单个地方进行链接,而不是将其分散在整个应用程序中。
结论
我希望这些例子,它们是我能想到的最简单的例子,已经展示了在 Python 中反转控制是多么容易。虽然它并不总是最明显的结构化方式,但只需很少的额外代码即可实现。
在现实世界中,您可能更喜欢使用更结构化的方式来应用这些技术。我经常选择类而不是函数作为可交换的依赖项,因为它们允许您以更正式的方式声明接口。依赖注入也有更复杂的实现,甚至有一些可用的第三方框架。
既有成本也有好处。在本地,采用 IoC 的代码可能更难理解和调试,因此请确保它总体上减少了复杂性。
无论您采用哪种方法,要记住的重要事情是,软件包中依赖项的关系对于理解和更改的难易程度至关重要。遵循最小阻力路径可能会导致依赖项以实际上不必要地难以使用的方式进行结构化。这些技术使您有能力在适当的地方反转依赖项,从而使您能够创建更可维护、更模块化的代码。明智地使用它们!