讀《深度探索 C++ 對象模型》

前言

這本書是之前京東做活動買的很多本書中的一本(主要閱讀時間是周末、每天早上起來吃早餐的時候,以及下班回來時候,前后花了一個月左右吧)。

C++ 只是在大一下的時候大量使用過,之后就沒怎么用過 C++ 了。起初以為看這本書可能比較吃力,因為前言說目標讀者是 C++ 有經驗的人。但是看的時候感覺沒那么難懂,基本上寫到的東西都能理解。

此書應該是上世紀末成書的,書中的內容可能有些過時了(比如內存布局),但是仍然是值得一看的書。總體上感覺就是:為了程序員寫代碼,同時又為了保證程序語意等的正確和執行期的效率,編譯器為我們做了很多事情。

這里我對每一章內容都寫一下我的一些總結和想法,由于是看完全書以后才寫的,前面很多內容其實有些忘了(??),盡量憑回憶和粗略的回看總結一下。。。

關于對象

本書第一章講的是對象。

c++ 的對象(類)使用其實是非常高效的,這里的高效指的是:對于 C,C++ 大部分簡單類的屬性存取操作其實是和 C 一樣高效的。其他語言沒有 C++ 高效有一部分原因是其存取可能涉及到很多間接內存操作,而 C/C++ 大部分存取操作可能只會涉及到一次內存讀寫。

C++ 帶來比 C 低效的大部分原因是要支持 virtual 機制:

  1. 虛函數
  2. 虛繼承

什么是對象模型?我的理解是語言本身是如何處理類的內存分布的策略,由于現在大部分語言支持 OOP 編程,該策略一個關注點就是如何實現繼承,還有語言本身的其他涉及到內存分布的特性,比如虛函數、虛繼承、多重繼承、RTTI 的支持等。

書中列舉了幾個可能的對象模型:

  1. 簡單對象模型
  2. 表格驅動對象模型
  3. C++對象模型(被具體實現的模型)

關于對象模型優劣的角度主要有:

  1. 間接性是否足夠少(越少內存訪問越快,帶來的問題是靈活性不足)
  2. 對象的可拓展性(這是我自己加的,主要指的是,如果基類發生變化,子類是否需要重新編譯)

構造函數語意學

這一章講的是 C++ 的構造函數相關內容。

默認構造函數。

一個 C++ 類程序員可以不定義其構造函數,但是在有必要的情況下,編譯器會自動幫助我們合成一個構造函數,雖然有時候這個構造函數可能并沒有什么用。

為什么默認構造函數是必須的(如果程序員不顯式的定義一個構造函數)?

  1. 當當前類定義包含一個成員對象,該成員對象有一個默認構造函數,那么編譯器會為當前類合成一個默認構造函數,然后在該函數內部調用成員對象的默認構造函數。
  2. 當當前定義的類是另一個類的子類,父類有一個默認構造函數,那么編譯器也會生成一個默認構造函數,在該函數內部調用父類的默認構造函數。
  3. 當當前類帶有虛函數的時候,編譯器需要設定好虛函數表和虛函數表指針。
  4. 當當前類虛繼承于另外一個類。

如果不是上述這四種情況,是否合成默認構造函數是可有可無的。默認構造函數的合成是出于編譯器的角度。而且默認構造函數中編譯器關注的內容主要是成員對象的初始化,內建類型(也就是基本類型)的初始化是程序員的責任。

拷貝構造函數

有三種情況需要拷貝構造函數:

  1. 顯式的將一個對象賦值給另一個對象
  2. 將對象作為參數傳遞
  3. 將對象作為函數返回值返回

程序員可以不定義或者定義一個拷貝構造函數,當不定義的時候,默認的拷貝構造函數行為是按成員從當前對象拷貝到另一個對象上的。如果某個成員帶有一個顯式定義的拷貝構造函數的話,編譯器就需要為我們合成一個拷貝構造函數,在函數內部調用該成員的顯式拷貝構造函數。否則,編譯器就不需要為我們合成拷貝構造函數。

所以什么情況下編譯器必須為我們合成一個拷貝構造函數?(如果程序員不顯式的定義一個拷貝構造函數)

  1. 當前類中某個成員對象定義(或者編譯器為它合成)了一個拷貝構造函數
  2. 當前類繼承的父類定義了一個拷貝構造函數
  3. 當前類有虛函數
  4. 當前類虛繼承與另外一個類

1和2比較簡單,編譯器簡單的將成員對象和父類的拷貝構造函數插入到合成的拷貝構造函數中即可。

對于3,當將一個子類賦值給父類的時候,如果父類含有虛函數,那么不能簡單的將子類的 vptr 賦值給父類對象,否則當父類對象虛函數調用的時候會執行子類的虛函數(這里沒有多態)。所以編譯器合成的拷貝構造函數要合理的設置 vptr 為父類的 vtable。(即使在程序員顯式的定義了一個拷貝構造函數,vptr 的指定也是靠編譯器來完成,因為 vptr 對于程序員是透明的)

對于4,由于虛繼承的特殊性,編譯器要很仔細的幫助我們處理子類對象賦值給父類對象這種情況,以便合理的設置父類對象的某些值。

程序轉換語意學

這一部分主要是為了告訴我們,在用到構造函數的地方,編譯器為我們的代碼做的轉換,主要是確保默認構造函數、拷貝構造函數或者顯式定義的構造函數們被正確的調用。主要從函數返回值、函數參數、初始化等幾個角度討論。

接著討論如何優化編譯器的轉換代碼。比如 NRV 優化,主要是為了盡量避免多余的構造函數的調用。

成員對象的初始化列表

為了避免構造函數中多余的成員對象和臨時對象的構造函數的調用,一種比較好的方法是使用初始化列表。但是要注意,初始化列表中的初始化順序不是按照程序員寫的順序執行的,而是按照成員被聲明的順序初始化的。

成員初始化列表最終會被編譯器所轉換,注入到用戶定義的構造函數的代碼內容的最前面。

Data 語意學

這一章講解的是類中的數據。一個類的大小可以使用 sizeof 來取得。決定類的大小有三個因素:

  1. 語言本身的額外負擔,比如為了支持虛函數調用機制,類的定義中會被安插一個 vptr 指針。
  2. 某些特殊情況下編譯器的優化。比如空的虛繼承基類的大小可能會反應到子類上。
  3. 內存對齊的需要

類數據成員的綁定

類數據成員在類的成員函數中使用似乎不應該存在太大問題。但是在早期的編譯器中卻是有問題的。比如類的成員函數在類數據成員定義之前用到了該數據成員,如果剛好在類定義外部有這么一個數據,那么編譯器就要決議應該使用的是類內部的數據成員而不是外部的變量。

數據成員的布局

類的靜態數據成員是被存放在類外部的,不會影響到對象的內存布局。非靜態數據成員的內存布局依賴各個編譯器具體的實現,C++ 標準并不加以限制。類的另外一些對程序員透明的成員,比如 vptr,它的位置是哪兒呢?編譯器可以放在類定義的最前端或者最后面。編譯器也可以合理的組織不同訪問權限的(public、private、protect)成員在各自的區域。

數據成員的訪問

首先是靜態數據成員的訪問。由于靜態數據成員是存儲在程序的數據段中,無論是通過指針還是對象來訪問,這兩種方式訪問沒有任何效率或者其他實質的區別。

如果有兩個類含有相同名字的靜態數據成員,由于靜態數據是存在數據段中的,為了防止名字沖突,編譯器會進行名字處理,也就是 name-mangling。

其次是非靜態數據的訪問。在成員函數中訪問成員函數,編譯器其實會做代碼上的一些轉換,將當前this 對象作為函數的第一個參數,函數中對成員數據的訪問是通過改 this 指針來完成的。

繼承和數據成員

一般來說,子類的數據成員會被定義在父類的下面(當然,這方面沒有絕對的要求)
在這一小節,作者分兩個角度討論:

  1. 一個是繼承無多態
  2. 含多態的繼承
  3. 多重繼承
  4. 虛繼承

對于1,在編譯器設計實現的時候需要特別注意一個問題,就是對于為了內存對齊所額外使用的空間的使用。父類存在為了內存對齊而多占用了幾個字節大小,如果子類是緊隨在父類之后的,并且為了節省空間考慮而將自己的數據成員填充到父類的內存對齊的幾個字節中,那么,在將一個父類對象賦值給子類對象的時候,父類對象的內存對齊字節會覆蓋了子類對象的起始數據成員,這顯然是錯誤的。

對于2,含多態的類的關注點是,將 vptr 放在類對象的哪兒呢?可以是開始,也可以是最后。

對于3,多重繼承要關注的是如何正確的處理派生類實例和其第二個父類的轉換。答案是通過偏移量來處理。

虛擬繼承要解決的問題是多重繼承中可能存在的菱形繼承問題。虛擬繼承的實現有兩種方式:

  1. 指針策略
  2. 虛函數表偏移策略

1是指,通過附加一個每個虛擬繼承子類添加一個指向共享部分的一個指針。2是指,在虛函數表中放置虛擬繼承基類的偏移量

指向數據成員的指針

當需要了解類中成員對象的底層內存布局的時候,使用這類指針會特別有用,因為這類指針取到的就是該數據成員在類模型中的偏移量。

為了區分“沒有指向任何數據成員” 和指向第一個數據成員這兩種情況,所有的指針都會被加1,在具體使用的時候需要減1

函數語意學

非靜態成員函數

非靜態成員函數是如何實現的呢?

  1. 編譯器會改寫該成員函數的簽名,安插一個額外的參數到函數中,也就是 this 指針。
  2. 所有對非靜態成員數據的訪問都是通過 this 指針來完成的
  3. 該非靜態成員函數的名字會被 name mangling,使得它的名字在程序中是獨一無二的,又能反應函數的簽名、所屬類等的信息

虛擬成員函數

虛擬成員函數是如何實現的呢?

  1. 每一個類對象中會又一個 vptr 指針,用來指向存儲虛函數列表。由于繼承體系的復雜,vptr 指針很有可能被 mangling
  2. 對虛函數的調用是通過 vptr 指針,和其中虛函數列表中的索引值來的。
  3. 在虛函數第一個參數位置傳遞調用者指針,也就是 this 指針
  • 在單一繼承體系中,每一個類對象如果有實現一個虛函數,則會重寫其 vptr 相應索引值的來自直接基類的函數指針,使其指向自己的實現。
  • 在多重繼承體系中,虛擬函數的實現比較復雜。一種是調整 this 指針,缺點是效率底下,解決方法是使用所謂的 thunk 函數。比較好的解決方法是用派生類中實現的虛函數指針重寫基類們的 vptr 對于索引值的值。

靜態成員函數

靜態成員函數是如何實現的?

一個非靜態成員函數如果沒有用到非靜態成員變量的話,其實沒有必要讓它通過類實例來調用。但是如果類中存在一個 nonpublic 的靜態成員函數,那么類必須提供成員函數來訪問它。解決之道是引入靜態成員函數。

靜態成員函數最大特點是沒有 this 指針,所以它又如下特點:

  1. 不能直接訪問類中的非靜態成員
  2. 不能被聲明為 const、 volatile 或者 virtual
  3. 不需要通過類實例來調用(雖然語法上可以,但是最終轉換的代碼并不需要類的實例)

靜態成員函數由于沒有 this 指針,看起來很像是非成員函數。

成員函數指針

  1. 非虛擬成員函數的指針實際上是函數在內存中的指針
  2. 虛函數指針實際上是虛函數在虛函數表中的索引值
  3. 多繼承下的成員函數指針是一個結構體,index、 faddr 和 delta。如果是非虛函數,則 index 為 -1,否則 index 指的是虛函數表中的索引值。faddr 是非虛擬成員函數的內存地址,delta 是一個可能的 this 指針調整值。

內斂函數

處理內斂函數有兩個步驟:

  1. 根據復雜度分析函數是否可以被內斂,若不能,則被轉換成靜態函數。
  2. 內斂函數的擴展是在調用的點上的。

內斂函數的擴展會帶來兩個問題:

  1. 參數求值
  2. 臨時對象管理

臨時對象管理一個可能的情況是內斂函數的參數是另一個函數調用返回的值(基本類型或者類類型)。編譯器要正確的處理臨時對象的釋放問題。

內斂函數是 C 中的 #define 的一個安全替代,特別是宏有它的負作用。但是內斂函數被使用太多的話,會產生大量的代碼,使得程序代碼增大。同時內斂函數要管理可能產生的臨時對象,還有就是內斂嵌套內斂這種復雜的情況。所以內斂函數有其優點但是要小心使用。

構造、析構、拷貝語意學

未完待續。。。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 一個博客,這個博客記錄了他讀這本書的筆記,總結得不錯。《深度探索C++對象模型》筆記匯總 1. C++對象模型與內...
    Mr希靈閱讀 5,652評論 0 13
  • 前言 把《C++ Primer》[https://book.douban.com/subject/25708312...
    尤汐Yogy閱讀 9,541評論 1 51
  • 1. 結構體和共同體的區別。 定義: 結構體struct:把不同類型的數據組合成一個整體,自定義類型。共同體uni...
    breakfy閱讀 2,143評論 0 22
  • 妮寶,六月你值日月,看到同學都放學了,媽媽雖然著急,但也只能耐心等候。因為媽媽知道你做事一向非常認真。今天你考了語...
    恩企媽媽閱讀 117評論 0 0
  • 端坐在女兒課桌前 爬山虎的藤莖在外墻上 畫出歲月痕跡 松柏刺穿冬日的寒冷 心里的枯草開始斷裂倒伏 新芽在腐朽里茂密...
    巖馬閱讀 167評論 1 4