浅谈C++对象内存布局
生活随笔
收集整理的這篇文章主要介紹了
浅谈C++对象内存布局
小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
http://yalung929.blog.163.com/blog/static/20389822520123910561654/
最簡單的類
先從一個簡單的類開始吧。如下,此簡單類,非常簡單,兩個int成員,通過printf很容易了解到它的內存布局,本質就是一個C結構體,兩個成員依次排列。
對象:|成員1 | 成員2 |??
1: #include <cstdio> 2: class Class0 3: { 4: public: 5: int member1; 6: int member2; 7: }; 8: int main() 9: { 10: Class0 c; 11: printf("object addr=0x%lx\nmember1 addr=0x%lx\nmember2 addr=0x%lx\n", 12: &c, &c.member1, &c.member2); 13: return 0; 14: } # ./a.out object addr=0x7fffea480d70 member1 addr=0x7fffea480d70 //類成員1 member2 addr=0x7fffea480d74 //類成員2
成員函數
那么我們增加點復雜性,添加一個成員函數。?
代碼段:? |成員函數|?
我們看到,對象數據成員的布局并沒有變化,但是函數成員的地址跑到十萬八千里之外了。為什么?很簡單,因為函數是代碼,放在了代碼段。這也是我們通過Class1::function1來取值,而不是c.function1的原因。從這里可以看出,類的函數成員本質就是一個C全局函數,那么如果函數內訪問類的非靜態數據成員,如何動態的獲取成員地址?編譯器是這樣做的:?
1. 編譯器生成function1()的指令時,如果遇到了訪問對象的數據成員,比如member1,就從一個約定的位置(比如一個寄存器)獲取對象的首地址(其實就是this指針),然后加上偏移(這個是編譯時期可以確定的),也就找到了member1對應的內存位置,就可以訪問member1了。?
2. 編譯器生成c.function1()對應的指令時,把c的地址,放到了上述約定的位置。?
簡單來說,c.function1() 等價于function1(c), c是作為隱含參數傳遞給function1了。?
虛函數
好了搞清楚了成員函數的工作機制,我們再進一步分析,如下例子,有了繼承,并且基類成員函數是一個虛函數。派生類重載了它。?
1: Base* pb = new Derived(); 2: pb->function();
派生類指針賦給了基類指針,調用function,但執行還是派生類的function,這就是多態了。那么對于 pb->function(); 這個語句來說,編譯器是不能夠在編譯時期決定調用哪個function的。因為它并不知道pb這個指針是通過派生類轉化而來。大家會說,我們上面的語句不是告訴它了嗎?這個肯定不行,編譯器不能做這個上下文關聯,你要是通過函數參數傳遞過來,賦值的地方離這條語句很遠甚至都不在一個源文件里面怎么辦?所以這個決定調用哪個function的信息,必須保存在內存里面,運行期間就可以執行正確的函數。那么具體保存在哪里?如何工作的?gcc是這樣做的:?
1. 申請一段內存,存放虛函數的地址。就是一些書上所說的虛表。本質就是一個數組。?
2. 在對象的起始位置,存放虛表首地址,而不是像普通類對象那樣存放第一個非靜態數據成員。?
3.? pb->function(); 這條語句執行時,編譯器知道function是一個虛函數(我們聲明了virtual關鍵字),那么就會采用虛函數的調用方法,首先根據pb找到虛表的首地址,然后加上一個偏移量,因為是編譯器把function這個函數的地址放到虛表內的,所以它知道偏移量。我們通過下面這段代碼驗證這點:?
1: #include <cstdio> 2: class Base 3: { 4: public: 5: virtual void function1() { printf("Base::function1\n"); } 6: virtual void function2() { printf("Base::function2\n"); } 7: }; 8: int main() 9: { 10: printf("Base::function addr = 0x%lx\n", &Base::function1); 11: printf("Base::function addr = 0x%lx\n", &Base::function2); 12: Base* pb = new Base; 13: long* vtl = *(long**)pb; 14: printf("0x%lx\n", *(vtl)); 15: printf("0x%lx\n", *(vtl + 1)); 16: return 0; 17: } # ./a.out Base::function addr = 0x1 Base::function addr = 0x9 0x40082a 0x400812 # nm a.out | grep function 000000000040082a W _ZN4Base9function1Ev 0000000000400812 W _ZN4Base9function2Ev # c++filt _ZN4Base9function1Ev Base::function1() # c++filt _ZN4Base9function2Ev Base::function2() 對象:??? | 虛表地址|成員1 | 成員2 |???
虛表:??? |虛函數1的地址|虛函數2的地址|?
代碼段:? |虛函數1|虛函數2|?
1. 通過long* vtl = *(long**)pb; 獲取pb對象第一個成員的內容,我們拿到了虛表的首地址vtl。??
2. printf("0x%lx\n", *(vtl)); 訪問虛表的第一個元素,打印的是0x40082a,恰好對應我們通過nm查看到的Base::function1的函數地址000000000040082a 。?
3. printf("0x%lx\n", *(vtl + 1)); 訪問虛表的第二個元素,打印的是0x400812,恰好對應我們通過nm查看到的Base::function2的函數地址0000000000400812 。?
那么&Base::function1是0x1,&Base::function2是0x9,何解?其實怎么解讀,完全看編譯器心情。。。從我們的實驗結果來看,gcc是把它解讀成了虛表偏移量+1。編譯器也是可以解讀為函數的真實地址的。?
所謂多態,也就是這么回事兒,其邏輯并不復雜,只是C++"封裝"了細節,只給我們展示了它的強大形象,讓我們覺得多態好神奇啊,其實丫的,本質就是函數指針,就是地址而已,因為地址才是CPU理解的東西。懂得這點,就知道內核里到處都是多態,同樣是一個read操作,read不同的文件,執行不同的函數。。。內核就是在文件對象(C結構體)里,保存了函數指針,不同的文件系統注冊不同的函數指針。內核是各種編程技術、思想的集大成者,OO思想隨處可見。?
單繼承
扯遠了,我們還是繼續回到C++,多態背后的內存布局講完了,我們再進一步分析類繼承的。當派生類繼承了基類,也就擁有了基類的數據成員,那么這些數據成員如何擺放?其實還能怎么擺放?無非是順著來就好了。對就這樣,但是誰先誰后?是否順序無所謂?答案是,基類在前面派生類在后邊更合理。為什么是這樣?我們考慮下面的代碼:?1: class Base 2: { 3: public: 4: int b; 5: }; 6: class Derived : public Base 7: { 8: public: 9: int d; 10: }; 11: int main() 12: { 13: Derived d; 14: d.b = 2012; 15: Base* b = &d; 16: b->b = 2012; 17: } 我們把一個派生類對象地址,賦給基類指針,并且通過它訪問基類成員,如果派生類對象的內存布局是,基類在后,即成員d在前面,然后成員b。??? d.b = 2012; 這條語句沒有問題,編譯器知道b的位置,d起始位置加4即可。但是 b->b = 2012; 就沒法玩了,因為編譯器不知道b是一個Derived對象(原因上面有說),那么它就按b在Base中的偏移0,來算,而這個偏移,取到的其實是Derived::d的內容。如果反過來放,就沒問題了。通過下面的代碼,我們可以看到,確實是按基類優先的順序存放的。?
1: #include <cstdio> 2: class Base 3: { 4: public: 5: int b; 6: }; 7: class Derived : public Base 8: { 9: public: 10: int d; 11: }; 12: int main() 13: { 14: Derived d; 15: printf("Derived = 0x%lx\n", &d); 16: printf("Derived.b = 0x%lx\n", &d.b); 17: printf("Derived.d = 0x%lx\n", &d.d); 18: return 0; 19: } # ./a.out Derived = 0x7fffece35bf0 Derived.b = 0x7fffece35bf0 Derived.d = 0x7fffece35bf4
對象:??? | 基類的成員|派生類的成員|???
多繼承
終于來到了最神奇的地方,那就是多繼承,在討論多繼承的內存布局之前,我忍不住要吐槽幾句。C++的設計哲學是大而全,實際上很多特性可能一輩子都用不到,我覺得一個好的編程語言,應該提供簡潔的語言特性和強大豐富的功能庫,比如Python。C++太不精簡了。實際上,所有C++程序其實都是C++子集程序員;但所有C程序員都是C全集程序員。C的語言特性基本沒有多余的,C程序員基本都會用到。多繼承就是最多余的C++特性之一。可能有些同學說,有些地方用多繼承很方便,不用不太好搞;沒這回事兒,那肯定是類設計出了問題,正是因為語言支持這種特性,才導致一些糟糕的設計存在。要是C++不支持,編譯器編譯不過,你丫的會想不出來解決方案?好的語言特性可以直接引導程序員好的設計思維。比如Erlang不支持循環、不支持變量二次賦值…… 逼得程序員完全改變思維方式。。。結果就是寫出來的程序,自然支持多核、高并發,還無鎖。另外你看google的C++編程規范就知道,最重要的一部分就是對C++做減法,取子集。吐槽完畢,可能引起一片拍磚。。。(偶爾還是得拋一些觀點,否則只是純技術性的,太冷清了)?單繼承的內存布局,是基類成員在前,派生在后,但是多繼承呢?丫的有兩個基類,誰前誰后?誰前誰后不重要,關鍵的是根據上面單繼承分析,如果基類成員在派生類對象的位置不是從頭開始,派生類對像指針轉化為基類指針之后,就不能正確訪問基類成員了。而多繼承,必然至少有一個基類不是從頭開始的。那么怎么辦?還能怎么辦,涼拌!當你把一個派生類對象地址賦值給一個基類指針,如果這個基類在派生類中的位置,不是從頭開始的,編譯器偷偷的把它改變,加上基類在派生類中的位置偏移量!我們來驗證下:?
1: #include <cstdio> 2: class Base1 3: { 4: public: 5: int b1; 6: }; 7: class Base2 8: { 9: public: 10: int b2; 11: }; 12: class Derived : public Base1, public Base2 13: { 14: public: 15: int d; 16: }; 17: int main() 18: { 19: Derived d; 20: printf("Derived = 0x%lx\n", &d); 21: printf("Derived.b1 = 0x%lx\n", &d.b1); 22: printf("Derived.b2 = 0x%lx\n", &d.b2); 23: printf("Derived.d = 0x%lx\n", &d.d); 24: Base2* b2p = &d; 25: printf("Base2 pointer = 0x%lx\n", b2p); 26: return 0; 27: } ?
# ./a.out Derived = 0x7fffedfe10e0 Derived.b1 = 0x7fffedfe10e0 Derived.b2 = 0x7fffedfe10e4 Derived.d = 0x7fffedfe10e8 Base2 pointer = 0x7fffedfe10e4
可以看到,擺放的順序是Base1,Base2,Derived:?
對象:| 基類1的成員 | 基類2的成員 | 派生類的成員?
而當我們把Derived的地址0x7fffedfe10e0賦給Base2時,變成了0x7fffedfe10e4,即Base2成員的起始位置,這樣我們的b2p->b2; 可以正確的工作。是不是很神奇?=號都是不可信的!?
多繼承+虛函數
如果在多繼承的基礎上有加上了虛函數怎么辦?也就說多了一個虛表,假設兩個基類,gcc是這樣處理的:?對象:| 虛表1的地址 | 基類1的成員 | 虛表2的地址 | 基類2的成員 | 派生類的成員?
其中虛表1中存放是派生類重載的虛函數地址,無論來自于基類1還是基類2。虛表2只存放基類2的重載函數地址(實際上GCC幫你生成了一個中間函數,中間函數再去調用實際的函數)。?
1: #include <cstdio> 2: class Base1 3: { 4: public: 5: int b1; 6: virtual void function1() { printf("Base1::function1\n"); } 7: }; 8: class Base2 9: { 10: public: 11: int b2; 12: virtual void function2() { printf("Base2::function2\n"); } 13: }; 14: class Derived : public Base1, public Base2 15: { 16: public: 17: int d; 18: void function1() { printf("Derived::function1\n"); } 19: void function2() { printf("Derived::function2\n"); } 20: }; 21: int main() 22: { 23: Derived d; 24: printf("Derived = 0x%lx\n", &d); 25: printf("Derived.b1 = 0x%lx\n", &d.b1); 26: printf("Derived.b2 = 0x%lx\n", &d.b2); 27: printf("Derived.d = 0x%lx\n", &d.d); 28: Base2* b2p = &d; 29: printf("Base2 pointer = 0x%lx\n", b2p); 30: long* vtl = *(long**)b2p; 31: printf("0x%lx\n", *(vtl)); 32: printf("0x%lx\n", *(vtl + 1)); 33: vtl = *(long**)&d; 34: printf("0x%lx\n", *(vtl)); 35: printf("0x%lx\n", *(vtl + 1)); 36: return 0; 37: } # ./a.out Derived = 0x7fffa74ae400 Derived.b1 = 0x7fffa74ae408//b1沒有放在最開始,因為第一個是虛表地址 Derived.b2 = 0x7fffa74ae418//b2沒有放在b1后面,因為前邊還有一個虛表地址 Derived.d = 0x7fffa74ae41c Base2 pointer = 0x7fffa74ae410//base2在派生類中的起始位置, 0x4008aa//虛表2中存放的函數地址,gcc生成的中間函數 0x0//虛表2中存放的函數地址 0x4008c8//虛表1中存放的函數地址,function1 0x4008b0//虛表1中存放的函數地址,function2 # nm a.out |grep function 00000000004008e0 W _ZN5Base19function1Ev 00000000004008f8 W _ZN5Base29function2Ev 00000000004008c8 W _ZN7Derived9function1Ev 00000000004008b0 W _ZN7Derived9function2Ev 00000000004008aa W _ZThn16_N7Derived9function2Ev # c++filt _ZN7Derived9function1Ev _ZN7Derived9function2Ev _ZThn16_N7Derived9function2Ev Derived::function1() Derived::function2() non-virtual thunk to Derived::function2() # objdump -d a.out | sed -n '/_ZThn16_N7Derived9function2Ev/,/00000/p' 00000000004008aa <_ZThn16_N7Derived9function2Ev>: 4008aa: 48 83 c7 f0 add $0xfffffffffffffff0,%rdi 4008ae: eb 00 jmp 4008b0 <_ZN7Derived9function2Ev>//中間函數跳轉到了function2 00000000004008b0 <_ZN7Derived9function2Ev>:
了解C++內存布局的意義
意義至少有一點,讓我們寫出更好的C++程序。內存布局越復雜,性能越差,所以你會知道該如何選擇。總結
以上是生活随笔為你收集整理的浅谈C++对象内存布局的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C++成员函数的内存分配问题
- 下一篇: c++union