歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux編程 >> Linux編程 >> 為什麼 C++ 成員函數指針是 16 字節寬的

為什麼 C++ 成員函數指針是 16 字節寬的

日期:2017/3/1 9:36:34   编辑:Linux編程

當提及指針時,我們通常認為它是可以用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++ 設計新思維》 下載見 http://www.linuxidc.com/Linux/2014-07/104850.htm

C++ Primer Plus 第6版 中文版 清晰有書簽PDF+源代碼 http://www.linuxidc.com/Linux/2014-05/101227.htm

讀C++ Primer 之構造函數陷阱 http://www.linuxidc.com/Linux/2011-08/40176.htm

讀C++ Primer 之智能指針 http://www.linuxidc.com/Linux/2011-08/40177.htm

讀C++ Primer 之句柄類 http://www.linuxidc.com/Linux/2011-08/40175.htm

將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成長之路(十):其他高級議題

Copyright © Linux教程網 All Rights Reserved