1.7.5 依赖倒置原则

依赖倒置原则(DIP)可以用于解耦。本质上,这意味着高级模块不依赖于低级模块,两者都依赖于抽象。

C++允许用两种方法倒置类之间的依赖关系。第一种方法是常规的多态方法,第二种方法是使用模板。我们将看看如何在实践中应用它们。

假设你正在建模一个有前端和后端开发人员的软件开发项目。一种简单的方法是这样写:

每个开发人员(FrontEndDeveloper和BackEndDeveloper)都是由Project类构造的。然而,这种方法并不理想,因为现在高级概念(Project)依赖于低级概念——单个开发人员模块。我们来看使用多态实现的依赖倒置是如何改变这一点的。我们可以将开发人员定义为依赖如下接口:

现在,Project类就不再需要知道开发人员(Developer)的实现了。因此,Project必须接受它们作为构造函数的参数:

在这种方法中,Project与具体的实现解耦了,只依赖于名为Developer的多态接口。“较低级别的”具体类也依赖于这个接口。这可以帮助你缩短构造时间,并让单元测试更简单——现在你可以轻松地将模拟(mock)对象作为参数传递到测试代码中。

然而,用虚分派(virtual dispatch)来实现依赖倒置是有代价的,因为我们处理的是内存分配,而动态分派(dynamic dispatch)本身就有开销。有时,C++编译器可以检测到只有一个实现被用于给定的接口,并通过去虚拟化(devirtualization)来消除开销(通常需要将函数标记为final才行)。但是,这里接口使用了两种实现,因此必须付出动态分派的代价(通常是通过虚函数表跳转,虚函数表也称为vtable)。

还有另一种倒置依赖关系的方法,它没有这些缺点。我们来看如何使用可变参数模板(variadic template)、C++14的泛型lambda和C++17或第三方库(如Abseil或Boost)中的变体(variant)来实现这一点。首先是开发人员(FrontEndDeveloper和BackEndDeveloper)类:

现在,我们不再依赖接口了,所以不会进行虚分派。Project类仍然接受一个Developers(FrontEndDeveloper和BackEndDeveloper)的vector:

你可能不熟悉variant,它只是一个类,可以接受模板参数传递的任何类型。因为我们使用的是可变参数模板,所以我们可以传递任意多类型。要调用存储在variant中的对象的函数,我们可以使用std::get或std::visit和可调用对象来提取它——在本例中是泛型lambda。它展示了鸭子类型是什么样子的。由于所有的开发人员类都实现了develop函数,所以代码可以进行编译和运行。如果开发人员类有不同的方法,则可以创建一个函数对象,通过重载操作符()来处理不同类型。

因为Project现在是一个模板,所以我们必须在每次创建它时指定类型列表,或者提供一个类型别名。最后,我们可以像这样使用这个类:

这种方法保证不会为每个开发人员分配单独的内存或使用虚函数表。但是,在某些情况下,这种方法会导致可扩展性降低,因为一旦声明了variant,就不能向其添加其他类型了。

关于依赖倒置,最后想提一点,有一个名称类似的概念,即依赖注入(dependency injection),我们在示例中使用过这个概念。依赖注入指通过构造函数或设置函数(setter)注入依赖关系,这可能有利于代码的可测试性(例如,考虑注入模拟对象)。甚至有完整的框架用于在整个应用程序中注入依赖关系,比如Boost.DI。这两个概念是相关的,经常一起使用。