指向成员的指针
参考 https://itanium-cxx-abi.github.io/cxx-abi/abi.html#member-pointers。
向下转换的能力!
和类的指针/引用的转换不同,指向成员的指针既可以向下转,又可以向上转!只需要目的类型拥有对应的成员即可。
struct A { long a; };
struct B: A { long b; };
int main() {
// long (A::*p) = &B::b; // 错误,A 类型没有成员 B::b
long (A::*q) = &B::a; // B::a 和 A::a 是相同的
long (B::*r) = &A::a; // 向下转
}
指向数据成员
需要存储 this 偏置,按照 Itanium C++ ABI,存储的类型为 ptrdiff_t。一般数据成员指针就用其相对于开头的偏移作为指针的值,而空指针则用 -1 表示。
奇怪的现象:对指向数据成员指针的空初始化会将其置为空指针,也就是 -1!虽然含义为空,但是其比特位并不是全零的。
C++ 标准不允许跨越虚基类转换数据成员指针:
The C++ standard does not permit base-to-derived and derived-to-base conversions of member pointers to cross a
virtual
base relationship, and so a static offset is always known.
struct X { int x; };
struct Y : virtual X { int y; };
int main() {
int (X::*p) = &Y::x; // ok
int (Y::*q) = &X::x; // error: pointer to member conversion via virtual base 'X'
// X::x 是虚继承得到的,并不属于 Y 本身(通过具体继承就属于 Y 本身)。
// 也就是说**跨越了虚基类的、不属于本身的成员的指针**不能转换,这也包括重写了的虚函数,
// 因为重写的虚函数相当于第二个版本。
}
下面是书中的描述:
书中说空指针为 0,而其他数据成员的指针有 1 的偏移。而现在广泛使用的 Itanium C++ ABI 则是将这个结果减了 1,这有利于减少大部分场景下的成员访问开销。
指向成员函数
需要存储虚表的索引和 this 偏置。
struct {
fnptr_t ptr;
ptrdiff_t adj;
};
对于虚函数,ptr 是虚表项以字节为单位的 offset 加上 1,因而必然是奇数(前提是虚表项的大小是偶数,实际上虚表项是一个指针,这在大多数平台都是满足的);对于普通成员函数,ptr 是函数的入口地址,并且在布局时额外要求这个入口地址是偶数,以便和虚函数指针区分。对于空指针,ptr 被置为 0,adj 的值不需要管。
有一些平台的表示方法有一些不同,比如 32-bit ARM。
adj 是 this 指针的偏移量。使用虚函数指针时编译器必须先在 this 上加上 adj 再做调用。
我有些测试中普通成员函数的偏置没置为 0,但是现在复现不出来了。
2023 年 6 月 7 日:
调用成员函数时,对象指针和函数指针是两大要素,它们都需要维护偏移。
虚函数需要处理 this 偏移,因为一个类的虚函数可能用的是自己的版本,也可能是继承来的(代码只有一份);重写了虚函数的子类也可能转换成基类的指针/引用。而每个版本的虚函数的 this 指针都是参照在定义它的类写的。这些指针/引用转换虽然保证了 vptr 始终在开头,也保证了虚函数下标不变,但虚函数只记住了一种 this 偏移!这显然需要额外的代码来改造虚函数以保证 this 的偏移正确。
指向成员函数的指针也需要记录偏移。刚取成员函数地址时偏移为 0,但指针经过转换后偏移就可能会变化。
对比:成员函数指针转换的 this 偏移由胖指针维护。对象指针类型转换的 this 偏移由虚表维护。(虚表中存有 whole_ptr 偏移量以便找到真实对象,虚表项也可能是 thunk 以便虚函数调用前能够修正 this 指针。)
书中提到的 Cfront 的一种实现:
当时先判断 index,如果 index 非负说明其指向虚函数,用它来访问虚表获得函数指针。如果 index 为负(其实是 -1),那么 faddr 就存储的是函数的真实地址。现在的 Itanium C++ ABI 相当于是把 index 和 faddr 合并了。
代码解释
另一份相似说明性的代码:https://github.com/minlux/ptmf
#include <iostream>
#include <iomanip>
#include <cstddef>
struct A {
int a;
virtual void fa() {}
void fa2() {}
};
struct B {
int b;
virtual void fb() {}
virtual void fb2() {}
virtual void fb3() {}
};
struct C {
int c;
virtual void fc() {}
};
struct D : A, B, virtual C {
int d;
void fa() override {}
void fb() override {}
void fc() override {}
};
template <typename T, typename R, typename... Args>
void print(R (T::*p)(Args...)) {
struct Ptr {
uintptr_t p;
ptrdiff_t adj;
};
auto &ptr = (Ptr const &)p;
std::cout << "pmf: " << ptr.p << ' ' << ptr.adj << '\n';
}
template <typename T, typename R>
void print(R T::*p) {
std::cout << "pmd: " << (ptrdiff_t const &)p << '\n';
}
#define PRINT(var) do { std::cout << std::left << std::setw(9) << #var" "; print(var); } while(0)
int main() {
// 关于虚函数和非虚函数的地址奇偶性在 Itanium C++ ABI 中只是尽量实施,并不保证
PRINT(&A::fa); // 虚函数,存虚表索引, 地址是虚表项按字节的 offset 加上 1(因而必然是奇数)
PRINT(&A::fa2); // 非虚函数,直接存函数地址,地址是偶数
PRINT(&C::fc); // 虚函数
PRINT(&D::fc); // 虚函数
PRINT(&A::a); //
PRINT(&D::a); // 等同于 &A::x
PRINT(&D::d); //
PRINT(&C::c); //
PRINT(&D::c); // 虚继承存储指针偏移后的 offset,等同于 &C::c
PRINT((void (D::*)())(&D::fb)); // 由于重写了 fb,this 指针是正确的,不需要偏置
PRINT((void (B::*)())(&D::fb)); // 由于重写了 fb,D 到 B 需要增加负数偏置
PRINT((void (D::*)())(&D::fb2));
PRINT((void (D::*)())(&D::fb3));
// 为了在二进制文件中生成 vtable,方便观察汇编
C *pointer = new D;
pointer->fc();
}
结果:
&A::fa pmf: 1 0
&A::fa2 pmf: 140699207020288 0
&C::fc pmf: 1 0
&D::fc pmf: 17 0
&A::a pmd: 8
&D::a pmd: 8
&D::d pmd: 28
&C::c pmd: 8
&D::fc pmf: 17 0
&A::a pmd: 8
&D::a pmd: 8
&D::d pmd: 28
&C::c pmd: 8
&D::c pmd: 8
(void (D::*)())(&D::fb) pmf: 9 0
(void (B::*)())(&D::fb) pmf: 9 -16
(void (D::*)())(&D::fb2) pmf: 9 16
(void (D::*)())(&D::fb3) pmf: 17 16