指向成员的指针

参考 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