前言
C++中的虚函数表作用主要是实现多态机制,当父类指针指向子类对象时,通过父类的指针调用子类的成员函数,可以实现在运行时有“多种形态”,这是一种泛型技术。所谓泛型技术,就是通过用不变的代码来实现可变的算法,比如:模板技术,虚函数技术。
在网上存在很多解析虚函数表的博客,但是说法并不统一,也可能是我在理解上有偏差,但确实在阐述父类,子类,虚函数表,虚表指针这些概念时稍有不慎就会词不达意,因此我想以最简洁明了的语言表述这一概念,先说明一下,因为C++虚函数表的组织方式是编译器实现的细节,C++标准并没有强制规定,所以在不同编译器不同版本下会存在差异。我使用的是主要是gcc version 9.4.0(文章最后也有一些Visual Studio 2019的虚函数表内存调试分析)。
虚函数表
虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的,可以理解为是一个数组记录了虚函数的地址,这张表会在程序的编译阶段生成,并放在只读数据段。
当且仅当使用指针调用虚函数时会进行访问虚函数表指针,然后查找虚函数表并进行虚函数的调用。因此C++的编译器应该是保证虚函数表的指针存在于对象实例的最前面,这是为了保证取到虚函数表时可以有最高性能。对象继承之间的关系和虚函数表的形式有很大关系,大抵可以分为以下几种情况,一般继承(无虚函数覆盖),一般继承(有虚函数覆盖),多重继承(无虚函数覆盖),多重继承(有虚函数覆盖),下面我会一一讲解。
一般继承(无虚函数覆盖)
如以下代码为例:
class A
{
int a;
virtual void fun(){}
};
class B:public A
{
int b;
virtual void fun1(){}
};
void test()
{
A a;
B b;
}
int main()
{
test();
}
因为只要有虚函数的类就会有虚函数表指针和一个在编译期间存储于只读数据段的本类所有对象共享的虚函数表,那么B继承A那么在B的对象的前8个字节(x64)会存在一个虚表指针,在B的虚函数表中存储的有4个元素,为什么是四个?不应该是只有两个个虚函数的地址吗?且听我来解释!
第一个元素是一个偏移量top_offset,第二个元素是类的标识typeinfo ,第三个元素A::fun(),第四个元素B::fun1().top_offset是在多重继承时调整this指针用的(后面会讲解),typeinfo是用以标识当前类的。我们可以在编译时使用-fdump-lang-class选项,如下:
会在当前目录下生成一个名为test1.cpp.001l.class的文件里面记录了类的组织结构以及虚函数表的布局,
可以看到当子类继承父类的虚函数时,确确实实会在内存中生成自己的一张虚函数表。
一般继承(有虚函数覆盖)
class A
{
int a;
virtual void fun(){}
};
class B:public A
{
int b;
virtual void fun1(){}
virtual void fun(){}
};
void test()
{
A a;
B b;
}
int main()
{
test();
}
直接上图,可以看到与上面不同之处在于,此时出现了覆盖,在B虚函数表中重写的函数将原本A虚函数表中的同名函数的地址进行覆盖了。
多重继承(无虚函数覆盖)
class A
{
int a;
virtual void fun(){}
};
class B
{
int b;
virtual void fun1(){}
};
class C:public A,public B
{
virtual void fun2(){}
};
void test()
{
A a;
B b;
C c;
}
int main()
{
test();
}
如图为类C的虚函数表,可见,当多重继承时,C类会生成本类独属的虚函数表,并且只会生成一个,此时在类C产生的对象布局内部应该是这样,(使用clang++的对象内存分析的工具,命令:clang++ -Xclang -fdump-record-layouts -c test1.cpp >log)
结合两张图可以知道,C继承A时会继承一个虚函数表指针,继承B时也会继承一个虚函数表指针,A vatable pointer 和 Bvtable pointer指向一张虚函数表的不同偏移处,并且类C新增的虚函数会被记录到主虚表的后面A vatable pointer指向和B vatable pointer指向我已经在上图标记。我们也可以自己通过指针偏移来验证下:
#include<iostream>
using namespace std;
class A
{
int a;
virtual void fun(){std::cout<<"hello"<<std::endl;}
};
class B
{
int b;
virtual void fun1(){}
};
class C:public A,public B
{
virtual void fun2(){}
};
//打印内存中8个字节的十六进制数据
void get(void*p)
{
unsigned char*ptr = static_cast<unsigned char*>(p);
for(int i=0;i<8;i++)
{
printf("%02x ",ptr[i]);
}
printf("\n");
}
typedef void(*Func)();
void test()
{
A a;
B b;
C c;
printf("A的前8字节:\n");
get(&a);
printf("B的前8字节:\n");
get(&b);
printf("C的前8字节:\n");
get(&c);
printf("C的后8字节:\n");
get((char*)&c+8);
printf("函数调用\n");
Func func;
//调用主虚表中的第一个虚函数.如果符合预期会打印出"hello"
func = (Func)(*((long*)(*((long*)&c)) ));
func();
printf("偏移量的地址\n");
get(((void*)(*((long*)&c)) +2));
printf("偏移量的值=%ld\n",*((long*)(*((long*)&c)) +2));
}
int main()
{
test();
return 0;
}
输出为小端字节序,但是不要紧,反转之后就是真实的数据,比较C的两个虚表指针相差32字节,刚好对应了上表,成功打印出”hello”说明函数调用成功,偏移量也正确为-16。
多重继承(有虚函数覆盖)
class A
{
int a;
virtual void fun(){}
};
class B
{
int b;
virtual void fun1(){}
};
class C:public A,public B
{
virtual void fun(){} //覆盖主虚表
virtual void fun1(){} //覆盖次级虚表
virtual void fun2(){} //新增虚函数
};
void test()
{
A a;
B b;
C c;
}
int main()
{
test();
}
使用-fdump-lang-class产生如图:
类C中的三个函数各有特色,先看class C::fun()函数,这是重写的class A::fun()此时原本存在于主虚表中的class A::fun()直接被覆盖为class C::fun().再看class C::fun2(),这是在类C中新添加的虚函数,类A与类B中并无此函数,可以看到被添加了主虚表的最后,在类C的虚函数表中,类C的虚函数表会和第一个继承的基类的虚函数表混合称为类C的主虚表,也就是说在子类中新添加的虚函数都会被直接添加到主虚表最后。
class C::fun1()这是重写的class B::fun1(),只要是我们在类C中定义的函数,无论是重写还是自定义都会出现在主虚表中,在次级虚表B处的地址起始也是class C::fun1(),由于被重写因此由class B::fun1()变为了class C::fun1()。
来解释一下_ZThn16_N1C4fun1Ev是什么意思?
在C++的虚函数表中_ZThn16_N1C4fun1Ev表示一个thunk,用于调整this指针并调用正确的函数。_Z是C++的名称修饰的起始标记,Thn8表示这是一个thunk(Th),需要将this指针减去8字节(n表示负偏移)。N1C4fun1Ev表示C::fun1(void)(N开始嵌套名称,1C是类名C,4fun1是函数名fun1,Ev表示参数void)。
_ZThn16_N1C4fun1Ev有什么用呢?
当通过第二个基类的指针指向子类对象时,要调用被覆盖的虚函数fun1时,此时需要将this指针从基类子对象的地址调整为完整的C类对象地址。那thunk技术就是实现了这一操作。
比如现在我们使用B*p = new C().并用p->fun1()时,此时p指针记录的地址应该是经过类型擦除或者说是隐式转换的,它并不指向C在内存中的起始地址,而是起始地址 + sizeof(A),如下:
但是现在我们要用b去调用被子类重写了的fun1(),在fun1中可能访问的对象内存空间的全部内容,但是此时this指针指向的并不是对象内存的起始地址,这肯定会发生问题的。而thunk就在b->fun1()之前,将this指针调整为了对象c的内存起始地址,在执行完b->fun1()之后又进行反向操作,将this指针回退。
总结
- 以下全部总结的环境是在gcc version 9.4.0版本。
- 子类中是可能存在多个虚函数表指针的,多继承时继承几个有虚函数的基类就会有几个虚函数表指针,每个虚函数表指针都指向同一张物理虚函数表的不同偏移处,指向的都是从基类继承来的逻辑虚函数表中的第一个虚函数的地址。
- 只要有虚函数的类就会在编译时期在只读数据段为虚函数表划分虚拟地址空间。而且类中只有物理上的一张虚函数表,可能从逻辑上我们可以看成是主虚表,次级虚表….并且逻辑上的每张虚表都由三部分构成,top_offset(当前指针指向的对象地址距离对象起始地址的偏移,由子类继承父类的顺序决定),typeinfo(类的标识)以及虚函数指针,这些条目都被强转成了int(*)(…)类型的函数指针。
- 派生类并不会产生独自的逻辑虚函数表,而是和第一个继承的基类共用一个逻辑虚函数表称为主虚表,当派生类新增虚函数时就会被添加到主虚表的末尾,当派生类重写虚函数时,此时在主虚表中会基类重写了的虚函数地址,并且之前从基类继承而来的逻辑虚函数表中的地址会被覆盖。(在说具体一些,如果被重写的是主虚表中的函数,那么只是将原本函数覆盖,如果重写的是次级虚表中的函数,那么就会将之前次级虚表中的条目以thunk技术覆盖,并且会添加到主虚表中,也就是说此时虚函数表中存在两个条目指向同一个虚函数地址。)比如下图中的fun1()函数。
- 在虚函数表中的typeinfo是用于动态类型识别的RTTI,比如我们使用dynamic_cast或者type_info进行类型判断时会使用。
- 整个多态的流程如下:以b->fun1()为例
class A
{
int a=0;
virtual void fun(){}
};
class B
{
public:
int b=1;
virtual void fun1(){}
};
class C:public A,public B
{
virtual void fun(){} //覆盖主虚表
virtual void fun1(){} //覆盖次级虚表
virtual void fun2(){} //新增虚函数
};
void test()
{
C c;
B*b = &c;
b->fun1();
}
int main()
{
test();
}
将派生类的地址赋值给基类,此时会发生类型擦除,基类b指向基类子对象在派生类中的地址位置,并非对象c的起始地址。b->fun1()在编译阶段时,如果编译器发现fun1()并非虚函数那么就会将fun1()地址进行确定,此时地址就在编译阶段确定了。如果是虚函数,那么实际调用的函数地址是在运行阶段确定的,通过获取基类指针指向的地址的前四个字节得到虚函数表指针,访问虚函数表指针获取记录在虚函数表中的虚函数的地址。
多态的实现,类型擦除是手段,虚表指针是纽带,虚函数表是基础。
Visual Studio 2019
在Visual Studio 2019中每一个虚函数表中都是以0作为结尾,并且允许一个个类中存在多个虚函数表,我们可以使用Visual Studio 2019提供的开发者工具来查看虚函数表的内存布局。
打开Developer Command Prompt for VS 2019,然后cd 进入Visual Studio 2019所使用文件的目录中,输入cl /d1 reportSingleClassLayoutBase “源4.cpp”命令。
可以看到如下代码的Base类的虚函数的内存布局
#include<iostream>
class A
{
public:
int a;
int c;
int c1;
int c2;
virtual void funa() {}
virtual ~A(){};
};
class B
{
public:
int b;
virtual void funb() {}
virtual ~B() {};
};
class Base :public A, public B
{
virtual void fun2() {}
v
virtual void funa() {}
virtual void fun3() {}
};
void test()
{
A a;
B b;
Base c;
std::cout << sizeof c ;
}
int main()
{
test();
return 0;
}
确实是存在两个虚函数表,每个虚函数表的开头第一个元素是this指针的偏移值。Base类和第一个继承的类的虚函数表组合而成主虚表,重写的虚函数会覆盖之前虚函数表中的虚函数地址,新增函数会添加到主虚表最后,但是有一点是与gcc明显不同的是,在base类中重写类B的虚函数后,并没有将重写的虚函数添加到主虚表中,但是在gcc中是添加了的。也就是说将主虚表和次级虚表进行了完全的分割。