Mudo C++網路庫第十章學習筆記
self發表於2018-10-24
C++編譯連結精要
- C++語言的三大約束: 與C相容, 零開銷(zero overhead)原則, 值語義;
- 相容C語言的編譯模型與執行模型, 也就是鎖能直接使用C語言的標頭檔案和庫;
- 標頭檔案包含具有傳遞性, 引入不必要的依賴;
- 標頭檔案是在編譯時使用, 動態庫檔案是在執行時使用, 二者的時間差可能帶來不匹配, 導致二進位制相容性方面的問題;
C++的編譯模型
- C++ 繼承了單遍編譯;
- 編譯器只能根據目前看到的程式碼做出決策, 讀到後面的程式碼也不會影響前面做出的決定;
- 這特別影響名字查詢(name lookup)和函式過載決議;
- 使用前向宣告來減少編譯期依賴;
C++連結(linking)
- C++與靜態連結;
- C++與動態連結;
- 傳統的是one-pass連結器 -- 越基礎的庫越是放到後面;
- C++在C的連結模型上主要增加了兩項內容:
- vague lingkage, 即同一個符號有多份不衝突的定義;
- 現在的編譯器聰明到可以自動判斷一個函式是否適合inline, 因此inline關鍵字在原始檔中往往不是必需的;
- 現在的C++編譯器採用重複程式碼消除的辦法來避免重複定義(multiple definition), 其餘的則丟棄(vague linkage);
- 編譯器如何處理inline函式中的static變數?
模板
- C++模板包括函式模板和類别範本:
- 函式定義, 包括具現化後的函式模板、類别範本的成員函式、類别範本的靜態成員函式;
- 變數定義, 包括函式模板的靜態資料變數、類别範本的靜態資料成員、類别範本的全域性物件;
- 模板編譯連結的不同之處在於, 以上具有external linkage的物件通常會在多個編譯單元被定義;
- 連結器必須進行重複程式碼消除, 才能正確生成可執行檔案;
- 模板的定義要放到標頭檔案中, 否則會有編譯錯誤(連結錯誤);
虛擬函式
- 在現在的C++實現中, 虛擬函式的動態呼叫(動態繫結, 執行期決議)是通過虛擬函式表(vtable)進行的, 每個多型class都應該有一份vtable;
- 定義或繼承了虛擬函式的物件中會有一個隱含成員: 指向vtable的指標, 即vptr;
- 在構造和析構物件的時候, 編譯器生成的程式碼會修改這個vptr成員, 這就要用到vtable的定義(使用其地址);
- 我們有時看到的連結錯誤不是抱怨找不到某個虛擬函式的定義, 而是找不到虛擬函式表的定義;
- 一個多型class的vtable應該恰好被某一個目標檔案定義, 這樣連結就不會有錯;
- 但是C++編譯器有時無法判斷是否應該在當前編譯單元生成vtable定義, 為了保險起見, 只能每個編譯單元都生成vtable, 交給連結器去消除重複資料;
- 有時我們不希望vtable導致目標檔案膨脹, 可以在標頭檔案的calss定義中宣告out-line虛擬函式;
工程專案中標頭檔案的使用規則
- C++還無法擺脫標頭檔案和預處理, 因此我們要深入理解可能存在的陷阱;
- 一旦為了使用某個struct或者某個庫函式而包含了一個標頭檔案, 那麼這個標頭檔案中定義的其他名字(struct, 函式, 巨集)也被引入當前編譯單元, 可能製造麻煩;
- 標頭檔案的害處:
- 傳遞性, 標頭檔案可以再包含其他標頭檔案;
- 合理地組織原始碼, 減少開發時rebuild的成本是每個稍具規模專案的必做功課;
- 順序性, 一個原始檔可以包含多個標頭檔案, 但可能會造成程式的語義跟標頭檔案包含的順序有關, 也跟是否包含某一個標頭檔案有關;
- 差異性, 內容差異造成不同原始檔看到的標頭檔案不一致, 時間差異造成標頭檔案與庫檔案內容不一致;
- 現代的程式語言, 模組化做得比較好:
- 對於解釋型語言, import的時候直接把對應模組的原始檔解析(parse)一遍(不再是簡單地把原始檔包含進來);
- 對於編譯型語言, 編譯出來的目標檔案(例如Java的.class檔案)裡直接包含了足夠的後設資料, import的時候只需要讀目標檔案的內容, 不需要讀原始檔;
- 這兩種做法都避免了宣告與定義不一致的問題, 因為在這些語言裡宣告與定義是一體的;
- 同時這種import手法也不會引入不想要的名字, 大大簡化了名字查詢的負擔(無論是人腦還是編譯器);
- 也不用擔心import的順序不同造成程式碼功能變化;
- 標頭檔案的使用規則:
- 幾乎每個C++程式設計都會涉及到標頭檔案的組織;
- 將標頭檔案的編譯依賴降至最小;
- 將定義式之間的依賴關係降至最小, 避免迴圈依賴;
- 讓class名字、標頭檔案名字、原始檔名字直接相關 -- 這樣方便原始碼的定位;
- 令標頭檔案自給自足;
- 總是在標頭檔案內寫內部#include guard(護套), 不要在原始檔寫外部護套;
include guard用的巨集的名字應該包含檔案的路徑全名(從版本管理器的角度), 必要的話還要加上專案名稱;
- 如果編寫程式庫, 那麼公開的標頭檔案應該表達模組的介面, 必要的時候可以把實現細節放到內部標頭檔案中;
工程專案中庫檔案的組織原則
- Linux的共享庫(shared library)比Windows的動態連結庫在C++程式設計方面要好用得多, 對應用程式來說基本可算是透明的, 跟使用靜態庫無區別;
- 一致的記憶體管理, Linux動態庫與應用程式共享同一個heap, 因此動態庫分配的記憶體可以交給應用程式去釋放, 反之亦可;
- 一致的初始化, 動態庫裡的靜態物件(全域性物件、namespace級的物件等等)的初始化和程式其他地方的靜態物件一樣, 不用特別區分物件的位置;
- 在動態庫的介面中可以放心地使用class、STL、boost(如果版本相同);
- 沒有dllimport/dllexport的累贅, 直接include標頭檔案就能使用;
- DLL Hell的問題也小得多, 因為Linux允許多個版本的動態庫並存, 而且每個符號可以有多個版本;
- 動態庫(.so), 靜態庫(.a), 原始碼庫(.cc);
- 動態庫比靜態庫節省磁碟空間和記憶體空間, 並且具備動態更新的能力(可以hot fix bug), 似乎動態庫應該是目前的首選;
動態庫是有害的
- 新的庫會破壞二進位制相容性;
- 靜態庫也好不到哪兒去, 靜態庫相比動態庫主要有幾點好處:
- 依賴管理在編譯器決定, 不用擔心日後它用的庫會變, 同理, 除錯core dump不會遇到庫更新導致debug符號失效的情況;
- 執行速度可能更快, 因為沒有PLT(過程查詢表), 函式呼叫的開銷更小;
- 釋出方便, 只要把單個可執行檔案拷貝到模板機器上;
- 靜態庫的一個小缺點是連結比動態庫慢, 有的公司甚至專門開發針對大型C++程式的連結器;
原始碼編譯是王道
- 每個應用程式自己選擇要用到的庫, 並自行編譯為單個可執行檔案;
- 最好能和原始碼版本工具配合, 讓應用程式只需要指定用那個庫, build工具能自動幫我們check out庫的原始碼;
- 在目前看到的開源build工具裡, 最接近這一點的是Chromium的gyp和騰訊的typhoon-blade, 其他如SCons, CMake, Premake, Waf等工具;