avatar

Catalog
C++ 中的 virtual 关键字和对象的内存布局

虚函数、虚表、虚指针

  • 一个类中,数据成员(除了静态)存放在堆栈上,静态数据成员存放在数据段,函数(包括静态和非静态)存放在代码段。

  • 如果一个类定义了虚函数或是该类的父类定义了虚函数,那么一个指向虚表的指针(_vptr) 会被编译器添加到该类对象的栈上(就好像这个指针也是该类的一个成员变量)。

    注意:

    c++
    1
    2
    3
    4
    5
    6
    7
    class A {
    virtual void f();
    };

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

    这种写法的话,似乎 B 类的 f() 仍然会被认为是一个虚函数,“虚函数链”并不会因为 B 类的 f() 没有加上 virtual 关键字就在此处断裂,但我不知道这是不是和编译器的实现有关。所以最好不要写成这种可能产生歧义的写法。

  • 虚表是一个类一个的,存放在数据段,而虚指针(_vptr)是一个对象一个的,存放在堆栈上。

    虚表说是一个类一个并不完全准确,原意应该是虚表是相对于类的概念而言的,因为一个类可以有多个虚表。实际上,虚表的个数等于该类的直接父类的个数。因为大多数情况下都是单一继承,所以就会有一个类一个虚表的错觉。那为什么虚表的个数等于直接父类的个数呢,因为虚表存在的意义在于对父类函数的覆写,如果有多个父类的话就会有多个覆写,考虑这样一种情形:

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class A {
    virtual void fa();
    };

    class B {
    virtual void fb();
    };

    class C : public A, public B {
    void fa();
    void fb();
    };

    那么 C 类对象的内存布局就会是这样:

    Code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    +-----+    +-----------+
    | vptr---->| C::fa() |
    | | +-----------+
    | A |
    +-----+ +-----------+
    | vptr---->| C::fb() |
    | | +-----------+
    | B |
    +-----+
    | C |
    +-----+

    可见,C 类有两个虚表。

    相对地,如果继承链仅仅是单一集成:A<-B<-C,那么 C 类也就只有一个虚表:

    Code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    +-----+    +-----------+
    | vptr---->| C::fa() |
    | | | C::fb() |
    | A | +-----------+
    +-----+
    | B |
    +-----+
    | C |
    +-----+

    值得注意的是,在多重继承的情形中,将 C 类对象向上转型为 B 类对象的话,转型后的指针是指向中间 B 类数据的起始位置的;而在单一继承的情形中,将 C 类对象向上转型成 B 类对象,转型后的指针仍指向开头 A 类数据的起始位置。

  • 虚表也会包含 type_info (RTTI),但是具体的内存布局是 compiler dependent 的。

  • 关于纯虚函数的实现,例如纯虚类 A 定义了一个纯虚函数 f(),那么由刚才的定义,只要类中定义了(纯)虚函数就会有虚表和虚指针,所以编译器会生成一张虚表,但是虚表中的纯虚函数 f() 所在的 entry 的值并不指向 f() 的实现(纯虚函数不能实现),而是指向一个 pure_virtual_called(),当这个函数被调用时,会抛出一个异常。正常状态下,纯虚类不能实例化对象,所以这个函数怎么也不会被调用到。

  • 虚函数所谓的 override,是指编译时刻的 override 而不是运行时刻。例如父类 A 定义了两个虚函数,生成了一张虚表,其中包含这两个函数的 entry,子类 B 覆写了其中一个函数,则生成 B 类虚表的时候也会覆写对应的 entry(加新的虚函数会在 B 类虚表后 append,但是子类有父类没有的函数实在不漂亮)。此为编译时刻的 override。

    而真正到了运行时刻,实例化 B 类对象时,该对象的内存布局中,先是 A 类的数据,包括 A 类的虚指针,但是这个虚指针是指向 B 类虚表的,这一指向在实例化的时候就已经确定了,不存在覆写的行为。

  • 只要是虚函数(包括自己定义的以及从父类继承的)就会出现在虚表中,但出现在虚表中并不意味着就可以被正常地调用,因为调用虚函数的本质是通过虚指针间接调用,如果某函数在编译时刻压根不会被重写成通过虚指针间接调用的方式,那自然就不会调用到虚表中的同名函数,考虑如下情形:

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class A {
    void f() { cout << "A::f()" << endl; }
    };

    class B : public A {
    virtual void f() { cout << "B::f()" << endl; }
    };

    A *a = new B();
    a->f(); // 输出 A::f()

    B 类对象内存布局如下:

    Code
    1
    2
    3
    4
    5
    6
    7
    +-----+    +----------+
    | vptr---->| B::f() |
    | | +----------+
    | A |
    +-----+
    | B |
    +-----+

    B 类定义了虚函数 f(),也的确出现在了 B 类的虚表中,但是当一个实际为 B 类的 A 类对象调用 f() 时,输出的仍然是 A::f(),这是因为在编译时刻 a->f() 根本没有被重写成 (*(a->_vptr[1]))(a),因为编译器认为 A 类的 f() 函数不是虚函数,所以不会走虚表间接调用。

    注:之所以是 _vptr[1] 而不是 _vptr[0],是因为 _vptr[0] 存着 RTTI(也有可能是 compiler-dependent)

虚拟继承

  • 为解决多重继承中的一些问题而出现,离开多重继承,虚拟继承几乎没有意义。考虑这样一个场景:B, C 类继承自 A 类,而 D 类继承自 B 类和 C 类,即菱形继承。按照上述内存模型,D 类对象的内存布局会是这样:

    Code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    +------+
    | B::A |
    | B |
    +------+
    | C::A |
    | C |
    +------+
    | D |
    +------+

    可见 D 类对象中包含了两个 A 类子对象(sub-object),这就导致这样的操作是非法的:

    c++
    1
    2
    3
    4
    5
    class A {
    int va;
    };

    d->va = 3; // error: D::va is ambiguous

    因为编译器没法判断对这个 va 的赋值是要修改 B 类子对象中的 va,还是 C 类子对象中的 va。

    此时虚拟继承就派上用场了:

    c++
    1
    2
    3
    4
    class A {};
    class B : virtual public A {};
    class C : virtual public A {};
    class D : public B, public C {};

    B, C 类虚拟继承自 A 类,称 A 类为虚基类(注意与纯虚类的区分),这样 D 类对象的内存布局就会是这样:

    Code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    +---------+
    | B | +---------------+
    | _vptr_B--->| offset of A |--+
    +---------+ +---------------+ |
    | C | +---------------+ |
    | _vptr_C--->| offset of A |--+
    +---------+ +---------------+ |
    | D | |
    +---------+ |
    | A |<--------------------+
    +---------+

    其中分为不变区域(invariant region)和共享区域(shared region),不变区域是指在内存布局的上半部分,距离顶部(this 指针)偏移量固定的区域,而共享区域即是虚基类子对象,在内存布局的下半部,所以随着继承链的延长,距离顶部偏移量也会增加。

    _vptr_* 称为虚基类指针。虚拟继承中的虚表相关和非虚继承一样。

虚析构函数

  • 虚析构函数:显然也是虚函数,但是和一般的虚函数不同:一般的虚函数在调用时,编译器只会将函数调用这一行语句展开成通过虚指针的方式,虚析构函数被调用时,编译器不仅会展开函数调用,还会在子类的析构函数中新增(augment)一些代码,考虑以下情形:

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class A {
    virtual void f();
    virtual ~A();
    };

    class B : public A {
    void f();
    ~B() { cout << "~B()" << endl; }
    };

    这种情况下 ~B() 会被修改成:

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ~B() {
    cout << "~B()" << endl;

    // rewire virtual table
    this->_vptr[0] = &type_info_A;
    this->_vptr[1] = &A::f();
    this->_vptr[2] = &A::~A();

    // call base class destructor
    A::~A(this);
    }

    不仅会额外调用基类的析构函数,还会在此之前重新绑定虚表,因为 C++ 标准规定对象的运行时类型必须为此刻正在被执行的构造/析构函数所属的类。也就是说 b 对象执行 B 类的析构函数时类型是 B,在此之后执行 A 类析构函数时,类型应为 A。

  • 为什么构造函数不能是虚函数:

    • 虚指针存储在对象的内存空间里,如果构造函数是虚函数,就要通过虚指针来调用,但是在实例化之前虚指针尚未存在,前后矛盾。
    • 虚函数的意义在于有时对象声明的类型和运行时类型可以不一样,但是创建一个对象时总要明确指定它的类型。

Reference

Author: Gusabary
Link: http://gusabary.cn/2020/02/23/C++%E4%B8%AD%E7%9A%84virtual%E5%85%B3%E9%94%AE%E5%AD%97%E5%92%8C%E5%AF%B9%E8%B1%A1%E7%9A%84%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.

Comment