Google C++ 程式設計風格指南:類
類是 C++ 中程式碼的基本單元. 顯然, 它們被廣泛使用. 本節列舉了在寫一個類時的主要注意事項.
3.1. 建構函式的職責
不要在建構函式中進行復雜的初始化 (尤其是那些有可能失敗或者需要呼叫虛擬函式的初始化).
定義:
在建構函式體中進行初始化操作.
優點:
排版方便, 無需擔心類是否已經初始化.
缺點:
在建構函式中執行操作引起的問題有:
- 建構函式中很難上報錯誤, 不能使用異常.
- 操作失敗會造成物件初始化失敗,進入不確定狀態.
- 如果在建構函式內呼叫了自身的虛擬函式, 這類呼叫是不會重定向到子類的虛擬函式實現. 即使當前沒有子類化實現, 將來仍是隱患.
- 如果有人建立該型別的全域性變數 (雖然違背了上節提到的規則), 建構函式將先
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 = {1, 2}; MyType MakeMyType() { return {1, 2}; } TakeMyType({1, 2});
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. 存取控制
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
; - 函式體儘量短小, 緊湊, 功能單一;
本系列文章
相關文章
- Google C++程式設計風格指南(三):C++ 類GoC++程式設計
- Google C++程式設計風格指南GoC++程式設計
- Google C++ 程式設計風格指南:格式GoC++程式設計
- Google C++ 程式設計風格指南:作用域GoC++程式設計
- Google C++ 程式設計風格指南:註釋GoC++程式設計
- Google C++程式設計風格指南(七):格式GoC++程式設計
- Google C++ 程式設計風格指南:其他 C++ 特性GoC++程式設計
- Google C++ 程式設計風格指南:命名約定GoC++程式設計
- Google C++程式設計風格指南(二):作用域GoC++程式設計
- Google C++ 程式設計風格指南:來自 Google 的奇技GoC++程式設計
- Google Java 程式設計風格指南GoJava程式設計
- Google C++ 程式設計風格指南:標頭檔案GoC++程式設計
- Google C++程式設計風格指南(五):命名約定GoC++程式設計
- Google C++程式設計風格指南(六):程式碼註釋GoC++程式設計
- Google Python 程式設計風格指南GoPython程式設計
- Google C++程式設計風格指南(八):規則之例外GoC++程式設計
- Google C++程式設計風格指南(四):智慧指標和其他C++特性GoC++程式設計指標
- Google Java 程式設計風格指南 —— 見微知著GoJava程式設計
- Google JavaScript 程式碼風格指南GoJavaScript
- JavaScript 程式設計風格指南JavaScript程式設計
- Google JavaScript 風格指南GoJavaScript
- [C++][程式設計風格]C++命名規則C++程式設計
- 公開“Google開發者文件風格指南”Go
- .NET框架-微軟C#程式設計風格官方指南框架微軟C#程式設計
- Javascript程式設計風格JavaScript程式設計
- JavaScript 程式碼風格指南JavaScript
- 糟糕程式設計師的程式設計風格程式設計師
- 物件導向程式設計風格 VS 基於物件程式設計風格(boost::bind/function)物件程式設計Function
- 《Google 開源專案風格指南》中文版Go
- Vue 前端程式碼風格指南Vue前端
- Python程式設計風格和設計模式Python程式設計設計模式
- 優秀Java程式設計師的程式設計風格Java程式設計師
- 設計團隊必看!教你10招搞定web設計風格指南Web
- Eclipse中使用google程式碼風格EclipseGo
- 各種流行的程式設計風格程式設計
- 前端 JavaScript 程式設計風格淺析前端JavaScript程式設計
- 你需要懂點程式設計風格程式設計
- 程式設計師高逼格指南程式設計師