为什么 C++ 成员函数指针是 16 字节宽的 已翻译 100%

oschina 投递于 2014/12/02 07:23 (共 4 段, 翻译完成于 12-02)
阅读 4715
收藏 90
6
加载中

当提及指针时,我们通常认为它是可以用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
pseudo
pseudo
翻译于 2014/12/02 09:02
3

但是在 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 类型表示。
泥牛
泥牛
翻译于 2014/12/02 09:57
1

一个成员函数指针是 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

泥牛
泥牛
翻译于 2014/12/02 10:40
2

正如你所见到的,传递给 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

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

泥牛
泥牛
翻译于 2014/12/02 11:37
2
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接。
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
加载中

评论(19)

S
SamuelBen
void foo()
{
  cout<<"hello world"<<endl;
}
class A
{
public:
  void bar()
  {
    cout<<"A"<<endl;
  }
};

class B
{
public:
  void barB()
  {
    cout<<"B"<<endl;
  }
};

class C:public A,public B
{
};

int main()
{
  typedef void (*f)() ;
  typedef void (C::*mem_f)();
  f f_ins = &foo;

  A* a = new A;
  //mem_f f_ins_member = &a->bar;
  cout<<sizeof(f_ins)<<endl;
  cout<<sizeof(&(C::bar))<<endl;
  system("pause");
  return 0;
}

在x64 vc100编译器的结果 两个指针都是8个字节,我想是不是因为C::bar不是一个实例的函数呢?
泥牛
泥牛

引用来自“娱乐你我”的评论

"一个成员函数指针是 16 位的,因为除了需要 8 位字节来存储函数的地址外" 应该是“16字节” 和 “8字节” 不是“位”。
#include <iostream>

struct Foo {
void bar() const { }
};

int main() {
std::cout << sizeof(&Foo::bar) << std::endl;
} 这个x86下,vc编译器,试过显示4字节。不过在多重继承以后再去打印成员函数确实是8字节(就后面那个例子打印mem_func长度)。
感谢指正,感谢 @红薯 帮我改正。 本文作者是以x86_64 平台作为参考环境的。所以字长都是 8 个字节。
Sail_鸢
Sail_鸢

引用来自“googlespot”的评论

说白就是虚表闹的
你肯定没看文章
hi31409230
hi31409230

引用来自“夫复何求”的评论

sizeof(int*) == 4
sizeof(double*) == 4
sizeof(void(*)()) == 4
[Finished in 0.1s]
me too
土卫十六
土卫十六
Delphi也是这样的,函数指针同样隐藏了一个this(self)指针,要不然它无法指向正确的数据,我为这个问题曾经困惑了很久,后来就明白了。今天看到你的文章就更清楚了。不过不光x64这样,x32也是这样,希望楼主能够补充一个x32的例子,毕竟更普遍。
哆啦比猫
哆啦比猫
成员函数指针大小是未定义的好吧,谁说是指针的两倍了
成员函数指针其实不是指针,而是结构体
beetleleo
beetleleo
C++中的坑比较多,实际开发中一不小心就会陷入.
ruki
ruki

引用来自“Raymin”的评论

C++ 的复杂性由此可见一斑!
想当年为了掌握 C++ 花费了太多的时间。
现在倾向于标准C与脚本语言结合的架构,Emacs 就是一个典型的例子。
偶也是 花了大巴时间在c++上 现在觉得还是c更适合我
BaiYang
BaiYang
而且在非多重继承,不需要进行 offset 调整的类中,也可以在编译时将普通成员函数优化到一个指针的 size。

当然反过来,虽然虚表中带了各个派生类的 offset 数据,但是编译器也可以因为图省事或者方便统一优化等原因,将虚函数指针也扩展到两个机器字尺寸。
BaiYang
BaiYang

引用来自“googlespot”的评论

说白就是虚表闹的
恰恰想反,真调用虚函数时反而不需要额外的 this 指针调整信息,因为虚表中有 offset 数据。
返回顶部
顶部