C++对象模型:g++的实现(六)
这篇博客开始介绍《深度探索C++对象模型》第四章的剩余部分,包括成员函数指针和内联函数。
1. 成员函数指针
对于静态成员函数,其和常规的函数是一样的,故这里不做介绍。下面主要介绍非静态的成员函数指针,包括普通的非virtual成员函数指针和virtual成员函数指针。
注意,这篇是按照《深度探索C++对象模型》的内容写的,最后讲到支持多继承的成员函数指针时才会给出真正的成员函数指针的实现!
1.1 非virtual成员函数指针
对于一个非virtual的成员函数取址,得到的就是该成员函数在内存中的地址,但是它不能单独调用,需要使用其绑定的对象/指针/引用调用。
// test26.cpp class Test { public: Test(int i) : m_i(i) {} int getInt() const { return m_i; } void setInt(int i) { m_i = i; } private: int m_i; }; int main() { Test t(1); int i = t.getInt(); void (Test::*pMemberFunc)(int) = nullptr; // 成员函数指针 pMemberFunc = &Test::setInt; (t.*pMemberFunc)(2); i = t.getInt(); }
1.2 支持“指向虚成员函数”的指针
对于非虚成员函数我们可以直接拿到其地址,因为其没有多态性。但对于虚函数,其地址要在运行时确定,因此对于虚成员函数我们取的应该是其相对虚表指针的偏移index。
所以如果有如下类:
class Point { public: Point(int x, int y); virtual ~Point(); int x() const {return m_x;} int y() const {return m_y;} virtual int z() const { return 0; } private: int m_x; int m_y; };
对于析构函数取值&Point::~Point
取得的是0。
对于x()和y()取址&Point::x, &Point::y
得到的是其地址,因为他们不是虚函数。
对于z()取址&Point::z
得到的是1。通过pMemberFunc调用z(),其会是类似下面的形式:
(*ptr->vptr[(int)pMemberFunc])(ptr)
1.3 支持多继承的成员函数的指针
在多继承的情况下还要考虑虚函数表的位置问题,因为在多重继承下可能有多个虚函数表;还有this指针可能需要进行偏移,如果派生类没有覆盖第二个或后面的基类的虚函数的话。
为了要支持以上种种特性:如果是非虚函数,指针中要包括其地址;如果是虚函数,要包括其相对虚表指针的偏移;如果是多重继承,还要找到虚函数在哪个虚表中和对this指针进行偏移。
在《深度探索C++对象模型》中提出的是这样的结构:
struct _mptr{ int delta; int index; union { PtrToFunc faddr; int v_offset; }; };
其中delta是this指针要进行的偏移,index是虚函数在虚表指针指向空间中的下标,faddr是非虚函数的地址,v_offset是虚表指针的的位置。
所以下面的操作:
(ptr->*pmf)();
会变成:
// 我觉得这个可能是有问题 pmf.index < 0 ? // 非虚函数调用 (*pmf.faddr)(paddr) : // 虚函数调用 (*ptr->vptr[pmf.index])(ptr)
《深度探索C++对象模型》中是这么写的,但按照作者的说法,实际的代码应该是:
pmf.index < 0 ? (pmf.faddr)(pmf + delta) : (((vptr*)(ptr+pmf.v_offset))[pmf.index])(ptr+delta) // (ptr+pmf.v_offset) 是虚表地址 // ((vptr*)(ptr+pmf.v_offset))[pmf.index] 是虚表的第pmf.index项 // ptr+delta是对this指针进行偏移
让我们来看看g++中是怎么实现的:
// test27.cpp class Point { public: Point(int x, int y); virtual ~Point(); int x() const {return m_x;} int y() const {return m_y;} virtual int z() const { return 0; } private: int m_x; int m_y; }; Point::Point(int x, int y) : m_x(x), m_y(y) {} Point::~Point() { m_x = m_y = 0; } int main() { Point p(1, 2); using MemberFunction_t = int (Point::*)() const ; MemberFunction_t pVirtualMemberFunc = nullptr; MemberFunction_t pMemberFunc = nullptr; pMemberFunc = &Point::x; pVirtualMemberFunc = &Point::z; int x = (p.*pMemberFunc)(); int z = (p.*pVirtualMemberFunc)(); ++z; }
我们使用gdb看一下这个成员函数指针的size:
(gdb) p sizeof(MemberFunction_t) $1 = 16
在赋值之后,查看pMemberFunc和pVirtualMemberFunc的二进制是什么:
(gdb) x/2ag &pMemberFunc 0x7ffffffee0d0: 0x8000a86 <Point::x() const> 0x0 (gdb) x/2ag &pVirtualMemberFunc 0x7ffffffee0c0: 0x11 0x0
可以看到g++实现的成员函数指针有两个QWORD(QWORD是size为8字节的【有符号或无符号】整型值)。如果函数指针指向的是非虚函数,第一个QWORD里面是该函数的地址;如果是的话,看上去是该虚函数相对于虚表的偏移+1,因为Point::z在vptr[2]
的地方(vptr[0]
是Point::~Point,但不调用::operator delete
;vptr[1]
也是Point::~Point,会随后调用::operator delete
),那偏移就是0x10,但内容是0x11,可能就是加了1。
让我们看一下汇编代码是怎么操作的:
上面的汇编是即将执行int x = (p.*pMemberFunc)();
这一语句。
总结如下:
- 如果不是虚函数,低8个字节是函数的地址,高8个字节是this指针的偏移;
- 如果是虚函数,低8个字节是虚表指针相对于this指针的偏移&1(位与操作),而高8个字节同样是this指针的偏移;
这两种情况就按低8个字节的QWORD的最低位是不是1决定:如果是1则是虚成员函数指针,不是1则是非虚成员函数指针。
虚函数地址相对于vptr偏移的字节数肯定是指针大小的整数倍,一般为4或8字节,最后一位肯定是0,所以与一个1可以理解,用的时候只需要减去这一位即可。
但函数地址最后一位肯定是0吗?我就这个问题查阅了资料,在博客《C++语言学习(十四)——C++类成员函数调用分析》中提到:
一般来说因为对齐的关系,函数地址都至少是4字节对齐的。即函数地址的最低位两个bit总是0。
虽然和我的观察略微有不同(在我编译的程序里,Point::x的地址是0x8000a86,只有最后一位是0,倒数第二位是1),但也说明了函数地址确实是有对齐这一现象的。
这里再继续引用一下这篇博客里的论述,用以辅助读者理解(感我写得不如这篇博客远矣):
GCC对于成员函数指针统一使用下面的结构进行表示:
struct { void* __pfn; //函数地址,或者是虚拟函数的index long __delta; // offset, 用来进行this指针调整 }; 不管是普通成员函数,还是虚成员函数,信息都记录在__pfn。一般来说因为对齐的关系,函数地址都至少是4字节对齐的。即函数地址的最低位两个bit总是0。 GCC充分利用了这两个bit。如果是普通的函数,__pfn记录函数的真实地址,最低位两个bit就是全0,如果是虚成员函数,最后两个bit不是0,剩下的30bit就是虚成员函数在函数表中的索引值。
// 注意,在我的版本里(g++ (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0),检查的是随后一位,函数地址也只是2对齐,而不是4对齐
GCC先取出函数地址最低位两个bit看看是不是0,若是0就使用地址直接进行函数调用。若不是0,就取出前面30位包含的虚函数索引,通过计算得到真正的函数地址,再进行函数调用。
这篇博客里还介绍了MSVC对于成员函数指针的实现,使用了thunk技术,大家可以去看一下。(其实这个在《深度探索C++对象模型》,里也有提到,大家感兴趣也可以看看原书)。
2. 内联函数
关于这一部分只是做一个总结,我也不知道如何比较好得验证其中的内容。
关键词inline只是一个请求,一般而言,处理一个inline函数会有两个阶段:
- 分析函数定义,以解决函数的"intrinsic inline ability"(本质的inline能力)。"intrinsic"(本质的、固有的)一词在这里意指“与编译器相关”【书中原话】
说白了就是编译器要看看能不能内联,要是太复杂就直接编译成函数,(在理想情况下)链接器会把生成的重复的内联函数清理掉。strip命令也可以达成这个目的。
- 真正的inline函数扩展操作是在调用的那一点上,这会带来参数的求值操作和临时对象的管理。
所谓求值操作是和宏函数做对比的,宏函数只是简单的复制粘贴,但inline函数在调用前会对传参进行求值(无论其内联展开与否)。
比如:
inline int min(int i, int j) { return i < j ? i : j; }
对于minval = min(foo(), bar() + 1)
会扩展成:
int t1, t2; minval = (t1 = foo()), (t2 = bar() + 1), t1 < t2 ? t1 : t2; // 逗号操作符, // 从左到右计算,表达式结果为最后一个值。 // 比如 t = foo(), bar(); // 会先调用foo(), 再调用bar(),t的值为bar()的返回值
这种特性使得内联函数比宏函数安全得多。
而临时对象管理则是在函数内联时会产生很多临时变量,比如形参列表、内联函数中的局部变量等等。
其他比如成员函数指针的执行效率我就不多做测试了,这一章也就结束了。
关于后面的内容,我会在有时间的时候做简要的总结,不会像这两章这么详细得分析汇编了,因为我觉得对象布局和虚函数的实现就是书最主要的内容了。
好的,就这样了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!