多态是C++的一个基本概念,简单来说就是程序运行时使用基类指针或引用去调用派生类的方法,被调用的方法必须是基类里定义过的虚函数。
本文从虚函数表去理解C++的多态。
一 类的虚指针vptr (virtual pointer)
只要类里有虚函数,那么它实例化的对象就会有虚指针,且位于对象所占内存空间的起始地方,虚指针指向虚函数表。下面来看下如何获取类的虚指针。
首先来看一个空类,
class Empty
{}
C++规定空类大小不能是0,而是1个字节。因为如果是0字节,那么就无法区分空类实例化出的多个对象,多个对象都是0字节,都没有内存地址,那么它们就是完全一样的了。
可以使用下面语句去查看空类大小,
printf("size: %d\n", sizeof(Empty));
下面来定义一个包含虚函数的类,
class Test
{
public:
virtual void fn1(void)
{
printf("hello world 1\n");
}
virtual void fn2(void)
{
printf("hello world 2\n");
}
};
使用下面的函数来获取对象的虚指针值,
int getVptr(Test * p)
{
// 把对象指针转为int*,然后解引用就可以获取对象地址上前4个字节的值
int data = *(int *)p;
return data;
}
这里把虚指针值转为整形,注意,本文使用的是32位系统,所以指针值为4个字节。
我们在main函数里来测试一下,
int main(void)
{
Test obj1, obj2;
printf("obj1 size: %d; obj2 size: %d\n", sizeof(obj1), sizeof(obj2));
printf("obj1 vptr: 0x%x\n", getVptr(&obj1));
printf("obj2 vptr: 0x%x\n", getVptr(&obj2));
}
输出如下,
可以看出,这2个对象的虚指针值是相同的,也就是它们指向的是相同的虚函数表。
同样也可以看出,在计算类对象大小时,并不包含其拥有的函数,因为函数可以看做是一块代码单元,相同类的对象都可以使用它,在调用函数时,会传递一个隐式参数this来表示是哪个类对象在调用它。这也是为什么obj1和obj2的虚指针指向的是相同的虚函数表。
二 类的虚函数表vtbl (virtual table)
上节中,获取了类对象的虚指针值,虚指针指向虚函数表,虚函数表里存放虚函数指针,下面通过虚函数表来获取类成员函数指针,
typedef void (*fn_t)(void);
int main(void)
{
Test obj1;
int vptr = getVptr(&obj1); // 获取虚指针值
int *vtbl = (int*)vptr; // 解引用得到虚函数表值
fn_t func = (fn_t)vtbl[0]; // 获取第一个函数(即fn1)的地址值,并转为函数指针
func(); // 通过函数指针去调用函数
}
输出打印如下,
可以看到类Test里定义的第一函数fn1被调用了。
我们可以写个函数来调用类里指定的函数,可以打印虚指针值和虚函数表里的函数地址值
void callFuncInVTable(Test * p, int index, bool printFlag=false)
{
int vptr = getVptr(p); // 获取虚指针值
if (printFlag)
{
printf("vptr: 0x%08x\n", vptr);
}
int *vtbl = (int*)vptr; // 解引用得到虚函数表值
fn_t func = (fn_t)vtbl[index]; // 获取第一个函数(即fn1)的地址值,并转为函数指针
if (printFlag)
{
printf("function addr: 0x%p\n", func);
}
func(); // 通过函数指针去调用函数
}
这样main函数可以写成如下这样,
int main(void)
{
Test obj1;
callFuncInVTable(&obj1, 0);
callFuncInVTable(&obj1, 1);
}
打印如下,
三 理解多态
下面创建2个派生类,继承Test
class YYY : public Test
{
virtual void fn1(void)
{
printf("goodbye 1\n");
}
virtual void fn2(void)
{
printf("goodbye 2\n");
}
};
class ZZZ : public Test
{
virtual void fn1(void)
{
printf("how are you 1\n");
}
virtual void fn2(void)
{
printf("how are you 2\n");
}
};
同样,我们在main()函数里去查看这些派生类的对象的虚指针和成员函数调用,
int main(void)
{
Test obj1;
callFuncInVTable(&obj1, 0, true);
callFuncInVTable(&obj1, 1, true);
printf("\n\n");
YYY yyyobj;
callFuncInVTable(&yyyobj, 0, true);
callFuncInVTable(&yyyobj, 1, true);
printf("\n\n");
ZZZ zzzobj;
callFuncInVTable(&zzzobj, 0, true);
callFuncInVTable(&zzzobj, 1, true);
}
注意,callFuncInVTable()的第一个参数的类型是Test *,即基类的指针类型,这也是多态的调用方法。
打印如下,
可以看到2个派生类和基类所创建的对象中虚指针是不同的,它们指向各自的虚函数表,并调用各自的成员函数。
这样我们就理解了基类指针或引用调用派生类方法的原理。
另外,由于2个派生类把基类里的虚函数都重写了,所以vtbl里的函数地址值也都是不一样的,如果派生类里只重写了部分虚函数,那么没被重写的虚函数它们的地址也在派生类的vtbl里吗?下面简单看下,
class TTT : public Test
{
virtual void fn2(void)
{
printf("goodbye 2\n");
}
};
main函数如下,
int main(void)
{
Test obj1;
callFuncInVTable(&obj1, 0, true);
callFuncInVTable(&obj1, 1, true);
printf("\n\n");
TTT tttobj;
callFuncInVTable(&tttobj, 0, true);
callFuncInVTable(&tttobj, 1, true);
}
打印如下,
可以看到,TTT只重写了Test的fn2,但是TTT的类对象的vtbl里包含了基类里fn1的函数地址,这样就可以正确调用fn1了,只是fn1是基类里的定义的。
四 总结
本文从虚指针和虚函数表去理解C++多态的工作原理,本文中所写的函数并不能100%模拟真实程序运行时多态的运行过程,如传递this指针,不过大体上原理都是差不多的。
如果有写的不对的地方,希望能留言指正,谢谢阅读。