C++最佳實踐 | 2. 程式碼風格

語言: CN / TW / HK

本系列是開源書C++ Best Practises的中文版,全書從工具、程式碼風格、安全性、可維護性、可移植性、多執行緒、效能、正確性等角度全面介紹了現代C++專案的最佳實踐。本文是該系列的第二篇。

程式碼風格

程式碼風格最重要的是一致性,其次是遵循C++程式設計師習慣的閱讀風格。

C++允許任意長度的識別符號名稱,因此在命名時沒必要非要保持簡潔,建議使用描述性名稱,並在風格上保持一致。

  • CamelCase(駝峰命名法)
  • snake_case(蛇形命名法)

這兩種是很常見的命名規範,snake_case的優點是,在需要的時候可以適配拼寫檢查器。

建立程式碼風格指南

無論建立什麼樣的程式碼風格指南,一定要實現指定期望風格的.clang-format檔案。雖然這對命名沒有幫助,但對於開源專案來說,保持一致的風格尤為重要。

許多IDE、編輯器都支援內建的clang-format,或者可以很方便的通過載入項安裝。

通用C++命名約定

  • 類以大寫字母開頭: MyClass
  • 函式和變數以小寫字母開頭: myMethod
  • 常量全部大寫: const double PI=3.14159265358979323

C++標準庫(以及其他著名C++庫,如Boost)使用以下指導原則:

  • 巨集使用大寫和下劃線: INT_MAX
  • 模板引數名使用駝峰命名法: InputIterator
  • 所有其他名稱都使用蛇形命名法: unordered_map

區分私有物件資料

使用m_字首命名私有資料,以區別於公共資料,m_代表“member(成員)”資料。

區分函式引數

最重要的是保持程式碼庫的一致性,這是一種有助於保持一致性的方式。

使用t_字首命名函式引數,t_可以被認為是“the”,但其可以表示任意含義,關鍵是要將函式引數與作用域內的其他變數區分開來,同時遵循一致的命名策略。

可以為團隊選擇任何字首或字尾,下面是一個例子,提出了一個有爭議的建議,相關討論見issue #11

```cpp struct Size { int width; int height;

Size(int t_width, int t_height) : width(t_width), height(t_height) {} };

// This version might make sense for thread safety or something, // but more to the point, sometimes we need to hide data, sometimes we don't. class PrivateSize { public: int width() const { return m_width; } int height() const { return m_height; } PrivateSize(int t_width, int t_height) : m_width(t_width), m_height(t_height) {}

private: int m_width; int m_height; }; ```

不要用下劃線(_)作為名字的開頭

_ 開頭的名字有可能與編譯器或標準庫的保留名發生衝突: What are the rules about using an underscore in a C++ identifier?

良好程式碼風格示例

```cpp class MyClass { public: MyClass(int t_data) : m_data(t_data) { }

int getData() const { return m_data; }

private: int m_data; }; ```

使Out-of-Source-Directory構建

確保構建生成的檔案存放在與原始檔夾分離的輸出資料夾中。

使用nullptr

C++11引入了nullptr表示空指標,應該用來代替0NULL來指示空指標。

註釋

註釋塊應該使用//,而不是/* */,使用//可以更容易的在除錯時註釋掉程式碼塊。

cpp // this function does something int myFunc() { }

要在除錯期間註釋掉這個函式塊,可以這樣做:

cpp /* // this function does something int myFunc() { } */

如果函式頭註釋使用/* */,這麼做就會有衝突。

永遠不要在標頭檔案中使用using namespace

這會導致正在using的名稱空間被強行拉入到包含標頭檔案的所有檔案的名稱空間中,從而造成名稱空間汙染,並可能在導致名稱衝突。在實現檔案中using名稱空間就足夠了。

Include保護符

標頭檔案必須包含名稱清晰的include保護符,從而避免同一標頭檔案被多次include的問題,並防止與其他專案的標頭檔案發生衝突。

```cpp

ifndef MYPROJECT_MYCLASS_HPP

define MYPROJECT_MYCLASS_HPP

namespace MyProject { class MyClass { }; }

endif

```

此外還可以考慮使用#pragma once指令,這是許多編譯器的準標準,內容簡短,意圖明確。

程式碼塊必須包含{}

省略{}可能會導致程式碼語義錯誤。

```cpp // Bad Idea // This compiles and does what you want, but can lead to confusing // errors if modification are made in the future and close attention // is not paid. for (int i = 0; i < 15; ++i) std::cout << i << std::endl;

// Bad Idea // The cout is not part of the loop in this case even though it appears to be. int sum = 0; for (int i = 0; i < 15; ++i) ++sum; std::cout << i << std::endl;

// Good Idea // It's clear which statements are part of the loop (or if block, or whatever). int sum = 0; for (int i = 0; i < 15; ++i) { ++sum; std::cout << i << std::endl; } ```

保持每行程式碼長度合理

```cpp // Bad Idea // hard to follow if (x && y && myFunctionThatReturnsBool() && caseNumber3 && (15 > 12 || 2 < 3)) { }

// Good Idea // Logical grouping, easier to read if (x && y && myFunctionThatReturnsBool() && caseNumber3 && (15 > 12 || 2 < 3)) { } ```

許多專案和編碼標準都對此制定了軟規則,即每行字元應該少於80或100個,這樣的程式碼通常更容易閱讀,此外還可以把兩個檔案並排顯示在一個螢幕上,不用小字型也能看到全部程式碼。

使用""表示include本地檔案

...<>表示include系統檔案

```cpp // Bad Idea. Requires extra -I directives to the compiler // and goes against standards.

include

include

// Worse Idea // Requires potentially even more specific -I directives and // makes code more difficult to package and distribute.

include

include

// Good Idea // Requires no extra params and notifies the user that the file // is a local file.

include

include "MyHeader.hpp"

```

初始化成員變數

...使用成員初始化列表。

對於POD型別,初始化列表的效能與手動初始化相同,但對於其他型別,有明顯的效能提升,見下文。

```cpp // Bad Idea class MyClass { public: MyClass(int t_value) { m_value = t_value; }

private: int m_value; };

// Bad Idea // This leads to an additional constructor call for m_myOtherClass // before the assignment. class MyClass { public: MyClass(MyOtherClass t_myOtherClass) { m_myOtherClass = t_myOtherClass; }

private: MyOtherClass m_myOtherClass; };

// Good Idea // There is no performance gain here but the code is cleaner. class MyClass { public: MyClass(int t_value) : m_value(t_value) { }

private: int m_value; };

// Good Idea // The default constructor for m_myOtherClass is never called here, so // there is a performance gain if MyOtherClass is not is_trivially_default_constructible. class MyClass { public: MyClass(MyOtherClass t_myOtherClass) : m_myOtherClass(t_myOtherClass) { }

private: MyOtherClass m_myOtherClass; }; ```

在C++11中,可以為每個成員初始化預設值(使用=或使用{})。

使用=設定預設值

cpp // ... // private: int m_value = 0; // allowed unsigned m_value_2 = -1; // narrowing from signed to unsigned allowed // ... //

這樣可以確保不會出現建構函式“忘記”初始化成員物件的情況。

用大括號初始化預設值

用大括號初始化不允許在編譯時截斷資料長度。

```cpp // Best Idea

// ... // private: int m_value{ 0 }; // allowed unsigned m_value_2 { -1 }; // narrowing from signed to unsigned not allowed, leads to a compile time error // ... // ```

除非有明確的理由,否則優先使用{}初始化,而不是=

忘記初始化成員會導致未定義行為錯誤,而這些錯誤通常很難發現。

如果成員變數在初始化後不會更改,則將其標記為const

```cpp class MyClass { public: MyClass(int t_value) : m_value{t_value} { }

private: const int m_value{0}; }; ```

由於不能給const成員變數賦值,拷貝賦值操作可能對這樣的類沒有意義。

總是使用名稱空間

幾乎沒有理由需要全域性名稱空間中宣告識別符號。相反,函式和類應該存在於適當命名的名稱空間中,或者存在於名稱空間裡的類中。放在全域性名稱空間中的識別符號有可能與來自其他庫(主要是沒有名稱空間的C庫)的識別符號發生衝突。

為標準庫特性使用正確的整數型別

標準庫通常使用std::size_t來處理與尺寸相關的內容,size_t的大小由實現定義。

一般來說,使用auto可以避免大部分問題。

請確保使用正確的整數型別,並與C++標準庫保持一致,否則有可能在當前使用的平臺上不會發出警告,但如果切換到其他平臺,可能會發出警告。

注意,在對無符號數執行某些操作時,可能會導致整數下溢。例如:

cpp std::vector<int> v1{2,3,4,5,6,7,8,9}; std::vector<int> v2{9,8,7,6,5,4,3,2,1}; const auto s1 = v1.size(); const auto s2 = v2.size(); const auto diff = s1 - s2; // diff underflows to a very large number

使用.hpp.cpp作為副檔名

歸根結底,這是個人喜好問題,但是.hpp和.cpp已被各種編輯器和工具廣泛認可。因此,這是一個務實的選擇。具體來說,Visual Studio只自動識別.cpp和.cxx為C++檔案,而Vim不一定會把.cc識別為C++檔案。

某個特別大的專案(OpenStudio)使用.hpp和.cpp表示使用者生成的檔案,而使用.hxx和.cxx表示工具生成的檔案。兩者都能被很好的識別,並且區分開來有很大的幫助。

不要混用tab和空格

某些編輯器喜歡在預設情況下使用tab和空格的混合縮排,這使得沒有使用完全相同的tab縮排設定的人很難閱讀程式碼。請配置好編輯器,確保不會發生這種情況。

不要將有副作用的程式碼放在assert()中

cpp assert(registerSomeThing()); // make sure that registerSomeThing() returns true

上述程式碼在debug模式下構建時可以成功執行,但在進行release構建時會被編譯器刪除,從而造成debug和release構建的行為不一致,原因在於assert()是一個巨集,它在release模式下展開為空。

不要害怕模板

模板可以幫助我們堅持DRY原則。由於巨集有不遵守名稱空間等問題,因此能用模板的地方就不要用巨集。

明智的使用操作符過載

運算子過載是為了支援表達性語法。比如讓兩個大數相加看起來像a + b,而不是a.add(b)。另一個常見的例子是std::string,通常使用string1 + string2連線兩個字串。

但是,使用過多或錯誤的操作符過載很容易寫出可讀性不強的表示式。在過載操作符時,要遵循stackoverflow文章中描述的三條基本規則。

具體來說,記住以下幾點:

  • 處理資源時必須過載operator=(),參見下面Rule of Zero章節。
  • 對於所有其他操作符,通常只有在需要在上下文中使用時才過載。典型的場景是用+連線事物,負號可以被認為是“真”或“假”的表示式,等等。
  • 一定要注意操作符優先順序,儘量避免不直觀的結構。
  • 除非實現數字型別或遵循特定域中可識別的語法,否則不要過載~或%這樣的外部操作符。
  • 永遠不要過載operator,()(逗號操作符)。
  • 處理流時使用非成員函式operator>>()operator<<()。例如,可以過載operator<<(std::ostream &, MyClass const &),從而允許將類“寫入”到一個流中,例如std::coutstd::fstreamstd::stringstream,後者通常用於建立值的字串表示。
  • 這篇文章描述了更多需要過載的常見操作符: What are the basic rules and idioms for operator overloading?

更多關於自定義操作符實現細節的技巧可以參考: C++ Operator Overloading Guidelines

避免隱式轉換

單引數建構函式

可以在編譯時應用單引數建構函式在型別之間自動轉換,比如像std::string(const char *),這樣的轉換很方便,但通常應該避免,因為可能會增加額外的執行時開銷。

相反,可以將單引數建構函式標記為explicit,從而要求顯式呼叫。

轉換操作符

與單引數建構函式類似,編譯器可以呼叫轉換操作符,同樣也會引入額外開銷,也應該被標記為explicit

```cpp //bad idea struct S { operator int() { return 2; } };

//good idea struct S { explicit operator int() { return 2; } }; ```

考慮Rule of Zero

Rule of Zero規定,除非所構造的類具有某種新的所有權形式,否則不提供編譯器可以提供的任何函式(拷貝建構函式、拷貝賦值操作符、移動建構函式、移動賦值操作符、解構函式)。

目標是讓編譯器提供在新增更多成員變數時自動維護的最佳版本。

這篇文章介紹了這一原則的背景,並解釋了幾乎可以覆蓋所有情況的實現技術: C++'s Rule of Zero

你好,我是俞凡,在Motorola做過研發,現在在Mavenir做技術工作,對通訊、網路、後端架構、雲原生、DevOps、CICD、區塊鏈、AI等技術始終保持著濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。 \ 微信公眾號:DeepNoMind