1.成员指针与获取字段偏移
1.1.基本语法与本质
c
struct A { virtual void vf(); int x; int y; };
int A::*p = &A::x; // 注意:不是 int*,是"指向 A 成员的指针"
A a{...};
a.*p = 42; // 通过对象解引用
A* pa = &a;
pa->*p = 42; // 通过对象指针解引用
核心认知:&A::x 不是地址,是偏移量。 它缺少"哪个对象"这一信息,只有绑定了对象才成为真正的地址。
实测字节(x86-64):
c
offsetof(A,x) = 8, offsetof(A,y) = 12 ← A 有 vptr,x 从 8 开始
&A::x 的内存表示 = 08 00 00 00 00 00 00 00 ← 就是数字 8!
&A::y 的内存表示 = 0c 00 00 00 00 00 00 00 ← 就是数字 12!
a.*p 的求值就是 (char)&a + 偏移,编译成一条加法指令。
1.2.获取字段偏移的两种手段
c
// ① 标准宏(编译期常量,只能用于标准布局类型成员)
offsetof(A, x) // 需要 <cstddef>
// ② 成员指针转整数(技巧性,非标准但通用)
auto off = reinterpret_cast<size_t>(&A::x); // 本例 = 8
1.3. 成员指针作为"可传递的字段存取器"
c
template <class T, class M>
void sort_by(std::vector<T>& v, M T::*key) {
std::sort(v.begin(), v.end(),
[&](const T& a, const T& b){ return a.*key < b.*key; });
}
sort_by(students, &Student::score); // 想按哪个字段排就传哪个
2.非成员函数指针
c
int free_add(int, int);
int (*fp)(int, int) = free_add; // 或 &free_add,等价
fp(3, 4); // 或 (*fp)(3,4),等价
- 本质就是代码地址,一个普通数据指针大小的整数。
- 函数名在表达式中自动 decay 为函数指针(类似数组 decay)。
- sizeof(fp) == 8(x86-64)。
函数指针数组是经典用法(状态机、跳转表):
c
void (*handlers[256])(Event&) = {...};
handlers[e.type](e);
限制:reinterpret_cast 在函数指针间转换是实现定义行为;POSIX 保证了函数指针与 void* 可互转(dlsym 依赖这一点),但 C++ 标准并不保证。
3.三类成员函数指针
3.1.非静态、非虚成员函数指针
c
void (A::*nf)() = &A::nf; // 类型里必须有类名
(a.*nf)(); // 调用
实测 16 字节,内部是 {函数地址, this 调整量} 二元组:
c
&A::nf = c6 14 ce f0 e1 55 00 00 | 00 00 00 00 00 00 00 00
^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
函数代码地址 this 调整量(单继承下为 0)
为什么需要 16 字节而不是 8?结合多重继承回忆:this 在调用时必须指向正确的子对象。如果函数定义在第二个基类里,调用前要把 D* 调整为 B* 子对象地址------这个调整量就存在第二槽里。
3.2.虚成员函数指针
这里需进一步明确--to do
cpp
void (A::*vf)() = &A::vf;
(a.*vf)(); // 走多态,调用的可能是派生类覆写版
实测字节揭示了惊天真相:
c
&A::vf = 01 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00
^^^^^^^^^^^^^^^^^^^^^^^
不是地址!是 vtable 偏移的编码(1 = vtable 字节偏移 0 + 标志位)
取虚函数的地址,得到的不是代码地址。 成员函数指针内部约定:低位为 1 表示"这是虚函数,请查虚表第 N 槽"。调用 (a.*vf)() 的展开逻辑相当于:
c
if (ptr 低位为 1) {
vtbl = *(void***)&a; // 取 vptr
addr = vtbl[(ptr >> 1) 换算的槽位]; // 间接寻址
addr(a 的 this 调整后的地址);
} else {
ptr(a); // 直接调用
}
编译器生成一个 trampoline(跳板函数) 来处理这个分派,&A::vf 实际存的是跳板逻辑所需的编码。
3.3.静态成员函数指针
c
auto sf = &A::sf; // 类型是 void(*)(),不带类名!
sf();
实测只有 8 字节,内容就是普通函数地址。静态成员函数没有 this,不依赖任何对象,语义上就是名字空间受限的普通函数。因此:
c
void (*fp)() = &A::sf; // ✅ 直接赋给普通函数指针
void (A::*mfp)() = &A::sf; // ❌ 编译错误:类型不匹配
3.汇总
| 指针类型 | 大小 | 内部表示 | 需要对象? |
|---|---|---|---|
int*(对象指针) |
8 | 地址 | --- |
void(*)()(自由/静态函数) |
8 | 代码地址 | 否 |
int A::*(数据成员) |
8 | 偏移量 | 是(.*/->*) |
void (A::*)()(成员函数) |
16 | {地址或 vtable 编码, this 调整量} | 是 |
| 虚函数的成员函数指针 | 16 | {vtable 偏移编码, this 调整量} | 是 |
- 所有对象指针(T*、void*、函数指针在主流 ABI 上)大小相同------这是各平台 ABI 的约定,使 void* 通用存储可行。但标准只保证 char*/void*/std::byte* 能表示任何对象地址,不保证 double* 与 void* 同尺寸(历史上 DOS 近/远指针时代就不等)。
- 成员指针是另一物种。
int A::*不是指针,是偏移+标志;void (A::*)()是二元组。16 字节的成员函数指针不能塞进 void*,成员指针与 void* 之间不能转换。 - MSVC 上更夸张:多重/虚拟继承的成员函数指针可达 20 字节,甚至涉及虚表查找。
- &a.x 与 &A::x 是两种完全不同的东西!前者是类型指针,后者常用语定位字段在类型实例中偏移。
4.std::function 与 std::bind
4.1.std::function:类型擦除的可调用对象包装
c
std::function<int(int,int)> f1 = free_add; // 函数
std::function<int(int,int)> f2 = [](int a,int b){...}; // lambda
std::function<int(int,int)> f3 = std::bind(&Adder::add, &ad, _1, _2); // bind 结果
std::function<int(int,int)> f4 = SomeFunctor{}; // 函数对象
- 接口统一:调用方只认 int(int,int) 签名,不关心底层是什么。
- 实现机制:内部持有指向堆上"控制块"的指针(vtable + 对象副本),小对象可能 SBO 优化就地存储。实测 sizeof(std::function<void()>) == 32(一个 16 字节就地存储 + 两个指针)。
- 成本:一次间接调用 + 可能的一次堆分配;比裸函数指针慢,但比 std::bind 结果更直观。
- 空状态:默认构造的 std::function 为空,调用抛 std::bad_function_call(实测确认)。
- 不能包装 move-only 捕获的 lambda(unique_ptr 捕获)------拷贝语义的要求所致。
4.2.std::bind:参数绑定(C++11 时代的 currying)
c
using namespace std::placeholders;
auto add5 = std::bind(free_add, 5, _1); // 预绑定第一个参数
add5(10); // = free_add(5, 10) = 15
auto swapped = std::bind(free_add, _2, _1); // 占位符重排参数
swapped(3, 10); // = free_add(10, 3) = 13