8. 其他 C++ 特性

8.1. 右值參照 (Rvalue Reference)

小訣竅

在下列的情況下使用右值參照:

  • 定義 move constructor 和 move assignment 運算子時。

  • 定義 多載化系列函式 中的 const&&& 版本時;但在定義前述的函式之前,你應該要有足夠的證據證明這樣的作法執行效率比直接傳值顯著高許多,或是你想要撰寫支援任意型別的泛型 (generic) 程式碼卻又不想造成太多額外負擔。注意不要產生太多參數排列組合的多載化系列函式;換句話說,儘量不要針對多個參數進行多載化。

  • 在泛型程式碼中支援「完美轉發 (perfect forwarding)」。

定義:

右值參照是一種只能綁定到暫存物件的參照。其語法與傳統的參照語法相似。例如:void f(string&& s); 宣告了一個函式,其引數是一個字串的右值參照。

在函式的參數中,若是其中某個未確認的模板引數前面加上了 &&,編譯器會套用特別的模板引數推導規則。這樣的參照被稱為「轉發參照 (forwarding reference)」。

優點:

  • 若是有定義 move constructor(參數為該類別的右值參的建構式)的話,就可以進行物件移動,而非只能複製。例如:如果 v1 的型別為 std::vector<string>,則 auto v2(std::move(v1)) 就可能以簡單的指標處理方式實作,不需進行大量的資料複製。在許多情況下可以大幅度增進執行效率。

  • 右值參照能實作「可移動但不可拷貝」的型別。這個特性對那些不需要複製,但有時又需要將它們當成函式參數傳遞或塞入容器的型別很有用。

  • 某些標準函式庫的型別(例如 std::unique_ptr)需要 std::move 才能有效地使用。

  • 有了使用右值參照的 轉發參照,我們就可以寫出將引數傳給另一個函式的泛型包裝函式 (generic function wrapper),就算引數是暫存物件而且/或是常數也可以運行無誤。這叫做「完美轉發」。

缺點:

  • 右值參照的概念尚未廣為人知。有些規則多少有些難以理解,像是「move constructor 何時會自動生成」和「參照折疊 (reference collapsing)」等(後者和套用在函式樣板的 T&& 參數特殊規則相關)。

  • 很多人常會以不正確的方式使用右值參照。若是我們期待傳入函式的引數在函式呼叫後會有合理的特定狀態,或是沒有打算要進行物件移動,那麼使用右值參照就違反了直覺。

結論:

你可以使用右值參照去定義 move constructor 和 move-assignment 運算子(如 「可複製 (copyable)」和「可移動 (movable)」型別 一節所述)。想知道更多關於「移動語意」和 std::move 的細節,可以參考 C++ Primer

你可以使用右值參照去定義成對的多載化函式,其中一個的參數型別為 Foo&&,另一個為 const Foo&。通常比較好的解決方案是直接用傳值方式傳遞,但有時候定義這樣的成對多載化函式執行效率較高,而且有時為了支援更多種類的型別而寫的泛型程式碼必須以這樣的方式撰寫。當然,如果你想要為了執行效率而要撰寫更複雜的程式碼,請確認這麼做對執行效率真的有幫助。

你可以搭配 std::forward 使用轉發參照,以支援完美轉發。

8.2. Friends

小訣竅

我們允許合理的使用 friend 類別及元函式。

通常 friends 應該定義在同一檔案內,避免閱讀者需要跑到其它檔案尋找該類別私有成員的使用方法。一個典型的 friend 使用時機是將 FooBuilder 宣告為 Foo 的 friend,以便 FooBuilder 可以正確建構 Foo 的內部狀態,而無需將此狀態公開出來。某些情況下,將一個單元測試類別宣告成待測類別的 friend 會很方便。

Friends 擴大了(但沒有打破)類別的封裝範圍。某些情況下,當你只想允許一個類別能存取該類別的私有成員時,使用 friend 是比較好的選擇。然而,大部份的類別都只應該透過公開成員互動。

8.3. 異常處理 (Exceptions)

小訣竅

我們不使用 C++ 的異常處理機制。

優點:

  • 異常處理允許應用程式外層決定如何處理在底層巢狀函式中「不可發生」的失敗狀況,不需使用那些含糊不且難以管理的錯誤碼。

  • 很多現代語言都有異常處理機制。導入異常處理使得 C++ 與 Python、Java 以及其它人熟悉的 C++ 更一致。

  • 有些第三方 C++ 函式庫會用到異常處理,在內部將異常處理關閉的話,便難以整合這些函式庫。

  • 異常處理是建構子失敗時通知外界的唯一途徑。雖然可以用工廠函式或 Init() 函式代替異常,但前者需要在 heap 配置記憶體,後者需要新的「不可用」狀態。

  • 異常處理機制在測試框架中很好用。

缺點:

  • 在現有函式中添加 throw 語句時,你必須檢查所有與此函式有關的呼叫點。可以讓所有的呼叫點都具備基本異常處理安全性保證 (basic exception safety guarantee),或是所有的呼叫點都不得捕捉這個異常、然後接受這個異常終結整個程式。例如,f() 呼叫 g()g() 又呼叫 h(),且 h 丟出的異常被 f 捕獲,那麼 g 就必須小心處理這個狀況,否則可能會無法妥善清理資源。

  • 更常見的問題是,異常處理讓人難以透過閱讀程式碼了解程式的執行流程:函式可能會在你意料不到的地方返回。這讓維護和除錯的難度大增。你可以加上一些「何時何處可使用異常處理」的規定來降低風險,然而這麼一來開發者需要記憶了解的負擔又更重了。

  • 異常處理安全性需要 RAII 和不同的程式碼撰寫方式。要輕鬆編寫出正確的異常處理安全的程式碼需要大量工具的支援。除此之外,為了避免閱讀者需要理解整個呼叫關係,異常處理安全的程式碼必須將「寫入保存狀態 (writes to persistent state)」的邏輯獨立出來,變成單一的「提交」階段。這種作法有利有弊(也許你為了隔離出提交而不得不把程式碼搞得很亂)。如果允許使用異常處理,我們就不得不付出這些代價,就算有時得到不到多少好處。

  • 啟用異常處理會增加二進位檔案的資料量,延長編譯時間(或許影響不大),還可能增加定址空間的負擔。

  • 異常處理可以使用這件事,可能會鼓勵開發人員在不適常的時機丟出異常,或是在不安全的情況下修補異常狀態。例如,使用者的輸入不符合格式要求時,不需要丟出異常。要是將所有類似的限制都列出來,這份文章恐怕要變長不少。

結論:

從表面上看來,使用異常處理利大於弊,尤其是在新專案中。但是對於既存的程式碼,導入異常處理會影響到所有相關程式碼。如果新開的專案允許廣泛使用異常處理,在跟以前未使用異常處理的程式碼整合時也將是個麻煩。因為 Google 現有的大多數 C++ 程式碼都沒有考慮異常處理的狀況,導入帶有異常處理的新程式碼相對來說比較困難。

鑒於 Google 現有程式碼不接受例外,在現有程式碼中使用異常處理比在新專案中使用的代價多少要大一些。轉換過程比較慢,也容易出錯。我們相信現有能取代異常處理的替代方案(如錯誤碼、assertion 等)不會造成嚴重負擔。

我們反對使用異常處理的理由並不是基於哲學或道德層面,而是講究實踐的基礎。我們希望在 Google 使用我們開源專案的程式碼,但專案中使用異常處理會為此帶來不便,因此對於 Google 的開源專案中,我們也建議不要使用異常處理。如果我們能推翻一切全部重來的話,情況可能會有所不同。

這項禁令同樣適用於 C++11 中異常處理相關的功能,例如 std::exception_ptrstd::nested_exception

對於 Windows 程式碼來說,有一些 例外狀況

8.4. noexcept

小訣竅

若是加上 noexcept 有用且正確,就加吧。

定義:

noexcept 指示詞是用來說明函式是否會丟出異常。如果函式加上了 noexcept 卻丟出異常,整個程式就會透過 std::terminate 強制關閉。

noexcept 運算子會在編譯時期進行檢查,如果運算式宣告為「不會丟出異常」的話,結果就會是 true。

優點:

  • 將 move constructor 指定為 noexcept 在某些狀況下可以增進執行效率,例如:若是 T 的 move constructor 是 noexcept 的話,std::vector<T>::resize() 就會直接移動其中的物件,而不是用複製的。

  • 在啟用異常處理的環境中,將函式指定為 noexcept 話,編譯器就可以進行某些最佳化,例如:如果編譯器看到 noexcept 指示詞,表示該函式不會丟出異常狀態,那麼編譯器就不會為了實作 stack-unwinding(堆疊輾轉開解)而產生額外的程式碼。

缺點:

  • 如果專案依照本指南禁止使用異常處理的話,我們很難確認 noexcept 指示詞是否正確,甚至很難定義什麼叫「正確」。

  • 要移除 noexcept 是件很難的事(甚至可能做不到),因為這麼做等於是移除了呼叫者可能依賴的保證,而我們很難知道呼叫者是不是真的依賴這項前題去撰寫。

結論:

  • 當加上 noexcept 可以增進執行效率時,你可以使用 noexcept,但它必須要能正確地反映你的函式所要想表達的語意,也就是:如果這個函式內有任何的異常狀況被丟出來,那麼就代表發生致命錯誤 (fatal error)。你可以認為加上 noexcept 後 move constructors 的效率能有效提昇。對於其他的函式,如果你認為加上 noexcept 對效率有顯著的幫助,請先和專案的領導人討論是否可以加。

  • 在完全禁用異常處理的環境中(大部份 Google 的 C++ 環境都是),儘量使用沒有任何條件的 noexcept。如果真的要用有條件的 noexcept,條件應該要簡單,只有在非常少數函式有可能會丟出異常的情況下,條件式運算的結果才會是 false。測試的條件可能包含型別特徵 (type traits) 的檢查,看看使用到的行為是否會引發異常(例如有 move constructor 的物件是否為 std::is_nothrow_move_constructible),或是配置記憶體時是否會引發異常(例如標準預設的配置是否為 absl::default_allocator_is_nothrow)。要注意的是,在許多情況下,唯一可能會丟出異常狀態的就是配置失敗(我們相信除非配置失敗,否則 move constructors 應該不會產生異常狀態);而許多應用程式認為遇到記憶體不足的狀況時,最佳的處理方式就是直接引發「致命錯誤」,而非把它當成可能產生異常狀態的條件、讓你的程式嘗試解決這個問題。即使在面對其他可能的失敗狀況時,你也應該優先考慮「維持界面的簡潔」,而非「支援所有可能的異常狀態」。舉例來說:若是有某個 hash 函式可能會丟出異常,與其寫一堆複雜的 noexcept 條件式,不如直接使用無條件的 noexcept,然後註解說明你的元件不支援會丟出異常的 hash 函式。

8.5. 執行時期型別識別 (RTTI)

小訣竅

避免使用 RTTI。

定義:

RTTI 允許開發者在執行時期,利用 typeiddynamic_cast,識別物件的 C++ 類別。

缺點:

在執行時期頻繁判斷型別通常意味著設計上有問題。如果你需要在執行時期確定一個物件的型別,那表示你的類別階層架構是有問題的。

肆無忌憚地使用 RTTI 會讓程式碼難以維護。以型別作為條件的判斷式或者 switch 述句會四散在程式碼各處,如果以後想要修改,就必須要仔細檢驗每一個條件是否正確。

優點:

RTTI 的標準替代方案(後詳述)需要修改或重新設計相關的類別階層架構。這樣的工作有時候是做不到、或是不希望做的,特別是對已經被廣泛使用、或非常成熟的程式碼來說。

RTTI 在某些單元測試中非常有用。例如在測試進行工廠類別時,測試必須要確認產生出來的物件是否屬於期望的動態型別。對於管理物件及其模仿物件 (mocks) 的關係時也很有用。

RTTI 在處理多個抽象物件時也很好用。例如:

bool Base::Equal(Base* other) = 0;
bool Derived::Equal(Base* other) {
  Derived* that = dynamic_cast<Derived*>(other);
  if (that == nullptr)
    return false;
  ...
}

結論:

RTTI 有合理的用途但是容易被濫用,因此在使用時請務必小心。在單元測試中可以自由使用 RTTI,但是在其他程式碼中請儘量避免。特別是在撰寫新的程式碼時,請務必三思。如果你發現你的程式碼需要依據物件類別的不同而有不同行為的話,請考慮用以下兩種替代方案之一查詢物件的型別:

  • 想要依照物件的子類別執行不同程式碼,比較合理的作法是使用虛擬函式。讓物件本身去負責這份工作。

  • 如果這項工作不在物件之內,而在某些處理程序中,可以考慮使用 double-dispatch 的解決方案,像是訪問者 (Visitor) 設計模式。如此一來就可以讓物件本身之外的程式碼,使用語言內建的型別系統,得知物件的類別。

如果程式的邏輯能夠保證某個基礎類別的 instance 實際上是某個衍生類別的 instance,那麼就可以自由對此物件使用 dynamic_cast。在這種情況下,也可以使用 static_cast 做為替代方案。

以型別作為條件的判斷式是一項很強烈的指標,說明你的程式碼已經偏離正軌了。

if (typeid(*data) == typeid(D1)) {
  ...
} else if (typeid(*data) == typeid(D2)) {
  ...
} else if (typeid(*data) == typeid(D3)) {
...

一旦在類別階層加入新的子類別,這樣的程式碼往往會出問題。而且,一旦某個子類別的屬性改變了,你很難找到並修改所有受影響的程式碼。

不要自己實作一個類似 RTTI 的替代方案。反對 RTTI 的理由同樣適用於這些解決方案(像是帶有型別標籤的類別階層架構)。而且,替代方案會讓人無法了解你的實際意圖。

8.6. 轉型 (Casting)

小訣竅

使用 C++ 風格的轉型(如:static_cast<float>(double_value)),或是針對算數型別使用 {} 初始化來轉型(如:int64 y = int64{1} << 42)。不要使用以下這樣的轉型格式:int y = (int)xint y = int(x) (但若是用來喚起類別型別的建構式的話,可以使用後者)。

定義:

C++ 導入了有別於 C 的轉型系統,用以區別不同類型的轉型運算。

優點:

C 語言轉型語法的問題在於無法清楚說明要進行何種運算;有時候是要做 轉換 (conversion) (例如:(int)3.5),有時候是要做 轉型 (cast) (例如:(int)"hello")。{} 初始化和 C++ 的轉型運算子通常可以避開這種模稜兩可的狀況。此外,C++ 的轉型語法在程式碼中更加醒目,有助於搜尋。

缺點:

C++ 風格的轉型語法實在是太冗長、太笨重。

結論:

不要使用 C 語言風格的轉型。當需要明確指定型別轉換時,使用 C++ 風格的轉語型法。

  • 使用 {} 初始化語法進行算術型別的轉換(例如:int64{x})。這是最安全的方法,因為如果這樣的轉換會造成資訊流失,編譯器就會卡下來。同時這樣的語法也很簡潔。

  • static_cast 和 C 語言轉型的「數值轉換」行為相同。當你需要明確地將某個類別的指標「向上轉型」成為其基礎類別的指標時,或是當你需要明確地把指向基礎類別的指標轉型為子類別的指標時,可以使用 static_cast。(在後者的狀況,你必須確保指標所指向的物件一定是子類別的實例。)

  • 使用 const_cast 除去 const 限制。參考 const 用法

  • 使用 reinterpret_cast 在整數和其他指標型別間進行不安全的指標型別轉換。你必須清楚一切可能發生的後果、同時了解別名問題後,才能使用 reinterpret_cast

dynamic_cast 的使用規範請參考 執行時期型別識別 (RTTI)

8.7. 資料流 (Streams)

小訣竅

在適當的時機使用資料流,並且不要做太過複雜的應用。只有在型別是表示數值時才多載化處理資料流的 << 運算子,而且輸出資料僅限於使用者看得到的數值,不要輸出實作細節。

定義:

資料流是 C++ 標準的 I/O 抽象層,如標準標頭檔 <iostream> 所例示。這項機制廣泛地被用在 Google 的程式碼中,但只用來輸出除錯訊息和測試檢測。

優點:

<<>> 運算子提供了格式化 I/O 的 API,並且擁有易學、可移植、可重覆使用,以及方便擴充等優點。而相對的,printfstring 都不支援,更別說其他的使用者型別,同時也移植性也不佳。另外,你必須從功能略有不同的數個 printf 家族函式中選一個來用,還要去記一堆轉換指定詞 (conversion specifiers)。

資料流透過 std::cinstd::coutstd::cerrstd::clog 等物件,提供了第一級的 console I/O 支援。C 語言的 API 也能做到,但因為需要手動緩衝處理輸入,所以限制較多。

缺點:

  • 資料流的格式可以透過改變資料流的狀態來設定。這樣的改變是永久性的,因此你的程式碼的行為會被資料流的整個之前的歷史而影響,除非每當其他的程式碼有可能改變資料流的狀態時,你都用自己的方法把它恢復到你能預期的狀態。使用者的程式碼不只可以修改內建的狀態,還可以透過註冊系統新增狀態變數和行為。

  • 基於以下幾個原因,我們很難精準控制資料流的輸出結果:一是上面提到的問題,二是在資料流中控制用的程式碼和資料交雜,三是運算子可能被多載化(被選到的多載化版本可能跟你預期的不同)。

  • 使用一連串 << 運算子建立輸出的作法不利於軟體的國際化 (internationalization/i18n),因為單字的順序被寫死在程式碼中,而資料流對於本地化 (localization/l10n) 的支援又 不盡完美

  • 資料流的 API 既微妙又複雜,因此程式員必須要有一定的經驗才能有效率地使用它。

  • 要解析各種多載化版本的 << 對編譯器來說是極大的負擔。若是在有大量程式碼的專案中廣泛使用的話,花在語法和語意分析上的時間最多可能會增加 20%。

結論:

只有在資料流是你的最佳選擇時才用它。通常指的是這些 I/O 符合「點對點 (ad-hoc)、只在本地端、給人類閱讀、給開發人員而非一般使用者看」這樣的特性。記得要和同程式碼庫中同時使用的其他程式碼保持一致;如果已經有人針對你的問題建立了專屬的工具,優先選用專屬的工具。更精確來說:針對檢測的輸出,專門拿來產生日誌的函式庫應該是比 std::errstd::clog 更好的選擇;absl/strings 或其他類似品中的函式庫應該比 std::stringstream 更合用。

避免使用資料流處理會讓外部使用者接觸、或是無法信任的資料的 I/O。取而代之的是:尋找、使用適當的模版函式庫去處理這些問題,像是國際化、本地化,或增加安全性等等。

如果你真的要用資料流的話,避免使用會修改狀態(錯誤狀態除外)的那些資料流 API,例如 imbue()xalloc(),和 register_callback()。要控制格式的細節(例如:要用哪種進位法表示、精準度、補零補空格等)時,使用明確的格式化函式(參考 absl/strings 中的範例),不要用資料流操控器 (manipulators) 或是格式化旗標。

你可以多載化 << 當成是資料流用的運算子,但前提是你的型別必須是某種數值型別,而且 << 會以給人類閱讀的格式、輸出表示該型別數值的字串。避免使用 << 輸出內部實作細節相關的資訊;如果為了除錯,你真的需要印出物件內部的資訊,請寫成有名字的函式(常見的慣例是建立名為 DebugString() 的函式)。

8.8. 前置式遞增和遞減

小訣竅

在處理迭代器 (iterators) 和其他模板物件時,使用前置式(++i)的遞增/遞減運算子。

定義:

當變數遞增(++ii++)或遞減(--ii--),而此運算式的結果又沒有用到的情況下,我們需要決定到底是使用前置還是後置的遞增/遞減運算子。

優點:

當運算回傳值沒有被使用時,前置式(++i)的執行效率絕對不會比後置式(i++)差,而且通常會更好。這是因為後置式遞增/遞減需要複製一份 i 當成運算式的回傳結果。如果 i 是迭代器或其他非數值型別的物件,複製的成本可能很高。既然在不需要回傳結果的情況兩種遞增方式的行為一樣,何不統統使用前置式遞增呢?

缺點:

在 C 語言開發傳統上,就算運算式的結果不需被使用,還是會使用後置式遞增,尤其是在 for 迴圈中。有些人覺得後置式遞增可讀性更高,因為這跟英文文法接近:主詞(i)放在動詞(++)前面。

結論:

處理簡單數值變數(非物件)時,選用何者都無所謂。處理迭代器和模板型別時,使用前置式遞增。

8.9. const 用法

小訣竅

在任何合理的情況下使用 const。若使用的是 C++11,有時使用 constexprconst 更好。

定義:

在宣告的變數或參數前加上關鍵字 const 表示該變數不會被修改(例如 const int foo)。類別的成員函式加上 const 修飾詞表示該函式不會修改類別成員變數的狀態(例如 class Foo { int Bar(char c) const; };)。

優點:

讓閱讀程式者更容易理解程式如何使用變數。編譯器可以進行更好的型別檢查,而且可想而知,能產生更好的機器碼。讓程式員對程式碼的正確性更有自信,因為程式員能知道他們所呼叫的函式只修改某個範圍內的變數。讓程式員知道哪些函式即使不用鎖定機制也能安全地在多執行緒環境下執行。

缺點:

const 具有病毒傳播性:如果你將 const 變數傳入一個函式,那麼函式宣告時參數也必須是 const (或是變數必須透過 const_cast 轉型)。這個問題在呼叫函式庫的函式時會特別突顯出來。

結論:

變數、資料成員、函式和引數加上 const 後,能多加上一層編譯時期型別檢查增;讓錯誤能儘早被發現出來。因此,只要情況合理,我們強烈建議在任何可能的情況下使用 const

  • 如果一個函式保證不會修改一個 pass-by-reference 或 pass-by-pointer 的引數的話,那麼對應的函式參數就應該被宣告為 reference-to-const(const T&)或是 pointer-to-const(const T*)。

  • 儘可能將函式宣告為 const。存取函式應該均為 const。其他的成員函式,只要不會修改任何資料成員、未呼叫非 const 函式、不會回傳資料成員的「非 const 指標」或「非 const reference」的話,也應該宣告成 const

  • 如果資料成員在物件建構之後不再需要被修改,可以考慮將其定義為 const

關鍵字 mutable 可以使用,但在多執行緒環境中是不安全的,因此必須先要考慮執行緒安全性的問題。

放置 const 的位置:

有人喜歡 int const *foo 形式勝過 const int* foo。他們認為前者可讀性較高,因為原則一致:const 一律置於其描述的對象之後。但若是程式碼庫中沒有很多複雜的巢狀指標運算式的話,這個「一致性」的理由就不是這麼強列,因為大部份的 const 運算式都只有一個 const 修飾詞,套用在運算式中的值。在這個狀況下,就沒有什麼維護「一致性」的理由了。換個角度想,將 const 放在前面可讀性才高,因為在英文文法中形容詞(const)會放在名詞(int)之前。

也就是說,我們提倡但不強制把 const 放在前面。但要和前後的程式碼保持一致!

8.10. constexpr 用法

小訣竅

在 C++11 裡,用 constexpr 來定義真正的常數,或確保可用來初始化常數。

定義:

變數可以被宣告成 constexpr 以表示它是真正意義上的常數,即其數值在編譯/連結時就固定下來了。函式或建構子也可以被宣告成 constexpr,表示它們可以用來定義 constexpr 變數。

優點:

如今 constexpr 就可以定義浮點式的真・常數,不用再相依性字面值了;也可以定義使用者自定義類型上的常量;甚至也可以定義函式呼叫所返回的常量。

缺點:

過早為變數加上 constexpr,若是之後又要把它改為常規變數時,會遇上移轉上的問題。目前哪些函式和建構式可以加上 constexpr 的限制,可能會在這些函式定義時帶來晦澀不清的暫時解決方案。

結論:

利用 constexpr,可以為界面提供更可靠的常數機制。善用 constexpr 來定義真正的常數以及可以用來定義常數的函式。不要為了硬要用 constexpr 而把函式定義搞得太過複雜。不要用 constexpr 強制做 inlining。

8.11. 整數型別

小訣竅

在所有 C++ 的內建整數型別中,只允許使用 int。如果程式中需要使用大小不同的變數,可以使用 <stdint.h> 中指定長度的整數型別,如 int16_t。如果你的變數可能大於或等於 2^31 (2GiB),就使用 64 位元的型別,例如 int64_t。此外要留意,即使你的數值不會超出 int 所能夠表示的範圍,在計算過程中也可能會超過而需要使用更大的型別。如果不能確定的話,就選用較大的型別。

定義:

C++ 並未硬性規定整數型別(如 int)的大小。一般來說,大家會假設 short 的長度為 16 位元,int 為 32 位元,long 是 32 位元,而 long long 則是 64 位元。

優點:

宣告時保持一致性。

缺點:

C++ 中整數型別的大小因編譯器與系統架構而異。

結論:

<stdint.h> 定義了像是 int16_tuint32_tint64_t 等這樣的整數。在需要確保整數大小時可以使用它們,不要使用 shortunsigned long long 等型別。在 C 的整數型別中,只有 int 允許使用。在合適的情況下,歡迎你使用如 size_tptrdiff_t 等的標準型別。

如果已知整數不會太大,我們常常會使用 int,例如迴圈計數。在類似的情況下使用 int。你可以假設 int 的長度至少為 32 位元,但不要認為它會多於 32 位元。如果需要 64 位元的整數,使用 int64_tuint64_t

對於可能會很大的整數,使用 int64_t

不要使用 uint32_t 等無號 (unsigned) 整數,除非你想宣告的是一個位元欄位 (bitfield) 而不是一個完整的數值,或是你需要定義以 2^N 為模的溢位 (overflow modulo 2^N) 結果。特別是不要使用無號數型別來表達「這個數值不可能為負」。你應該要用 assertion 來確保這件事。

如果你的程式碼為會回傳大小的容器,確保回傳值的型別大小足以應付容器各種可能的使用情況。若是無法確定,就選用大一點的型別,不要用小的。

在整數型別間轉換時要特別小心。整數型別的轉換和提升 (promotion) 可能會導致未定義行為,造成安全性以及其他的問題。

關於無號整數:

無號整數很適合用來表示位元欄位以及進行同餘算數 (modular arithmetic)。因為歷史的因素,C++ 標準也使用無號整數來表示容器的大小。標準委員會中有許多成員也認為這麼做是錯的,但在現在這個時間點,我們沒有辦法有效地修正這個問題。無號數的算數所建立的不是單純整數運算的模型,而是依照同餘算數的標準模型而建立的(當發生溢位或下溢位 (underflow) 時,數字會從另一頭開始繼續下去)。這表示有某些非常明顯的 bugs 編譯器捉不出來。另外,這種算數定義的行為,也不利於執行碼的最佳化。

因此,將有號整數和無號整數混在一起運算,會造成不小的問題。我們能提供的最佳建議就是:儘量使用迭代器 (iterators) 和容器,避免使用指標和大小;儘量不要混用有號數和無號數;儘量避免使用無號型別(除非是想要宣告位元欄位或是進行同餘算數)。不要只是為了能確保變數一定不會是負數而使用無號型別。

8.12. 64 位元的可移植性

小訣竅

程式碼應該要考慮到 64 位元和 32 位元系統。要將處理印出、比較,以及結構對齊 (alignment) 時可能遇到的問題謹記在心。

  • 某些整數類 typedef 有可移植 printf() 轉換指示詞 (conversion specifiers) 是以巨集擴充的方式實作的(即 <cinttypes> 中以 PRI 開頭的一系列巨集)。我們認它這些巨集用起來很麻煩,而且也很難要求開發人員使用,因此如果有用掉的話,修正它們。除非你有特別需求找不到其他合理的替代方案,儘可能避免或甚至昇級會使用到 printf 系列函式的 API。請改用支援型別安全數值格式化的函式庫,像是 StrCatSubstitute,來做快速簡單的轉換,或是改用 std::ostream

    不幸的,對於帶有位元長度的標準 typedef(例如:int64_tuint64_tint32_tuint32_t 等等)來說,PRI 系列的巨集是唯一可以指定轉換、且具有移植性的方法。如果可能的話,避免將「用帶有位元長度的 typedef」指定型別的引數傳進 printf 系列的 API 中。不過某些 typedef 在 printf 中已經有固定長度的 modifier,例如 size_t (z)ptrdiff_t (t),和 maxint_t (j) 等,這些就可以用。

  • 記住:sizeof(void *) 不等於 sizeof(int)。如果需要一個表示指標大小的整數,要用 intptr_t

  • 你要非常小心的處理結構對齊的問題,尤其是要存入磁碟的結構。在 64 位元的系統中,任何含有 int64_t/uint64_t 成員的類別/結構,預設都會對齊到 8-byte 的邊界。如果你想將這樣的結構存入磁碟,然後在 32 位元和 64 位元的程式碼間共用的話,你需要確保在兩種架構下結構都對齊到一樣的邊界。大多數編譯器都提供了調整結構體對齊的方法。在 gcc 中可使用 __attribute__((packed))。MSVC 則提供了 #pragma pack()__declspec(align())

  • 使用 {} 初始化語法建立 64 位元的常數。例如:

    int64_t my_value{0x123456789};
    uint64_t my_mask{3ULL << 48};
    

8.13. 前置處理器巨集

小訣竅

避免定義巨集,特別是在標頭檔中;儘量改用 inline 函式、列舉 (enum),和 const 變數。在巨集前面加上專案專屬的前綴字。不要利用巨集去產生 C++ API 的定義。

巨集意味著你和編譯器看到的程式碼是不同的。這可能會導致無法預期的行為,尤其因為巨集具有全域作用域。

如果你拿巨集來產生 C++ API 的定義的話,那巨集所帶來的問題會更加嚴重。若是開發人員使用這些界面的方式有誤,那麼他必須要了解這些巨集是如何產生界面的,才能看懂編譯器丟出來的錯誤訊息。重構以及分析工具在更新這些界面時所需的時間也會大幅增加。因此,我們要特別指出:禁止使用巨集產生 C++ API 的定義。例如,避免產生類似這樣的程式碼:

class WOMBAT_TYPE(Foo) {
  // ...

 public:
  EXPAND_PUBLIC_WOMBAT_API(Foo)

  EXPAND_WOMBAT_COMPARISONS(Foo, ==, <)
};

幸好,C++ 和 C 語言不同,巨集並不是不可或缺的。與其用巨集將需要執行效率的程式碼在行內展開,不如使用 inline 函式。不要用巨集存放常數,改用 const 變數。不要用巨集「縮寫」名稱過長的變數,改用 reference。不要用巨集進行條件編譯,改用… 唔,請完全不要這麼做(當然防止標頭檔被重覆引入的 #define 保護是個例外)。這麼做會讓除錯變得非常困難。

巨集可以做一些其他技術無法實作的事情,在一些程式碼庫(尤其是底層的函式庫)中可以看到這些技巧。某些巨集的特別功能(例如字串化 stringifying、連接字串等等)無法在語言中找到合適的替代方案。但在使用巨集前,仔細考慮一下能不能不使用巨集達到同樣的目的。如果你需要使用巨集去定義界面,先詢問你的專案領導人,看是否能夠豁免這條規則。

以下所列的使用規範可以避開使用巨集帶來的問題;如果你要用巨集,請儘可能遵守:

  • 不要在 .h 檔中定義巨集。

  • 在要使用到的地方才 #define 巨集,用完後要立即 #undef

  • 如果遇想用的巨集名稱已經被用走了,不要直接 #undef 原有的巨集,選擇一個不會衝突的名稱。

  • 儘量不要使用展開後會產生「不完整的 C++ 程式碼結構」的巨集,或至少要用註解或文件詳細說明其行為。

  • 儘量不要用 ## 產生函式、類別和變數的名稱。

我們強烈不建議在標頭檔輸出巨集(也就是說:在標頭檔中定義巨集,但在該檔案結束前都沒有 #undef 這些巨集)。如果你真的要從標頭檔輸出巨集,它的名稱在全域範圍中必須是獨一無二的。要做到這件事,巨集的名稱開頭必須加上專案命名空間的名稱(但全部改成大寫)。

8.14. 0 和 nullptr/NULL

小訣竅

整數用 0,實數用 0.0,指標用 nullptr,字元用 '\0'

整數用 0,實數用 0.0

指標(記憶體位址的值)用 nullptr,可以確保型別安全。

若專案使用的是 C++03,建議使用 NULL 而不是 0。雖然這兩者效果一致,但對閱讀程式者來說,NULL 看起來比較像是指標,而且有些 C++ 的編譯器會以特別的方式定義 NULL,讓編譯器能產生有用的警告訊息。

'\0' 代表空字元。使用正確的型別對可以增進可讀性。

8.15. sizeof

小訣竅

儘可能用 sizeof(varname)變數名稱)代替 sizeof(type)型別)。

當你需要取得變數的大小時,使用 sizeof(varname)。當有人改變了變數的型別時,sizeof(varname) 會適當地更新。如果一段程式碼不涉及任何的變數,你可以使用 sizeof(type),例如管理來自外部或內部的資料格式,但又很難挑出有適合 C++ 型別的變數。

Struct data;
Struct data; memset(&data, 0, sizeof(data));
memset(&data, 0, sizeof(Struct));
if (raw_size < sizeof(int)) {
  LOG(ERROR) << "compressed record not big enough for count: " << raw_size;
  return false;
}

8.16. auto

小訣竅

若是型別名稱很冗長、不言而喻,或是不重要——而且對閱讀程式者來說,寫出來對理解程式碼也沒什麼幫助時,可以使用 auto 避免這些問題。若是使用清楚的型別宣告對可讀性有幫助的話,繼續使用。

優點:

  • C++ 的型別名稱有時候會又冗長又笨重,特別是牽渉到模板或命名空間的時候。

  • 當某個 C++ 的型別名稱在單一的宣告式或一小段程式碼中不斷地重覆出現時,這樣一直重覆的型別名稱對可讀性可能沒什麼幫助。

  • 有時候透過初始化運算式的結果來指定型別比較安全,因為這樣做可以避免產生預期之外的物件或型別轉換。

缺點:

有時候清楚地型別才會讓程式碼更乾淨,特別當變數初始化所需參考到的資訊宣告在很前面的地方時。例如下面的運算式:

auto foo = x.add_foo();
auto i = y.Find(key);

如果我們不知道 y 的型別,或是 y 的宣告在很多行前面時,運算結果的型別可能就不是那麼清楚。

程式員必須了解 autoconst auto& 有什麼不同,否則會在不需要複製物件的時候取得複製出來的物件。

如果在介面裡用到了 auto (例如宣告標頭檔裡的一個常數),那麼當程式員想要修改其值時,可能會不小心修改到它的型別,導致 API 產生不預期的大幅度變動。

結論:

我們允許使用 auto,只要用了對可讀性有幫助,特別是以下列出的那些狀況。絕對不要使用 {} 初始化列表去對型別為 auto 的變數進行初始化。

允許或鼓勵使用 auto 的情境:

  • 【鼓勵】迭代器和其他冗長、雜亂的型別名稱,特別是其型別從前後文(例如呼叫 findbegin,或 end 等)可以清楚得知時。

  • 【允許】當型別可以從附近的前後文(在同一條運算式中,或是前後數行)清楚推斷時。指標的初始化,或是透過 newstd::make_unique 初始化智慧型指標,或是在 range-based 迴圈中使用 auto 遍歷容器中的內容(型別從附近的前後文即可推斷)等,一般都屬於此類情境。

  • 【允許】當型別只用來比較是否相同外,沒有任何用處,顯得不重要時。

  • 【鼓勵】當我們在 range-based 迴圈中遍歷 map 容器時(因為通常我們會認為正確的型別是 std::pair<KeyType, ValueType>,但實際上應該是 std::pair<KeyType, ValueType>。在我們想要幫 .first.second (型別通常為 const-ref)建立區域別名 keyvalue 時特別有用。

    for (const auto& item : some_map) {
      const KeyType& key = item.first;
      const ValType& value = item.second;
      // 現在這個迴圈的以下部份可以使用 key 和 value 讀值,
      // 閱讀程式者可以看到我們的討論的型別,而且我們已經避開
      // 了許多人常犯的錯誤:在迭代中複製出不必要的物件。
    }
    

8.17. {} 初始化

小訣竅

你可以用 {} 初始化。

在 C++03 裡,聚合型別(aggregate types,即沒有建構式的陣列和結構)就已經可以使用 {} 初始化了。

struct Point { int x; int y; };
Point p = {1, 2};

C++11 中,這個語法得到進一步的推廣,任何物件型別都可以透過 {} 初始化建立,在 C++ 文法中被稱為 braced-init-list/初值列。以下為一些範例:

// Vector 接受了一個內含元素的初值列。
std::vector<string> v{"foo", "bar"};

// 基本上和前面一樣,除了一些技術上的細節。
// 你可以任選其一。
std::vector<string> v = {"foo", "bar"};

// 可以配合 new 一起用。
auto p = new std::vector<string>{"foo", "bar"};

// map 可以接受 pair 的列表。初值列可以寫成巢狀結構。
std::map<int, string> m = {{1, "one"}, {2, "2"}};

// 初值列可以隱式轉換成回傳型別。
std::vector<int> test_function() { return {1, 2, 3}; }

// 利用隱式轉換進行迭代。
for (int i : {-1, -2, -3}) {}

// 使用初值列呼叫函式。
void TestFunction2(std::vector<int> v) {}
TestFunction2({1, 2, 3});

使用者自定型別也可以定義接收 std::initializer_list<T> 的建構式和/或 assignment 運算子,這樣就可以透過初值列自動建立物件:

class MyType {
 public:
  // std::initializer_list 為其中初值列的 reference。
  // 應該要直接傳值。
  MyType(std::initializer_list<int> init_list) {
    for (int i : init_list) append(i);
  }
  MyType& operator=(std::initializer_list<int> init_list) {
    clear();
    for (int i : init_list) append(i);
  }
};
MyType m{2, 3, 5, 7};

最後,就算沒有定義接收 std::initializer_list<T> 的建構式,{} 初始化也會去呼叫資料型別的一般建構式。

double d{1.23};
// 只要 MyOtherType 沒有定義接收 std::initializer_list
// 的建構子,就會去呼叫一般的建構子。
class MyOtherType {
 public:
  explicit MyOtherType(string);
  MyOtherType(int, string);
};
MyOtherType m = {1, "b"};
// 不過如果建構式加上了 explicit,你就不能用 "= {}" 這種格式了。
MyOtherType m{"b"};

千萬不要將初值列 assign 給 auto 區域變數。如果初值列中只有一個數值,其代表的意義會讓人感到困惑:

auto d = {1.23};        // d 的型別會是 std::initializer_list<double>
auto d = double{1.23};  // 沒問題 -- d 的型別是 double,而非 std::initializer_list。

至於撰寫的格式,參見 {} 初值列格式

8.18. Lambda 運算式

小訣竅

在適當時機使用 lambda 運算式。若是 lambda 物件會離開現在的作用域的話,儘量使用顯式取得 (explicit capture)。

定義:

Lambda 運算式是建立匿名函式物件 (function object) 的一種簡明方式。通常在需要把函式當成引數傳遞的場合非常有用。舉例來說:

std::sort(v.begin(), v.end(), [](int x, int y) {
  return Weight(x) < Weight(y);
});

除此之外,lambda 還能從所在的作用域中取得 (capture) 變數,可以透過變數名稱進行顯式取得,或是利用預設的取得方式進行隱式取得。要進行顯式取得,必須把每個要取得的變數名稱列出來,然後再以 by-value 或是 by-reference 的方式取得。

int weight = 3;
int sum = 0;
// 以 by-value 方式取得 `weight`,以 by-reference 方式取得 `sum`。
std::for_each(v.begin(), v.end(), [weight, &sum](int x) {
  sum += weight * x;
});

預設的取得方式會隱式地取得所有在 lambda 本體中參考到的變數,包括 this (如果使用到任何成員的話):

const std::vector<int> lookup_table = ...;
std::vector<int> indices = ...;
// 以 by-reference 方式取得 `lookup_table`,然後再依照 `lookup_table`
// 中相關元素的值,對 `indices` 進行排序。
std::sort(indices.begin(), indices.end(), [&](int a, int b) {
  return lookup_table[a] < lookup_table[b];
});

Lambda 是在 C++11 引入的,同時引入了一系列處理函式物件的工具,例如多型的包裝器 (polymorphic wrapper) std::function

優點:

  • 想要定義傳遞給 STL 演算法的函式物件,使用 lambda 是最簡潔的做法,可以提高可讀性。

  • 適當地使用預設取得可以減少冗餘的程式碼,並可以從預設中突顯重要的例外情況。

  • 利用 lambda、std::function,以及 std::bind,我們可以組合出通用的 callback 機制;利用這些工具,我們可以很容易地寫出「以綁定的函式為引數」的函式。

缺點:

  • Lambda 運算式的變數取得功能可能會造成「懸置指標 (dangling-pointer)」的問題,特別是如果這個 lambda 會脫離目前的作用域的話。

  • 預設使用 by-value 的方式取得變數可能會讓人誤會可以防止懸置指標發生,但事實上卻不是如此。利用 by-value 的方式取得變數並不會進行「深度拷貝 (deep copy)」,因此用 by-reference 方式取得變數會發生的物件生命週期問題,用 by-value 一樣會發生。尤其是利用 by-value 的方式取得 『this』 時更容易讓人困擾,因為 『this』 的使用通常是隱式的。

  • Lambdas 在使用可能會失控;太多層的巢狀匿名函式會讓程式碼變得難以閱讀。

結論:

  • 適當地依底下的格式說明使用 lambda 運算式。

  • 如果 lambda 會脫離目前的作用域的話,儘量使用顯示取得。舉例來說,不要這樣寫:

    {
      Foo foo;
      ...
      executor->Schedule([&] { Frobnicate(foo); })
      ...
    }
    // 不良示範!
    // 如果閱讀程式者只有匆匆瞄過去的話,很難知道這個 lambda 有用到 `foo`,
    // 而且還可能用到 `this`(如果 `Frobnicate` 是成員函式的話)。
    // 如果在這個函式回傳後,這個 lambda 又被呼叫的話,事情會變得很麻煩,
    // 因為很可能 `foo` 和整個物件都已經被消滅了。
    

    改用這樣的寫法:

    {
      Foo foo;
      ...
      executor->Schedule([&foo] { Frobnicate(foo); })
      ...
    }
    // 較佳 - 如果 `Frobnicate` 是成員函式的話,編譯器不會給過,而且現在很明顯
    // 可以看到 `foo` 以 by-reference 的方式取得,會有危險。
    
  • 只有在 lambda 的生命週期明顯地短於任何可能取的變數時,才使用 by-reference 的預設取得(即 [&])。

  • 只有在 lambda 本身很短,綁定的變數很少,可以快速一眼望去就知道有哪些變數被取得時,才使用 by-value 的預設取得(即 [=])。若是使用 by-value 的預設取得,避免把 lambda 寫得太長或太複雜。

  • auto 一樣,如果把 lambda 的回傳型別明確地寫出來會讓閱讀者更清楚的話,就寫出來吧。

8.19. 模板超程式設計 (Template Metaprogramming)

小訣竅

避免使用複雜的模板超程式設計。

定義:

模板超程式設計是一系列技巧的組合。C++ 的模板具現化 (instantiation) 機制具有圖靈完備性 (Turing complete),因此可以在編譯時期於型別域 (type domian) 進行任意的運算。模板超程式設計即基於這樣的事實而進行。

優點:

模板超程式設計允許我們設計極有彈性的界面,同時兼具型別安全與高執行效率。如果沒有這樣的技術,就做不出如 Google Teststd::tuplestd::function,和 Boost.Spirit 等函式庫。

缺點:

模板超程式設計所使用到的技巧,對一般人來說十分晦澀難懂,除非你是 C++ 的專家。使用複雜的模板語法寫出來的程式碼通常難以閱讀,很難除錯、維護。

編譯模板超程式設計的程式碼時所產生出來的錯誤訊息通常十分糟糕:就算是一條簡單的界面,要是發生了什麼問題的話,產生的錯誤訊息會把所有複雜的實作細節都擺在你眼前。

編譯模板超程式設計不利於大規模的程式碼重構,因為重構工具在這樣的情況下很難運作。第一,模板在不同的脈胳下會以不同的方式展開,工具很難判斷這樣的轉換過程是否合理。第二,有些重構工具會依據抽象語法樹 (AST/Abstract Syntax Tree) 進行重構,但這 AST 所呈現的,是「模板展開後的程式碼」的結構。重構工具很難把那些需要重構的程式碼以原有的結構重新寫回。

結論:

和不使用這些技巧相比,編譯模板超程式設計有時候能讓界面更清楚、更容易使用,但有時候會忍不住變得太過聰明。最好只用在少量的低階元件,如此一來維護的成本才能被大量使用的次數攤平。

想要使用模板超程式設計或其他複雜的模板技巧前,請三思;先想想,如果你哪天改去處理別的事務,團隊中大部份的成員是否有能力了解、維護你的程式碼?或是有非 C++ 的程式員或隨便一個路人,哪天不經意地翻著你的程式碼,他是否有能力看懂錯誤訊息、或是追踪他們想要呼叫的函式的流程?如果你用到了遞迴模版具現化 (recursion template instantiations)、或是型別列別 (type lists)、或是超函式 (metafunctions)、或是運算式模板 (expression templates),或是依賴 SFINAE/sizeof 的技巧去判別現在使用了哪個版本的多載化函式,那就表示你很有可能太超過了。

如果你用到了模板超程式設計,你應該要考慮多花點精神,儘量縮小複雜的部份,並且和其他的程式碼隔離開來。你應該要儘可能地把超程式設計的部份藏在實作細節中,這樣使用者看得到的標頭檔才能清楚可讀,同時要確認使用了較高深技巧的程式碼要特別仔細地加上註解。你應該要小心地寫下程式碼要如何使用,然後你應該要稍微提一下「產生」出來的程式碼大概會長成什麼樣。多花點注意力在使用者犯錯時編譯器會丟出來的錯誤訊息。錯誤訊息也是你的「使用者界面」的一部份,你必須從使用者的角度出發,依需求仔細調校程式碼,使錯誤訊息清楚可理解,同時可以提示使用者該如何修正。

8.20. Boost 函式庫

小訣竅

只使用 Boost 函式庫中被認可的部份。

定義:

Boost 函式庫 是一個廣受歡迎、經過同儕審查、免費、開源的 C++ 函式庫總集。

優點:

一般來說,Boost 的程式碼品質很高,可移植性好,填補了 C++ 標準函式庫中很多的不足,像是型別特性 (type traits) 和更完善的綁定器。

缺點:

某些 Boost 函式庫提倡的程式撰寫方式可讀性差,比如超程式設計 (metaprogramming) 和其他進階的模板使用技巧,以及極度「函數化 (functional)」風格的程式撰寫方式。

結論:

為了能幫閱讀和維護程式碼的人員維持更佳的可讀性,我們只允許使用 Boost 中一部分經認可的函式庫。目前允許使用以下函式庫:

我們很積極地考慮增加其它 Boost 特性,所以未來列表可能會不斷擴充。

以下函式庫可以使用,但由於其功能已經被 C++ 11 標準函式庫取代,因此不再鼓勵使用:

8.21. std::hash

小訣竅

不要定義特化版本的 std::hash

定義:

std::hash<T> 是 C++11 中 hash 容器用來產生型別為 T 的 hash key 時所使用的 function object,除非使用者刻意指定不同的 hash 函式。舉例來說,std::unordered_map<int, string> 是使用 std::hash<int> 去產生 hash key 的 hash map,而 std::unordered_map<int, string, MyIntHash> 則是使用 MyIntHash 去產生 hash key。

std::hash 預設支援所有的整數型別、浮點數型別、指標、enum 型別,同時也支援某些標準函式庫中的型別,像是 stringunique_ptr。使用者若是希望 std::hash 支援自行定義的型別的話,可以為這些型別定義特化版本的 std::hash

優點:

std::hash 使用起來很簡單,而且也簡化了程式碼,因為你不需要特別幫它命名。特化 std::hash 是幫型別指定專屬 hash 函式的標準方法,所以外部有資源教你怎麼做,新進的工程師也會預想到這樣的行為。

缺點:

想要特化 std::hash 不是件簡單的事。有很多重覆而煩人的程式碼需要撰寫,更重要的是,它必須負責識別 hash 的輸入,同時負責執行 hashing 演算法本身。型別的作者必須負責前者,但後者需要型別作者通常不會有(也不需要有)的專業知識。這裡的風險很高,因為品質不夠高的 hash 函式可能會產生安全性的漏洞,因為有 hash 洪水攻擊 的存在。

即使是這方面的專家,想要為複合資料型別 (compound types) 實作正確的特化 std::hash 也是件很困難的工作,因為在實作中不能以遞迴方式對資料成員呼叫 std::hash。高品質的 hash 演算法會保存很多的內部狀態,而且「將 std::hash 回傳的 size_t 個 bytes 的狀態化簡」通常是最花計算時間的部份,因此這樣的計算不該重覆進行。

正因為上述的理由,std::hash 不支援 std::pair 或是 std::tuple,而且 C++ 也不允許我們擴充去支援這些型別。

結論:

只要是 std::hash 「出廠預設」支援的型別,你都可以使用,但不要特化去支援額外的型別。目前,如果你需要對某個特定的鍵值型別建立 hash table,考慮使用舊有的 hash 容器(例如 hash_map);這些舊有的容器使用不同的預設 hasher,不受這項禁令影響。

如果你還是想用標準的 hash 容器,你需要為鍵值型別指定一個客制化的 hasher,例如:

std::unordered_map<MyKeyType, Value, MyKeyTypeHasher> my_map;

先詢問型別的負責人是不是已經有既有的 hasher 可用;如果沒有的話,跟他們合作寫一個 hasher,或者自己動手做一個。

我們計畫提供一個可以搭配任意型別的 hash 函式,使用新的客製化機制,不會擁有 std::hash 的缺點。

8.22. C++11

小訣竅

在適當的時機可使用 C++11 的函式庫和語言擴充語法。在你的專案用 C++11 的功能前,請考慮是否能夠移植到其他的環境的可能性。

定義:

C++11 在語法和函式庫上都有 不小的變革

優點:

在 2014 年之前,C++11 是官方標準,大多數的 C++ 編譯器都支援這項標準。C++11 把某些我們已經在用的常見 C++ 延伸功能標準化,為某些作業提供了簡化的語法,而且還有一些性能和安全性方面的改善。

缺點:

C++11 和之前的標準相比變得非常複雜(規格書從 800 頁變成 1300 頁),許多開發者不怎麼熟悉。有些功能對程式碼的可讀性和維護性是好是壞,從長遠來看還很難說。新增的功能這麼多,我們很難預測我們想用的工具什麼時候才會全部支援某項功能,尤其是對那些必須使用老舊工具的專案來說更是個問題。

Boost 函式庫 一樣,有些 C++11 的延伸語法所提倡的程式撰寫方法可讀性不佳——例如移除已檢查過的冗餘資訊(像是型別名稱),但這些資訊對閱讀程式是有幫助的;或是鼓勵進行模板超程式設計等。此外,有些延伸功能其實用現有的機制就做得到,重覆定義這些功能可能導致混淆或是轉換的成本。

結論:

除非有特別指出禁止使用,大部份 C++11 的功能都可以使用。除了本指南中其他部份有提到的以外,下列的 C++11 功能不要使用:

  • 編譯時期有理數 <ratio>,因為它的界面風格太過仰賴模板程式設計。

  • <cfenv><fenv.h> 標頭檔,因為許多編譯器尚未提供可靠的實作版本。

8.23. 非標準的延伸功能

小訣竅

除非有特別說明,否則不得使用非標準的 C++ 延伸功能。

定義:

各家編譯器提供許 C++ 標準以外的延伸功能,包括了 GCC 的 __attribute__、內建函式 (intrinsic functions)(例如 __builtin_prefetch)、指定初始式 (designated initializers)(範例:Foo f = {.field = 3})、inline 組語、__COUNTER____PRETTY_FUNCTION__、複合述句運算式 (compound statement expressions)(範例:foo = ({ int x; Bar(&x); x }))、長度可變的陣列和 alloca(),以及「貓王運算子 a?:b」等等。

優點:

  • 非標準的延伸功能可能提供標準 C++ 中沒有的功能。舉例來說,有些人覺得指定初始式的可讀性比標準 C++ 的建構式等功能還要好。

  • 除了使用編譯器的延伸語法,沒有其他的方法可以把重要的運算效率指示資訊傳遞給編譯器。

缺點:

  • 非標準的延伸功能不一定所有的編譯器都支援。使用非標準的延伸功能會降低可移植性。

  • 就算所有目標編譯器都支援,這些延伸功能通常不會定義得很詳盡,不同的編譯器之間在實作上可能會有些微的不同。

  • 對於加到語言功能中的非標準延伸功能,閱讀程式者必須要了解這些功能,才能看懂程式碼。

結論:

不要使用非標準的延伸功能。你可以用非標準的延伸功能實作可移植的包裝函式,同時透過指定的專案移植性標頭檔提供這些包裝函式。

8.24. 別名 (Aliases)

小訣竅

公開的別名對 API 的使用者有利,使用時應該要明確地註解說明。

定義:

有幾種方法可以建立項目別名的名稱:

typedef Foo Bar;
using Bar = Foo;
using other_namespace::Foo;

對於新增的程式碼來說,我們建議使用 using,少用 typedef,因為前者的語法和其他 C++ 的程式碼更具有一致性,而且和模板語法相容。

和其他的宣告式一樣,宣告在標頭檔中的別名是標頭檔公開 API 的一部份,除非你宣告在函式中、宣告在類別的 private 的區間中,或是宣告在明確標示為內部使用的命名空間中。宣告在上述非公開區域或是在 .cc 檔案中的別名,屬於實作細節(因為客戶端的程式碼無法存取這些別名),使用上不受這條規的限制。

優點:

  • 使用別名將很長或很複雜的名稱簡化,可以增加可讀性。

  • 幫 API 中某處常用的型別取別名,可以減少重覆的程式碼,之後如果要更換型別,也許 會方便一些。

缺點:

  • 當別名放在客戶的程式碼可以接觸到的標頭檔時,別名會增加標頭檔的 API 中的名稱項目,讓標頭檔更加複雜。

  • 客戶端程式很可能使用到公開別名中原本不打算公開的細節,使得程式碼變得很難修改。

  • 很有可能為了貪圖方便,就把只會在實作處用到的別名宣告為公開,完全沒有考慮到這對 API 本身或是後續維護上所帶來的衝擊。

  • 別名增加了撞名 (name collisions) 的風險。

  • 若是為大家熟知的名稱取上大家都不熟悉的別名,反而會降低可讀性。

  • 型別別名可能讓 API 合約 (contract) 模糊不清:我們不清楚這個別名到底是保證和原本的型別完全一致,還是擁有相同的 API,或者只限於在某些更狹隘的範圍中使用。

結論:

不要只是為了在實作中少打一些字,就把別名放在公開的 API 中;只有在你想要讓客戶端程式碼使用時,才把別名放在公開 API 中。

在定義公開的別名時,要清楚地留下文件說明新名稱的意圖,包括是否保證別名和原本的型別永遠一致,或是是否希望有更多限制的相容性。這些說明讓使用者能知道他們是否可以將別名視為原本型別的替代品,或者有更多的規則必須遵守。這同時對實作也有幫助,因為保留了在某種程度內修改別名的自由。

不要將命名空間的別名放在公開 API 中(請參閱 命名空間 (Namespaces))。

舉例來說,這些別名的註解說明了客戶端的程式碼應該要怎麼使用這些別名:

namespace mynamespace {
// Used to store field measurements. DataPoint may change from Bar* to some internal type.
// Client code should treat it as an opaque pointer.
using DataPoint = foo::Bar*;

// A set of measurements. Just an alias for user convenience.
using TimeSeries = std::unordered_set<DataPoint, std::hash<DataPoint>, DataPointComparator>;
}  // namespace mynamespace

這些別名沒有明確的註解說明該怎麼用,而且其中有一半不是給客戶端程式使用的:

namespace mynamespace {
// 不好:沒有說明應該要怎麼用。
using DataPoint = foo::Bar*;
using std::unordered_set;  // 不好:只是為了方便在區域內使用的別名
using std::hash;           // 不好:只是為了方便在區域內使用的別名
typedef unordered_set<DataPoint, hash<DataPoint>, DataPointComparator> TimeSeries;
}  // namespace mynamespace

不過在函式定義內、類別的 private 區間、明確標明的內部使用命名空間,和 .cc 檔案中,為了方便,使用區域別名是沒有問題的:

// 在 .cc 檔案中
using foo::Bar;