C++如何打印虚函数表的地址

C++打印虚函数表地址

引入

今天遇到了一个问题,非常有意思,在这做一下记录:

对于存在虚函数的类,如何打印虚函数表的地址,并利用这个虚函数表的地址来执行该类中的虚函数

虚函数介绍

先简单来讲一下虚函数的概念。

要理解虚函数的概念,先要明确什么是静态类型,什么是动态类型,静态类型可以直观理解为声明时使用的类型,其在编译期间就已经确定,所谓动态类型则在程序运行时才具体表现其类型,在编译期间是不能确定的。

C++中的多态通过虚函数实现,而虚函数通过虚函数表和虚函数表指针实现,利用基类的指针指向派生类,可以调用派生类对应的虚函数,多个不同的派生类可以在运行期间表现出不同的表现,简单示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Base {
public:
    virtual void fun1() {
        std::cout << "Base fun1" << std::endl;
    }

    virtual void fun2() {
        std::cout << "Base fun2" << std::endl;
    }
};

class ChildA : public Base {
public:
    void fun1() override {
        std::cout << "ChildA fun1" << std::endl;
    }

    void fun2() override {
        std::cout << "ChildA fun2" << std::endl;
    }
};

class ChildB : public Base {
public:
    void fun1() override {
        std::cout << "ChildB fun1" << std::endl;
    }

    void fun2() override {
        std::cout << "ChildB fun2" << std::endl;
    }
};

这里先定义三个类,继承关系为ChildA与ChildB均public继承Base类,接下来使用基类函数分别去指向三个类对象并执行虚函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

int main() {
    Base *ptr1 = new Base();
    Base *ptr2 = new ChildA();
    Base *ptr3 = new ChildB();

    ptr1->fun1();
    ptr2->fun1();
    ptr3->fun1();

    ptr1->fun2();
    ptr2->fun2();
    ptr3->fun2();

    delete ptr1;
    delete ptr2;
    delete ptr3;

    return 0;
}

执行结果如下:

1
2
3
4
5
6
Base fun1
ChildA fun1
ChildB fun1
Base fun2
ChildA fun2
ChildB fun2

可以看到,虽然三个指针都是Base类型的指针,但执行的函数并不相同,这就是虚函数实现的多态:对于相同的请求可以给出不同的反应,执行不同的计划。

简单阐述一下实现原理:

在有虚函数的类中,虚函数用virtual关键字修饰,编译器在遇到有虚函数的类后,会为这个类型创建虚函数表(所谓虚函数表根据编译器的不同实现也不同,从原理上可以认为是一个函数指针数组),以及一个指向这个虚函数表的虚函数表指针,然后再将这个指针存储到类中,在实际运行中,如果执行的是虚函数则会先通过找到虚函数表,再在虚函数表中找到对应的虚函数的函数指针来执行。

而对于继承这个拥有虚函数的子类来说,在继承的时候会一并继承基类的虚函数表以及虚函数指针,可以使用override来对虚函数进行覆盖,当进行覆盖后则虚函数表中原来存储基类虚函数的函数指针会被替换为子类override后的函数指针,这样当找到子类的虚函数时执行的就是子类自己定义的虚函数。

对于继承关系,可以简单归纳为:

  1. 一般继承时,子类的虚函数表中会先将基类的虚函数放在最前面,再放自己的虚函数指针
  2. 如果子类覆盖了基类的虚函数,则该虚函数将被放到虚函数表中原来基类虚函数的位置
  3. 对于多继承的情况,每个基类都拥有自己的虚函数表,子类自身的虚函数将被放到第一个基类的表中,也就是说当类存在多重继承情况时,其实例对象的内存结构里并不只保存一个虚函数表指针,而是有几个基类就会保存几个虚函数表指针

针对上面第三种情况,给一个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class A {
public:
    virtual void func_a() {}
private:
    int a;
};

class B {
public:
    virtual void func_b() {}
private:
    int b;
};

class C : public A, B {
public:
    virtual void func_c() {}
private:
    int c;
};

对于类C,其内存分布如下图:

如何打印虚函数表

要想知道如何打印虚函数表,首先要确定虚函数表位于类的什么位置,因此我们来用一个例子来具体看一下一个拥有虚函数的类的内存分布到底是什么样子:

首先创建一个类,这个类拥有两个虚函数以及一个成员变量,并在主函数中创建这样一个类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Test {
public:
    virtual void v_func() {}
    virtual ~Test() {}
    int m_test;
};

int main() {
    Test* t = new Test();
    return 0;
}

这个类的内存中的分布大致如下:

  • 首先创建的Test*对象位于栈上,指向一个堆中对应的Test实例
  • 对象Test的头部是一个虚函数表指针,紧接着是Test对象按照声明顺序排列的成员变量(当创建一个对象时,可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针)
  • 虚函数表指针指向的是代码段中Test类型的虚函数表中的第一个虚函数起始地址
  • 在虚函数表中会构建两个虚析构函数,因为对象有两种构造方式,栈上构造和堆上构造,对于栈上构造的对象其析构不需要执行对应的delete函数,会自动被回收
  • typeinfo存储着Test的类基础信息,包括父类与类名称,C++关键字typeid返回的也就是这个对象
  • typeinfo本质也是一个类,对于没有父类的Test类来说,当前的tinfo是class_type_info类型

最后重新再把虚表摘出来,其内部结构如下:

  • 常规认知中的虚函数表并不是单独存在的,而是虚表的一部分,橘色框中的内容即之前理解的虚函数表,其中存放的是虚函数指针
  • 紫色框中的是虚函数的一些基本信息:
    • offset to top指的是这个表到对象起始位置(即内存顶部)的偏移值,只有多重继承的情形才有可能不为0,单继承或者无继承的情况下都是0
    • RTTI information是一个对象指针,它用于唯一的标识该类型,指向存储运行时类型信息(type_info)的地址,用于运行时进行类型识别,用于typeiddynamic_cast
  • 蓝色框中的内容仅限于虚拟继承的情况(若无虚拟继承,则无此内容)

来看一下如何打印虚函数表吧~

这里拿出前面总结中最重要的两点

  • 对象Test的头部是一个虚函数表指针,紧接着是Test对象按照声明顺序排列的成员变量(当创建一个对象时,可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针)
  • 虚函数表指针指向的是代码段中Test类型的虚函数表中的第一个虚函数起始地址

可以很简单理解为,类中有一个虚函数表指针,该指针位于类的最前面即首地址处,而虚函数表指针则指向了第一个虚函数。

因此还是这个基本类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Base {
public:
    virtual void fun1() {
        std::cout << "Base fun1" << std::endl;
    }

    virtual void fun2() {
        std::cout << "Base fun2" << std::endl;
    }
};

首先我们使用语句查看编译后的虚函数表与前面分析是否一致:

1
2
3
4
5
6
# gcc查看对象布局
g++ -fdump-class-hierarchy main.cpp后查看生成的文件

# clang可以使用如下命令
clang -Xclang -fdump-record-layouts -stdlib=libc++ -c main.cpp # 查看对象布局
clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c main.cpp # 查看虚函数表布局

对象布局结果如下(只展示Base部分):

1
2
3
4
5
*** Dumping AST Record Layout
         0 | class Base
         0 |   (Base vtable pointer)
           | [sizeof=8, dsize=8, align=8,
           |  nvsize=8, nvalign=8]

这个含有虚函数的Base类大小为8,因为只有一个虚函数指针,在对象的头部,前8个字节是虚函数表的指针,指向虚函数的相应函数指针地址。

虚函数表布局执行结果如下(只展示虚函数表部分):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Vtable for 'Base' (4 entries).
   0 | offset_to_top (0)
   1 | Base RTTI
       -- (Base, 0) vtable address --
   2 | void Base::fun1()
   3 | void Base::fun2()

VTable indices for 'Base' (2 entries).
   0 | void Base::fun1()
   1 | void Base::fun2()

可以看到,虚函数表地址指向的是void Base::fun1(),往上是信息以及偏移,由于无继承,因此offset也确实为0,往下是两个虚函数,符合预期,与前文分析相同,接下来具体实践一下如何通过编程的手段来打印这个虚函数表。

要想打印这个类的虚函数表,可以采用如下方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using FUNC = void (*)();

int main() {
    Base *b = new Base();

    // 将b的地址转换成long long型,因为在64位编译器上面,指针占用8个字节
    // b是指向虚函数表的指针的地址
    auto *tmp = (long long *) b;

    // 对tmp解引用,得到虚函数表的地址
    auto *vptr = (long long *) (*tmp);
    cout << "虚函数表的地址为:" << vptr << endl;

    // 虚函数表中第一个方法,继承于父类的虚函数fun1
    FUNC fun1 = (FUNC) *vptr;
    // 虚函数表中第二个方法
    FUNC fun2 = (FUNC) *(vptr + 1);

    fun1();
    fun2();

    return 0;
}

最终执行结果为:

1
2
3
虚函数表的地址为:0x104cb42c8
Base fun1
Base fun2

可以看到,打印出来了虚函数表的地址,并且可以直接通过这个地址区调用对应的虚函数

0%