C++虚函数剖析 从二级指针角度

 
Category: C++

写在前面

一直说 C++的多态, 其实底层原理是虚函数支持, 那么虚函数的底层原理呢, 之前一直停留在表面, 直到后来看了很多书籍/视频/博客文章, 才有了一点深刻的理解, 下面来具体看看如何通过 C 指针进行虚函数的调用, 相当于对 C 指针的一个复习, 同时也是对 C++虚函数底层原理的一个理解.

主要内容有如下几点:

  1. 二级指针的复习
  2. 通过对象首地址访问虚函数表
  3. 通过虚函数表调用虚函数
  4. 基类的私有虚函数, 可以通过指针运算访问! 这也是 C++灵活性的一个问题(缺陷)

测试代码可以参考我的 GitHub: Learn_C_CPP/oop_ood/virtual_func/read-vfunc.cpp at master · zorchp/Learn_C_CPP;

前置知识

需要 C指针基础, 不只是停留在变量取地址和解引用等方面, 还需要知道指针变量的地址, 即二级指针, 函数指针等知识, 还有 C++类内的成员函数指针/数据成员指针. 下面先来看一下二级指针相关.

指针类型的定义

这里用一个宏来确保后面的测试程序在 64 位机器和 32 位机器下都可以执行

#if __WORDSIZE == 64
using TYPE = unsigned long long;
#else
using TYPE = int;
#endif

复习: 二级指针

首先定义一个数组, 其包含三个元素, 那么这个数组的名称arr的类型是什么呢?

int arr[3]{18, 22, 43};
// arr 其实就是首地址, 值相同但是意义不同
int *p = (int *)arr; // lost size info
assert(p == arr); // 类型转换, 但是值相同
cout << typeid(arr).name() << endl;  // A3_i, int [3]
cout << typeid(&arr).name() << endl; // PA3_i, int (*) [3]
// 并且都可以用下标进行取元素操作
cout << *(p + 1) << endl;
cout << *(p + 2) << endl;
cout << *(arr + 1) << endl;
cout << *(arr + 2) << endl;
// 22
// 43
// 22
// 43

下面来到二级指针:

int **pp = &p; // 指向 p 的指针

printf("arr=%p\n", arr);
printf("p=%p\n", p);
printf("pp=%p\n", pp);
// arr=0x16ce72a18
// p=0x16ce72a18
// pp=0x16ce72a10
cout << typeid(pp).name() << endl; // PPi, int**

那么现在我知道了 pp, 即指向数组头的指针, 怎么通过 pp 获取arr 的每一个元素呢:(其实这就是后面说的虚函数表查表的原理)

// 此时 pp 是二级指针, 指向数组 arr 的首地址
// 想解出来 arr 的各个元素, 就要先通过 pp 找到 arr 的首地址p,
// 需要进行以下操作:
// 1. 通过二级指针找到数组的原始地址, 这里在 64 位机器下使用 ull
// 类型作为指针大小执行转换
TYPE parr = *(TYPE *)pp;
// parr 是 ull 类型的值, 值就是 p 的地址, 也就是数组头的地址
// 2. 将数组头(TYPE 类型)转换成数组元素类型(int), 并通过指针运算移动指针, 解引用取元素
int val1 = *(int *)parr; // 这里将数组头表示为数组内元素的指针,
                         // 然后解引用获取到元素的值
int val2 = *((int *)parr + 1);
int val3 = *((int *)parr + 2);
cout << val1 << endl;
cout << val2 << endl;
cout << val3 << endl;
// 18
// 22
// 43

一个包含虚函数的类

后面的操作均基于这个类完成

class A {
public:
    int x;
    int y;
    virtual void f() { cout << "f() called !" << endl; };
    virtual void f1() { cout << "f1() called !" << endl; };
    virtual void f2() { cout << "f2() called !" << endl; };
    // private:
    void f3() {
        // x = 12;
        // 涉及到变量读取等操作, 静态绑定失效
        cout << "f3() called !" << endl;
    }
};

using FUNC = void (*)(); // 函数指针类型别名

成员指针

这是 C++的类成员的特性, 指针类型如下:

cout << typeid(&A::x).name() << endl;  // M1Ai, int A::*
cout << typeid(&A::f).name() << endl;  // M1AFvvE, void (A::*)()
cout << typeid(&A::f1).name() << endl; // M1AFvvE, void (A::*)()

由此, 在使用 C++的标准 IO 输出地址时候结果会有问题, 参考: C++地址值为1(情况说明)_c++函数地址为1_谢永奇1的博客-CSDN博客;

所以下面都用 c 的 printf 了, 方便.

// 成员指针
printf("%p\n", &A::x);
printf("%p\n", &A::y);
// 0x8
// 0xc

printf("%p\n", &A::f);
printf("%p\n", &A::f1);
printf("%p\n", &A::f2);
printf("%p\n", &A::f3); // private 函数不可以取成员函数指针, 但是并不是所有的private 函数都不能通过 hack 方式调用, 之后会提到, 这里先挖个坑
// 0x0, 即虚表指针位置, class 的头
// 0x8
// 0x10
// 0x104137890
// 说明第一个地址是虚指针

由这个变量的地址信息以及分布情况, 可以知道, 对象的地址前 8 字节是虚指针, 然后才是数据成员.

于是就自然想到, 可不可以用指针访问的方式来调用虚函数? 当然是可以的.

不过这个要视编译器实现而定, 这里我仅测试了 Unix 平台下 gcc/clang 的情况.

hack 技巧

下面就以类 A 为例, 展示调用虚函数的方法, 其实就是二级指针的解引用和类型转换, 下面详细分析下. 跟上面的不同之处在于 int 类型换成了函数指针类型.

A a;
cout << hex;
cout << "address of a : " << &a << endl;
cout << "address of vtbl : " << *(TYPE *)(&a) << endl;
// &a得到对象a的首地址,强制转换为(TYPE*)
// 意为将从&a开始的sizeof(TYPE)个字节看作一个整体
// 而&a就是这个sizeof(TYPE)字节整体的首地址 再解引用,
// 最终得到由这sizeof(TYPE)个字节数据组成的地址 也就是虚表的地址。
// 1. 通过虚指针取虚表地址
TYPE vptr = *(TYPE *)(&a); // 其实相当于把地址(指针变量)强制类型转换为TYPE
                           // 类型的值, 这个值其实就是虚指针的值(指针变量)
// 下面的转换指的是将 vptr 这个变量存储的地址信息变成数组指针,
// 然后解引用得到第一个元素(即 TYPE 类型的指针),
// 后续将其转换为函数指针进行调用
cout << "vptr=" << vptr << endl;
// 2. 通过虚表首地址访问首元素
TYPE pf = *(TYPE *)vptr;
// 这一步转换是必要的, 将 vptr 指向的数组的首地址解引用出来
cout << "pf=" << pf << endl;
// vptr=1028701d8
// pf=102868b4c
// 3. 转为函数指针
FUNC f = (FUNC)pf;
f();

这里要注意一点, 第一次类型转换是取出虚表的地址, 保存为 ull 类型(其实只要是 8 字节类型即可), 然后将这个 ull 值强转为函数指针类型, 即可调用了.

后两步可以合并成:

FUNC f = *(FUNC *)vptr;

对于偏移量, 可以有两种计算方法:

// vptr 加偏移量
TYPE pf1 = *(TYPE *)(vptr + 1 * sizeof(TYPE));
TYPE pf2 = *(TYPE *)(vptr + 2 * sizeof(TYPE));
FUNC f1 = (FUNC)pf1; // 转为函数指针
FUNC f2 = (FUNC)pf2;
f1();
f2();
// 数组首地址加偏移量
auto pf1 = *((TYPE *)vptr + 1);
auto pf2 = *((TYPE *)vptr + 2);
auto f1 = (FUNC)pf1; // 转为函数指针
auto f2 = (FUNC)pf2;
f1();
f2();

上面的分析都是针对栈内存来说, 针对堆内存, 即:

A* pa = new A;

其分析也是一样的, 直接把&a 换成 pa 即可

最后熟练了的话可以封装一下:

FUNC getvfunc(A *pa, int pos = 0) { return *((FUNC *)(*(TYPE *)pa) + pos); }

pos 代表虚函数在虚表中出现的位置, 即数组的下标.

当然, 上面都是用的 C-style 的类型转换, 看起来有点难受, 下面用 C++重写:

// c++ style cast:
A a;
TYPE vptr = *reinterpret_cast<TYPE *>(&a);
FUNC f = *reinterpret_cast<FUNC *>(vptr);
f();

C++虚函数的缺陷: 基类私有虚函数可被访问

说了这么多, 重头戏来了, 这里主要讲一下 C++虚函数的设计缺陷, 其实也不能算缺陷, 因为 C++功能本来就是非常丰富的, 这个功能应该算是一个 hack 技巧.(就像上面那样)

来看这个例子:

我们可以不在改动基类代码的前提下访问基类的私有虚函数吗?

乍一听好像是不可能, 因为 private 访问级别只能让类内的成员访问, 子类是完全没机会访问的, 但是, 来看代码:

class B {
private:
    virtual void f() { std::cout << "B::f()\n"; }
};


class D : public B {
public:
    void f() override { std::cout << "D::f()\n"; }
};

using FUNC = void (*)();

首先来看一下通过多态能不能调用:

B *pb [[maybe_unused]] = new D;
// 由于 private, 不能实现多态
// pb->f();

再试试黑科技:

B b;
TYPE pvtbl = *(TYPE *)&b;
FUNC f = *((FUNC *)pvtbl);
f(); // B::f()

所以私有虚函数其实是可以被调用的, 只要查找虚函数表即可..

C++ 灵活性的一个体现..