虚函数,虚表深度剖析

2021-02-27 02:26:22 +08:00
 yiouejv

面向对象,从单一的类开始说起。

class A
{
private:
    int m_a;
    int m_b;
};

这个类中有两个成员变量,都是int类型,所以这个类在内存中占用多大的内存空间呢?

sizeof(A), 8 个字节,一个int占用四个字节。 下图验证:

这两个数据在内存中是怎样排列的呢?

原来是这样,我们根据 debug 出来的地址画出 a 对象在内存的结构图


如果 class A 中包含成员函数呢? A 的大小又是多少?

class A
{
public:
    void func1() {}    
private:
    int m_a;
    int m_b;
};

直接告诉你答案,类的成员函数多大? 没人能回答你,并且不是本文的重点,类的成员函数是放在代码区的,不算在类的大小内。

类的对象共享这一段代码,试想,如果每一个对象都有一段代码,光是存储这些代码得占用多少空间? 所以同一个类的对象共用一段代码。

共用同一段代码怎么区分不同的对象呢?

实际上,你在调用成员函数时,a.func1() 会被编译器翻译为 A::func1(&a),也就是A* const this, this 就是 a 对象的地址。

所以根据 this 指针就能找到对应的数据,通过这同一段代码来处理不同的数据。


接下来我们讨论一下继承,子类继承父类,将会继承父类的数据,以及父类函数的调用权。

以下的测试可以验证这个情况。

class A
{
public:
    void func1() { cout << "A func1" << endl; }
private:
    int m_a;
    int m_b;
};

class B : public A
{
public:
    void func2() { cout << "B func2" << endl; }
private:
    int m_c;
};

int main(int argc, char const* argv[])
{
    B b;
    b.func1();
    b.func2();
    return 0;
}

输出:

// A func1
// B func2

那么对象 b 在内存中的结构是什么样的呢?

继承关系,先把 a 中的数据继承过来,再有一份自己的数据。


每个包含虚函数的类都有一个虚表,虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

为了指定对象的虚表,对象内部包含指向一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。 这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

class A
{
public:
    void func1() { cout << "A func1" << endl; }
    virtual void vfunc1() { cout << "A vfunc1" << endl; }
private:
    int m_a;
    int m_b;
};

cout << sizeof(A);, 输出 12,A 中包括两个 int 型的成员变量,一个虚指针,指针占 4 个字节。

a 的内存结构如下:

虚表是一个函数指针数组,数组里存放的都是函数指针,指向虚函数所在的位置。

对象调用虚函数时,会根据虚指针找到虚表的位置,再根据虚函数声明的顺序找到虚函数在数组的哪个位置,找到虚函数的地址,从而调用虚函数。

调用普通函数则不像这样,普通函数在编译阶段就指定好了函数位置,直接调用即可。


class A
{
public:
    void func1() { cout << "A func1" << endl; }
    virtual void vfunc1() { cout << "A vfunc1" << endl; }
private:
    int m_a;
    int m_b;
};

class B : public A
{
public:
    void func1() { cout << "B func1" << endl; }
    virtual void vfunc2() { cout << "B vfunc2" << endl; }
private:
    int m_a;
};

像这样,B 类继承自 A 类,B 中又定义了一个虚函数 vfunc2, 它的虚表又是怎么样的呢?

给出结论,虚表如下图所示:

我们来验证一下:

A a;
B b;
void(*avfunc1)() = (void(*)()) *(int*) (*(int*)&a);
void (*bvfunc1)() = (void(*)()) *(int*) *((int*)&b);
void (*bvfunc2)() = (void(*)()) * (int*)(*((int*)&b) + 4);
avfunc1();
bvfunc1();
bvfunc2();

来解释一下代码: void(*avfunc1)() 声明一个返回值为void, 无参数的函数指针 avfunc1, 变量名代表我们想要取 A 类的 vfunc1 这个虚函数。

右半部分的第一部分,(void(*)()) 代表我们最后要转换成对应上述类型的指针,右边需要给一个地址。

我们看 (*int(*)&a), 把 a 的地址强转成int*, 再解引用得到 虚指针的地址。

*(int*) (*(int*)&a) 再强转解引用得到虚表的地址,最后强转成函数指针。

同理得到 bvfunc1, bvfunc2, +4是因为一个指针占 4 个字节,+4得到虚表的第二项。


覆盖

class A
{
public:
    void func1() { cout << "A func1" << endl; }
    virtual void vfunc1() { cout << "A vfunc1" << endl; }
private:
    int m_a;
    int m_b;
};

class B : public A
{
public:
    void func1() { cout << "B func1" << endl; }
    virtual void vfunc1() { cout << "B vfunc1" << endl; }
private:
    int m_a;
};

子类重写父类的虚函数,需要函数签名保持一致,该种情况在内存中的结构为:


多态

父类指针指向子类对象的情况下,如果指针调用的是虚函数,则编译器会将会从虚指针所指的虚函数表中找到对应的地址执行相应的函数。

子类很多的话,每个子类都覆盖了对应的虚函数,则通过虚表找到的虚函数执行后不就执行了不同的代码嘛,表现出多态了嘛。

我们把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。

那么,什么时候会执行函数的动态绑定?这需要符合以下三个条件。

  • 通过指针来调用函数
  • 指针 upcast 向上转型(继承类向基类的转换称为 upcast )
  • 调用的是虚函数

为什么父类指针可以指向子类?

子类继承自父类,子类也属于 A 的类型。

最后通过一个例子来体会一下吧:

class Shape
{
public:
    virtual void draw() = 0;
};

class Rectangle : public Shape
{
    void draw() { cout << "rectangle" << endl; }
};

class Circle : public Shape
{
    void draw() { cout << "circle" << endl; }
};

class Triangle : public Shape
{
    void draw() { cout << "triangle" << endl; }
};


int main(int argc, char const *argv[])
{
    vector<Shape*> v;
    v.push_back(new Rectangle());
    v.push_back(new Circle());
    v.push_back(new Triangle());
    for (Shape* p : v) {
        p->draw();
    }
    return 0;
}

有些话是大白话,哈哈,如果这篇文章写的不错,解决了你的疑惑的话,点个赞再走吧!

不对的地方也请指出来,大家一起学习进步。

2528 次点击
所在节点    C++
11 条回复
geelaw
2021-02-27 02:47:32 +08:00
正确标题:浅度剖析

深度剖析至少应该涉及多重继承、虚拟继承、虚拟析构的问题。
jingous
2021-02-27 09:29:13 +08:00
感觉是把侯捷老师的课又复述了一遍
lqf96
2021-02-27 10:03:20 +08:00
这…连 MSVC 和 Itanium ABI 下虚表的结构和区别都没有也能叫深度🤦‍♂️
ArchScott
2021-02-27 11:54:40 +08:00
额,现在校招面试都不止说这点吧...
yiouejv
2021-02-27 13:16:06 +08:00
还有补充的可以推荐一下吗
Cyshall
2021-02-27 15:34:20 +08:00
这种随便一搜到处都有的东西再写一遍真的只有浪费时间这一个用处了。
yiouejv
2021-02-27 15:36:05 +08:00
@Cyshall 没有自己测一遍深刻的,看过之后,真的,也可以我比较菜咯
paoqi2048
2021-02-27 18:34:49 +08:00
可以,正好复习一下
ng29
2021-02-27 20:31:08 +08:00
直接汇编码 其实更容易 深度出来,还是太浅了
lraining
2021-02-27 22:17:44 +08:00
深度探索 C++对象模型 C++编译器开发者写的,读完就不会对面向对象有什么疑问了
yiouejv
2021-02-28 08:43:34 +08:00
@lraining 谢谢,已经在读了

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/756658

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX