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

时间:2017-04-23 21:12:54

标签: c++ oop

在阅读并关注SOLID原则后,我非常热衷于在我的工作中使用这些原则(主要是C ++开发),因为我认为它们是很好的原则,它们确实会为我的代码质量带来很多好处,可读性,可测试性,重用性和可维护性。 但是我真的很难与D' D&#39 (依赖性倒置)。 该主要说明:
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 ++开发人员)都想编写符合SOLID设计原则的C ++ API(包括D' D),我将不得不实现大量代码来适应所有常见的非自然类型 除了在努力方面不现实之外,这个解决方案还有其他问题,例如 - 如果STL改变了怎么办?(对于这个例子)
它不是真正的解决方案,因为STL没有实现IString,而IString正在抽象STL,所以即使我要创建这样的接口,主要问题仍然存在。
(我甚至没有遇到这样的问题,这增加了多态开销,对某些系统来说,根据尺寸和硬件要求可能是不可接受的)

所以可能有疑问是:
我在这里遗漏了一些东西(我想这是真正的答案,但又是什么?),有没有办法在C ++中使用依赖性反转而无需以实际方式为常见类型编写全新的界面层 - 或者我们注定要编写始终依赖于某些实现的API吗?
谢谢你的时间!

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

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

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

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

2 个答案:

答案 0 :(得分:6)

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

然而,用衍生类实现的用虚拟方法定义接口的C#/ Java风格不属于那个类别,因为即使你不需要多态行为,也是{{1实现虚拟接口,其中一个方法的每次调用都会产生vtable查找。对于应该在各种设置中使用的C ++标准库中的类,这是不可接受的。

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

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

与基于继承的多态通常还需要堆分配和引用语义及其有关生命周期管理的所有问题这一事实一起,最好避免继承C ++中的抽象接口类。

隐式接口的通用模板

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

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

但这并不意味着您无法使用依赖项反转:使用模板实现的依赖项反转的公共Button-Lamp example如下所示:

std::vector

此处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拥有成员函数TT::activate。由于T::deactivate碰巧实现了该接口,因此它可以与Lamp类一起使用。当然,在实际代码中,您还会在Button类的文档中的T上说明这些要求,以便用户不需要查找实现。

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

setSomeString

然后这将适用于实现在template <typename String> void setSomeString(String const& string); 的实现中使用的所有方法的所有类型,因此仅依赖于抽象 - 尽管是隐式 - 接口。

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

  • 在字符串示例中,假设您只使用setSomeString.begin()成员函数返回在解除引用时返回.end()的迭代器(例如将其复制到类& #39;本地的,具体的字符串数据成员),你也可以不小心地将char传递给它,即使它在技术上不是一个字符串。如果你认为这个问题是有争议的,那么在某种程度上,这也可以被视为仅依赖于抽象的缩影。

  • 如果您传递的类型的对象没有所需的(成员)函数,那么您最终可能会遇到可怕的编译器错误消息,这使得很难找到错误的来源

  • 仅在非常有限的情况下,可以将模板化类或函数的接口与其实现分开,这通常使用单独的std::vector<char>.h文件。这可能会导致更长的编译时间。

使用Concepts TS

定义接口

如果您真的关心模板化函数和类中使用的类型以符合固定接口,无论您实际使用什么,都有办法将模板参数仅限制为符合{{1}的特定接口的类型但是,这些都不是非常易读且非常冗长。为了使这种通用编程更容易,Concepts TS允许实际定义由编译器检查的接口,从而大大改进了诊断。使用Concepts TS,上面的Button-Lamp示例转换为

.cpp

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

运行时多态性的类型擦除

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

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

或者您执行您在问题中描述的内容并创建类型擦除类。 标准库中的一个示例是std::enable_if。您只声明函数的接口,它可以存储具有该接口的任意函数指针和可调用对象。一般来说,编写类型擦除类可能相当繁琐,所以我不在这里给出一个类型擦除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}; }; 的例子,但我强烈推荐Sean Parent的讲话Inheritance is the base class of evil,在那里他演示了&#34; Drawable&#34;在20分钟内完成对象并探索可以在其上构建的内容。

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

结论

继承公共基类只是为了定义接口,虽然简单,但却导致许多问题可以通过不同的方法来避免:

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

  • 如果你需要运行时多态(实际上我的经验实际上很少见),你可以使用typ-erasure类。

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

但与往常一样,无论您是否真的需要抽象,或者只是简单地使用具体类型,都要根据具体情况使用良好判断。您提出的字符串示例是我使用具体类型的示例,因为不同的字符串类不共享公共接口(例如std::function具有Switchable,但{ {1}}同一函数的版本称为std::string)。编写转换函数并在项目中明确定义的边界使用它时,为两者编写包装类可能同样费力。

答案 1 :(得分:2)

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

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

C ++的未来版本将支持模板参数必须遵守的接口的显式描述 - 此功能称为概念 - 但只使用duck-typing启用解耦而无需冗余接口定义。

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

现在,当您拥有虚拟功能时,您需要提交特定类型,因为虚拟表布局不能容纳模板的使用,每个模板都会生成任意数量的实例其中需要单独发送。但是在使用模板时,您几乎不需要虚拟功能,例如Java确实如此,所以在实践中这不是一个大问题。