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



    tags: C++
    categories: 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
    
    • 1
    • 2
    • 3
    • 4
    • 5

    复习: 二级指针

    首先定义一个数组, 其包含三个元素, 那么这个数组的名称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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    下面来到二级指针:

    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**
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    那么现在我知道了 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    一个包含虚函数的类

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

    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 (*)(); // 函数指针类型别名
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    成员指针

    这是 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::*)()
    
    • 1
    • 2
    • 3

    由此, 在使用 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
    // 说明第一个地址是虚指针
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    由这个变量的地址信息以及分布情况, 可以知道, 对象的地址前 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();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

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

    后两步可以合并成:

    FUNC f = *(FUNC *)vptr;
    
    • 1

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

    // 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();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

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

    A* pa = new A;
    
    • 1

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

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

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

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

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

    // c++ style cast:
    A a;
    TYPE vptr = *reinterpret_cast<TYPE *>(&a);
    FUNC f = *reinterpret_cast<FUNC *>(vptr);
    f();
    
    • 1
    • 2
    • 3
    • 4
    • 5

    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 (*)();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

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

    再试试黑科技:

    B b;
    TYPE pvtbl = *(TYPE *)&b;
    FUNC f = *((FUNC *)pvtbl);
    f(); // B::f()
    
    • 1
    • 2
    • 3
    • 4

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

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

  • 相关阅读:
    Python 直接赋值、浅拷贝和深度拷贝解析
    遍历二叉树 先序+后序+中序递归 用递归序来了解递归基本样子
    day24每日一考
    【MySQL】sql调优实战教学
    数据链路层-可靠传输机制(选择重传协议SR)
    理解Window和WindowManager(一)
    一文搞懂Docker
    stm32f4xx-USART串口
    CFA一级学习-CFA一级中文精讲(第三版)-第一章(1)
    行为型-命令模式
  • 原文地址:https://blog.csdn.net/qq_41437512/article/details/132997842