目录
前言
\quad\quad 相信很多人在了解到多态之后,都想要知道其具体的实现原理。其实大家应该都或多或少地知道:虚函数是通过虚函数表实现的
。但是呢,可能和我之前一样,知道大概是怎样的,但是没有通过代码真正地运行测试。最近我也在网上查看了一番,发现有的文章写得很好,但是代码上面可能欠考虑一点,有的则是没有详细解释。因此本文将使用详细示例来探讨虚函数的实现原理,教你如何通过对象查找虚函数地址并调用。末尾还会分享一些没有用但好玩的技巧。
运行环境
本文代码在以下环境编译通过,且运行结果一致。如果你们的运行结果不一致,欢迎评论反馈。
VS2017、C++11
g++ (GCC) 4.8.5、C++11
什么是虚函数
\quad\quad 虚函数是可在派生类中覆盖其行为的成员函数。与非虚函数相反,即使没有关于该类实际类型的编译时信息,仍然保留进行覆盖的行为。当使用到基类的指针或引用来处理派生类时,对被覆盖的虚函数的调用,将会调用定义于派生类中的行为。这种函数调用被称为虚函数调用或虚调用。当使用有限定名字查找(即函数名出现在作用域解析运算符 :: 的右侧)时,虚函数调用被抑制。
\quad\quad 相信看这篇文章的你一定是知道虚函数的作用,这里也就不详细说了。虚函数的详细介绍见virtual 函数说明符。
简单示例
#include <iostream>
struct Base {
virtual void f() {
std::cout << "base\n";
}
};
struct Derived : Base {
void f() override {
// 'override' 可选
std::cout << "derived\n";
}
};
int main()
{
Base b;
Derived d;
// 通过引用调用虚函数
Base& br = b; // br 的类型是 Base&
Base& dr = d; // dr 的类型也是 Base&
br.f(); // 打印 "base"
dr.f(); // 打印 "derived"
// 通过指针调用虚函数
Base* bp = &b; // bp 的类型是 Base*
Base* dp = &d; // dp 的类型也是 Base*
bp->f(); // 打印 "base"
dp->f(); // 打印 "derived"
// 非虚函数调用
br.Base::f(); // 打印 "base"
dr.Base::f(); // 打印 "base"
}
虚函数的实现原理
虚函数表
\quad\quad 对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表是类共享的,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
\quad\quad C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
通过虚表找到函数地址并调用
我们现在有这样一个类:
class Base
{
public:
virtual void f() {
std::cout << "Base::f" << std::endl; }
virtual void g() {
std::cout << "Base::g" << std::endl; }
virtual void h() {
std::cout << "Base::h" << std::endl; }
};
那么他的虚表和对象关系如下所示:
\quad\quad 当然在本例中Base没有其他成员变量,我画出来是为了方便理解。实际上sizeof(Base)
得到的就是一个指针的大小,因为Base没有其他成员,只有虚函数,因此只需要存一个虚函数表的地址即可。注意:虚函数表是类共享的,不是每个类的实例都有一个虚函数表,而是每个类的实例都存有虚函数表的起始地址。
\quad\quad 那么现在我们知道虚函数如何存储了,那么指针学得较好的同学可能就会想:那我是不是可以直接从对象里手动去找到虚函数的函数地址并调用呢?答案是肯定的。只需要如下几步:
得到虚函数表的地址
:我们知道虚函数表的地址是在对象所占空间的最前面。因此可以这样:Base base; Base *pb = &base; // 把base空间的前4个字节按int读出来,由于我们知道它是指针,因此我们直接转成指针类型并打印 // 但是这里有个问题:指针在32位是4字节,但是64位程序是8字节,因此这样的代码是不通用的, // 后面会使用通用的代码进行解析。 void **v_table_ptr = (void **)(*(int *)pb);
找到虚函数f的地址并调用
:我们知道了虚表的地址实际上虚表里的元素就是虚函数的地址。因此可以这样:
这样就会打印出// 定义函数指针vf,获取虚表的第一个元素,我们知道它是Base的虚函数f的地址, // 因此直接转为f的函数指针类型:void (*)() void (*vf)() = (void (*)())(v_table_ptr[0]); vf();
Base::f()
当然你可能会觉得上面的代码很绕,那么我就给出一个简化的版本,让你能轻松理解这几行代码,也是为后面的示例做准备。- 首先我们定义一个类型别名:
using func_type = void (*)()
或者typedef void (*func_type)();
这两种方式都是可以的,但是我推荐用C++的using
来定义,因为我们很明显可以看出用using
可读性更高。 - 然后呢,我们知道虚函数表其实就是一个数组,里面存了函数地址,因此虚函数表就是一个指针数组,
那么我们就可以定义一个基本指针类型pointer:using pointer = void *;
,
然后定义一个指针数组类型:using pointer_arr = pointer *;
。 - 现在我们考虑如何取到虚表地址:
pointer_arr v_table_ptr = *(pointer_arr *)(pb);
,因为pb的前几个字节存的是pointer_arr的地址,因此我们将其转为pointer_arr *
类型,再解引用,就得到了pointer_arr
的地址。 - 这下找到函数地址并调用就简单了:
这样通过类型别名,是不是这几步操作就十分通俗易懂了呢?所以对于复杂类型,有时善用类型重定义,有化繁为简的效果。func_type vf = (func_type)(v_table_ptr[0]); func_type vg = (func_type)(v_table_ptr[1]); vf(); vg();
- 完整代码:
#include <iostream> class Base { virtual void f() { std::cout << "Base::f" << std::endl; } virtual void g() { std::cout << "Base::g" << std::endl; } virtual void h() { std::cout << "Base::h" << std::endl; } }; int main() { using func_type = void(*)(); using pointer = void *; using pointer_arr = pointer * ; Base base; Base *pb = &base; // 使用这种方式访问还有一个好处就是32位和64位兼容, // 上面使用int *去取一个int的字节,在64位下就是错误的。 // 而我们这里是取指针,而指针在32位下4字节,64位下8字节, // 因此是兼容的写法,可读性也更高 pointer_arr v_table_ptr = *(pointer_arr *)(pb); std::cout << "虚函数表地址是: " << v_table_ptr << std::endl; func_type vf = (func_type)(v_table_ptr[
- 首先我们定义一个类型别名: