3. 類別 (Classes)

類別是 C++ 中程式碼的基本單元。想當然爾, 在程式中類別將被廣泛使用。本節列舉了在撰寫一個類別時該做的和不該做的事項.

3.1. 建構子的職責

Tip

不要在建構子中進行複雜的初始化 (尤其是那些有可能失敗或者需要呼叫虛函式的初始化).

定義:

在建構子體中進行初始化操作.

優點:

排版方便, 無需擔心類是否已經初始化.

缺點:

在建構子中執行操作引起的問題有:

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

結論:

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

3.2. 初始化

Tip

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

定義:

new 一個不帶參數的類對象時, 會呼叫這個類的默認建構子. 用 new[] 創建陣列時, 默認建構函式則總是被呼叫. 在類成員裡面進行初始化是指宣告一個成員變數的時候使用一個結構例如 int _count = 17 或者 string _name{"abc"} 來替代 int _count 或者 string _name 這樣的形式.

優點:

使用者定義的默認建構子將在沒有提供初始化操作時將對象初始化. 這樣就保證了對象在被建構之時就處於一個有效且可用的狀態, 同時保證了對象在被創建時就處於一個顯然”不可能”的狀態, 以此幫助調試.

缺點:

對程式碼編寫者來說, 這是多餘的工作.

如果一個成員變數在宣告時初始化又在建構子中初始化, 有可能造成混亂, 因為建構函式中的值會覆蓋掉宣告中的值.

結論:

簡單的初始化用類成員初始化完成, 尤其是當一個成員變數要在多個建構子里用相同的方式初始化的時候.

如果你的類中有成員變數沒有在類裡面進行初始化, 而且沒有提供其它建構子, 你必須定義一個 (不帶參數的) 默認建構函式. 把對象的內部狀態初始化成一致 / 有效的值無疑是更合理的方式.

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

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

3.3. 顯式建構子

Tip

對單個參數的建構子使用 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. 可拷貝類型和可移動類型

Tip

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

定義:

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

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

拷貝 / 移動建構子在某些情況下會被編譯器隱式呼叫. 例如, 通過傳值的方式傳遞對象.

優點:

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

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

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

缺點:

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

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

結論:

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

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

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

3.5. 委派和繼承建構子

Tip

在能夠減少重複程式碼的情況下使用委派和繼承建構子.

定義:

委派和繼承建構子是由 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. 類

Tip

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

說明:

在 C++ 中 struct 和 class 關鍵字幾乎含義一樣. 我們為這兩個關鍵字添加我們自己的語義理解, 以便未定義的數據類型選擇合適的關鍵字.

struct 用來定義包含數據的被動式對象, 也可以包含相關的常數, 但除了存取數據成員之外, 沒有別的函式功能. 並且存取功能是通過直接訪問位域, 而非函式呼叫. 除了建構子, 解構子, Initialize(), Reset(), Validate() 等類似的函式外, 不能提供其它功能的函式.

如果需要更多的函式功能, class 更適合. 如果拿不準, 就用 class.

為了和 STL 保持一致, 對於仿函式和 trait 特性可以不用 class 而是使用 struct.

注意: 類和結構體的成員變數使用不同的命名規則.

3.7. 繼承

Tip

使用組合 (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. 多重繼承

Tip

真正需要用到多重實作繼承的情況少之又少. 只在以下情況我們才允許多重繼承: 最多只有一個父類別是非抽像類; 其它父類別都是以 Interface 為後綴的 純介面類.

定義:

多重繼承允許子類擁有多個父類別. 要將作為 純介面 的父類別和具有 實作 的父類別區別開來.

優點:

相比單繼承 (見 繼承), 多重實作繼承可以復用更多的程式碼.

缺點:

真正需要用到多重 實作 繼承的情況少之又少. 多重實作繼承看上去是不錯的解決方案, 但你通常也可以找到一個更明確, 更清晰的不同解決方案.

結論:

只有當所有父類別除第一個外都是 純介面類 時, 才允許使用多重繼承. 為確保它們是純接口, 這些類必須以 Interface 為後綴.

Note

關於該規則, Windows 下有個 特例.

3.9. 介面

Tip

介面是指滿足特定條件的類, 這些類以 Interface 為後綴 (不強制).

定義:

當一個類滿足以下要求時, 稱之為純介面:

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

介面類不能被直接實例化, 因為它宣告了純虛函式. 為確保接口類的所有實作可被正確銷毀, 必須為之宣告虛解構子 (作為上述第 1 條規則的特例, 解構子不能是純虛函式). 具體細節可參考 Stroustrup 的 The C++ Programming Language, 3rd edition 第 12.4 節.

優點:

Interface 為後綴可以提醒其他人不要為該介面類增加函式實作或非靜態數據成員. 這一點對於 多重繼承 尤其重要. 另外, 對於 Java 開發者來說, 接口的概念已是深入人心.

缺點:

Interface 後綴增加了類名長度, 為閱讀和理解帶來不便. 同時,介面特性作為實作細節不應暴露給使用者.

結論:

只有在滿足上述需要時, 類才以 Interface 結尾, 但反過來, 滿足上述需要的類未必一定以 Interface 結尾.

3.10. 運算子重載

Tip

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

定義:

一個類可以定義諸如 +/ 等運算子, 使其可以像內建類型一樣直接操作.

優點:

使程式碼看上去更加直觀, 類表現的和內建類型 (如 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. 存取控制

Tip

所有 數據成員宣告為 private, 並根據需要提供相應的存取函式. 例如, 某個名為 foo_ 的變數, 其取值函式是 foo(). 還可能需要一個賦值函式 set_foo().

特例是, 靜態常數數據成員 (一般寫做 kFoo) 不需要是私有成員.

一般在標頭檔中把存取函式定義成內聯函式.

參考 繼承函式命名

3.11. 宣告順序

Tip

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

類的訪問控制區段的宣告順序依次為: public:, protected:, private:. 如果某區段沒內容, 可以不宣告.

每個區段內的宣告通常按以下順序:

  • typedefs 和列舉
  • 常數
  • 建構子
  • 解構子
  • 成員函式, 含靜態成員函式
  • 數據成員, 含靜態數據成員

友元宣告應該放在 private 區段. 如果用巨集 DISALLOW_COPY_AND_ASSIGN 禁用拷貝和賦值, 應當將其置於 private 區段的末尾, 也即整個類宣告的末尾. 參見可拷貝類型和可移動類型.

.cc 文件中函式的定義應盡可能和宣告順序一致.

不要在類定義中內聯大型函式. 通常, 只有那些沒有特別意義或性能要求高, 並且是比較短小的函式才能被定義為內聯函式. 更多細節參考 內聯函式.

3.12. 編寫簡短函式

Tip

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

我們承認長函式有時是合理的, 因此並不硬性限制函式的長度. 如果函式超過 40 行, 可以思索一下能不能在不影響程式結構的前提下對其進行分割.

即使一個長函式現在工作的非常好, 一旦有人對其修改, 有可能出現新的問題. 甚至導致難以發現的 bug. 使函式盡量簡短, 便於他人閱讀和修改程式碼.

在處理程式碼時, 你可能會發現複雜的長函式. 不要害怕修改現有程式碼: 如果證實這些程式碼使用 / 調試困難, 或者你需要使用其中的一小段程式碼, 考慮將其分割為更加簡短並易於管理的若干函式.

譯者 (YuleFox) 筆記

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