書中第六章 隔離。 主要在撰述什麼需要定義在頭文件?什麼應當移到編譯單元中?
核心仍然是先區分接口定義與實現細節。實現細節的改變會導致客戶代碼的重新編譯,從邏輯上也表示與客戶代碼間可能存在着強耦合。
實現細節與隔離
主要考察以下實現細節,它們會在接口中引入實現細節,也是需要考慮進行隔離的內容:
- 繼承
- 分層
簡單的說就是類的成員中有另一個類的實例時,如Foo mFoo. 這個類就會依賴於Foo的定義。而轉爲持有地址時,即將關係從HasA改爲HoldA時,就不存在這個問題。也就是定義爲Foo* mFoo;或Foo& mFoo; 這也是Google C++ Coding Style曾經就減少頭文件依賴建議過的方式,後來則去掉了這項建議,改爲:”不要爲了使用前置聲明,將成員變量改爲指針類型”, 因爲它反而增加了邏輯上的複雜度,比如額外的判空處理。 - 內聯函數
- 私有成員
- 保護成員
- 編譯器生成的默認實現函數,如拷貝。
- 包含指令,即頭文件的包含。
- 默認參數
- 枚舉
在一些大型項目中,一些存有基本枚舉類型的頭文件,最後變成沒人敢改,而更願意新增頭文件。其實還不如放到具體的域或類中定義。
後面作者對各個細節推薦一些手法,相對比較簡單。後面則介紹了幾個常用手法:
- 協議類(接口類)
- Opaque Pointer和PIMPL
- Wrapper (封裝器), 即引入中間層。
過程接口
考慮到上層代碼對底層的操作需求,作者提出了過程接口(The Procedural Interface),可以結合常見的API來理解,它是一組函數的集合,出現在組件的頂部,並將功能的一個子集暴露給用戶。作者概括了編程接口的要求:
- 接口必須提供必要的功能來操縱底層系統。
- 接口一定不能暴露專屬的實現細節。
- 底層組織的變化必須與客戶端程序相隔離。
- 與該接口相關的開銷一定不能過大。
在實現方式上,以面向對象的Wrapper來實現這樣的需求最佳的,而過程接口將針對無法簡單使用獨立的封裝類來實現的系統。其實一個大型系統也是可以拆分出不同的領域,分別以Wrapper的形式來實現的。可以對比WebView的接口,以及Blink中的web層次。
書中主要是探討了針對所持有對象的操作。上面也提到的Opaque Pointer,還特別說明了Handle(句柄)模式來管理動態分配的對象。
一個過程接口既不是面向對象的也不是特別美觀,但它確有一個很大的優點:過程接口總是能夠用於將大系統的組織與客戶端程序相隔離–即使在設計的早期階段並沒有考慮這樣的接口。
隔離或不隔離
隔離會引入一些開銷,選擇是否進行隔離的常見原因包括:
- 暴露 (被使用的範圍,或者扇入)
- 訪問數據的性能
- 創建對象的性能
- 開發成本 (在沒有明確理由的情況強行隔離,會引入額外的開發工作)
- 組件的數量 (可能會新增組件,增加維護成本)
- 組件的複雜性 (引入新的複雜度,導致難以理解和維護)
作者提供兩套經驗值供決策時參考(中文編譯的圖表不太嚴謹,第5章有圖標錯,這裏明明是兩個表,卻合成了一個表。)。
訪問的相對開銷
- 內聯函數傳遞值 : 1
- 內聯函數傳遞指針 : 2
- 非內聯函數,非虛函數 : 10
- 虛函數機制 : 20
創建相對於單獨分配的成本
- 自動 (棧上) : 1.5
- 動態 (堆上) : 100+
作者最後討論隔離決策時,建議是否進行隔離取於被使用的範圍,性能要求的高低,以及成員函數的大小(是否輕量級)。性能要求高不要隔離,輕量級的實現也不需要隔離。其實就是隔離本身會引入開銷,如果爲了隔離引入的開銷過式,或者引入更不穩定的複雜度,就不要急於隔離。而對於大型、廣泛使用的對象則要儘早隔離。