我正在编写一个库,我通过使它的函数和析构函数纯虚拟来为每个类定义一个接口。现在,随着时间的推移,我经历了这种设计的许多缺点( - 仅举几个例子:没有可能的静态方法,很多虚拟继承,当然,虚函数极其缓慢。) 我在界面中看到的唯一优势是为用户提供简单的界面并隐藏其背后的复杂细节。 但考虑到所有的缺点,我不明白为什么即使是大型的已知库也在使用接口。 (例如,Ogre 3D,Irrlicht和许多其他3D库,其中性能是最重要的。) 我的问题是: 有没有一个令人信服的观点,我错过了为什么要使用接口?为什么其他人这样做?更常见的是 - 使用接口或 不使用它们? 此外,使用接口时 - 制作某种“混合”设计是否有效?哪些依赖性能的类直接在接口层实现,以避免虚函数调用,所有其他类都照常实现?或者这是一个糟糕的设计?
答案 0 :(得分:5)
为什么要使用界面?
“接口”在C ++中不是一个定义明确的术语:有些人认为任何具有虚方法的基类都是接口,而其他人则认为没有数据成员,或没有公共数据成员,或者没有私有数据成员;一些人可能会说所有成员必须是虚拟的,而其他人则必须是纯虚拟的。
每个设计决策都有利弊:
具有虚函数的基类是C ++运行时多态的机制,这是使用它们的一个很好的理由
将公共数据保留在基类之外可以保留动态计算数据的自由
将私有数据保留在基类之外,避免了只在实现发生变化时必须在其中进行更改;这样的更改会强制客户端重新编译而不是重新链接(当动态链接的共享对象/库中的实现只需要重新链接时,只能重新链接,因为只需要分发更新的库)
虚拟调度可以轻松实现状态机(在运行时更改implementatino),以及切换模拟实现以进行测试
更常见的是 - 使用界面还是不使用界面?
这在很大程度上取决于应用程序的类型,数据输入或状态是否自然地受益于运行时多态性,以及程序员所涉及的设计决策。 C ++用于如此普遍不同的目的,没有一般声明的意义。
此外,当使用接口时 - 是否有效进行某种“混合”设计?
是的 - 一些“混合”方法列在下面的“缓解”下。
“虚拟功能非常慢”
实际虚拟调度必然是脱节的,因此如果做一些非常简单的事情(例如int
成员的getter / setter),可能比内联调用差一个数量级,但请参见下面的缓解。 (如果在编译时知道所涉及变量的动态类型,优化器通常可以避免虚拟分派。)
“没有可能的静态方法”
每个类都可以有静态方法 - 没有办法以多态方式调用它们,但它甚至意味着什么呢?您必须有一些方法来了解动态/运行时类型,因为这是选择调用哪个函数的基础....
调整性能有很多选项 - 当您仔细考虑实际的性能问题时,您应该经常看到这些选项。以下是一个随机的,以尝试可能的和偶尔有用的......
尽量为每个虚函数调用做尽可能多的工作。例如,采用单个像素的set_pixel
函数通常是糟糕的界面设计。可以采用任意长列表的set_pixels
函数会好得多,但还有许多其他选择,例如提供某种虚拟绘图表面,客户端代码可以在没有运行时多态分派的情况下工作,然后传回一个虚函数调用中的整个表面。
您可以手动编排目标(每个性能分析结果)从运行时到编译时多态的切换(尽管以手动维护集中切换例程为代价。
实施例
假设基类B
包含virtual void f();
,两个派生D1
,D2
。
首先,一些明确的neuter虚拟调度的多语言算法代码:
template <typename T>
struct Algo
{
void operator()(T& t)
{
.. do lots of stuff...
t.T::f(); // each t member access explicitly dispatched statically
...lots more...
}
};
然后,根据动态类型将一些代码分派到指定算法的静态类型特定实例:
template <template <typename> class F>
void runtime_to_compiletime(B& b) {
if (D1* p = dynamic_cast<D1*>(&b))
F<D1>()(*p);
else if (D2* p = dynamic_cast<D2*>(&b))
F<D2>()(*p);
}
用法:
D1 d1;
D2 d2;
runtime_to_compiletime<Algo>(d1);
runtime_to_compiletime<Algo>(d2);
如果在实施过程中dynamic_cast速度过慢,您可以快速切换动态类型 - 以相当大的成本维护它 - 如下所示:
struct Base
{
Base() : type_(0) { }
int get_type() const { return type_; }
protected:
Base(int type) : type_(type) { }
int type_;
};
struct Derived : Base
{
Derived() : Base(1) { }
};
然后快速切换是微不足道的:
void f(Base* p)
{
switch (p->get_type())
{
... handle using static type in here ...
}
}
而不是virtual int f() const;
公开只有少数派生类需要动态计算的int
数据成员,请考虑:
class Base
{
public:
Base() : virtual_f_(false) { }
int f() const { return virtual_f_ ? virtual_f() : f_; }
private:
int f_;
bool virtual_f_;
virtual int f() const { }
};
答案 1 :(得分:3)
接口只是C ++为提高可重用性和可扩展性而提供的众多机制之一。
<强>重用强>
如果类A
具有指向具体类B
的指针,则不能在没有A
的情况下重新使用类B
。
解决方案:您引入了I
实现的接口B
,A
有一个指向I
的指针。通过这种方式,您可以在软件(或其他应用程序)中重复使用A
类,而不是B
(请注意,您将I
与A
一起使用,因此您需要不管怎样实现它)
<强>可扩展性强>
如果类A
具有指向具体类B
的指针,则类A
受限于使用B
提供的“算法”。将来,如果您需要使用不同的“算法”,则必须修改A
源代码。
解决方案:如果A
有指向接口I
的指针,则可以自由更改I
实施(例如,您可以用B
替换C
},无需修改I
源代码即可实现A
。
(顺便说一下:测试的模拟实现包含在可扩展性案例中)。
让我们回顾一下:
std::function
,boost::signal
等)。答案 2 :(得分:1)
我认为你可以使用下一种方法:当你有相同接口的多个实现并且应该在运行时执行选择(可能那些接口和实现包装某种“策略”等)然后你应该使用“interface-实现“方法(工厂创建等),当它是某种实用功能时 - 比你应该避免”接口实现“方法。您也不应忘记库和主代码之间正确的对象创建/销毁调用。希望这会有所帮助。
答案 3 :(得分:1)
使用非侵入式多态性http://isocpp.org/blog/2012/12/value-semantics-and-concepts-based-polymorphism-sean-parent可以通过真正将接口与实现分离来帮助解决多重继承和虚拟继承问题。这应该消除了对虚拟继承的需要。在我个人看来,虚拟继承是糟糕/旧设计的标志。
此外,如果您使用多态来实现开放的闭合主体,那么通过CRTP的静态多态性可以更快。
class Base {
virtual void foo(){
//default foo which the suer can override
}
void bar(){
foo();
}
}
class UserObject : public Base{
void foo() override{
//I needed to change default foo,
//this probably cannot be inlined unless the compiler is really
//good at devirtialization
}
}
成为
template<typename T_Derived>
class Base {
virtual void foo(){
//default foo which the suer can override
}
void bar(){
static_cast<T_Derived*>(this)->foo();
}
}
class UserObject : public Base<UserObject>{
void foo() {
//I needed to change default foo, ths can be inlined no problem
}
}
答案 4 :(得分:1)
接口的一个优点是可以编写单元测试。编写使用接口的组件时,可以实现简单的假接口版本。可以将伪造版本提供给在单元测试期间使用的组件。这意味着单元测试会很快,因为它们并不真正执行库操作。您可以对接口的伪造实现进行编码,以便将值和数据返回到组件,使其执行某些代码路径,而伪实现可以检查组件是否预期调用了接口。
这说服了我!显然,并非所有库都是相同的。编写虚假版本的3D图形库可能并不总是有用,因为您真的需要亲眼看看图像是否正确,因为单元测试可能很难编码以检查输出是否正确。但是,对于许多其他应用程序,单元测试值得额外工作,因为它们使您有信心对代码库进行更改并确保它仍然可以作为行为,并有助于确保质量。