动态多态 (Dynamic Polymorphism)

在 c++中为了实现多态,使用了一种动态绑定的技术,这个技术的核心就是虚函数表(virtual table)。下面就简单的说明一下基于虚表的动态绑定的原理,从而更好的与静态多态做比较。

在 c++中,每个包含虚函数的类都有一个虚表。我们来看下面这个类:

// demo.cpp
class A
{
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void         func1();
    void         func2();

private:
    int m_data1, m_data2;
};

我们可以借助编译器来查看上述类的对象布局:

# 使用llvm编译工具
clang -Xclang -fdump-record-layouts -stdlib=libc++ -c demo.cpp # 查看对象布局
clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c demo.cpp # 查看虚表布局

# 使用gcc编译工具
g++ -fdump-lang-class demo.cpp

这里为了便于分析,使用 clang 打印的结果来具体说明:

// clang -Xclang -fdump-record-layouts -stdlib=libc++ -c demo.cpp
*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int m_data1
        12 |   int m_data2
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

// clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c demo.cpp
Original map
Vtable for 'A' (4 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::vfunc1()
   3 | void A::vfunc2()

VTable indices for 'A' (2 entries).
   0 | void A::vfunc1()
   1 | void A::vfunc2()

根据对象布局可以简单画出class A的对象布局图:

virtual table

  • offset_to_top(0):表示这个虚表地址距离对象顶部地址的偏移量,这里是 0,表示该虚表位于对象最顶端
  • RTTI 指针:Run-Time Type Identification,通过运行时类型信息程序能够使用基类的指针或引用来检查这些指针或引用所指的对象的实际派生类型
  • RTTI 下面就是虚函数表指针真正指向的地址,存储了类里面所有的虚函数

花了这么大力气,来讲解了类的对象布局和虚表,其实是为了给接下来类的继承和动态多态的实现做铺垫。我们在上面类的基础上,写实现一个派生类:

class B : public A
{
    void vfunc1() {}
};

然后再通过工具打印出类的对象布局:

// clang -Xclang -fdump-record-layouts -stdlib=libc++ -c demo.cpp
*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int m_data1
        12 |   int m_data2
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int m_data1
        12 |     int m_data2
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

// clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c demo.cpp
Original map
 void B::vfunc1() -> void A::vfunc1()
Vtable for 'B' (4 entries).
   0 | offset_to_top (0)
   1 | B RTTI
       -- (A, 0) vtable address --
       -- (B, 0) vtable address --
   2 | void B::vfunc1()
   3 | void A::vfunc2()

VTable indices for 'B' (1 entries).
   0 | void B::vfunc1()

Original map
 void B::vfunc1() -> void A::vfunc1()
Vtable for 'A' (4 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::vfunc1()
   3 | void A::vfunc2()

VTable indices for 'A' (2 entries).
   0 | void A::vfunc1()
   1 | void A::vfunc2()

从上面的对象布局可以看出,子类 B 由于继承了 A,也拥有了虚表,并且 B 的虚表地址和父类 A 的虚表地址是相同的。 同时 B 和 A 一样,都占 16 个字节,前 8 个字节保存虚表指针,两个虚函数指针各占 4 个字节。而不同的是,由于子类 B 覆盖了vfunc1,所以 B 的虚函数表中变成了void B::vfunc1()

基于上面的继承示例,我们可以很容易写出多态的代码 —— 父类引用指向子类对象:

A* b = new B;
b->vfunc1();
b->vfunc2();

A类类型指向B类的对象,在调用的时候,会根据b的 RTTI 来获取b的实际类型为B,然后调用vfunc1vfunc2的时候会通过B的虚表指针来找到vfunc1vfunc2的具体位置。 因此,采用此类多态的调用,会增加一些额外的开销:

原因时间开销空间开销
RTTI几次整形比较和一次取址操作(可能还会有 1、2 次整形加法)每个类多出一个 type_info 对象(包括类型 ID 和类名称)
虚函数一次整形加法和一次指针间接引用每个类一个虚表,每个对象一个(通常情况下是一个)虚表指针,每个虚表指针占 8 字节

静态多态 (Static Polymorphism)

所谓静态多态,就是在程序在编译期就确定了对象类型和调用的函数地址,并生成对应的代码。 而 C++中常用来实现静态多态的方式就是 Curiously recurring template pattern 简称 CRTP。 CRTP 是 c++模板编程中的惯用模式,其主要特点是把派生类作为基类的模板参数。 翻译成代码就是:

template<class T>
class Base
{
    // methods within Base can use template to access members of Derived
};
class Derived : public Base<Derived>
{
    // ...
};

下面我们通过一段代码来分析如何使用 CRTP 实现静态多态:

#include <iostream>

template<typename Derived> struct Base
{
    void interface()
    {
        static_cast<Derived*>(this)->implementation();
    }
    void implementation()
    {
        std::cout << "Implementation Base" << std::endl;
    }
};

struct Derived1 : Base<Derived1>
{
    void implementation() { std::cout << "Implementation Derived1" << std::endl; }
};

struct Derived2 : Base<Derived2>
{
    void implementation() { std::cout << "Implementation Derived2" << std::endl; }
};

struct Derived3 : Base<Derived3>
{};

template<typename T>
void execute(T& base)
{
    base.interface();
}

int main()
{
    Derived1 d1;
    execute(d1);
    Derived2 d2;
    execute(d2);
    Derived3 d3;
    execute(d3);
    std::cout << '\n';
}

程序运行的结果是:

Implementation Derived1
Implementation Derived2
Implementation Base

这里通过execute模板函数来执行静态多态,调用参数baseinterface方法。Base::interface方法是 CRTP 机制的关键, 它通过static_cast<Derived*>(this)->implementation()来调用子类的implementation方法。由于Derived1Derived2都 实现了自己的implementation方法,所以这里使用的是他们实现,而Derived3没有实现自己的implementation方法,所以 Base::implementation就充当了默认实现。这个例子没有使用任何虚函数,就实现了多态的效果。同样地,使用编译器打印对象布局:

*** Dumping AST Record Layout
         0 | struct Base<struct Derived1> (empty)
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

*** Dumping AST Record Layout
         0 | struct Derived1 (empty)
         0 |   struct Base<struct Derived1> (base) (empty)
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

*** Dumping AST Record Layout
         0 | struct Base<struct Derived2> (empty)
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

*** Dumping AST Record Layout
         0 | struct Derived2 (empty)
         0 |   struct Base<struct Derived2> (base) (empty)
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

*** Dumping AST Record Layout
         0 | struct Base<struct Derived3> (empty)
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

*** Dumping AST Record Layout
         0 | struct Derived3 (empty)
         0 |   struct Base<struct Derived3> (base) (empty)
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

我们不难发现,对象中不包含任何虚表。

动态多态和静态多态的对比

动态多态发生在运行时,而静态多态发生在编译时。动态多态通常在运行时需要一个指针间接寻址,因此会有额外的性能开销,而静态多态的对象类型和函数是在编译时确定的,因此不会产生额外的性能开销。同时静态多态也不需要为每个类创建虚表,因此在空间上也不会像动态多态那样产生额外的消耗。