继承的成本是什么?

10

这是一个非常基础的问题,但我仍然不确定:

如果我有一个将被实例化数百万次的类 - 是不是建议它不要从其他类派生?换句话说,继承是否会带来某些成本(以内存或运行时构造或销毁对象的形式),在实践中我应该担心吗?

示例:

class Foo : public FooBase { // should I avoid deriving from FooBase?
 // ...
};

int main() {
  // constructs millions of Foo objects...
}

1
像这样的问题就是为什么每个询问有关微观优化代码的人都要面对一大堆“过早优化是万恶之源”的评论。 - user395760
1
如果你对是否应该继承存在疑虑,那么你就不应该这样做。原因并非性能问题,而是继承经常被滥用。 - David Rodríguez - dribeas
7个回答

21

从一个类继承并不会在运行时增加任何成本。

如果基类中有变量,那么当然会占用更多内存,但这并不比直接放在派生类中而不继承更多。

这不包括虚方法,虚方法会产生一些运行时成本。

总之,你不应该担心这个。


5
如果你需要 virtual 提供的动态性,那么你很可能无法以比编译器处理的虚函数表更便宜的价格获得它。 - user395760
1
虚函数的成本与继承无关。当您调用由函数指针指向的函数时,在C中支付的成本是相同的。多态和继承不是同一件事。 - wilhelmtell
@wilhelm,它们之间的关系在于你必须在某个地方使用继承才能使用虚函数。我想解释整个过程,而虚函数是继承的一部分。 - Seth Carnegie
虚函数的成本无论你是否使用继承都是相同的(是的,使用它们意味着你期望存在一个派生类,但在实例化基类时你支付的成本完全相同...除了多重/虚拟继承的情况)。 - Qwertie

6
我很惊讶目前为止一些回应和评论...

继承是否会带来一些性能成本(内存方面)

是的。考虑下面的例子:
namespace MON {
class FooBase {
public:
    FooBase();
    virtual ~FooBase();
    virtual void f();
private:
    uint8_t a;
};

class Foo : public FooBase {
public:
    Foo();
    virtual ~Foo();
    virtual void f();
private:
    uint8_t b;
};

class MiniFoo {
public:
    MiniFoo();
    ~MiniFoo();
    void f();
private:
    uint8_t a;
    uint8_t b;
};

    class MiniVFoo {
    public:
        MiniVFoo();
        virtual ~MiniVFoo();
        void f();
    private:
        uint8_t a;
        uint8_t b;
    };

} // << MON

extern "C" {
struct CFoo {
    uint8_t a;
    uint8_t b;
};
}

在我的系统上,以下是尺寸:

32 bit: 
    FooBase: 8
    Foo: 8
    MiniFoo: 2
    MiniVFoo: 8
    CFoo: 2

64 bit:
    FooBase: 16
    Foo: 16
    MiniFoo: 2
    MiniVFoo: 16
    CFoo: 2

运行时构造或销毁对象

需要额外的函数开销和必要的虚拟调用(包括适当的析构函数)。这可能会花费很多时间,一些明显的优化,如内联,可能无法执行。

整个主题更加复杂,但这将给您一个成本的概念。

如果速度或大小真正关键,则通常可以使用静态多态性(例如模板)来实现性能和编程易用性之间的卓越平衡。

关于 CPU 性能,我创建了一个简单的测试,对栈和堆上创建了数百万个此类型,并调用 f,结果如下:

FooBase 16.9%
Foo 16.8%
Foo2 16.6%
MiniVFoo 16.6%
MiniFoo 16.2%
CFoo 15.9%

注意:Foo2派生自foo

在测试中,分配被添加到向量中,然后被删除。如果没有这个阶段,CFoo将完全被优化掉。正如Jeff Dege在他的答案中所发布的那样,分配时间将是这个测试的一个巨大部分。

从示例中修剪分配函数和向量创建/销毁可以得出以下数字:

Foo 19.7%
FooBase 18.7%
Foo2 19.4%
MiniVFoo 19.3%
MiniFoo 13.4%
CFoo 8.5%

这意味着虚拟变体执行它们的构造函数、析构函数和调用需要两倍于CFoo的时间,而MiniFoo则快约1.5倍。
顺便提一下分配:如果您可以在实现中使用单个类型,则在此场景中可以减少必须进行的分配次数,因为您可以分配一个包含1M个对象的数组,而不是创建一个包含1M个地址的列表,然后用唯一的新类型填充它。当然,有特殊用途的分配器可以减轻这种负担。由于分配/释放时间是此测试的重点,因此这将显著减少您花费在分配和释放对象上的时间。
Create many MiniFoos as array 0.2%
Create many CFoos as array 0.1%

请注意,MiniFoo和CFoo的大小每个元素消耗1/4至1/8的内存,连续分配消除了存储动态对象指针的需要。您可以用更多的方式(指针或索引)跟踪对象的实现,但数组也可以显着减少客户端的分配需求(uint32_t vs 64位架构上的指针),此外还有系统为分配所需的所有簿记(在处理如此多的小分配时非常重要)。
具体而言,在此测试中,这些大小消耗了:
32 bit
    267MB for dynamic allocations (worst)
    19MB for the contiguous allocations
64 bit
    381MB for dynamic allocations (worst)
    19MB for the contiguous allocations

这意味着所需的内存减少了十倍以上,并且分配/释放所花费的时间明显优于以前!

静态调度实现与混合或动态调度相比,速度可以快几倍。这通常为优化器提供了更多机会来查看程序并相应地进行优化。

实际上,动态类型往往会导出更多符号(方法、析构函数、虚函数表),这可能会显着增加二进制文件的大小。

假设这是您的实际用例,那么您可以显著改善性能和资源使用情况。我已经提出了许多重要的优化...以防有人认为这样改变设计将被视为“微”优化。


4
他问是否继承有成本,而不是询问虚函数或动态类型。 - Bo Persson
2
这是最恰当的答案。谈论继承时不能不提虚函数。@BoPersson - namar0x0309
通常在描述性能时,会提到运行时间或操作/秒。但这里我们有百分比,这非常令人困惑。这些百分比具体指的是什么? - Askaga

3
大体上,这取决于实现方式。但是有一些共性。
如果您的继承树包含任何虚函数,编译器将需要为每个类创建一个vtable - 一个指向各种虚函数的跳转表。这些类的每个实例都将携带一个指向其类的vtable的隐藏指针。
任何对虚函数的调用都将涉及一个隐藏的间接级别 - 调用不会跳转到在链接时已解析的函数地址,而是涉及从vtable读取地址,然后跳转到该地址。
一般来说,在任何除了最紧急的软件之外,这种开销不太可能被测量出来。
另一方面,您说您将实例化和销毁数百万个这些对象。在大多数情况下,最大的成本不是构造对象,而是为其分配内存。
换句话说,您可能会从为该类使用自己的自定义内存分配器中受益。

http://www.cprogramming.com/tutorial/operator_new.html


3
事实上,如果你对是否应该继承存在疑问,答案是不应该。继承是语言中第二种最耦合的关系。
就性能差异而言,在大多数情况下几乎没有区别,除非你开始使用多重继承,其中如果一个基类具有虚函数,则如果基类子对象与最终覆盖者不对齐,分派将具有额外的(最小、可忽略的)成本,因为编译器将添加一个“thunk”来调整“this”指针。

3
我认为我们所有的程序员都太过于像孤狼一样编程了... 我们忘记了考虑维护成本、可读性和特性扩展。以下是我的看法:
继承成本++
1. 对于较小的项目:开发时间增加。编写全局数独代码很容易。对我来说,编写类继承以做正确的事情总是需要更多的时间。 2. 对于较小的项目:修改时间增加。并不总是容易修改现有代码以符合现有接口。 3. 设计时间增加。 4. 由于多个消息传递而导致程序略微效率低下,而不是暴露内部数据(我指的是数据成员。 :)) 5. 只有通过基类指针进行的虚函数调用,存在一个额外的解引用。 6. 在RTTI方面会有一些小的空间开销。 7. 为了完整起见,我还要补充一点,太多的类会增加太多的类型,这必然会增加编译时间,无论它可能有多小。 8. 还有跟踪多个对象的成本,包括基类对象和所有运行时系统,这显然意味着代码大小略微增加 + 由于异常委派机制(无论你是否使用它)而导致轻微的运行时性能损失。 9. 如果你只想隔离接口函数的用户免于重新编译,那么你不必以PIMPL的方式折断自己的手臂。(相信我,这是一个很重的代价。)
继承成本--
1. 当程序规模超过1/2千行时,使用继承更易于维护。如果你是唯一的程序员,那么你可以轻松地将代码推送到4k/5k行以上。 2. 缺陷修复的成本降低了。 3. 你可以轻松地扩展现有框架以处理更具挑战性的任务。
我知道我有点像恶魔的辩护律师,但我认为我们必须要公正。

2
如果你需要在Foo中使用FooBase的功能,你可以通过继承或组合来实现。继承会增加虚表(vtable)的代价,而使用组合会增加指向FooBase对象的指针、FooBase对象本身以及FooBase的虚表的代价。它们的代价大致相同,所以你不必担心继承的代价问题。

1
为什么派生类会有“虚函数表的成本”?另外,什么是虚函数表? - Kerrek SB
@Rafal,没有虚函数并不代表什么。 - alternative

2
创建派生对象需要调用所有基类的构造函数,而销毁它们则会调用这些类的析构函数。因此,成本取决于这些构造函数做了什么,但是如果您不派生而在派生类中包含相同的功能,则需要付出相同的代价。从内存的角度来看,每个派生类对象都包含其基类的一个对象,但是,如果您只是将所有这些字段包含在类中而不是派生它,则完全使用相同的内存用量。
请注意,在许多情况下,最好采用组合(具有“基”类的数据成员而不是派生自该类),特别是如果您没有覆盖虚函数并且“派生”和“基”之间的关系不是“一种”的关系。但是,就CPU和内存使用两方面而言,这两种技术是等效的。

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