跳过正文
  1. Articles/

C++ 成员指针完全解析

·3421 字·7 分钟· ·
ykiko
作者
ykiko
目录

Introduction
#

在 C++ 中,形如&T::name的表达式返回的结果就是成员指针。写代码的时候偶尔会用到,但是这个概念可能很多人都并不熟悉。考虑如下代码

struct Point {
    int x;
    int y;
};

int main() {
    Point point;
    *(int*)((char*)&point + offsetof(Point, x)) = 20;
    *(int*)((char*)&point + offsetof(Point, y)) = 20;
}

在 C 语言中,我们经常通过这样计算 offset 的方式来访问结构体成员。如果把它封装成函数,还能用来根据传入的参数动态访问结构体的成员变量。然而上面的代码在 C++ 中是 undefined behavior,具体的原因可以参考 Stack Overflow 上的这个讨论。但是如果我们确实有这样需求,那该怎么合法的实现需求呢?C++ 为我们提供了一层抽象:pointers to members,用来合法进行这样的操作。

Usage
#

pointer to data member
#

一个指向类C非静态成员m的成员指针可以用&C::m进行初始化。当在C的成员函数里面使用&C::m会出现二义性。即既可以指代对m成员取地址&this->m,也可以指代成员指针。为此标准规定,&C::m表示成员指针,&(C::m)或者&m表示对m成员取地址。可以通过运算符.*->*来访问对应的成员。示例代码如下

struct C {
    int m;

    void foo() {
        int C::*x1 = &C::m;  // pointer to member m of C
        int* x2 = &(C::m);   // pointer to member this->m
    }
};

int main() {
    int C::*p = &C::m;
    // type of a member pointer is: T U::*
    // T is the type of the member, U is the class type
    // here, T is int, U is C

    C c = {7};
    std::cout << c.*p << '\n';  // same as c.m, print 7

    C* cp = &c;
    cp->m = 10;
    std::cout << cp->*p << '\n';  // same as cp->m, print 10
}
  • 指向基类的数据成员指针 可以隐式转换成 非虚继承的 派生类数据成员指针
struct Base {
    int m;
};

struct Derived1 : Base {};  // non-virtual inheritance

struct Derived2 : virtual Base {};  // virtual inheritance

int main() {
    int Base::*bp = &Base::m;
    int Derived1::*dp = bp;   // ok, implicit cast
    int Derived2::*dp2 = bp;  // error

    Derived1 d;
    d.m = 1;
    std::cout << d.*dp << ' ' << d.*bp << '\n';  // ok, prints 1 1
}
  • 根据传入的指针,动态访问结构体字段
struct Point {
    int x;
    int y;
};

auto& access(Point& point, auto pm) { return point.*pm; }

int main() {
    Point point;
    access(point, &Point::x) = 10;
    access(point, &Point::y) = 20;
    std::cout << point.x << ' ' << point.y << '\n';  // 10 20
}}

pointer to member function
#

一个指向非静态成员函数f的成员指针可以用&C::f进行初始化。由于不能对非静态成员函数取地址,&(C::f)&f什么都不表示。类似的可以通过运算符.*->*来访问对应的成员函数。如果成员函数是重载函数,想要获取对应的成员函数指针,请参考 如何获取重载函数的地址。示例代码如下

struct C {
    void foo(int x) { std::cout << x << std::endl; }
};

int main() {
    using F = void(int);         // function type
    using MP = F C::*;           // pointer to member function
    using T = void (C::*)(int);  // pointer to member function
    static_assert(std::is_same_v<MP, T>);

    auto mp = &C::foo;
    T mp2 = &C::foo;
    static_assert(std::is_same_v<decltype(mp), T>);

    C c;
    (c.*mp)(1);  // call foo, print 1

    C* cp = &c;
    (cp->*mp)(2);  // call foo, print 2
}
  • 指向基类的成员函数指针 可以隐式转换成非虚继承的派生类成员函数指针
struct Base {
    void f(int) {}
};

struct Derived1 : Base {};  // non-virtual inheritance

struct Derived2 : virtual Base {};  // virtual inheritance

int main() {
    void (Base::*bp)(int) = &Base::f;
    void (Derived1::*dp)(int) = bp;   // ok, implicit cast
    void (Derived2::*dp2)(int) = bp;  // error
    Derived1 d;
    (d.*dp)(1);  // ok
}
  • 根据传入参数动态调用成员函数
struct C { 
    void f(int x) { std::cout << x << std::endl;} 
    void g(int x) { std::cout << x + 1 << std::endl;}
};

auto access(C& c, auto pm, auto... args){
    return (c.*pm)(args...);
}

int main(){
    C c;
    access(c, &C::f, 1); // 1
    access(c, &C::g, 1); // 2
}

Implementation
#

首先要明确的是,C++ 标准并没有规定成员指针是什么实现的。在这一点上和虚函数一样,即标准没有规定虚函数是怎么实现的,只规定了虚函数的行为。所以成员指针相关的实现完全是 implementation defined。本来只需要了解怎么使用就足够了,不要关心底层实现。但是奈何网络上相关话题的错误文章太多了,已经严重的产生了误导,所以有必要进行澄清。

对于三大主流编译器,GCC 遵循 Itanium C++ ABI ,MSVC 则遵守 MSVC C++ ABI,Clang 通过不同的编译选项可以分别设置为这两种 ABI。关于 ABI 的详细讨论请移步 彻底理解 C++ ABIMSVC 与 GCC 产生的动态库如何才能相互替换,这里不过多介绍。

请注意:文章具有时效性,未来的实现可能会改变,所以仅作参考使用。还是以官方文档为准。

首先尝试打印一个成员指针的值

struct C { 
    int m;
    void foo(int x) { std::cout << x << std::endl;} 
};

int main(){
    int C::* p = &C::m;
    void (C::* p2)(int) = &C::foo;
    std::cout << p << std::endl;  // 1
    std::cout << p2 << std::endl; // 1
}

输出的结果都是1。鼠标移到<<就会发现,这是发生了到bool的隐式类型转换。<<并没有重载成员指针类型。想要打出它具体的值,必须要强制类型转换。

Itanium C++ ABI
#

pointer to data member
#

一般来说可以用下述结构体表示,数据成员指针。表示相对于对象首地址的偏移量。如果是nullptr则里面存的是-1。此时成员指针大小就是sizeof(ptrdiff_t)

struct data_member_pointer{ 
    ptrdiff_t offset; 
};

由于 C++ 标准不允许虚继承的成员函数指针转换。所以在发生类型转换的时候,编译器就可以自动算出转换需要的 offset。没有虚继承,也不需要在运行期去查虚表找 offset。

struct A {
    int a;
};

struct B {
    int b;
};

struct C : A, B {};

void log(auto mp) {
    std::cout << "offset is "
              << *reinterpret_cast<ptrdiff_t*>(&mp)
              // or use std::bit_cast after C++20
              // std::bit_cast<std::ptrdiff_t>(mp)
              << std::endl;
}

int main() {
    auto a = &A::a;
    log(a);  // offset is 0
    auto b = &B::b;
    log(b);  // offset is 0

    int C::*c = a;
    log(c);  // offset is 0
    // implicit cast
    int C::*c2 = b;
    log(c2);  // offset is 4
}

这种实现方式有一点缺陷,在文档里面详细说明了,这里就不说了。

pointer to member function
#

在主流的平台上,一般来说可以用下述结构体表示,成员函数指针:

struct member_function_pointer {
    std::ptrdiff_t ptr;  // function address or vtable offset
    // if low bit is 0, it's a function address, otherwise it's a vtable offset
    ptrdiff_t offset;  // offset to the base(unless multiple inheritance, it's always 0)
};

这个实现依赖于一些大多数平台的假定:

  • 考虑到地址对齐,非静态成员函数的地址最低位几乎总是 0
  • 空的函数指针是 0,所以空函数指针可以和虚表偏移量区分开来
  • 体系结构是字节寻址,并且指针大小是偶数,所以虚表偏移量是偶数
  • 只要知道虚表的地址,虚表偏移量和函数类型就可以进行函数调用,具体的实现细节由编译器根据 ABI 来决定

当然也有一些平台不满足上述假设,例如 ARM32 平台的某些情况,这时候它的实现方式就和我们刚才说的不同了。所以你现在应该能更加理解什么叫实现定义的行为了,即使编译器相同,但是目标平台不同,实现都有可能不同。

在我的环境 x64 windows 上,符合主流实现的要求。于是对着这个 ABI,进行了"解糖"。

struct member_func_pointer {
    std::size_t ptr;
    ptrdiff_t offset;
};

template <typename Derived, typename Ret, typename Base, typename... Args>
Ret invoke(Derived& object, Ret (Base::*ptr)(Args...), Args... args) {
    Ret (Derived::*dptr)(Args...) = ptr;
    member_func_pointer mfp = *(member_func_pointer*)(&dptr);
    using func = Ret (*)(void*, Args...);

    void* self = (char*)&object + mfp.offset;
    func fp = nullptr;
    bool is_virtual = mfp.ptr & 1;

    if(is_virtual) {
        auto vptr = (char*)(*(void***)self);
        auto voffset = mfp.ptr - 1;
        auto address = *(void**)(vptr + voffset);
        fp = (func)address;
    } else {
        fp = (func)mfp.ptr;
    }

    return fp(self, args...);
}

struct A {
    int a;

    A(int a) : a(a) {}

    virtual void foo(int b) { std::cout << "A::foo " << a << b << std::endl; }

    void bar(int b) { std::cout << "A::bar " << a << b << std::endl; }
};

int main() {
    A a = {4};
    invoke(a, &A::foo, 3);  // A::foo 43
    invoke(a, &A::bar, 3);  // A::bar 43
}

MSVC C++ ABI
#

MSVC 对于此的实现非常复杂,还对 C++ 标准进行了扩展。如果想要细致全面的了解,还是建议阅读上面那篇博客。

C++ 标准不允许虚基类成员指针向子类成员指针转换,但是 MSVC 允许。

struct Base {
    int m;
};

struct Derived1 : Base {};  // non-virtual inheritance

struct Derived2 : virtual Base {};  // virtual inheritance

int main() {
    int Base::*bp = &Base::m;
    int Derived1::*dp = bp;   // ok, implicit cast
    int Derived2::*dp2 = bp;  // ok in MSVC, error in GCC
}

为了不浪费空间,即使在同一程序中 msvc 的成员指针大小也可能是不同的大小(GCC 中由于统一实现,所以都是一样大的)。MSVC 对不同情况做了不同处理。另外请注意 MSVC 对于虚继承的是实现和 Itanium 也是不一样的。详见 C++中虚函数、虚继承内存模型 这篇文章中的相关介绍。

pointer to data member
#

对于非虚继承的情况下,实现的和 GCC 类似。除了大小有点区别。64位程序中 GCC 是8字节,MSVC 是4字节。都是用-1表示nullptr

struct data_member_pointer{ 
    int offset; 
};

对于虚继承的情况下(标准扩展),需要额外存储一个 voffset。用于运行期从虚表里面找到对应虚基类成员的 offset。

struct Base {
    int m;
};

struct Base2 {
    int n;
};

struct Base3 {
    int n;
};

struct Derived : virtual Base, Base2, Base3 {};

struct dmp {
    int offset;
    int voffset;
};

template <typename T>
void log(T mp) {
    dmp d = *reinterpret_cast<dmp*>(&mp);
    std::cout << "offset is " << d.offset << ", voffset is " << d.voffset << std::endl;
}

int main() {
    int Derived::*dp = &Base::m;
    log(dp);  // offset is 0, voffset is 4
    dp = &Base3::n;
    log(dp);  // offset is 4, voffset is 0
}

pointer to member function
#

对于成员函数指针就更复杂了,有四种情况:

  • 非虚继承,非多继承
struct member_function_ptr{ 
    void* address; 
};
  • 非虚继承,多继承
struct member_function_ptr{ 
    void* address;
    int offset;
};
  • 虚继承,多继承
struct member_function_ptr{ 
    void* address; 
    int offset;
    int vindex;
};
  • 未知继承
struct member_function_ptr{
    void*   address; 
    int     offset;
    int     vadjust; // use to find vptr 
    int     vindex; 
};

还要注意:32程序中成员函数的调用约定和普通函数不一样。所以如果希望转换成函数指针并调用,需要在函数指针里面把函数调用约定写上才行,不然会导致调用失败。

Conclusion
#

讨论 C++ 问题千万不要想当然,你在特定平台上的测试结果,不代表所有可能的实现。而且 MSVC 已经告诉你了,即使是同一个程序内,你的测试也可能没有覆盖到所有的 case。之前发现 MSVC 的成员函数指针大小变来变去的时候给我吓了一跳,以为是我的代码出了问题。如果希望自己写一个类似std::function的容器,并希望执行 SBO 优化,最好把 SBO 大小设置在16字节以上,这样能覆盖掉绝大部分的成员函数指针。

如果需要成员函数作为回调函数的,推荐使用 lambda 表达式包裹一层。 像下面这样

struct A {
    int a;

    void bar(int b) { std::cout << "A::bar " << a << b << std::endl; }
};

int main() {
    auto f = +[](A& a, int b) { a.bar(b); };
    // + is unary plus operator, use to cast a non-capturing lambda to a function pointer
    // f is function pointer
}

在 C++23 之后,如果使用 explicit this 定义成员函数,则&C::f可以直接获取对应成员函数的函数指针,不需要像上面那样多一层包裹了

struct A {
    void bar(this A& self, int b);
};

auto p = &A::bar;
// p is function pointer, rather than member function pointer