为什么 C++ 成员函数指针是 16 字节宽的

当提及指针时,我们通常认为它是可以用void * 指针表示的在x86_64架构上占用8字节的东西。例如, 维基百科有一篇中这样写道:

Pushes and pops on the stack are always in 8-byte strides, and pointers are 8 bytes wide.

从CPU的角度来看,指针就只是一个内存地址,并且x86_64中的所有内存地址用64位表示,所以8字节的假设是成立的。其实可以简单地通过打印不同类型的指针大小来得到这个结论。

#include <iostream>
 
int main() {
    std::cout <<
        "sizeof(int*)      == " << sizeof(int*) << "\n"
        "sizeof(double*)  == " << sizeof(double*) << "\n"
        "sizeof(void(*)()) == " << sizeof(void(*)()) << std::endl;
}

编译并运行这个程序,结果明确地说明了所有指针是8字节的:

$ uname -i
x86_64
$ g++ -Wall ./example.cc
$ ./a.out
sizeof(int*)      == 8
sizeof(double*)  == 8
sizeof(void(*)()) == 8

但是在 C++ 里就有这么一个例外 —— 指向成员函数的指针。

更有趣的是,成员函数指针的大小正好是其他指针大小的两倍。通过下面的简单的程序就可以验证这一点,它会打印 “16”:

#include <iostream>
 
struct Foo {
    void bar() const { }
};
 
int main() {
    std::cout << sizeof(&Foo::bar) << std::endl;
}

难道是 Wikipedia 错了么?当然不是。对于所有硬件来说,所有指针依然还是 8 个字节的宽度。那成员函数指针到底是什么呢?它其实是 C++ 语言的一个特性,是一个不能与硬件(物理)地址一一对应的虚拟出来的地址。由于它是由 C++ 编译器在运行时来实现(把成员函数指针转换成实际的虚拟内存地址,还伴随其他的一些相关工作),这一特性会带来轻微的运行时开销从而导致性能损失。C++ 规范并不关心具体的语言实现,所以它对该类指针并未做过多说明。幸运的是 Itanium C++ ABI specification (安腾 C++ 应用二进制接口规范,致力于标准化 C++ 运行时的实现)除了对 virtual table(虚表),RTTI(运行时类型识别)和 exceptions(异常)的实现做了说明外,还在 §2.3 节对成员函数指针做了如下的说明:

每一个指向成员函数的指针都是有如下两部分成:
ptr:
如果指针指向一个非虚成员函数,该字段就是一个简单的函数指针。如果该指针指向的是一个虚函数成员,那么该字段的值是该虚函数成员在其虚表中位移值加 1,在 C++ 中用 ptrdiff_t 类型表示。0 值表示 NULL 指针,与下面的调整字段值无关。
adj:
当成员函数被调用时,this 指针所必须做的位置调整(译者注:这与 C++ 的对象内存模型有关,确保每个成员函数正确的访问其函数体内引用的各种函数成员,下面会有进一步的解释),在 C++ 中用 ptrdiff_t 类型表示。

一个成员函数指针是 16 位的,因为除了需要 8 位字节来存储函数的地址外,还需要一个地址大小(8 字节)的字段来存储 this 指针位置如何调整的信息(常识: 每当一个非静态的成员函数被调用时,this 指针都会被编译器暗中传递给该函数,以便于在函数体内部通过该指针正确的访问调用对象的各类成员)。上面的 ABI 规范没有说清楚的是为什么以及什么时候需要对 this 指针的位置做调整。原因一开始可能没这么明显。不过不要紧,让我们先来看一看如下的类层次结构:

struct A {
    void foo() const { }
    char pad0[32];
};
 
struct B {
    void bar() const { }
    char pad2[64];
};
 
struct C : A, B
{ };

类 A 和 B 都各自有一个非静态成员函数以及一个数据成员。两个成员函数都能通过暗中传递进来的 this 指针正确的访问各自的数据成员。我们只需要对调用对象的基础地址施加一个类型为 ptrdiff_t 的地址偏移,就能正确的得到所需访问的数据成员的地址。但是当涉及到多重继承时,一切就变得复杂起来了。现在我们让类 C 继承类 A 和类 B,会发生什么呢?编译器会把 A 和 B 一起放在 C 对象的内存布局里,按上面代码里面的书写顺序 A 在前,B 紧跟在后。这样,A 定义的成员方法和 B 定义的成员方法理应 “看见” 不一样的 “this”  指针值才对。这也很容易验证,请看如下代码:

#include <iostream>
 
struct A {
    void foo() const {
        std::cout << "A's this: " << this << std::endl;
    }
    char pad0[32];
};
 
struct B {
    void bar() const {
        std::cout << "B's this: " << this << std::endl;
    }
    char pad2[64];
};
 
struct C : A, B
{ };

$ g++ -Wall -o test ./test.cc && ./test
A's this: 0x7fff57ddfb48
B's this: 0x7fff57ddfb68 

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/d7199c8006c8bbb501edc28284c8a8fd.html