C++接口与模板的区别

38

我有两个解决方案针对同一个问题——从一个“控制器”向使用的对象进行某种回调,但我不知道选择哪个。

解决方案1:使用接口

struct AInterface
{
    virtual void f() = 0;
};

struct A : public AInterface
{
    void f(){std::cout<<"A::f()"<<std::endl;}
};

struct UseAInterface
{
    UseAInterface(AInterface* a) : _a(a){}
    void f(){_a->f();}

    AInterface* _a;
};

解决方案2:使用模板。
struct A
{
    void f(){std::cout<<"A::f()"<<std::endl;}
};

template<class T>
struct UseA
{
    UseA(T* a) : _a(a){}
    void f(){_a->f();}

    T* _a;
};

这只是一个简单的例子,以说明我的问题。在真实世界中,接口将有几个函数,一个类可能(而且会!)实现多个接口。

代码不会用作外部项目的库,我也不必隐藏模板实现 - 我之所以说这个是因为如果我需要隐藏"控制器"实现,则第一种情况会更好。

您能告诉我每种情况的优缺点,并且哪种更好使用吗?


6
在AInterface上应该有一个虚析构函数,否则继承它的对象可能无法调用析构函数。 - Steve
1
@Steve:实际上,不一定。你也可以完全拥有一个“protected”析构函数。 - Matthieu M.
7个回答

59

在我看来,性能应该被忽略(其实不完全是这样,但微小的优化应该被忽略),直到你有了理由去关注它。如果没有一些硬性要求(比如这是一个占用大部分 CPU 时间的紧凑循环,接口成员函数的实际实现非常小...),很难甚至不可能注意到差异。

所以,我会集中精力在更高的设计层面上。在 UseA 中使用的所有类型是否共享一个公共基类是有意义的吗?它们真的相关吗?这两种类型之间是否有明确的“is-a”关系?那么面向对象的方法可能适用。它们是否无关?也就是说,它们共享一些特征,但没有直接的“is-a”关系可以进行建模?那就采用模板方法。

模板的主要优点是可以使用不符合特定且精确继承层次结构的类型。例如,在向量中可以存储任何可复制构造(在 C++11 中可以移动构造)的内容,但 intCar 实际上没有任何联系。这样,可以减少与 UseA 类型一起使用的不同类型之间的耦合。

模板的一个缺点是,每个模板实例化都是一个不相关于同一基本模板中生成的其他模板实例化的不同类型。这意味着你不能将 UseA<A>UseA<B> 存储在同一个容器中,会有代码膨胀(UseA<int>::fooUseA<double>::foo 都在二进制文件中生成),长时间编译(即使不考虑额外的函数,使用 UseA<int>::foo 的两个翻译单元都将生成相同的函数,链接器必须丢弃其中之一)。

关于其他答案声称的性能,它们有些是正确的,但大多数都忽略了重要的点。选择模板而不是动态分派的主要优点不是动态分派的额外开销,而是编译器可以内联小函数(如果函数定义本身可见)。

如果这些函数没有被内联,除非函数只需要很少的周期执行,否则函数的总成本将超过动态分派的额外成本(即调用中的额外间接寻址和多/虚拟继承的this指针可能偏移的情况)。如果这些函数执行一些实际的工作,并且/或者它们不能被内联,它们的性能将相同。

即使在极少数情况下,其中一个方法与另一个方法相比的性能差异是可测量的(例如函数仅需要两个周期,因此分派会使每个函数的成本翻倍),如果此代码是占据CPU时间80%以下的20%的代码之一,并且假设此特定代码占据1%的CPU(如果您考虑到为使性能显着,函数本身必须只占1或2个周期!),那么你谈论的是1小时程序运行中的30秒。再次检查假设,在2GHz CPU上,1%的时间意味着函数每秒必须被调用超过1000万次。

以上都是描绘性的,而且与其他答案相反(即,有一些不精确性可能会使其看起来差异更小,但现实则更接近于这个总体答案:动态分派会使你的代码变慢


1
+1 设计由所需的抽象决定,实现和性能调优稍后进行。 - TemplateRex
顺便说一句,在我的经验中,运行时多态的最大开销通常是与之相关的动态分配。例如,考虑一个天真的国际象棋引擎,它有一个包含64个“IPiece*”的棋盘结构,每秒创建和删除1M+次:这里不会影响性能的是指针追踪(但当然你可以使用Flyweight并使用板指针来消除分配)。 - TemplateRex
@rhalbersma:正确的做法是使用分配器,或者……您真的需要首先重新创建对象吗?但这些都与模板/OO方法无关,在这种特殊情况下,模板将无济于事,因为假定数组中需要能够保存不同类型的元素。 - David Rodríguez - dribeas
关于语句“您可以使用不符合特定和精确继承层次结构的类型”,在大多数情况下,不能通过适配器实现相同的效果吗?如果A是一个使用B作为模板参数传递的模板类,例如A<B>,那么A使用的B的方法是否可以封装到一个抽象类IB中,然后通过模板传递给A的具体类的适配器派生并实现IB? - Fabio A.

24
每种方法都有其优点和缺点。从C++编程语言中可以看出:
  1. 在运行时效率至关重要时,请使用模板而不是派生类。
  2. 如果重要的是添加新变体而无需重新编译,请使用派生类而不是模板。
  3. 当无法定义公共基类时,请使用模板而不是派生类。
  4. 如果内置类型和带有兼容性约束的结构很重要,请使用模板而不是派生类。
然而,模板也有其缺点
  1. 使用OO接口的代码可以隐藏在.cpp/.CC文件中,而模板强制在头文件中公开整个代码;
  2. 模板会导致代码膨胀;
  3. OO接口是显式的,而对模板参数的要求是隐式的,只存在于开发人员的头脑中;
  4. 大量使用模板会影响编译速度。

使用哪种取决于您的情况,有些取决于您的偏好。模板代码可能会产生一些晦涩难懂的编译错误,这导致了诸如STL Error decrypt之类的工具的出现。希望很快就能实现这些概念。


+1 很好的回答!“钝”绝对是用于模板编译错误的一个好形容词 :) - Brady
模板可以导致代码膨胀,但并非总是如此。这取决于您将实例化该模板的不同方式以及编译器可以优化掉其中多少个实例(通过证明目标代码与另一个代码相同)。出于导致规则1的相同原因,基于模板的方法可能比基于继承的方法更小。 - user180247
实际上,可以使用类似继承的方法来实现相当多的模板功能 - 例如在Java/Ada等中使用泛型,在Haskell等中使用类型类(它们不同,但执行相关工作)。我希望能够轻松地在两种方法之间切换,而无需重写代码,更不用切换语言 - 只有在需要时才能有选择地交换代码大小和速度。 - user180247

18

模板方案将具有稍好的性能,因为不涉及虚拟调用。如果回调被极频繁地使用,请选择模板解决方案。需要注意的是,“极频繁”实际上不会发生在每秒数千次甚至更晚的情况下。

另一方面,模板必须放在头文件中,这意味着对它进行的每个更改都将强制重新编译调用它的所有代码,而在接口场景中,实现可以在.cpp文件中,并且只需重新编译一个文件即可。


2
您还可以提到每个实例化的虚拟表会有几个字节的开销(取决于编译器实现)。 - nurettin
+1,但过度使用模板让我感到担忧(并且在我们获得概念之前都会这样),因为模板可能很脆弱,错误消息也可能非常冗长和晦涩。此外,继承可以做一些模板无法做到的事情(当然,反之亦然)。 - user180247

4
你可以将接口看作合同。任何从它派生的类都必须实现接口的方法。
另一方面,模板隐含了一些约束条件。例如,你的模板参数 T 必须有一个方法 f。这些隐含的要求应该仔细记录,涉及模板的错误消息可能会相当令人困惑。
Boost 概念可以用于概念检查,这使得隐含的模板要求更容易理解。

1
这段内容讲述了静态多态和动态多态之间的选择。如果搜索相关话题,会发现很多讨论。一般而言,静态多态可能会带来更好的性能,但由于C++11标准缺乏概念的支持,当一个类不符合所需概念时,也可能会出现有趣的编译器错误信息。

0

我会选择模板版本。如果你从性能的角度考虑,那么这是有道理的。

虚拟接口 - 使用虚拟意味着方法的内存是动态的,并且在运行时决定。这会带来额外的开销,因为它必须查询vlookup表以定位内存中的方法。

模板 - 你可以获得静态映射。这意味着当调用方法时,它不必查询查找表,已经知道方法在内存中的位置。

如果你关心性能,那么模板几乎总是更好的选择。


什么时候从性能角度来看有意义?关于性能的空话通常是误导性的,如果不是错误的。动态分派的成本非常小,更高的成本是抑制内联,而不是额外的间接寻址。如果在调用处不可见模板参数类型成员函数的定义,并且函数不是平凡的(它们生成几百条处理器指令),那么成本差异将很小,大多数情况下不会被测量 - David Rodríguez - dribeas
@DavidRodríguez-dribeas 希望我没有过度宣传整个性能问题。我知道这很小,但它仍然存在。如果我的帖子让它听起来比实际情况更大...那不是我的意图。 - Freddy
问题在于同一行中有许多答案:动态分派会变慢。其中大部分真正错过了重点,动态分派并不会影响性能 通常情况下,只有在少数情况下才会影响性能,即使在那些情况下,它也必须在一个需要大量 CPU 的代码片段中才能被注意到。主要的性能损失不是分派,而是它抑制了内联,但编译器可能不进行内联的其他原因,而额外的间接引用只需要一个额外的 CPU 指令(一个昂贵的指令,但只需要几个周期)。 - David Rodríguez - dribeas

0

第三个选项怎么样?

template<auto* operation, class Sig = void()>
struct can_do;

template<auto* operation, class R, class...Args>
struct can_do<operation, R(Args...)> {
  void* pstate = 0;
  R(*poperation)(void*, Args&&...) = 0;

  template<class T,
    std::enable_if_t<std::is_convertible_v<
      std::invoke_result_t<decltype(*operation), T&&, Args&&...>,
      R>,
    bool> = true,
    std::enable_if_t<!std::is_same_v<can_do, std::decay_t<T>>, bool> =true
  >
  can_do(T&& t):
    pstate((void*)std::addressof(t)),
    poperation(+[](void* pstate, Args&&...args)->R {
      return (*operation)( std::forward<T>(*static_cast<std::remove_reference_t<T>*>(pstate)), std::forward<Args>(args)... );
    })
  {}
  can_do(can_do const&)=default;
  can_do(can_do&&)=default;
  can_do& operator=(can_do const&)=default;
  can_do& operator=(can_do&&)=default;
  ~can_do()=default;

  auto operator->*( decltype(operation) ) const {
    return [this](auto&&...args)->R {
      return poperation( pstate, decltype(args)(args)... );
    };
  }
};

现在你可以做到

auto invoke_f = [](auto&& elem)->void { elem.f(); };

struct UseA
{
  UseA(can_do<&invoke_f> a) : m_a(a){}
  void f(){(m_a->*&invoke_f)();}
  can_do<&invoke_f> m_a;
};

测试代码:

struct A {
    void f() { std::cout << "hello world"; }
};
struct A2 {
    void f() { std::cout << "goodbye"; }
};

A a;
UseA b(a);
b.f();
A2 a2;
UseA b2(a2);
b2.f();

实时示例

can_do上拥有更丰富的多操作接口留作练习。

UseA不是一个模板。AA2没有共同的基础接口类。

然而它可以工作。


UseA和std::function<void()>等价吗? - user10339780
@user UseA修复了操作,但没有修复数据;函数既不修复操作也不修复数据,只修复参数。 - Yakk - Adam Nevraumont

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接