首页 > 编程语言 >C++虚函数剖析-从二级指针角度

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

时间:2023-09-26 14:36:50浏览次数:53  
标签:函数 int C++ 剖析 FUNC TYPE 指针



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

复习: 二级指针

首先定义一个数组, 其包含三个元素, 那么这个数组的名称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的博客

所以下面都用 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++ 灵活性的一个体现…


标签:函数,int,C++,剖析,FUNC,TYPE,指针
From: https://blog.51cto.com/u_15366127/7608377

相关文章

  • MacOS 使用 Asan 编译 C++报警告malloc: nano zone abandoned due to inability to re
    问题clang(llvm)编译c++程序,带内存问题检查工具选项-fsanitize=address-fsanitize=undefined之后出现:malloc:nanozoneabandonedduetoinabilitytoreservevmspace.解决vi~/.zshrc#加入:exportMallocNanoZone=0source~/.zshrc参考:ios-malloc:nanozonea......
  • 使用Optional优雅避免空指针异常
    本文已收录至GitHub,推荐阅读......
  • ModBus协议原理、Modbus Slave以及基于C++和Qt的代码实现
    ModBus协议目的:规定与PLC交互的指令,其数据帧包括两部分:报文头(MBAP)和帧结构(PDU)。报文头(MBAP)(分为6个部分):1.事务处理标识:即报文序列号,一般每次通信之后就要加1以区别不同的通信数据报文,长度2字节。2.协议标识符:有串口的RTU协议和TCP协议,如0000表示ModbusTCP......
  • 力扣16.最接近的三数之和(双指针)
    给你一个长度为 n 的整数数组 nums 和一个目标值 target。请你从 nums 中选出三个整数,使它们的和与 target 最接近。返回这三个数的和。假定每组输入只存在恰好一个解。 示例1:输入:nums=[-1,2,1,-4],target=1输出:2解释:与target最接近的和是2(-1+2+......
  • 关联式数据结构_哈希表剖析 #C++
    哈希概述哈希(hash)又称散列,其基本想法是,将存储的值与其存储位置建立某种映射,因此哈希的查找效率非常高,是一种支持常数平均时间查找的结构。与红黑树相比,哈希的效率表现是以统计为基础的,不需要依赖输入数据的随机性。建立值-址映射建立哈希结构的第一步是将“值”(数据)与“址”(存......
  • C++面试重点整理
    整理各大八股,夹杂自己理解,死记硬背效果差,搜索验证才记得牢C++基础语法在main函数执行之前,可能会进行以下操作:设置栈指针。初始化静态(static)变量和全局(global)变量,即初始化存储在.data段中的内容。为未初始化部分的全局变量赋初值,例如将数值类型(如short、int、long)初始化为0,......
  • WebRTC C++ 线程和线程管理剖析
    线程管理实例化代码(单例)ThreadManager*ThreadManager::Instance(){staticThreadManager*constthread_manager=newThreadManager();returnthread_manager;}初始化位置WebRTC中启动新线程的标准方法是通过创建Thread对象,然后调用Thread.Start()方法来启用......
  • C++踩坑--set与重载<
    set与重载<set是有序容器,在定义容器的时候必须要指定key的比较函数。只不过这个函数通常是默认的less,表示小于关系,不用特意写出来:template<classKey,//模板参数是key类型,即元素类型classCompare=std::less<Key>//比较函数>classs......
  • 【C++】动态内存管理 ⑤ ( 基础数据类型数组 内存分析 | 类对象 内存分析 | malloc 分
    文章目录一、基础数据类型数组内存分析1、malloc分配内存delete释放内存2、new分配内存free释放内存二、类对象内存分析1、malloc分配内存delete释放内存2、new分配内存free释放内存博客总结:C语言中使用malloc分配的内存,使用free进行释放;C++语言中......
  • c++ 删除自己
     HowtowriteaprograminC++suchthatitwilldeleteitselfafterexecution?-StackOverflow #include<strsafe.h>#include<Windows.h>#defineSELF_REMOVE_STRINGTEXT("cmd.exe/Cping1.1.1.1-n1-w3000>Nul&Del/f/q......