在阅读并观察了很多关于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++开发人员)想要编写遵循SOLID设计原则(包括'D')的C++ API,我将不得不实现大量代码以适应所有常见的非自然类型.
除了在努力方面不现实之外,这个解决方案还有其他一些问题,例如 - 如果STL发生了变化?(对于这个例子)
并且它不是真正的解决方案,因为STL没有实现IString,而IString正在抽象STL,所以即使我是创建这样一个接口,主要问题仍然存在.
(我甚至没有遇到这样的问题,这会增加多态开销,对某些系统而言,取决于大小和硬件要求可能是不可接受的)
所以可能会问:
我在这里遗漏了什么(我猜这是真正的答案,但是什么?),是否有一种方法可以在C++中使用依赖性反转,而无需以实际方式为常见类型编写全新的接口层- 或者我们注定要编写始终依赖于某些实现的API?
谢谢你的时间!
编辑:从我到目前为止收到的前几条评论中我认为需要澄清:std :: string的选择只是一个例子.它可能是QString - 我只是采用STL,因为它是标准.它的字符串类型甚至不重要,它可以是任何常见类型.
EDIT2:我选择Corristo的答案不是因为他明确地回答了我的问题,而是因为广泛的帖子(加上其他答案)让我能够隐含地从中提取答案,意识到讨论倾向于偏离实际问题是:当你使用基本的复杂类型(如字符串和容器)以及任何有意义的STL时,你能用C++实现依赖性反转吗?(最后一部分是问题的一个非常重要的元素).也许我应该明确地指出我在运行时多态性之后没有编译时间.明确的答案是否定的,这是不可能的.如果STL将抽象接口暴露给它们的实现(如果确实存在阻止STL实现从这些接口派生的原因(比如性能)),那么它可能是可能的,那么它仍然可以简单地维护这些抽象接口以匹配实现).
对于我完全可以控制的类型,是的,实现DIP没有技术问题.但很可能任何这样的接口(我自己的)仍将使用字符串或容器,迫使它使用STL实现或其他.以下所有建议的解决方案要么在运行时不是多态的,要么/并且在界面周围强制安静一些编码 - 当你认为你必须为所有这些常见类型执行此操作时,实际情况就不存在了.
如果你认为你知道的更好,并且你说我可以拥有上面描述的内容,那么只需发布证明它的代码即可.我赌你!:-)
1> Corristo..:
请注意,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
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 b(l)
b.toggle();
}
这里Button::toggle
隐含地依赖于一个Switchable
接口,需要T
具有成员函数T::activate
和T::deactivate
.由于Lamp
恰好实现了该接口,因此可以与Button
该类一起使用.当然,在实际代码中,您还要T
在Button
类的文档中说明这些要求,以便用户不需要查找实现.
同样,您也可以将setSomeString
方法声明为
template
void setSomeString(String const& string);
然后这将适用于实现您在实现中使用的所有方法的所有类型setSomeString
,因此仅依赖于抽象 - 尽管是隐式 - 接口.
与往常一样,需要考虑一些缺点:
在字符串示例中,假设您只使用.begin()
和.end()
成员函数返回迭代器,这些迭代器返回一个char
取消引用的时间(例如,将其复制到类的本地,具体的字符串数据成员),您也可以意外地传递std::vector
给它,即使它在技术上不是一个字符串.如果你认为这个问题是有争议的,那么在某种程度上,这也可以被视为仅仅依赖于抽象的缩影.
如果传递的类型的对象没有所需的(成员)函数,那么最终可能会出现可怕的编译器错误消息,这使得很难找到错误的来源.
仅在非常有限的情况下,可以将模板化类或函数的接口与其实现分开,这通常使用单独的文件.h
和.cpp
文件来完成.因此,这可能导致更长的编译时间.
使用Concepts TS定义接口
如果您真的关心模板化函数和类中使用的类型以符合固定接口,无论您实际使用什么,都有办法将模板参数仅限制为符合某个接口的类型std::enable_if
,但这些不是很可读且非常冗长.为了使这种通用编程更容易,Concepts TS允许实际定义由编译器检查的接口,从而大大改进了诊断.使用Concepts TS,上面的Button-Lamp示例转换为
template
concept bool Switchable = requires(T t) {
t.activate();
t.deactivate();
};
// Lamp as before
template
class Button {
public:
Button(T&); // implementation as before
void toggle(); // implementation as before
private:
T* _switchable;
bool _buttonIsInOnPosition{false};
};
如果你不能使用Concepts TS(它现在只在GCC中实现),你可以得到的最接近的是Boost.ConceptCheck库.
为运行时多态性键入擦除
有一种情况是编译时多态性不够,那就是在传递给特定函数或从特定函数获取的类型在编译时没有完全确定但依赖于运行时参数(例如来自配置文件,传递给可执行文件的命令行参数,甚至传递给函数本身的参数值.
如果需要存储依赖于运行时参数的类型的对象(甚至是变量),传统方法是将指针存储到公共基类,并通过虚拟成员函数使用动态调度来获取所需的行为.但是这仍然存在以前描述的问题:您不能使用有效地执行您需要但在外部库中定义的类型,因此不会从您定义的基类继承.所以你必须编写一个包装类.
或者您执行您在问题中描述的内容并创建类型擦除类.标准库的一个例子是std::function
.您只声明函数的接口,它可以存储具有该接口的任意函数指针和可调用对象.一般来说,编写类型擦除类可能非常繁琐,所以我不在Switchable
这里举一个类型擦除的例子,但我强烈推荐Sean Parent的讲话继承是邪恶的基类,在那里他演示了"可绘制的"对象,并在20分钟内探索您可以在其上构建的内容.
有些库可以帮助编写类型擦除类,例如Louis Dionne的实验性dyno,你可以通过他在C++代码中直接用"概念图"来定义接口,或者Zach Laine的emtypen使用python工具创建类型擦除您提供的C++头文件中的类.后者还附带了一个CppCon演讲,描述了这些特性以及一般的想法以及如何使用它.
结论
继承公共基类只是为了定义接口,虽然简单,但却导致许多问题可以使用不同的方法来避免:
(约束)模板允许编译时多态,这对于大多数情况是足够的,但是当与不符合接口的类型一起使用时,可能导致难以理解的编译器错误.
如果你需要运行时多态性(实际上我的经验实际上很少见),你可以使用typ-erasure类.
因此,即使STL和其他C++库中的类很少派生自抽象接口,如果您真的想要,仍然可以使用上述两种方法之一应用依赖项反转.
但是,一如既往,根据具体情况使用良好的判断,无论您是否真的需要抽象,或者只是简单地使用具体类型.您提出的字符串示例是我将使用具体类型的示例,因为不同的字符串类不共享公共接口(例如,std::string
有.find()
,但QString
调用相同函数的s版本.contains()
).编写转换函数并在项目中明确定义的边界使用它时,为两者编写包装类可能同样费力.