这篇博客来讲一下g++实现的C++对象模型中的虚函数的实现,包括:单一继承体系下的虚函数,多继承下的虚函数和虚继承下的虚函数。其中虚继承下的虚函数在《深度探索C++对象模型》中只是说很复杂,受限于技术力和查到的资料,这里我只是对于g++的部分实现进行观察。
单一继承体系下的虚函数
在前面的博客中我们已经通过对虚表的探索讲了虚函数的一般实现,大体上来说就是编译器会在适当的时候(在单一继承体系中就是当类中第一次出现虚函数的时候)添加一个虚表指针,指向属于该类的虚函数表,而所有虚函数的地址会出现在虚表指针的固定表项,也就是说在继承体系下的一个虚函数会被赋予固定的虚表下标。当派生类覆写(override)了基类的虚函数时,新的虚函数的地址会出现在基类虚函数在虚表中的位置,在多态调用虚函数时从虚表中取出虚函数地址来调用,从而实现多态。
一般而言,在单一继承体系下每一个类都只有一个虚表,在这个虚表中存有所有active virtual functions(中文版《深度探索C++对象模型》没有翻译,我这里也直接使用了,在我的理解里就是派生类所有有效的、能用的虚函数)的地址。这些active virtual functions包括:
- 该类所定义的所有虚函数,包括其覆写(override)的基类的虚函数;
- 继承自基类的虚函数,如果派生类不覆写这些虚函数的话;
- 一个pure_vairtual_called()函数实体,她既可以扮演pure virtual function的空间保卫者角色,也可以当作异常处理函数(有时候会用到)【《深度探索C++对象模型》原话】
// test23.cppclass Base {public: Base(int i) : m_i(i) {} virtual ~Base() { m_i = 0; } virtual int getInt() { return m_i; } virtual void increaseInt() { m_i++; } virtual long getLong() = 0;private: int m_i;};class Derived: public Base {public: Derived(int i, long l) : Base(i), m_l(l) {} virtual ~Derived() { m_l = 0; } virtual int getInt() override { // overrid Base::getInt() return Base::getInt() + 1; } virtual long getLong() override { // overrid Base::getLong(),在Base中是一个纯虚函数 return m_l; } virtual void increaseLong() { // new virtual function ++m_l; }private: long m_l;};int main() { Derived* pd = new Derived(1, 2L); int i = pd->getInt(); pd->increaseInt(); long l = pd->getLong(); pd->increaseLong(); pd->~Derived(); delete pd;}
另外,在这里我们可以注意到一个问题,虚表指针指向的空间,前两个表项都显示是Derived::~Derived(),也就是都是析构函数,而且地址不一样,这是怎么回事?我们看一下这两处地方的汇编代码:
可以看到,第一个析构函数就是普通的析构函数它先调用了我们自己定义的析构函数,再调用了基类的析构函数Base::~Base;而第二个虚构函数则是先调用了第一个析构函数,再调用了::operator delete
(_ZdlPvm使用c++filt工具查看可知其就是operator delete(void*, unsigned long)
)。
那是不是就是当我们自己调用Derived::~Derived时调用第一个,使用delete操作符时调用的就是第二个呢?我们看到反汇编:
可以看到确实是这样的。同时,我们还有一个小发现,就是当delete操作符操作的指针是nullptr时,是不会调用析构函数的,编译器真是相当费心了(在我的测试下好像是只有delete一个指向有虚析构函数的对象的指针时才会检查,否则就直接不检查调用::operator delete
)。
关于最后一个,因为我们无法实例化抽象基类,所以使用-fdump-class-hierarchy
选项查看类信息:
Vtable for BaseBase::_ZTV4Base: 7 entries0 (int (*)(...))08 (int (*)(...))(& _ZTI4Base)16 024 032 (int (*)(...))Base::getInt40 (int (*)(...))Base::increaseInt48 (int (*)(...))__cxa_pure_virtualClass Base size=16 align=8 base size=12 base align=8Base (0x0x7f24b28e7960) 0 vptr=((& Base::_ZTV4Base) + 16)Vtable for DerivedDerived::_ZTV7Derived: 8 entries0 (int (*)(...))08 (int (*)(...))(& _ZTI7Derived)16 (int (*)(...))Derived::~Derived24 (int (*)(...))Derived::~Derived32 (int (*)(...))Derived::getInt40 (int (*)(...))Base::increaseInt48 (int (*)(...))Derived::getLong56 (int (*)(...))Derived::increaseLongClass Derived size=24 align=8 base size=24 base align=8Derived (0x0x7f24b277d1a0) 0 vptr=((& Derived::_ZTV7Derived) + 16) Base (0x0x7f24b28e7de0) 0 primary-for Derived (0x0x7f24b277d1a0)
我们可以看到在Base
类的48偏移处确实有一个__cxa_pure_virtual表项,应该就是所谓的pure_vairtual_called,在结合Derived
类的虚表,在对应位置是Derived::getLong,说明正是使用该函数占位了Base::getLong这个虚函数。
多重继承下的虚函数
在单一继承体系下一切都显得那么美好,完全不涉及到指针的调整,因为所有的指针转化都不需要做底层的调整,始终指向类的开头。你可能现在还不能理解,在看完这一部分后再来看上面这一句话就会感慨:啊,单一继承是这么简单的事!
但在多重继承下事情开始变得复杂,看下面的例子:
// test24.cppclass Base1 {public: Base1(int i) : m_i(i) {} virtual int getInt() { return m_i; } virtual Base1* clone() { return new Base1(m_i); }private: int m_i;};class Base2 {public: Base2(long l) : m_l(l) {} virtual long getLong() { return m_l; } virtual Base2* clone() { return new Base2(m_l); }private: long m_l;};class Derived: public Base1, public Base2 {public: Derived(int i, long l) : Base1(i), Base2(l) {} virtual long getLong() { // override Base2::getLong() return Base2::getLong() + 1L; } virtual Derived* clone() { // override Base1::clone 和 Base2::clone return new Derived(getInt(), getLong()); }private:};int main() { Derived* pd = new Derived(1, 2L); Base2* pb2 = pd; long l = pb2->getLong(); // (1) Base2* p = pb2->clone(); // (2)}
试想,在(1)这一语句上,我们使用pb2调用getLong这一虚函数,虽然pb2类型是Base2*,但它实际上指向的是类Derived的对象。由前面的知识我们知道,在指针由Dervied*
转化为Base2*
时,会加上Base2在类Derived内的偏移(为0x10)。那就出问题了,pd2 = (pd2 == nullptr ? nullptr : pd + 0x10),在执行pb2->getLong()时,传入的是pd2,但实际上调用的是Derived::getLong(),需要的是派生类Derived的指针,怎么办?
同时,在(2)这一语句上,返回的是Derived*
指针,但接收的是Base2*
指针,如何在运行时知道对指针进行处理?
解决这两个问题的方法就是一个被称为”thunk”的技术。
所谓”thunk”,就是在代码的前面或后面添加一段小的代码段。
比如在Derived::getLong(),为了调整指针,编译器会生成这样一段代码:
// 伪码// thunk for Derived::getLong()this = this - 0x10jmp Derived::getLong(this)
而在虚函数表中Derived::getLong()应该在的位置,便由上述thunk的地址代替了。
至于在pb2的clone()函数,则被调整为:
// 伪码// thunk for Derived::clone()this = this - 0x10Derived* pd = Derived::clone()pd = (pd == nullptr ? nullptr : pd + 0x10)return pd
我们看一下反汇编,验证一下:
可以看到Derived::getLong()确实是这样的。再看一下Derived::clone():
确实是前面描述的那样,只不过编译器将其分为了两部分,一部分调整this指针,一部分调整返回值。
其实在《深度探索C++对象模型》中,还提到了一种情况,那就是基类指针调用派生类的虚函数,而在派生类的虚函数中又调用基类的虚函数。在这种情况下,在派生类的虚函数中调用基类的虚函数时又要调整this指针。
我觉得这种其实不是问题,因为在派生类中this指针明确是Derived*
类型,既然要调用基类的虚函数,肯定是要将Derived*
类型转化为Base1*
或者Base2*
类型,自然要进行this指针的调整,这是自然而然的,不需要添加额外的东西。
总的来说,一个派生自n个基类的派生类,除了原本要生成的一个虚函数表外,还要生成n-1个额外的虚函数表。在本例中,有两个虚函数表被编译出来:
- 一个主要实体,与Base1(最左侧的基类)共享
- 一个次要实体,与Base2(第二个基类)共享
在g++中,这两个表是紧贴在一起的。我们使用-fdump-class-hierarchy
参数看一下类信息:
Vtable for DerivedDerived::_ZTV7Derived: 9 entries0 (int (*)(...))08 (int (*)(...))(& _ZTI7Derived)16 (int (*)(...))Base1::getInt // 第一个虚表指针指向的地方24 (int (*)(...))Derived::clone32 (int (*)(...))Derived::getLong40 (int (*)(...))-1648 (int (*)(...))(& _ZTI7Derived)56 (int (*)(...))Derived::_ZThn16_N7Derived7getLongEv // 第二个虚表指针指向的地方64 (int (*)(...))Derived::_ZTchn16_h16_N7Derived5cloneEvClass Derived size=32 align=8 base size=32 base align=8Derived (0x0x7fa4a118e5b0) 0 vptr=((& Derived::_ZTV7Derived) + 16) Base1 (0x0x7fa4a12e7ea0) 0 primary-for Derived (0x0x7fa4a118e5b0) Base2 (0x0x7fa4a12e7f00) 16 vptr=((& Derived::_ZTV7Derived) + 56)
虚继承下的虚函数
《深度探索C++对象模型》中对于虚函数的实现并无讲解,只是说再虚继承体系下虚函数的实现非常复杂,其建议不要在虚基类中定义非静态的数据成员。所以下面只是我对于g++对虚继承下虚函数的实现的观察,并没有形成总结。
// test25.cppclass Point2D {public: Point2D(int x, int y) : m_x(x), m_y(y) {} virtual ~Point2D() { m_x = m_y = 0; } virtual void allAddOne() { m_x += 1; m_y += 1; } virtual int z() const { return 0; }private: int m_x; int m_y;};class Point3D: virtual public Point2D {public: Point3D(int x, int y, int z) : Point2D(x, y), m_z(z) {} virtual ~Point3D() { m_z = 0; } virtual void allAddOne() override { Point2D::allAddOne(); m_z += 1; } virtual int z() const override { return m_z; }private: int m_z;};int main () { Point3D* p3d = new Point3D(1, 2, 3); Point2D* p2d = p3d; p2d->allAddOne(); int z = p2d->z();}
我们先使用-fdump-class-hierarchy
查看类的信息:
Vtable for Point2DPoint2D::_ZTV7Point2D: 6 entries0 (int (*)(...))08 (int (*)(...))(& _ZTI7Point2D)16 (int (*)(...))Point2D::~Point2D24 (int (*)(...))Point2D::~Point2D32 (int (*)(...))Point2D::allAddOne40 (int (*)(...))Point2D::zClass Point2D size=16 align=8 base size=16 base align=8Point2D (0x0x7ff517ae7960) 0 vptr=((& Point2D::_ZTV7Point2D) + 16)Vtable for Point3DPoint3D::_ZTV7Point3D: 16 entries0 168 (int (*)(...))016 (int (*)(...))(& _ZTI7Point3D)24 (int (*)(...))Point3D::~Point3D32 (int (*)(...))Point3D::~Point3D40 (int (*)(...))Point3D::allAddOne48 (int (*)(...))Point3D::z56 1844674407370955160064 1844674407370955160072 1844674407370955160080 (int (*)(...))-1688 (int (*)(...))(& _ZTI7Point3D)96 (int (*)(...))Point3D::_ZTv0_n24_N7Point3DD1Ev104 (int (*)(...))Point3D::_ZTv0_n24_N7Point3DD0Ev112 (int (*)(...))Point3D::_ZTv0_n32_N7Point3D9allAddOneEv120 (int (*)(...))Point3D::_ZTv0_n40_NK7Point3D1zEvVTT for Point3DPoint3D::_ZTT7Point3D: 2 entries0 ((& Point3D::_ZTV7Point3D) + 24)8 ((& Point3D::_ZTV7Point3D) + 96)Class Point3D size=32 align=8 base size=12 base align=8Point3D (0x0x7ff51797d1a0) 0 vptridx=0 vptr=((& Point3D::_ZTV7Point3D) + 24) Point2D (0x0x7ff517ae7de0) 16 virtual vptridx=8 vbaseoffset=-24 vptr=((& Point3D::_ZTV7Point3D) + 96)
Point3D对象的结构还是比较简单的,如下:
(gdb) x/8xw p3d0x8414e70: 0x08201cd0 0x00000000 0x00000003 0x000000000x8414e80: 0x08201d18 0x00000000 0x00000001 0x00000002
很明显0x08201cd0是Point3D新增的虚表指针,结合类信息,我们知道其指向了((& Point3D::_ZTV7Point3D) + 24);而0x08201d18是继承自虚基类的放虚表指针的地方,只不过这里放了Derived类自己的虚表指针,其指向了((& Point3D::_ZTV7Point3D) + 96)。
我们关注的重点是虚基类的虚函数表和其中虚函数的实现:虚函数表中放的是什么的地址?不像没有虚基类的多重继承那样各个对象的偏移是一定的,(在只有指针或引用的情况下)虚继承下虚基类的偏移是运行时才能知道的,其中的虚函数又是如何调整this指针的呢?
(gdb) x/4ag 0x08201d180x8201d18 : 0x8000ba6 0x8000bdb 0x8201d28 : 0x8000c24 0x8000c3f
正如类信息中展示的那样,虚基类的虚表中放置的正是这几个函数名字,但这几个函数是什么呢?我们使用c++filt看一下:
$ c++filt _ZTv0_n24_N7Point3DD1Evvirtual thunk to Point3D::~Point3D()$ c++filt _ZTv0_n24_N7Point3DD0Evvirtual thunk to Point3D::~Point3D()$ c++filt _ZTv0_n32_N7Point3D9allAddOneEvvirtual thunk to Point3D::allAddOne()$ c++filt _ZTv0_n40_NK7Point3D1zEvvirtual thunk to Point3D::z() const
可以看到他们被称为virtual thunk,看来是和thunk相似的技术,用来调整this指针和返回值,我们来看看其内部是怎么运行的:
和我们前面讨论的thunk非常像,都是调整this指针,只是前面的thunk里this指针调整的值是固定的,而这里this指针调整的值是动态的放在vptr[-3]
处,我们再看一下这里放的是什么,我们直接看g++生成的类信息,虚表指针是指向((& Point3D::_ZTV7Point3D) + 96),那vptr[-3]
就应该是((& Point3D::_ZTV7Point3D) + 72)放的东西了,可以看到是18446744073709551600,把这个值当作一个long类型的值的话正好是-16,这不就是从Point2D*
类型转化为Point3D*
类型需要减的值嘛(因为Point2D在Point3D类的实体中偏移为16)。我们再检查一下其他的virtual thunk是不是也是一样?
嗯,没问题,再看看下一个:
不好,出现不一样了,这次偏移是vptr[-4]
这里,也就是((& Point3D::_ZTV7Point3D) + 64)放的东西,可以看到是18446744073709551600。咦,和上面的是一样的。考虑到虚表指针指向的前两项其实是一个函数,只不过一个不调用::operator delete
,一个调用而已。那是不是编译器为每个虚函数都准备了一个this指针的调整量?我们继续看最后剩下的那个virtual thunk:
果然是这样的,这次是vptr[-5]
。
我们可以稍微总结一下虚继承下虚函数的实现:就是在虚表里为每个虚函数增加了虚基类指针到override该虚函数的派生类的指针需要对this进行的偏移。
我能做的总结也就是这样了,如果有大神知道详细的规则可以评论一下,或者给一个链接,谢谢。
这一章后面还有成员函数指针的内容,就留在后面的博客里讲吧。