Google C++ 程式設計風格指南:其他 C++ 特性

readthedocs發表於2017-02-03

5.1. 引用引數

所有按引用傳遞的引數必須加上 const.

定義:

在 C 語言中, 如果函式需要修改變數的值, 引數必須為指標, 如 int foo(int *pval). 在 C++ 中, 函式還可以宣告引用引數: int foo(int &val).

優點:

定義引用引數防止出現 (*pval)++ 這樣醜陋的程式碼. 像拷貝建構函式這樣的應用也是必需的. 而且更明確, 不接受 NULL 指標.

缺點:

容易引起誤解, 因為引用在語法上是值變數卻擁有指標的語義.

結論:

函式引數列表中, 所有引用引數都必須是 const:

void Foo(const string &in, string *out);

事實上這在 Google Code 是一個硬性約定: 輸入引數是值參或 const 引用, 輸出引數為指標. 輸入引數可以是 const 指標, 但決不能是非 const 的引用引數,除非用於交換,比如 swap().

有時候,在輸入形參中用 const T* 指標比 const T& 更明智。比如:

  • 您會傳 null 指標。
  • 函式要把指標或對地址的引用賦值給輸入形參。

總之大多時候輸入形參往往是 const T&. 若用 const T* 說明輸入另有處理。所以若您要用 const T*, 則應有理有據,否則會害得讀者誤解。

5.2. 右值引用

只在定義移動建構函式與移動賦值操作時使用右值引用. 不要使用 std::forward.

定義:

右值引用是一種只能繫結到臨時物件的引用的一種, 其語法與傳統的引用語法相似. 例如, void f(string&& s); 宣告瞭一個其引數是一個字串的右值引用的函式.

優點:

用於定義移動建構函式 (使用類的右值引用進行構造的函式) 使得移動一個值而非拷貝之成為可能. 例如, 如果 v1 是一個 vector<string>, 則 auto v2(std::move(v1)) 將很可能不再進行大量的資料複製而只是簡單地進行指標操作, 在某些情況下這將帶來大幅度的效能提升.

右值引用使得編寫通用的函式封裝來轉發其引數到另外一個函式成為可能, 無論其引數是否是臨時物件都能正常工作.

右值引用能實現可移動但不可拷貝的型別, 這一特性對那些在拷貝方面沒有實際需求, 但有時又需要將它們作為函式引數傳遞或塞入容器的型別很有用.

要高效率地使用某些標準庫型別, 例如 std::unique_ptrstd::move 是必需的.

缺點:

右值引用是一個相對比較新的特性 (由 C++11 引入), 它尚未被廣泛理解. 類似引用崩潰, 移動建構函式的自動推導這樣的規則都是很複雜的.

結論:

只在定義移動建構函式與移動賦值操作時使用右值引用, 不要使用 std::forward 功能函式. 你可能會使用 std::move 來表示將值從一個物件移動而不是複製到另一個物件.

5.3. 函式過載

若要用好函式過載,最好能讓讀者一看呼叫點(call site)就胸有成竹,不用花心思猜測呼叫的過載函式到底是哪一種。該規則適用於建構函式。

定義:

你可以編寫一個引數型別為 const string& 的函式, 然後用另一個引數型別為 const char* 的函式過載它:

class MyClass {
    public:
    void Analyze(const string &text);
    void Analyze(const char *text, size_t textlen);
};

優點:

通過過載引數不同的同名函式, 令程式碼更加直觀. 模板化程式碼需要過載, 同時為使用者帶來便利.

缺點:

如果函式單單靠不同的引數型別而過載(acgtyrant 注:這意味著引數數量不變),讀者就得十分熟悉 C++ 五花八門的匹配規則,以瞭解匹配過程具體到底如何。另外,當派生類只過載了某個函式的部分變體,繼承語義容易令人困惑。

結論:

如果您打算過載一個函式, 可以試試改在函式名里加上引數資訊。例如,用 AppendString() 和 AppendInt() 等, 而不是一口氣過載多個 Append().

5.4. 預設引數

我們不允許使用預設函式引數,少數極端情況除外。儘可能改用函式過載。

優點:

當您有依賴預設引數的函式時,您也許偶爾會修改修改這些預設引數。通過預設引數,不用再為個別情況而特意定義一大堆函式了。與函式過載相比,預設引數語法更為清晰,程式碼少,也很好地區分了「必選引數」和「可選引數」。

缺點:

預設引數會干擾函式指標,害得後者的函式簽名(function signature)往往對不上所實際要呼叫的函式簽名。即在一個現有函式新增預設引數,就會改變它的型別,那麼呼叫其地址的程式碼可能會出錯,不過函式過載就沒這問題了。此外,預設引數會造成臃腫的程式碼,畢竟它們在每一個呼叫點(call site)都有重複(acgtyrant 注:我猜可能是因為呼叫函式的程式碼表面上看來省去了不少引數,但編譯器在編譯時還是會在每一個呼叫程式碼裡統統補上所有預設實參資訊,造成大量的重複)。函式過載正好相反,畢竟它們所謂的「預設引數」只會出現在函式定義裡。

結論:

由於缺點並不是很嚴重,有些人依舊偏愛預設引數勝於函式過載。所以除了以下情況,我們要求必須顯式提供所有引數(acgtyrant 注:即不能再通過預設引數來省略引數了)。

其一,位於 .cc 檔案裡的靜態函式或匿名空間函式,畢竟都只能在區域性檔案裡呼叫該函式了。

其二,可以在建構函式裡用預設引數,畢竟不可能取得它們的地址。

其三,可以用來模擬變長陣列。

// 通過空 AlphaNum 以支援四個形參
string StrCat(const AlphaNum &a,
              const AlphaNum &b = gEmptyAlphaNum,
              const AlphaNum &c = gEmptyAlphaNum,
              const AlphaNum &d = gEmptyAlphaNum);

5.5. 變長陣列和 alloca()

我們不允許使用變長陣列和 alloca().

優點:

變長陣列具有渾然天成的語法. 變長陣列和 alloca() 也都很高效.

缺點:

變長陣列和 alloca() 不是標準 C++ 的組成部分. 更重要的是, 它們根據資料大小動態分配堆疊記憶體, 會引起難以發現的記憶體越界 bugs: “在我的機器上執行的好好的, 釋出後卻莫名其妙的掛掉了”.

結論:

改用更安全的分配器(allocator),就像 std::vector 或 std::unique_ptr<T[]>.

5.6. 友元

我們允許合理的使用友元類及友元函式.

通常友元應該定義在同一檔案內, 避免程式碼讀者跑到其它檔案查詢使用該私有成員的類. 經常用到友元的一個地方是將 FooBuilder 宣告為 Foo 的友元, 以便 FooBuilder 正確構造 Foo 的內部狀態, 而無需將該狀態暴露出來. 某些情況下, 將一個單元測試類宣告成待測類的友元會很方便.

友元擴大了 (但沒有打破) 類的封裝邊界. 某些情況下, 相對於將類成員宣告為 public, 使用友元是更好的選擇, 尤其是如果你只允許另一個類訪問該類的私有成員時. 當然, 大多數類都只應該通過其提供的公有成員進行互操作.

5.7. 異常

我們不使用 C++ 異常.

優點:

  • 異常允許應用高層決定如何處理在底層巢狀函式中「不可能發生」的失敗(failures),不用管那些含糊且容易出錯的錯誤程式碼(acgtyrant 注:error code, 我猜是C語言函式返回的非零 int 值)。
  • 很多現代語言都用異常。引入異常使得 C++ 與 Python, Java 以及其它類 C++ 的語言更一脈相承。
  • 有些第三方 C++ 庫依賴異常,禁用異常就不好用了。
  • 異常是處理建構函式失敗的唯一途徑。雖然可以用工廠函式(acgtyrant 注:factory function, 出自 C++ 的一種設計模式,即「簡單工廠模式」)或 Init() 方法代替異常, 但是前者要求在堆疊分配記憶體,後者會導致剛建立的例項處於 ”無效“ 狀態。
  • 在測試框架裡很好用。

缺點:

  • 在現有函式中新增 throw 語句時,您必須檢查所有呼叫點。要麼讓所有呼叫點統統具備最低限度的異常安全保證,要麼眼睜睜地看異常一路歡快地往上跑,最終中斷掉整個程式。舉例,f() 呼叫 g()g() 又呼叫 h(), 且 h 丟擲的異常被 f 捕獲。當心 g, 否則會沒妥善清理好。
  • 還有更常見的,異常會徹底擾亂程式的執行流程並難以判斷,函式也許會在您意料不到的地方返回。您或許會加一大堆何時何處處理異常的規定來降低風險,然而開發者的記憶負擔更重了。
  • 異常安全需要RAII和不同的編碼實踐. 要輕鬆編寫出正確的異常安全程式碼需要大量的支援機制. 更進一步地說, 為了避免讀者理解整個呼叫表, 異常安全必須隔絕從持續狀態寫到 “提交” 狀態的邏輯. 這一點有利有弊 (因為你也許不得不為了隔離提交而混淆程式碼). 如果允許使用異常, 我們就不得不時刻關注這樣的弊端, 即使有時它們並不值得.
  • 啟用異常會增加二進位制檔案資料,延長編譯時間(或許影響小),還可能加大地址空間的壓力。
  • 濫用異常會變相鼓勵開發者去捕捉不合時宜,或本來就已經沒法恢復的「偽異常」。比如,使用者的輸入不符合格式要求時,也用不著拋異常。如此之類的偽異常列都列不完。

結論:

從表面上看來,使用異常利大於弊, 尤其是在新專案中. 但是對於現有程式碼, 引入異常會牽連到所有相關程式碼. 如果新專案允許異常向外擴散, 在跟以前未使用異常的程式碼整合時也將是個麻煩. 因為 Google 現有的大多數 C++ 程式碼都沒有異常處理, 引入帶有異常處理的新程式碼相當困難.

鑑於 Google 現有程式碼不接受異常, 在現有程式碼中使用異常比在新專案中使用的代價多少要大一些. 遷移過程比較慢, 也容易出錯. 我們不相信異常的使用有效替代方案, 如錯誤程式碼, 斷言等會造成嚴重負擔.

我們並不是基於哲學或道德層面反對使用異常, 而是在實踐的基礎上. 我們希望在 Google 使用我們自己的開源專案, 但專案中使用異常會為此帶來不便, 因此我們也建議不要在 Google 的開源專案中使用異常. 如果我們需要把這些專案推倒重來顯然不太現實.

對於 Windows 程式碼來說, 有個 特例.

(YuleFox 注: 對於異常處理, 顯然不是短短几句話能夠說清楚的, 以建構函式為例, 很多 C++ 書籍上都提到當構造失敗時只有異常可以處理, Google 禁止使用異常這一點, 僅僅是為了自身的方便, 說大了, 無非是基於軟體管理成本上, 實際使用中還是自己決定)

5.8. 執行時型別識別

TODO

我們禁止使用 RTTI.

定義:

RTTI 允許程式設計師在執行時識別 C++ 類物件的型別. 它通過使用 typeid 或者 dynamic_cast 完成.

優點:

RTTI 的標準替代 (下面將描述) 需要對有問題的類層級進行修改或重構. 有時這樣的修改並不是我們所想要的, 甚至是不可取的, 尤其是在一個已經廣泛使用的或者成熟的程式碼中.

RTTI 在某些單元測試中非常有用. 比如進行工廠類測試時, 用來驗證一個新建物件是否為期望的動態型別. RTTI 對於管理物件和派生物件的關係也很有用.

在考慮多個抽象物件時 RTTI 也很好用. 例如:

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

缺點:

在執行時判斷型別通常意味著設計問題. 如果你需要在執行期間確定一個物件的型別, 這通常說明你需要考慮重新設計你的類.

隨意地使用 RTTI 會使你的程式碼難以維護. 它使得基於型別的判斷樹或者 switch 語句散佈在程式碼各處. 如果以後要進行修改, 你就必須檢查它們.

結論:

RTTI 有合理的用途但是容易被濫用, 因此在使用時請務必注意. 在單元測試中可以使用 RTTI, 但是在其他程式碼中請儘量避免. 尤其是在新程式碼中, 使用 RTTI 前務必三思. 如果你的程式碼需要根據不同的物件型別執行不同的行為的話, 請考慮用以下的兩種替代方案之一查詢型別:

虛擬函式可以根據子類型別的不同而執行不同程式碼. 這是把工作交給了物件本身去處理.

如果這一工作需要在物件之外完成, 可以考慮使用雙重分發的方案, 例如使用訪問者設計模式. 這就能夠在物件之外進行型別判斷.

如果程式能夠保證給定的基類例項實際上都是某個派生類的例項, 那麼就可以自由使用 dynamic_cast. 在這種情況下, 使用 dynamic_cast 也是一種替代方案.

基於型別的判斷樹是一個很強的暗示, 它說明你的程式碼已經偏離正軌了. 不要像下面這樣:

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

一旦在類層級中加入新的子類, 像這樣的程式碼往往會崩潰. 而且, 一旦某個子類的屬性改變了, 你很難找到並修改所有受影響的程式碼塊.

不要去手工實現一個類似 RTTI 的方案. 反對 RTTI 的理由同樣適用於這些方案, 比如帶型別標籤的類繼承體系. 而且, 這些方案會掩蓋你的真實意圖.

5.9. 型別轉換

使用 C++ 的型別轉換, 如 static_cast<>(). 不要使用 int y = (int)x 或 int y = int(x) 等轉換方式;

定義:

C++ 採用了有別於 C 的型別轉換機制, 對轉換操作進行歸類.

優點:

C 語言的型別轉換問題在於模稜兩可的操作; 有時是在做強制轉換 (如 (int)3.5), 有時是在做型別轉換 (如 (int)"hello"). 另外, C++ 的型別轉換在查詢時更醒目.

缺點:

噁心的語法.

結論:

不要使用 C 風格型別轉換. 而應該使用 C++ 風格.

  • 用 static_cast 替代 C 風格的值轉換, 或某個類指標需要明確的向上轉換為父類指標時.
  • 用 const_cast 去掉 const 限定符.
  • 用 reinterpret_cast 指標型別和整型或其它指標之間進行不安全的相互轉換. 僅在你對所做一切瞭然於心時使用.

至於 dynamic_cast 參見 5.8. 執行時型別識別.

5.10. 流

只在記錄日誌時使用流.

定義:

流用來替代 printf() 和 scanf().

優點:

有了流, 在列印時不需要關心物件的型別. 不用擔心格式化字串與引數列表不匹配 (雖然在 gcc 中使用 printf 也不存在這個問題). 流的構造和解構函式會自動開啟和關閉對應的檔案.

缺點:

流使得 pread() 等功能函式很難執行. 如果不使用 printf 風格的格式化字串, 某些格式化操作 (尤其是常用的格式字串 %.*s) 用流處理效能是很低的. 流不支援字串操作符重新排序 (%1s), 而這一點對於軟體國際化很有用.

結論:

不要使用流, 除非是日誌介面需要. 使用 printf 之類的代替.

使用流還有很多利弊, 但程式碼一致性勝過一切. 不要在程式碼中使用流.

擴充討論:

對這一條規則存在一些爭論, 這兒給出點深層次原因. 回想一下唯一性原則 (Only One Way): 我們希望在任何時候都只使用一種確定的 I/O 型別, 使程式碼在所有 I/O 處都保持一致. 因此, 我們不希望使用者來決定是使用流還是 printf + read/write. 相反, 我們應該決定到底用哪一種方式. 把日誌作為特例是因為日誌是一個非常獨特的應用, 還有一些是歷史原因.

流的支持者們主張流是不二之選, 但觀點並不是那麼清晰有力. 他們指出的流的每個優勢也都是其劣勢. 流最大的優勢是在輸出時不需要關心列印物件的型別. 這是一個亮點. 同時, 也是一個不足: 你很容易用錯型別, 而編譯器不會報警. 使用流時容易造成的這類錯誤:

cout << this;   // 輸出地址
cout << *this;  // 輸出值

由於 << 被過載, 編譯器不會報錯. 就因為這一點我們反對使用操作符過載.

有人說 printf 的格式化醜陋不堪, 易讀性差, 但流也好不到哪兒去. 看看下面兩段程式碼吧, 實現相同的功能, 哪個更清晰?

cerr << "Error connecting to '" << foo->bar()->hostname.first
     << ":" << foo->bar()->hostname.second << ": " << strerror(errno);

fprintf(stderr, "Error connecting to '%s:%u: %s",
        foo->bar()->hostname.first, foo->bar()->hostname.second,
        strerror(errno));

你可能會說, “把流封裝一下就會比較好了”, 這兒可以, 其他地方呢? 而且不要忘了, 我們的目標是使語言更緊湊, 而不是新增一些別人需要學習的新裝備.

每一種方式都是各有利弊, “沒有最好, 只有更適合”. 簡單性原則告誡我們必須從中選擇其一, 最後大多數決定採用 printf + read/write.

5.11. 前置自增和自減

對於迭代器和其他模板物件使用字首形式 (++i) 的自增, 自減運算子.

定義:

對於變數在自增 (++i 或 i++) 或自減 (--i 或 i--) 後表示式的值又沒有沒用到的情況下, 需要確定到底是使用前置還是後置的自增 (自減).

優點:

不考慮返回值的話, 前置自增 (++i) 通常要比後置自增 (i++) 效率更高. 因為後置自增 (或自減) 需要對錶達式的值 i 進行一次拷貝. 如果 i 是迭代器或其他非數值型別, 拷貝的代價是比較大的. 既然兩種自增方式實現的功能一樣, 為什麼不總是使用前置自增呢?

缺點:

在 C 開發中, 當表示式的值未被使用時, 傳統的做法是使用後置自增, 特別是在 for 迴圈中. 有些人覺得後置自增更加易懂, 因為這很像自然語言, 主語 (i) 在謂語動詞 (++) 前.

結論:

對簡單數值 (非物件), 兩種都無所謂. 對迭代器和模板型別, 使用前置自增 (自減).

5.12. const 用法

我們強烈建議你在任何可能的情況下都要使用 const. 此外有時改用 C++11 推出的 constexpr 更好。

定義:

在宣告的變數或引數前加上關鍵字 const 用於指明變數值不可被篡改 (如 const int foo ). 為類中的函式加上 const 限定符表明該函式不會修改類成員變數的狀態 (如 class Foo { int Bar(char c) const; };).

優點:

大家更容易理解如何使用變數. 編譯器可以更好地進行型別檢測, 相應地, 也能生成更好的程式碼. 人們對編寫正確的程式碼更加自信, 因為他們知道所呼叫的函式被限定了能或不能修改變數值. 即使是在無鎖的多執行緒程式設計中, 人們也知道什麼樣的函式是安全的.

缺點:

const 是入侵性的: 如果你向一個函式傳入 const 變數, 函式原型宣告中也必須對應 const 引數 (否則變數需要 const_cast 型別轉換), 在呼叫庫函式時顯得尤其麻煩.

結論:

const 變數, 資料成員, 函式和引數為編譯時型別檢測增加了一層保障; 便於儘早發現錯誤. 因此, 我們強烈建議在任何可能的情況下使用 const:

  • 如果函式不會修改傳你入的引用或指標型別引數, 該引數應宣告為 const.
  • 儘可能將函式宣告為 const. 訪問函式應該總是 const. 其他不會修改任何資料成員, 未呼叫非 const 函式, 不會返回資料成員非 const 指標或引用的函式也應該宣告成 const.
  • 如果資料成員在物件構造之後不再發生變化, 可將其定義為 const.

然而, 也不要發了瘋似的使用 const. 像 const int * const * const x; 就有些過了, 雖然它非常精確的描述了常量 x. 關注真正有幫助意義的資訊: 前面的例子寫成 const int** x 就夠了.

關鍵字 mutable 可以使用, 但是在多執行緒中是不安全的, 使用時首先要考慮執行緒安全.

const 的位置:

有人喜歡 int const *foo 形式, 不喜歡 const int* foo, 他們認為前者更一致因此可讀性也更好: 遵循了 const 總位於其描述的物件之後的原則. 但是一致性原則不適用於此, “不要過度使用” 的宣告可以取消大部分你原本想保持的一致性. 將 const 放在前面才更易讀, 因為在自然語言中形容詞 (const) 是在名詞 (int) 之前.

這是說, 我們提倡但不強制 const 在前. 但要保持程式碼的一致性! (Yang.Y 注: 也就是不要在一些地方把 const 寫在型別前面, 在其他地方又寫在後面, 確定一種寫法, 然後保持一致.)

5.13. constexpr 用法

在 C++11 裡,用 constexpr 來定義真正的常量,或實現常量初始化。

定義:

變數可以被宣告成 constexpr 以表示它是真正意義上的常量,即在編譯時和執行時都不變。函式或建構函式也可以被宣告成 constexpr, 以用來定義 constexpr 變數。

優點:

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

缺點:

若過早把變數優化成 constexpr 變數,將來又要把它改為常規變數時,挺麻煩的;當前對constexpr函式和建構函式中允許的限制可能會導致這些定義中解決的方法模糊。

結論:

靠 constexpr 特性,方才實現了 C++ 在介面上打造真正常量機制的可能。好好用 constexpr 來定義真・常量以及支援常量的函式。避免複雜的函式定義,以使其能夠與constexpr一起使用。 千萬別痴心妄想地想靠 constexpr 來強制程式碼「內聯」。

5.14. 整型

C++ 內建整型中, 僅使用 int. 如果程式中需要不同大小的變數, 可以使用 <stdint.h> 中長度精確的整型, 如 int16_t.如果您的變數可能不小於 2^31 (2GiB), 就用 64 位變數比如 int64_t. 此外要留意,哪怕您的值並不會超出 int 所能夠表示的範圍,在計算過程中也可能會溢位。所以拿不準時,乾脆用更大的型別。

定義:

C++ 沒有指定整型的大小. 通常人們假定 short 是 16 位, int 是 32 位, long 是 32 位, long long是 64 位.

優點:

保持宣告統一.

缺點:

C++ 中整型大小因編譯器和體系結構的不同而不同.

結論:

<stdint.h> 定義了 int16_tuint32_tint64_t 等整型, 在需要確保整型大小時可以使用它們代替 shortunsigned long long 等. 在 C 整型中, 只使用 int. 在合適的情況下, 推薦使用標準型別如 size_t 和 ptrdiff_t.

如果已知整數不會太大, 我們常常會使用 int, 如迴圈計數. 在類似的情況下使用原生型別 int. 你可以認為 int 至少為 32 位, 但不要認為它會多於 32 位. 如果需要 64 位整型, 用 int64_t 或 uint64_t.

對於大整數, 使用 int64_t.

不要使用 uint32_t 等無符號整型, 除非你是在表示一個位組而不是一個數值, 或是你需要定義二進位制補碼溢位. 尤其是不要為了指出數值永不會為負, 而使用無符號型別. 相反, 你應該使用斷言來保護資料.

如果您的程式碼涉及容器返回的大小(size),確保其型別足以應付容器各種可能的用法。拿不準時,型別越大越好。

小心整型型別轉換和整型提升(acgtyrant 注:integer promotions, 比如 int 與 unsigned int 運算時,前者被提升為 unsigned int 而有可能溢位),總有意想不到的後果。

關於無符號整數:

有些人, 包括一些教科書作者, 推薦使用無符號型別表示非負數. 這種做法試圖達到自我文件化. 但是, 在 C 語言中, 這一優點被由其導致的 bug 所淹沒. 看看下面的例子:

for (unsigned int i = foo.Length()-1; i >= 0; --i) ...

上述迴圈永遠不會退出! 有時 gcc 會發現該 bug 並報警, 但大部分情況下都不會. 類似的 bug 還會出現在比較有符合變數和無符號變數時. 主要是 C 的型別提升機制會致使無符號型別的行為出乎你的意料.

因此, 使用斷言來指出變數為非負數, 而不是使用無符號型!

5.15. 64 位下的可移植性

程式碼應該對 64 位和 32 位系統友好. 處理列印, 比較, 結構體對齊時應切記:

對於某些型別, printf() 的指示符在 32 位和 64 位系統上可移植性不是很好. C99 標準定義了一些可移植的格式化指示符. 不幸的是, MSVC 7.1 並非全部支援, 而且標準中也有所遺漏, 所以有時我們不得不自己定義一個醜陋的版本 (標頭檔案 inttypes.h 仿標準風格):

// printf macros for size_t, in the style of inttypes.h
#ifdef _LP64
#define __PRIS_PREFIX "z"
#else
#define __PRIS_PREFIX
#endif

// Use these macros after a % in a printf format string
// to get correct 32/64 bit behavior, like this:
// size_t size = records.size();
// printf("%"PRIuS"\n", size);
#define PRIdS __PRIS_PREFIX "d"
#define PRIxS __PRIS_PREFIX "x"
#define PRIuS __PRIS_PREFIX "u"
#define PRIXS __PRIS_PREFIX "X"
#define PRIoS __PRIS_PREFIX "o"
型別 不要使用 使用 備註
void * (或其他指標型別) %lx %p
int64_t %qd, %lld %"PRId64"
uint64_t %qu, %llu, %llx %"PRIu64", %"PRIx64"
size_t %u %"PRIuS", %"PRIxS" C99 規定 %zu
ptrdiff_t %d %"PRIdS" C99 規定 %zd

注意 PRI* 巨集會被編譯器擴充套件為獨立字串. 因此如果使用非常量的格式化字串, 需要將巨集的值而不是巨集名插入格式中. 使用 PRI* 巨集同樣可以在 % 後包含長度指示符. 例如, printf("x = %30"PRIuS"\n", x) 在 32 位 Linux 上將被展開為 printf("x = %30" "u" "\n", x), 編譯器當成 printf("x = %30u\n", x) 處理 (Yang.Y 注: 這在 MSVC 6.0 上行不通, VC 6 編譯器不會自動把引號間隔的多個字串連線一個長字串).

記住 sizeof(void *) != sizeof(int). 如果需要一個指標大小的整數要用 intptr_t.

你要非常小心的對待結構體對齊, 尤其是要持久化到磁碟上的結構體 (Yang.Y 注: 持久化 – 將資料按位元組流順序儲存在磁碟檔案或資料庫中). 在 64 位系統中, 任何含有 int64_t/uint64_t 成員的類/結構體, 預設都以 8 位元組在結尾對齊. 如果 32 位和 64 位程式碼要共用持久化的結構體, 需要確保兩種體系結構下的結構體對齊一致. 大多數編譯器都允許調整結構體對齊. gcc 中可使用 __attribute__((packed)). MSVC 則提供了 #pragma pack() 和 __declspec(align()) (YuleFox 注, 解決方案的專案屬性裡也可以直接設定).

建立 64 位常量時使用 LL 或 ULL 作為字尾, 如:

int64_t my_value = 0×123456789LL;
uint64_t my_mask = 3ULL << 48;

如果你確實需要 32 位和 64 位系統具有不同程式碼, 可以使用 #ifdef _LP64 指令來切分 32/64 位程式碼. (儘量不要這麼做, 如果非用不可, 儘量使修改區域性化)

5.16. 預處理巨集

使用巨集時要非常謹慎, 儘量以行內函數, 列舉和常量代替之.

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

值得慶幸的是, C++ 中, 巨集不像在 C 中那麼必不可少. 以往用巨集展開效能關鍵的程式碼, 現在可以用行內函數替代. 用巨集表示常量可被 const 變數代替. 用巨集 “縮寫” 長變數名可被引用代替. 用巨集進行條件編譯… 這個, 千萬別這麼做, 會令測試更加痛苦 (#define 防止標頭檔案重包含當然是個特例).

巨集可以做一些其他技術無法實現的事情, 在一些程式碼庫 (尤其是底層庫中) 可以看到巨集的某些特性 (如用 # 字串化, 用 ## 連線等等). 但在使用前, 仔細考慮一下能不能不使用巨集達到同樣的目的.

下面給出的用法模式可以避免使用巨集帶來的問題; 如果你要巨集, 儘可能遵守:

  • 不要在 .h 檔案中定義巨集.
  • 在馬上要使用時才進行 #define, 使用後要立即 #undef.
  • 不要只是對已經存在的巨集使用#undef,選擇一個不會衝突的名稱;
  • 不要試圖使用展開後會導致 C++ 構造不穩定的巨集, 不然也至少要附上文件說明其行為.
  • 不要用 ## 處理函式,類和變數的名字。

5.17. 0, nullptr 和 NULL

整數用 0, 實數用 0.0, 指標用 nullptr 或 NULL, 字元 (串) 用 '\0'.

整數用 0, 實數用 0.0, 這一點是毫無爭議的.

對於指標 (地址值), 到底是用 0NULL 還是 nullptr. C++11 專案用 nullptr; C++03 專案則用 NULL, 畢竟它看起來像指標。實際上,一些 C++ 編譯器對 NULL 的定義比較特殊,可以輸出有用的警告,特別是 sizeof(NULL) 就和 sizeof(0) 不一樣。

字元 (串) 用 '\0', 不僅型別正確而且可讀性好.

5.18. sizeof

儘可能用 sizeof(varname) 代替 sizeof(type).

使用 sizeof(varname) 是因為當程式碼中變數型別改變時會自動更新. 您或許會用 sizeof(type) 處理不涉及任何變數的程式碼,比如處理來自外部或內部的資料格式,這時用變數就不合適了。

Struct data;
Struct data; memset(&data, 0, sizeof(data));

Warning

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

5.19. auto

用 auto 繞過煩瑣的型別名,只要可讀性好就繼續用,別用在區域性變數之外的地方。

定義:

C++11 中,若變數被宣告成 auto, 那它的型別就會被自動匹配成初始化表示式的型別。您可以用 auto 來複制初始化或繫結引用。

vector<string> v;
...
auto s1 = v[0];  // 建立一份 v[0] 的拷貝。
const auto& s2 = v[0];  // s2 是 v[0] 的一個引用。

優點:

C++ 型別名有時又長又臭,特別是涉及模板或名稱空間的時候。就像:

sparse_hash_map<string, int>::iterator iter = m.find(val);

返回型別好難讀,程式碼目的也不夠一目瞭然。重構其:

auto iter = m.find(val);

好多了。

沒有 auto 的話,我們不得不在同一個表示式裡寫同一個型別名兩次,無謂的重複,就像:

diagnostics::ErrorStatus* status = new diagnostics::ErrorStatus("xyz");

有了 auto, 可以更方便地用中間變數,顯式編寫它們的型別輕鬆點。

缺點:

型別夠明顯時,特別是初始化變數時,程式碼才會夠一目瞭然。但以下就不一樣了:

auto i = x.Lookup(key);

看不出其型別是啥,x 的型別宣告恐怕遠在幾百行之外了。

程式設計師必須會區分 auto 和 const auto& 的不同之處,否則會複製錯東西。

auto 和 C++11 列表初始化的合體令人摸不著頭腦:

auto x(3);  // 圓括號。
auto y{3};  // 大括號。

它們不是同一回事——x 是 inty 則是 std::initializer_list<int>. 其它一般不可見的代理型別(acgtyrant 注:normally-invisible proxy types, 它涉及到 C++ 鮮為人知的坑:Why is vector<bool> not a STL container?)也有大同小異的陷阱。

如果在介面裡用 auto, 比如宣告標頭檔案裡的一個常量,那麼只要僅僅因為程式設計師一時修改其值而導致型別變化的話——API 要翻天覆地了。

結論:

auto 只能用在區域性變數裡用。別用在檔案作用域變數,名稱空間作用域變數和類資料成員裡。永遠別列表初始化 auto 變數。

auto 還可以和 C++11 特性「尾置返回型別(trailing return type)」一起用,不過後者只能用在 lambda 表示式裡。

5.20. 列表初始化

你可以用列表初始化。

早在 C++03 裡,聚合型別(aggregate types)就已經可以被列表初始化了,比如陣列和不自帶建構函式的結構體:

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

C++11 中,該特性得到進一步的推廣,任何物件型別都可以被列表初始化。示範如下:

// Vector 接收了一個初始化列表。
vector<string> v{"foo", "bar"};

// 不考慮細節上的微妙差別,大致上相同。
// 您可以任選其一。
vector<string> v = {"foo", "bar"};

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

// map 接收了一些 pair, 列表初始化大顯神威。
map<int, string> m = {{1, "one"}, {2, "2"}};

// 初始化列表也可以用在返回型別上的隱式轉換。
vector<int> test_function() { return {1, 2, 3}; }

// 初始化列表可迭代。
for (int i : {-1, -2, -3}) {}

// 在函式呼叫裡用列表初始化。
void TestFunction2(vector<int> v) {}
TestFunction2({1, 2, 3});

使用者自定義型別也可以定義接收 std::initializer_list<T> 的建構函式和賦值運算子,以自動列表初始化:

class MyType {
 public:
  // std::initializer_list 專門接收 init 列表。
  // 得以值傳遞。
  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"};
// 不過如果建構函式是顯式的(explict),您就不能用 `= {}` 了。
MyOtherType m{"b"};

千萬別直接列表初始化 auto 變數,看下一句,估計沒人看得懂:

Warning

auto d = {1.23};        // d 即是 std::initializer_list<double>
auto d = double{1.23};  // 善哉 -- d 即為 double, 並非 std::initializer_list.

至於格式化,參見 braced-initializer-list-format.

5.21. Lambda 表示式

適當使用 lambda 表示式。別用預設 lambda 捕獲,所有捕獲都要顯式寫出來。

定義:

Lambda 表示式是建立匿名函式物件的一種簡易途徑,常用於把函式當引數傳,例如:

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

C++11 首次提出 Lambdas, 還提供了一系列處理函式物件的工具,比如多型包裝器(polymorphic wrapper) std::function.

優點:

  • 傳函式物件給 STL 演算法,Lambdas 最簡易,可讀性也好。
  • Lambdas, std::functions 和 std::bind 可以搭配成通用回撥機制(general purpose callback mechanism);寫接收有界函式為引數的函式也很容易了。

缺點:

  • Lambdas 的變數捕獲略旁門左道,可能會造成懸空指標。
  • Lambdas 可能會失控;層層巢狀的匿名函式難以閱讀。

結論:

  • 按 format 小用 lambda 表示式怡情。
  • 禁用預設捕獲,捕獲都要顯式寫出來。打比方,比起 [=](int x) {return x + n;}, 您該寫成 [n](int x) {return x + n;} 才對,這樣讀者也好一眼看出 n 是被捕獲的值。
  • 匿名函式始終要簡短,如果函式體超過了五行,那麼還不如起名(acgtyrant 注:即把 lambda 表示式賦值給物件),或改用函式。
  • 如果可讀性更好,就顯式寫出 lambd 的尾置返回型別,就像auto.

5.22. 模板程式設計

不要使用複雜的模板程式設計

定義:

模板程式設計指的是利用c++ 模板例項化機制是圖靈完備性, 可以被用來實現編譯時刻的型別判斷的一系列程式設計技巧

優點:

模板程式設計能夠實現非常靈活的型別安全的介面和極好的效能, 一些常見的工具比如Google Test, std::tuple, std::function 和 Boost.Spirit. 這些工具如果沒有模板是實現不了的

缺點:

  • 模板程式設計所使用的技巧對於使用c++不是很熟練的人是比較晦澀, 難懂的. 在複雜的地方使用模板的程式碼讓人更不容易讀懂, 並且debug 和 維護起來都很麻煩
  • 模板程式設計經常會導致編譯出錯的資訊非常不友好: 在程式碼出錯的時候, 即使這個介面非常的簡單, 模板內部複雜的實現細節也會在出錯資訊顯示. 導致這個編譯出錯資訊看起來非常難以理解.
  • 大量的使用模板程式設計介面會讓重構工具(Visual Assist X, Refactor for C++等等)更難發揮用途. 首先模板的程式碼會在很多上下文裡面擴充套件開來, 所以很難確認重構對所有的這些展開的程式碼有用, 其次有些重構工具只對已經做過模板型別替換的程式碼的AST 有用. 因此重構工具對這些模板實現的原始程式碼並不有效, 很難找出哪些需要重構.

結論:

  • 模板程式設計有時候能夠實現更簡潔更易用的介面, 但是更多的時候卻適得其反. 因此模板程式設計最好只用在少量的基礎元件, 基礎資料結構上, 因為模板帶來的額外的維護成本會被大量的使用給分擔掉
  • 在使用模板程式設計或者其他複雜的模板技巧的時候, 你一定要再三考慮一下. 考慮一下你們團隊成員的平均水平是否能夠讀懂並且能夠維護你寫的模板程式碼.或者一個非c++ 程式設計師和一些只是在出錯的時候偶爾看一下程式碼的人能夠讀懂這些錯誤資訊或者能夠跟蹤函式的呼叫流程. 如果你使用遞迴的模板例項化, 或者型別列表, 或者元函式, 又或者表示式模板, 或者依賴SFINAE, 或者sizeof 的trick 手段來檢查函式是否過載, 那麼這說明你模板用的太多了, 這些模板太複雜了, 我們不推薦使用
  • 如果你使用模板程式設計, 你必須考慮儘可能的把複雜度最小化, 並且儘量不要讓模板對外暴漏. 你最好只在實現裡面使用模板, 然後給使用者暴露的介面裡面並不使用模板, 這樣能提高你的介面的可讀性. 並且你應該在這些使用模板的程式碼上寫儘可能詳細的註釋. 你的註釋裡面應該詳細的包含這些程式碼是怎麼用的, 這些模板生成出來的程式碼大概是什麼樣子的. 還需要額外注意在使用者錯誤使用你的模板程式碼的時候需要輸出更人性化的出錯資訊. 因為這些出錯資訊也是你的介面的一部分, 所以你的程式碼必須調整到這些錯誤資訊在使用者看起來應該是非常容易理解, 並且使用者很容易知道如何修改這些錯誤

5.23. Boost 庫

只使用 Boost 中被認可的庫.

定義:

Boost 庫集 是一個廣受歡迎, 經過同行鑑定, 免費開源的 C++ 庫集.

優點:

Boost程式碼質量普遍較高, 可移植性好, 填補了 C++ 標準庫很多空白, 如型別的特性, 更完善的繫結器, 更好的智慧指標。

缺點:

某些 Boost 庫提倡的程式設計實踐可讀性差, 比如超程式設計和其他高階模板技術, 以及過度 “函式化” 的程式設計風格.

結論:

為了向閱讀和維護程式碼的人員提供更好的可讀性, 我們只允許使用 Boost 一部分經認可的特性子集. 目前允許使用以下庫:

  • Call Traits : boost/call_traits.hpp
  • Compressed Pair : boost/compressed_pair.hpp
  • <The Boost Graph Library (BGL) : boost/graph, except serialization (adj_list_serialize.hpp) and parallel/distributed algorithms and data structures(boost/graph/parallel/* and boost/graph/distributed/*)
  • Property Map : boost/property_map.hpp
  • The part of Iterator that deals with defining iterators: boost/iterator/iterator_adaptor.hppboost/iterator/iterator_facade.hpp, and boost/function_output_iterator.hpp
  • The part of Polygon that deals with Voronoi diagram construction and doesn’t depend on the rest of Polygon: boost/polygon/voronoi_builder.hppboost/polygon/voronoi_diagram.hpp, and boost/polygon/voronoi_geometry_type.hpp
  • Bimap : boost/bimap
  • Statistical Distributions and Functions : boost/math/distributions
  • Multi-index : boost/multi_index
  • Heap : boost/heap
  • The flat containers from Containerboost/container/flat_map, and boost/container/flat_set

我們正在積極考慮增加其它 Boost 特性, 所以列表中的規則將不斷變化.

以下庫可以用,但由於如今已經被 C++ 11 標準庫取代,不再鼓勵:

5.24. C++11

適當用 C++11(前身是 C++0x)的庫和語言擴充套件,在貴專案用 C++11 特性前三思可移植性。

定義:

C++11 有眾多語言和庫上的`變革 <https://en.wikipedia.org/wiki/C%2B%2B11>`_ 。

優點:

在二〇一四年八月之前,C++11 一度是官方標準,被大多 C++ 編譯器支援。它標準化很多我們早先就在用的 C++ 擴充套件,簡化了不少操作,大大改善了效能和安全。

缺點:

C++11 相對於前身,複雜極了:1300 頁 vs 800 頁!很多開發者也不怎麼熟悉它。於是從長遠來看,前者特性對程式碼可讀性以及維護代價難以預估。我們說不準什麼時候採納其特性,特別是在被迫依賴老實工具的專案上。

和 5.23. Boost 庫 一樣,有些 C++11 擴充套件提倡實則對可讀性有害的程式設計實踐——就像去除冗餘檢查(比如型別名)以幫助讀者,或是鼓勵模板超程式設計等等。有些擴充套件在功能上與原有機制衝突,容易招致困惑以及遷移代價。

缺點:

C++11 特性除了個別情況下,可以用一用。除了本指南會有不少章節會加以討若干 C++11 特性之外,以下特性最好不要用:

  • 尾置返回型別,比如用 auto foo() -> int 代替 int foo(). 為了相容於現有程式碼的宣告風格。
  • 編譯時合數 <ratio>, 因為它涉及一個重模板的介面風格。
  • <cfenv> 和 <fenv.h> 標頭檔案,因為編譯器尚不支援。
  • 預設 lambda 捕獲。

譯者(acgtyrant)筆記

  1. 實際上,預設引數會改變函式簽名的前提是改變了它接收的引數數量,比如把 void a() 改成 void a(int b = 0), 開發者改變其程式碼的初衷也許是,在不改變「程式碼相容性」的同時,又提供了可選 int 引數的餘地,然而這終究會破壞函式指標上的相容性,畢竟函式簽名確實變了。
  2. 此外把自帶預設引數的函式地址賦值給指標時,會丟失預設引數資訊。
  3. 我還發現 濫用預設引數會害得讀者光只看呼叫程式碼的話,會誤以為其函式接受的引數數量比實際上還要少。
  4. friend 實際上只對函式/類賦予了對其所在類的訪問許可權,並不是有效的宣告語句。所以除了在標頭檔案類內部寫 friend 函式/類,還要在類作用域之外正式地宣告一遍,最後在對應的 .cc檔案加以定義。
  5. 本風格指南都強調了「友元應該定義在同一檔案內,避免程式碼讀者跑到其它檔案查詢使用該私有成員的類」。那麼可以把其宣告放在類宣告所在的標頭檔案,定義也放在類定義所在的檔案。
  6. 由於友元函式/類並不是類的一部分,自然也不會是類可呼叫的公有介面,於是我主張全集中放在類的尾部,即的資料成員之後,參考 宣告順序 。
  7. 對使用 C++ 異常處理應具有怎樣的態度? 非常值得一讀。
  8. 注意初始化 const 物件時,必須在初始化的同時值初始化。
  9. 用斷言代替無符號整型型別,深有啟發。
  10. auto 在涉及迭代器的迴圈語句裡挺常用。
  11. Should the trailing return type syntax style become the default for new C++11 programs? 討論了 auto 與尾置返回型別一起用的全新編碼風格,值得一看。

本系列文章

相關文章