文章目錄
- 背景介紹
- 風格指南的目標
- C++ 版本
- 頭文件
- 自包含頭文件
- #define 防護
- 包含所需內容
- 前置聲明
- 在頭文件中定義函數
- 頭文件包含順序與命名規范
- 作用域
- 命名空間
- 內部鏈接
- 非成員函數、靜態成員函數與全局函數
- 局部變量
- 靜態與全局變量
- 關于析構的決策
- 關于初始化的決策
- 常見模式
- thread_local 變量
- 類
- 構造函數中的工作處理
- 隱式轉換
- 可復制與可移動類型
- 優勢
- 實現要點
- 注意事項
- 規范要求
- 結構體與類的選擇
- 結構體 vs. 對組與元組
- 繼承
- 運算符重載
- 訪問控制
- 聲明順序
- 函數
- 輸入與輸出
- 編寫短小的函數
- 函數重載
- 默認參數
- 尾置返回類型語法
- Google 特有的魔法技巧
- 所有權與智能指針
- cpplint
- 其他 C++ 特性
- 右值引用
- 友元
- 異常處理規范
- 支持使用異常的理由
- 反對使用異常的理由
- 現狀考量
- `noexcept`
- 運行時類型信息 (RTTI)
- 類型轉換
- 流
- 前增量和前減量
- const的使用
- const 的位置選擇
- constexpr、constinit 和 consteval 的使用
- 整數類型
- 關于無符號整數
- 浮點類型
- 架構可移植性
- 預處理器宏
- 0 與 nullptr/NULL 的區別
- sizeof
- 類型推導(包括auto)
- 函數模板參數推導
- 局部變量類型推導
- 返回類型推導
- 參數類型推導
- Lambda 初始化捕獲
- 結構化綁定
- 類模板參數推導
- 指定初始化器
- Lambda 表達式
- 模板元編程
- 概念與約束的使用準則
- C++20 模塊
- 協程
- Boost庫使用規范
- 禁用標準庫特性
- 非標準擴展
- 別名
- Switch 語句
- 包容性語言
- 命名規范
- 命名選擇
- 文件名規范
- 類型命名
- 概念命名
- 變量命名
- 常見變量命名
- 類數據成員
- 結構體數據成員
- 常量命名
- 函數命名
- 命名空間名稱
- 枚舉器命名
- 模板參數命名規范
- 宏命名規范
- 別名
- 命名規則的例外情況
- 注釋
- 注釋風格
- 文件注釋
- 法律聲明與作者署名
- 結構體與類注釋
- 類注釋規范
- 函數注釋
- 函數聲明
- 函數定義
- 變量注釋
- 類數據成員
- 全局變量
- 實現注釋
- 解釋性注釋
- 函數參數注釋
- 避免事項
- 標點、拼寫與語法規范
- TODO 注釋
- 代碼格式規范
- 行寬限制
- 允許例外的情況
- 非ASCII字符
- 空格與制表符
- 函數聲明與定義
- Lambda 表達式
- 浮點數字面量
- 函數調用
- 大括號初始化列表格式
- 循環與分支語句
- 指針與引用表達式及類型
- 布爾表達式
- 返回值
- 變量與數組初始化
- 預處理器指令
- 類格式
- 構造函數初始化列表
- 命名空間格式化
- 水平空白符的使用
- 概述
- 循環與條件語句
- 運算符
- 模板與類型轉換
- 垂直留白
- 規則的例外情況
- 現有不符合規范的代碼
- Windows 代碼規范
背景介紹
C++是谷歌眾多開源項目采用的主要開發語言之一。正如每位C++開發者所知,該語言具備許多強大特性,但伴隨這種強大而來的是復雜性,這種復雜性可能導致代碼更易出錯且難以閱讀維護。
本指南旨在通過詳細闡述編寫C++代碼的最佳實踐和禁忌來管控這種復雜性。這些規則的存在既是為了保持代碼庫的可管理性,同時也讓開發者能高效利用C++語言特性。
所謂代碼風格(也稱為可讀性),是指我們規范C++代碼的約定集合。"風格"這個術語其實不夠準確,因為這些約定涵蓋的范圍遠不止源代碼格式化。
谷歌開發的大多數開源項目都遵循本指南的要求。
請注意,本指南并非C++教程:我們默認讀者已具備該語言的基礎知識。
風格指南的目標
為什么我們需要這份文檔?
我們認為本指南應服務于幾個核心目標。這些根本性的"為什么"構成了所有具體規則的基礎。通過將這些理念置于首位,我們希望為討論奠定基礎,并讓更廣泛的社區更清楚地理解規則制定的原因以及特定決策背后的考量。如果您能理解每條規則所服務的目標,那么當某條規則可能被豁免時(有些規則確實可以),以及需要怎樣的論據或替代方案才能修改指南中的規則時,對所有人來說都會更加清晰。
當前我們認為風格指南的目標如下:
- 規則的價值必須與其成本相當
風格規則的收益必須足夠大,才能證明要求所有工程師記住它是合理的。收益是相對于沒有該規則時我們可能獲得的代碼庫來衡量的,因此針對極其有害做法的規則即使收益較小也可能是合理的——如果人們不太可能這么做的話。這一原則主要解釋了我們沒有哪些規則,而非已有規則:例如goto
違反了許多后續原則,但由于其已極為罕見,因此風格指南并未討論它。 - 為讀者而非作者優化
我們的代碼庫(以及提交給它的絕大多數獨立組件)預計會持續存在相當長時間。因此,閱讀代碼的時間將遠超過編寫代碼的時間。我們明確選擇為工程師在代碼庫中閱讀、維護和調試代碼的平均體驗進行優化,而非追求編寫代碼時的便利性。"為讀者留下痕跡"是這一原則下特別常見的子要點:當代碼片段中出現意外或特殊情況時(例如指針所有權的轉移),在使用處為讀者留下文本提示非常有價值(如std::unique_ptr
在調用點明確展示了所有權轉移)。 - 與現有代碼保持一致
在整個代碼庫中保持統一的風格讓我們能專注于其他(更重要的)問題。一致性還為自動化提供了可能:格式化代碼或調整#include
的工具只有在代碼符合工具預期時才能正常工作。許多情況下,“保持一致性"的規則可歸結為"選定一種方式并停止糾結”——在這些問題上允許靈活性的潛在價值,遠低于人們爭論它們所付出的代價。但一致性也有其限度:當缺乏明確技術論據或長期方向時,它是很好的決策依據。一致性在局部(單個文件或緊密相關的接口集)應用得更嚴格。通常不應將一致性作為沿用舊風格的借口,而不考慮新風格的優勢或代碼庫隨時間推移向新風格靠攏的趨勢。 - 在適當時與更廣泛的C++社區保持一致
與其他組織使用C++的方式保持一致,其價值與保持代碼庫內部一致性同理。如果C++標準中的特性解決了問題,或者某些慣用法被廣泛認知和接受,這就是使用它的理由。但有時標準特性或慣用法存在缺陷,或設計時未考慮我們代碼庫的需求。在這些情況下(如下文所述),限制或禁止標準特性是合理的。有時我們更傾向于使用自研或第三方庫而非C++標準庫,這可能是出于對優越性的判斷,或認為遷移到標準接口的價值不足。 - 避免意外或危險的構造
C++中有些特性比乍看之下更令人意外或危險。部分風格限制正是為了防止落入這些陷阱。對此類限制的豁免門檻很高,因為豁免往往直接危及程序正確性。 - 避免讓普通C++程序員感到復雜或難以維護的構造
C++的某些特性可能因引入的復雜性而不適合普遍使用。在廣泛使用的代碼中,采用更復雜的語言構造可能更可接受,因為復雜實現帶來的收益會通過廣泛使用被放大,而理解復雜性的成本在接觸代碼庫新部分時無需重復支付。如有疑問,可向項目負責人申請豁免此類規則。這對我們的代碼庫尤為重要,因為代碼所有權和團隊成員會隨時間變化:即使當前所有相關開發人員都理解某段代碼,也不能保證幾年后依然如此。 - 考慮我們的規模
在擁有上億行代碼和數千名工程師的環境中,個別工程師的錯誤或簡化可能對許多人造成高昂代價。例如避免污染全局命名空間尤為重要:在數億行代碼庫中,如果所有人都將內容放入全局命名空間,命名沖突將難以處理和避免。 - 必要時為優化讓步
性能優化有時是必要且恰當的,即使它們與本文件的其他原則相沖突。
本文檔旨在提供最大限度的指導,同時保持合理限制。一如既往,常識和良好的判斷力應占上風。這里我們特指整個Google C++社區建立的慣例,而非您個人或團隊的偏好。對于聰明但非典型的構造應保持懷疑和謹慎態度:未被禁止不意味著可以隨意使用。運用您的判斷力,如有疑問,請隨時向項目負責人尋求額外意見。
C++ 版本
當前代碼應以 C++20 為標準,即不應使用 C++23 的特性。本指南所采用的 C++ 版本會(積極地)隨時間推移而更新。
禁止使用非標準擴展。
在項目中使用 C++17 和 C++20 的特性前,請考慮其對其他環境的可移植性。
頭文件
通常來說,每個 .cc
文件都應該有一個對應的 .h
文件。但存在一些常見例外情況,例如單元測試文件以及僅包含 main()
函數的小型 .cc
文件。
正確使用頭文件能顯著影響代碼的可讀性、體積和性能。
以下規則將幫助你規避使用頭文件時的各種陷阱。
自包含頭文件
頭文件應當具備自包含性(能夠獨立編譯),并以.h
作為擴展名。非頭文件但需要被包含的文件應使用.inc
擴展名,且應謹慎使用。
所有頭文件都必須是自包含的。用戶和重構工具不應為了包含該頭文件而遵循特殊條件。具體而言,頭文件應包含頭文件保護,并引入其所需的所有其他頭文件。
當頭文件中聲明了內聯函數或模板(且這些內容會被頭文件的使用者實例化時),這些內聯函數和模板的定義也必須存在于頭文件中——可以直接定義,也可以通過包含其他文件引入。不要將這些定義移至單獨引入的頭文件(如-inl.h
)中;這種做法在過去很常見,但現在已被禁止。如果某個模板的所有實例化都發生在單個.cc
文件中(無論是通過顯式實例化還是因為定義僅對該.cc
文件可見),則模板定義可保留在該文件中。
極少數情況下,某些設計為被包含的文件可能不具備自包含性。這類文件通常需要在特殊位置被包含(例如另一個文件的中間部分)。它們可能不使用頭文件保護,也可能不包含其依賴項。此類文件應使用.inc
擴展名命名。請謹慎使用此類文件,并盡可能優先選擇自包含頭文件。
#define 防護
所有頭文件都應使用 #define
防護機制來防止重復包含。符號名稱的格式應為 *<項目名>*_*<路徑>*_*<文件名>*_H_
。
為確保唯一性,防護符號應基于文件在項目源碼樹中的完整路徑。例如,項目 foo
中的文件 foo/src/bar/baz.h
應使用以下防護定義:
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_...#endif // FOO_BAR_BAZ_H_
包含所需內容
如果源文件或頭文件引用了在其他地方定義的符號,該文件應直接包含一個明確提供該符號聲明或定義的頭文件。不應出于其他任何原因包含頭文件。
不要依賴間接包含。這樣開發者就能從自己的頭文件中移除不再需要的#include
語句,而不會破壞客戶端代碼。此規則同樣適用于相關頭文件——如果foo.cc
使用了來自bar.h
的符號,即使foo.h
已經包含了bar.h
,foo.cc
也應顯式包含bar.h
。
前置聲明
盡可能避免使用前置聲明。相反,應該包含你所需的頭文件。
"前置聲明"是指對某個實體的聲明,但不包含其定義。
// In a C++ source file:
class B;
void FuncInB();
extern int variable_in_b;
ABSL_DECLARE_FLAG(flag_in_b);
- 前向聲明可以節省編譯時間,因為
#include
會強制編譯器打開更多文件并處理更多輸入。 - 前向聲明可以減少不必要的重新編譯。由于頭文件中無關的更改,
#include
可能導致代碼更頻繁地被重新編譯。 - 前向聲明可以隱藏依賴關系,當頭文件發生更改時,允許用戶代碼跳過必要的重新編譯。
- 與
#include
語句相比,前向聲明使得自動工具難以發現定義符號的模塊。 - 前向聲明可能會因庫的后續更改而失效。函數和模板的前向聲明可能會阻止頭文件所有者對其API進行其他兼容性更改,例如擴展參數類型、添加帶有默認值的模板參數或遷移到新的命名空間。
- 對命名空間
std::
中的符號進行前向聲明會導致未定義行為。 - 可能很難確定是需要前向聲明還是完整的
#include
。用前向聲明替換#include
可能會靜默地更改代碼的含義:
// b.h:struct B {};struct D : B {};// good_user.cc:#include "b.h"void f(B*);void f(void*);void test(D* x) { f(x); } // Calls f(B*)
如果將 #include
替換為對 B
和 D
的前向聲明,test()
將會調用 f(void*)
。
- 相比直接使用
#include
包含頭文件,前向聲明多個符號通常會更冗長。 - 為了支持前向聲明而調整代碼結構(例如使用指針成員而非對象成員),可能導致代碼運行更慢且更復雜。
盡量避免對另一個項目中定義的實體使用前向聲明。
在頭文件中定義函數
僅當函數定義較短時,才在頭文件的聲明處直接包含其定義。如果定義因其他原因需要放在頭文件中,應將其置于文件的內部區域。若需確保定義符合ODR安全規則,請使用inline
說明符標記。
定義在頭文件中的函數有時被稱為"內聯函數",這個術語承載了多重含義,涉及幾種不同但相互關聯的情形:
- 文本內聯符號的定義在聲明處直接暴露給閱讀者。
- 頭文件中定義的函數或變量具有可內聯擴展特性,因為編譯器可利用其定義進行內聯擴展,從而生成更高效的目標代碼。
- ODR安全實體不會違反"單一定義規則",這通常要求頭文件中的定義使用inline關鍵字。
雖然函數通常是更常見的混淆來源,這些定義同樣適用于變量,此處規則亦是如此。
- 對簡單函數(如訪問器和修改器)采用文本內聯定義可減少樣板代碼。
- 如前所述,由于編譯器的內聯擴展,頭文件中的函數定義可能為小型函數生成更高效的目標代碼。
- 函數模板和
constexpr
函數通常需要在聲明它們的頭文件中定義(但不一定在公開部分)。 - 在公開API中嵌入函數定義會增加API的瀏覽難度,并為API閱讀者帶來認知負擔——函數越復雜,代價越高。
- 公開定義會暴露實現細節,這些細節往好了說是無害的,但往往多余。
僅當函數較短(例如10行或更少)時,才在其公開聲明處定義。較長的函數體應放在.cc
文件中,除非出于性能或技術原因必須置于頭文件。
即使定義必須放在頭文件中,也不足以成為將其置于公開部分的理由。相反,定義可以放在頭文件的內部區域,例如類的private部分、包含internal
字樣的命名空間內,或類似// 以下僅為實現細節
的注釋下方。
一旦定義出現在頭文件中,必須通過添加inline
說明符、或作為函數模板隱式內聯、或在首次聲明時定義于類體內等方式確保其ODR安全性。
template <typename T>
class Foo {public:int bar() { return bar_; }
void MethodWithHugeBody();private:int bar_;
};// Implementation details only below heretemplate <typename T>
void Foo<T>::MethodWithHugeBody() {...
}
頭文件包含順序與命名規范
頭文件應按以下順序包含:相關頭文件、C系統頭文件、C++標準庫頭文件、其他庫的頭文件、本項目頭文件。
項目的所有頭文件路徑應基于項目源碼目錄進行描述,禁止使用UNIX目錄別名.
(當前目錄)或..
(上級目錄)。例如,google-awesome-project/src/base/logging.h
應以下列方式包含:
#include "base/logging.h"
僅當庫明確要求時,才應使用尖括號路徑包含頭文件。特別是以下類型的頭文件必須使用尖括號:
- C和C++標準庫頭文件(如
<stdlib.h>
和<string>
) - POSIX、Linux和Windows系統頭文件(如
<unistd.h>
和<windows.h>
) - 少數第三方庫頭文件(如
<Python.h>
)
在dir/foo.cc
或dir/foo_test.cc
文件中(其主要目的是實現或測試dir2/foo2.h
的功能),頭文件包含順序應遵循:
- 主關聯頭文件
dir2/foo2.h
- 空行
- C系統頭文件及其他帶.h擴展名的尖括號頭文件(如
<unistd.h>
、<stdlib.h>
、<Python.h>
) - 空行
- 無擴展名的C++標準庫頭文件(如
<algorithm>
、<cstddef>
) - 空行
- 其他庫的.h文件
- 空行
- 本項目自身的.h文件
每個非空組之間需用空行分隔。
采用這種優先順序時,如果關聯頭文件dir2/foo2.h
遺漏了必要的依賴,編譯dir/foo.cc
或dir/foo_test.cc
時會立即報錯。這種機制能確保問題首先暴露給直接維護這些文件的開發者,而非其他無關模塊的開發者。
通常dir/foo.cc
和dir2/foo2.h
位于同一目錄(例如base/basictypes_test.cc
和base/basictypes.h
),但有時也可能分屬不同目錄。
注意:C風格頭文件(如stddef.h
)與其C++等效版本(cstddef
)可互換使用。兩種風格均可接受,但建議與現有代碼風格保持一致。
每個分組內的頭文件應按字母順序排列。舊代碼可能不符合此規范,在方便時應予以修正。
示例:google-awesome-project/src/foo/internal/fooserver.cc
的頭文件包含可能如下所示:
#include "foo/server/fooserver.h"#include <sys/types.h>
#include <unistd.h>#include <string>
#include <vector>#include "base/basictypes.h"
#include "foo/server/bar.h"
#include "third_party/absl/flags/flag.h"
異常情況:
有時,系統特定的代碼需要條件包含。這類代碼可以將條件包含放在其他包含之后。當然,請保持系統特定代碼的簡潔和局部化。例如:
#include "foo/public/fooserver.h"#ifdef _WIN32
#include <windows.h>
#endif // _WIN32
作用域
命名空間
除少數例外情況外,應將代碼置于命名空間內。命名空間名稱應基于項目名稱(可能包含路徑)保持唯一。禁止使用 using 指令(例如 using namespace foo
),同時禁止使用內聯命名空間。關于匿名命名空間,請參閱內部鏈接。
命名空間將全局作用域劃分為獨立的具名作用域,可有效避免全局作用域下的命名沖突。
命名空間為大型程序提供了防止命名沖突的解決方案,同時允許大多數代碼使用簡潔的短名稱。
例如,若兩個不同項目在全局作用域中都有名為 Foo
的類,這些符號可能在編譯期或運行時發生沖突。如果每個項目都將代碼置于各自的命名空間內,project1::Foo
和 project2::Foo
將成為互不沖突的獨立符號,且各項目命名空間內的代碼仍可直接使用 Foo
而無需添加前綴。
內聯命名空間會自動將其名稱放入外層作用域。參考以下代碼片段示例:
namespace outer {
inline namespace inner {void foo();
} // namespace inner
} // namespace outer
表達式 outer::inner::foo()
和 outer::foo()
可以互換使用。內聯命名空間主要用于跨版本的ABI兼容性。
命名空間可能會帶來困惑,因為它們增加了確定名稱所指定義的機制復雜性。
特別是內聯命名空間容易令人混淆,因為名稱實際上并不局限于它們被聲明的命名空間內。它們僅作為某些更大版本控制策略的一部分才有用。
在某些情況下,必須反復使用完全限定名稱來引用符號。對于深層嵌套的命名空間,這可能會帶來大量冗余代碼。
命名空間的使用應遵循以下規范:
- 遵守命名空間命名規則
- 如示例所示,用注釋結束多行命名空間
- 命名空間應包裹整個源文件,位置在頭文件包含、gflags定義/聲明以及其他命名空間的類前置聲明之后
// In the .h filenamespace mynamespace {// All declarations are within the namespace scope.// Notice the lack of indentation.class MyClass {public:...void Foo();};} // namespace mynamespace
// In the .cc filenamespace mynamespace {// Definition of functions is within scope of the namespace.void MyClass::Foo() {...}} // namespace mynamespace
更復雜的 .cc
文件可能包含額外細節,例如標志或 using 聲明。
#include "a.h"ABSL_FLAG(bool, someflag, false, "a flag");namespace mynamespace {using ::foo::Bar;...code for mynamespace... // Code goes against the left margin.} // namespace mynamespace
- 若要將生成的協議消息代碼放入命名空間,請在
.proto
文件中使用package
指令。詳情參見Protocol Buffer Packages。 - 禁止在
std
命名空間中聲明任何內容,包括標準庫類的前向聲明。在std
命名空間中聲明實體屬于未定義行為,即不具備可移植性。如需使用標準庫中的實體,請包含對應的頭文件。 - 禁止通過using-directive指令使命名空間下的所有名稱全局可用。
// Forbidden -- This pollutes the namespace.using namespace foo;
- 不要在頭文件的命名空間作用域中使用命名空間別名,除非是在明確標記為內部使用的命名空間中。因為任何被導入到頭文件命名空間的內容都會成為該文件導出的公共API的一部分。當不滿足上述條件時可以使用命名空間別名,但必須遵循適當的命名規范。
// In a .h file, an alias must not be a separate API, or must be hidden in an// implementation detail.namespace librarian {namespace internal { // Internal, not part of the API.namespace sidetable = ::pipeline_diagnostics::sidetable;} // namespace internalinline void my_inline_function() {// Local to a function.namespace baz = ::foo::bar::baz;...}} // namespace librarian
// Remove uninteresting parts of some commonly used names in .cc files.namespace sidetable = ::pipeline_diagnostics::sidetable;
- 不要使用內聯命名空間。
- 對于 API 中不應被用戶提及的部分,使用名稱中包含 “internal” 的命名空間進行文檔標注。
// We shouldn't use this internal name in non-absl code.using ::absl::container_internal::ImplementationDetail;
請注意,嵌套在 internal
命名空間中的庫仍存在命名沖突的風險,因此應通過添加庫文件名的方式為每個庫分配唯一的內部命名空間。例如,gshoe/widget.h
應使用 gshoe::internal_widget
而非簡單的 gshoe::internal
。
- 在新代碼中推薦使用單行嵌套命名空間聲明,但并非強制要求。
內部鏈接
當.cc
文件中的定義不需要被該文件外部引用時,應通過將其放入未命名命名空間或聲明為static
來賦予內部鏈接屬性。不要在.h
文件中使用這兩種結構。
所有聲明都可以通過放入未命名命名空間來獲得內部鏈接。函數和變量也可以通過聲明為static
來獲得內部鏈接。這意味著你聲明的任何內容都無法從其他文件訪問。如果不同文件聲明了同名實體,這兩個實體將完全獨立。
對于不需要在其他地方引用的代碼,鼓勵在.cc
文件中使用內部鏈接。切勿在.h
文件中使用內部鏈接。
未命名命名空間的格式應與命名空間相同。在結束注釋中,命名空間名稱留空:
namespace {
...
} // namespace
非成員函數、靜態成員函數與全局函數
建議將非成員函數放在命名空間中,盡量避免使用完全全局的函數。不要僅僅為了組織靜態成員而創建類。類的靜態方法通常應與類的實例或類的靜態數據密切相關。
非成員函數和靜態成員函數在某些場景下很有用。將非成員函數置于命名空間中可以避免污染全局命名空間。
當非成員函數或靜態成員函數需要訪問外部資源或存在顯著依賴時,將其作為新類的成員可能更合理。
有時定義不綁定類實例的函數很有必要。這類函數可以是靜態成員函數或非成員函數。非成員函數不應依賴外部變量,且幾乎總是應該存在于命名空間中。不要僅為組織靜態成員而創建類——這與僅給名稱添加共同前綴沒有區別,而且這類分組通常也是不必要的。
如果定義的非成員函數僅在其.cc
文件中使用,應使用內部鏈接來限制其作用域。
局部變量
將函數的變量置于盡可能小的作用域內,并在聲明時初始化變量。
C++允許在函數內的任何位置聲明變量。我們建議在盡可能局部的作用域內聲明變量,并盡量靠近首次使用的位置。這樣便于讀者查找聲明,了解變量的類型及其初始值。特別要注意的是,應該使用初始化而非先聲明后賦值的方式,例如:
int i;
i = f(); // Bad -- initialization separate from declaration.
int i = f(); // Good -- declaration has initialization.
int jobs = NumJobs();
// More code...
f(jobs); // Bad -- declaration separate from use.
int jobs = NumJobs();
f(jobs); // Good -- declaration immediately (or closely) followed by use.
std::vector<int> v;
v.push_back(1); // Prefer initializing using brace initialization.
v.push_back(2);
std::vector<int> v = {1, 2}; // Good -- v starts initialized.
if
、while
和 for
語句所需的變量通常應聲明在這些語句內部,以便將變量限制在各自的作用域內。例如:
while (const char* p = strchr(str, '/')) str = p + 1;
有一個注意事項:如果變量是對象,每次進入作用域時都會調用其構造函數并創建,每次離開作用域時都會調用其析構函數。
// Inefficient implementation:
for (int i = 0; i < 1000000; ++i) {Foo f; // My ctor and dtor get called 1000000 times each.f.DoSomething(i);
}
在循環外部聲明循環中使用的變量可能更高效:
Foo f; // My ctor and dtor get called once each.
for (int i = 0; i < 1000000; ++i) {f.DoSomething(i);
}
靜態與全局變量
禁止使用具有靜態存儲期的對象,除非它們是可平凡析構的。通俗地說,這意味著析構函數不執行任何操作(即使考慮成員和基類的析構函數)。更正式的定義是:該類型不能有用戶定義或虛析構函數,且所有基類和非靜態成員都必須是可平凡析構的。靜態函數局部變量允許動態初始化,但靜態類成員變量或命名空間作用域的變量應避免使用動態初始化(僅在有限情況下允許,詳見下文)。
經驗法則:若一個全局變量的聲明本身可滿足constexpr
要求,則通常符合這些條件。
每個對象都有與其生命周期關聯的存儲期。具有靜態存儲期的對象從初始化時刻存活到程序結束,包括以下形式:
- 命名空間作用域的變量(“全局變量”)
- 類的靜態數據成員
- 使用
static
修飾符聲明的函數局部靜態變量
函數局部靜態變量在控制流首次經過其聲明時初始化;其他靜態存儲期對象在程序啟動時初始化。所有靜態存儲期對象會在程序退出時銷毀(發生在未合并線程終止之前)。
初始化可能是動態的(涉及非平凡操作,例如分配內存的構造函數或用當前進程ID初始化的變量),也可能是靜態初始化。兩者并非完全對立:靜態初始化總是先于動態初始化發生(將對象初始化為給定常量或全零字節表示),動態初始化僅在需要時隨后執行。
全局和靜態變量在以下場景非常有用:
- 命名常量
- 翻譯單元內部的輔助數據結構
- 命令行標志
- 日志系統
- 注冊機制
- 后臺基礎設施等
但使用動態初始化或非平凡析構函數的全局/靜態變量會引入復雜性,容易導致難以發現的缺陷。動態初始化在翻譯單元間沒有順序保證,析構同樣如此(僅保證析構順序與初始化相反)。當某個初始化引用另一個靜態存儲期變量時,可能導致對象在其生命周期開始前(或結束后)被訪問。此外,若程序啟動的線程在退出時未被合并,這些線程可能在對象生命周期結束后嘗試訪問已被析構的對象。
關于析構的決策
當析構函數是平凡(trivial)時,它們的執行完全不受順序約束(實際上不會"運行");否則我們將面臨在對象生命周期結束后仍訪問它們的風險。因此,我們只允許具有靜態存儲期且可平凡析構的對象存在。基礎類型(如指針和int
)是可平凡析構的,由可平凡析構類型構成的數組也是如此。請注意,標記為constexpr
的變量也是可平凡析構的。
const int kNum = 10; // Allowedstruct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // Allowedvoid foo() {static const char* const kMessages[] = {"hello", "world"}; // Allowed
}// Allowed: constexpr guarantees trivial destructor.
constexpr std::array<int, 3> kArray = {1, 2, 3};
// bad: non-trivial destructor
const std::string kFoo = "foo";// Bad for the same reason, even though kBar is a reference (the
// rule also applies to lifetime-extended temporary objects).
const std::string& kBar = StrCat("a", "b", "c");void bar() {// Bad: non-trivial destructor.static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}
請注意,引用并非對象,因此不受析構性約束的限制。不過,動態初始化的約束仍然適用。特別地,允許使用函數局部靜態引用的形式 static T& t = *new T;
。
關于初始化的決策
初始化是一個更為復雜的話題。這不僅需要考慮類構造函數是否執行,還必須考慮初始化器的求值過程:
int n = 5; // Fine
int m = f(); // ? (Depends on f)
Foo x; // ? (Depends on Foo::Foo)
Bar y = g(); // ? (Depends on g and on Bar::Bar)
除第一條語句外,其他語句都會導致初始化順序不確定的問題。
我們在尋找的概念在C++標準中被稱為常量初始化。這意味著初始化表達式必須是一個常量表達式,如果對象通過構造函數調用進行初始化,那么該構造函數也必須被聲明為constexpr
。
struct Foo { constexpr Foo(int) {} };int n = 5; // Fine, 5 is a constant expression.
Foo x(2); // Fine, 2 is a constant expression and the chosen constructor is constexpr.
Foo a[] = { Foo(1), Foo(2), Foo(3) }; // Fine
常量初始化始終是被允許的。對于靜態存儲期變量的常量初始化,應當使用 constexpr
或 constinit
進行標記。任何未如此標記的非局部靜態存儲期變量都應被假定為動態初始化,并需要非常仔細地審查。
相比之下,以下初始化方式是有問題的:
// Some declarations used below.
time_t time(time_t*); // Not constexpr!
int f(); // Not constexpr!
struct Bar { Bar() {} };// Problematic initializations.
time_t m = time(nullptr); // Initializing expression not a constant expression.
Foo y(f()); // Ditto
Bar b; // Chosen constructor Bar::Bar() not constexpr.
不鼓勵對非局部變量進行動態初始化,通常這是被禁止的。然而,如果程序的任何部分都不依賴于該初始化與其他所有初始化之間的順序關系,我們允許這樣做。在這些限制條件下,初始化的順序不會產生可觀察到的差異。例如:
int p = getpid(); // Allowed, as long as no other static variable// uses p in its own initialization.
允許(并且常見)對靜態局部變量進行動態初始化。
常見模式
- 全局字符串:如果需要命名的全局或靜態字符串常量,考慮使用指向字符串字面量的
constexpr
變量(類型為string_view
、字符數組或字符指針)。字符串字面量本身具有靜態存儲期,通常已能滿足需求。詳見TotW #140。 - 映射表、集合和其他動態容器:如果需要靜態的固定集合(例如用于檢索的集合或查找表),不能將標準庫中的動態容器作為靜態變量使用,因為它們具有非平凡的析構函數。可考慮使用由平凡類型構成的簡單數組,例如整型數組的數組(實現"整型到整型的映射"),或由鍵值對構成的數組(例如
int
和const char*
的組合)。對于小型集合,線性搜索完全足夠(且由于內存局部性而高效);建議使用absl/algorithm/container.h提供的標準操作工具。必要時可保持集合有序排列并使用二分搜索算法。如果確實希望使用標準庫的動態容器,可考慮采用下文所述的函數局部靜態指針方案。 - 智能指針(
std::unique_ptr
、std::shared_ptr
):智能指針會在析構時執行清理操作,因此被禁止使用。請評估需求是否適用于本節描述的其他模式。一個簡單解決方案是使用指向動態分配對象的普通指針且永不刪除(參見最后一項)。 - 自定義類型的靜態變量:如果需要使用自定義類型的靜態常量數據,應確保該類型具有平凡的析構函數和
constexpr
構造函數。 - 如果其他方案均不適用,可以通過函數局部靜態指針或引用動態創建對象且永不刪除(例如
static const auto& impl = *new T(args...);
)。
thread_local 變量
在函數外部聲明的 thread_local
變量必須使用真正的編譯時常量進行初始化,并且需要通過 constinit
屬性來強制執行這一要求。相比其他定義線程局部數據的方式,更推薦使用 thread_local
。
可以通過 thread_local
說明符來聲明變量:
thread_local Foo foo = ...;
這類變量實際上是一個對象集合,因此當不同線程訪問它時,實際上訪問的是不同的對象。thread_local
變量在許多方面與靜態存儲期變量非常相似。例如,它們可以在命名空間作用域、函數內部或作為靜態類成員聲明,但不能作為普通類成員聲明。
thread_local
變量的初始化方式與靜態變量類似,區別在于它們必須為每個線程單獨初始化,而不是在程序啟動時只初始化一次。這意味著函數內部聲明的thread_local
變量是安全的,但其他thread_local
變量會面臨與靜態變量相同的初始化順序問題(甚至更多)。
thread_local
變量存在一個微妙的銷毀順序問題:在線程關閉期間,thread_local
變量會按照與初始化相反的順序銷毀(C++中通常如此)。如果任何thread_local
變量的析構函數觸發的代碼引用了該線程上已銷毀的其他thread_local
變量,就會導致特別難以診斷的"釋放后使用"錯誤。
- 線程本地數據天生具有競態安全性(因為通常只有一個線程能訪問它),這使得
thread_local
在并發編程中非常有用。 thread_local
是標準支持的創建線程本地數據的唯一方式。- 訪問
thread_local
變量可能會在線程啟動或首次使用時觸發執行不可預測且不受控制的其他代碼。 thread_local
變量實際上是全局變量,除了不具備線程安全性外,具有全局變量的所有缺點。thread_local
變量消耗的內存會隨著運行線程數量線性增長(最壞情況下),這在程序中可能非常龐大。- 數據成員不能聲明為
thread_local
,除非它們同時也是static
的。 - 如果
thread_local
變量具有復雜的析構函數,我們可能會遭受"釋放后使用"的錯誤。特別是,任何此類變量的析構函數不得調用(間接)引用任何可能已銷毀的thread_local
的代碼。這一特性很難強制執行。 - 避免全局/靜態上下文中"釋放后使用"的方法對
thread_local
無效。具體來說,跳過全局和靜態變量的析構函數是可以接受的,因為它們的生命周期在程序關閉時結束。因此,任何"泄漏"都會由操作系統立即清理內存和其他資源來處理。相比之下,跳過thread_local
變量的析構函數會導致資源泄漏,其數量與程序生命周期內終止的線程總數成正比。
類或命名空間作用域的thread_local
變量必須用真正的編譯時常量初始化(即不能有動態初始化)。為了強制執行這一點,類或命名空間作用域的thread_local
變量必須用constinit
(或罕見的constexpr
)進行標注。
constinit thread_local Foo foo = ...;
函數內部的thread_local
變量不存在初始化問題,但在線程退出時仍存在釋放后使用的風險。需要注意的是,可以通過定義暴露該變量的函數或靜態方法,用函數作用域的thread_local
來模擬類或命名空間作用域的thread_local
。
Foo& MyThreadLocalFoo() {thread_local Foo result = ComplicatedInitialization();return result;
}
請注意,thread_local
變量在線程退出時會被銷毀。如果其中任何一個變量的析構函數引用了其他(可能已被銷毀的)thread_local
變量,就會導致難以診斷的釋放后使用(use-after-free)錯誤。建議優先使用簡單類型,或能證明在析構時不執行用戶提供代碼的類型,以降低訪問其他thread_local
變量的風險。
在定義線程局部數據時,應優先選擇thread_local
而非其他機制。
類
類是 C++ 中最基礎的代碼單元,我們自然會大量使用它們。本節列出了編寫類時應遵循的主要注意事項。
構造函數中的工作處理
應避免在構造函數中調用虛方法,若無法有效傳遞錯誤信號,則盡量避免可能失敗的初始化操作。
在構造函數體內執行任意初始化是可行的:
- 無需擔心類是否已完成初始化
- 通過構造函數調用完成完全初始化的對象可聲明為
const
,且更易于配合標準容器或算法使用 - 若涉及虛函數調用,這些調用不會分派到子類實現。即使當前類未被繼承,未來修改仍可能悄然引入此問題,導致難以排查的隱患
- 構造函數缺乏有效的錯誤通知機制,除了終止程序(并非總是適用)或使用異常(根據規范禁止)
- 若初始化失敗,將獲得一個初始化異常的對象,可能需要引入
bool IsValid()
等狀態檢查機制(此類檢查常被遺漏調用) - 無法獲取構造函數地址,因此構造函數中的工作難以移交(例如給其他線程)
構造函數絕不應調用虛函數。若符合代碼場景,終止程序可能是合理的錯誤處理方式。否則建議采用如TotW #42所述的工廠函數或Init()
方法。對于不存在其他狀態影響公共方法調用的對象(此類半構造對象尤其難以正確處理),應避免使用Init()
方法。
隱式轉換
不要定義隱式轉換。對于轉換運算符和單參數構造函數,請使用 explicit
關鍵字。
隱式轉換允許將一種類型(稱為源類型)的對象用在需要另一種類型(稱為目標類型)的場合,例如將 int
參數傳遞給接受 double
參數的函數。
除了語言定義的隱式轉換外,用戶還可以通過在源類型或目標類型的類定義中添加適當的成員來自定義隱式轉換。源類型的隱式轉換通過以目標類型命名的類型轉換運算符定義(例如 operator bool()
)。目標類型的隱式轉換則通過能接受源類型作為唯一參數(或唯一無默認值參數)的構造函數來定義。
explicit
關鍵字可應用于構造函數或轉換運算符,確保它們只能在目標類型顯式指定的情況下使用(例如通過強制轉換)。這不僅適用于隱式轉換,也適用于列表初始化語法:
class Foo {explicit Foo(int x, double y);...
};void Func(Foo f);
Func({42, 3.14}); // Error
從技術上講,這類代碼并不屬于隱式轉換,但就explicit
而言,語言會將其視為隱式轉換。
- 隱式轉換能提升類型的可用性和表達力,當類型顯而易見時無需顯式指定類型名稱。
- 隱式轉換可以成為重載的更簡單替代方案,例如使用單個
string_view
參數的函數可以替代針對std::string
和const char*
的獨立重載版本。 - 列表初始化語法是初始化對象的簡潔表達方式。
- 隱式轉換可能掩蓋類型不匹配的錯誤,當目標類型不符合用戶預期,或用戶未意識到會發生轉換時尤其如此。
- 隱式轉換會使代碼更難閱讀,特別是在存在重載的情況下,難以直觀判斷實際調用的代碼。
- 單參數構造函數可能意外成為隱式類型轉換途徑,即使設計初衷并非如此。
- 當單參數構造函數未標記為
explicit
時,無法可靠判斷其設計意圖是定義隱式轉換,還是作者遺漏標記。 - 隱式轉換可能導致調用點歧義,特別是在存在雙向隱式轉換時。可能由兩種類型都提供隱式轉換,或單個類型同時具有隱式構造函數和隱式類型轉換運算符導致。
- 當目標類型為隱式時,列表初始化可能遭遇相同問題,特別是列表僅包含單個元素時。
類型轉換運算符以及可通過單參數調用的構造函數,必須在類定義中標記為explicit
。例外情況是拷貝和移動構造函數不應標記為explicit
,因為它們不執行類型轉換。
對于設計為可互換的類型(例如兩種類型的對象只是同一底層值的不同表現形式),有時確實需要適當的隱式轉換。這種情況下,請聯系項目負責人申請豁免此規則。
無法通過單參數調用的構造函數可省略explicit
。接受單個std::initializer_list
參數的構造函數也應省略explicit
,以支持拷貝初始化(例如MyType m = {1, 2};
)。
可復制與可移動類型
類的公開API必須明確說明該類是否支持復制、僅支持移動,或兩者皆不支持。當復制和/或移動操作對你的類型具有明確意義時,才應支持這些操作。
可移動類型指能夠通過臨時對象進行初始化和賦值的類型。
可復制類型指能夠通過同類型任意對象進行初始化或賦值(因此根據定義也必然是可移動的),且要求源對象的值不會改變的類型。例如:
std::unique_ptr<int>
是可移動但不可復制的類型(因為賦值時源std::unique_ptr<int>
的值必須被修改)int
和std::string
是既可移動又可復制的類型(對int
而言移動與復制操作相同;對std::string
存在比復制成本更低的移動操作)
對于用戶自定義類型:
- 復制行為由拷貝構造函數和拷貝賦值運算符定義
- 移動行為由移動構造函數和移動賦值運算符定義(若存在),否則由拷貝構造函數和拷貝賦值運算符定義
編譯器在某些場景會隱式調用拷貝/移動構造函數,例如按值傳遞對象時。
優勢
使用可復制/可移動類型的對象進行值傳遞和返回值具有以下優勢:
- 使API更簡單、安全且通用
- 相比指針/引用傳遞,避免了所有權、生命周期、可變性等問題的混淆
- 無需在接口契約中額外說明上述問題
- 減少了客戶端與實現之間的非局部交互,提升代碼可理解性、可維護性和編譯器優化空間
- 兼容需要按值傳遞的泛型API(如大多數容器)
- 為類型組合等場景提供額外靈活性
實現要點
拷貝/移動構造函數和賦值運算符相比Clone()
、CopyFrom()
或Swap()
等替代方案具有以下優勢:
- 可通過編譯器隱式生成或使用
= default
顯式生成 - 語法簡潔且確保所有數據成員都被正確處理
- 通常更高效(無需堆分配或分離初始化/賦值步驟)
- 支持拷貝省略等優化
移動操作允許從右值對象隱式高效轉移資源,能簡化某些場景的代碼實現。
注意事項
某些類型不應支持復制操作:
- 單例對象(如
Registerer
) - 與特定作用域綁定的對象(如
Cleanup
) - 與對象標識強關聯的類型(如
Mutex
) - 多態基類類型(可能導致對象切片)
默認實現或草率實現的拷貝操作可能導致難以診斷的錯誤。
需特別注意:
- 隱式調用的拷貝構造函數容易被忽略
- 可能誤導習慣引用傳遞語法的開發者
- 過度復制可能導致性能問題
規范要求
每個類的公開接口必須明確聲明支持的拷貝/移動操作,通常應在聲明public
段顯式聲明或刪除相應操作:
- 可復制類應顯式聲明拷貝操作
- 僅移動類應顯式聲明移動操作
- 不可復制/移動類應顯式刪除拷貝操作
- 可復制類可額外聲明移動操作以支持高效移動
- 允許但不強制要求顯式聲明/刪除全部四個操作
- 若提供拷貝/移動賦值運算符,必須同時提供對應的構造函數
class Copyable {public:Copyable(const Copyable& other) = default;Copyable& operator=(const Copyable& other) = default;
// The implicit move operations are suppressed by the declarations above.// You may explicitly declare move operations to support efficient moves.
};class MoveOnly {public:MoveOnly(MoveOnly&& other) = default;MoveOnly& operator=(MoveOnly&& other) = default;
// The copy operations are implicitly deleted, but you can// spell that out explicitly if you want:MoveOnly(const MoveOnly&) = delete;MoveOnly& operator=(const MoveOnly&) = delete;
};class NotCopyableOrMovable {public:// Not copyable or movableNotCopyableOrMovable(const NotCopyableOrMovable&) = delete;NotCopyableOrMovable& operator=(const NotCopyableOrMovable&)= delete;
// The move operations are implicitly disabled, but you can// spell that out explicitly if you want:NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)= delete;
};
以下內容僅在顯而易見的情況下可以省略聲明/刪除:
- 如果類沒有
private
部分(例如結構體或純接口基類),那么其可復制性/可移動性取決于公有數據成員的相應特性。 - 如果基類明顯不可復制或移動,派生類自然也不具備這些特性。僅通過隱式操作定義的純接口基類,不足以明確具體子類的這些行為。
- 注意:如果顯式聲明或刪除了拷貝構造函數或拷貝賦值操作中的任意一個,另一個拷貝操作不會自動生效,必須顯式聲明或刪除。移動操作同理。
當普通用戶難以理解復制/移動的語義,或這些操作會帶來意外開銷時,類型不應支持復制/移動。對于可復制類型而言,移動操作僅是性能優化手段,可能引發錯誤和復雜性,因此除非移動操作效率顯著高于拷貝操作,否則應避免定義。如果類型支持拷貝操作,建議將類的默認實現設計為正確行為。切記像檢查其他代碼一樣審查默認操作的正確性。
為避免對象切割風險,建議通過以下方式將基類設為抽象類:將其構造函數設為protected、聲明protected析構函數,或提供至少一個純虛成員函數。盡量避免從具體類繼承。
結構體與類的選擇
僅當處理純數據載體時使用struct
,其他情況一律使用class
。
在C++中,struct
和class
關鍵字的行為幾乎完全一致。我們為這兩個關鍵字賦予特定的語義含義,因此應根據定義的數據類型選擇合適的關鍵字。
struct
應當用于純數據載體,可以包含關聯常量。所有字段必須公開。結構體不應存在隱含字段間關系的約束條件,因為用戶直接訪問字段可能破壞這些約束。允許存在構造函數、析構函數和輔助方法,但這些方法不得要求或強制任何約束條件。
若需要更復雜的功能或約束條件,或結構體具有廣泛可見性且預期會演進,則更適合使用class
。如有疑問,優先選擇class
。
為保持與STL的一致性,對于無狀態的類型(如特性類、模板元函數和部分函數對象),可使用struct
替代class
。
注意:結構體與類的成員變量遵循不同的命名規則。
結構體 vs. 對組與元組
當元素可以擁有有意義的名稱時,優先使用 struct
而非對組(pair)或元組(tuple)。
雖然使用對組和元組可以避免定義自定義類型,可能在編寫代碼時減少工作量,但在閱讀代碼時,一個有意義的字段名幾乎總是比 .first
、.second
或 std::get<X>
清晰得多。盡管 C++14 引入了通過類型而非索引訪問元組元素的 std::get<Type>
(當類型唯一時)有時能部分緩解這個問題,但字段名通常比類型名更清晰且信息量更豐富。
在泛型代碼中,若對組或元組的元素沒有特定含義時,使用它們可能是合適的。此外,為了與現有代碼或 API 交互,也可能需要使用對組或元組。
繼承
組合通常比繼承更合適。當使用繼承時,應將其設為public
。
當子類繼承基類時,它會包含基類定義的所有數據和操作的定義。“接口繼承"是指從純抽象基類(無狀態或已定義方法)繼承;其他所有繼承都屬于"實現繼承”。
實現繼承通過復用基類代碼來縮小代碼規模,同時特化現有類型。由于繼承是編譯時聲明,開發者和編譯器都能理解操作并檢測錯誤。接口繼承可用于以編程方式強制類暴露特定API。同樣,編譯器可以檢測錯誤,例如當類未定義API的必要方法時。
對于實現繼承,由于子類的實現代碼分布在基類和子類之間,可能更難理解具體實現。子類無法重寫非虛函數,因此不能改變其實現。
多重繼承尤其存在問題,因為它通常會帶來更高的性能開銷(實際上,從單繼承到多重繼承的性能下降往往比普通派發到虛派發的下降更顯著),并且可能導致"菱形"繼承模式,這種模式容易引發歧義、混淆甚至直接錯誤。
所有繼承都應該是public
的。如果需要私有繼承,應該改為將基類實例作為成員包含。當不希望類被用作基類時,可以使用final
修飾符。
不要過度使用實現繼承。組合通常更合適。盡量將繼承限制在"is-a"的情況下:如果可以說Bar
是Foo
的一種,那么Bar
才應該繼承Foo
。
將protected
的使用限制在可能需要被子類訪問的成員函數上。注意數據成員應為private
。
使用override
或(較少使用的)final
修飾符明確標注虛函數或虛析構函數的重寫。聲明重寫時不要使用virtual
關鍵字。原理:標記為override
或final
的函數或析構函數如果不是基類虛函數的重寫,將無法通過編譯,這有助于捕獲常見錯誤。這些修飾符也起到文檔作用;如果沒有修飾符,讀者需要檢查類的所有祖先才能確定函數或析構函數是否為虛函數。
允許使用多重繼承,但強烈不建議使用多重實現繼承。
運算符重載
應謹慎使用運算符重載。不要使用用戶自定義字面量。
C++允許用戶代碼通過operator
關鍵字聲明內置運算符的重載版本,只要其中一個參數是用戶自定義類型。operator
關鍵字還允許用戶代碼使用operator""
定義新的字面量類型,以及定義類型轉換函數如operator bool()
。
運算符重載能讓用戶自定義類型表現得像內置類型一樣,使代碼更簡潔直觀。重載運算符是某些操作的慣用名稱(如==
、<
、=
和<<
),遵循這些約定可以使自定義類型更具可讀性,并能與期望這些名稱的庫互操作。
用戶自定義字面量是創建用戶自定義類型對象的極簡表示法。
- 提供正確、一致且符合預期的運算符重載集需要格外小心,否則可能導致混淆和錯誤。
- 濫用運算符會導致代碼晦澀難懂,特別是當重載運算符的語義不符合慣例時。
- 函數重載的風險同樣存在于運算符重載中,甚至更為嚴重。
- 運算符重載可能誤導我們以為高開銷操作是廉價的內置操作。
- 查找重載運算符的調用點可能需要支持C++語法的搜索工具,而非簡單的grep。
- 如果重載運算符的參數類型錯誤,可能會調用不同的重載版本而非觸發編譯錯誤。例如
foo < bar
和&foo < &bar
可能執行完全不同的操作。 - 某些運算符重載本身具有風險。重載一元
&
會導致同一代碼在不同上下文中含義不同。&&
、||
和逗號運算符的重載無法匹配內置運算符的求值順序語義。 - 運算符通常在類外定義,因此存在不同文件引入相同運算符不同定義的風險。若兩個定義鏈接到同一二進制文件,會導致未定義行為,表現為微妙的運行時錯誤。
- 用戶自定義字面量(UDLs)會創建即使經驗豐富的C++程序員也不熟悉的語法形式,如用
"Hello World"sv
表示std::string_view("Hello World")
。現有表示法雖然不夠簡潔,但更為清晰。 - 由于UDLs不能限定命名空間,使用時需要配合using指令(我們禁止使用)或using聲明(頭文件中禁止使用,除非導入的名稱是該頭文件接口的一部分)。鑒于頭文件必須避免UDL后綴,我們更傾向于保持頭文件與源文件字面量規則的一致性。
僅當運算符含義明確、符合預期且與對應內置運算符一致時才定義重載。例如,使用|
表示按位或邏輯或,而非shell風格的管道。
僅對自定義類型定義運算符。更準確地說,應在與操作類型相同的頭文件、.cc
文件和命名空間中定義它們。這樣運算符在類型可用的地方都可用,最小化多重定義風險。如有可能,避免將運算符定義為模板,因為它們必須對所有模板參數滿足此規則。如果定義了一個運算符,也應定義所有相關的合理運算符,并確保定義一致。
優先將非修改性二元運算符定義為非成員函數。若二元運算符作為類成員定義,隱式轉換適用于右參數但不適用于左參數。如果a + b
能編譯而b + a
不能,會讓用戶感到困惑。
對于可比較相等性的類型T
,定義非成員operator==
并說明何時認為兩個T
類型的值相等。如果存在明確的比較規則,可以額外定義與operator==
保持一致的operator<=>
。盡量避免重載其他比較和排序運算符。
不要刻意避免定義運算符重載。例如,優先定義==
、=
和<<
,而非Equals()
、CopyFrom()
和PrintTo()
。反之,不要僅因其他庫需要就定義運算符重載。例如,若類型沒有自然排序但需存入std::set
,應使用自定義比較器而非重載<
。
不要重載&&
、||
、逗號或一元&
。不要重載operator""
,即不要引入用戶自定義字面量。不要使用他人提供的此類字面量(包括標準庫)。
類型轉換運算符在隱式轉換章節說明。=
運算符在拷貝構造函數章節說明。流操作相關的<<
重載在流章節說明。另請參閱同樣適用于運算符重載的函數重載規則。
訪問控制
除非是常量,否則應將類的數據成員聲明為private
。這種做法雖然需要編寫一些簡單的訪問器(通常是const
類型)作為樣板代碼,但能顯著簡化對不變量的推理。
出于技術原因,我們允許在.cc
文件中定義的測試夾具類(使用[Google Test](https://github.com/google/googletest)時)將其數據成員聲明為protected
。但如果測試夾具類是在使用它的.cc
文件之外定義的(例如在.h
文件中),則應將數據成員聲明為private
。
聲明順序
將相似的聲明分組放置,public
部分應放在前面。
類定義通常應以 public:
段開頭,其次是 protected:
,最后是 private:
。如果某段為空,可以省略。
在每個段內部,建議將相似類型的聲明分組,并遵循以下順序:
- 類型和類型別名(
typedef
、using
、enum
、嵌套結構體和類,以及friend
類型) - (僅適用于結構體,可選)非
static
數據成員 - 靜態常量
- 工廠函數
- 構造函數和賦值運算符
- 析構函數
- 所有其他函數(
static
和非static
成員函數,以及friend
函數) - 所有其他數據成員(靜態和非靜態)
不要在類定義中內聯定義大型方法。通常,只有簡單、性能關鍵且非常簡短的方法可以內聯定義。更多細節請參閱在頭文件中定義函數。
函數
輸入與輸出
C++函數的輸出通常通過返回值提供,有時也通過輸出參數(或輸入/輸出參數)實現。
優先使用返回值而非輸出參數:返回值可提升代碼可讀性,且通常能提供相同或更好的性能。詳見 TotW #176。
返回值傳遞方式:優先按值返回,其次按引用返回。除非可能返回空值,否則避免返回原始指針。
參數分類:函數參數可分為輸入參數、輸出參數或兼具二者功能。非可選的輸入參數通常應為值類型或const
引用,而非可選的輸出參數和輸入/輸出參數通常應為引用(且不可為空)。通常使用std::optional
表示可選的值類型輸入參數,當非可選形式本應使用引用時改用const
指針。使用非const
指針表示可選的輸出參數和可選的輸入/輸出參數。
生命周期注意事項:避免定義要求引用參數在函數調用后繼續存活的函數。某些情況下引用參數可能綁定到臨時對象,導致生命周期錯誤。應通過消除生命周期要求(例如復制參數)或改用指針傳遞并明確文檔化生命周期和非空要求來解決此問題。詳見 TotW 116。
參數順序規則:
- 所有純輸入參數應置于輸出參數之前
- 不要僅因新增參數就將其置于函數末尾,新增的純輸入參數應放在輸出參數前
- 此規則非絕對——兼具輸入輸出功能的參數可能打破此順序
- 與相關函數保持一致性時可能需要調整規則
- 可變參數函數可能需要特殊參數排序
(注:保留所有代碼術語如const
、std::optional
等原樣,鏈接和文獻引用格式完整保留)
編寫短小的函數
推薦使用小巧而專注的函數。
我們理解長函數有時是合理的,因此并未對函數長度設置硬性限制。但如果一個函數超過約40行,請考慮是否可以在不影響程序結構的前提下將其拆分。
即使你的長函數現在運行完美,幾個月后有人修改它時可能會添加新功能。這可能導致難以發現的錯誤。保持函數短小簡單,能讓其他人更容易閱讀和修改你的代碼。小函數也更容易測試。
在處理某些代碼時,你可能會遇到冗長復雜的函數。不要害怕修改現有代碼:如果發現處理這類函數很困難、錯誤難以調試,或者需要在多個不同上下文中使用其中一部分功能,請考慮將函數拆分為更小、更易管理的片段。
函數重載
僅當閱讀代碼的人無需精確判斷調用的是哪個重載版本,就能清晰理解調用處的意圖時,才使用重載函數(包括構造函數)。
例如,可以編寫一個接收const std::string&
參數的函數,并重載另一個接收const char*
參數的版本。但在此場景下,建議優先考慮使用std::string_view
替代方案。
class MyClass {public:void Analyze(const std::string &text);void Analyze(const char *text, size_t textlen);
};
通過允許同名函數接受不同參數,重載可以使代碼更加直觀。這對于模板化代碼可能是必要的,對于訪問者模式也很方便。
基于 const
或引用限定符的重載可以提高工具代碼的可用性、效率,或兩者兼具。更多信息請參閱 TotW #148。
如果函數僅通過參數類型重載,讀者可能需要理解 C++ 復雜的匹配規則才能明白發生了什么。此外,如果派生類僅覆蓋函數的某些變體,許多人會對繼承的語義感到困惑。
當不同變體之間沒有語義差異時,可以對函數進行重載。這些重載可能在類型、限定符或參數數量上有所不同。然而,調用處的讀者不需要知道選擇了重載集中的哪個成員,只需知道調用了集中的某個成員即可。
為了體現這種統一設計,建議使用一個全面的"總括"注釋來記錄整個重載集,并將其放在第一個聲明之前。
如果讀者可能難以將總括注釋與特定重載聯系起來,可以為特定重載添加注釋。
默認參數
當默認值能確保始終相同時,非虛函數允許使用默認參數。需遵循與函數重載相同的限制條件——如果默認參數帶來的可讀性提升無法抵消下述缺點,則應優先使用重載函數。
常見場景是函數通常使用默認值,但偶爾需要覆蓋默認值。默認參數提供了一種簡便的實現方式,無需為少數例外情況定義多個函數。與函數重載相比,默認參數的語法更簡潔,減少了樣板代碼,同時更清晰地區分了"必需"和"可選"參數。
默認參數是實現重載函數語義的另一種方式,因此所有反對函數重載的理由同樣適用。
虛函數調用中的參數默認值由目標對象的靜態類型決定,無法保證該函數的所有重寫都聲明相同的默認值。
默認參數會在每次調用時重新求值,可能導致生成代碼膨脹。閱讀者也可能期望默認值在聲明時固定,而非每次調用時變化。
當存在默認參數時,函數指針會令人困惑,因為函數簽名常與調用簽名不匹配。通過添加函數重載可避免這些問題。
虛函數禁止使用默認參數(因其無法正常工作),在指定默認值可能因求值時機不同而產生不同結果時也應避免使用。(例如不要寫void f(int n = counter++);
)
其他某些情況下,默認參數能顯著改善函數聲明的可讀性,此時允許使用。如有疑問,請使用重載。
尾置返回類型語法
僅在常規語法(前置返回類型)不實用或可讀性明顯較差時,才使用尾置返回類型。
C++允許兩種不同的函數聲明形式。在較舊的形式中,返回類型出現在函數名之前。例如:
int foo(int x);
新形式在函數名前使用 auto
關鍵字,并在參數列表后添加返回類型。例如,上述聲明可以等價地寫成:
auto foo(int x) -> int;
尾置返回類型位于函數的作用域內。對于像int
這樣的簡單類型這沒有區別,但對于更復雜的情況(如在類作用域內聲明的類型或根據函數參數編寫的類型)就很重要。
尾置返回類型是顯式指定lambda表達式返回類型的唯一方式。某些情況下編譯器能夠推導出lambda的返回類型,但并非所有情況都適用。即使編譯器可以自動推導,有時顯式指定返回類型會讓代碼對閱讀者更清晰。
當函數參數列表已經出現后,再指定返回類型可能更容易且更可讀。這在返回類型依賴于模板參數時尤其明顯。例如:
template <typename T, typename U>auto add(T t, U u) -> decltype(t + u);
versus
template <typename T, typename U>decltype(declval<T&>() + declval<U&>()) add(T t, U u);
尾置返回類型語法相對較新,在C++類語言(如C和Java)中沒有類似用法,因此部分讀者可能會感到陌生。
現有代碼庫中存在大量函數聲明不會改用新語法,因此實際選擇只有兩種:僅使用舊語法或混合使用兩者。統一采用單一版本更有利于保持代碼風格的一致性。
在大多數情況下,建議繼續使用傳統的函數聲明風格(即返回類型位于函數名前)。僅在以下場景使用尾置返回類型:語法強制要求時(如lambda表達式),或者將返回類型放在參數列表后能顯著提升可讀性。后一種情況應當非常罕見,主要出現在相當復雜的模板代碼中——而這類代碼在大多數情況下是不鼓勵使用的。
Google 特有的魔法技巧
我們采用多種技巧和工具來增強 C++ 代碼的健壯性,這些方法可能與其他地方常見的 C++ 使用方式有所不同。
所有權與智能指針
優先為動態分配的對象設置單一固定所有者。建議使用智能指針進行所有權轉移。
"所有權"是一種用于管理動態分配內存(及其他資源)的簿記技術。動態分配對象的所有者是一個對象或函數,負責確保在不再需要時刪除該對象。所有權有時可以共享,此時通常由最后一個所有者負責刪除。即使所有權不共享,也可以在不同代碼段之間轉移。
"智能"指針是行為類似指針的類(例如通過重載*
和->
運算符)。某些智能指針類型可自動完成所有權簿記,確保滿足這些職責。std::unique_ptr
是一種表示獨占所有權的智能指針類型,當std::unique_ptr
離開作用域時,對象會被自動刪除。它不可復制,但可通過移動操作表示所有權轉移。std::shared_ptr
是表示共享所有權的智能指針類型,可被復制,對象所有權在所有副本間共享,當最后一個std::shared_ptr
被銷毀時對象會被刪除。
- 沒有所有權邏輯幾乎不可能管理動態分配內存
- 轉移對象所有權可能比復制對象成本更低(如果可復制的話)
- 所有權轉移比"借用"指針或引用更簡單,因為減少了協調兩個使用者之間對象生命周期的需求
- 智能指針通過明確所有權邏輯使代碼更易讀、自文檔化且無歧義
- 智能指針可消除手動所有權簿記,簡化代碼并排除大量錯誤類別
- 對于
const
對象,共享所有權是深度復制的簡單高效替代方案
注意事項:
- 所有權必須通過指針(智能或原始)表示和轉移。指針語義比值語義更復雜,尤其在API中:不僅需考慮所有權,還需考慮別名、生命周期和可變性等問題
- 值語義的性能成本常被高估,所有權轉移的性能收益可能無法抵消可讀性和復雜性成本
- 轉移所有權的API會強制客戶端采用單一內存管理模型
- 使用智能指針的代碼對資源釋放位置不夠明確
std::unique_ptr
使用移動語義表達所有權轉移,該特性較新可能使部分程序員困惑- 共享所有權可能成為精心設計所有權方案的誘人替代品,模糊系統設計
- 共享所有權需要在運行時進行顯式簿記,可能代價高昂
- 某些情況下(如循環引用),共享所有權的對象可能永遠不會被刪除
- 智能指針并非原始指針的完美替代品
若必須動態分配,優先讓分配代碼保留所有權。若其他代碼需要訪問對象,考慮傳遞副本,或傳遞不轉移所有權的指針/引用。建議使用std::unique_ptr
明確所有權轉移。例如:
std::unique_ptr<Foo> FooFactory();
void FooConsumer(std::unique_ptr<Foo> ptr);
除非有非常充分的理由,否則不要設計使用共享所有權的代碼。其中一個理由是避免昂貴的復制操作,但僅當性能提升顯著且底層對象不可變時(例如std::shared_ptr<const Foo>
)才應這樣做。如果確實需要使用共享所有權,優先選擇std::shared_ptr
。
切勿使用std::auto_ptr
,而應使用std::unique_ptr
。
cpplint
使用 cpplint.py
來檢測代碼風格問題。
cpplint.py
是一個讀取源代碼文件并識別多種風格錯誤的工具。它并非完美無缺,既存在誤報也可能漏報,但仍不失為一個有價值的工具。
部分項目會提供如何通過其項目工具運行 cpplint.py
的說明。如果你貢獻的項目沒有相關指引,可以單獨下載 cpplint.py
。
其他 C++ 特性
右值引用
僅在以下特定情況下使用右值引用。
右值引用是一種只能綁定到臨時對象的引用類型。其語法與傳統引用語法類似。例如,void f(std::string&& s);
聲明了一個參數為 std::string
右值引用的函數。
當符號 &&
應用于函數參數中未限定的模板參數時,會觸發特殊的模板參數推導規則。這種引用稱為轉發引用。
- 定義移動構造函數(接受類類型右值引用的構造函數)可以實現移動而非復制值。例如,若
v1
是std::vector<std::string>
,則auto v2(std::move(v1))
可能僅涉及簡單的指針操作,而無需復制大量數據。這在許多情況下能顯著提升性能。 - 右值引用使得實現可移動但不可復制的類型成為可能。這對于那些沒有合理復制定義但仍需作為函數參數傳遞或放入容器等的類型非常有用。
- 要高效使用某些標準庫類型(如
std::unique_ptr
),必須使用std::move
。 - 使用右值引用符號的轉發引用可以編寫通用函數包裝器,將其參數轉發給其他函數,無論參數是否為臨時對象和/或常量。這稱為“完美轉發”。
- 右值引用尚未被廣泛理解。引用折疊和轉發引用的特殊推導規則等概念較為晦澀。
- 右值引用常被誤用。在函數調用后參數預期保持有效指定狀態或未執行移動操作的場景中,使用右值引用會違反直覺。
除非符合以下情況,否則不要使用右值引用(或在方法上應用 &&
限定符):
- 可用于定義移動構造函數和移動賦值運算符(如可復制和可移動類型中所述)。
- 可用于定義邏輯上“消耗”
*this
的&&
限定方法,使其處于不可用或空狀態。注意這僅適用于方法限定符(位于函數簽名右括號之后);若要“消耗”普通函數參數,建議按值傳遞。 - 可與
std::forward
結合使用轉發引用,以支持完美轉發。 - 可用于定義重載對,例如一個接受
Foo&&
,另一個接受const Foo&
。通常首選方案是按值傳遞,但重載函數對有時能提供更好性能(例如函數有時不消耗輸入)。切記:若為性能編寫更復雜代碼,需確保其確實有效。
友元
我們允許在合理范圍內使用friend
類和函數。
友元通常應定義在同一文件中,這樣讀者無需查看其他文件就能了解類私有成員的使用情況。friend
的常見用法是讓FooBuilder
類成為Foo
的友元,這樣它就能正確構建Foo
的內部狀態,而無需將這些狀態暴露給外部。某些情況下,將單元測試類設為被測試類的友元也很有用。
友元擴展了類的封裝邊界,但不會破壞它。當您只想讓另一個類訪問某個成員時,使用友元比將該成員設為public
更合適。不過,大多數類應僅通過其公共成員與其他類交互。
異常處理規范
我們禁止使用 C++ 異常機制,原因如下:
支持使用異常的理由
- 簡化錯誤處理:異常機制允許應用程序高層決定如何處理深層嵌套函數中的"不可能發生"錯誤,避免了錯誤碼帶來的晦澀和易錯問題
- 語言一致性:多數現代語言都采用異常機制,在 C++ 中使用可使代碼風格與 Python、Java 等語言保持統一
- 第三方庫兼容:部分第三方 C++ 庫依賴異常機制,禁用異常會增加集成難度
- 構造函數失敗處理:異常是構造函數報告失敗的唯一途徑。雖然可通過工廠函數或
Init()
方法模擬,但這分別需要堆內存分配或引入"無效"狀態 - 測試框架優勢:異常機制在測試框架中非常實用
反對使用異常的理由
- 調用鏈維護成本:當向現有函數添加
throw
語句時,必須檢查所有調用鏈。調用者要么實現基本異常安全保證,要么接受程序終止的后果。例如f()
調用g()
調用h()
時,若h()
拋出被f()
捕獲的異常,g()
必須謹慎處理否則可能無法正確清理資源 - 控制流混亂:異常會導致程序流程難以通過代碼靜態分析判斷,函數可能在預期外的位置返回,增加維護和調試難度。雖然可以通過使用規范降低影響,但這增加了開發者的認知負擔
- 編碼實踐要求:要實現異常安全需要結合 RAII 和特殊編碼規范,需要大量輔助機制。為確保代碼可讀性,還必須將對持久狀態的修改隔離到"提交"階段,這會帶來額外的設計成本
- 性能影響:啟用異常會增加二進制文件體積,可能輕微影響編譯速度并增加內存壓力
- 濫用風險:異常機制可能誘使開發者在不當場景拋出異常(如用戶輸入校驗),或在不安全時進行恢復。要防范此類問題需要制定更冗長的規范
現狀考量
表面上看,異常機制的優勢(特別是對新項目)大于代價。但對于既有代碼庫,引入異常會影響所有依賴代碼。若允許異常傳播到新項目外,將難以與現有無異常代碼集成。由于 Google 大多數現有 C++ 代碼未做異常處理準備,集成異常代碼的難度更高。
鑒于 Google 現有代碼對異常的支持有限,使用異常的成本遠高于新項目。遷移過程將緩慢且易錯。我們認為錯誤碼和斷言等替代方案不會帶來顯著負擔。
我們的禁用建議并非出于哲學考量,而是實踐因素。由于希望 Google 開源項目能在內部使用,而這些項目若使用異常會導致集成困難,因此開源項目同樣需要禁用異常。如果從頭開始設計,可能會做出不同選擇。
本規范同樣適用于異常處理相關特性(如std::exception_ptr
和std::nested_exception
)。
Windows 平臺代碼存在特例(并非雙關語)。
noexcept
在有用且正確的情況下使用 noexcept
。
noexcept
說明符用于指定函數是否會拋出異常。如果異常從標記為 noexcept
的函數中逃逸,程序會通過 std::terminate
崩潰。
noexcept
運算符在編譯時執行檢查,如果表達式聲明為不拋出任何異常,則返回 true。
- 將移動構造函數標記為
noexcept
在某些情況下可以提高性能,例如,如果 T 的移動構造函數是noexcept
,std::vector<T>::resize()
會移動對象而不是復制。 - 在啟用異常的環境中,對函數指定
noexcept
可以觸發編譯器優化,例如,如果編譯器知道由于noexcept
說明符不會拋出異常,就不必為棧展開生成額外的代碼。 - 在遵循本指南且禁用異常的項目中,很難確保
noexcept
說明符的正確性,甚至難以定義“正確”的含義。 - 撤銷
noexcept
很困難(甚至不可能),因為它消除了調用者可能依賴的保證,而這些依賴關系很難檢測。
如果 noexcept
能準確反映函數的預期語義(即,如果函數體內以某種方式拋出異常,則表示致命錯誤),并且對性能有幫助,可以使用它。可以假設移動構造函數上的 noexcept
具有顯著的性能優勢。如果認為在其他函數上指定 noexcept
能帶來顯著的性能提升,請與項目負責人討論。
如果完全禁用異常(例如大多數 Google C++ 環境),優先使用無條件 noexcept
。否則,使用帶有簡單條件的條件 noexcept
說明符,僅在少數可能拋出異常的情況下求值為 false。測試可能包括檢查相關操作是否會拋出異常的類型特征(例如,移動構造對象時使用 std::is_nothrow_move_constructible
),或者檢查分配是否會拋出異常(例如,標準默認分配使用 absl::default_allocator_is_nothrow
)。請注意,在許多情況下,異常的唯一可能原因是分配失敗(我們認為移動構造函數不應拋出異常,除非由于分配失敗),并且在許多應用中,將內存耗盡視為致命錯誤而非程序應嘗試恢復的異常情況是合適的。即使對于其他潛在故障,也應優先考慮接口簡單性,而不是支持所有可能的異常拋出場景:例如,與其編寫一個復雜的 noexcept
子句來依賴哈希函數是否會拋出異常,不如直接說明組件不支持哈希函數拋出異常,并將其設為無條件 noexcept
。
運行時類型信息 (RTTI)
應避免使用運行時類型信息 (RTTI)。
RTTI 允許程序員在運行時查詢對象的 C++ 類信息,通常通過 typeid
或 dynamic_cast
實現。
RTTI 的標準替代方案(如下所述)需要對相關類層次結構進行修改或重新設計。有時這類修改難以實現或不可取,尤其是在廣泛使用或成熟的代碼中。
RTTI 在某些單元測試中可能有用。例如,在測試工廠類時,可用于驗證新創建的對象是否具有預期的動態類型。它也有助于管理對象與其模擬對象之間的關系。
當處理多個抽象對象時,RTTI 也很有用。考慮…
bool Base::Equal(Base* other) = 0;
bool Derived::Equal(Base* other) {Derived* that = dynamic_cast<Derived*>(other);if (that == nullptr)return false;...
}
在運行時頻繁查詢對象的類型通常意味著設計存在問題。需要獲知對象運行時類型的情況,往往表明類層次結構的設計存在缺陷。
隨意使用運行時類型識別(RTTI)會導致代碼難以維護。它可能引發基于類型的決策樹或分散在代碼各處的switch語句,這些在后續修改時都需要重新檢查。
RTTI確有合理用途但容易被濫用,因此使用時必須謹慎。在單元測試中可以自由使用,但在其他代碼中應盡量避免。特別是新增代碼時更要三思而行。如果發現需要根據對象類別的不同而編寫不同行為代碼,請考慮以下替代方案:
- 虛方法是根據特定子類類型執行不同代碼路徑的首選方式。這種方式將工作交由對象自身完成。
- 若處理邏輯應放在對象外部,可考慮雙重分派方案,如訪問者設計模式。這允許外部設施利用內置類型系統來確定類別。
當程序邏輯能確保基類實例實際上是特定派生類實例時,可以自由使用dynamic_cast
。通常在這種情況下也可以用static_cast
作為替代方案。
基于類型的決策樹強烈暗示著代碼設計存在問題。
if (typeid(*data) == typeid(D1)) {...
} else if (typeid(*data) == typeid(D2)) {...
} else if (typeid(*data) == typeid(D3)) {
...
當類層次結構中新增子類時,這類代碼通常會失效。此外,當子類屬性發生變化時,很難找到并修改所有受影響的代碼段。
不要手動實現類似RTTI的變通方案。反對使用RTTI的論點同樣適用于帶有類型標簽的類層次結構等變通方案。更重要的是,這些變通方案會掩蓋你的真實意圖。
類型轉換
推薦使用C++風格的強制類型轉換,例如static_cast<float>(double_value)
,或通過大括號初始化對算術類型進行轉換,如int64_t y = int64_t{1} << 42
。除非轉換為void
類型,否則不要使用(int)x
這類轉換格式。只有當T
是類類型時,才允許使用T(x)
這類轉換格式。
C++引入了一套不同于C的類型轉換系統,能夠區分不同類型的轉換操作。
C風格類型轉換的問題在于操作存在歧義——有時執行的是值轉換(例如(int)3.5
),有時執行的是類型重解釋(例如(int)"hello"
)。大括號初始化和C++風格轉換通常能避免這種歧義。此外,C++風格轉換在代碼搜索時也更醒目。
雖然C++風格的轉換語法較為冗長,但出于以下原因仍建議優先使用:
通常情況下,應避免使用C風格類型轉換。當需要進行顯式類型轉換時,請使用以下C++風格轉換方式:
- 大括號初始化:用于算術類型轉換(例如
int64_t{x}
)。這是最安全的方式,因為如果轉換可能導致信息丟失,代碼將無法通過編譯。該語法也更為簡潔。 - 函數式轉換:當顯式轉換為類類型時,優先使用
std::string(some_cord)
而非static_cast<std::string>(some_cord)
。 - absl::implicit_cast:用于安全地向上轉換類型層次結構,例如將
Foo*
轉換為SuperclassOfFoo*
或將Foo*
轉換為const Foo*
。雖然C++通常會自動執行這類轉換,但在某些場景(如使用?:
運算符時)需要顯式向上轉換。 - static_cast:作為C風格轉換的等效替代,用于數值轉換、顯式將類指針向上轉換為其父類指針,或顯式將父類指針向下轉換為子類指針(此時必須確保對象確實是子類實例)。
- const_cast:用于移除
const
限定符(參見const使用規范)。 - reinterpret_cast:用于指針類型與整型或其他指針類型(包括
void*
)之間的不安全轉換。僅在充分理解別名問題且明確操作后果時使用。也可考慮先解引用指針(不進行轉換),再使用std::bit_cast
轉換結果值。 - std::bit_cast:用于將值的原始位重新解釋為相同大小的其他類型(類型雙關),例如將
double
的位模式解釋為int64_t
。
關于dynamic_cast
的使用指南,請參閱RTTI章節。
流
在適當場合使用流,并保持"簡單"的用法。僅對表示值的類型重載 <<
運算符進行流式輸出,且只輸出用戶可見的值,不暴露任何實現細節。
流是 C++ 中的標準 I/O 抽象,標準頭文件 <iostream>
是其典型代表。流在 Google 代碼中被廣泛使用,主要用于調試日志和測試診斷。
<<
和 >>
流運算符提供了格式化 I/O 的 API,易于學習、可移植、可復用且可擴展。相比之下,printf
甚至不支持 std::string
,更不用說用戶自定義類型,而且很難做到可移植使用。printf
還迫使你在眾多略有差異的函數版本中選擇,并處理數十個轉換說明符。
流通過 std::cin
、std::cout
、std::cerr
和 std::clog
提供一流的控制臺 I/O 支持。C API 也能做到,但需要手動緩沖輸入,這限制了其使用。
- 流的格式化可以通過改變流的狀態來配置。這種改變是持久的,因此除非你特意在每次其他代碼可能修改流后將其恢復到已知狀態,否則代碼行為可能會受到流之前整個歷史狀態的影響。用戶代碼不僅可以修改內置狀態,還可以通過注冊系統添加新的狀態變量和行為。
- 由于上述問題、流式代碼中代碼和數據的混合方式,以及運算符重載的使用(可能選擇與你預期不同的重載),精確控制流輸出非常困難。
- 通過
<<
運算符鏈構建輸出的做法不利于國際化,因為它將詞序硬編碼到代碼中,且流對本地化的支持存在缺陷。 - 流 API 微妙且復雜,程序員必須積累經驗才能有效使用。
- 編譯器解析
<<
的眾多重載成本極高。在大型代碼庫中廣泛使用時,可能消耗高達 20% 的解析和語義分析時間。
僅在流是最佳工具時使用它們。這通常適用于 I/O 是臨時、局部、人類可讀且面向其他開發者而非最終用戶的情況。與周圍代碼及整個代碼庫保持一致;如果已有現成工具解決你的問題,就使用該工具。特別是,對于診斷輸出,日志庫通常是比 std::cerr
或 std::clog
更好的選擇,而 absl/strings
或等效庫中的工具通常比 std::stringstream
更合適。
避免在面對外部用戶或處理不可信數據的 I/O 中使用流。相反,尋找并使用適當的模板庫來處理國際化、本地化和安全加固等問題。
如果確實使用流,避免使用流 API 的有狀態部分(錯誤狀態除外),如 imbue()
、xalloc()
和 register_callback()
。使用顯式格式化函數(如 absl::StreamFormat()
)而非流操縱器或格式化標志來控制數字進制、精度或填充等格式化細節。
僅當你的類型表示一個值,且 <<
輸出該值的人類可讀字符串表示時,才為你的類型重載 <<
作為流運算符。避免在 <<
的輸出中暴露實現細節;如果需要打印對象內部信息進行調試,改用命名函數(最常見的約定是名為 DebugString()
的方法)。
前增量和前減量
除非需要后綴語義,否則請使用遞增和遞減運算符的前綴形式(++i
)。
當變量被遞增(++i
或 i++
)或遞減(--i
或 i--
)且表達式的值未被使用時,必須決定是使用前增(減)量還是后增(減)量。
后綴遞增/遞減表達式的求值結果是修改前的原始值。這可能導致代碼更緊湊但更難閱讀。前綴形式通常更具可讀性,效率不會更低,甚至可能更高效,因為它不需要復制操作前的值。
在 C 語言中形成了使用后增量的傳統,即使表達式的值未被使用,尤其是在 for
循環中。
除非代碼明確需要后綴遞增/遞減表達式的結果,否則應使用前綴遞增/遞減形式。
const的使用
在API中,只要合理就應使用const
。對于某些const
的使用場景,constexpr
是更好的選擇。
可以在聲明的變量和參數前加上const
關鍵字,表明這些變量不會被修改(例如const int foo
)。類函數可以使用const
限定符,表示該函數不會改變類成員變量的狀態(例如class Foo { int Bar(char c) const; };
)。
這樣做的好處包括:
- 便于理解變量的使用方式
- 讓編譯器能進行更好的類型檢查,并可能生成更優的代碼
- 幫助開發者確認程序正確性,因為他們知道所調用的函數對變量的修改是受限的
- 在多線程程序中,幫助開發者了解哪些函數可以安全地不加鎖調用
const
具有傳染性:如果將const
變量傳遞給函數,該函數的原型中必須包含const
(否則需要使用const_cast
)。這在調用庫函數時可能成為特定問題。
我們強烈建議在API中有意義且準確的地方使用const
(即函數參數、方法和非局部變量)。這提供了關于操作可能改變哪些對象的一致且主要由編譯器驗證的文檔。擁有區分讀寫操作的一致可靠方法,對于編寫線程安全代碼至關重要,在其他許多場景中也很有用。具體而言:
- 如果函數保證不會修改通過引用或指針傳遞的參數,相應的函數參數應分別為常量引用(
const T&
)或常量指針(const T*
) - 對于按值傳遞的函數參數,
const
對調用者沒有影響,因此不建議在函數聲明中使用。參見TotW #109 - 除非方法會改變對象的邏輯狀態(或允許用戶修改該狀態,例如返回非常量引用,但這很罕見),或者不能安全地并發調用,否則應將方法聲明為
const
對于局部變量使用const
既不鼓勵也不反對。
類的所有const
操作都應能安全地并發調用。如果不可行,必須明確將類文檔標注為"非線程安全"。
const 的位置選擇
有些人更喜歡使用 int const *foo
而非 const int* foo
。他們認為這種形式更具可讀性,因為它更符合一致性原則:const
始終跟在它所描述的對象之后。然而,在指針嵌套層級較少的代碼庫中,這種一致性論點并不適用——因為大多數 const
表達式只有一個 const
,且它修飾的是底層值。這種情況下,并不需要維護所謂的一致性。將 const
放在前面可以說更具可讀性,因為它遵循了英語中將"形容詞"(const
)置于"名詞"(int
)之前的習慣。
盡管如此,雖然我們鼓勵將 const
前置,但并不強制要求。關鍵是要與周圍的代碼風格保持一致!
constexpr、constinit 和 consteval 的使用
使用 constexpr
來定義真正的常量或確保常量初始化。使用 constinit
來確保非常量變量的常量初始化。
某些變量可以聲明為 constexpr
,以表明這些變量是真正的常量,即在編譯/鏈接時固定。某些函數和構造函數可以聲明為 constexpr
,這使得它們可用于定義 constexpr
變量。函數可以聲明為 consteval
,以限制它們僅在編譯時使用。
使用 constexpr
可以定義浮點表達式而非僅字面量的常量;定義用戶自定義類型的常量;以及通過函數調用定義常量。
過早地將某些內容標記為 constexpr
可能會導致后續降級時的遷移問題。當前對 constexpr
函數和構造函數中允許內容的限制可能會在這些定義中引入晦澀的變通方法。
constexpr
定義能夠更穩健地指定接口的常量部分。使用 constexpr
來指定真正的常量以及支持其定義的函數。consteval
可用于那些不得在運行時調用的代碼。避免為了使其與 constexpr
兼容而復雜化函數定義。不要使用 constexpr
或 consteval
來強制內聯。
整數類型
在C++內置的整數類型中,唯一推薦使用的是int
。若程序需要不同大小的整數類型,請使用<stdint.h>
中定義的精確寬度整數類型,例如int16_t
。如果數值可能大于或等于2^31,則應使用64位類型如int64_t
。需注意即使數值本身不會超出int
的范圍,但在中間計算過程中可能需要更大的類型。如有疑問,請選擇更大的類型。
C++并未規定int
等整數類型的精確大小。現代架構中常見的大小為:short
占16位,int
占32位,long
占32或64位,long long
占64位,但不同平臺可能有不同選擇,特別是long
類型。
聲明一致性原則:
C++中整型的大小會隨編譯器和架構而變化。
標準庫頭文件<stdint.h>
定義了int16_t
、uint32_t
、int64_t
等類型。當需要確保整數大小時,應優先使用這些類型而非short
、unsigned long long
等。建議省略這些類型的std::
前綴,因為額外的5個字符會帶來不必要的混亂。在內置整數類型中,只應使用int
。在適當情況下,可以使用size_t
和ptrdiff_t
等標準類型別名。
我們經常使用int
來表示已知不會過大的整數(如循環計數器)。對于這種情況直接使用傳統的int
即可。應假設int
至少為32位,但不要假設其超過32位。若需要64位整數類型,請使用int64_t
或uint64_t
。
對于可能較大的整數,使用int64_t
。
除非有特殊需求(如表示位模式而非數值,或需要明確的2^N模溢出),否則不應使用uint32_t
等無符號整數類型。特別要注意,不要用無符號類型來表示"數值永不為負"的概念,應改用斷言來實現這個目的。
如果代碼是返回大小的容器,請確保使用能容納所有可能情況的類型。如有疑問,優先選擇更大的類型而非更小的類型。
轉換整數類型時需謹慎。整數轉換和提升可能導致未定義行為,引發安全漏洞等問題。
關于無符號整數
無符號整數非常適合表示位域和模運算。由于歷史原因,C++標準也使用無符號整數來表示容器的大小——標準委員會的許多成員認為這是一個錯誤,但目前實際上已無法修正。無符號算術運算并不模擬簡單整數的行為,而是被標準定義為模運算(在溢出/下溢時回繞),這意味著編譯器無法診斷一大類錯誤。在其他情況下,這種定義行為會阻礙優化。
盡管如此,混合使用有符號和無符號整數類型同樣會導致大量問題。我們能提供的最佳建議是:盡量使用迭代器和容器而非指針和大小參數,盡量避免混合符號類型,并盡可能避免使用無符號類型(除非用于表示位域或模運算)。不要僅僅為了斷言變量非負就使用無符號類型。
浮點類型
在C++內置的浮點類型中,僅使用float
和double
兩種類型。可以假定這兩種類型分別對應IEEE-754標準的binary32和binary64格式。
不要使用long double
類型,因為它會導致不可移植的結果。
架構可移植性
編寫具備架構可移植性的代碼。不要依賴特定于單一處理器的CPU特性。
- 打印數值時,使用類型安全的數字格式化庫,如
absl::StrCat
、absl::Substitute
、absl::StrFormat
或std::ostream
,而非printf
系列函數。 - 在進程內外傳輸結構化數據時,使用 Protocol Buffers 等序列化庫進行編碼,而非直接復制內存表示形式。
- 若需將內存地址作為整數處理,應將其存儲在
uintptr_t
類型中,而非uint32_t
或uint64_t
。 - 必要時使用大括號初始化來創建64位常量。例如:
int64_t my_value{0x123456789};uint64_t my_mask{uint64_t{3} << 48};
- 使用可移植的浮點類型;避免使用
long double
。 - 使用可移植的整數類型;避免使用
short
、long
和long long
。
預處理器宏
應避免定義宏,尤其在頭文件中;優先使用內聯函數、枚舉和const
常量。若必須使用宏,需添加項目專屬前綴。禁止通過宏來定義C++ API的組成部分。
宏會導致你看到的代碼與編譯器處理的代碼不一致,這可能引發意外行為——特別是由于宏具有全局作用域。
當宏被用于定義C++ API組件時(尤其是公開API),其引發的問題會尤為嚴重。開發者錯誤使用接口時,編譯器給出的每條錯誤信息都必須解釋宏如何構建該接口。重構和分析工具在更新接口時也會面臨極大困難。因此,我們明確禁止此類用法。例如,應避免如下模式:
class WOMBAT_TYPE(Foo) {// ...public:EXPAND_PUBLIC_WOMBAT_API(Foo)
EXPAND_WOMBAT_COMPARISONS(Foo, ==, <)
};
幸運的是,在C++中宏遠不如在C語言中那樣必不可少。對于需要內聯的性能關鍵代碼,應使用內聯函數而非宏;對于存儲常量,應使用const
變量而非宏;對于"縮寫"長變量名,應使用引用而非宏;至于條件編譯代碼…除非是防止頭文件重復包含的#define
守衛,否則根本不要用宏——這會讓測試變得異常困難。
雖然宏能實現其他技術無法完成的功能(在代碼庫中尤其是底層庫仍能看到它們的身影),且某些特性(如字符串化、連接等)無法通過語言本身實現,但在使用宏前務必慎重考慮是否存在非宏的替代方案。若需通過宏定義接口,請聯系項目負責人申請豁免此規則。
遵循以下模式可規避多數宏相關的問題:
- 不要在
.h
文件中定義宏 - 使用宏前立即
#define
,使用后立即#undef
- 不要直接
#undef
現有宏后替換為自己的定義,應選擇具有唯一性的名稱 - 避免使用會展開為不平衡C++結構的宏,至少需完整記錄該行為
- 盡量不要使用
##
生成函數/類/變量名
強烈反對在頭文件中導出宏(即在頭文件中定義宏且未在結尾前#undef
)。若必須導出,必須確保宏具有全局唯一名稱——采用項目命名空間的大寫形式作為前綴(例如PROJECTNAME_MACRO
)。
0 與 nullptr/NULL 的區別
對于指針,使用 nullptr
;對于字符,使用 '\0'
(而不是字面量 0
)。
在處理指針(地址值)時,應使用 nullptr
,因為它能提供類型安全性。
空字符應使用 '\0'
。使用正確的類型能使代碼更具可讀性。
sizeof
優先使用 sizeof(varname)
而非 sizeof(type)
。
當獲取特定變量的大小時,應使用 sizeof(varname)
。若后續有人修改變量類型,sizeof(varname)
會自動適應更新。只有在處理與具體變量無關的代碼時(例如管理外部或內部數據格式,且使用合適的 C++ 類型變量不方便時),才考慮使用 sizeof(type)
。
MyStruct data;
memset(&data, 0, sizeof(data));
memset(&data, 0, sizeof(MyStruct));
if (raw_size < sizeof(int)) {LOG(ERROR) << "compressed record not big enough for count: " << raw_size;return false;
}
類型推導(包括auto)
僅在類型推導能使代碼對不熟悉項目的讀者更清晰,或能提升代碼安全性時使用。不要僅僅為了避免編寫顯式類型的不便而使用它。
C++中有多種上下文允許(甚至要求)編譯器推導類型,而非在代碼中顯式寫出:
- 函數模板參數推導
調用函數模板時可省略顯式模板參數。編譯器會根據函數實參類型推導這些參數:
template <typename T> void f(T t); f(0); // 調用f<int>(0)
auto
變量聲明
變量聲明可用auto
關鍵字替代類型。編譯器根據初始化表達式推導類型,規則與函數模板參數推導相同(只要不使用花括號替代圓括號):
auto a = 42; // a是int類型 auto& b = a; // b是int&類型 auto c = b; // c是int類型 auto d{42}; // d是int類型,而非std::initializer_list<int>
auto
可搭配const
限定符,也可作為指針或引用類型的一部分,且(C++17起)可作為非類型模板參數。此語法的罕見變體使用decltype(auto)
替代auto
,此時推導類型是對初始化器應用decltype
的結果。- 函數返回類型推導
auto
(及decltype(auto)
)也可替代函數返回類型。編譯器根據函數體內的return
語句推導返回類型,規則與變量聲明相同:
auto f() { return 0; } // f的返回類型是int
Lambda表達式的返回類型可通過省略返回類型(而非顯式使用auto
)觸發推導。需注意,函數的尾置返回類型語法雖在返回類型位置使用auto
,但不依賴類型推導,僅是顯式返回類型的替代語法。 - 泛型lambda
Lambda表達式可用auto
替代部分或全部參數類型。這會使lambda的調用運算符成為函數模板(而非普通函數),每個auto
參數對應獨立的模板參數:
// 按降序排序vec std::sort(vec.begin(), vec.end(), [](auto lhs, auto rhs) { return lhs > rhs; });
- Lambda初始化捕獲
Lambda捕獲可含顯式初始化器,用于聲明全新變量(而非僅捕獲現有變量):
[x = 42, y = "foo"] { ... } // x是int類型,y是const char*類型
此語法不允許指定類型,而是按auto
變量規則推導。 - 類模板參數推導
參見下文。 - 結構化綁定
用auto
聲明元組、結構體或數組時,可為單個元素指定名稱(而非整個對象)。這些名稱稱為"結構化綁定",整個聲明稱為"結構化綁定聲明"。此語法無法指定外圍對象或單個綁定的類型:
auto [iter, success] = my_map.insert({key, value}); if (!success) { iter->second = value; }
auto
可搭配const
、&
和&&
限定符,但注意這些限定符實際應用于匿名元組/結構體/數組,而非單個綁定。綁定類型的判定規則較復雜,結果通常符合直覺,但綁定類型通常不會是引用(即使聲明了引用,其行為通常仍類似引用)。
(上述總結省略了許多細節和注意事項,詳見各鏈接。)
- C++類型名可能冗長繁瑣,尤其涉及模板或命名空間時
- 當類型名在單個聲明或小范圍代碼中重復出現時,重復可能無助于可讀性
- 有時類型推導更安全,可避免意外拷貝或類型轉換
顯式類型通常使C++代碼更清晰,尤其是當類型推導依賴遠處代碼信息時。例如在以下表達式中:
auto foo = x.add_foo();
auto i = y.Find(key);
如果 y
的類型不太明確,或者 y
的聲明在很早之前的代碼行中,那么最終的類型可能并不顯而易見。
程序員必須清楚何時類型推導會產生引用類型、何時不會,否則可能會在無意中得到對象的副本而非引用。
如果將推導出的類型用作接口的一部分,程序員可能在僅意圖修改其值時意外改變了類型,從而導致比預期更劇烈的 API 變更。
基本原則是:僅當類型推導能使代碼更清晰或更安全時才使用它,不要僅僅為了避免顯式寫出類型的麻煩而使用。在判斷代碼是否更清晰時,請記住你的讀者不一定是你的團隊成員,也不一定熟悉你的項目。因此,對你和審閱者而言看似多余的類型信息,往往能為其他人提供有用的信息。例如,你可以認為 make_unique<Foo>()
的返回類型顯而易見,但 MyWidgetFactory()
的返回類型很可能并非如此。
這些原則適用于所有形式的類型推導,但具體細節會有所不同,如下文各節所述。
函數模板參數推導
函數模板參數推導在絕大多數情況下都是可行的。類型推導是與函數模板交互時的預期默認方式,因為它使得函數模板能夠像無限多個普通函數重載一樣工作。因此,函數模板的設計幾乎總是確保模板參數推導既清晰又安全,或者直接無法通過編譯。
局部變量類型推導
對于局部變量,可以通過類型推導消除那些顯而易見或無關緊要的類型信息,使代碼更加清晰,從而讓讀者專注于代碼中真正有意義的部分:
std::unique_ptr<WidgetWithBellsAndWhistles> widget =std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
absl::flat_hash_map<std::string,std::unique_ptr<WidgetWithBellsAndWhistles>>::const_iteratorit = my_map_.find(key);
std::array<int, 6> numbers = {4, 8, 15, 16, 23, 42};
auto widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
auto it = my_map_.find(key);
std::array numbers = {4, 8, 15, 16, 23, 42};
類型有時會混雜有用信息和樣板代碼,比如上面例子中的 it
:很明顯這是一個迭代器類型,而且在許多場景下容器類型甚至鍵類型并不重要,但值類型的信息可能很有用。這種情況下,通常可以通過定義具有明確類型的局部變量來傳達相關信息:
if (auto it = my_map_.find(key); it != my_map_.end()) {WidgetWithBellsAndWhistles& widget = *it->second;// Do stuff with `widget`
}
如果類型是模板實例,且參數是樣板代碼但模板本身具有信息性,可以使用類模板參數推導來省略樣板代碼。不過,這種情況真正能帶來顯著收益的案例相當罕見。請注意,類模板參數推導還需要遵守單獨的樣式規則。
當存在更簡單的替代方案時,不要使用decltype(auto)
;由于這是一個相當晦澀的特性,它會顯著降低代碼清晰度。
返回類型推導
僅在函數體包含極少量return
語句且其他代碼極少時使用返回類型推導(適用于函數和lambda表達式),否則讀者可能無法一眼看出返回類型。此外,僅當函數或lambda的作用域非常狹窄時才使用該特性,因為具有推導返回類型的函數不會定義抽象邊界:其實現就是接口。特別注意,頭文件中的公共函數幾乎永遠不應使用推導返回類型。
參數類型推導
使用 lambda 表達式的 auto
參數類型時應謹慎,因為實際類型由調用該 lambda 的代碼決定,而非 lambda 自身的定義。因此,除非滿足以下情況之一,否則顯式聲明類型通常會更清晰:
- lambda 在定義處附近被顯式調用(讀者能輕松查看兩者上下文);
- lambda 被傳遞到一個接口,該接口的調用參數非常明確(例如前文提到的
std::sort
場景)。
Lambda 初始化捕獲
初始化捕獲遵循更具體的樣式規則,該規則在很大程度上取代了類型推導的通用規則。
結構化綁定
與其他類型推導形式不同,結構化綁定實際上能為讀者提供額外信息——通過為較大對象的元素賦予有意義的名稱。這意味著在某些情況下,即使使用auto
無法提升可讀性,結構化綁定聲明相比顯式類型聲明仍能帶來凈可讀性提升。當對象是pair或tuple時(如前文insert
示例所示),結構化綁定尤為有益,因為這些類型本身缺乏有意義的字段名。但請注意,除非像insert
這樣的現有API強制要求,否則通常不應使用pair或tuple。
若被綁定的對象是結構體,有時提供與具體使用場景更貼切的名稱會有所幫助,但需注意這可能使得名稱對讀者而言不如原字段名易于識別。我們建議:當綁定名稱與底層字段名不一致時,采用與函數參數注釋相同的語法,通過注釋注明原始字段名。
auto [/*field_name1=*/bound_name1, /*field_name2=*/bound_name2] = ...
與函數參數注釋類似,這能讓工具檢測出字段順序是否正確。
類模板參數推導
僅當模板明確聲明支持該特性時,才使用類模板參數推導功能。
類模板參數推導(常縮寫為"CTAD")發生在以下場景:當變量聲明時使用了模板類名,但未提供模板參數列表(甚至不包含空尖括號):
std::array a = {1, 2, 3}; // `a` is a std::array<int, 3>
編譯器通過模板的"推導指引"從初始化器中推導參數,這些指引可以是顯式或隱式的。
顯式推導指引看起來像帶有尾置返回類型的函數聲明,區別在于沒有開頭的 auto
,且函數名就是模板名。例如,上面的例子依賴于 std::array
的這個推導指引:
namespace std {
template <class T, class... U>
array(T, U...) -> std::array<T, 1 + sizeof...(U)>;
}
主模板(相對于模板特化)中的構造函數也會隱式定義推導指南。
當你聲明一個依賴CTAD的變量時,編譯器會使用構造函數重載解析規則選擇推導指南,該指南的返回類型將成為變量的類型。
CTAD有時能幫助你減少代碼中的樣板內容。
從構造函數生成的隱式推導指南可能存在不良行為,甚至完全錯誤。這對于C++17引入CTAD之前編寫的構造函數尤為棘手,因為那些構造函數的作者無法預知(更不用說修復)其構造函數會給CTAD帶來的問題。此外,添加顯式推導指南來修復這些問題可能會破壞依賴隱式推導指南的現有代碼。
CTAD也存在許多與auto
相同的缺點,因為它們都是從初始化表達式推斷變量全部或部分類型的機制。雖然CTAD比auto
能向代碼閱讀者提供更多信息,但它同樣沒有給出明顯的提示表明信息已被省略。
除非模板維護者通過提供至少一個顯式推導指南明確支持CTAD的使用(std
命名空間中的所有模板也被假定為已支持),否則不應在給定模板中使用CTAD。如果編譯器支持,應通過警告來強制執行此規則。
CTAD的使用還必須遵循類型推導的通用規則。
指定初始化器
僅使用符合 C++20 標準的指定初始化器語法。
指定初始化器 是一種允許通過顯式命名字段來初始化聚合體(“普通舊式結構體”)的語法:
struct Point {float x = 0.0;float y = 0.0;float z = 0.0;};
Point p = {.x = 1.0,.y = 2.0,// z will be 0.0};
顯式列出的字段將按照指定方式進行初始化,其余字段則采用與傳統聚合初始化表達式(如Point{1.0, 2.0}
)相同的方式初始化。
指定初始化器能創建便捷且高度可讀的聚合表達式,尤其適用于字段順序不如上述Point
示例直觀的結構體。
雖然指定初始化器長期作為C標準的一部分存在,且被C++編譯器以擴展形式支持,但在C++20之前并未得到C++標準的正式支持。
C++標準中的規則比C語言及編譯器擴展更為嚴格,要求指定初始化器的順序必須與結構體定義中字段的聲明順序一致。因此在上例中,按照C++20標準先初始化x
再初始化z
是合法的,但先初始化y
再初始化x
則不符合規范。
請僅使用與C++20標準兼容的形式來應用指定初始化器:確保初始化器順序與結構體定義中對應字段的聲明順序完全一致。
Lambda 表達式
在適當場合使用 lambda 表達式。當 lambda 會脫離當前作用域時,建議采用顯式捕獲。
Lambda 表達式是創建匿名函數對象的簡潔方式。在需要將函數作為參數傳遞時,它們通常很有用。例如:
std::sort(v.begin(), v.end(), [](int x, int y) {return Weight(x) < Weight(y);
});
它們還允許通過顯式指定變量名或隱式使用默認捕獲的方式,從外圍作用域中捕獲變量。顯式捕獲要求列出每個變量,并指定是按值捕獲還是按引用捕獲:
int weight = 3;
int sum = 0;
// Captures `weight` by value and `sum` by reference.
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 = ...;
// Captures `lookup_table` by reference, sorts `indices` by the value
// of the associated element in `lookup_table`.
std::sort(indices.begin(), indices.end(), [&](int a, int b) {return lookup_table[a] < lookup_table[b];
});
變量捕獲也可以包含顯式初始化器,這適用于通過值捕獲僅移動(move-only)變量的情況,或處理普通引用捕獲或值捕獲無法覆蓋的其他場景。
std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)] () {...
}
這種捕獲方式(通常稱為"初始化捕獲"或"廣義lambda捕獲")實際上不需要從外圍作用域"捕獲"任何內容,甚至可以使用與外圍作用域無關的名稱;該語法是定義lambda對象成員的完全通用方式。
[foo = std::vector<int>({1, 2, 3})] () {...
}
帶有初始化器的捕獲類型推導規則與 auto
相同。
- 相比其他定義函數對象傳遞給STL算法的方式,Lambda表達式更加簡潔,可顯著提升代碼可讀性。
- 合理使用默認捕獲能消除冗余,并突出與默認情況不同的重要例外。
- Lambda表達式、
std::function
和std::bind
可組合使用作為通用回調機制,便于編寫接受綁定函數作為參數的函數。 - Lambda中的變量捕獲可能引發懸垂指針問題,特別是當Lambda逃逸當前作用域時。
- 按值默認捕獲可能產生誤導,因為它無法避免懸垂指針問題。按值捕獲指針不會進行深拷貝,因此其生命周期問題通常與引用捕獲相同。當按值捕獲
this
時尤其容易混淆,因為this
的使用常常是隱式的。 - 捕獲實際上會聲明新變量(無論是否帶初始化器),但其語法與C++中任何其他變量聲明都截然不同。具體而言,這種語法既沒有變量類型的位置,也沒有
auto
占位符(盡管初始化捕獲可通過類型轉換等方式間接體現)。這可能導致難以識別它們是變量聲明。 - 初始化捕獲本質上依賴類型推導,存在與
auto
相同的許多缺點,且語法本身不會提示讀者正在進行類型推導。 - 過度使用Lambda可能導致代碼失控,過長的嵌套匿名函數會使代碼難以理解。
- 在適當場景使用Lambda表達式時,請遵循格式規范。
- 若Lambda可能逃逸當前作用域,應優先使用顯式捕獲。例如,避免這樣寫:
{Foo foo;...executor->Schedule([&] { Frobnicate(foo); })...}// BAD! The fact that the lambda makes use of a reference to `foo` and// possibly `this` (if `Frobnicate` is a member function) may not be// apparent on a cursory inspection. If the lambda is invoked after// the function returns, that would be bad, because both `foo`// and the enclosing object could have been destroyed.
建議寫作方式:
{Foo foo;...executor->Schedule([&foo] { Frobnicate(foo); })...}// BETTER - The compile will fail if `Frobnicate` is a member// function, and it's clearer that `foo` is dangerously captured by// reference.
- 僅當 lambda 的生命周期明顯短于任何潛在捕獲對象時,才使用默認引用捕獲 (
[&]
)。 - 僅當需要為簡短 lambda 綁定少量變量時使用默認值捕獲 (
[=]
),此時捕獲的變量集一目了然,且不會隱式捕獲this
。(這意味著出現在非靜態類成員函數中并引用其體內非靜態類成員的 lambda,必須顯式捕獲this
或通過[&]
捕獲。)盡量避免對冗長或復雜的 lambda 使用默認值捕獲。 - 捕獲僅應用于實際從外圍作用域捕獲變量。不要使用帶初始化器的捕獲來引入新名稱,或實質上改變現有名稱的含義。相反,應以常規方式聲明新變量再捕獲它,或避免使用 lambda 簡寫而顯式定義函數對象。
- 關于參數和返回類型的指定指引,請參閱類型推導章節。
模板元編程
避免使用復雜的模板編程技術。
模板元編程是指利用C++模板實例化機制具有圖靈完備性這一特性,在類型領域執行任意編譯期計算的一系列技術。
模板元編程能夠實現類型安全且高性能的極致靈活接口。諸如GoogleTest、std::tuple
、std::function
和Boost.Spirit等設施都離不開這項技術。
但模板元編程技術往往只有語言專家才能理解。使用復雜模板方式的代碼通常難以閱讀,調試和維護也極為困難。
模板元編程經常導致極其糟糕的編譯期錯誤信息:即便接口設計簡單,當用戶操作失誤時,復雜的實現細節仍會暴露無遺。
模板元編程會加大重構工具的難度,從而阻礙大規模重構。首先,模板代碼會在多個上下文中展開,很難驗證轉換在所有上下文中都合理;其次,部分重構工具基于模板展開后的AST結構工作,很難自動追溯到需要重寫的原始源代碼結構。
雖然模板元編程有時能實現更簡潔易用的接口,但也容易誘使開發者過度炫技。最合理的應用場景是少量底層組件,通過大量復用分攤額外的維護成本。
在使用模板元編程或其他復雜模板技術前請三思:考慮當您轉至其他項目后,團隊普通成員是否能充分理解代碼進行維護;非C++程序員或代碼庫瀏覽者能否理解錯誤信息或追蹤目標函數的執行流程。如果您正在使用遞歸模板實例化、類型列表、元函數、表達式模板,或依賴SFINAE、sizeof
技巧檢測函數重載決議,那么很可能已經過度設計了。
若必須使用模板元編程,您需要投入大量精力來最小化和隔離復雜性。應盡可能將元編程隱藏為實現細節,保證用戶可見頭文件的可讀性,并對精巧代碼進行詳盡注釋。需仔細記錄代碼使用方式,并說明"生成"代碼的形態。要特別關注用戶出錯時編譯器產生的錯誤信息——這些信息是用戶界面的一部分,必要時應該調整代碼,確保錯誤信息從用戶角度易于理解和操作。
概念與約束的使用準則
應謹慎使用概念。通常,概念和約束僅應用于那些在C++20之前會使用模板的場景。避免在頭文件中引入新概念,除非這些頭文件被標記為庫的內部實現。不要定義編譯器無法強制實施的概念。優先選擇約束而非模板元編程,并避免使用template<*概念* T>
語法,改用requires(*概念<T>*)
語法。
concept
關鍵字是一種定義模板參數需求(如類型特征或接口規范)的新機制。requires
關鍵字則提供了對模板施加匿名約束并在編譯時驗證約束是否滿足的能力。概念與約束常結合使用,但也可獨立應用。
- 優勢
- 概念能讓編譯器在涉及模板時生成更清晰的錯誤信息,減少困惑并顯著提升開發體驗。
- 概念可減少定義和使用編譯時約束所需的樣板代碼,提升代碼可讀性。
- 約束能實現一些模板和SFINAE技術難以達成的功能。
- 風險
- 與模板類似,概念可能大幅增加代碼復雜度,降低可理解性。
- 概念語法易造成混淆,因其在使用處看起來類似類類型。
- 概念(尤其在API邊界)會增加代碼耦合度、僵化性和固化風險。
- 概念可能重復函數體內的邏輯,導致代碼冗余和維護成本上升。
- 概念作為獨立命名實體可在多處使用,但其底層契約的真實來源可能模糊,導致聲明需求與實際需求隨時間推移產生偏差。
- 概念與約束會以新穎且非顯而易見的方式影響重載決議。
- 與SFINAE類似,約束會加大大規模代碼重構的難度。
實施規范
- 標準庫預定義概念應優先于類型特征(例如:若C++20之前會用
std::is_integral_v
,則C++20代碼應改用std::integral
)。 - 優先采用現代約束語法(通過
requires(*條件*)
),避免遺留模板元編程結構(如std::enable_if<*條件*>
)及template<*概念* T>
語法。 - 禁止手動重新實現現有概念或特征。例如:應使用
requires(std::default_initializable<T>)
而非requires(requires { T v; })
。 - 新增
concept
聲明應當罕見,且僅限庫內部定義,避免暴露在API邊界。更廣泛地說,若在C++17中不會使用等效模板方案,則不應使用概念或約束。 - 禁止定義與函數體重復的概念,或強加那些通過閱讀代碼體或錯誤信息即可明確的無實質意義的需求。例如避免如下情況:
template <typename T> // Bad - redundant with negligible benefit
concept Addable = std::copyable<T> && requires(T a, T b) { a + b; };
template <Addable T>
T Add(T x, T y, T z) { return x + y + z; }
相反,除非能證明概念能為特定情況帶來顯著改進(例如針對深層嵌套或不直觀需求產生的錯誤消息),否則應優先保持代碼作為普通模板。
概念應當能被編譯器靜態驗證。不要使用那些主要優勢來自語義(或其他無法強制執行的)約束的概念。對于編譯時無法強制的要求,應通過注釋、斷言或測試等其他機制來實現。
C++20 模塊
不要使用 C++20 模塊。
C++20 引入了“模塊”這一新語言特性,旨在替代傳統的頭文件文本包含方式。為此新增了三個關鍵字:module
、export
和 import
。
模塊徹底改變了 C++ 的編寫和編譯方式,我們仍在評估它們未來如何融入 Google 的 C++ 生態系統。此外,當前的構建系統、編譯器及其他工具鏈對模塊的支持尚不完善,關于編寫和使用模塊的最佳實踐也需要進一步探索。
協程
僅允許通過項目負責人批準的庫來使用 C++20 協程。
C++20 引入了協程:這類函數可以暫停執行并在之后恢復。它們在異步編程中特別便利,能顯著優于傳統的基于回調的框架。
與大多數其他編程語言(如 Kotlin、Rust、TypeScript 等)不同,C++ 并未提供具體的協程實現。相反,它要求用戶自行實現可等待類型(通過承諾類型),該類型決定了協程參數類型、協程執行方式,并允許在協程執行的不同階段運行用戶自定義代碼。
- 協程可用于實現針對特定任務(如異步編程)的安全高效庫。
- 協程在語法上幾乎與非協程函數相同,這使得它們的可讀性遠高于替代方案。
- 高度可定制性使得相比替代方案,能在協程中插入更詳細的調試信息。
- 目前沒有標準的協程承諾類型,每個用戶自定義實現在某些方面都可能具有獨特性。
- 由于返回類型、承諾類型中的各種可定制鉤子以及編譯器生成代碼之間存在關鍵性交互,僅通過閱讀用戶代碼極難推斷協程語義。
- 協程的眾多可定制特性會引入大量陷阱,尤其是懸垂引用和競態條件問題。
總之,設計高質量且可互操作的協程庫需要大量復雜工作、周密思考和完善的文檔。
僅使用項目負責人批準在全項目范圍內使用的協程庫。切勿自行實現承諾類型或可等待類型。
Boost庫使用規范
僅允許使用Boost庫集合中經過批準的庫。
Boost庫集合是一個廣受歡迎的、經過同行評審的免費開源C++庫集合。Boost代碼通常具有極高的質量,具備廣泛的移植性,并填補了C++標準庫中的許多重要空白,例如類型特征和更優的綁定器。
部分Boost庫提倡的編碼實踐可能會影響代碼可讀性,例如元編程和其他高級模板技術,以及過度"函數式"的編程風格。為了確保所有可能閱讀和維護代碼的貢獻者都能保持高水平的可讀性,我們僅允許使用Boost功能的一個批準子集。目前允許使用的庫包括:
- Call Traits 來自
boost/call_traits.hpp
- Compressed Pair 來自
boost/compressed_pair.hpp
- Boost圖庫(BGL) 來自
boost/graph
,但不包括序列化(adj_list_serialize.hpp
)以及并行/分布式算法和數據結構(boost/graph/parallel/*
和boost/graph/distributed/*
) - Property Map 來自
boost/property_map
,但不包括并行/分布式屬性映射(boost/property_map/parallel/*
) - Iterator 來自
boost/iterator
- Polygon中涉及Voronoi圖構造且不依賴Polygon其他部分的內容:
boost/polygon/voronoi_builder.hpp
、boost/polygon/voronoi_diagram.hpp
和boost/polygon/voronoi_geometry_type.hpp
- Bimap 來自
boost/bimap
- 統計分布和函數 來自
boost/math/distributions
- 特殊函數 來自
boost/math/special_functions
- 求根與最小化函數 來自
boost/math/tools
- Multi-index 來自
boost/multi_index
- Heap 來自
boost/heap
- Container中的扁平容器:
boost/container/flat_map
和boost/container/flat_set
- Intrusive 來自
boost/intrusive
boost/sort
庫- Preprocessor 來自
boost/preprocessor
我們正在積極考慮將其他Boost功能添加到列表中,因此未來可能會擴展此列表。
禁用標準庫特性
與 Boost 類似,某些現代 C++ 庫功能會助長降低代碼可讀性的編程實踐——例如移除對讀者可能有幫助的冗余檢查(如類型名稱),或鼓勵模板元編程。其他擴展功能則通過現有機制提供了重復功能,可能導致混淆和轉換成本。
以下 C++ 標準庫特性禁止使用:
- 編譯時有理數 (
<ratio>
),因其與更重度依賴模板的接口風格緊密耦合。 <cfenv>
和<fenv.h>
頭文件,因許多編譯器無法可靠支持這些特性。<filesystem>
頭文件,其缺乏足夠的測試支持,并存在固有的安全漏洞。
非標準擴展
除非另有說明,否則不得使用C++的非標準擴展。
編譯器支持許多不屬于標準C++的擴展功能。這些擴展包括GCC的__attribute__
、內建函數如__builtin_prefetch
或SIMD指令、#pragma
、內聯匯編、__COUNTER__
、__PRETTY_FUNCTION__
、復合語句表達式(例如foo = ({ int x; Bar(&x); x })
)、變長數組和alloca()
,以及"Elvis運算符"a?:b
。
- 非標準擴展可能提供標準C++中不存在的有用功能
- 某些重要的編譯器性能優化指引只能通過擴展來實現
- 非標準擴展并非所有編譯器都支持,使用會降低代碼可移植性
- 即使目標編譯器都支持某個擴展,其具體實現往往缺乏明確規范,不同編譯器間可能存在細微行為差異
- 非標準擴展增加了語言特性,代碼閱讀者必須了解這些特性才能理解代碼
- 跨架構移植時需要為使用非標準擴展的代碼額外付出移植成本
禁止直接使用非標準擴展。但可以通過項目指定的跨平臺移植頭文件中提供的封裝接口來使用這些擴展功能,這些封裝接口內部可以使用非標準擴展實現。
別名
公開別名是為了方便API用戶使用,應當清晰地記錄在文檔中。
有幾種方法可以創建其他實體的別名:
using Bar = Foo;
typedef Foo Bar; // But prefer `using` in C++ code.
using ::other_namespace::Foo;
using enum MyEnumType; // Creates aliases for all enumerators in MyEnumType.
在新代碼中,優先使用 using
而非 typedef
,因為它能提供與 C++ 其余部分更一致的語法,并且支持模板。
與其他聲明類似,頭文件中定義的別名屬于該頭文件公開 API 的一部分——除非它們位于函數定義內、類的私有部分或顯式標記的內部命名空間中。位于上述區域或 .cc
文件中的別名屬于實現細節(因為客戶端代碼無法引用它們),不受此規則限制。
- 別名能通過簡化冗長或復雜的名稱提升可讀性
- 別名能通過在單一位置命名 API 中重復使用的類型來減少重復,這可能便于后續修改類型
- 當別名置于客戶端可引用的頭文件時,會增加該頭文件 API 的實體數量,提高其復雜性
- 客戶端可能輕易依賴公開別名中的非預期細節,導致后續修改困難
- 開發者可能為僅用于實現的類型創建公開別名,卻未考慮其對 API 和維護性的影響
- 別名可能導致命名沖突風險
- 別名可能通過為熟悉的結構賦予陌生名稱而降低可讀性
- 類型別名可能導致 API 契約不清晰:無法明確別名是否保證與原始類型完全一致、具有相同 API,還是僅在特定場景下可用
不要僅為減少實現中的輸入量而在公開 API 中添加別名;僅當明確希望客戶端使用時才這樣做。
定義公開別名時,應記錄新名稱的意圖,包括是否保證始終與當前別名類型相同,還是僅提供有限兼容性。這能讓用戶清楚是否能將類型視為可互換,或是否需要遵循特定規則,同時為實現保留一定的修改自由度。
不要在公開 API 中使用命名空間別名。(另見命名空間)
例如,以下別名明確記錄了它們在客戶端代碼中的預期用途:
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 {
// Bad: none of these say how they should be used.
using DataPoint = ::foo::Bar*;
using ::std::unordered_set; // Bad: just for local convenience
using ::std::hash; // Bad: just for local convenience
typedef unordered_set<DataPoint, hash<DataPoint>, DataPointComparator> TimeSeries;
} // namespace mynamespace
然而,在函數定義、類的private
部分、顯式標記的內部命名空間以及.cc
文件中,使用局部便捷別名是可以接受的。
// In a .cc file
using ::foo::Bar;
Switch 語句
當不基于枚舉值進行條件判斷時,switch 語句必須始終包含 default
分支(對于枚舉值的情況,編譯器會在存在未處理枚舉值時發出警告)。如果 default 分支理論上不應被執行,應將其視為錯誤情況處理。例如:
switch (var) {case 0: {...break;}case 1: {...break;}default: {LOG(FATAL) << "Invalid value in switch statement: " << var;}
}
從一個 case 標簽向下貫穿到另一個 case 標簽時,必須使用 [[fallthrough]];
屬性進行標注。[[fallthrough]];
應放置在執行流程實際發生貫穿到下一個 case 標簽的位置。常見例外情況是連續的 case 標簽之間沒有插入代碼,此時不需要標注。
switch (x) {case 41: // No annotation needed here.case 43:if (dont_be_picky) {// Use this instead of or along with annotations in comments.[[fallthrough]];} else {CloseButNoCigar();break;}case 42:DoSomethingSpecial();[[fallthrough]];default:DoSomethingGeneric();break;
}
包容性語言
在所有代碼中,包括命名和注釋,請使用包容性語言,避免使用其他程序員可能認為不尊重或冒犯的術語(例如"master"和"slave"、“blacklist"和"whitelist"或"redline”),即使這些術語表面上具有中性含義。同樣,請使用性別中立語言,除非您特指某個具體的人(并使用其代詞)。例如,對未指定性別的人使用"they"/“them”/“their”(即使是單數情況),對軟件、計算機和其他非人物體使用"it"/“its”。
命名規范
最重要的代碼一致性規則體現在命名約定上。通過名稱的風格,我們就能立即判斷出該實體是什么類型:類型、變量、函數、常量、宏等,而無需查找其聲明。我們大腦的模式識別機制高度依賴這些命名規則。
關于命名的風格規則看似主觀,但我們認為一致性遠比個人偏好更重要。因此無論您是否認同這些規則,都必須遵守。
在以下命名規則中,“單詞"指任何不含內部空格的英文書寫單元。單詞可以全部小寫并用下劃線連接(“snake_case”),也可以采用混合大小寫形式(首字母大寫的"camelCase"或全詞首字母大寫的"PascalCase”)。
命名選擇
為事物賦予能讓新讀者(即使是不同團隊的成員)一眼理解其用途或意圖的名稱。不必擔心占用水平空間,因為讓代碼對新讀者立即可理解要重要得多。
考慮名稱使用的上下文環境。即使名稱在遠離其定義的地方使用,也應保持描述性。但名稱不應通過重復當前上下文中已存在的信息來分散讀者注意力。通常這意味著描述性應與名稱的可見范圍成正比:頭文件中聲明的自由函數可能需要提及所屬庫名,而局部變量則無需說明所在函數。
盡量減少使用項目外部人員可能不熟悉的縮寫(特別是首字母縮略詞)。不要通過刪除單詞中的字母來縮寫。使用縮寫時,建議將其視為一個"單詞"并大寫,例如StartRpc()
優于StartRPC()
。經驗法則是:如果該縮寫被維基百科收錄,則基本可用。注意某些通用縮寫是可接受的,如用i
表示循環索引,T
表示模板參數。
高頻出現的名稱與普通名稱不同:少量"詞匯級"名稱被廣泛復用,始終自帶上下文。這類名稱往往簡短甚至縮寫,其完整含義來自顯式的長篇文檔而非定義處的注釋或名稱本身。例如absl::Status
在開發指南中有專屬頁面說明其正確用法。雖然不常需要定義新詞匯級名稱,但若需定義,應通過額外設計評審確保所選名稱在廣泛使用時仍能良好工作。
class MyClass {public:int CountFooErrors(const std::vector<Foo>& foos) {int n = 0; // Clear meaning given limited scope and contextfor (const auto& foo : foos) {...++n;}return n;}// Function comment doesn't need to explain that this returns non-OK on// failure as that is implied by the `absl::Status` return type, but it// might document behavior for some specific codes.absl::Status DoSomethingImportant() {std::string fqdn = ...; // Well-known abbreviation for Fully Qualified Domain Namereturn absl::OkStatus();}private:const int kMaxAllowedConnections = ...; // Clear meaning within context
};
class MyClass {public:int CountFooErrors(const std::vector<Foo>& foos) {int total_number_of_foo_errors = 0; // Overly verbose given limited scope and contextfor (int foo_index = 0; foo_index < foos.size(); ++foo_index) { // Use idiomatic `i`...++total_number_of_foo_errors;}return total_number_of_foo_errors;}// A return type with a generic name is unclear without widespread education.Result DoSomethingImportant() {int cstmr_id = ...; // Deletes internal letters}private:const int kNum = ...; // Unclear meaning within broad scope
};
文件名規范
文件名應全部使用小寫字母,可以包含下劃線(_
)或連字符(-
)。請遵循項目已有的命名慣例。如果沒有統一的本地規范,建議優先使用"_
"。
可接受的文件名示例:
my_useful_class.cc
my-useful-class.cc
myusefulclass.cc
myusefulclass_test.cc // 已棄用_unittest和_regtest后綴
C++源文件應使用.cc
作為擴展名,頭文件使用.h
擴展名。需要被特定位置包含的文本文件應使用.inc
擴展名(另見自包含頭文件章節)。
避免使用/usr/include
中已存在的文件名(如db.h
)。
通常應使文件名盡可能具體。例如,使用http_server_logs.h
而非泛泛的logs.h
。一個典型做法是使用成對的文件命名,例如foo_bar.h
和foo_bar.cc
,其中定義名為FooBar
的類。
類型命名
類型名稱以大寫字母開頭,每個新單詞首字母大寫,不使用下劃線:MyExcitingClass
、MyExcitingEnum
。
所有類型的名稱——包括類、結構體、類型別名、枚舉和類型模板參數——都遵循相同的命名約定。類型名稱應以大寫字母開頭,每個新單詞首字母大寫,且不使用下劃線。例如:
// classes and structs
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...// typedefs
typedef hash_map<UrlTableProperties *, std::string> PropertiesMap;// using aliases
using PropertiesMap = hash_map<UrlTableProperties *, std::string>;// enums
enum class UrlTableError { ...
概念命名
概念名稱遵循與類型命名相同的規則。
變量命名
變量名稱(包括函數參數)和數據成員應采用snake_case
命名法(全小寫,單詞間用下劃線連接)。類(不包括結構體)的數據成員需額外添加末尾下劃線。例如:a_local_variable
、a_struct_data_member
、a_class_data_member_
。
常見變量命名
例如:
std::string table_name; // OK - snake_case.
std::string tableName; // Bad - mixed case.
類數據成員
類的數據成員(包括靜態和非靜態)命名方式與普通非成員變量相同,但需在末尾添加下劃線。唯一的例外是靜態常量類成員,應遵循常量命名規則。
class TableInfo {public:...static const int kTableVersion = 3; // OK - constant naming....private:std::string table_name_; // OK - underscore at end.static Pool<TableInfo>* pool_; // OK.
};
結構體數據成員
結構體的數據成員(包括靜態和非靜態成員)命名方式與普通非成員變量相同。它們不像類中的數據成員那樣帶有尾部下劃線。
struct UrlTableProperties {std::string name;int num_entries;static Pool<UrlTableProperties>* pool;
};
請參閱結構體與類的比較了解何時使用結構體而非類的討論。
常量命名
對于聲明為 constexpr
或 const
且在程序運行期間值保持不變的變量,其命名應以小寫字母 “k” 開頭,后接大小寫混合的形式。在極少數無法通過大小寫進行分隔的情況下,可以使用下劃線作為分隔符。例如:
const int kDaysInAWeek = 7;
const int kAndroid8_0_0 = 24; // Android 8.0.0
所有具有靜態存儲期的變量(即靜態變量和全局變量,詳見存儲期)都應采用此命名方式,包括靜態常量類數據成員以及模板中可能因不同實例化而值不同的變量。對于其他存儲類別的變量(如自動變量),此約定是可選的;其他情況下適用常規變量命名規則。例如:
void ComputeFoo(absl::string_view suffix) {// Either of these is acceptable.const absl::string_view kPrefix = "prefix";const absl::string_view prefix = "prefix";...
}
void ComputeFoo(absl::string_view suffix) {// Bad - different invocations of ComputeFoo give kCombined different values.const std::string kCombined = absl::StrCat(kPrefix, suffix);...
}
函數命名
通常,函數遵循PascalCase命名規范:以大寫字母開頭,每個新單詞首字母大寫。
AddTableEntry()
DeleteUrl()
OpenFileOrDie()
同樣的命名規則適用于作為API一部分公開且設計成類似函數形式的類和命名空間作用域常量,因為它們是對象而非函數這一事實屬于無關緊要的實現細節。
訪問器和修改器(get和set函數)可以采用snake_case
風格的變量命名方式。這些方法通常對應實際的成員變量,但并非強制要求。例如:int count()
和 void set_count(int count)
。
命名空間名稱
命名空間名稱采用snake_case
格式(全小寫,單詞間用下劃線連接)。
在為命名空間選擇名稱時需注意:由于通常禁止使用非限定別名,在命名空間外部的頭文件中使用時必須使用完全限定名稱。
頂級命名空間必須全局唯一且易于識別,因此每個頂級命名空間應由單個項目或團隊專屬,其名稱應基于該項目或團隊名稱。通常,該命名空間下的所有代碼都應位于一個或多個與命名空間同名的目錄中。
嵌套命名空間應避免使用知名頂級命名空間的名稱(特別是std
和absl
),因為在C++中,嵌套命名空間無法防止與其他命名空間中的名稱發生沖突(參見TotW #130)。
枚舉器命名
枚舉器(包括作用域枚舉和非作用域枚舉)的命名應當遵循常量的命名規范,而非宏的命名規范。也就是說,應該使用 kEnumName
這樣的形式,而不是 ENUM_NAME
。
enum class UrlTableError {kOk = 0,kOutOfMemory,kMalformedInput,
};
enum class AlternateUrlTableError {OK = 0,OUT_OF_MEMORY = 1,MALFORMED_INPUT = 2,
};
在2009年1月之前,枚舉值的命名風格與宏類似。這導致了枚舉值與宏之間的名稱沖突問題。因此,后續改為推薦使用常量風格的命名方式。新代碼應當采用常量風格的命名規范。
模板參數命名規范
模板參數的命名風格應與其類別保持一致:
- 類型模板參數應遵循類型命名規則
- 非類型模板參數應遵循變量命名或常量命名規則
宏命名規范
你真的要定義宏嗎?如果必須這么做,宏的命名應該像這樣:MY_MACRO_THAT_SCARES_SMALL_CHILDREN_AND_ADULTS_ALIKE
。
請參閱宏的使用說明;通常來說不應該使用宏。但如果確實需要,宏名應當全部使用大寫字母和下劃線,并加上項目特定的前綴。
#define MYPROJECT_ROUND(x) ...
別名
別名的命名遵循與其他新名稱相同的原則,但應基于別名定義所在的上下文環境,而非原始名稱出現的上下文。
命名規則的例外情況
如果某個命名對象與現有的C或C++實體類似,則可以沿用現有的命名約定方案:
bigopen()
函數名,遵循open()
的形式uint
typedef
類型定義bigpos
struct
或class
,遵循pos
的形式sparse_hash_map
類似STL的實體;遵循STL命名規范LONGLONG_MAX
常量,類似INT_MAX
的形式
注釋
注釋對于保持代碼可讀性至關重要。以下規則說明了應該在何處添加注釋以及注釋內容。但請記住:雖然注釋非常重要,但最好的代碼應當具備自解釋性。為類型和變量取一個合理的名稱,遠比使用晦澀難懂的命名然后通過注釋來解釋要好得多。
撰寫注釋時,請為你的讀者考慮:即下一位需要理解這段代碼的貢獻者。慷慨地添加注釋——因為下一個可能需要理解它的人可能就是你自己!
注釋風格
可以使用 //
或 /* */
語法,只要保持一致性即可。
雖然兩種語法都可以接受,但 //
的使用頻率遠高于另一種。請確保注釋方式和風格在不同場景中保持一致。
文件注釋
每個文件開頭應包含許可證聲明模板。
如果源文件(如.h
文件)聲明了多個面向用戶的抽象(公共函數、相關類等),需添加注釋描述這些抽象的集合。注釋應包含足夠細節,讓后續開發者能明確哪些內容不屬于該文件。但具體到單個抽象的詳細文檔應歸屬于各抽象自身,而非文件層級。
例如,若為frobber.h
編寫了文件注釋,則無需在frobber.cc
或frobber_test.cc
中重復添加。反之,如果在沒有對應頭文件的registered_objects.cc
中編寫了一組類,則必須在registered_objects.cc
內添加文件注釋。
法律聲明與作者署名
每個文件都應包含許可證樣板文本。請根據項目所使用的許可證(如 Apache 2.0、BSD、LGPL、GPL)選擇合適的樣板內容。
若對帶有作者署名的文件進行重大修改,建議刪除原署名行。新建文件通常不應包含版權聲明或作者署名。
結構體與類注釋
每個非顯而易見的類或結構體聲明都應附帶注釋,說明其用途及使用方法。
// Iterates over the contents of a GargantuanTable.
// Example:
// std::unique_ptr<GargantuanTableIterator> iter = table->NewIterator();
// for (iter->Seek("foo"); !iter->done(); iter->Next()) {
// process(iter->key(), iter->value());
// }
class GargantuanTableIterator {...
};
類注釋規范
類注釋應當為讀者提供足夠的信息,使其了解何時以及如何使用該類,同時說明正確使用該類所需的注意事項。若該類涉及線程同步假設,必須明確記錄。如果類的實例可能被多個線程訪問,需要特別詳細說明多線程使用時的規則和不變量。
在類注釋中添加一個簡短示例代碼片段通常很有幫助,可以直觀展示該類的核心用法。
當代碼文件分離時(如.h
頭文件和.cc
實現文件):
- 描述類使用方式的注釋應當與接口定義放在一起
- 關于類操作和實現細節的注釋應當伴隨類方法的實現代碼
函數注釋
聲明注釋用于描述函數的用途(當不明顯時);函數定義處的注釋則描述其具體操作。
函數聲明
幾乎每個函數聲明前都應緊跟著描述其功能和使用方法的注釋。只有當函數非常簡單明了時(例如對類中顯而易見屬性的簡單訪問器),這些注釋才可以省略。私有方法及在.cc
文件中聲明的函數也不例外。函數注釋應以隱含的主語該函數開頭,并使用動詞短語,例如"Opens the file"而非"Open the file"。通常,這些注釋不描述函數如何完成任務,具體實現細節應留給函數定義中的注釋說明。
函數聲明注釋中需涵蓋的內容類型:
- 輸入和輸出是什么。如果用
反引號
標注函數參數名,代碼索引工具可能更好地呈現文檔。 - 對于類成員函數:對象是否會在方法調用結束后仍保留引用或指針參數。這在構造函數指針/引用參數中很常見。
- 對于每個指針參數,是否允許為null以及為null時的處理方式。
- 對于每個輸出或輸入/輸出參數,參數原有狀態會發生什么變化(例如狀態是被追加還是被覆蓋)。
- 函數使用方式是否存在性能影響。
示例如下:
// Returns an iterator for this table, positioned at the first entry
// lexically greater than or equal to `start_word`. If there is no
// such entry, returns a null pointer. The client must not use the
// iterator after the underlying GargantuanTable has been destroyed.
//
// This method is equivalent to:
// std::unique_ptr<Iterator> iter = table->NewIterator();
// iter->Seek(start_word);
// return iter;
std::unique_ptr<Iterator> GetIterator(absl::string_view start_word) const;
然而,避免不必要的冗長或陳述完全顯而易見的內容。
在記錄函數重寫時,重點關注重寫本身的細節,而不是重復被重寫函數的注釋。在許多情況下,重寫不需要額外的文檔,因此無需添加注釋。
在注釋構造函數和析構函數時,請記住閱讀代碼的人已經了解構造函數和析構函數的用途,因此僅說明“銷毀此對象”之類的注釋并無實際意義。應著重說明構造函數如何處理其參數(例如,是否獲取指針的所有權),以及析構函數執行了哪些清理操作。如果這些內容顯而易見,直接省略注釋即可。析構函數沒有頭部注釋的情況十分常見。
函數定義
如果函數在實現過程中有任何技巧性的處理,其定義處應當包含解釋性注釋。例如,在定義注釋中你可以描述所使用的編碼技巧、概述實現步驟,或是說明為何選擇當前實現方式而非其他可行方案。舉例來說,可以解釋為何函數前半部分需要獲取鎖,而后半部分卻不需要。
請注意,不要僅僅重復函數聲明處的注釋(比如在.h
文件中)。可以簡要重述函數功能,但注釋的重點應放在實現方式上。
變量注釋
通常來說,變量的實際名稱應具有足夠的描述性,能清晰表達該變量的用途。在某些情況下,需要添加更多注釋說明。
類數據成員
每個類數據成員(也稱為實例變量或成員變量)的用途必須明確。如果存在類型和名稱無法清晰表達的約束條件(特殊值、成員間關系、生命周期要求等),則必須添加注釋說明。不過,若類型和名稱已足夠明確(如int num_events_;
),則無需額外注釋。
特別需要注意的是,當存在哨兵值(如nullptr或-1)且其含義不明顯時,應通過注釋說明這些值的存在及其意義。例如:
private:// Used to bounds-check table accesses. -1 means// that we don't yet know how many entries the table has.int num_total_entries_;
全局變量
所有全局變量都應添加注釋,說明其用途、功能,以及在含義不明確時解釋為何需要設為全局。例如:
// The total number of test cases that we run through in this regression test.
const int kNumTestCases = 6;
實現注釋
在代碼實現中,你應該在那些復雜、不明顯、有趣或重要的部分添加注釋說明。
解釋性注釋
對于復雜或難以理解的代碼塊,應在代碼前添加注釋說明。
函數參數注釋
當函數參數的含義不夠直觀時,可以考慮以下解決方案:
- 如果參數是字面常量,并且該常量在多個函數調用中以默認相同的方式使用,應該使用命名常量來顯式表達這種約束,并確保其一致性。
- 考慮修改函數簽名,用
enum
參數替代bool
參數。這樣可以讓參數值具有自描述性。 - 對于具有多個配置選項的函數,考慮定義一個類或結構體來保存所有選項,并傳遞其實例。這種方法有幾個優點:選項在調用處通過名稱引用,使含義更清晰;同時減少了函數參數數量,使函數調用更易讀寫。額外的好處是,添加新選項時無需修改調用處的代碼。
- 用命名變量替代龐大或復雜的嵌套表達式。
- 最后的手段是:在調用處使用注釋來闡明參數含義。
請看以下示例:
// What are these arguments?
const DecimalNumber product = CalculateProduct(values, 7, false, nullptr);
versus:
ProductOptions options;
options.set_precision_decimals(7);
options.set_use_cache(ProductOptions::kDontUseCache);
const DecimalNumber product =CalculateProduct(values, options, /*completion_callback=*/nullptr);
避免事項
不要陳述顯而易見的內容。特別是,不要逐字描述代碼的功能,除非其行為對于精通C++的讀者來說并不直觀。相反,應提供更高層次的注釋,說明代碼為何如此實現,或者讓代碼本身具備自解釋性。
對比以下示例:
// Find the element in the vector. <-- Bad: obvious!
if (std::find(v.begin(), v.end(), element) != v.end()) {Process(element);
}
// Process "element" unless it was already processed.
if (std::find(v.begin(), v.end(), element) != v.end()) {Process(element);
}
自描述代碼不需要注釋。上面例子中的注釋會顯得多余:
if (!IsAlreadyProcessed(element)) {Process(element);
}
標點、拼寫與語法規范
注重標點符號、拼寫和語法的正確性——閱讀書寫規范的注釋遠比糟糕的表述更輕松高效。
注釋應像敘述性文字一樣具備可讀性,注意規范的大小寫和標點使用。多數情況下,完整的句子比零碎片段更易理解。行尾簡短注釋有時可以稍顯隨意,但需保持風格一致性。
盡管代碼審查時被指出該用分號卻誤用逗號會令人沮喪,但保持源碼的高度清晰與可讀性至關重要。規范的標點、拼寫和語法正是實現這一目標的基礎。
TODO 注釋
對于臨時性代碼、短期解決方案或勉強可用但不夠完美的代碼,請使用 TODO
注釋。
所有 TODO
注釋必須包含全大寫的 TODO
字符串,后接對應的缺陷 ID、責任人姓名、郵箱地址或其他能明確關聯問題上下文的標識信息(例如關聯的問題追蹤編號)。
// TODO: bug 12345678 - Remove this after the 2047q4 compatibility window expires.
// TODO: example.com/my-design-doc - Manually fix up this code the next time it's touched.
// TODO(bug 12345678): Update this list after the Foo service is turned down.
// TODO(John): Use a "\*" here for concatenation operator.
如果你的TODO
注釋形式為"在將來某個時間做某事",請確保包含非常具體的日期(例如"在2005年11月前修復")或非常具體的事件(例如"當所有客戶端都能處理XML響應時移除此代碼")。
代碼格式規范
代碼風格和格式雖然具有一定的主觀性,但當項目成員采用統一風格時,代碼會更容易維護。個人可能不會完全認同所有格式規則,某些規則也需要時間適應,但關鍵在于所有貢獻者都應遵守這些規范,這樣才能輕松閱讀和理解彼此的代碼。
為幫助您正確格式化代碼,我們提供了 emacs 配置文件。
行寬限制
代碼中每行文本的長度不應超過80個字符。
我們理解這條規范存在爭議,但考慮到已有大量代碼遵循此慣例,保持一致性尤為重要。
支持該規范的觀點認為:
- 強制調整窗口尺寸有違使用習慣,且超出行寬并無必要
- 開發者常需要并排顯示多個代碼窗口,實際無法增加窗口寬度
- 工作環境通常基于特定窗口寬度配置,而80列是傳統標準
主張放寬限制的觀點則認為:
- 更寬的行寬能提升代碼可讀性
- 80列限制是1960年代大型機時代的產物
- 現代寬屏設備完全能顯示更長代碼行
允許例外的情況
當出現以下情形時,允許突破80字符限制:
- 注釋行:若拆分會影響可讀性、復制粘貼或自動鏈接功能(如包含超長示例命令或URL)
- 字符串字面量:符合以下任一條件時:
- 包含URI等關鍵語義內容
- 內嵌特定語言結構
- 多行文本中換行符具有實際意義(如幫助信息)
注意:測試代碼除外,這類字面量應置于文件頂部的命名空間作用域。若Clang-Format等工具無法識別不可拆分內容,可臨時禁用格式化功能
- include語句
- 頭文件保護宏
- using聲明語句
(需權衡字面量的可用性/可搜索性與周邊代碼可讀性之間的平衡)
非ASCII字符
非ASCII字符應當極少出現,且必須使用UTF-8編碼格式。
即使對于英文內容,也不應在源代碼中硬編碼面向用戶的文本,因此非ASCII字符的使用應當非常有限。但在某些情況下,代碼中包含這類字符是合理的。例如,若代碼需要解析來自國外數據源的文件,將數據文件中用作分隔符的非ASCII字符串硬編碼可能是合適的。更常見的情況是,單元測試代碼(無需本地化)可能包含非ASCII字符串。此類情況下,應使用UTF-8編碼,因為大多數能處理ASCII以外字符的工具都支持該編碼。
十六進制編碼也是可接受的,且在提升可讀性時更受鼓勵——例如"\xEF\xBB\xBF"
或更簡潔的"\uFEFF"
表示Unicode零寬度不換行空格字符,若直接以UTF-8形式存在于源碼中將不可見。
盡可能避免使用u8
前綴。從C++20開始其語義與C++17有顯著差異,會生成char8_t
數組而非char
數組,且C++23中會再次變更。
不應使用char16_t
和char32_t
字符類型,因為它們用于非UTF-8文本。同理也不應使用wchar_t
(除非編寫與Windows API交互的代碼,因后者廣泛使用wchar_t
)。
空格與制表符
請僅使用空格進行縮進,每次縮進2個空格。
我們采用空格作為縮進方式。代碼中禁止使用制表符。您需要將編輯器設置為按下Tab鍵時輸出空格。
函數聲明與定義
函數名與返回類型放在同一行,如果參數能放得下也放在同一行。對于無法在一行內放下的參數列表,應按照函數調用時的參數換行方式進行換行處理。
函數格式示例如下:
ReturnType ClassName::FunctionName(Type par_name1, Type par_name2) {DoSomething();...
}
如果一行顯示不下過多文本內容:
ReturnType ClassName::ReallyLongFunctionName(Type par_name1, Type par_name2,Type par_name3) {DoSomething();...
}
或者如果你連第一個參數都無法適配:
ReturnType LongClassName::ReallyReallyReallyLongFunctionName(Type par_name1, // 4 space indentType par_name2,Type par_name3) {DoSomething(); // 2 space indent...
}
需要注意以下幾點:
- 選擇恰當的參數命名。
- 僅當參數未在函數定義中使用時,方可省略參數名稱。
- 若返回類型與函數名無法在同一行顯示,應在兩者之間換行。
- 如果在函數聲明或定義的返回類型后換行,不要縮進。
- 左圓括號始終與函數名保持在同一行。
- 函數名與左圓括號之間不得留有空格。
- 圓括號與參數列表之間不得留有空格。
- 左大括號應始終位于函數聲明最后一行的末尾,而非新行的開頭。
- 右大括號應單獨占據最后一行,或與左大括號保持在同一行。
- 右圓括號與左大括號之間應保留一個空格。
- 所有參數應盡可能對齊。
- 默認縮進為2個空格。
- 換行顯示的參數應采用4個空格縮進。
對于上下文明確的無用參數,可省略其名稱:
class Foo {public:Foo(const Foo&) = delete;Foo& operator=(const Foo&) = delete;
};
建議將函數定義中可能不明顯的未使用參數注釋掉變量名:
class Shape {public:virtual void Rotate(double radians) = 0;
};class Circle : public Shape {public:void Rotate(double radians) override;
};void Circle::Rotate(double /*radians*/) {}
// Bad - if someone wants to implement later, it's not clear what the
// variable means.
void Circle::Rotate(double) {}
屬性(以及展開為屬性的宏)出現在函數聲明或定義的最開始位置,位于返回類型之前:
ABSL_ATTRIBUTE_NOINLINE void ExpensiveFunction();[[nodiscard]] bool IsOk();
Lambda 表達式
Lambda 表達式的參數和函數體格式與其他函數相同,捕獲列表的格式則類似于其他逗號分隔的列表。
對于按引用捕獲的情況,在取地址符 (&
) 和變量名之間不要留空格。
int x = 0;
auto x_plus_n = [&x](int n) -> int { return x + n; }
簡短的 lambda 表達式可以直接內聯作為函數參數。
absl::flat_hash_set<int> to_remove = {7, 8, 9};
std::vector<int> digits = {3, 9, 1, 8, 4, 7, 1};
digits.erase(std::remove_if(digits.begin(), digits.end(), [&to_remove](int i) {return to_remove.contains(i);}),digits.end());
浮點數字面量
浮點數字面量應始終包含小數點,且小數點兩側都需有數字,即使采用指數表示法時也應如此。
遵循這種常見形式能提升代碼可讀性,既可避免將浮點數字面量誤認為整數字面量,也能防止指數標記中的E
/e
被誤認作十六進制數字。
允許使用整數字面量初始化浮點變量(前提是變量類型能精確表示該整數),但需注意:采用指數表示法的數值絕不會是整數字面量。
float f = 1.f;
long double ld = -.5L;
double d = 1248e6;
float f = 1.0f;
float f2 = 1.0; // Also OK
float f3 = 1; // Also OK
long double ld = -0.5L;
double d = 1248.0e6;
函數調用
可以采用以下三種格式之一:將整個調用寫在一行內;在括號處換行并對齊參數;或將參數另起一行并以4個空格縮進,后續行保持相同縮進。
若無特殊要求,應盡量使用最少的行數,包括在適當情況下將多個參數放在同一行。
函數調用的標準格式如下:
bool result = DoSomething(argument1, argument2, argument3);
如果參數無法全部放在一行,應將它們分成多行顯示,后續每行與第一個參數對齊。
不要在開括號后或閉括號前添加空格:
bool result = DoSomething(averyveryveryverylongargument1,argument2, argument3);
參數可以選擇全部放在后續行中,縮進四個空格:
if (...) {......if (...) {bool result = DoSomething(argument1, argument2, // 4 space indentargument3, argument4);...}
將多個參數放在同一行以減少函數調用所需的行數,除非存在特定的可讀性問題。有些人認為嚴格每行一個參數的格式更易讀且便于參數編輯。但我們優先考慮讀者的體驗而非參數編輯的便利性,大多數可讀性問題可以通過以下技巧更好地解決。
如果由于某些參數表達式過于復雜或混亂導致單行多參數降低可讀性,可以嘗試創建具有描述性名稱的變量來封裝這些參數:
int my_heuristic = scores[x] * y + bases[x];
bool result = DoSomething(my_heuristic, x, y, z);
或者將難以理解的參數單獨放在一行,并附上解釋性注釋:
bool result = DoSomething(scores[x] * y + bases[x], // Score heuristic.x, y, z);
如果某個參數單獨成行能顯著提升可讀性,就讓它獨占一行。這個決定應基于該參數自身的可讀性需求,而非通用規則。
當多個參數組合形成對可讀性至關重要的結構時,可按照該結構自由調整參數格式:
// Transform the widget by a 3x3 matrix.
my_widget.Transform(x1, x2, x3,y1, y2, y3,z1, z2, z3);
大括號初始化列表格式
格式化大括號初始化列表時,應完全按照在該位置格式化函數調用的方式來處理。
如果大括號列表跟在某個名稱后面(例如類型名或變量名),則按照{}
是該名稱對應的函數調用括號的方式進行格式化。如果沒有名稱,則假定名稱為空。
// Examples of braced init list on a single line.
return {foo, bar};
functioncall({foo, bar});
std::pair<int, int> p{foo, bar};// When you have to wrap.
SomeFunction({"assume a zero-length name before {"},some_other_function_parameter);
SomeType variable{some, other, values,{"assume a zero-length name before {"},SomeOtherType{"Very long string requiring the surrounding breaks.",some, other, values},SomeOtherType{"Slightly shorter string",some, other, values}};
SomeType variable{"This is too long to fit all in one line"};
MyType m = { // Here, you could also break before {.superlongvariablename1,superlongvariablename2,{short, interior, list},{interiorwrappinglist,interiorwrappinglist2}};
循環與分支語句
從高層次來看,循環或分支語句包含以下組成部分:
- 一個或多個語句關鍵字(例如
if
、else
、switch
、while
、do
或for
)。 - 一個位于圓括號內的條件或迭代說明符。
- 一個或多個受控語句,或受控語句塊。
對于這些語句:
- 語句的各組成部分之間應使用單個空格分隔(而非換行)。
- 在條件或迭代說明符內部,每個分號與下一個標記之間應留一個空格(或換行),除非該標記是右括號或另一個分號。
- 在條件或迭代說明符內部,左括號后和右括號前不應添加空格。
- 將所有受控語句置于代碼塊內(即使用花括號)。
- 在受控代碼塊內部,左花括號后立即換行,右花括號前立即換行。
if (condition) { // Good - no spaces inside parentheses, space before brace.DoOneThing(); // Good - two-space indent.DoAnotherThing();
} else if (int a = f(); a != 3) { // Good - closing brace on new line, else on same line.DoAThirdThing(a);
} else {DoNothing();
}// Good - the same rules apply to loops.
while (condition) {RepeatAThing();
}// Good - the same rules apply to loops.
do {RepeatAThing();
} while (condition);// Good - the same rules apply to loops.
for (int i = 0; i < 10; ++i) {RepeatAThing();
}
if(condition) {} // Bad - space missing after `if`.
else if ( condition ) {} // Bad - space between the parentheses and the condition.
else if (condition){} // Bad - space missing before `{`.
else if(condition){} // Bad - multiple spaces missing.for (int a = f();a == 10) {} // Bad - space missing after the semicolon.// Bad - `if ... else` statement does not have braces everywhere.
if (condition)foo;
else {bar;
}// Bad - `if` statement too long to omit braces.
if (condition)// CommentDoSomething();// Bad - `if` statement too long to omit braces.
if (condition1 &&condition2)DoSomething();
由于歷史原因,我們允許對上述規則有一個例外:如果受控語句的整個內容能顯示在單行(此時右括號與受控語句之間需留一個空格)或兩行(此時右括號后需換行且不使用大括號),則可以省略受控語句的大括號或大括號內的換行符。
// OK - fits on one line.
if (x == kFoo) { return new Foo(); }// OK - braces are optional in this case.
if (x == kFoo) return new Foo();// OK - condition fits on one line, body fits on another.
if (x == kBar)Bar(arg1, arg2, arg3);
此例外情況不適用于多關鍵字語句,例如 if ... else
或 do ... while
。
// Bad - `if ... else` statement is missing braces.
if (x) DoThis();
else DoThat();// Bad - `do ... while` statement is missing braces.
do DoThis();
while (x);
僅在語句簡短時使用此風格,并注意帶有復雜條件或控制語句的循環和分支結構使用大括號可能更具可讀性。部分項目要求始終使用大括號。
switch
語句中的case
代碼塊是否使用大括號可根據個人偏好決定。若使用大括號,應按以下方式放置。
switch (var) {case 0: { // 2 space indentFoo(); // 4 space indentbreak;}default: {Bar();}
}
空循環體應使用一對空花括號或不帶花括號的 continue
,而不是單獨一個分號。
while (condition) {} // Good - `{}` indicates no logic.
while (condition) {// Comments are okay, too
}
while (condition) continue; // Good - `continue` indicates no logic.
while (condition); // Bad - looks like part of `do-while` loop.
指針與引用表達式及類型
點號和箭頭運算符周圍不加空格。指針運算符后不跟空格。
以下是正確格式化的指針和引用表達式示例:
x = *p;
p = &x;
x = r.y;
x = r->y;
請注意:
- 訪問成員時,點號或箭頭周圍不加空格。
- 指針操作符在
*
或&
之后不加空格。
當涉及指針或引用時(變量聲明或定義、參數、返回類型、模板參數等),不能在星號/與號前加空格。類型與聲明的名稱(如果有)之間用一個空格分隔。
// These are fine.
char* c;
const std::string& str;
int* GetPointer();
std::vector<char*> // Note no space between '*' and '>'
允許(盡管不常見)在同一個聲明中聲明多個變量,但如果其中任何變量帶有指針或引用修飾符則不允許。這類聲明很容易被誤讀。
// Fine if helpful for readability.
int x, y;
int x, *y; // Disallowed - no & or * in multiple declaration
int *x, *y; // Disallowed - no & or * in multiple declaration
int *x; // Disallowed - & or * must be left of the space
char * c; // Bad - spaces on both sides of *
const std::string & str; // Bad - spaces on both sides of &
布爾表達式
當布爾表達式長度超過標準行寬時,需要保持換行方式的一致性。
在這個示例中,邏輯與運算符始終位于行末:
if (this_one_thing > this_other_thing &&a_third_thing == a_fourth_thing &&yet_another && last_one) {...
}
請注意,在此示例中代碼換行時,兩個 &&
邏輯與運算符都位于行尾。
這種情況在 Google 代碼中更為常見,不過將所有運算符放在行首的換行方式也是允許的。
可以酌情添加額外的括號,因為合理使用時它們能顯著提升代碼可讀性,但需注意避免過度使用。另外請注意,應當始終使用標點形式的運算符(如 &&
和 ~
),而非單詞形式的運算符(如 and
和 compl
)。
返回值
不要毫無必要地用括號包裹 return
表達式。
僅在 x = expr;
中會使用括號的情況下,才在 return expr;
中使用括號。
return result; // No parentheses in the simple case.
// Parentheses OK to make a complex expression more readable.
return (some_long_condition &&another_condition);
return (value); // You wouldn't write var = (value);
return(result); // return is not a function!
變量與數組初始化
您可以選擇使用 =
、()
或 {}
,以下寫法都是正確的:
int x = 3;
int x(3);
int x{3};
std::string name = "Some Name";
std::string name("Some Name");
std::string name{"Some Name"};
在使用帶有 std::initializer_list
構造函數的類型時,需謹慎使用花括號初始化列表 {...}
。
只要有可能,非空的花括號初始化列表會優先匹配 std::initializer_list
構造函數。
需注意,空花括號 {}
是特殊情況——若存在默認構造函數,則會調用它。
若要強制調用非 std::initializer_list
構造函數,請改用圓括號而非花括號。
std::vector<int> v(100, 1); // A vector containing 100 items: All 1s.
std::vector<int> v{100, 1}; // A vector containing 2 items: 100 and 1.
此外,大括號形式可以防止整型類型的窄化轉換,這有助于避免某些類型的編程錯誤。
int pi(3.14); // OK -- pi == 3.
int pi{3.14}; // Compile error: narrowing conversion.
預處理器指令
以井號開頭的預處理器指令必須始終位于行首。
即使預處理器指令位于縮進代碼塊內部,這些指令也應從行首開始。
// Good - directives at beginning of lineif (lopsided_score) {
#if DISASTER_PENDING // Correct -- Starts at beginning of lineDropEverything();
# if NOTIFY // OK but not required -- Spaces after #NotifyClient();
# endif
#endifBackToNormal();}
// Bad - indented directivesif (lopsided_score) {#if DISASTER_PENDING // Wrong! The "#if" should be at beginning of lineDropEverything();#endif // Wrong! Do not indent "#endif"BackToNormal();}
類格式
類定義的基本格式(不含注釋,關于所需注釋的討論請參閱類注釋)如下:
各節按 public
、protected
和 private
順序排列,每節縮進一個空格。
class MyClass : public OtherClass {public: // Note the 1 space indent!MyClass(); // Regular 2 space indent.explicit MyClass(int var);~MyClass() {}
void SomeFunction();void SomeFunctionThatDoesNothing() {}
void set_some_var(int var) { some_var_ = var; }int some_var() const { return some_var_; }private:bool SomeInternalFunction();
int some_var_;int some_other_var_;
};
注意事項:
- 基類名稱應與子類名稱位于同一行,且遵循80列字符限制。
public:
、protected:
和private:
關鍵字應縮進一個空格。- 除首次出現外,這些關鍵字前需空一行。該規則在小類中可選。
- 關鍵字后不要留空行。
- 聲明順序應為:先
public
部分,其次protected
,最后private
。 - 各部分的聲明順序規則請參閱聲明順序。
構造函數初始化列表
構造函數初始化列表可以全部寫在一行,也可以將后續行縮進四個空格。
初始化列表可接受的格式包括:
// When everything fits on one line:
MyClass::MyClass(int var) : some_var_(var) {DoSomething();
}// If the signature and initializer list are not all on one line,
// you must wrap before the colon and indent 4 spaces:
MyClass::MyClass(int var): some_var_(var), some_other_var_(var + 1) {DoSomething();
}// When the list spans multiple lines, put each member on its own line
// and align them:
MyClass::MyClass(int var): some_var_(var), // 4 space indentsome_other_var_(var + 1) { // lined upDoSomething();
}// As with any other code block, the close curly can be on the same
// line as the open curly, if it fits.
MyClass::MyClass(int var): some_var_(var) {}
命名空間格式化
命名空間內的內容不需要縮進。
命名空間不會增加額外的縮進層級。例如,應該這樣使用:
namespace {void foo() { // Correct. No extra indentation within namespace....
}} // namespace
不要在命名空間內縮進:
namespace {
// Wrong! Indented when it should not be.void foo() {...}} // namespace
水平空白符的使用
水平空白符的使用取決于具體位置。切勿在行尾添加尾部空白符。
概述
int i = 0; // Two spaces before end-of-line comments.void f(bool b) { // Open braces should always have a space before them....
int i = 0; // Semicolons usually have no space before them.
// Spaces inside braces for braced-init-list are optional. If you use them,
// put them on both sides!
int x[] = { 0 };
int x[] = {0};// Spaces around the colon in inheritance and initializer lists.
class Foo : public Bar {public:// For inline function implementations, put spaces between the braces// and the implementation itself.Foo(int b) : Bar(), baz_(b) {} // No spaces inside empty braces.void Reset() { baz_ = 0; } // Spaces separating braces from implementation....
在文件末尾添加空格會導致其他人在合并時對同一文件進行額外編輯工作,同樣地,刪除現有末尾空格也會造成類似問題。因此,請避免引入末尾空格。若您已在修改該行代碼,請順手移除這些空格;或者專門進行一次清理操作(最好在其他人未同時修改該文件時進行)。
循環與條件語句
if (b) { // Space after the keyword in conditions and loops.
} else { // Spaces around else.
}
while (test) {} // There is usually no space inside parentheses.
switch (i) {
for (int i = 0; i < 5; ++i) {
// Loops and conditions may have spaces inside parentheses, but this
// is rare. Be consistent.
switch ( i ) {
if ( test ) {
for ( int i = 0; i < 5; ++i ) {
// For loops always have a space after the semicolon. They may have a space
// before the semicolon, but this is rare.
for ( ; i < 5 ; ++i) {...// Range-based for loops always have a space before and after the colon.
for (auto x : counts) {...
}
switch (i) {case 1: // No space before colon in a switch case....case 2: break; // Use a space after a colon if there's code after it.
運算符
// Assignment operators always have spaces around them.
x = 0;// Other binary operators usually have spaces around them, but it's
// OK to remove spaces around factors. Parentheses should have no
// internal padding.
v = w * x + y / z;
v = w*x + y/z;
v = w * (x + z);// No spaces separating unary operators and their arguments.
x = -5;
++x;
if (x && !y)...
模板與類型轉換
// No spaces inside the angle brackets (< and >), before
// <, or between >( in a cast
std::vector<std::string> x;
y = static_cast<char*>(x);// Spaces between type and pointer are OK, but be consistent.
std::vector<char *> x;
垂直留白
應謹慎使用垂直留白;不必要的空行會干擾代碼整體結構的辨識。僅在有助于讀者理解結構時添加空行。
當縮進已能清晰劃分代碼塊(如代碼塊開頭或結尾處)時,無需額外添加空行。但可用空行將代碼分隔為邏輯緊密的段落,類似于文本中的分段。在語句或聲明內部,通常僅因以下情況換行:超出行長度限制,或需要為局部內容添加注釋。
規則的例外情況
上述編碼規范是強制性的。然而,正如所有優秀的規則一樣,這些規范有時也存在例外情況,我們將在本節進行討論。
現有不符合規范的代碼
在處理不符合本風格指南的代碼時,您可以偏離這些規則。
如果您正在修改的代碼是根據不同于本指南提出的規范編寫的,為了與該代碼中的局部約定保持一致,您可能需要偏離這些規則。如果您不確定如何操作,請咨詢原始作者或當前負責該代碼的人員。請記住,一致性也包括局部一致性。
Windows 代碼規范
Windows 程序員發展出了一套獨特的編碼慣例,主要源自 Windows 頭文件和其他微軟代碼的約定。為了讓所有人都能輕松理解您的代碼,我們為所有平臺的 C++ 開發者制定了統一的規范指南。
以下是幾個值得重申的規范要點(如果您習慣了常見的 Windows 編碼風格,可能會忽略這些):
- 避免匈牙利命名法(例如用
iNum
表示整數)。請遵循 Google 命名規范,包括使用.cc
作為源文件擴展名。 - Windows 為基本類型定義了大量別名(如
DWORD
、HANDLE
等)。在調用 Windows API 函數時,完全可以(也推薦)使用這些類型。即便如此,請盡量貼近底層 C++ 類型。例如,使用const TCHAR *
而非LPCTSTR
。 - 編譯設置:使用 Microsoft Visual C++ 時,請將編譯器警告級別設為 3 或更高,并將所有警告視為錯誤。
- 頭文件保護:禁用
#pragma once
,改用標準的 Google 頭文件保護宏。保護宏中的路徑應相對于項目根目錄。 - 非標準擴展:除非絕對必要,否則不要使用任何非標準擴展(如
#pragma
和__declspec
)。允許使用__declspec(dllimport)
和__declspec(dllexport)
,但必須通過DLLIMPORT
和DLLEXPORT
等宏來封裝,以便其他人在共享代碼時能輕松禁用這些擴展。
但在 Windows 平臺上有少數例外情況允許打破常規:
- 多重繼承:通常我們強烈反對使用多重實現繼承,但在使用 COM 和部分 ATL/WTL 類時是必需的。您可以通過多重繼承來實現 COM 或 ATL/WTL 類及接口。
- 異常處理:雖然自定義代碼中不應使用異常,但 ATL 和部分 STL(包括 Visual C++ 附帶的版本)廣泛使用了異常。使用 ATL 時應定義
_ATL_NO_EXCEPTIONS
來禁用異常。建議檢查能否在 STL 中禁用異常,若不能,則允許開啟編譯器異常選項(注意:這僅用于通過 STL 編譯,您仍不應自行編寫異常處理代碼)。 - 預編譯頭文件:常規做法是在每個源文件頂部包含
StdAfx.h
或precompile.h
等頭文件。為提高代碼可移植性,請避免顯式包含該文件(precompile.cc
除外),改用/FI
編譯器選項自動包含。 - 資源頭文件:通常命名為
resource.h
且僅包含宏定義的資源頭文件,可不遵循本風格指南。
2025-08-16(六)