Google C++ Style Guide 繁體中文版
本書翻譯了 Google 官方訂定的 Coding Style (程式碼風格) 標準。
如果程式碼都能夠遵照同一份標準來撰寫的話,就能夠大大地增加程式的可讀性。 Google 所訂定的 C++ 程式碼風格非常明確,也非常仔細。 我認為是一個很不錯的標準。
不過可惜的是,Google 官方只有提供英文版的說明。 為了讓不善英文,並使用中文的人能夠理解文件的內容,特別翻譯本書供大家閱讀。
注意:本書正在更新到 2025 年版
本書自從 2021 年之後因為作者的私人原因沒有把翻譯完成。 在這段期間 Google 風格指南又經歷了多次修改,特別是參照的 C++ 版本從 C++11 版直接跳躍到 C++20。 同時也在規則中加入了許多 Abseil Library 的 C++ Tips of The Week 作為參考資料。
為了夠讓本中文版指南能夠跟上時代,作者正在積極校對與更新之前已翻譯的內容,同時也會盡快完成剩餘未完成的翻譯。
如果想要瞭解本指南的翻譯更新進度,請參考 GitHub 上的 這個 issue。
連結
- 本書的 Github Repository:https://github.com/SLMT/google-cpp-style-guide-zh-tw
- 線上電子書網址:https://www.slmt.tw/google-cpp-style-guide-zh-tw/
- Google 官方 C++ Style 說明文件 (英文):https://google.github.io/styleguide/cppguide.html
回報與修正
如果覺得哪裡翻譯有誤,或者覺得語句不通順,歡迎到 Github repository 的 issue 提出問題。 如果熟悉 Git 的話,也歡迎在 Github 上 fork 這個 repository 自行修改,並開啟 pull request 請求合併。
一些撰寫時的規則
- 所有文句除標題外,一律以句號結尾。
- 句號之後若還有其他語句在同一段,一律加上一個半形空白。
- 英文單字與中文字詞的銜接處一律加上一個半形空白。
英文專有名詞的處理
這點我想了很久,有些程式中的關鍵字像是 class 或者 function 之類的,到底該使用中文的翻譯名詞呢?還是直接打英文名詞?
使用中文專有名詞的好處是,對於直接從中文教材學習的人來說,很快就可以進入狀況。 不過有可能我翻譯的名詞與這些人當初所學的不同,這樣或許也沒有好處。 而且對於本來就學英文的人來說,一下子看到中文名詞可能很難進入狀況。 事實上,我個人平常是中英文夾雜使用的。 遇到專有名詞就切換成英文,一般解釋時就用中文。 但是無論如何,我必須要訂出一個統一的標準貫穿本書,以方便讀者閱讀。
我想來想去,覺得這本書主要的目還是在於翻譯。因此除了程式碼的部分外,盡量少用英文名詞較好。 所以我最後的方針是:
除非是非得使用英文的情況,不然程式的專有名詞一律翻譯成中文。 少見名詞第一次出現時,在旁標明英文。 常用的名詞則直接使用中文翻譯。
為了方便只知道英文名詞的人能夠查閱看不懂翻譯的名詞,我在附錄列了一張中英專有名詞的對照表。 希望多少有點幫助。
背景
C++ 是許多 Google 開源專案中主要使用的程式語言之一。 如同許多 C++ 程式設計師所知,C++ 具有許多強大的特性,但這種強大也帶來了複雜性,使得程式碼容易產生各種錯誤 (Bug),並且也難以閱讀及維護。
本指南的目標在於藉由詳細說明哪些東西該寫、哪些不該寫,來管理 C++ 程式碼的複雜度。 這些規則存在的目的即是讓程式碼保持易於維護的同時,也讓開發者能有效地發揮 C++ 的特性。
風格 (Style),也稱作可讀性 (Readability),即是我們用來管理 C++ 程式碼中遵循的慣例。 事實上,用 Style 這個字眼可能不太精確,因為這類約定並非只有著墨在程式碼的排版。
Google 開發的絕大多數開源專案都遵循本指南。
請注意:本指南並非 C++ 教學,我們假設讀者已熟悉這門程式語言。
風格指引的目標
為什麼會有這份文件?
這邊有幾個核心目標是我們相信這份文件該提供的。 這些目標是所有規則的根本依據。 藉由提出這些想法,我們希望透過明確呈現這些理念,讓大家理解這些規則存在的原因,以及某些決策背後的考量。 如果你能了解每個規則是為了甚麼目標而制定,那大家應該也應該更清楚何時某些規定可以豁免(確實有些可以),以及要修改本指南的一條規定時該做出哪些爭論與考量。
這份文件的目標目前我們認為有以下幾點:
規則需要具有足夠的份量
一條風格規則所帶來的好處必須大到足夠合理來要求我們的工程師記住它。 它的好處是以沒有這份指南的情況下撰寫出的程式碼為準來衡量的。 有些規則可能在一些極端不好的程式碼中具有些微的好處,但是這種程式碼一般人不太可能會寫得出來的話,我們就不會寫進這份指引。 這個原則解釋了大多數我們沒有寫進的規則。 舉例來說,goto
違反了下面很多條原則,但是現在已經很少見了,因此這份風格指引就不討論它。
優化的對象應該是程式碼的讀者,而非撰寫者
我們預計我們的程式碼庫 (以及大多數提交到上面的各個組件) 會持續維護很長的一段時間。 因此大多數的時間會花在讀懂它,而非撰寫它。 我們明確選擇優先考量工程師在閱讀、維護與除錯程式碼時的體驗,而非撰寫的便利性。 「為讀者留下足跡」正是一種在這個原則之下常見的觀點:如果在一段程式碼中正在發生一些驚喜或者不尋常的事情 (例如:轉移一個指標的所有權),在這裡留下一些文字上的提示將會非常有價值。 (std::unique_ptr
明確地在呼叫處提示了所有權轉移的行為)。
與現有的程式碼保持一致
在我們程式庫中使用一致的風格讓我們可以專注在其他 (更重要) 的問題。 一致性也促進了自動化:例如格式化工具或 #include
調整工具,只有在程式碼符合預期的一致性時才能正常運作。 在許多情況下,那些被歸類為「保持一致性」的規則,實際上是在說「選一個標準並遵循它,別再糾結」。 在那些情形允許彈性的潛在好處其實大於大家在那邊爭論所花費的時間。 然而,一致性也有其局限性:當沒有明確的技術論點,或缺乏長期發展方向時,它可以作為決策的參考,但也僅限於應用在局部範圍內(例如單個檔案,或一組緊密相關的介面)。 要注意一致性不應成為忽視新風格優勢或阻礙程式碼轉向新風格的理由。
適時與更大的 C++ 社群保持一致
跟其他組織使用 C++ 的方式保持一致所帶來的價值與在我們自己的程式庫中保持一致的理由是相同的。 如果一個 C++ 標準的特性可以解決一個問題,或者一個寫法被廣泛所知且接受,那就是一個使用它的有力理由。 然而,有時候這些特性與寫法可能具有某些缺陷,或者並不是我們的程式庫所需要的。 在這類情況下,其實更適合限制或者禁止這些特性。 在某些情形,沒有特別的好處或者足夠的價值讓我們轉換到標準介面上,我們會更傾向於使用我們自己撰寫或者第三方的函式庫。
避免令人驚訝或者危險的結構
C++ 有著一些比看起來更令人訝異或者危險的特性。 這份風格指引內的部分限制就是為了要避免掉入這種陷阱裡。 風格指引對於豁免那些限制具有很高的標準,因為放棄那些規則很有可能會對程式的正確性造成很大的風險。
避免那些讓我們的一般 C++ 程式設計師認為詭異或者難以維護的結構
有些 C++ 的特性可能一般來說不太適合被使用,因為它們可能常造成一些額外的複雜性。 在一些廣泛被使用的程式碼中可能會適合使用這種特別的結構,因為複雜實作所帶來的好處會被廣泛使用這點放大,而且了解這些複雜東西的代價不需要在寫新一塊程式碼時再度付出一次。 如果有疑問的話,可以向專案領導詢問是否可以豁免這類的規則。 這對我們的程式庫來說尤其重要,因為程式碼的擁有者與團隊成員會隨時間變動,使得長期維護變得更具挑戰性:儘管現在正在使用某段程式碼的每個人都了解它,經過了幾年後也不能保證仍會是如此。
在我們的規模下要非常小心
對於一個超過 1 億行以及有著數千位工程師管理的程式庫來說,一個工程師的一些錯誤或者過於簡化的程式碼可能會對許多人造成極大的負擔。 舉例來說,要特別注意不能汙染全域命名空間 (global namespace):如果大家都把東西放在全域命名空間的話,對於這種具有數億行程式碼的程式庫來說可能會很難避免命名衝突,並使得程式庫難以維護。
在必要時優先考量效能優化
雖然有些效能上的優化可能會牴觸這份文件的一些原則,但是對於必要的情況來說還是可接受的。
結論
這份文件的用意在於最大化地提供指引與適當的限制。 一如往常,我們盡量讓這些規則符合常識並維持良好的程式風格。 我們特別參考了整個 Google C++ 社群建立起的傳統,而非單獨考量你個人的喜好或者你的團隊。 當你遇到不尋常的結構時,請保持懷疑並謹慎使用:沒有限制它們並不代表你可以忽略它們。 此時請你自行判斷,如果你不太確定,請不要猶豫,立即詢問你的專案領導來取得意見。
C++ 版本
目前程式碼應以 C++20 版為目標,這也代表不應該使用 C++23 的特性。 本指南作為目標的 C++ 版本會隨時間快速更新。
不要使用非標準的擴充功能 (Non-standard Extensions) (TODO: 加入到該章節的連結)。
在你的專案使用 C++17 與 C++20 的特性以前應考慮不同環境的可攜性 (Portability)。
標頭檔 (Header Files)
一般來說,每一個 .cc
檔都應該要有一個對應的 .h
檔。 不過也有一些常見的例外,像是單元測試 (Unit Test) 跟一些只包含著 main()
的小型 .cc
檔就不需要有。
正確地使用標頭檔可以對可讀性、程式碼大小與效能帶來巨大的影響。
本章中的規則會引領你克服標頭檔中各式各樣的陷阱。
自給自足標頭檔 (Self-contained Headers)
標頭檔應該要自給自足 (self-contained),也就是說,應該能夠單獨被引入並正確編譯,而且副檔名必須是
.h
。 其他具有插入目的,但不是標頭檔者,則應該要使用.inc
作為副檔名,並且應該盡少使用。
所有標頭檔都應該要自給自足。 換句話說,使用者或者重構工具 (Refactoring Tool) 並不需要依賴任何額外的條件才能夠引入標頭檔。 更精確地說,標頭檔應該要包含 標頭檔保護,而且應該要自己插入所有需要的其他標頭檔。
如果一個標頭檔內宣告了行內函式 (Inline Functions) 或模板 (Templates),並且這些函式或模板將由標頭檔的使用者進行實例化的話,那麼它們的定義必須直接出現在標頭檔內,或是出現在該標頭檔所包含的其他檔案中。 不要將這些定義移至單獨的 -inl.h
文件。 這種作法以前很常見,但現在我們不允許這樣做。 如果某個模板的實例化僅發生在同一個 .cc
檔案中,無論是因為它被 顯示實例化 (Explicit Instantiation) (譯註一),或是因為其定義只有該 .cc
檔案可以存取,那麼該模板的定義可以放在 .cc
檔案內。
在某些極少數的狀況下,標頭檔可以不用是自給自足的。 這些特殊的標頭檔通常是用來載入程式碼到一些不尋常的位置,例如引入到另一個檔案的中間某個部位。 他們可以不使用 標頭檔保護,而且可以不載入他們所需的檔案。 這種類型的檔案應該使用 .inc
作為副檔名。 應盡量避免使用這類檔案,並優先考慮使用自給自足的標頭檔。
譯註一:顯示實例化 (Explicit Instantiation) 說明
顯示實例化指的是在使用模板時,直接指示編譯器應該針對哪些型別生成對應的模板。 例如下面例子:
my_class.h
:
#ifndef MY_CLASS_H
#define MY_CLASS_H
template <typename T>
class MyClass {
public:
void DoSomething();
};
extern template class MyClass<int>; // 宣告:MyClass<int> 會被顯式實例化(但不在這裡)
#endif // MY_CLASS_H
my_class.cc
:
#include "my_class.h"
template <typename T>
void MyClass<T>::DoSomething() {
// 實作內容
}
// 顯式實例化
template class MyClass<int>; // 只會在這個 `.cc` 檔案內產生 MyClass<int> 的實例
這個有別於一般常見的隱性實例化 (Implicit Instantiation) 的作法,也就是只有單純宣告模板,然後在真正要使用時才指定型別變數 T
對應的型別。 顯性實例化可以大幅提升編譯速度,因為它會確保整個程式編譯時模板只對指定的型別各生成一份類別的程式碼。
#define
保護 (The #define
Guard)
所有的標頭檔應該要包含
#define
保護,以防止多重載入。 其名稱的格式為<專案名稱>_<路徑>_<檔名>_H_
。
為了保證名稱的獨特性,應該要遵照該檔案在專案中的完整路徑來定義。 例如,一個在專案 foo 之中 foo/src/bar/baz.h
位置下的檔案,其保護應該要這樣寫:
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_
引入你需要的
如果一份原始碼檔或者標頭檔使用了一個定義在其他地方的符號,該檔應該要直接引入明確提供該符號的宣告或者定義的標頭檔。 它不應該為了其他目的引入標頭檔。
不要依賴傳遞式引入 (Transitive Inclusions) (譯註二)。 這讓大家可以放心移除自己標頭檔中不需要的 #include
,而不會影響使用該標頭檔案的程式碼。 這項原則同樣適用於相關標頭檔: foo.cc
如果使用了 bar.h
的符號,那就該直接引入。 即使 foo.h
已經引入了 bar.h
,foo.cc
仍應直接引入 bar.h
。
譯註二:傳遞式引入 (Transitive Inclusion) 說明
假設有以下兩個標頭檔:
a_class.h
:
#ifndef A_CLASS_H
#define A_CLASS_H
class A {
public:
void DoSomethingA();
};
#endif // A_CLASS_H
b_class.h
:
#ifndef B_CLASS_H
#define B_CLASS_H
#include "a_class.h"
class B {
public:
void DoSomethingB(A a);
};
#endif // B_CLASS_H
此時若 class B
的實作 b_class.cc
沒有引入 a_class.h
,而是依賴 b_class.h
的引入的話,就叫做傳遞式引入。 但 Google 的風格指南是鼓勵不要依賴這種引入。
前向宣告 (Forward Declarations)
盡可能地避免使用前向宣告。 只要
#include
你需要的標頭檔就好。
定義
前向宣告是指在沒有提供完整定義的情況下,預先宣告某個實體的存在:
// 在 C++ 程式碼中:
class B;
void FuncInB();
extern int variable_in_b;
ABSL_DECLARE_FLAG(flag_in_b); // 譯註:這是 Abseil 函式庫中一個用於宣告旗標(flag)的巨集。
優點
- 前向宣告可以節省編譯時間。
#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); } // 這裡會呼叫 f(B*)
如果上面的程式碼中,將
#include
替換成B
跟D
的前向宣告的話,test()
就會變成呼叫f(void*)
。 -
從標頭檔前向宣告多個符號,通常比直接
#include
更繁瑣且難以維護。 -
為了使用前向宣告而調整程式架構(例如將物件成員改為指標成員),可能會使程式變慢或增加複雜度。
決定
盡量避免前向宣告其他專案中的實體。
行內函式 (Inline Functions)
當函式程式碼較少 (少於或等於 10 行時),可以考慮將它宣告為行內函式。
定義
透過宣告函式為行內函式,可以讓編譯器直接在呼叫該函式的地方展開函式,而不是遵照一般的函式呼叫機制。
優點
若函式足夠小,行內化可以幫助產生更有效率的目的碼 (object code)。 你可以盡量將存取函式 (accessor) 、修改函式 (mutator) 以及一些極短但對效能有巨大影響的函式行內化。
缺點
過度使用行內函式可能會造成程式變慢。 依照函式長度的不同,行內化可能會增加或減少程式碼的大小。 行內化一個很小的存取函式通常可以縮小程式碼體積,而行內化一個較大的函式則可能會大幅增加程式碼的大小。 現代的處理器因為可以更有效率地使用指令快取 (instruction cache),處理較短的程式碼通常會更快。
決定
一個適當的規則是不要將 10 行以上的函式行內化。 需要特別注意解構函式 (destructors),它們往往比表面上看到的更長,因為還包含了隱含的基底類別 (base class) 與成員解構函式的呼叫。
另一個有用的規則: 一般來說將一個具有迴圈或者 switch
的函式行內化 (除非大多數的情況下這個迴圈或 switch
都不會被執行),通常不是有效率的做法。
還有一個重要的須知:就算將一個函式宣告為行內函式,編譯器也不一定會照做。 例如:虛擬函式 (virtual function) 或遞迴函式 (recursive function) 一般不會被行內化。 遞迴函式通常不應該行內化。 至於將虛擬函式寫成行內函式的理由,通常只是為了方便將函式的定義放在類別內,或是用來記錄其行為,例如存取函式與修改函式。
譯註三:行內函式範例
這邊提供一個行內函式的範例,主要是透過 inline
關鍵字將函式行內化:
inline int sum(int a, int b) {
return a + b;
}
#include
時的名稱與順序
以右列順序引入標頭檔:相關的標頭檔、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
檔內的東西,那麼 #include
的順序應該這樣寫:
dir2/foo2.h
- 空白行
- C 系統檔,或是其他應該用尖括號引入且以
.h
為副檔名的標頭檔(例如:<unistd.h>
、<stdlib.h>
與<Python.h>
)。 - 空白行
- C++ 標準函式庫標頭檔且不含副檔名(例如:
<algorithm>
與<cstddef>
)。 - 空白行
- 其他函式庫的
.h
檔。 - 空白行
- 你的專案的
.h
檔。
注意所有有包含標頭檔的群組都應該以空白行分開。
依照這個順序,如果 dir2/foo2.h
遺漏了任何必要的 #include
,那麼在建置 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/server/fooserver.cc
檔中的 #include
可能長這樣:
#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
,這種引入就可以放在所有 #include
之後。 當然,盡可能地讓這種程式碼越少且影響範圍越小越好。
例子:
#include "foo/public/fooserver.h"
#include "base/port.h" // 可能有 LANG_CXX11
#ifdef LANG_CXX11
#include <initializer_list>
#endif // LANG_CXX11
作用域 (Scoping)
名稱空間 (Namespaces)
除了某些特殊情況外,程式碼應當放在名稱空間中。 名稱空間應該具有基於專案名稱的獨特名稱,可能也包含其路徑。 不要使用 using 指示詞 (using-directive),像是
using namespace foo
。 不要使用行內名稱空間 (inline namespace)。 關於未命名的名稱空間,請參考 「內部鏈結」 一節。
定義
名稱空間將全域作用域細分為彼此獨立且具名的子作用域,這可以有效避免在整個作用域中遇到名稱相衝的狀況。
優點
名稱空間提供了一種避免在大型程式中名稱相衝的同時,也能讓大部分程式碼使用較短名稱的方法。
例如,兩個不同的專案在全域都具有 Foo
這個類別,這個符號可能會在編譯或執行時發生衝突。 如果這些專案能把他們的程式碼分別放在各自的名稱空間下,那麼 project1::Foo
跟 project2::Foo
就會被視為不同的符號,也不會有衝突的問題,而且在各自的專案中還能夠繼續使用 Foo
這個名字同時不需加上前綴。
行內名稱空間 (inline namespace) 會自動把它們的名稱放進作用域中。 例如,請參考以下程式碼:
namespace X {
inline namespace Y {
void foo();
} // namespace Y
} // namespace X
這會使 X::Y::foo()
與 X::foo()
可以互相交換使用。 當初行內名稱空間的主要目標就是用來處理不同版本的 ABI (Application Binary Interface, 應用二進位介面) 的兼容問題。
缺點
名稱空間可能容易造成混淆,因為它們將找出一個名稱指向的實際東西這個機制複雜化。
行內名稱空間可能會造成混淆,因為名稱實際上不會受限於它們被宣告的名稱空間內。 它們只在某些需要分類不同版本的情況下才可能會有幫助。
在某些狀況中,可能會需要持續使用完整的名稱來指出正確的符號。 對於多層的名稱空間來說,這樣的寫法可能會造成雜亂。
決定
名稱空間應該如下列般使用:
-
遵從 名稱空間命名 一章的規則。
-
如同這章節的範例中在名稱空間的收尾處加上註解。
-
名稱空間必須將整個原始碼檔中,在
#include
、gflags 定義和前向宣告其他名稱空間的類別之後的內容全部包裹起來。// 在某個 .h 檔中 namespace mynamespace { // 所有的定義都在名稱空間的作用域內 // 注意這裡沒有縮排 class MyClass { public: ... void Foo(); }; } // namespace mynamespace
// 在某個 .cc 檔中 namespace mynamespace { // 函式的定義也放在名稱空間的作用域內 void MyClass::Foo() { ... } } // namespace mynamespace
較複雜的
.cc
檔可能包含額外細節,例如 flags 或者 using 指示詞。#include "a.h" ABSL_FLAG(bool, someflag, false, "a flag"); namespace mynamespace { using ::foo::bar; ...code for mynamespace... // 程式碼左側不留空間 } // namespace mynamespace
-
如果想將自動產生的協議訊息 (Protocol Message) 的程式碼放在命名空間中,請在
.proto
中使用package
來指定。 詳情請參考 Protocol Buffer Packages。 -
別在
std
名稱空間下宣告任何東西,包括標準函式庫的前向宣告。 宣告std
名稱空間下的實體可能導致未定義的行為,也就是說,這並不是一個跨平台的作法。 如果想要宣告標準函式庫內的實體,請直接#include
那些標頭檔。 -
你不該為了要讓某個名稱空間中的名稱皆可直接呼叫而使用 「using 指示詞 (using-directive)」。
// 禁用 -- 這會汙染名稱空間 using namespace foo;
-
別在標頭檔中的名稱空間中使用名稱空間別名 (Namespace Alias),除非是在明確被標示為只給專案內部使用的名稱空間。 因為在一個被載入的標頭檔內的任何東西,都會被當作該檔的公開 API 對待。
// 在 `.cc` 檔中簡化存取某個常用名稱的動作 namespace fbz = ::foo::bar::baz;
// 在 `.h` 檔中簡化存取某個常用名稱的動作 namespace librarian { namespace internal { // 內部區域,不是公共 API 的一部份 namespace sidetable = ::pipeline_diagnostics::sidetable; } // namespace internal inline void my_inline_function() { // 只在這個函式(或方法)內才有作用的命名空間別名 namespace baz = ::foo::bar::baz; ... } } // namespace librarian
-
別使用行內名稱空間
-
在名稱空間的命名中加入
internal
字樣來標註這個名稱空間不該被 API 的使用者所用。// 我們不該在非 `absl` 的名稱空間中使用向下面這種內部 API。 using ::absl::container_internal::ImplementationDetail;
-
我們推薦在新程式碼中使用單行巢狀名稱空間,但不是必須的。
譯註四:行內名稱空間介紹
因為行內名稱空間並非常見的 C++ 特性,以下稍微介紹一下這個功能。
首先看下面這段範例:
namespace a {
namespace b {
void fun() {
// code
}
} // namespace b
} // namespace a
一般來說,上面這段程式碼想要呼叫 fun
這個函式的話,就要使用 a::b::fun()
這個名稱。 但是如果此時引入了行內關鍵字,在 namespace b
前面加上 inline
,變成這樣:
namespace a {
inline namespace b {
void fun() {
// code
}
} // namespace b
} // namespace a
這個時候你使用 a::fun()
這個名稱的話,就可以呼叫到 fun
這個函式。 當然,使用 a::b::fun()
也同樣可以呼叫得到 fun
。 有興趣的話可以自己試試看。
運作的原理很簡單,其實就是程式在編譯將 a::fun()
解析成 a::b::fun()
而已。
那這個技巧有甚麼用? 既然我想讓其他人直接呼叫到 fun
,那為什麼還要特別放一個 namespace b
?
這個技巧最常用到的地方,就像上面 Google 文件中所提到的,版本控管。
如果今天我是在寫函式庫,此時我寫了兩種不同版本的 fun
,可是有些人的程式已經連結到了舊版的 fun
,那要怎麼樣在不重新編譯那些程式的狀況下保持連結? 這個時候可以這樣做:
假設原本的函式庫長這樣:
namespace some_lib {
inline namespace v1 {
void fun() {
// code
}
} // namespace v1
} // namespace some_lib
因此原本編譯的程式裡,呼叫到 some_lib::fun
的地方都會連結到 some_lib::v1::fun
。 而此時我只要改成:
namespace some_lib {
namespace v1 {
void fun() {
// code
}
} // namespace v1
inline namespace v2 {
void fun() {
// code
}
} // namespace v2
} // namespace some_lib
這樣原本連結到 some_lib::v1::fun
的程式仍然可以運作,而且新的程式呼叫 some_lib::fun
時,也可以如我預期地使用到新的版本 (v2 版)。 因此行內名稱空間才常用於函式庫的版本控管。 但是如同 Google 風格指南所說,這樣的行為可能實在會導致預期外的結果,所以還是盡量避免使用。
內部鏈結 (Internal Linkage)
當
.cc
檔中的定義不需要被外部檔案參考時,請透過將其放入無名名稱空間或宣告為static
來使其具有內部鏈結,但不要在.h
檔內使用任何這種結構。
定義
所有的宣告都可以透過放入無名名稱空間來使其具有內部鏈結。 函數與變數也可以透過宣告為 static
來實現內部鏈結。 這表示所宣告的內容無法被其他檔案存取。 如果其他檔案中宣告了同樣名稱的東西,那麼這兩者會被視為獨立的個體。
決定
我們鼓勵在 .cc
檔中對不需要外部引用的程式碼使用內部鏈結,但不要在 .h
檔中使用。
無名名稱空間的格式與具名名稱空間相同。 對於這種名稱空間的結尾註解,只要在名稱的部分留白即可:
namespace {
...
} // namespace
非成員、靜態成員、全域函式
盡量將非成員函式放在名稱空間中;應盡量避免使用全域範圍的函式(即不屬於任何名稱空間的函式)。 請不要單純為了組合靜態函式而創建一個類別。 類別的靜態函式一般來說應該要與類別的實例或者靜態資料有高度相關。
優點
非成員及靜態成員函式在某些狀況下很有用。 將非成員函式放在名稱空間中可以避免汙染全域的名稱空間。
缺點
非成員及靜態成員函式也許作為某個類別中的成員會更合理,特別是當它們需要存取外部資源或具有較高的相依性時。
決定
有時候定義一個不受類別實例綁住的函式很有用。 這樣的函式可以是靜態成員或者非成員函式。 非成員函式不應該依賴在某個外部變數上,而且應該幾乎都放在某個名稱空間內。 不要為了將一組靜態成員歸類而建立類別,這與直接在函式名稱前加上相同前綴並無不同,而這種做法通常沒有必要。
如果你定義了一個非成員函式,而且它只需要用在它所屬的 .cc
檔中,請使用 內部鏈結 來限制它的作用域。
區域變數
將函式的變數盡可能地放在最小的作用域內,並在宣告變數的同時初始化。
C++ 允許你在函式內的任何地方宣告變數。 我們鼓勵你盡可能地將變數宣告在越局部的作用域越好,並且最好靠近它第一次被使用的地方。 這讓讀者更容易找到變數的宣告位置,並了解其型別及初始化值。 特別要注意初始化與宣告應避免分開,例如:
int i;
i = f(); // 不好 -- 初始化與宣告分離
int i = f(); // 很好 -- 宣告同時初始化
int jobs = NumJobs();
// 很多程式碼...
f(jobs); // 不好 -- 宣告與使用處分離
int jobs = NumJobs();
f(jobs); // 很好 -- 宣告後面緊跟著(或很接近)使用處
std::vector<int> v;
v.push_back(1); // 偏好使用大括號初始化法 (brace initialization)
v.push_back(2);
std::vector<int> v = {1, 2}; // 很好 -- v 一開始就初始化好
if
、while
與 for
陳述句需要的變數一般來說應宣告在該陳述句內,以將其限制在對應的作用域中。 例如:
while (const char* p = strchr(str, '/')) str = p + 1;
有一個例外:如果該變數是物件,則每次進入該作用域時,建構函式都會被呼叫,而解構函式則會在離開時執行。
// 低效率的寫法
for (int i = 0; i < 1000000; ++i) {
Foo f; // 我的建構函式與解構函式會各被呼叫 1000000 次
f.DoSomething(i);
}
在這種情況下,將變數宣告在迴圈外能提升執行效率:
Foo f; // 我的建構函式與解構函式只會各被呼叫一次
for (int i = 0; i < 1000000; ++i) {
f.DoSomething(i);
}
靜態與全域變數
除非它們是可平凡解構的 (trivially destructible),否則禁止使用具有靜態儲存期 (static storage duration) 的物件。 簡單來說,這表示該物件的解構函式不執行任何操作,即便考慮其成員與基底類別的解構函式也是如此。 正式地說,這表示該類型沒有使用者自定義的解構函式,也沒有虛擬解構函式 (virtual destructor),而且他的基底類別跟非靜態成員都是可平凡解構的。 函式內部的靜態變數可以使用動態初始化。 不建議對靜態類別成員變數或命名空間作用域內的變數使用動態初始化,但在有限的情況下可以接受。 詳情請看下面說明。
一個基本原則:若某個全域變數的宣告本身能夠是 constexpr
(常數表示式),則它自然符合這些要求。
定義
每個物件都有儲存期 (storage duration),這與他的存活期 (lifetime) 相關。 有著靜態儲存期 (static storage duration) 的物件從被初始化開始會一路存活到程式結束。 這種物件可能是命名空間作用域內的一個 (全域) 變數、類別的靜態資料成員或是函式中帶有 static
修飾詞的區域變數。 函式靜態區域變數會在程式第一次經過他的宣告時被初始化;所有其他有著靜態儲存期的物件則作為程式啟動的一部分而同時被初始化。 所有有著靜態儲存期的物件在程式結束時被摧毀 (發生在所有尚未合併的執行緒終止之前)。
初始化可以是動態的,這表示初始化過程中可能涉及非平凡 (non-trivial) 的操作。 (例如:一個動態分配記憶體,或是使用現在程序的 ID 進行初始化的建構函式。) 其他類的初始化都叫做靜態的初始化,不過這兩者並不是完全相對的: 有著靜態儲存期的物件總是會先靜態初始化 (例如用一個常數或者是一組全部都是零的位元初始化變數),然後需要的話才會再進行動態初始化。
優點
全域與靜態變數對於很多應用來說非常好用: 具名常數、一些轉換元件中的輔助資料結構、命令提示的旗標 (flag)、日誌、註冊機制、背景基礎設施等等。
缺點
全域與靜態變數使用動態初始化或者不平凡的解構函式而產生的複雜度容易導致難以尋找的錯誤。 動態初始化在轉換元件之間或者解構時沒有被排序 (除了那種在反向初始化時的解構動作)。 當一個初始化指向另一個有著靜態儲存期的變數時,有可能會導致一個物件在他的存活期開始前 (或者結束之後) 被存取。 更甚者,當一個程式啟動了一些在結束時不會合併的執行緒,這些執行緒可能會存取已經超出存活期的物件 (若該物件的解構函式已經執行完畢)。
決定
對於解構函式
當解構函式是平凡的 (trivial),他的執行完全並不會被順序所影響 (因為他們基本上不會執行); 否則,我們可能會面臨在物件存活期結束後仍然存取它們的風險。 因此只有當物件是可平凡解構的時,我們才允許它們擁有靜態儲存期。 基礎型別 (像是指標或者 int
) 都是可平凡解構的,這些型別的陣列也都是。 請注意,使用 constexpr
標示的變數都是可平凡解構的。
const int kNum = 10; // 允許
struct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // 允許
void foo() {
static const char* const kMessages[] = {"hello", "world"}; // 允許
}
// 允許: constexpr 保證該變數可平凡解構
constexpr std::array<int, 3> kArray = {{1, 2, 3}};
// 不好: 非可平凡解構的物件
const string kFoo = "foo";
// 即使 kBar 是參考 (reference),仍涉及非平凡解構。
// 注意: 此規則亦適用於存活期延長的臨時物件。
const string& kBar = StrCat("a", "b", "c");
void bar() {
// 不好: 非可平凡解構函式
static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}
請注意,參考 (reference) 不是物件,因此不受解構性的限制影響。 不過動態初始化的限制仍然適用。 特別是具有 static T& t = *new T;
形式的函式內區域靜態變數是允許的。
對於初始化
初始化是一個更複雜的主題。 因為我們不只要考慮類別的建構式是否執行,也要考慮初始化時的計算:
int n = 5; // 可以
int m = f(); // ? (根據 f 的行為決定)
Foo x; // ? (根據 Foo::Foo 的行為決定)
Bar y = g(); // ? (根據 g 與 Bar::Bar 的行為決定)
除了第一式之外都帶來不確定的初始化順序問題。
我們這邊想要表達的概念在 C++ 標準的正式語言中稱為「常數初始化 (Constant Initialization)」。 這代表初始化的表示式 (expression) 是一個常數表示式 (constant expresion),而且如果物件是透過建構函式初始化,那麼該建構函式也一定要被標記 constexpr
:
struct Foo { constexpr Foo(int) {} };
int n = 5; // 可以,5 是一個常數表示式
Foo x(2); // 可以,2 是一個常數表示式,而且選擇的建構函式也是 constexpr
Foo a[] = { Foo(1), Foo(2), Foo(3) }; // 可以
我們一律允許常數初始化。 靜態儲存期的變數若使用常數初始化的話,應標上 constexpr
或 constinit
。 任何沒有依照這種方式標記的非區域靜態儲存期變數應該要假定是使用動態初始化,並且在審查時要特別小心。
作為反例,以下初始化是有問題的:
// 一些宣告
time_t time(time_t*); // 沒有使用 constexpr!
int f(); // 沒有使用 constexpr!
struct Bar { Bar() {} };
// 有問題的初始化
time_t m = time(nullptr); // 初始化的表示式並非是常數表示式
Foo y(f()); // 同上
Bar b; // 選用的建構函數 Bar::Bar() 也不是 constexpr
我們不鼓勵對於非區域變數使用動態初始化,而且通常來說也不允許。 然而,如果程式中沒有任何層面依賴該初始化與其他初始化之間的順序的話,我們就允許這麼做。 在這樣的限制下,初始化間的順序並不會產生可見的差異。 例如:
int p = getpid(); // 允許,只要沒有其他靜態變數使用 p 來初始化就好
我們允許對區域靜態變數使用動態初始化 (而且很常用)。
常見模式
- 全域字串: 如果需要一個具名的全域或者靜態字串,請考慮使用右列的
constexpr
變數:string_view
(在 Abseil 函式庫中)、字元陣列、或是指向字串字面值 (string literal) 的字元指標。 字串字面值本來就有靜態儲存期,而且通常就夠用。 詳情請參考 TotW #140。 - Map、Set、以及其他動態容器: 如果你要求使用靜態的固定集合,像是一個 Set 或是一個查詢表,你不能使用標準函式庫的動態容器作為靜態變數,因為他們具有非平凡的解構函式。 反之,請考慮使用一個包含平凡型別的簡單陣列,例如使用一個整數的陣列的陣列來表示一個從整數映射到另一組整數的關係,或是一個整數與字元指標的
Pair
陣列表示整數映射到字串的關係。 對於小的資料集,線性搜尋就很夠了 (而且因為記憶體局部性 (memory locality) 的關係還很有效率)。 可以考慮使用absl/algorithm/container.h
來對他們進行一些標準操作。 如果必要的話,保持集合的排序,以便使用二元搜尋 (binary search)。 如果你真的比較想使用標準函式庫的動態容器,請考慮使用函式內的靜態指標,詳情見下文。 - 智慧指標 (smart pointers): 智慧指標會在解構時執行清理動作,因此被禁止使用。 請考慮你的使用狀況是否符合這章節中描述的其他模式。 一種簡單的解法是對動態分配的物件使用一般指標,然後永遠不要刪除它 (請看最後一項)。
- 自製型別的靜態變數: 如果你需要對你自己設計的型別使用靜態的常數資料,請確保該型別擁有平凡的解構函式與
constexpr
建構函式。 - 如果這些方法都不能用,你可以動態建立一個物件,並永遠都不要刪除它 (例如:
static const auto& impl = *new T(args...);
)。
thread_local
變數
對於不是在 function 內宣告的
thread_local
變數,一定要以真編譯期常數 (true compile-time constant) 來初始化,這一點必須透過constinit
屬性來強制執行。 偏好使用thread_local
來定義執行緒內資料 (thread-local data)。
定義
變數可以在宣告時加上 thread_local
修飾詞:
thread_local Foo foo = ...;
這類變數實際上是多個物件的集合,因此當不同的執行緒存取它時,實際上是在存取不同的物件。 thread_local
變數其實在很多方面與 靜態儲存期變數 很接近。 舉例來說,它們可以被宣告在命名空間的作用域、函式內部、或是做為類別的靜態成員,但不能做為一般類別的成員。
thread_local
變數的初始化很像靜態變數的作法,只差在它們必須在每個執行緒裡面分別初始化,而不是在程式啟動時一次初始化。 這代表著那些在函式內宣告的 thread_local
變數很安全,但是其他的 thread_local
變數則會像靜態變數一樣遭遇初始化順序的問題 (以及其他更多問題)。
thread_local
變數有一個微妙的解構順序問題: 在執行緒結束的時候,thread_local
變數會依照他們被初始化的相反順序進行解構 (在 C++ 通常是這樣)。 如果發生任何被解構函式觸動的程式碼參考了任何該執行緒內已經被銷毀的 thread_local
變數,那對我們來說會變得很難去分析釋出後使用 (use-after-free) 的問題。
優點
- 執行緒內資料從本質上來說不會受到資料競爭 (data race) 影響 (因為只有一個執行緒可以存取),這點讓
thread_local
對並行程式設計很有幫助。 thread_local
是唯一標準用來建立執行緒內變數 (thread-local variables) 的方式。
缺點
- 在執行緒啟動或者初次存取
thread_local
的變數時,可能會觸發無法預測且數量不可控的額外程式碼執行。 thread_local
變數本質上與全域變數無異,因此它具有全域變數的所有缺點,唯一的例外是它是執行緒安全的 (thread safe)。thread_local
變數帶來的記憶體消耗會隨著執行緒數量增加而變大,有可能會造成很大的負擔。- 一般類別的成員除非是
static
,不然就不能是thread_local
。 - 如果
thread_local
變數有很複雜的解構函式,那可能會遭遇到釋出後使用 (use-after-free) 的問題。 特別是這類變數的解構函式不得間接或直接存取任何可能已被銷毀的thread_local
資料。 但這件事情也很難被強制。 - 對於防範全域或靜態資料發生釋出後使用問題的方法並無法用在
thread_local
資料上。 確切來說,我們可以允許略過全域或靜態變數的解構函數,因為他們的生存期止於程式結束。 因此,任何洩漏問題會由作業系統的資源與記憶體清理機制進行處理。 相反地,在程式執行中結束的執行緒若是略過thread_local
變數的解構函式的話,可能會導致資源洩漏,其規模與程式執行期間終止的執行緒總數成正比。
決定
對於類別與名稱空間作用域內的 thread_local
變數,一定要以真編譯期常數 (true compile-time constant) 來初始化 (換言之,他們不能用動態初始化)。 為了要強制這點,類別與名稱空間作用域內的 thread_local
變數一定要標註 constinit
(或是 constexpr
,但須盡量少用):
constinit thread_local Foo foo = ...;
定義在函式內的 thread_local
變數沒有初始化疑慮,但在執行緒結束時可能會有釋出後使用 (use-after-free) 的問題。 注意你可以利用定義一個會回傳 thread_local
變數的函式或靜態方法,來使用函式作用域等級的 thread_local
變數,以模擬類別或是命名空間作用域等級的 thread_local
:
Foo& MyThreadLocalFoo() {
thread_local Foo result = ComplicatedInitialization(); // 函式名稱:很複雜的初始化
return result;
}
注意 thread_local
變數會在執行緒結束時銷毀。 如果任何此類變數的解構函式用到其他可能被摧毀的 thread_local
變數,都可能會造成難以診斷的釋出後使用錯誤。 因此,宣告 thread_local
變數時應優先選擇簡單型別 (trivial type),或確保在解構時不會執行使用者提供的程式碼,以降低誤用已刪除的 thread_local
變數的風險。
應優先使用 thread_local
來定義執行緒內資料。
類別 (Classes)
類別是 C++ 程式碼中的基本元件。 我們很自然地會廣泛使用它。 這章列出了你在撰寫類別的時候,應該以及不應該做的事情。
在建構函式內的工作
避免在建構函式內呼叫虛擬函式,並且在無法適當回報錯誤時,避免進行可能失敗的初始化。
定義
建構函式內可以執行任意的初始化動作。
優點
- 不需要擔心類別是否已經初始化過了。
- 已經經過建構函式充分初始化的物件可以作為
const
使用,而且與標準容器與演算法使用時更容易。
缺點
- 如果在建構函式內呼叫虛擬函式,這些呼叫不會被轉發至子類別的實作。 即使你的類別目前沒有子類別,未來的修改可能會悄然引入這個問題,造成困惑。
- 建構函式沒有簡單的方法來回報錯誤,除了直接讓程式崩潰 (這並不總是合適) 或使用例外 (也被我們 禁止 了)。
- 如果工作失敗了,那我們此時就會有個初始化失敗的物件。 這時可能需要提供
bool IsValid()
之類的方法來檢查狀態,但這種機制容易被遺漏,導致錯誤。 - 由於無法取得建構函式的函式指標,因此建構函式內的工作難以被移交,例如交給另一個執行緒。
決定
建構函式應該永遠不要呼叫虛擬函式。 在適當的情況下,終止程式可能是一種合理的錯誤處理方式。 否則,可以考慮像 TotW #42 所述那樣建立工廠方法 (Factory Method) 或者 Init()
方法。 避免在沒有其他狀態影響可呼叫公用方法的物件上使用 Init()
方法 (這類半建構狀態的物件特別難以正確使用) (譯註:這句話的意思是,如果沒有需要延遲初始化或者錯誤處理的需求,就不要特別為了初始化寫一個 Init()
,而是使用建構函式進行初始化即可。)
隱性轉換 (Implicit Conversions)
不要定義隱性轉換。 轉換運算子和單一引數的建構函式應標記為
explicit
。
定義
隱性轉換允許將一種型別 (稱為「來源型別」) 的物件用於原本預期另一種不同的型別 (稱為「目的型別」) 的地方,例如將一個 int
的引數傳給預期接受 double
引數的函式。
除了語言本身定義的隱性轉換之外,使用者也可以藉由在來源型別或目的型別的類別中加入適當的成員來自行定義。 若要在來源型別中定義隱性轉換,可透過定義一個名稱為目的型別的型別轉換運算子來完成(例如:operator bool()
)。 若要在目的型別中定義隱性轉換,則是透過定義一個僅接受來源型別作為唯一引數的建構函式(或唯一沒有預設值的引數)來完成。
可以在建構函式或轉換運算子前加上 explicit
關鍵字,以確保只有在使用處明確指定目的型別(例如透過強制轉型)時才能使用。 這個限制除了應用於隱性轉換外,也會應用於 list 初始化語法:
class Foo {
explicit Foo(int x, double y);
...
}
void Func(Foo f);
Func({42, 3.14}); // 發生錯誤
這類程式碼技術上來說並不算是隱性轉換,但是語言把它納入 explicit
關鍵字的適用範圍中。
優點
- 當型別已經很明顯時,隱性轉換可以省去明確標示型別的必要,使程式更容易使用且更明瞭。
- 隱性轉換可以當作比多載 (overloading) 更簡單的一種方案,例如一個接受
string_view
為引數的函式就可以同時代表接受string
與const char*
兩種型別的函式。 - List 初始化語法是一種簡潔明瞭的初始化做法。
缺點
- 隱性轉換會隱藏型別不合 (type-mismatch) 的 bug,像是目的型別不符合使用者的預期,或是使用者根本沒意識到有轉換發生。
- 隱性轉換會讓程式碼難以閱讀,尤其在有多載函式時,會讓實際呼叫的程式碼變得不明確。
- 單一引數的建構子可能會被意外地用於隱性型別轉換,就算這並非本意。
- 如果一個單一引數的建構子沒有標註
explicit
,並沒有一種可靠的方式能夠判斷到底是作者想要提供隱性轉換,還是只是單純忘記標註而已。 - 隱性轉換可能導致呼叫處產生混淆,特別是在雙向隱性轉換存在時。 這可能發生於兩邊的型別都有實作隱性轉換,或者一邊的型別同時實作了隱性建構函式與隱性型別的轉換函式。
- List 初始化在目的型別不明確時也會遭遇相同問題,特別是在 list 中只有一個元素的時候。
決定
型別轉換運算子與以單一引數呼叫的建構子必須在類別的定義中標註 explicit
。 一個例外是,複製與轉移建構函式不應該是 explicit
,因為他們並不會進行型別轉換。
隱性轉換在某些時候對於可以互換的型別來說可能是必要且恰當的,例如當某兩種型別其實只是底層數值的不同表示方式時就是如此。 如果遇到這種情況,請聯繫你的專案領導來豁免這條規則。
對於無法使用單一引數呼叫的建構子可以省略 explicit
。 只接受單一 std::initializer_list
引述的建構函式也應該省略 explicit
,以支援複製初始化語法 (copy-initalization, 例如:MyType m = {1, 2};
)。
建置中...
🚧 Struct 與 Class 的比較
建置中...
建置中...
建置中...
建置中...
建置中...
建置中...
🚧 其他 C++ 特性
🚧 例外 (Exceptions)
WIP
🚧 預處理器巨集
WIP
命名 (Naming)
最重要的一致性規則是那些規範命名方式的規則。 一個名稱的風格能夠立即告訴我們被命名的實體是哪種類型,而不需要去搜尋該實體的宣告:一個型別、一個變數、一個函數、一個常數、一個巨集等等。 我們大腦中的模式匹配 (Pattern-Matching) 引擎高度仰賴這些命名規則。
命名的規則相當主觀,但我們認為在這個領域內,一致性比個人喜好更重要。 因此無論你認為這些規則是否合理,規則就是規則。
對於以下的命名規則來說,在這裡所謂的『單字』,指的是以英文撰寫、不含空格的詞彙。 無論這些單字是全部都小寫,中間包含底線(蛇形命名法:snake_case
),或者是由多個單字組成,且每個單字的首字母大寫的寫法(駝峰式命名法:camelCase
或帕斯卡命名法:PascalCase
)。
選擇名稱 (Choosing Names)
為事物命名時,應使其目的與意圖對讀者而言清晰易懂,即使是來自不同團隊的讀者也能理解。 不必在意節省橫向空間,因為讓程式碼能被新讀者立即理解更為重要。
考慮該名稱可能被使用的情境。 名稱應該具有描述性,即使它會在離其定義處很遠的地方被使用,然而名稱不應重複當下語境中已經明示的資訊。 一般而言,名稱的描述性應該與其可見範圍成正比。 例如定義在標頭檔中的自由函式名稱可能會包含標頭檔所屬的函式庫名稱,而區域變數則不需要解釋它所屬的函式。
盡量減少使用對專案外的人來說可能不熟悉的縮寫(特別是縮寫成首字母的縮略字)。 不要透過刪除單字中的字母來產生縮寫。 如果要使用縮寫,偏好將縮寫視作單一單字並只將首字母大寫 (像是 StartRpc()
,而不是 StartRPC()
)。 有一個原則是,一個縮寫如果有被列在維基百科 (Wikipedia) 上的話,應該也可以接受。 注意有些普遍知道的縮寫是可以接受的,像是 i
作為疊代 (iteration) 次數以及 T
作為模板 (template) 參數。
你最常會看到的名稱與大多數名稱不同,極少數的「詞彙」名稱被廣泛重複使用,因此它們總是出現在適當語境中。 這些名稱傾向於很短或者甚至是縮寫,而他們完整的意思常來自於明確且完整的長篇文件而非僅靠名稱定義旁的註解或名稱本身的詞語來理解。 像是 absl::Status
在開發文件中有一個 專門的頁面 說明它正確的用法。 你大概不會很常定義新的詞彙名稱,但如果有的話,記得進行額外的設計審查,以確保該名稱在廣泛使用時仍保持合適。
好的例子:
class MyClass {
public:
int CountFooErrors(const std::vector<Foo>& foos) {
int n = 0; // 在有限的作用域與上下文中意圖明顯
for (const auto& foo : foos) {
...
++n;
}
return n;
}
// 函式的註解不需解釋這個函式在錯誤時會回傳 non-OK 狀態,因為 `absl::Status`
// 本身就自帶這個意思。 但可以用來記錄某些特定錯誤碼的行為。
absl::Status DoSomethingImportant() {
std::string fqdn = ...; // 這是 "Fully Qualified Domain Name" 的常見縮寫
return absl::OkStatus();
}
private:
const int kMaxAllowedConnections = ...; // 在上下文中意圖明顯
};
不好的例子:
class MyClass {
public:
int CountFooErrors(const std::vector<Foo>& foos) {
int total_number_of_foo_errors = 0; // 在有限作用域與上下文中顯得太冗長了
for (int foo_index = 0; foo_index < foos.size(); ++foo_index) { // 建議使用慣用的 `i`
...
++total_number_of_foo_errors;
}
return total_number_of_foo_errors;
}
// `Result` 這個名稱過於通用,若無廣泛教學則難以理解其含義
Result DoSomethingImportant() {
int cstmr_id = ...; // 這種寫法刪除了單字內部的字母
}
private:
const int kNum = ...; // 在這個較大的作用範圍內,這個名稱的意圖不明
};
檔案名稱 (File Names)
檔案名稱應該全部使用小寫字母,並可以包含底線 (
_
) 或是橫槓 (-
)。 關於這點請遵循各專案的慣例。 如果沒有一致可循的本地慣例,偏好使用_
。
一些可接受的檔案名稱範例:
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
。 一個常見的情況是,當定義名為 FooBar
的類別時,通常會有一對檔案分別名為 foo_bar.h
與 foo_bar.cc
。
型別名稱 (Type Names)
型別名稱始於一個大寫字母,並且每個單字的開頭皆為大寫字母,同時不包含底線:
MyExcitingClass
、MyExcitingEnum
。
所有型別的名稱 - 類別、結構、型別別名、列舉、型別模板參數 - 都有相同的命名慣例。 型別名稱始於一個大寫字母,並且每個單字的開頭皆為大寫字母。 沒有底線。 例如:
// 類別與結構
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...
// typedef
typedef hash_map<UrlTableProperties *, string> PropertiesMap;
// 使用別名
using PropertiesMap = hash_map<UrlTableProperties *, string>;
// 列舉
enum UrlTableErrors { ...
變數名稱 (Variable Names)
變數 (包含函式的參數) 與資料成員的名稱全部都使用小寫,並使用底線隔開單字。 類別 (但不包含結構) 的資料成員要另外在尾部加上底線。 例如,區域變數:
a_local_variable
、結構資料成員:a_struct_data_member
、類別資料成員:a_class_data_member_
。
常見的變數名稱
可以接受的:
string table_name; // 可以 - 使用底線
string tablename; // 可以 - 全部小寫
不可接受的:
string tableName; // 不好 - 大小寫混合
類別資料成員
類別資料成員,包括靜態 (static
) 與非靜態的,都如同一般變數命名,但要在尾端加上底線。
class TableInfo {
...
private:
string table_name_; // 可以 - 尾端有底線
string tablename_; // 可以
static Pool<TableInfo>* pool_; // 可以
};
結構資料成員
類別資料成員,包括靜態 (static
) 與非靜態的,都如同一般變數命名。 他們不像類別一樣要在尾端加上底線。
struct UrlTableProperties {
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
所有具備以上性質同時有靜態儲存期 (例如靜態變數與全域變數,詳情請參照儲存期) 的變數都應該這樣命名。 這項慣例對其他儲存種類的變數來說是選擇性的 (例如自動變數),其他情況應使用一般的變數命名規則。
函數名稱
一般函式應使用大小寫混和;存取子 (Accessor) 與修改子 (Mutator) 可以使用變數的方式命名。
一般來說,函式應以大寫字母開頭,並在每一個新單字的首字使用大寫字母。
AddTableEntry()
DeleteUrl()
OpenFileOrDie()
(相同的命名規則也適用於類別與名稱空間範圍之中,那些作為 API 的一部分釋出的、或是意圖使人看起來像是函數的常數。 因為這些常數其實是物件而不是函式這點算是不重要的實作細節。)
存取子與修改子 (get 與 set 函式) 可以用變數的方式命名。 他們的命名通常與實際的成員變數有關,但這並非必要。 例如:int count()
與 void set_count(int count)
。
名稱空間名稱
名稱空間全部使用小寫。 最頂層的名稱空間應使用基於專案名稱的名字。 避免巢狀名稱空間以及與知名頂層名稱空間的命名碰撞。
頂層的命名空間應通常使用專案名稱或者開發該程式的團隊名稱。 該名稱空間中的程式碼應該要放在與其名稱空間的命名相符的資料夾中 (或是子資料夾中)。
注意針對縮寫名稱的規則也如同變數一樣適用於名稱空間。 名稱空間中的程式碼很少會需要提及名稱空間的名稱,所以通常沒有使用縮寫的必要。
避免使用名稱與知名頂層名稱空間衝突的巢狀名稱空間。 名稱空間的命名衝突可能因為命名查詢規則造成意外的建置錯誤。 特別是不要建立任何命名為 std
的巢狀名稱空間。 優先使用獨特的專案 ID (websearch::index
、websearch::index_util
),而不是容易發生衝突的名稱,像是 websearch::util
。
對於 internal
名稱空間,請注意將其他程式碼加入同樣的 internal
名稱空間中會導致衝突 (團隊內部的輔助函式 (Helper Functions) 傾向於且可能導致衝突)。 在這種情況下,使用檔名來建立一個獨特的內部名稱很有用 (像是對 frobber.h
中的程式使用 websearch::index::frobber_internal
)。
列舉器名稱
列舉器的值 (無論是否有限定範圍) 都應該要以常數或者巨集的方式命名:像是
kEnumName
或ENUM_NAME
。
可以的話,列舉器的值傾向於用常數的方式命名,不過使用巨集的方式也是可以接受的。 列舉器本身的名稱,例如 UrlTableErrors
(與 AlternateUrlTableErrors
),是一種型別,因此使用大小寫混和的命名方式。
enum UrlTableErrors {
kOK = 0,
kErrorOutOfMemory,
kErrorMalformedInput,
};
enum AlternateUrlTableErrors {
OK = 0,
OUT_OF_MEMORY = 1,
MALFORMED_INPUT = 2,
};
到 2009 年一月前,這個指引寫著應該要以巨集的方式命名。 這造成了列舉器的值與巨集的名稱衝突問題。 因此才改成了建議使用常數命名法。 新的程式碼應該在可以的時候皆使用常數命名法。 然而目前也沒有理由要把舊程式碼也改成常數命名法,除非舊程式碼造成了編譯時期的問題。
巨集名稱
你不是真要定義一個巨集吧? 如果真的要做的話,他們應該長這樣:
MY_MACRO_THAT_SCARES_SMALL_CHILDREN_AND_ADULTS_ALIKE
。
請先閱讀關於巨集的說明; 一般來說不該使用巨集。 然而如果你真的需要使用,他們應該全部使用大寫與底線命名。
#define ROUND(x) ...
#define PI_ROUNDED 3.0
名稱規則的例外
如果你正在為類似於 C 或 C++ 內已經存在的實體進行命名,那麼你可以遵循他們慣例的命名規則。
bigopen()
- 函式名稱,跟隨
open()
的形式命名
- 函式名稱,跟隨
uint
typedef
bigpos
struct
或class
,跟隨pos
的形式命名
sparse_hash_map
- 像是 STL 的實體;跟隨著 STL 的命名慣例
LONGLONG_MAX
- 一個常數,像
INT_MAX
一樣命名
- 一個常數,像
建置中...
建置中...
建置中...
建置中...
附錄:常用專有名詞中英對照表
下表以英文名詞按照字母順序排序
英文 (English) | 繁體中文 (Traditional Chinese) |
---|---|
Alias | 別名 |
Array | 陣列 |
Base Class | 基底類別 |
Bug | 錯誤 |
Class | 類別 |
Compile | 編譯 |
Compiler | 編譯器 |
Constructor | 建構函式 |
Data Race | 資料競爭 |
Declaration | 宣告 |
Define, Definition | 定義 |
Dependency | 依賴關係 |
Destructor | 解構函式 |
Directory | 資料夾、目錄 |
Entity | 實體 |
Enumerator | 列舉器 |
Explicit Instantiation | 顯式實例化 |
Expression | 表示式 |
External Linkage | 外部連結性 |
Forward Declaration | 前向宣告 |
Function | 函式 |
Global Scope | 全域 |
Handler | (尚無翻譯) |
Header, Header File | 標頭檔 |
Implicit Instantiation | 隱性實例化 |
Inline Function | 行內函式 |
Instance | 實例 |
Internal Linkage | 內部鏈結 |
Input | 輸入值 |
Iteration | 疊代 |
Library | 函式庫 |
Lifetime | 存活期 |
Link | 連結 |
Linker | 連結器 |
Literal | 字面值 |
Local | 局部的 |
Loop | 迴圈 |
Namespace | 命名空間 |
Object Code | 目的碼 |
Object File | 目的碼檔 |
Output | 輸出值 |
Overloading | 多載 |
Parameter | 參數 |
Pointer | 指標 |
Prefix | 前綴 |
Private | 私有的 |
Private Member | 私有成員 |
Public | 公用的 |
Public Member | 公用成員 |
Raw Pointer | 原始指標 |
Reference | 參考 |
Smart Pointer | 智慧指標 |
Statement | 陳述句 |
Static Data Member | 靜態資料成員 |
Static Member Function | 靜態成員函式 |
Storage Duration | 儲存期 |
Struct | 結構 |
Subclass | 子類別 |
Symbol | 符號 |
Transitive Inclusion | 傳遞式引入 |
Template | 模板 |
Thread | 執行緒 |
Type | 型別 |
Unnamed Namespaces | 無名名稱空間 |
Virtual Destructor | 虛擬解構函式 |
Virtual Function | 虛擬函式 |