5. 類別 (Classes)

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

5.1. 建構式的職責

小訣竅

不要在建構式中呼叫虛擬函式。如果你沒辦法發出錯誤信號的話,避免進行可能會失敗的初始化。

定義:

在建構式中,你可以進行任何的初始化行為。

優點:

  • 你不用擔心該類別是否已經被初始化了。

  • 在建構式完全初始化的物件,可以宣告為 const,而且也比較容易在標準容器或演算法中使用這樣的物件。

缺點:

  • 如果在建構式內呼叫了自身的虛擬函式,不會導向到子類別的實作版本。即使當前沒有子類別覆寫實作,將來不小心加上時不會有任何錯誤訊息,還是會帶來困擾。

  • 沒有簡單的方法可以從建構式中發出錯誤信號,除了讓程式當掉(有時不怎麼恰當)或是使用例外處理 (exceptipns)(但我們 禁用例外處理)。

  • 如果建構式執行失敗,就會產生一個初始化不完全的物件,導致我們得用類似 bool IsValid() 的函式來檢查物件是否可用。但一般人很容易忘掉這件事。

  • 你沒有辦法取得建構式的位址,所以不管你在建構式裡做了什麼,你都沒辦法輕易將結果傳到其他地方(例如另一個執行緒)。

結論:

建構式不得呼叫虛擬函式。如果可以的話,在建構式中發生問題時直接結束整個程式。否則,考慮使用工廠函式 (factory function) 或 Init() 方法(如 TotW #42 所示)。若是物件沒有公開的方法可以得知其可用狀態,避免建立 Init() 方法,因為這種分段建構的物件很容易發生錯誤。

5.2. 隱式轉換 (Implicit Conversions)

小訣竅

不要定義隱式轉換。使用加上 explicit 關鍵字的轉換運算子,以及只有一個參數的建構式。

定義:

隱式轉換指的是把某個型別( 來源型別 )的物件用在需要另一個型別( 目的型別 )的地方。例如有個函式的參數為 double,然後我們傳 int 的引數進去。

除了語言本身定義的隱式轉換外,使用者也可以自行定義新的規則,只要來源或目的型別的定義處加上適當的成員函式即可。來源型別中的隱式轉換是「以目的型別命名」的型別運算子(例如:operator bool())。目的型別中的隱式轉換,則是定義「唯一的引數就是來源型別」的建構式(或是唯一引數具沒有預設值)。

建構式或是轉換運算子(後者從 C++11 開始)可以加上 explicit 關鍵字,以確保它們只會在目的型別有明確指定(例如有轉型)的情況下使用。這不只在隱式轉換時會用到,在 C++11 的條列式初始化 (list initialization) 語法中也會用到:

class Foo {
  explicit Foo(int x, double y);
  ...
};

void Func(Foo f);
Func({42, 3.14});  // 錯誤

這種程式碼在技術上來說並不是隱式轉換,但只要 explicit 存在,編譯器就會將之視為隱式轉換。

優點:

  • 隱式轉換可以讓型別可用性更高,而且在行為明確時,可以不需要特別指出要轉換的型別。

  • 隱式轉換可以做為函式覆載 (overloading) 的簡單替代方案,例如只要寫一個擁有 string_view 參數的函式,就可代替吃 stringconst char* 兩種型別的覆載函式。

  • 條列式初始化的語法簡潔又能明確表達意涵。

缺點:

  • 隱式轉換可能會讓人忽略某些型別不符的 bug,像是目的型別和使用者預期的不一樣,或是使用者不知道會發生型別轉換。

  • 隱式轉換可能會降低程式碼的可讀型(特別是還有函式覆載時),讓人無法清楚了解哪一段程式碼會被呼叫。

  • 只有單一引數的建構式可能無意間會被當成隱式型別轉換呼叫,即使那不是我們的本意。

  • 若是只有單一引數的建構式沒有加上 explicit 關鍵字,我們就沒有可靠的方法得知作者是想要定義隱式轉換,還是單純忘了加上 explicit。有時候哪個型別該提供轉換不是那麼明顯;若兩個型別都有提供,那就會發生模稜兩可 (ambigous) 的問題。

  • 如果目的型別是隱式的話,條列式初始會也會遇到一樣的問題,特別是若是條列內容中只有一個元素時。

結論:

在類別定義中,型別轉換運算子、以及只有一個引數的公開建構式,必須要加上 explicit 修飾字。但複製和移動建構子不應加上 explicit,因為它們不會進行型別轉換。對於那些在設計上就是用來包裝其他的型別的型別來說,隱式轉換有時是必要且恰當的。若遇到這種情況,請和專案領導人討論,看是否可以忽略這條規則。

所需的引數不是剛好一個的建構式,可以不用加 explicit。若建構式接受單一參數,而型別為 std::initializer_list 的話,也不需要加 explicit,這樣才能支援複製初始化 (copy-initialization)(例如:MyType m = {1, 2};)。

5.3. 「可複製 (copyable)」和「可移動 (movable)」型別

小訣竅

類別的公開 API 應該要明確告知此類別為「可複製」、「僅能移動」,還是「不能複製也不能移動」。如果複製和/或移動行為對你的型別來說很清楚、很自然的話,支援這些行為。

定義:

若一個型別可以由暫存物件初始化、且取得其內容,即為「可移動」。

若一個型別可以由另一個相同型別的物件初始化、或是取得其內容,而且不會改變來源物件的內容,則為「可複製」(這樣的條件也自然成為「可移動」)。std::unique_ptr<int> 就是一個「可移動、不可複製」的範例(因為 std::unique_ptr<int> 物件在將內容傳指派給另一個物件時,來源物件的內容必須改變)。intstring 則是「可移動,且可複製」的範例(對 int 來說,移動和複製行為完全一樣;而 string 則是有一個比複製節省資源的移動實作)。

對使用者定義型別來說,複製行為是透過定義 copy constructor(複製建構式)和 copy-assignment(複製指派)運算子而達成的。移動行為是透過定義 move constructor(移動建構式)和 move-assignment(移動指派)運算子、或是 copy constructor 和 copy-assignment 運算子而產生。

在某些情況下編譯器會逕行呼叫複製/移動建構式,例如以傳值的方式傳遞物件時。

優點:

可移動及可複製類別的物件可以通過傳值的方式進行傳遞或者回傳,這使得 API 更簡單、更安全,也更通用。與傳遞指標和 reference 不同,這樣的傳遞不會造成所有權、生命週期、可變性等方面的混亂,也就沒必要在協議中特別註明。這同時也防止了客戶端與實作進行非本地端的互動,讓它們更容易被理解、維護、以及在編譯器進行最佳化。另外,這樣的物件可以和需要傳值操作的泛型 API(例如大多數容器)一起使用,而且在某些應用下(例如 type composition)也更有彈性。

複製/移動建構式與賦值操作一般來說要比它們的各種替代方案(例如 Clone()CopyFrom()Swap()) 更容易定義,因為無論是隱式的版本還是 = 的預設行為,編譯器都能幫我們自動產生。這種方式很簡潔,也保證所有資料成員都會被複製。複製與移動建構式一般也更有效率,因為它們不需要配置 heap 空間或是單獨的初始化和賦值步驟,同時也很適合進行類似 複製省略 這樣的最佳化。

移動作業允許隱式且有效地將 rvalue 物件中的資源轉移出來。有時這能讓程式碼風格更加簡潔。

缺點:

有些類別不需要能被複製,為這些型別提供複製功能會讓人迷惑,也顯得荒謬而不合理。描述 singleton 物件的型別 (Registerer)、跟某個特定作用域綁定的物件 (Cleanup),或是和物件識別 (object identity) 緊密結合的類別 (Mutex) 等,也都沒有提供複製功能的必要。為多型架構下的基底類別提供複製功能是有害的,因為會造成 object slicing 的問題。未經仔細設計或預設的複製功能實作可能不正確,這往往會產生令人困惑且難以揪出的臭蟲。

複製建構式是隱式呼叫的,因此很容易被人忽略。對於那些慣用「資料一定是以 reference 方式傳遞」的語言的開發人員們來說,這尤其讓人困擾。這也可能過度鼓勵複製行為,進而導致效能低落。

結論:

每個類別的公開界面都須明確指明這個類別要支援哪些複製和移動作業。作法通常是在類別宣告的 public 區間中,明確地宣告希望支援的行為、同時明確地刪除不想支援的行為。

更精確地來說:可複製的類別應該要明確宣告複製相關函式;只能被移動的類別應該要明確宣告移動相關函式;而不能移動也不能複製的類別,應該要明確地刪除複製及移動相關函式。不管是宣告還是刪除,你可以同時將複製、移動相關的四個函式全部列出,但不是必要的。如果你提供了 copy-assignment 或 move-assignment 運算子,你必須同時提供對應的建構式。

class Copyable {
 public:
  Copyable(const Copyable& rhs) = default;
  Copyable& operator=(const Copyable& rhs) = default;

  // 上述的宣告覆蓋了隱式的移動行為。
};

class MoveOnly {
 public:
  MoveOnly(MoveOnly&& rhs);
  MoveOnly& operator=(MoveOnly&& rhs);

  // 上述宣告已隱含「刪除複製行為」之意,
  // 不過如果你希望的話,可以明確表示出來:
  MoveOnly(const MoveOnly&) = delete;
  MoveOnly& operator=(const MoveOnly&) = delete;
};

class NotCopyableOrMovable {
 public:
  // 不可複製也不可移動
  NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
  NotCopyableOrMovable& operator=(const NotCopyableOrMovable&)
      = delete;

  // 上述宣告已隱含「刪除移動行為」之意,
  // 不過如果你希望的話,可以明確表示出來:
  NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
  NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)
      = delete;
};

只有在非常明顯的情況下才能省略宣告/刪除語句:舉例來說,如果基底類別不可複製或不可移動,繼承它的類別自然也不行。同樣的,結構 是否可以複製或移動,得視它的資料成員是否可以複製或移動而定(和類別的規則不同,因為在 Google 的程式碼中,類別的資料成員不是公開的)。但如果你明確地宣告或刪除了複製/移動行為,另一組的行為不明確,那麼就不能套用這段所說的例外情況(特別是:若是你宣告或刪除了結構的複製/移動行為,那麼你就得遵守這一節中所有針對類別設定的規則)。

如果一個型別的複製/移動行為意義不明確,或是會帶來意料之外的效率成本,那麼這個型別就不應為「可複製」或「可移動」。對於「可複製」的型別來說,移動行為完全是為了對效率最佳化而生,而且是臭蟲和複雜性的潛在來源,所以除非它的執行效率真的遠勝單純的複製行為,儘量不要額外定義移動行為。如果你的型別是可複製的,我們建議你仔細設計你的類別,好讓預設的實作版本能正常運作。記得要仔細檢查預設實作版本的正確性,一如你對待其他的程式碼。

為了避免發生 slicing 的問題,若是一個類別是設計來當基底類別的,儘量不要提供公開的指派運算子或複製/移動建構式(同時,儘量不要去繼承有這類成員的類別)。如果你的基底類別須為可複製的,那麼請提供公開的 Clone() 虛擬函式、以及 protected 的複製建構式,以利繼承類別能實作自己的版本。

5.4. 結構 (struct) vs. 類別 (class)

小訣竅

想要建立只有資料的被動物件時,使用 struct;其他狀況一律使用 class

在 C++ 中 structclass 的行為幾乎一樣。我們為這兩個關鍵字添加我們自己的語義,以便為定義的資料型別選擇合適的關鍵字。

struct 用來定義包含數據的被動物件,也可以包含相關的常數,但除了可以存取其中的資料成員外,沒有其他功能。存取資料時直接存取資料所在的欄外,而非透過函式。除了建構式、解構式、Initialize()Reset()Validate() 等設定資料成員的方法外,不得提供其他的行為方法。

如果需要更多的功能,class 更適合。如果難以判斷,就用 class

為了和 STL 保持一致,對於函式物件 (functor) 和 trait 特性可以用 struct 而非 class

注意:結構和類別的資料成員 命名規則 不同。

5.5. 繼承

小訣竅

使用組合 (composition) 常比使用繼承更合理。如果使用繼承的話,定義為 public 繼承。

定義:

當子類別繼承基底類別時,子類別包含了基底類別所定義的所有資料及函式。「界面繼承 (interface inheritance)」指的是繼承自「純抽象基底類別 (pure abstract class)」,也就是完全沒有狀態或方法實作的類別;其他的繼承行為都是「實作繼承 (implementation inheritance)」。

優點:

實作繼承通過原封不動的重覆使用基底類別程式碼減少了程式碼的數量。由於繼承是在編譯時宣告,開發者和編譯器都可以理解對應操作並發現錯誤。從程式撰寫角度來說,界面繼承是用來強制類別輸出特定的 API。在類別沒有實作 API 中某個必須的方法時,編譯器同樣會發現並回報錯誤。

缺點:

對於實作繼承,由於子類別的實作程式碼散佈在基底類別和子類別的定義處,要理解其實作變得更加困難。子類別不能覆寫基底類別的非虛擬函式,當然也就不能修改其實作。

多重繼承的問題又更多了。它通常會造成非常明顯的效能負擔(事實上,「從單一繼承變成多重繼承」所造成的效能衝擊,通常比「從一般繼承變成虛擬繼承」所造成的效能衝擊還要大),而且還有可能會產生「鑽石型繼承樣式」,造成理解上的困難、模稜兩可的問題,以及難解的 bug。

結論:

只能使用 public 繼承。如果你覺得要用私有繼承,那應該改為把基底類別的實例當作資料成員。

不要過度使用實作繼承。組合常常更合適一些。儘量做到只在 「is-a」 的情況下使用繼承:如果 Bar 的確 「is-a」 FooBar 才能繼承 Foo

儘量不要使用 protected 宣告子類別可以存取的資料成員。類別的資料成員 應該要是私有的

在子類別覆載虛擬函式或虛擬解構式時,加上 override 或是 final 修飾字(雖然後者較不常用);不要加上 virtual 修飾字。原因是:假設一個函式/解構式在基底類別中並沒有被宣告為可覆載的虛擬函式/解構式,那麼在子類別中加上 override 或是 final 就會產生編譯時的錯誤。這樣的結果有助於我們找到一些常見的錯誤。這些修飾字相當於程式碼中的說明文件;如果沒有這些修飾字的話,閱讀程式碼的人就必須檢查所有的基底類別,才能知道這個函式/解構式是否為虛擬函式/解構式。

你可以使用多重繼承,但我們強烈不建議進行多重 實作 繼承。

5.6. 運算子多載化 (Operator Overloading)

小訣竅

謹慎判斷多載化運算子的時機。不要建立使用者定義的字面符號 (literal)。

定義:

C++ 允許使用者使用 operator 關鍵字,自行 宣告內建運算子的多載化版本 ,只要其中之一的參數型別為使用者自訂型別即可。此外,operator 關鍵字也可以用來定義新的字面符號 (literal)(透過 operator""),以及定義型別轉換函式(例如 operator bool())。

優點:

運算子多載化讓使用者定義型別的行為更接近內建型別,可以讓程式碼更簡潔、更直觀。對於某些運算來說,多載化的運算子更符合一般的使用習慣(例如 ==<=,以及 << 等)。維持使用這些慣用法讓使用者自訂型別的可讀性更佳,同時也可以套用到使用這些名稱的函式庫中。

使用者自訂字面符號可以更簡潔地建立使用者定義型別的物件。

缺點:

  • 想要提供正確、一致、行為完全符合預期的多載化運算子並沒有那麼簡單。一個不小心,就會產生 bug 或是難以理解的程式碼。

  • 過度使用運算子可能會讓程式碼更難懂,特別是如果多載化的運算子語意和一般使用慣例不符的時候。

  • 函式多載化所帶來的危害,運算子多載化一個也逃不掉,甚至更多。

  • 運算子多載化容易讓我們以為這些運算子和內建運算子一樣不會耗費太多資源;但事實上付出的代價是很大的。

  • 想要找到所有呼叫多載化運算子的地方,可能得用能夠分析 C++ 語法的搜尋工具才行;一般的工具(如 grep)恐怕很難勝任。

  • 如果餵給多載化運算子的物件型別不對,你可能會呼叫到錯的版本,卻不會有任何的錯誤訊息。舉例來說, foo < bar&foo < &bar 的行為可能完全不一樣。

  • 某些運算子的多載化天生就是很危險的。多載化單引數 (unary) 運算子 & 可能會讓相同的程式碼在不同的地方有不同的意義(視多載化的宣告是否能被看到而定)。多載化版本的 &&||, 等無法擁有和它們內建版本一樣的求值順序。

  • 運算子通常會定義在類別之外,因此有可能不同的檔案會給予同一個運算子不同的定義。如果這兩份定義都被連結進同一份二進位檔的話,其行為未定義,通常會發生難以捉摸的 bug。

  • 使用者自訂字面符號會產生新的語法格式,就連經驗豐富的 C++ 程式員也不一定搞得清楚。

結論:

只有在意義明確、不會讓人意外,且和內建運算子行為一致時,才定義多載化運算子。舉例來說:把 | 用在需要 bitwise 或是 logical OR 的場合,而不要把它當成 shell 的管線 (pipe) 來用。

只為你自己的型別定義運算子。更精確地說,定義在他們要操作的型別的相同標頭檔、.cc 檔,以及命名空間中。如此一來,這些運算子就會跟著型別跑,降低重覆定義的風險。如果可能的話,避免將運算子定義為模板,因為如此一來所有可以套用這個模板的型別就必須全部滿足本條規則。如果你定義了某個運算子,所有有關的運算子請一併定義,同時必須依一致的原則定義。例如:如果你多載化了 <,那麼其他所有比較運算子都必須被定義,而且請確保 <> 在引數相同時不會回傳 true

儘可能將雙引數 (binary) 運算子定義為「不會修改內容」的「非成員函式」。如果一個二元運算子被定義為類別成員的話,運算元右手邊的引數就會被隱式轉換,但左手邊的引數不會。如果 a < b 可以通過編譯但 b < a 不行,程式碼的使用者會很頭大的。

不要刻意完全不去多載化運算子。舉例來說,與其定義 Equals()CopyFrom()PrintTo(),不如定義 ===<<。反過來說,不要因為其他的函式庫需要就刻意多載化運算子。舉例來說,如果你的型別沒有明確的順序概念,但你想用 std::set 來存放這種型別的物件,那你應該要使用客製化的比較函式 (comparator),而不是去多載化 <

不要多載化 &&||,,或是單引數的 &。不要多載化 operator"";換句話說,不要導入使用者定義的字面符號。

關於型別轉換運算子,請參考 隱式轉換 (Implicit Conversions) 一節。運算子 = 的相關討論在 「可複製 (copyable)」和「可移動 (movable)」型別 一節。多載化 << 在串流中的應用在 資料流 (Streams) 一節有詳細討論。另外也請參考 函式多載化 的內容,因為也適用在運算子的多載化上。

5.7. 存取控制

小訣竅

類別中所有的資料成員都必須放在 private 區間,但 static const 除外(並請依 常數的命名規範 命名)。

因為技術上的原因,若是專案導入 Google Test,放在 .cc 檔中的測試治具 (test fixture) 類別的資料成員可以放在 protected 區間。

5.8. 宣告順序

小訣竅

將類似的宣告放在一起,公開的部份放在前面。

類別的存取控制區間的宣告順序通常依次為:public:protected:private:。可以省略沒有內容的區間。

在每個區間內,儘量把相同種類的宣告放在一起,並儘量按照以下的順序排列:類別(包括 typedefusing,以及巢狀宣告的結構和類別)、常數、工廠函式 (factory functions)、建構式、指派類運算子、解構式、其他所有的函式、資料成員。

不要在類別定義中 inline 大型函式。通常,只有那些特別瑣碎或性能要求高、並且比較短小的函式才能被定義為 inline 函式。更多細節請參考 行內函式 (Inline Functions)