C++的成员函数指针是一种强大但晦涩的工具. 与普通函数指针不同, 它不能被直接调用, 而必须通过 .*->* 运算符与一个对象实例绑定. 这个看似简单的调用语法 (object.*pointer)(), 背后却隐藏着一套由编译器和C++对象模型共同协作的、精密的this指针调整机制, 尤其是在处理多重继承时.

成员函数指针的使用

1. 问题的根源: 多重继承与this指针

要理解为何需要调整, 首先要看多重继承的内存布局. 假设有如下结构:

struct Base1 { int b1; };
struct Base2 { int b2; };
struct Derived : public Base1, public Base2 { int d; };

一个 Derived 对象的内存布局通常是 [ Base1 subobject | Base2 subobject | Derived members ]. 这意味着, 一个 Derived 对象指针 d_ptr 在数值上与其 Base1 子对象的指针相等, 但与 Base2 子对象的指针不相等.

Derived* d_ptr = new Derived();
Base1* b1_ptr = d_ptr; // 地址值相同
Base2* b2_ptr = d_ptr; // 地址值不同!编译器会自动加上一个偏移量

因此, 如果一个成员函数指针指向 Base2 的成员, 并通过一个 Derived 对象来调用它, 那么传递给该函数的 this 指针必须被精确地调整到 Base2 子对象的起始地址.

2. 成员函数指针的用法 (Usage)

成员函数指针是一种独特的C++类型, 用于存储一个非静态成员函数的“调用信息”. 其使用遵循三个步骤:

  1. 声明 (Declaration) 其类型包含函数的返回类型、所属类名和参数列表. ReturnType (ClassName::*PointerName)(ArgTypes);

    // 声明一个指针 p_func, 它可以指向 MyClass 中任何“返回void, 无参数”的成员函数
    void (MyClass::*p_func)();
    
  2. 赋值 (Assignment) 必须使用取地址运算符 &, 并明确指定类作用域. PointerName = &ClassName::FunctionName;

    auto p_func = &MyClass::my_method;
    // 展开后的完整类型声明如下:
    // void (MyClass::*p_func) = &MyClass::my_method;
    
  3. 调用 (Invocation) 必须通过 .* (用于对象或引用) 或 ->* (用于指针) 运算符, 将其与一个类的实例“绑定”后才能调用.

    MyClass obj;
    MyClass* p_obj = &obj;
       
    (obj.*p_func)();      // 通过对象调用
    (p_obj->*p_func)();   // 通过对象指针调用
    

现代C++ (C++11及以后) 更倾向于使用std::function和 Lambda 表达式而非裸露的成员函数指针作为通用的回调机制.

成员函数指针的原理

3. 实际的 {ptr, adj} 结构

在遵循 Itanium C++ ABI 的64位系统 (如 g++ on Linux) 上, 一个成员函数指针的内存表示为一个16字节的结构体, 我们称其为 {ptr, adj} 组合.

struct MemberFunctionPointer {
    // 函数信息: 对于非虚函数是地址, 对于虚函数是vtable偏移
    void* ptr;
    // this指针调整量 (字节偏移) 
    std::ptrdiff_t adj; 
};
  • ptr: 存储函数的核心信息.
  • adj: 存储一个偏移量, 用于在某些复杂的继承场景下调整this指针.

具体来讲,

  • ptr (void*, 8字节): ptr 字段的内容取决于所指向的函数类型:
    1. 指向非虚函数 (Non-Virtual Function)
      • ptr 存储该函数在代码段中的直接内存地址.
      • 根据ABI假设, 这个地址的最低位永远是0.
    2. 指向虚函数 (Virtual Function)
      • ptr 存储一个编码后的值: 1 + vtable_offset.
      • 最低位为1: 这是虚函数的“身份标志”.
      • 值右移一位 (ptr >> 1): 得到的是该函数在其v-table中的字节偏移量. 例如, 如果 print_v 是虚析构函数后的第一个虚函数, 其vtable偏移为8, 则 ptr 的值会是 1 + 8 = 9 (二进制 ...1001).
    3. 指向 nullptr
      • ptr 字段的值为 0 (NULL).
  • adj (ptrdiff_t, 8字节):
    • 产生于成员函数指针赋值时, 赋值操作 pfunc_derived = &base::func 本质是一次从“指向基类成员的指针”到“指向派生类成员的指针”的类型转换.
    • 在进行这个转换时, 编译器会预先计算出基类子对象 (base) 在派生类 (derived) 中的内存偏移量, 即adj. 如果该基类是第一个基类, 则偏移量adj为0.
    • 但在可观测结果相同的前提下, 实际情况取决于编译器实现, 因为C++标准中未给出具体实现, 以上说法均根据 Itanium C++ ABI 与实验观测结果.

4. .* 运算符的统一调用模型

当编译器遇到 (object.*pointer)() 这样的表达式时, 它会遵循一个统一的两步模型来确保this指针的正确性.

第一步: 编译期类型对齐 (Compile-Time Type Alignment)

编译器首先比较 object 的类型和 pointer 所属的类类型.

如果 object 的类型是 pointer 所属类的派生类, 编译器会在编译期执行一次向上转型 (Upcasting). 这会生成在运行时调整地址的指令, 将 object 的地址转换为其基类子对象的地址.

  • 示例: (derived_obj.*base_class_pointer)()
  • 动作: 编译器生成代码, 将 &derived_obj 的地址调整为 derived_obj 内部 Base 子对象的地址.

如果类型匹配, 则此步骤不产生地址变化.

第二步: adj 偏移应用 (Runtime adj Adjustment)

在第一步 (如果需要的话) 完成地址调整之后, 程序会读取成员函数指针内部的 adj 字段, 并将其加到第一步调整后的地址上.

final_this = result_from_step_1 + pointer.adj;

这一步解释了为何成员函数指针需要 adj 字段. 当一个“指向基类成员的指针”被赋值给一个“指向派生类成员的指针”变量时, adj 就会被编译器设置为一个非零值, 用以表示这两个类之间的内存偏移.

derived class:
+-------------------+
| |     base1     | | -> adj = 0
| +---------------+ |
| |     base2     | | -> adj = sizeof(base1)
| +---------------+ |
| | other members | |
+-------------------+

第一步类型对齐和第二步adj偏移对this指针调整的应用发生在.*运算符的调用时, 而adj的生成发生在成员函数指针的赋值时.


与普通函数指针的差异及问题

成员函数指针与普通函数指针在原理上完全不同, 不可混用.

对比项 普通函数指针 非静态成员函数指针
核心原理 存储一个独立函数的内存地址. 它是一个完整的调用目标. 存储一个依赖于对象的函数的调用信息. 它是一个不完整的“配方”, 需要对象实例才能调用.
this 指针 不需要 需要. 这是其存在的核心, 也是一切复杂性的根源.
类型系统 ReturnType (*)(Args) ReturnType (ClassName::*)(Args). 两者类型不兼容.
大小 (sizeof) 等于一个普通指针 (64位下为8字节) . 通常大于一个普通指针 (64位下为16字节) , 以存储额外信息.
带来的问题 无法直接存储成员函数. 不能存入普通函数指针数组, 不能直接转换为void*(这使得试图使用std::cout输出成员函数指针时重载决议不得不将其转换为bool型). 需要使用std::function等类型擦除工具才能与普通函数统一处理.