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

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

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 

 正如你所见到的,传递给 B 的成员函数的 “this” 值比传递给 A 的成员函数的 “this” 值大 32 个字节 —— 正好是一个类 A 实例的大小。但是当我们在如下函数中,通过一个类 C 对象的地址来调用类 C 的成员方法(可能是 C 自己定义的,也可能是 C 继承自 A 或者 B的)时,会发生什么呢?

void call_by_ptr(const C &obj, void (C::*mem_func)() const) {
    (obj.*mem_func)();
}

取决于所调用的具体的成员方法不同,会有不同的 “this” 值被传递进去。但是 “call_by_ptr” 函数本身并不清楚它从第二个形参得到是指向 “foo()” 还是 “bar()” 的函数指针。只有等到这两个函数的地址被引用时(即 “call_by_ptr” 被调用,实参列表被求值时),函数地址才会确定。这就是为什么在成员函数指针里需要并可以保存这样的信息,以指导程序在调用函数成员之前正确的调整 “this” 指针的位置(译者注:this 指针指向的对象需在运行时才能分配出具体地址,而对成员函数施加 “&” 操作符求地址的运算也是在运行时才可进行)。

最后,让我们把所有信息集中起来,放到如下的小程序中,来揭开该特性背后的秘密吧:

#include <iostream>
 
struct A {
    void foo() const {
        std::cout << "A's this:\t" << this << std::endl;
    }
    char pad0[32];
};
 
struct B {
    void bar() const {
        std::cout << "B's this:\t" << this << std::endl;
    }
    char pad2[64];
};
 
struct C : A, B
{ };
 
void call_by_ptr(const C &obj, void (C::*mem_func)() const)
{
    void *data[2];
    std::memcpy(data, &mem_func, sizeof(mem_func));
    std::cout << "------------------------------\n"
        "Object ptr:\t" << &obj <<
        "\nFunction ptr:\t" << data[0] <<
        "\nPointer adj:\t" << data[1] << std::endl;
    (obj.*mem_func)();
}
 
int main()
{
    C obj;
    call_by_ptr(obj, &C::foo);
    call_by_ptr(obj, &C::bar);
}

上面的程序输出如下:

------------------------------
Object ptr:    0x7fff535dfb28
Function ptr:  0x10c620cac
Pointer adj:  0
A's this:    0x7fff535dfb28
------------------------------
Object ptr:    0x7fff535dfb28
Function ptr:  0x10c620cfe
Pointer adj:  0x20
B's this:    0x7fff535dfb48

但愿本文把这个问题讲清楚了。

将C语言梳理一下,分布在以下10个章节中:

  1. Linux-C成长之路(一):Linux下C编程概要 http://www.linuxidc.com/Linux/2014-05/101242.htm
  2. Linux-C成长之路(二):基本数据类型 http://www.linuxidc.com/Linux/2014-05/101242p2.htm
  3. Linux-C成长之路(三):基本IO函数操作 http://www.linuxidc.com/Linux/2014-05/101242p3.htm
  4. Linux-C成长之路(四):运算符 http://www.linuxidc.com/Linux/2014-05/101242p4.htm
  5. Linux-C成长之路(五):控制流 http://www.linuxidc.com/Linux/2014-05/101242p5.htm
  6. Linux-C成长之路(六):函数要义 http://www.linuxidc.com/Linux/2014-05/101242p6.htm
  7. Linux-C成长之路(七):数组与指针 http://www.linuxidc.com/Linux/2014-05/101242p7.htm
  8. Linux-C成长之路(八):存储类,动态内存 http://www.linuxidc.com/Linux/2014-05/101242p8.htm
  9. Linux-C成长之路(九):复合数据类型 http://www.linuxidc.com/Linux/2014-05/101242p9.htm
  10. Linux-C成长之路(十):其他高级议题

相关推荐