Google C++ 編程風格指南:類

構造函數的職責

不要在構造函數中進行復雜的初始化 (尤其是那些有可能失敗或者需要調用虛函數的初始化).

 

定義:
在構造函數體中進行初始化操作.
優點:
排版方便, 無需擔心類是否已經初始化.
缺點:
在構造函數中執行操作引起的問題有:

 

  • 構造函數中很難上報錯誤, 不能使用異常.
  • 操作失敗會造成對象初始化失敗,進入不確定狀態.
  • 如果在構造函數內調用了自身的虛函數, 這類調用是不會重定向到子類的虛函數實現. 即使當前沒有子類化實現, 將來仍是隱患.
  • 如果有人創建該類型的全局變量 (雖然違背了上節提到的規則), 構造函數將先 main() 一步被調用, 有可能破壞構造函數中暗含的假設條件. 例如, gflags 尚未初始化.

 

結論:
構造函數不得調用虛函數, 或嘗試報告一個非致命錯誤. 如果對象需要進行有意義的 (non-trivial) 初始化, 考慮使用明確的 Init() 方法或使用工廠模式.


3.2. 初始化

如果類中定義了成員變量, 則必須在類中爲每個類提供初始化函數或定義一個構造函數. 若未聲明構造函數, 則編譯器會生成一個默認的構造函數, 這有可能導致某些成員未被初始化或被初始化爲不恰當的值.

 

定義:
new 一個不帶參數的類對象時, 會調用這個類的默認構造函數. 用 new[] 創建數組時, 默認構造函數則總是被調用. 在類成員裏面進行初始化是指聲明一個成員變量的時候使用一個結構例如 int _count = 17 或者 string _name{"abc"} 來替代 int _count 或者 string _name 這樣的形式.
優點:
用戶定義的默認構造函數將在沒有提供初始化操作時將對象初始化. 這樣就保證了對象在被構造之時就處於一個有效且可用的狀態, 同時保證了對象在被創建時就處於一個顯然”不可能”的狀態, 以此幫助調試.
缺點:
對代碼編寫者來說, 這是多餘的工作.
如果一個成員變量在聲明時初始化又在構造函數中初始化, 有可能造成混亂, 因爲構造函數中的值會覆蓋掉聲明中的值.
結論:
簡單的初始化用類成員初始化完成, 尤其是當一個成員變量要在多個構造函數裏用相同的方式初始化的時候.
如果你的類中有成員變量沒有在類裏面進行初始化, 而且沒有提供其它構造函數, 你必須定義一個 (不帶參數的) 默認構造函數. 把對象的內部狀態初始化成一致 / 有效的值無疑是更合理的方式.

 

這麼做的原因是: 如果你沒有提供其它構造函數, 又沒有定義默認構造函數, 編譯器將爲你自動生成一個. 編譯器生成的構造函數並不會對對象進行合理的初始化.

 

如果你定義的類繼承現有類, 而你又沒有增加新的成員變量, 則不需要爲新類定義默認構造函數.



3.3. 顯式構造函數

對單個參數的構造函數使用 C++ 關鍵字 explicit.

 

定義:
通常, 如果構造函數只有一個參數, 可看成是一種隱式轉換. 打個比方, 如果你定義了 Foo::Foo(string name), 接着把一個字符串傳給一個以 Foo 對象爲參數的函數, 構造函數 Foo::Foo(string name) 將被調用, 並將該字符串轉換爲一個 Foo 的臨時對象傳給調用函數. 看上去很方便, 但如果你並不希望如此通過轉換生成一個新對象的話, 麻煩也隨之而來. 爲避免構造函數被調用造成隱式轉換, 可以將其聲明爲 explicit.
除單參數構造函數外, 這一規則也適用於除第一個參數以外的其他參數都具有默認參數的構造函數, 例如 Foo::Foo(string name, int id = 42).
優點:
避免不合時宜的變換.
缺點:
結論:
所有單參數構造函數都必須是顯式的. 在類定義中, 將關鍵字 explicit 加到單參數構造函數前: explicit Foo(string name);

 

例外: 在極少數情況下, 拷貝構造函數可以不聲明成 explicit. 作爲其它類的透明包裝器的類也是特例之一. 類似的例外情況應在註釋中明確說明.

 

最後, 只有 std::initializer_list 的構造函數可以是非 explicit, 以允許你的類型結構可以使用列表初始化的方式進行賦值. 例如:


MyType m = {12};MyType MakeMyType() { return {12}; }TakeMyType({12});

3.4. 可拷貝類型和可移動類型

如果你的類型需要, 就讓它們支持拷貝 / 移動. 否則, 就把隱式產生的拷貝和移動函數禁用.

 

定義:
可拷貝類型允許對象在初始化時得到來自相同類型的另一對象的值, 或在賦值時被賦予相同類型的另一對象的值, 同時不改變源對象的值. 對於用戶定義的類型, 拷貝操作一般通過拷貝構造函數與拷貝賦值操作符定義. string 類型就是一個可拷貝類型的例子.

 

可移動類型允許對象在初始化時得到來自相同類型的臨時對象的值, 或在賦值時被賦予相同類型的臨時對象的值 (因此所有可拷貝對象也是可移動的). std::unique_ptr<int> 就是一個可移動但不可複製的對象的例子. 對於用戶定義的類型, 移動操作一般是通過移動構造函數和移動賦值操作符實現的.

 

拷貝 / 移動構造函數在某些情況下會被編譯器隱式調用. 例如, 通過傳值的方式傳遞對象.

 

優點:
可移動及可拷貝類型的對象可以通過傳值的方式進行傳遞或者返回, 這使得 API 更簡單, 更安全也更通用. 與傳指針和引用不同, 這樣的傳遞不會造成所有權, 生命週期, 可變性等方面的混亂, 也就沒必要在協議中予以明確. 這同時也防止了客戶端與實現在非作用域內的交互, 使得它們更容易被理解與維護. 這樣的對象可以和需要傳值操作的通用 API 一起使用, 例如大多數容器.

 

拷貝 / 移動構造函數與賦值操作一般來說要比它們的各種替代方案, 比如 Clone(), CopyFrom() or Swap(), 更容易定義, 因爲它們能通過編譯器產生, 無論是隱式的還是通過 = 默認. 這種方式很簡潔, 也保證所有數據成員都會被複制. 拷貝與移動構造函數一般也更高效, 因爲它們不需要堆的分配或者是單獨的初始化和賦值步驟, 同時, 對於類似省略不必要的拷貝這樣的優化它們也更加合適.

 

移動操作允許隱式且高效地將源數據轉移出右值對象. 這有時能讓代碼風格更加清晰.

 

缺點:
許多類型都不需要拷貝, 爲它們提供拷貝操作會讓人迷惑, 也顯得荒謬而不合理. 爲基類提供拷貝 / 賦值操作是有害的, 因爲在使用它們時會造成對象切割. 默認的或者隨意的拷貝操作實現可能是不正確的, 這往往導致令人困惑並且難以診斷出的錯誤.


拷貝構造函數是隱式調用的, 也就是說, 這些調用很容易被忽略. 這會讓人迷惑, 尤其是對那些所用的語言約定或強制要求傳引用的程序員來說更是如此. 同時, 這從一定程度上說會鼓勵過度拷貝, 從而導致性能上的問題.

結論:
如果需要就讓你的類型可拷貝 / 可移動. 作爲一個經驗法則, 如果對於你的用戶來說這個拷貝操作不是一眼就能看出來的, 那就不要把類型設置爲可拷貝. 如果讓類型可拷貝, 一定要同時給出拷貝構造函數和賦值操作的定義. 如果讓類型可拷貝, 同時移動操作的效率高於拷貝操作, 那麼就把移動的兩個操作 (移動構造函數和賦值操作) 也給出定義. 如果類型不可拷貝, 但是移動操作的正確性對用戶顯然可見, 那麼把這個類型設置爲只可移動並定義移動的兩個操作.

 

建議通過 = default 定義拷貝和移動操作. 定義非默認的移動操作目前需要異常. 時刻記得檢測默認操作的正確性. 由於存在對象切割的風險, 不要爲任何有可能有派生類的對象提供賦值操作或者拷貝 / 移動構造函數 (當然也不要繼承有這樣的成員函數的類). 如果你的基類需要可複製屬性, 請提供一個 public virtual Clone() 和一個 protected 的拷貝構造函數以供派生類實現.

 

如果你的類不需要拷貝 / 移動操作, 請顯式地通過 = delete 或其他手段禁用之.



3.5. 委派和繼承構造函數

在能夠減少重複代碼的情況下使用委派和繼承構造函數.

 

定義:
委派和繼承構造函數是由 C++11 引進爲了減少構造函數重複代碼而開發的兩種不同的特性. 通過特殊的初始化列表語法, 委派構造函數允許類的一個構造函數調用其他的構造函數. 例如:


X::X(const string& name) : name_(name) {  ...}X::X() : X("") { }

繼承構造函數允許派生類直接調用基類的構造函數, 一如繼承基類的其他成員函數, 而無需重新聲明. 當基類擁有多個構造函數時這一功能尤其有用. 例如:


class Base { public:  Base();  Base(int n);  Base(const string& s);  ...};class Derived : public Base { public:  using Base::Base;  // Base's constructors are redeclared here.};

如果派生類的構造函數只是調用基類的構造函數而沒有其他行爲時, 這一功能特別有用.
優點:
委派和繼承構造函數可以減少冗餘代碼, 提高可讀性. 委派構造函數對 Java 程序員來說並不陌生.
缺點:
使用輔助函數可以預估出委派構造函數的行爲. 如果派生類和基類相比引入了新的成員變量, 繼承構造函數就會讓人迷惑, 因爲基類並不知道這些新的成員變量的存在.
結論:
只在能夠減少冗餘代碼, 提高可讀性的前提下使用委派和繼承構造函數. 如果派生類有新的成員變量, 那麼使用繼承構造函數時要小心. 如果在派生類中對成員變量使用了類內部初始化的話, 繼承構造函數還是適用的.


3.6. 結構體 VS. 類

僅當只有數據時使用 struct, 其它一概使用 class.

 

說明:
在 C++ 中 struct 和 class 關鍵字幾乎含義一樣. 我們爲這兩個關鍵字添加我們自己的語義理解, 以便未定義的數據類型選擇合適的關鍵字.
struct 用來定義包含數據的被動式對象, 也可以包含相關的常量, 但除了存取數據成員之外, 沒有別的函數功能. 並且存取功能是通過直接訪問位域, 而非函數調用. 除了構造函數, 析構函數, Initialize(), Reset(), Validate() 等類似的函數外, 不能提供其它功能的函數.
如果需要更多的函數功能, class 更適合. 如果拿不準, 就用 class.
爲了和 STL 保持一致, 對於仿函數和 trait 特性可以不用 class 而是使用 struct.
注意: 類和結構體的成員變量使用不同的命名規則.


3.7. 繼承

使用組合 (composition, YuleFox 注: 這一點也是 GoF 在 <<Design Patterns>> 裏反覆強調的) 常常比使用繼承更合理. 如果使用繼承的話, 定義爲 public 繼承.

 

定義:
當子類繼承基類時, 子類包含了父基類所有數據及操作的定義. C++ 實踐中, 繼承主要用於兩種場合: 實現繼承 (implementation inheritance), 子類繼承父類的實現代碼; 接口繼承 (interface inheritance), 子類僅繼承父類的方法名稱.
優點:
實現繼承通過原封不動的複用基類代碼減少了代碼量. 由於繼承是在編譯時聲明, 程序員和編譯器都可以理解相應操作並發現錯誤. 從編程角度而言, 接口繼承是用來強制類輸出特定的 API. 在類沒有實現 API 中某個必須的方法時, 編譯器同樣會發現並報告錯誤.
缺點:
對於實現繼承, 由於子類的實現代碼散佈在父類和子類間之間, 要理解其實現變得更加困難. 子類不能重寫父類的非虛函數, 當然也就不能修改其實現. 基類也可能定義了一些數據成員, 還要區分基類的實際佈局.
結論:
所有繼承必須是 public 的. 如果你想使用私有繼承, 你應該替換成把基類的實例作爲成員對象的方式.
不要過度使用實現繼承. 組合常常更合適一些. 儘量做到只在 “是一個” (“is-a”, YuleFox 注: 其他 “has-a” 情況下請使用組合) 的情況下使用繼承: 如果 Bar 的確 “是一種” Foo, Bar 才能繼承 Foo.
必要的話, 析構函數聲明爲 virtual. 如果你的類有虛函數, 則析構函數也應該爲虛函數. 注意 數據成員在任何情況下都必須是私有的.
當重載一個虛函數, 在衍生類中把它明確的聲明爲 virtual. 理論依據: 如果省略 virtual 關鍵字, 代碼閱讀者不得不檢查所有父類, 以判斷該函數是否是虛函數.


3.8. 多重繼承

真正需要用到多重實現繼承的情況少之又少. 只在以下情況我們才允許多重繼承: 最多隻有一個基類是非抽象類; 其它基類都是以 Interface 爲後綴的 純接口類.

 

定義:
多重繼承允許子類擁有多個基類. 要將作爲 純接口 的基類和具有 實現 的基類區別開來.
優點:
相比單繼承 (見 繼承), 多重實現繼承可以複用更多的代碼.
缺點:
真正需要用到多重 實現 繼承的情況少之又少. 多重實現繼承看上去是不錯的解決方案, 但你通常也可以找到一個更明確, 更清晰的不同解決方案.
結論:
只有當所有父類除第一個外都是 純接口類 時, 才允許使用多重繼承. 爲確保它們是純接口, 這些類必須以 Interface 爲後綴.
關於該規則, Windows 下有個 特例.



3.9. 接口

接口是指滿足特定條件的類, 這些類以 Interface 爲後綴 (不強制).

 

定義:
當一個類滿足以下要求時, 稱之爲純接口:

 

  • 只有純虛函數 (“=0”) 和靜態函數 (除了下文提到的析構函數).
  • 沒有非靜態數據成員.
  • 沒有定義任何構造函數. 如果有, 也不能帶有參數, 並且必須爲 protected.
  • 如果它是一個子類, 也只能從滿足上述條件並以 Interface 爲後綴的類繼承.

 

接口類不能被直接實例化, 因爲它聲明瞭純虛函數. 爲確保接口類的所有實現可被正確銷燬, 必須爲之聲明虛析構函數 (作爲上述第 1 條規則的特例, 析構函數不能是純虛函數). 具體細節可參考 Stroustrup 的 The C++ Programming Language, 3rd edition 第 12.4 節.
優點:
以 Interface 爲後綴可以提醒其他人不要爲該接口類增加函數實現或非靜態數據成員. 這一點對於 多重繼承 尤其重要. 另外, 對於 Java 程序員來說, 接口的概念已是深入人心.
缺點:
Interface 後綴增加了類名長度, 爲閱讀和理解帶來不便. 同時,接口特性作爲實現細節不應暴露給用戶.
結論:
只有在滿足上述需要時, 類才以 Interface 結尾, 但反過來, 滿足上述需要的類未必一定以 Interface 結尾.


3.10. 運算符重載

除少數特定環境外,不要重載運算符.

 

定義:
一個類可以定義諸如 + 和 / 等運算符, 使其可以像內建類型一樣直接操作.
優點:
使代碼看上去更加直觀, 類表現的和內建類型 (如 int) 行爲一致. 重載運算符使 Equals(), Add()等函數名黯然失色. 爲了使一些模板函數正確工作, 你可能必須定義操作符.
缺點:
雖然操作符重載令代碼更加直觀, 但也有一些不足:
  • 混淆視聽, 讓你誤以爲一些耗時的操作和操作內建類型一樣輕巧.
  • 更難定位重載運算符的調用點, 查找 Equals() 顯然比對應的 == 調用點要容易的多.
  • 有的運算符可以對指針進行操作, 容易導致 bug. Foo + 4 做的是一件事, 而 &Foo + 4 可能做的是完全不同的另一件事. 對於二者, 編譯器都不會報錯, 使其很難調試;
重載還有令你吃驚的副作用. 比如, 重載了 operator& 的類不能被前置聲明.
結論:
一般不要重載運算符. 尤其是賦值操作 (operator=) 比較詭異, 應避免重載. 如果需要的話, 可以定義類似 Equals(), CopyFrom() 等函數.
然而, 極少數情況下可能需要重載運算符以便與模板或 “標準” C++ 類互操作 (如 operator<<(ostream&, const T&)). 只有被證明是完全合理的才能重載, 但你還是要儘可能避免這樣做. 尤其是不要僅僅爲了在 STL 容器中用作鍵值就重載 operator== 或 operator<; 相反, 你應該在聲明容器的時候, 創建相等判斷和大小比較的仿函數類型.
有些 STL 算法確實需要重載 operator== 時, 你可以這麼做, 記得別忘了在文檔中說明原因.


3.11. 存取控制

將 所有 數據成員聲明爲 private, 並根據需要提供相應的存取函數. 例如, 某個名爲 foo_ 的變量, 其取值函數是 foo(). 還可能需要一個賦值函數 set_foo().
特例是, 靜態常量數據成員 (一般寫做 kFoo) 不需要是私有成員.
一般在頭文件中把存取函數定義成內聯函數.
參考 繼承 和 函數命名



3.11. 聲明順序

在類中使用特定的聲明順序: public: 在 private: 之前, 成員函數在數據成員 (變量) 前;

 

類的訪問控制區段的聲明順序依次爲: public:, protected:, private:. 如果某區段沒內容, 可以不聲明.
每個區段內的聲明通常按以下順序:
  • typedefs 和枚舉
  • 常量
  • 構造函數
  • 析構函數
  • 成員函數, 含靜態成員函數
  • 數據成員, 含靜態數據成員
友元聲明應該放在 private 區段. 如果用宏 DISALLOW_COPY_AND_ASSIGN 禁用拷貝和賦值, 應當將其置於 private 區段的末尾, 也即整個類聲明的末尾. 參見可拷貝類型和可移動類型.
.cc 文件中函數的定義應儘可能和聲明順序一致.
不要在類定義中內聯大型函數. 通常, 只有那些沒有特別意義或性能要求高, 並且是比較短小的函數才能被定義爲內聯函數. 更多細節參考 內聯函數.


3.12. 編寫簡短函數

傾向編寫簡短, 凝練的函數.

 

我們承認長函數有時是合理的, 因此並不硬性限制函數的長度. 如果函數超過 40 行, 可以思索一下能不能在不影響程序結構的前提下對其進行分割.
即使一個長函數現在工作的非常好, 一旦有人對其修改, 有可能出現新的問題. 甚至導致難以發現的 bug. 使函數儘量簡短, 便於他人閱讀和修改代碼.
在處理代碼時, 你可能會發現複雜的長函數. 不要害怕修改現有代碼: 如果證實這些代碼使用 / 調試困難, 或者你需要使用其中的一小段代碼, 考慮將其分割爲更加簡短並易於管理的若干函數.


譯者 (YuleFox) 筆記

  • 不在構造函數中做太多邏輯相關的初始化;
  • 編譯器提供的默認構造函數不會對變量進行初始化, 如果定義了其他構造函數, 編譯器不再提供, 需要編碼者自行提供默認構造函數;
  • 爲避免隱式轉換, 需將單參數構造函數聲明爲 explicit;
  • 爲避免拷貝構造函數, 賦值操作的濫用和編譯器自動生成, 可將其聲明爲 private 且無需實現;
  • 僅在作爲數據集合時使用 struct;
  • 組合 > 實現繼承 > 接口繼承 > 私有繼承, 子類重載的虛函數也要聲明 virtual 關鍵字, 雖然編譯器允許不這樣做;
  • 避免使用多重繼承, 使用時, 除一個基類含有實現外, 其他基類均爲純接口;
  • 接口類類名以 Interface 爲後綴, 除提供帶實現的虛析構函數, 靜態成員函數外, 其他均爲純虛函數, 不定義非靜態數據成員, 不提供構造函數, 提供的話,聲明爲 protected;
  • 爲降低複雜性, 儘量不重載操作符, 模板, 標準類中使用時提供文檔說明;
  • 存取函數一般內聯在頭文件中;
  • 聲明次序: public -> protected -> private;
  • 函數體儘量短小, 緊湊, 功能單一;

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章