C++中的依赖反转(来自 S.O.L.I.D 原则)

Dependency inversion (from S.O.L.I.D principles) in C++

本文关键字:原则 来自 依赖 C++      更新时间:2023-10-16

在阅读和观看了很多关于 SOLID 原则之后,我非常热衷于在我的工作(主要是C++开发)中使用这些原则,因为我确实认为它们是很好的原则,它们确实会为我的代码质量、可读性、可测试性、重用性和可维护性带来很多好处。 但是我对"D"(依赖反转)感到非常困难。 该原则指出:
A.高级模块不应依赖于低级模块。两者都应该依赖于抽象。
B.抽象不应该依赖于细节。细节应该取决于抽象。

让我举例解释一下:
假设我正在编写以下接口:

class SOLIDInterface {
//usual stuff with constructor, destructor, don't copy etc
public:
virtual void setSomeString(const std::string &someString) = 0;
};

(为了简单起见,请忽略"正确界面"所需的其他内容,例如非虚拟公共,私有虚拟等,这不是问题的一部分。

请注意,setSomeString() 采用 std::string。
但这打破了上述原则,因为 std::string 是一个实现。
Java和C#没有这个问题,因为该语言为所有复杂的常见类型(如字符串和容器)提供了接口。
C++没有提供这一点。
现在,C++确实提供了以这样一种方式编写此接口的可能性,即我可以编写一个"IString"接口,该接口将采用任何支持使用类型擦除
的 std::string 接口的实现(非常好的文章:http://www.artima.com/cppsource/type_erasure.html)

所以实现可以使用STL(std::string)或Qt(QString),或者我自己的字符串实现或其他东西。
就像它应该是。

但这意味着,如果我(不仅是我,还有所有C++开发人员)想要编写C++遵循 SOLID 设计原则(包括"D")的 API,我将不得不实现大量代码以适应所有常见的非自然类型。
除了在工作量方面不切实际之外,此解决方案还存在其他问题,例如 - 如果 STL 发生变化怎么办?(对于此示例)
它并不是一个真正的解决方案,因为 STL 没有实现 IString,而是 IString 抽象了 STL,所以即使我要创建这样的接口,主要问题仍然存在。
(我什至没有遇到诸如这会增加多态开销的问题,对于某些系统,根据大小和硬件要求,这可能是不可接受的)

所以可能是问题是:
我在这里错过了一些东西(我猜这是真正的答案,但是什么?),有没有办法在C++中使用依赖反转,而无需以现实的方式为常见类型编写全新的接口层 - 或者我们注定要编写始终依赖于某些实现的 API?

谢谢你的时间!

从到目前为止我收到的前几条评论中,我认为需要澄清一下: std::string的选择只是一个例子。 它可能是QString-我只是选择了STL,因为它是标准。 它是字符串类型甚至不重要,它可以是任何常见的类型。

我选择Corristo的答案不是因为他明确回答了我的问题,而是因为广泛的帖子(加上其他答案)允许我隐含地从中提取我的答案,意识到讨论倾向于偏离实际问题,即: 当您使用基本的复杂类型(如字符串和容器)以及基本上任何有意义的 STL 时,您能否在 C++ 中实现依赖关系反转。(最后一部分是问题的一个非常重要的元素)。 也许我应该明确指出,我追求的是运行时多态性而不是编译时间。 明确的答案是否定的,这是不可能的。 如果 STL 向其实现公开抽象接口(如果确实有理由阻止 STL 实现从这些接口派生(例如,性能)),那么它仍然可以简单地维护这些抽象接口以匹配实现)。

对于我可以完全控制的类型,是的,实现 DIP 没有技术问题。 但很可能任何这样的接口(我自己的)仍将使用字符串或容器,迫使它使用 STL 实现或其他实现。 下面所有建议的解决方案要么在运行时不是多态的,要么/并且强制在界面周围安静一些编码 - 当你认为你必须为所有这些常见类型执行此操作时,实用性根本不存在。

如果你认为你知道得更好,并且你说有可能拥有我上面描述的内容,那么只需发布证明它的代码。 我敢!:-)

请注意,C++不是一种面向对象的编程语言,而是让程序员在许多不同的范式之间进行选择。C++的关键原则之一是零成本抽象,这尤其需要以用户不为他们不使用的东西付费的方式构建抽象。

但是,使用虚拟方法定义接口的 C#/Java 风格,然后由派生类实现,不属于该类别,因为即使您不需要多态行为,std::string实现虚拟接口,其方法之一的每次调用都会导致 vtable 查找。对于应该在各种设置中使用的C++标准库中的类,这是不可接受的。

定义接口而不从抽象接口类继承

C#/Java 方法的另一个问题是,在大多数情况下,您实际上并不关心某些内容是否继承自特定的抽象接口类,只需要传递给函数的类型支持您使用的操作。因此,将接受的参数限制为从特定接口类继承的参数实际上阻碍了现有组件的重用,并且您通常最终会编写包装器以使一个库的类符合另一个库的接口 - 即使它们已经具有完全相同的成员函数。

再加上基于继承的多态性通常还需要堆分配和引用语义及其与生存期管理有关的所有问题,因此最好避免从C++中的抽象接口类继承。

隐式接口的泛型模板

在C++中,您可以通过模板获得编译时多态性。 在最简单的形式中,模板化函数或类中使用的对象需要符合的接口实际上并不C++代码中指定,而是通过对它们调用的函数来暗示。

这是STL中使用的方法,它非常灵活。以std::vector为例。在那里,对存储在其中的对象的值类型T的要求取决于您对向量执行的操作。例如,这允许存储仅移动类型,只要您不使用任何需要复制的操作。在这种情况下,定义值类型需要符合的接口将大大降低std::vector的有用性,因为您要么需要删除需要副本的方法,要么需要从其中排除仅移动类型。

不过,这并不意味着您不能使用依赖反转:使用模板实现的依赖反转的常见 Button-Lamp 示例如下所示:

class Lamp {
public:
void activate();
void deactivate();
};
template <typename T>
class Button {
Button(T& switchable)
: _switchable(&switchable) {
}
void toggle() {
if (_buttonIsInOnPosition) {
_switchable->deactivate();
_buttonIsInOnPosition = false;
} else {
_switchable->activate();
_buttonIsInOnPosition = true;
}      
}
private:
bool _buttonIsInOnPosition{false};
T* _switchable;  
}
int main() {
Lamp l;
Button<Lamp> b(l)
b.toggle();
}

这里Button<T>::toggle隐式依赖于Switchable接口,要求T具有成员函数T::activateT::deactivate。由于Lamp碰巧实现了该接口,因此它可以与Button类一起使用。当然,在实际代码中,您还将在Button类的文档中说明这些T要求,以便用户无需查找实现。

同样,您也可以将setSomeString方法声明为

template <typename String>
void setSomeString(String const& string);

然后这将适用于实现您在实现setSomeString时使用的所有方法的所有类型,因此仅依赖于抽象的 - 尽管是隐式的 - 接口。

与往常一样,需要考虑一些缺点:

  • 在字符串示例中,假设您只使用.begin().end()成员函数返回迭代器,这些迭代器在取消引用时返回char(例如,将其复制到类的本地具体字符串数据成员中),您也可能意外地将std::vector<char>传递给它,即使它在技术上不是字符串。如果你认为这是一个有争议的问题,在某种程度上,这也可以看作是只依赖抽象的缩影。

  • 如果传递的类型对象没有必需的(成员)函数,则最终可能会得到可怕的编译器错误消息,从而很难找到错误的源。

  • 只有在非常有限的情况下,才可以将模板化类或函数的接口与其实现分开,就像通常使用单独的.h.cpp文件一样。因此,这可能会导致更长的编译时间。

使用 TS 概念定义接口

如果你真的关心模板化函数和类中使用的类型以符合固定接口,不管你实际使用什么,都有办法将模板参数限制为仅符合具有std::enable_if的某个接口的类型,但这些非常冗长且不可读。为了使这种泛型编程更容易,Concepts TS允许实际定义由编译器检查的接口,从而大大改善诊断。使用概念 TS,上面的按钮灯示例转换为

template <typename T>
concept bool Switchable = requires(T t) {
t.activate();
t.deactivate();
};
// Lamp as before
template <Switchable T>
class Button {
public:
Button(T&);    // implementation as before
void toggle(); // implementation as before
private:
T* _switchable;
bool _buttonIsInOnPosition{false};
};

如果你不能使用概念TS(它现在只在GCC中实现),你可以得到的最接近的是Boost.ConceptCheck库。

运行时多态性的类型擦除

有一种情况下,编译时多态性是不够的,那就是当你传递给特定函数或从特定函数获取的类型在编译时没有完全确定,而是取决于运行时参数(例如,从配置文件、传递给可执行文件的命令行参数,甚至是传递给函数本身的参数值)。

如果需要存储依赖于运行时参数的类型的对象(甚至在变量中),则传统方法是存储指向公共基类的指针,并通过虚拟成员函数使用动态调度来获取所需的行为。但这仍然存在前面描述的问题:您不能使用有效地执行所需操作但在外部库中定义的类型,因此不会从您定义的基类继承。所以你必须编写一个包装类。

或者,您按照问题中的描述进行操作并创建一个类型擦除类。 标准库中的一个示例是std::function。仅声明函数的接口,它可以存储具有该接口的任意函数指针和可调用对象。一般来说,编写类型擦除类可能非常乏味,所以我不在这里给出类型擦除Switchable的例子,但我强烈推荐 Sean Parent 的演讲继承是邪恶的基础类,他演示了"可绘制"对象的技术,并在短短 20 多分钟内探索了您可以在其上构建的内容。

不过,有一些库可以帮助编写类型擦除类,例如Louis Dionne的实验性dyno,您可以在其中通过他所谓的"概念图"直接在C++代码中定义接口,或者Zach Laine的emtypen,它使用python工具从您提供的C++头文件创建类型擦除类。后者还附带一个CppCon演讲,描述了功能以及一般想法以及如何使用它。

结论

从公共基类继承只是为了定义接口,虽然很容易,但会导致许多问题,这些问题可以使用不同的方法避免:

  • (受约束的)模板允许编译时多态性,这对于大多数情况来说已经足够了,但在与不符合接口的类型一起使用时,可能会导致难以理解的编译器错误。

  • 如果您需要运行时多态性(根据我的经验,这实际上很少见),则可以使用类型擦除类。

因此,即使 STL 和其他C++库中的类很少从抽象接口派生,如果您真的愿意,您仍然可以使用上述两种方法之一应用依赖反转。

但与往常一样,根据具体情况使用良好的判断,无论你是否真的需要抽象,或者是否最好简单地使用具体类型。您提出的字符串示例是我将使用具体类型的示例,仅仅是因为不同的字符串类不共享一个通用接口(例如std::string.find(),但同一函数的QString版本称为.contains())。为两者编写包装类可能与编写转换函数并在项目中明确定义的边界使用它一样多。

啊,但是C++允许您编写独立于特定实现的代码,而无需实际使用继承。

std::string本身就是一个很好的例子...它实际上是std::basic_string<char, std::char_traits<char>, std::allocator<char>>的 typedef . 这允许您使用其他分配器创建字符串(或者模拟分配器对象以测量调用次数,如果您愿意)。 只是没有任何像IAllocator这样的显式接口,因为C++模板使用鸭子类型。

C++的未来版本将支持对模板参数必须遵守的接口的显式描述 - 此功能称为概念 - 但仅使用鸭子类型即可解耦,而无需冗余接口定义。

由于C++在模板实例化执行优化,因此没有多态开销。

现在,当您确实有虚拟函数时,您需要提交到特定类型,因为虚拟表布局不允许使用模板,每个模板都会生成任意数量的实例,每个实例都需要单独调度。 但是在使用模板时,你不会像Java那样需要虚拟函数,所以在实践中这不是一个大问题。