c++開發規范


c++代碼規范

目錄

前言

作者:孟賽

版本:v1.1

本規范參照 google-cpp-styleguide

修訂

V1.0 2019/04/15 初始版本

V1.1 2019/04/27 添加章節5.9 指針 5.10 new x 修正部分內容。

1. 頭文件

通常每一個 .cc文件都有一個對應的 .h 文件. 也有一些常見例外, 如單元測試代碼和只包含 main() 函數的 .cc 文件.

正確使用頭文件可令代碼在可讀性、文件大小和性能上大為改觀.

下面的規則將引導你規避使用頭文件時的各種陷阱.

1.1. Self-contained 頭文件

Tip

頭文件應該能夠自給自足(self-contained,也就是可以作為第一個頭文件被引入),以 .h 結尾。至於用來插入文本的文件,說到底它們並不是頭文件,所以應以 .inc 結尾。不允許分離出 -inl.h 頭文件的做法.

所有頭文件要能夠自給自足。換言之,用戶和重構工具不需要為特別場合而包含額外的頭文件。詳言之,一個頭文件要有 1.2. #define 保護 ,統統包含它所需要的其它頭文件,也不要求定義任何特別符號 (symbols) .

不過有一個例外,即一個文件並不是 self-contained 的,而是作為文本插入到代碼某處。或者,文件內容實際上是其它頭文件的特定平台(platform-specific)擴展部分。這些文件就要用 .inc 文件擴展名。

如果 .h 文件聲明了一個模板或內聯函數,同時也在該文件加以定義。凡是有用到這些的 .cc 文件,就得統統包含該頭文件,否則程序可能會在構建中鏈接失敗。

有個例外:如果某函數模板為所有相關模板參數顯式實例化,或本身就是某類的一個私有成員,那么它就只能定義在實例化該模板的 .cc 文件里。

1.2. #define 保護

Tip

所有頭文件都應該使用 #define 來防止頭文件被多重包含, 命名格式當是: <PROJECT>_<PATH>_<FILE>_H_ .

為保證唯一性, 頭文件的命名應該基於所在項目源代碼樹的全路徑. 例如, 項目 foo 中的頭文件 foo/src/bar/baz.h 可按如下方式保護:

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_

1.3. 前置聲明

Tip

盡可能地避免使用前置聲明。使用 #include 包含需要的頭文件即可。

定義:

所謂「前置聲明」(forward declaration)是類、函數和模板的純粹聲明,沒伴隨着其定義.

在兩個類互相包含時會用到前置聲明:

//a.h
class B; //前置聲明
class A
{
...
private:
  B* b;
}

//b.h
#include "a.h"
class B
{
...
private:
  A a;
}

優點:**

  • 前置聲明能夠節省編譯時間,多余的 #include 會迫使編譯器展開更多的文件,處理更多的輸入。
  • 前置聲明能夠節省不必要的重新編譯的時間。 #include 使代碼因為頭文件中無關的改動而被重新編譯多次。

缺點:

  • 前置聲明隱藏了依賴關系,頭文件改動時,用戶的代碼會跳過必要的重新編譯過程。

  • 前置聲明可能會被庫的后續更改所破壞。前置聲明函數或模板有時會妨礙頭文件開發者變動其 API. 例如擴大形參類型,加個自帶默認參數的模板形參等等。

  • 前置聲明來自命名空間 std:: 的符號 (symbols) 時,其行為未定義。

  • 很難判斷什么時候該用前置聲明,什么時候該用 #include 。極端情況下,用前置聲明代替 includes 甚至都會暗暗地改變代碼的含義:

    // 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*)

如果 #includeBD 的前置聲明替代, test() 就會調用 f(void*) .

  • 前置聲明了不少來自頭文件的 symbol 時,就會比單單一行的 include 冗長。
  • 僅僅為了能前置聲明而重構代碼(比如用指針成員代替對象成員)會使代碼變得更慢更復雜.

結論:

  • 盡量避免前置聲明那些定義在其他項目中的實體.
  • 函數:總是使用 #include.
  • 類模板:優先使用 #include.

至於什么時候包含頭文件,參見 1.5. #include 的路徑及順序

1.4. 內聯函數

Tip

只有當函數只有 10 行甚至更少時才將其定義為內聯函數.

定義:

當函數被聲明為內聯函數之后, 編譯器會將其內聯展開, 而不是按通常的函數調用機制進行調用.

    //內聯函數
    inline int Max (int a, int b)
    {
        if(a >b)
            return a;
        return b;
    }

優點:

只要內聯的函數體較小, 內聯該函數可以令目標代碼更加高效. 對於存取函數以及其它函數體比較短, 性能關鍵的函數, 鼓勵使用內聯.

缺點:

濫用內聯將導致程序變得更慢. 內聯可能使目標代碼量或增或減, 這取決於內聯函數的大小. 內聯非常短小的存取函數通常會減少代碼大小, 但內聯一個相當大的函數將戲劇性的增加代碼大小. 現代處理器由於更好的利用了指令緩存, 小巧的代碼往往執行更快。

結論:

一個較為合理的經驗准則是, 不要內聯超過 10 行的函數. 謹慎對待析構函數, 析構函數往往比其表面看起來要更長, 因為有隱含的成員和基類析構函數被調用!

另一個實用的經驗准則: 內聯那些包含循環或 switch 語句的函數常常是得不償失 (除非在大多數情況下, 這些循環或 switch 語句從不被執行).

有些函數即使聲明為內聯的也不一定會被編譯器內聯, 這點很重要; 比如虛函數和遞歸函數就不會被正常內聯. 通常, 遞歸函數不應該聲明成內聯函數.(注: 遞歸調用堆棧的展開並不像循環那么簡單, 比如遞歸層數在編譯時可能是未知的, 大多數編譯器都不支持內聯遞歸函數). 虛函數內聯的主要原因則是想把它的函數體放在類定義內, 為了圖個方便, 抑或是當作文檔描述其行為, 比如精短的存取函數.

1.5. #include 的路徑及順序

Tip

使用標准的頭文件包含順序可增強可讀性, 避免隱藏依賴: 相關頭文件, C 庫, C++ 庫, 其他庫的 .h, 本項目內的 .h.

項目內頭文件應按照項目源代碼目錄樹結構排列, 避免使用 UNIX 特殊的快捷目錄 . (當前目錄) 或 .. (上級目錄). 例如, google-awesome-project/src/base/logging.h 應該按如下方式包含:

#include "base/logging.h"

又如, dir/foo.ccdir/foo_test.cc 的主要作用是實現或測試 dir2/foo2.h 的功能, foo.cc 中包含頭文件的次序如下:

  1. dir2/foo2.h (優先位置, 詳情如下)
  2. C 系統文件
  3. C++ 系統文件
  4. 其他庫的 .h 文件
  5. 本項目內 .h 文件

這種優先的順序排序保證當 dir2/foo2.h 遺漏某些必要的庫時, dir/foo.ccdir/foo_test.cc 的構建會立刻中止。因此這一條規則保證維護這些文件的人們首先看到構建中止的消息而不是維護其他包的人們。

舉例來說, google-awesome-project/src/foo/internal/fooserver.cc 的包含次序如下:

#include "foo/public/fooserver.h" // 優先位置

#include <sys/types.h>
#include <unistd.h>

#include <hash_map>
#include <vector>

#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/public/bar.h"

dir/foo.ccdir2/foo2.h 通常位於同一目錄下 (如 base/basictypes_unittest.ccbase/basictypes.h), 但也可以放在不同目錄下.

按字母順序分別對每種類型的頭文件進行二次排序是不錯的主意。注意較老的代碼可不符合這條規則,要在方便的時候改正它們。

您所依賴的符號 (symbols) 被哪些頭文件所定義,您就應該包含(include)哪些頭文件前置聲明 (forward declarations) 情況除外。比如您要用到 bar.h 中的某個符號, 哪怕您所包含的 foo.h 已經包含了 bar.h, 也照樣得包含 bar.h, 除非 foo.h 有明確說明它會自動向您提供 bar.h 中的符號 (symbols) . 不過,凡是 cc 文件所對應的「相關頭文件」已經包含的,就不用再重復包含進其 cc 文件里面了,就像 foo.cc 只包含 foo.h 就夠了,不用再管后者所包含的其它內容。

舉例來說, fooserver.cc 用到 bar.h 中的某個符號,foo.h 已經包含了 bar.h,fooserver.cc 的包含次序如下:

#include "fooserver.h"

#include <sys/types.h>
#include <unistd.h>

#include <hash_map>
#include <vector>

#include "bar.h"
#include "foo.h"

fooserver.cc 用到 bar.h 中的某個符號,fooserver.h 已經包含了 bar.h,fooserver.cc 的包含次序如下:

#include "fooserver.h" 

#include <sys/types.h>
#include <unistd.h>

#include <hash_map>
#include <vector>

#include "foo.h"

例外:

有時,平台特定(system-specific)代碼需要條件編譯(conditional includes),這些代碼可以放到其它 includes 之后。當然,您的平台特定代碼也要夠簡練且獨立,比如:

#include "foo/public/fooserver.h"

#include "base/port.h"  // For LANG_CXX11.

#ifdef LANG_CXX11
#include <initializer_list>
#endif  // LANG_CXX11

2. 作用域

2.1. 命名空間

Tip

鼓勵在 .cc 文件內使用匿名命名空間或 static 聲明. 使用具名的命名空間時, 其名稱可基於項目名或相對路徑. 禁止使用 using 指示(using-directive)。禁止使用內聯命名空間(inline namespace)。

定義:

命名空間將全局作用域細分為獨立的, 具名的作用域, 可有效防止全局作用域的命名沖突.

優點:

雖然類已經提供了(可嵌套的)命名軸線 (注: 將命名分割在不同類的作用域內), 命名空間在這基礎上又封裝了一層.

舉例來說, 兩個不同項目的全局作用域都有一個類 Foo, 這樣在編譯或運行時造成沖突. 如果每個項目將代碼置於不同命名空間中, project1::Fooproject2::Foo 作為不同符號自然不會沖突.

內聯命名空間會自動把內部的標識符放到外層作用域,比如:

namespace X {
inline namespace Y {
void foo();
}  // namespace Y
}  // namespace X

X::Y::foo()X::foo() 彼此可代替。內聯命名空間主要用來保持跨版本的 ABI 兼容性。

缺點:

命名空間具有迷惑性, 因為它們使得區分兩個相同命名所指代的定義更加困難。

內聯命名空間很容易令人迷惑,畢竟其內部的成員不再受其聲明所在命名空間的限制。內聯命名空間只在大型版本控制里有用。

有時候不得不多次引用某個定義在許多嵌套命名空間里的實體,使用完整的命名空間會導致代碼的冗長。

在頭文件中使用匿名空間導致違背 C++ 的唯一定義原則 (One Definition Rule (ODR)).

結論:

根據下文將要提到的策略合理使用命名空間.

  • 遵守 命名空間命名中的規則。

  • 像之前的幾個例子中一樣,在命名空間的最后注釋出命名空間的名字。

  • 用命名空間把文件包含, 以及類的前置聲明以外的整個源文件封裝起來, 以區別於其它命名空間:

    // .h 文件
    namespace mynamespace 
    {
    // 所有聲明都置於命名空間中
    // 注意不要使用縮進
    class CMyClass 
    
    {
    public:
    ...
    void Foo();
    };
    
    } // namespace mynamespace
    
// .cc 文件
namespace mynamespace 
{

// 函數定義都置於命名空間中
void CMyClass::Foo() 
{
...
}

} // namespace mynamespace

更復雜的 .cc 文件包含更多, 更復雜的細節, 比如 gflags 或 using 聲明。

#include "a.h"

DEFINE_FLAG(bool, someflag, false, "dummy flag");

namespace a 
{

...code for a...                // 左對齊

} // namespace a
  • 不要在命名空間 std 內聲明任何東西, 包括標准庫的類前置聲明. 在 std 命名空間聲明實體是未定義的行為, 會導致如不可移植. 聲明標准庫下的實體, 需要包含對應的頭文件.

  • 不應該使用 using 指示 引入整個命名空間的標識符號。

    // 禁止 —— 污染命名空間
    using namespace foo;
    
  • 不要在頭文件中使用 命名空間別名 除非顯式標記內部命名空間使用。因為任何在頭文件中引入的命名空間都會成為公開API的一部分。

    // 在 .cc 中使用別名縮短常用的命名空間
    namespace baz = ::foo::bar::baz;
    
// 在 .h 中使用別名縮短常用的命名空間
namespace librarian {
namespace impl {  // 僅限內部使用
namespace sidetable = ::pipeline_diagnostics::sidetable;
}  // namespace impl

inline void my_inline_function() {
// 限制在一個函數中的命名空間別名
namespace baz = ::foo::bar::baz;
...
}
}  // namespace librarian
  • 禁止用內聯命名空間

2.2. 匿名命名空間和靜態變量

Tip

.cc 文件中定義一個不需要被外部引用的變量時,可以將它們放在匿名命名空間或聲明為 static 。但是不要在 .h 文件中這么做。

定義:

所有置於匿名命名空間的聲明都具有內部鏈接性,函數和變量可以經由聲明為 static 擁有內部鏈接性,這意味着你在這個文件中聲明的這些標識符都不能在另一個文件中被訪問。即使兩個文件聲明了完全一樣名字的標識符,它們所指向的實體實際上是完全不同的。

結論:

推薦、鼓勵在 .cc 中對於不需要在其他地方引用的標識符使用內部鏈接性聲明,但是不要在 .h 中使用。

匿名命名空間的聲明和具名的格式相同,在最后注釋上 namespace :

namespace 
{
...
}  // namespace

2.3. 非成員函數、靜態成員函數和全局函數

Tip

使用靜態成員函數或命名空間內的非成員函數, 盡量不要用裸的全局函數. 將一系列函數直接置於命名空間中,不要用類的靜態方法模擬出命名空間的效果,類的靜態方法應當和類的實例或靜態數據緊密相關.

優點:

某些情況下, 非成員函數和靜態成員函數是非常有用的, 將非成員函數放在命名空間內可避免污染全局作用域.

缺點:

將非成員函數和靜態成員函數作為新類的成員或許更有意義, 當它們需要訪問外部資源或具有重要的依賴關系時更是如此.

結論:

有時, 把函數的定義同類的實例脫鈎是有益的, 甚至是必要的. 這樣的函數可以被定義成靜態成員, 或是非成員函數. 非成員函數不應依賴於外部變量, 應盡量置於某個命名空間內. 相比單純為了封裝若干不共享任何靜態數據的靜態成員函數而創建類, 不如使用 2.1.命名空間 。舉例而言,對於頭文件 myproject/foo_bar.h , 應當使用

namespace myproject 
{
namespace foo_bar 
{
void Function1();
void Function2();
}  // namespace foo_bar
}  // namespace myproject

而非

namespace myproject 
{
class FooBar 
{
public:
  static void Function1();
  static void Function2();
};
}  // namespace myproject

定義在同一編譯單元的函數, 被其他編譯單元直接調用可能會引入不必要的耦合和鏈接時依賴; 靜態成員函數對此尤其敏感. 可以考慮提取到新類中, 或者將函數置於獨立庫的命名空間內.

如果你必須定義非成員函數, 又只是在 .cc 文件中使用它, 可使用匿名 2.1.命名空間static 鏈接關鍵字 (如 static int Foo() {...}) 限定其作用域.

2.4. 局部變量

Tip

將函數變量盡可能置於最小作用域內, 並在變量聲明時進行初始化.

C++ 允許在函數的任何位置聲明變量. 我們提倡在盡可能小的作用域中聲明變量, 離第一次使用越近越好. 這使得代碼瀏覽者更容易定位變量聲明的位置, 了解變量的類型和初始值. 特別是,應使用初始化的方式替代聲明再賦值, 比如:

int i;
i = f(); // 壞——初始化和聲明分離

int j = g(); // 好——初始化時聲明

vector<int> v;
v.push_back(1); // 用花括號初始化更好
v.push_back(2);

vector<int> v = {1, 2}; // 好——v 一開始就初始化

為了程序可讀性if, whilefor 括號內語句變量應在外面聲明 (for循環迭代變量 i 除外):

//好
const char* p = strchr(str, '/');
while (p) str = p + 1;

//不推薦
while (const char* p = strchr(str, '/')) 
{
  str = p + 1;
}

// 低效的實現
for (int i = 0; i < 1000000; ++i) 
{
    Foo f;                  // 構造函數和析構函數分別調用 1000000 次!
    f.DoSomething(i);
}

在循環作用域外面聲明這類變量要高效的多:

Foo f;                      // 構造函數和析構函數只調用 1 次
for (int i = 0; i < 1000000; ++i) 
{
    f.DoSomething(i);
}

非原生變量要在循環外定義可不遵循最小作用域原則,除非設計上需要變量在循環中構造。

2.5. 靜態和全局變量

Tip

禁止定義靜態儲存周期非POD變量,禁止使用含有副作用的函數初始化POD全局變量,因為多編譯單元中的靜態變量執行時的構造和析構順序是未明確的,這將導致代碼的不可移植。

禁止使用類的 靜態儲存周期 變量:由於構造和析構函數調用順序的不確定性,它們會導致難以發現的 bug 。不過 constexp 變量除外,畢竟它們又不涉及動態初始化或析構。

靜態生存周期的對象,即包括了全局變量,靜態變量,靜態類成員變量和函數靜態變量,都必須是原生數據類型 (POD : Plain Old Data): 即 int, char 和 float, 以及 POD 類型的指針、數組和結構體。

靜態變量的構造函數、析構函數和初始化的順序在 C++ 中是只有部分明確的,甚至隨着構建變化而變化,導致難以發現的 bug. 所以除了禁用類類型的全局變量,我們也不允許用函數返回值來初始化 POD 變量,除非該函數(比如 getenv()getpid() )不涉及任何全局變量。函數作用域里的靜態變量除外,畢竟它的初始化順序是有明確定義的,而且只會在指令執行到它的聲明那里才會發生。

同理,全局和靜態變量在程序中斷時會被析構,無論所謂中斷是從 main() 返回還是對 exit() 的調用。析構順序正好與構造函數調用的順序相反。但既然構造順序未定義,那么析構順序當然也就不定了。比如,在程序結束時某靜態變量已經被析構了,但代碼還在跑------比如其它線程------並試圖訪問它且失敗;再比如,一個靜態 string 變量也許會在一個引用了前者的其它變量析構之前被析構掉。

改善以上析構問題的辦法之一是用 quick_exit() 來代替 exit() 並中斷程序。它們的不同之處是前者不會執行任何析構,也不會執行 atexit() 所綁定的任何 handlers. 如果您想在執行 quick_exit() 來中斷時執行某 handler(比如刷新 log),您可以把它綁定到 _at_quick_exit(). 如果您想在 exit()quick_exit() 都用上該 handler, 都綁定上去。

綜上所述,我們只允許 POD 類型的靜態變量,即完全禁用 vector (使用 C 數組替代) 和 string (使用 const char [])。

如果您確實需要一個 class 類型的靜態或全局變量,可以考慮在 main() 函數或 pthread_once() 內初始化一個指針且永不回收。注意只能用 raw 指針,別用智能指針,畢竟后者的析構函數涉及到上文指出的不定順序問題。

3. 類

類是 C++ 中代碼的基本單元. 顯然, 它們被廣泛使用. 本節列舉了在寫一個類時的主要注意事項.

3.1. 構造函數的職責

總述

不要在構造函數中調用虛函數, 也不要在無法報出錯誤時進行可能失敗的初始化.不要拋出異常。

定義

在構造函數中可以進行各種初始化操作.

優點

  • 無需考慮類是否被初始化.
  • 經過構造函數完全初始化后的對象可以為 const 類型, 也能更方便地被標准容器或算法使用.

缺點

  • 如果在構造函數內調用了自身的虛函數, 這類調用是不會重定向到子類的虛函數實現. 即使當前沒有子類化實現, 將來仍是隱患.
  • 在沒有使程序崩潰 (因為並不是一個始終合適的方法) 或者使用異常 (因為已經被 禁用 了) 等方法的條件下, 構造函數很難上報錯誤
  • 如果執行失敗, 會得到一個初始化失敗的對象, 這個對象有可能進入不正常的狀態, 必須使用 bool IsValid() 或類似這樣的機制才能檢查出來, 然而這是一個十分容易被疏忽的方法.
  • 構造函數的地址是無法被取得的, 因此, 舉例來說, 由構造函數完成的工作是無法以簡單的方式交給其他線程的.

結論

構造函數不允許調用虛函數. 如果代碼允許, 直接終止程序是一個合適的處理錯誤的方式. 否則, 考慮用 Init() 方法或工廠函數.

構造函數不得調用虛函數, 或嘗試報告一個非致命錯誤. 如果對象需要進行有意義的 (non-trivial) 初始化, 考慮使用明確的 Init() 方法或使用工廠模式.

3.2. 隱式類型轉換

總述

不要定義隱式類型轉換. (對於轉換運算符和單參數構造函數,在C++11項目中使用 explicit 關鍵字).

定義

隱式類型轉換允許一個某種類型 (稱作 源類型) 的對象被用於需要另一種類型 (稱作 目的類型) 的位置, 例如, 將一個 int 類型的參數傳遞給需要 double 類型的函數.

除了語言所定義的隱式類型轉換, 用戶還可以通過在類定義中添加合適的成員定義自己需要的轉換. 在源類型中定義隱式類型轉換, 可以通過目的類型名的類型轉換運算符實現 (例如 operator bool()). 在目的類型中定義隱式類型轉換, 則通過以源類型作為其唯一參數 (或唯一無默認值的參數) 的構造函數實現.

explicit 關鍵字可以用於構造函數或 (在 C++11 引入) 類型轉換運算符, 以保證只有當目的類型在調用點被顯式寫明時才能進行類型轉換, 例如使用 cast. 這不僅作用於隱式類型轉換, 還能作用於 C++11 的列表初始化語法:

class CFoo {
  explicit CFoo(int x, double y);
  ...
};

void Func(CFoo f);

此時下面的代碼是不允許的:

Func({42, 3.14});  // Error

這一代碼從技術上說並非隱式類型轉換, 但是語言標准認為這是 explicit 應當限制的行為.

優點

  • 有時目的類型名是一目了然的, 通過避免顯式地寫出類型名, 隱式類型轉換可以讓一個類型的可用性和表達性更強.
  • 隱式類型轉換可以簡單地取代函數重載.
  • 在初始化對象時, 列表初始化語法是一種簡潔明了的寫法.

缺點

  • 隱式類型轉換會隱藏類型不匹配的錯誤. 有時, 目的類型並不符合用戶的期望, 甚至用戶根本沒有意識到發生了類型轉換.
  • 隱式類型轉換會讓代碼難以閱讀, 尤其是在有函數重載的時候, 因為這時很難判斷到底是哪個函數被調用.
  • 單參數構造函數有可能會被無意地用作隱式類型轉換.
  • 如果單參數構造函數沒有加上 explicit 關鍵字, 讀者無法判斷這一函數究竟是要作為隱式類型轉換, 還是作者忘了加上 explicit 標記.
  • 並沒有明確的方法用來判斷哪個類應該提供類型轉換, 這會使得代碼變得含糊不清.
  • 如果目的類型是隱式指定的, 那么列表初始化會出現和隱式類型轉換一樣的問題, 尤其是在列表中只有一個元素的時候.

結論

在類型定義中, 類型轉換運算符和單參數構造函數都應當用 explicit 進行標記. 一個例外是, 拷貝和移動構造函數不應當被標記為 explicit, 因為它們並不執行類型轉換. 對於設計目的就是用於對其他類型進行透明包裝的類來說, 隱式類型轉換有時是必要且合適的. 這時應當聯系項目組長並說明特殊情況.

不能以一個參數進行調用的構造函數不應當加上 explicit. 接受一個 std::initializer_list 作為參數的構造函數也應當省略 explicit, 以便支持拷貝初始化 (例如 MyType m = {1, 2};) .

3.3. 可拷貝類型

總述

如果你的類型需要, 就讓它們支持拷貝. 否則, 就把隱式產生的拷貝構造函數和賦值運算符禁用.

定義

可拷貝類型允許對象在初始化時得到來自相同類型的另一對象的值, 或在賦值時被賦予相同類型的另一對象的值, 同時不改變源對象的值. 對於用戶定義的類型, 拷貝操作一般通過拷貝構造函數與拷貝賦值操作符定義. string 類型就是一個可拷貝類型的例子.

拷貝構造函數在某些情況下會被編譯器隱式調用. 例如, 通過傳值的方式傳遞對象.

優點

可拷貝類型的對象可以通過傳值的方式進行傳遞或者返回, 這使得 API 更簡單, 更安全也更通用. 與傳指針和引用不同, 這樣的傳遞不會造成所有權, 生命周期, 可變性等方面的混亂, 也就沒必要在協議中予以明確. 這同時也防止了客戶端與實現在非作用域內的交互, 使得它們更容易被理解與維護. 這樣的對象可以和需要傳值操作的通用 API 一起使用, 例如大多數容器.

拷貝構造函數與賦值操作一般來說要比它們的各種替代方案, 比如 Clone(), CopyFrom() or Swap(), 更容易定義, 因為它們能通過編譯器產生, 無論是隱式的還是通過 = default. 這種方式很簡潔, 也保證所有數據成員都會被復制. 拷貝構造函數一般也更高效, 因為它們不需要堆的分配或者是單獨的初始化和賦值步驟, 同時, 對於類似 省略不必要的拷貝 這樣的優化它們也更加合適.

缺點

許多類型都不需要拷貝, 為它們提供拷貝操作會讓人迷惑, 也顯得荒謬而不合理. 單件類型 (Registerer), 與特定的作用域相關的類型 (Cleanup), 與其他對象實體緊耦合的類型 (Mutex) 從邏輯上來說都不應該提供拷貝操作. 為基類提供拷貝 / 賦值操作是有害的, 因為在使用它們時會造成 對象切割 . 默認的或者隨意的拷貝操作實現可能是不正確的, 這往往導致令人困惑並且難以診斷出的錯誤.

拷貝構造函數是隱式調用的, 也就是說, 這些調用很容易被忽略. 這會讓人迷惑, 尤其是對那些所用的語言約定或強制要求傳引用的程序員來說更是如此. 同時, 這從一定程度上說會鼓勵過度拷貝, 從而導致性能上的問題.

結論

如果需要就讓你的類型可拷貝. 作為一個經驗法則, 如果對於你的用戶來說這個拷貝操作不是一眼就能看出來的, 那就不要把類型設置為可拷貝. 如果讓類型可拷貝, 一定要同時給出拷貝構造函數和賦值操作的定義, 反之亦然.

如果定義了拷貝操作, 則要保證這些操作的默認實現是正確的. 記得時刻檢查默認操作的正確性, 並且在文檔中說明類是可拷貝的.

class CFoo 
{
public:
  CFoo(const CFoo& other) : field_(other.field) {}
  // 差, 只定義了拷貝構造函數, 而沒有定義對應的賦值運算符.

private:
  Field field_;
};

由於存在對象切割的風險, 不要為任何有可能有派生類的對象提供賦值操作或者拷貝構造函數 (當然也不要繼承有這樣的成員函數的類). 如果你的基類需要可復制屬性, 請提供一個 public virtual Clone() 和一個 protected 的拷貝構造函數以供派生類實現(設計模式中的原型模式).

class BCase 
{
public:
  public virtual CBase* Clone() {return new CBase(*this);}

protected:
  CBase(const CBase& other);
};

class CFoo : public CBase 
{
public:
  public virtual CFoo* Clone() {return new CFoo(*this);}
  //注意 Clone() 返回值與基類不同
protected:
  CFoo(const CFoo& other) {...};
};

如果你的類不需要拷貝操作, 請顯式地通過在 private 域中定義拷貝構造函數和賦值運算符並不予實現.

class CMyClass
{
private:
  // CMyClass is neither copyable nor movable.
  CMyClass(const CMyClass&);
  CMyClass& operator=(const CMyClass&);
}

3.4. 結構體 VS. 類

總述

僅當只有數據成員時使用 struct, 其它一概使用 class.

說明

在 C++ 中 structclass 關鍵字幾乎含義一樣. 我們為這兩個關鍵字添加我們自己的語義理解, 以便為定義的數據類型選擇合適的關鍵字.

struct 用來定義包含數據的被動式對象, 也可以包含相關的常量, 但除了存取數據成員之外, 沒有別的函數功能. 並且存取功能是通過直接訪問位域, 而非函數調用. 除了構造函數, 析構函數, Initialize(), Reset(), Validate() 等類似的用於設定數據成員的函數外, 不能提供其它功能的函數.

如果需要更多的函數功能, class 更適合. 如果拿不准, 就用 class.

為了和 STL 保持一致, 對於仿函數等特性可以不用 class 而是使用 struct.

注意: 類和結構體的成員變量使用不同的 命名規則 .

3.5. 繼承

總述

使用組合常常比使用繼承更合理. 如果使用繼承的話, 定義為 public 繼承.

定義

當子類繼承基類時, 子類包含了父基類所有數據及操作的定義. C++ 實踐中, 繼承主要用於兩種場合: 實現繼承, 子類繼承父類的實現代碼; 接口繼承, 子類僅繼承父類的方法名稱.

優點

實現繼承通過原封不動的復用基類代碼減少了代碼量. 由於繼承是在編譯時聲明, 程序員和編譯器都可以理解相應操作並發現錯誤. 從編程角度而言, 接口繼承是用來強制類輸出特定的 API. 在類沒有實現 API 中某個必須的方法時, 編譯器同樣會發現並報告錯誤.

缺點

對於實現繼承, 由於子類的實現代碼散布在父類和子類間之間, 要理解其實現變得更加困難. 子類不能重寫父類的非虛函數, 當然也就不能修改其實現. 基類也可能定義了一些數據成員, 因此還必須區分基類的實際布局.

結論

所有繼承必須是 public 的. 如果你想使用私有繼承, 你應該替換成把基類的實例作為成員對象的方式.

不要過度使用實現繼承. 組合常常更合適一些. 盡量做到只在 "是一個" ("is-a", 注: 其他 "has-a" 情況下請使用組合) 的情況下使用繼承: 如果 Bar 的確 "是一種" Foo, Bar 才能繼承 Foo.

必要的話, 析構函數聲明為 virtual. 如果你的類有虛函數, 則析構函數也應該為虛函數.

對於可能被子類訪問的成員函數, 不要過度使用 protected 關鍵字. 注意, 數據成員都必須是 私有的.

對於重載的虛函數或虛析構函數, 使用 override, 或 (較不常用的) final 關鍵字顯式地進行標記. 較早 (早於 C++11) 的代碼可能會使用 virtual 關鍵字作為不得已的選項. 因此, 在聲明重載時, 請使用 override, finalvirtual 的其中之一進行標記. 標記為 overridefinal 的析構函數如果不是對基類虛函數的重載的話, 編譯會報錯, 這有助於捕獲常見的錯誤. 這些標記起到了文檔的作用, 因為如果省略這些關鍵字, 代碼閱讀者不得不檢查所有父類, 以判斷該函數是否是虛函數.

例子

(1)若在邏輯上B 是A 的“一種”(a kind of ),則允許B 繼承A 的功能。如男人(Man)是人(Human)的一種,男孩(Boy)是男人的一種。那么類Man 可以從類Human 派生,類Boy 可以從類Man 派生。示例程序如下:

class CHuman
{
  …
};
class CMan : public CHuman
{
  …
};
class CBoy : public CMan
{
  …
};

(2)若在邏輯上A 是B 的“一部分”(a part of),則不允許B 繼承A 的功能,而是要用A和其它東西組合出B。例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是頭(Head)的一部分,所以類Head 應該由類Eye、Nose、Mouth、Ear 組合而成,不是派生而成。示例程序如下:

class CEye
{
public:
  void Look(void);
};
class CNose
{
public:
  void Smell(void);
};
class CMouth
{
public:
  void Eat(void);
};
class CEar
{
public:
  void Listen(void);
};

// 正確的設計
class CHead
{
public:
  void Look(void) { m_eye.Look(); }
  void Smell(void) { m_nose.Smell(); }
  void Eat(void) { m_mouth.Eat(); }
  void Listen(void) { m_ear.Listen(); }
private:
  CEye m_eye;
  CNose m_nose;
  CMouth m_mouth;
Ear m_ear;
};
// 錯誤的設計
class CHead : public CEye, public CNose, public CMouth, public CEar
{
};

3.6. 多重繼承

總述

真正需要用到多重實現繼承的情況少之又少. 只在以下情況我們才允許多重繼承: 最多只有一個基類是非抽象類; 其它基類都是 純接口類.

定義

多重繼承允許子類擁有多個基類. 要將作為 純接口 的基類和具有 實現 的基類區別開來.

優點

相比單繼承 (見 繼承), 多重實現繼承可以復用更多的代碼.

缺點

真正需要用到多重 實現 繼承的情況少之又少. 有時多重實現繼承看上去是不錯的解決方案, 但這時你通常也可以找到一個更明確, 更清晰的不同解決方案.

結論

只有當所有父類除第一個外都是 純接口類 時, 才允許使用多重繼承.

注意

關於該規則, Windows 下有個 特例.

3.7. 接口

總述

接口是指滿足特定條件的類.

定義

當一個類滿足以下要求時, 稱之為純接口:

  • 只有純虛函數 ("=0") 和靜態函數 (除了下文提到的析構函數).
  • 沒有非靜態數據成員.
  • 沒有定義任何構造函數. 如果有, 也不能帶有參數, 並且必須為 protected.
  • 如果它是一個子類, 也只能從滿足上述條件的類繼承.
class MCDInterface
{
public:
  inline virtual ~MCDInterface(){};
  virtual void connect()=0;
  virtual void disconnect()=0;
protected:
  MCDInterface(const MCDInterface&);
  MCDInterface& operator=(const MCDInterface&);
}

接口類不能被直接實例化, 因為它聲明了純虛函數. 為確保接口類的所有實現可被正確銷毀, 必須為之聲明虛析構函數 (作為上述第 1 條規則的特例, 析構函數不能是純虛函數). 具體細節可參考 Stroustrup 的 The C++ Programming Language, 3rd edition 第 12.4 節.

3.8. 運算符重載

總述

除少數特定環境外, 不要重載運算符. 也不要創建用戶定義字面量.

定義

C++ 允許用戶通過使用 operator 關鍵字 對內建運算符進行重載定義 , 只要其中一個參數是用戶定義的類型. operator 關鍵字還允許用戶使用 operator"" 定義新的字面運算符, 並且定義類型轉換函數, 例如 operator bool().

優點

重載運算符可以讓代碼更簡潔易懂, 也使得用戶定義的類型和內建類型擁有相似的行為. 重載運算符對於某些運算來說是符合符合語言習慣的名稱 (例如 ==, <, =, <<), 遵循這些語言約定可以讓用戶定義的類型更易讀, 也能更好地和需要這些重載運算符的函數庫進行交互操作.

對於創建用戶定義的類型的對象來說, 用戶定義字面量是一種非常簡潔的標記.

缺點

  • 要提供正確, 一致, 不出現異常行為的操作符運算需要花費不少精力, 而且如果達不到這些要求的話, 會導致令人迷惑的 Bug.
  • 過度使用運算符會帶來難以理解的代碼, 尤其是在重載的操作符的語義與通常的約定不符合時.
  • 函數重載有多少弊端, 運算符重載就至少有多少.
  • 運算符重載會混淆視聽, 讓你誤以為一些耗時的操作和操作內建類型一樣輕巧.
  • 對重載運算符的調用點的查找需要的可就不僅僅是像 grep 那樣的程序了, 這時需要能夠理解 C++ 語法的搜索工具.
  • 如果重載運算符的參數寫錯, 此時得到的可能是一個完全不同的重載而非編譯錯誤. 例如: foo < bar 執行的是一個行為, 而 &foo < &bar 執行的就是完全不同的另一個行為了.
  • 重載某些運算符本身就是有害的. 例如, 重載一元運算符 & 會導致同樣的代碼有完全不同的含義, 這取決於重載的聲明對某段代碼而言是否是可見的. 重載諸如 &&, ||, 會導致運算順序和內建運算的順序不一致.
  • 運算符從通常定義在類的外部, 所以對於同一運算, 可能出現不同的文件引入了不同的定義的風險. 如果兩種定義都鏈接到同一二進制文件, 就會導致未定義的行為, 有可能表現為難以發現的運行時錯誤.
  • 用戶定義字面量所創建的語義形式對於某些有經驗的 C++ 程序員來說都是很陌生的.

結論

只有在意義明顯, 不會出現奇怪的行為並且與對應的內建運算符的行為一致時才定義重載運算符. 例如, | 要作為位或或邏輯或來使用, 而不是作為 shell 中的管道.

只有對用戶自己定義的類型重載運算符. 更准確地說, 將它們和它們所操作的類型定義在同一個頭文件中, .cc 中和命名空間中. 這樣做無論類型在哪里都能夠使用定義的運算符, 並且最大程度上避免了多重定義的風險. 如果可能的話, 請避免將運算符定義為模板, 因為此時它們必須對任何模板參數都能夠作用. 如果你定義了一個運算符, 請將其相關且有意義的運算符都進行定義, 並且保證這些定義的語義是一致的. 例如, 如果你重載了 <, 那么請將所有的比較運算符都進行重載, 並且保證對於同一組參數, <> 不會同時返回 true.

建議不要將不進行修改的二元運算符定義為成員函數. 如果一個二元運算符被定義為類成員, 這時隱式轉換會作用域右側的參數卻不會作用於左側. 這時會出現 a < b 能夠通過編譯而 b < a 不能的情況, 這是很讓人迷惑的.

不要為了避免重載操作符而走極端. 比如說, 應當定義 ==, =, 和 << 而不是 Equals(), CopyFrom()PrintTo(). 反過來說, 不要只是為了滿足函數庫需要而去定義運算符重載. 比如說, 如果你的類型沒有自然順序, 而你要將它們存入 std::set 中, 最好還是定義一個自定義的比較運算符而不是重載 <.

不要重載 &&, ||, , 或一元運算符 &. 不要重載 operator"", 也就是說, 不要引入用戶定義字面量.

類型轉換運算符在 隱式類型轉換 一節有提及. = 運算符在 可拷貝類型 一節有提及. 運算符 << 一節有提及. 同時請參見 函數重載 一節, 其中提到的的規則對運算符重載同樣適用.

3.9. 存取控制

總述

所有 數據成員聲明為非 public, 除非是 static const 類型成員 (遵循 常量命名規則). 處於技術上的原因, 在使用 Google Test 時我們允許測試固件類中的數據成員為 protected.

3.10. 聲明順序

總述

將相似的聲明放在一起, 將 public 部分放在最前.

說明

類定義一般應以 public: 開始, 后跟 protected:, 最后是 private:. 省略空部分.

在各個部分中, 建議將類似的聲明放在一起, 並且建議以如下的順序: 類型 (包括 typedef, using 和嵌套的結構體與類), 常量, 工廠函數, 構造函數, 賦值運算符, 析構函數, 其它函數, 數據成員.

不要將大段的函數定義內聯在類定義中. 通常,只有那些普通的, 或性能關鍵且短小的函數可以內聯在類定義中. 參見 內聯函數 一節.

4. 函數

4.1. 參數順序

總述

函數的參數順序為: 輸入參數在先, 后跟輸出參數.

說明

C/C++ 中的函數參數或者是函數的輸入, 或者是函數的輸出, 或兼而有之. 輸入參數通常是值參或 const 引用, 輸出參數或輸入/輸出參數則一般為非 const 指針. 在排列參數順序時, 將所有的輸入參數置於輸出參數之前. 特別要注意, 在加入新參數時不要因為它們是新參數就置於參數列表最后, 而是仍然要按照前述的規則, 即將新的輸入參數也置於輸出參數之前.

這並非一個硬性規定. 輸入/輸出參數 (通常是類或結構體) 讓這個問題變得復雜. 並且, 有時候為了其他函數保持一致, 你可能不得不有所變通.

4.2. 編寫簡短函數

總述

我們傾向於編寫簡短, 凝練的函數.

說明

我們承認長函數有時是合理的, 因此並不硬性限制函數的長度. 如果函數超過 40 行, 可以思索一下能不能在不影響程序結構的前提下對其進行分割.

即使一個長函數現在工作的非常好, 一旦有人對其修改, 有可能出現新的問題, 甚至導致難以發現的 bug. 使函數盡量簡短, 以便於他人閱讀和修改代碼.

在處理代碼時, 你可能會發現復雜的長函數. 不要害怕修改現有代碼: 如果證實這些代碼使用 / 調試起來很困難, 或者你只需要使用其中的一小段代碼, 考慮將其分割為更加簡短並易於管理的若干函數.

4.3. 引用參數

總述

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

定義

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

優點

定義引用參數可以防止出現 (*pval)++ 這樣丑陋的代碼. 引用參數對於拷貝構造函數這樣的應用也是必需的. 同時也更明確地不接受空指針.

缺點

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

結論

函數參數列表中, 所有引用參數都必須是 const,輸出參數不能使用引用.:

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

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

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

  • 可能會傳遞空指針.
  • 函數要把指針或對地址的引用賦值給輸入形參.

總而言之, 大多時候輸入形參往往是 const T&. 若用 const T* 則說明輸入另有處理. 所以若要使用 const T*, 則應給出相應的理由, 否則會使得讀者感到迷惑.

參數如果是指針使用前一定要對指針有效性進行判斷。

例子

4.4. 函數重載

總述

若要使用函數重載, 則必須能讓讀者一看調用點就胸有成竹, 而不用花心思猜測調用的重載函數到底是哪一種. 這一規則也適用於構造函數.

定義

你可以編寫一個參數類型為 const string& 的函數, 然后用另一個參數類型為 const char* 的函數對其進行重載:

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

優點

通過重載參數不同的同名函數, 可以令代碼更加直觀. 模板化代碼需要重載, 這同時也能為使用者帶來便利.

缺點

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

結論

如果打算重載一個函數, 可以試試改在函數名里加上參數信息. 例如, 用 AppendString()AppendInt() 等, 而不是一口氣重載多個 Append(). 如果重載函數的目的是為了支持不同數量的同一類型參數, 則優先考慮使用 std::vector 以便使用者可以用 列表初始化 指定參數.

4.5. 缺省參數

總述

只允許在非虛函數中使用缺省參數, 且必須保證缺省參數的值始終一致. 缺省參數與 函數重載 遵循同樣的規則. 一般情況下建議使用函數重載, 尤其是在缺省參數帶來的可讀性提升不能彌補下文中所提到的缺點的情況下.

優點

有些函數一般情況下使用默認參數, 但有時需要又使用非默認的參數. 缺省參數為這樣的情形提供了便利, 使程序員不需要為了極少的例外情況編寫大量的函數. 和函數重載相比, 缺省參數的語法更簡潔明了, 減少了大量的樣板代碼, 也更好地區別了 "必要參數" 和 "可選參數".

缺點

缺省參數實際上是函數重載語義的另一種實現方式, 因此所有 不應當使用函數重載的理由 也都適用於缺省參數.

虛函數調用的缺省參數取決於目標對象的靜態類型, 此時無法保證給定函數的所有重載聲明的都是同樣的缺省參數.

缺省參數是在每個調用點都要進行重新求值的, 這會造成生成的代碼迅速膨脹. 作為讀者, 一般來說也更希望缺省的參數在聲明時就已經被固定了, 而不是在每次調用時都可能會有不同的取值.

缺省參數會干擾函數指針, 導致函數簽名與調用點的簽名不一致. 而函數重載不會導致這樣的問題.

結論

對於虛函數, 不允許使用缺省參數, 因為在虛函數中缺省參數不一定能正常工作. 如果在每個調用點缺省參數的值都有可能不同, 在這種情況下缺省函數也不允許使用. (例如, 不要寫像 void f(int n = counter++); 這樣的代碼.)

在其他情況下, 如果缺省參數對可讀性的提升遠遠超過了以上提及的缺點的話, 可以使用缺省參數. 如果仍有疑惑, 就使用函數重載.

4.6. 函數指針類型參數檢查

總述

參數如果是指針使用前一定要對指針有效性進行檢查.

例如:

int foo(int *x)
{
    if (nullptr == x)
    {
        ...
        return -1;
    }
    ...
}

5. 其他 C++ 特性

5.1. 友元

Tip

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

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

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

5.2. 異常

Tip

我們不使用 C++ 異常.但第三方庫使用到異常還是需要抓取異常.

優點:

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

缺點:

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

結論:

我們自己的代碼中不要拋出異常,但是需要抓取第三方庫的異常。捕獲的異常需要處理,錯誤異常要輸出日志。

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

5.3. 運行時類型識別

Tip

除了項目中第三方庫需要,我們禁止使用 RTTI.

定義:

RTTI 允許程序員在運行時識別 C++ 類對象的類型. 它通過使用 typeid 或者 dynamic_cast 完成.

優點:

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

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

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

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

缺點:

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

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

結論:

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

虛函數可以根據子類類型的不同而執行不同代碼. 這是把工作交給了對象本身去處理.

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

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

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

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

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

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

5.4. 流

Tip

只在記錄日志時使用流.

定義:

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

優點:

有了流, 在打印時不需要關心對象的類型. 不用擔心格式化字符串與參數列表不匹配 (雖然在 gcc 中使用 printf 也不存在這個問題). 流的構造和析構函數會自動打開和關閉對應的文件.

缺點:

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

結論:

不要使用流, 除非是日志接口需要. 使用 printf 之類的代替.

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

拓展討論:

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

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

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

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

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

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

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

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

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

5.5. 前置自增和自減

Tip

使用前綴形式 (++i) 的自增, 自減運算符.

定義:

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

優點:

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

缺點:

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

結論:

統一使用前置自增 (自減).

5.6. const 用法

Tip

我們強烈建議你在任何可能的情況下都要使用 const.

定義:

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

優點:

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

缺點:

const 是入侵性的: 如果你向一個函數傳入 const 變量, 函數原型聲明中也必須對應 const 參數 (否則變量需要 const_cast 類型轉換), 在調用庫函數時顯得尤其麻煩.

結論:

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

  • 如果函數不會修改傳你入的引用或指針類型參數, 該參數應聲明為 const.
  • 盡可能將函數聲明為 const. 訪問函數應該總是 const. 其他不會修改任何數據成員, 未調用非 const 函數, 不會返回數據成員非 const 指針或引用的函數也應該聲明成 const.
  • 如果數據成員在對象構造之后不再發生變化, 可將其定義為 const.

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

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

const 的位置:

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

這是說, 我們強制 const 在前.

5.7. 整型

Tip

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

定義:

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

優點:

保持聲明統一.

缺點:

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

結論:

<stdint.h> 定義了 int16_t, uint32_t, int64_t 等整型, 在需要確保整型大小時可以使用它們代替 short, unsigned long long 等. 在 C 整型中, 只使用 int. 在合適的情況下, 推薦使用標准類型如 size_tptrdiff_t.

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

對於大整數, 使用 int64_t.

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

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

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

關於無符號整數:

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

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

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

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

5.8. 預處理宏

Tip

使用宏時要非常謹慎, 盡量以內聯函數, 枚舉和常量代替之.程序中不要使用“魔數”,要使用宏、常量枚舉替換.

宏意味着你和編譯器看到的代碼是不同的. 這可能會導致異常行為, 尤其因為宏具有全局作用域.

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

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

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

  • 只在一個文件中使用的宏要在.cc文件中定義不要定義在頭文件中.
  • 不要試圖使用展開后會導致 C++ 構造不穩定的宏, 不然也至少要附上文檔說明其行為.

5.9. 指針

Tip

指針變量定義時要初始化,指針銷毀后要置`nullptr`,指針使用前要進行有效性判斷.

指針變量定義時要初始化,指針被free或者delete之后,要置為nullptr,避免訪問'野指針'。指針使用前要使用if (p == nullptr) if (p != nullptr)進行防錯處理.

//正例
char *p = nullptr;					//指針初始化
try
{
    p = new char;
}
catch (std::bad_alloc &ba)
{
    ...
}

if (nullptr != p)					//使用前檢查有效性
{
    *p = 'a';
}

delete p;
p = nullptr;						//釋放后置空

//反例
char *p;			// 指針沒有初始化.
p = new char;

*p = 'a';			// 使用前沒有判斷有效性.

delete p;			// 釋放后沒有置空.

5.10. new

Tip

使用new分配內存時一定要考慮分配失敗的情況.

目前標准版本的new失敗后會拋出一個異常的類型std::bad_alloc,在早期C++編譯器中new失敗后將返回NULL,和malloc()非常相似。所以要對兩種情況都做處理。

char *p = nullptr;
try
{
    p = new char;
}
catch (std::bad_alloc &ba)
{
	p = nullptr;
    ...
    printf("new err\n");
}

if (nullptr != p)
{
    ...
}
else
{
    printf("new err\n");
}

5.11. 0, nullptrNULL

Tip

整數用 0, 實數用 0.0, 指針用 nullptr, 字符 (串) 用 '\0'.

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

對於指針 (地址值), 到底是用0NULL 還是nullptr。實際上,一些 C++ 編譯器對NULL的定義比較特殊,特別是sizeof(NULL)就和sizeof(0)不一樣。(如果編譯器不支持nullptr可以用宏定義一個。)

字符(串)用 '\0',不僅類型正確而且可讀性好。

5.12. sizeof

Tip

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

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

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

Warning

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

5.13. 列表初始化

Tip

你可以用列表初始化。

早在 C++03 里,聚合類型(aggregate types)就已經可以被列表初始化了,比如數組和不自帶構造函數的結構體:

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

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

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

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

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

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

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

// 在函數調用里用列表初始化。
void TestFunction2(vector<int> v) {}
TestFunction2({1, 2, 3});

用戶自定義類型也可以定義接收 std::initializer_list<T> 的構造函數和賦值運算符,以自動列表初始化:

class CMyType {
 public:
  // std::initializer_list 專門接收 init 列表。
  // 得以值傳遞。
  CMyType(std::initializer_list<int> init_list) {
    for (int i : init_list) append(i);
  }
  CMyType& operator=(std::initializer_list<int> init_list) {
    clear();
    for (int i : init_list) append(i);
  }
};
CMyType m{2, 3, 5, 7};

最后,列表初始化也適用於常規數據類型的構造,哪怕沒有接收 std::initializer_list<T> 的構造函數。

double d{1.23};
// CMyOtherType 沒有 std::initializer_list 構造函數,
 // 直接上接收常規類型的構造函數。
class CMyOtherType {
 public:
  explicit CMyOtherType(string);
  CMyOtherType(int, string);
};
CMyOtherType m = {1, "b"};
// 不過如果構造函數是顯式的(explict),您就不能用 `= {}` 了。
CMyOtherType m{"b"};

至於格式化,參見 8.6. 列表初始化格式.

5.14. 模板編程

Tip

不要使用復雜的模板編程,參數不要超過3個.

定義:

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

優點:

模板編程能夠實現非常靈活的類型安全的接口和極好的性能, 一些常見的工具比如Google Test, std::tuple, std::function 和 Boost.Spirit. 這些工具如果沒有模板是實現不了的

缺點:

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

結論:

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

5.15. Boost 庫

Tip

只使用 Boost 中被認可的庫.

定義:

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

優點:

Boost代碼質量普遍較高, 可移植性好, 填補了 C++ 標准庫很多空白, 如型別的特性, 更完善的綁定器, 更好的智能指針。

缺點:

某些 Boost 庫提倡的編程實踐可讀性差, 比如元編程和其他高級模板技術, 以及過度 "函數化" 的編程風格.

結論:

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

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

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

5.16. C++11

Tip

不要使用C++11,除非項目中第三方庫需要.

定義:

C++11 有眾多語言和庫上的變革

優點:

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

缺點:

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

5.15. Boost 庫 一樣,有些 C++11 擴展提倡實則對可讀性有害的編程實踐------就像去除冗余檢查(比如類型名)以幫助讀者,或是鼓勵模板元編程等等。有些擴展在功能上與原有機制沖突,容易招致困惑以及遷移代價。

總結:

出於編譯器支持和移植方面考慮不要使用C++11,除非項目中第三方庫需要.

6. 命名約定

最重要的一致性規則是命名管理. 命名的風格能讓我們在不需要去查找類型聲明的條件下快速地了解某個名字代表的含義: 類型, 變量, 函數, 常量, 宏, 等等, 甚至. 我們大腦中的模式匹配引擎非常依賴這些命名規則.

命名規則具有一定隨意性, 但相比按個人喜好命名, 一致性更重要, 所以無論你認為它們是否重要, 規則總歸是規則.

6.1. 通用命名規則

總述

函數命名, 變量命名, 文件命名要有描述性; 少用縮寫.

說明

盡可能使用描述性的命名, 別心疼空間, 畢竟相比之下讓代碼易於新讀者理解更重要. 不要用只有項目開發者能理解的縮寫, 也不要通過砍掉幾個字母來縮寫單詞.

int price_count_reader;    // 無縮寫
int num_errors;            // "num" 是一個常見的寫法
int num_dns_connections;   // 人人都知道 "DNS" 是什么
int n;                     // 毫無意義.
int nerr;                  // 含糊不清的縮寫.
int n_comp_conns;          // 含糊不清的縮寫.
int wgc_connections;       // 只有貴團隊知道是什么意思.
int pc_reader;             // "pc" 有太多可能的解釋了.
int cstmr_id;              // 刪減了若干字母.

注意, 一些特定的廣為人知的縮寫是允許的, 例如用 i 表示迭代變量和用 T 表示模板參數.

模板參數的命名應當遵循對應的分類: 類型模板參數應當遵循 類型命名 的規則, 而非類型模板應當遵循 變量命名 的規則.

6.2. 文件命名

總述

文件名要全部小寫, 可以包含下划線 (_) ,.

說明

可接受的文件命名示例:

  • my_useful_class.cc
  • myusefulclass.cc

C++ 文件要以 .cc/.cpp 結尾, 頭文件以 .h 結尾. 專門插入文本的文件則以 .inc 結尾, 參見 頭文件自足.

不要使用已經存在於 /usr/include 下的文件名 ( 注: 即編譯器搜索系統頭文件的路徑), 如 db.h.

通常應盡量讓文件名更加明確. http_server_logs.h 就比 logs.h 要好. 定義類時文件名一般成對出現, 如 foo_bar.hfoo_bar.cc, 對應於類 FooBar.

內聯函數必須放在 .h 文件中. 如果內聯函數比較短, 就直接放在 .h 中.

對於MFC項目中自動生成的文件名可不遵循本規則。

6.3. 類型命名

總述

類型名稱的每個單詞首字母均大寫, 不包含下划線,命名規則如下:

命名規則 類型+描述
類型
C :類
Struct : 結構體
P : typedef/using 指針類型
En: 枚舉

說明

所有類型命名 ------ 類, 結構體, 類型定義 (typedef), 枚舉 均使用相同約定, 例如:

// 類和結構體
class CUrlTable { ...
class CUrlTableTester { ...
struct StructUrlTableProperties { ...

// 類型定義
typedef CUrlTable *PCUrlTable;

// using 別名
using *PCUrlTable = CUrlTable;

// 枚舉
enum EnUrlTableErrors { ...

6.4. 變量命名

匈牙利命名法

總述

變量 (包括函數參數) 和數據成員名采用匈牙利命名法.

說明

匈牙利命名法的規則是:

屬性+類型+描述
屬性一般是小寫字母+_:
g_:全局變量
m_:類成員變量
s_:靜態變量
類型就多了:
b:bool
sz:以零結束的字符串
p:指針
n:整型
dw:雙字
l:長整型
無符號:u
函數:fn

6.5. 常量命名

總述

聲明為 const 的變量, 或在程序運行期間其值始終保持不變的, 命名時以 "const" 開頭, 大小寫混合. 例如:

const int constDaysInAWeek = 7;

說明

所有具有靜態存儲類型的變量 (例如靜態變量或全局變量, 參見 存儲類型) 都應當以此方式命名. 對於其他存儲類型的變量, 如自動變量等, 這條規則是可選的. 如果不采用這條規則, 就按照一般的變量命名規則.

6.6. 函數命名

總述

常規函數使用大小寫混合: MyExcitingFunction(), MyExcitingMethod()

說明

一般來說, 函數名的每個單詞首字母大寫 (即 "駝峰變量名" 或 "帕斯卡變量名"), 沒有下划線. 對於首字母縮寫的單詞, 更傾向於將它們視作一個單詞進行首字母大寫 (例如, 寫作 StartRpc() 而非 StartRPC()).

AddTableEntry()
DeleteUrl()
OpenFileOrDie()

(同樣的命名規則同時適用於類作用域與命名空間作用域的常量, 因為它們是作為 API 的一部分暴露對外的, 因此應當讓它們看起來像是一個函數, 因為在這時, 它們實際上是一個對象而非函數的這一事實對外不過是一個無關緊要的實現細節.)

6.7. 命名空間命名

總述

命名空間以小寫字母命名. 最高級命名空間的名字取決於項目名稱. 要注意避免嵌套命名空間的名字之間和常見的頂級命名空間的名字之間發生沖突.

頂級命名空間的名稱應當是項目名或者是該命名空間中的代碼所屬的團隊的名字. 命名空間中的代碼, 應當存放於和命名空間的名字匹配的文件夾或其子文件夾中.

注意 不使用縮寫作為名稱 的規則同樣適用於命名空間. 命名空間中的代碼極少需要涉及命名空間的名稱, 因此沒有必要在命名空間中使用縮寫.

要避免嵌套的命名空間與常見的頂級命名空間發生名稱沖突. 由於名稱查找規則的存在, 命名空間之間的沖突完全有可能導致編譯失敗. 尤其是, 不要創建嵌套的 std 命名空間. 建議使用更獨特的項目標識符 (websearch::index, websearch::index_util) 而非常見的極易發生沖突的名稱 (比如 websearch::util).

對於 internal 命名空間, 要當心加入到同一 internal 命名空間的代碼之間發生沖突 (由於內部維護人員通常來自同一團隊, 因此常有可能導致沖突). 在這種情況下, 請使用文件名以使得內部名稱獨一無二 (例如對於 frobber.h, 使用 websearch::index::frobber_internal).

6.8. 枚舉命名

總述

枚舉命名和 類型命名 相同,枚舉值的命名采用全部字母大寫單詞之間用 _ 分隔 ENUM_ 開頭后跟名字: ENUM_NAME.

enum EnUrlTableErrors {
    ENUM_OK = 0,
    ENUM_ERROR_OUT_OF_MEMORY,
    ENUM_ERROR_MALFORMED_INPUT,
};

6.9. 宏命名

總述

宏像這樣命名: MY_MACRO_THAT_SCARES_SMALL_CHILDREN.

說明

參考 預處理宏; 宏命名像枚舉命名一樣全部大寫, 使用下划線:

#define ROUND(x) ...
#define PI_ROUNDED 3.0

6.10. 命名規則的特例

總述

如果你命名的實體與已有 C/C++ 實體相似, 可參考現有命名策略.

bigopen(): 函數名, 參照 open() 的形式

uint: typedef

bigpos: structclass, 參照 pos 的形式

sparse_hash_map: STL 型實體; 參照 STL 命名約定

LONGLONG_MAX: 常量, 如同 INT_MAX

7. 注釋

注釋雖然寫起來很痛苦, 但對保證代碼可讀性至關重要. 下面的規則描述了如何注釋以及在哪兒注釋. 當然也要記住: 注釋固然很重要, 但最好的代碼應當本身就是文檔. 有意義的類型名和變量名, 要遠勝過要用注釋解釋的含糊不清的名字.

你寫的注釋是給代碼讀者看的, 也就是下一個需要理解你的代碼的人. 所以慷慨些吧, 下一個讀者可能就是你!

7.1. 注釋風格

總述

文件頭,函數/類說明使用/* */

其他使用 ///* */, 統一就好.

說明

///* */ 都可以; 但 // 常用. 要在如何注釋及注釋風格上確保統一.

7.2. 文件注釋

總述

在每一個文件開頭加入版權公告.

文件注釋描述了該文件的內容. 如果一個文件只聲明, 或實現, 或測試了一個對象, 並且這個對象已經在它的聲明處進行了詳細的注釋, 那么就沒必要再加上文件注釋. 除此之外的其他文件都需要文件注釋.

/***********************************************
*  Copyright DPIN                              
*                                              
*  @file     Example.h                         
*  @brief    對文件的簡述,文件的功能,規范文檔,引用開源代碼版權信息.                       
*  @author   name                           
*  @date     2018/04/10                        
*                                              
*  Change History :                            
*  2018/04/10 | 1.0.0.1 | name | 描述修改項  
*                                              
***********************************************/

說明

法律公告和作者信息

每個文件都應該包含許可證引用. 為項目選擇合適的許可證版本.(比如, Apache 2.0, BSD, LGPL, GPL)

如果你對原始作者的文件做了重大修改, 請考慮刪除原作者信息.

作者使用簡稱,日期,修改記錄

文件內容

如果一個 .h 文件聲明了多個概念, 則文件注釋應當對文件的內容做一個大致的說明, 同時說明各概念之間的聯系. 一個一到兩行的文件注釋就足夠了, 對於每個概念的詳細文檔應當放在各個概念中, 而不是文件注釋中.

不要在 .h.cc 之間復制注釋, 這樣的注釋偏離了注釋的實際意義.

7.3. 類注釋

總述

每個類的定義都要附帶一份注釋, 描述類的功能和用法, 除非它的功能相當明顯.

/* 
  Iterates over the contents of a GargantuanTable.
  Example:
    GargantuanTableIterator* iter = table->NewIterator();
    for (iter->Seek("foo"); !iter->done(); iter->Next()) {
      process(iter->key(), iter->value());
    }
    delete iter;
*/
class GargantuanTableIterator {
  ...
};

說明

類注釋應當為讀者理解如何使用與何時使用類提供足夠的信息, 同時應當提醒讀者在正確使用此類時應當考慮的因素. 如果類有任何同步前提, 請用文檔說明. 如果該類的實例可被多線程訪問, 要特別注意文檔說明多線程環境下相關的規則和常量使用.

如果你想用一小段代碼演示這個類的基本用法或通常用法, 放在類注釋里也非常合適.

如果類的聲明和定義分開了(例如分別放在了 .h.cc 文件中), 此時, 描述類用法的注釋應當和接口定義放在一起, 描述類的操作和實現的注釋應當和實現放在一起.

7.4. 函數注釋

總述

函數聲明處的注釋描述函數功能; 定義處的注釋描述函數實現.

說明

函數聲明

基本上每個函數聲明處前都應當加上注釋, 描述函數的功能和用途. 只有在函數的功能簡單而明顯時才能省略這些注釋(例如, 簡單的取值和設值函數). 注釋使用敘述式 ("Opens the file") 而非指令式 ("Open the file"); 注釋只是為了描述函數, 而不是命令函數做什么. 通常, 注釋不會描述函數如何工作. 那是函數定義部分的事情.

函數聲明處注釋的內容:

  • 函數的輸入輸出.
  • 對類成員函數而言: 函數調用期間對象是否需要保持引用參數, 是否會釋放這些參數.
  • 函數是否分配了必須由調用者釋放的空間.
  • 參數是否可以為空指針.
  • 是否存在函數使用上的性能隱患.
  • 如果函數是可重入的, 其同步前提是什么?

舉例如下:

// Returns an iterator for this table.  It is the client's
// responsibility to delete the iterator when it is done with it,
// and it must not use the iterator once the GargantuanTable object
// on which the iterator was created has been deleted.
//
// The iterator is initially positioned at the beginning of the table.
//
// This method is equivalent to:
//    Iterator* iter = table->NewIterator();
//    iter->Seek("");
//    return iter;
// If you are going to immediately seek to another place in the
// returned iterator, it will be faster to use NewIterator()
// and avoid the extra seek.
Iterator* GetIterator() const;

但也要避免羅羅嗦嗦, 或者對顯而易見的內容進行說明. 下面的注釋就沒有必要加上 "否則返回 false", 因為已經暗含其中了:

// Returns true if the table cannot hold any more entries.
bool IsTableFull();

注釋函數重載時, 注釋的重點應該是函數中被重載的部分, 而不是簡單的重復被重載的函數的注釋. 多數情況下, 函數重載不需要額外的文檔, 因此也沒有必要加上注釋.

注釋構造/析構函數時, 切記讀代碼的人知道構造/析構函數的功能, 所以 "銷毀這一對象" 這樣的注釋是沒有意義的. 你應當注明的是注明構造函數對參數做了什么 (例如, 是否取得指針所有權) 以及析構函數清理了什么. 如果都是些無關緊要的內容, 直接省掉注釋. 析構函數前沒有注釋是很正常的.

函數定義

如果函數的實現過程中用到了很巧妙的方式, 那么在函數定義處應當加上解釋性的注釋. 例如, 你所使用的編程技巧, 實現的大致步驟, 或解釋如此實現的理由. 舉個例子, 你可以說明為什么函數的前半部分要加鎖而后半部分不需要.

不要.h 文件或其他地方的函數聲明處直接復制注釋. 簡要重述函數功能是可以的, 但注釋重點要放在如何實現上.

7.5. 變量注釋

總述

通常變量名本身足以很好說明變量用途. 某些情況下, 也需要額外的注釋說明.

說明

類數據成員

每個類數據成員 (也叫實例變量或成員變量) 都應該用注釋說明用途. 如果有非變量的參數(例如特殊值, 數據成員之間的關系, 生命周期等)不能夠用類型與變量名明確表達, 則應當加上注釋. 然而, 如果變量類型與變量名已經足以描述一個變量, 那么就不再需要加上注釋.

特別地, 如果變量可以接受 NULL-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 tests cases that we run through in this regression test.
const int kNumTestCases = 6;

7.6. 實現注釋

總述

對於代碼中巧妙的, 晦澀的, 有趣的, 重要的地方加以注釋.

說明

代碼前注釋

巧妙或復雜的代碼段前要加注釋. 比如:

// Divide result by two, taking into account that x
// contains the carry from the add.
for (int i = 0; i < result->size(); i++) {
  x = (x << 8) + (*result)[i];
  (*result)[i] = x >> 1;
  x &= 1;
}

行注釋

比較隱晦的地方要在行尾加入注釋. 在行尾空兩格進行注釋. 比如:

// If we have enough memory, mmap the data portion too.
mmap_budget = max<int64>(0, mmap_budget - index_->length());
if (mmap_budget >= data_size_ && !MmapData(mmap_chunk_bytes, mlock))
  return;  // Error already logged.

注意, 這里用了兩段注釋分別描述這段代碼的作用, 和提示函數返回時錯誤已經被記入日志.

如果你需要連續進行多行注釋, 可以使之對齊獲得更好的可讀性:

DoSomething();                  // Comment here so the comments line up.
DoSomethingElseThatIsLonger();  // Two spaces between the code and the comment.
{ // One space before comment when opening a new scope is allowed,
  // thus the comment lines up with the following comments and code.
  DoSomethingElse();  // Two spaces before line comments normally.
}
std::vector<string> list{
                    // Comments in braced lists describe the next element...
                    "First item",
                    // .. and should be aligned appropriately.
"Second item"};
DoSomething(); /* For trailing block comments, one space is fine. */

函數參數注釋

如果函數參數的意義不明顯, 考慮用下面的方式進行彌補:

  • 如果參數是一個字面常量, 並且這一常量在多處函數調用中被使用, 用以推斷它們一致, 你應當用一個常量名讓這一約定變得更明顯, 並且保證這一約定不會被打破.
  • 考慮更改函數的簽名, 讓某個 bool 類型的參數變為 enum 類型, 這樣可以讓這個參數的值表達其意義.
  • 如果某個函數有多個配置選項, 你可以考慮定義一個類或結構體以保存所有的選項, 並傳入類或結構體的實例. 這樣的方法有許多優點, 例如這樣的選項可以在調用處用變量名引用, 這樣就能清晰地表明其意義. 同時也減少了函數參數的數量, 使得函數調用更易讀也易寫. 除此之外, 以這樣的方式, 如果你使用其他的選項, 就無需對調用點進行更改.
  • 用具名變量代替大段而復雜的嵌套表達式.
  • 萬不得已時, 才考慮在調用點用注釋闡明參數的意義.

比如下面的示例的對比:

// What are these arguments?
const DecimalNumber product = CalculateProduct(values, 7, false, nullptr);

ProductOptions options;
options.set_precision_decimals(7);
options.set_use_cache(ProductOptions::kDontUseCache);
const DecimalNumber product =
    CalculateProduct(values, options, /*completion_callback=*/nullptr);

哪個更清晰一目了然.

不允許的行為

不要描述顯而易見的現象, 永遠不要 用自然語言翻譯代碼作為注釋, 除非即使對深入理解 C++ 的讀者來說代碼的行為都是不明顯的. 要假設讀代碼的人 C++ 水平比你高, 即便他/她可能不知道你的用意:

你所提供的注釋應當解釋代碼 為什么 要這么做和代碼的目的, 或者最好是讓代碼自文檔化.

比較這樣的注釋:

// Find the element in the vector.  <-- 差: 這太明顯了!
iter = std::find(v.begin(), v.end(), element);
if (iter != v.end()) 
{
  Process(element);
}

和這樣的注釋:

// Process "element" unless it was already processed.
iter = std::find(v.begin(), v.end(), element);
if (iter != v.end()) 
{
  Process(element);
}

自文檔化的代碼根本就不需要注釋. 上面例子中的注釋對下面的代碼來說就是毫無必要的:

if (!IsAlreadyProcessed(element)) 
{
  Process(element);
}

7.7. 標點, 拼寫和語法

總述

注意標點, 拼寫和語法; 寫的好的注釋比差的要易讀的多.

說明

注釋的通常寫法是包含正確大小寫和結尾句號的完整敘述性語句. 大多數情況下, 完整的句子比句子片段可讀性更高. 短一點的注釋, 比如代碼行尾注釋, 可以隨意點, 但依然要注意風格的一致性.

雖然被別人指出該用分號時卻用了逗號多少有些尷尬, 但清晰易讀的代碼還是很重要的. 正確的標點, 拼寫和語法對此會有很大幫助.

代碼中的常量字符只能使用英文(包括日志內容),注釋可以用中文。

7.8. TODO 注釋

總述

對那些臨時的, 短期的解決方案, 或已經夠好但仍不完美的代碼使用 TODO 注釋.

TODO 注釋要使用全大寫的字符串 TODO, 在隨后的圓括號里寫上你的名字, 郵件地址, bug ID, 或其它身份標識和與這一 TODO 相關的 issue. 主要目的是讓添加注釋的人 (也是可以請求提供更多細節的人) 可根據規范的 TODO 格式進行查找. 添加 TODO 注釋並不意味着你要自己來修正, 因此當你加上帶有姓名的 TODO 時, 一般都是寫上自己的名字.

// TODO(kl@gmail.com): Use a "*" here for concatenation operator.
// TODO(Zeke) change this to use relations.
// TODO(bug 12345): remove the "Last visitors" feature

如果加 TODO 是為了在 "將來某一天做某事", 可以附上一個非常明確的時間 "Fix by November 2005"), 或者一個明確的事項 ("Remove this code when all clients can handle XML responses.").

7.9. 棄用注釋

總述

通過棄用注釋(DEPRECATED comments)以標記某接口點已棄用.

您可以寫上包含全大寫的 DEPRECATED 的注釋, 以標記某接口為棄用狀態. 注釋可以放在接口聲明前, 或者同一行.

DEPRECATED 一詞后, 在括號中留下您的名字, 郵箱地址以及其他身份標識.

棄用注釋應當包涵簡短而清晰的指引, 以幫助其他人修復其調用點. 在 C++ 中, 你可以將一個棄用函數改造成一個內聯函數, 這一函數將調用新的接口.

僅僅標記接口為 DEPRECATED 並不會讓大家不約而同地棄用, 您還得親自主動修正調用點(callsites), 或是找個幫手.

修正好的代碼應該不會再涉及棄用接口點了, 着實改用新接口點. 如果您不知從何下手, 可以找標記棄用注釋的當事人一起商量.

7.10. 修改他人代碼注釋

需要留下您的名字或其他身份標識,和修改時間以及修改原因.

//name 日期,修改原因


8. 格式

每個人都可能有自己的代碼風格和格式, 但如果一個項目中的所有人都遵循同一風格的話, 這個項目就能更順利地進行. 每個人未必能同意下述的每一處格式規則, 而且其中的不少規則需要一定時間的適應, 但整個項目服從統一的編程風格是很重要的, 只有這樣才能讓所有人輕松地閱讀和理解代碼.

8.1. 行長度

總述

每一行代碼字符數不超過 80.

我們也認識到這條規則是有爭議的, 但很多已有代碼都遵照這一規則, 因此我們感覺一致性更重要.

優點

提倡該原則的人認為強迫他們調整編輯器窗口大小是很野蠻的行為. 很多人同時並排開幾個代碼窗口, 根本沒有多余的空間拉伸窗口. 大家都把窗口最大尺寸加以限定, 並且 80 列寬是傳統標准. 那么為什么要改變呢?

缺點

反對該原則的人則認為更寬的代碼行更易閱讀. 80 列的限制是上個世紀 60 年代的大型機的古板缺陷; 現代設備具有更寬的顯示屏, 可以很輕松地顯示更多代碼.

結論

80 個字符是最大值.

如果無法在不傷害易讀性的條件下進行斷行, 那么注釋行可以超過 80 個字符, 這樣可以方便復制粘貼. 例如, 帶有命令示例或 URL 的行可以超過 80 個字符.

包含長路徑的 #include 語句可以超出80列.

頭文件保護 可以無視該原則.

8.2. 空格還是制表位

總述

只使用空格, 每次縮進 2 個空格.

說明

我們使用空格縮進. 不要在代碼中使用制表符. 你應該設置編輯器將制表符轉為空格.(注:Visual Studio 2010 工具->選項->文本編輯器->C/C++->制表符->插入空格)

8.3. 函數聲明與定義

總述

返回類型和函數名在同一行, 參數也盡量放在同一行, 如果放不下就對形參分行, 分行方式與 函數調用 一致.

說明

函數看上去像這樣:

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 indent
    Type par_name2,
    Type par_name3) 
{
  DoSomething();  // 2 space indent
  ...
}

注意以下幾點:

  • 使用好的參數名.
  • 只有在參數未被使用或者其用途非常明顯時, 才能省略參數名.
  • 如果返回類型和函數名在一行放不下, 分行.
  • 如果返回類型與函數聲明或定義分行了, 不要縮進.
  • 左圓括號總是和函數名在同一行.
  • 函數名和左圓括號間永遠沒有空格.
  • 圓括號與參數間沒有空格.
  • 左大括號總在最后一個參數同一行的末尾處, 不另起新行.
  • 右大括號總是單獨位於函數最后一行, 或者與左大括號同一行.
  • 右圓括號和左大括號間總是有一個空格.
  • 所有形參應盡可能對齊.
  • 缺省縮進為 2 個空格.
  • 換行后的參數保持 4 個空格的縮進.

未被使用的參數, 或者根據上下文很容易看出其用途的參數, 可以省略參數名:

class CFoo 
{
 public:
  CFoo(CFoo&&);
  CFoo(const CFoo&);
  CFoo& operator=(CFoo&&);
  CFoo& operator=(const CFoo&);
};

未被使用的參數如果其用途不明顯的話, 在函數定義處將參數名注釋起來:

class CShape 
{
 public:
  virtual void Rotate(double radians) = 0;
};

class CCircle : public CShape 
{
 public:
  void Rotate(double radians) override;
};

void CCircle::Rotate(double /*radians*/) {}
// 差 - 如果將來有人要實現, 很難猜出變量的作用.
void CCircle::Rotate(double) {}

屬性, 和展開為屬性的宏, 寫在函數聲明或定義的最前面, 即返回類型之前:

MUST_USE_RESULT bool IsOK();

8.4. Lambda 表達式

總述

Lambda 表達式對形參和函數體的格式化和其他函數一致; 捕獲列表同理, 表項用逗號隔開.

說明

若用引用捕獲, 在變量名和 & 之間不留空格.

int x = 0;
auto add_to_x = [&x](int n) { x += n; };

短 lambda 就寫得和內聯函數一樣.

std::set<int> blacklist = {7, 8, 9};
std::vector<int> digits = {3, 9, 1, 8, 4, 7, 1};
digits.erase(std::remove_if(digits.begin(), digits.end(), [&blacklist](int i) {
               return blacklist.find(i) != blacklist.end();
             }),
             digits.end());

8.5. 函數調用

總述

要么一行寫完函數調用, 要么在圓括號里對參數分行, 要么參數另起一行且縮進四格. 如果沒有其它顧慮的話, 盡可能精簡行數, 比如把多個參數適當地放在同一行里.

說明

函數調用遵循如下形式:

bool retval = DoSomething(argument1, argument2, argument3);

如果同一行放不下, 可斷為多行, 后面每一行都和第一個實參對齊, 左圓括號后和右圓括號前不要留空格:

bool retval = DoSomething(averyveryveryverylongargument1,
                          argument2, argument3);

參數也可以放在次行, 縮進四格:

if (...) 
{
  ...
  ...
  if (...) 
  {
    DoSomething(
        argument1, argument2,  // 4 空格縮進
        argument3, argument4);
  }

把多個參數放在同一行以減少函數調用所需的行數, 除非影響到可讀性. 有人認為把每個參數都獨立成行, 不僅更好讀, 而且方便編輯參數. 不過, 比起所謂的參數編輯, 我們更看重可讀性, 且后者比較好辦:

如果一些參數本身就是略復雜的表達式, 且降低了可讀性, 那么可以直接創建臨時變量描述該表達式, 並傳遞給函數:

int my_heuristic = scores[x] * y + bases[x];
bool retval = DoSomething(my_heuristic, x, y, z);

或者放着不管, 補充上注釋:

bool retval = DoSomething(scores[x] * y + bases[x],  // Score heuristic.
                          x, y, z);

如果某參數獨立成行, 對可讀性更有幫助的話, 那也可以如此做. 參數的格式處理應當以可讀性而非其他作為最重要的原則.

此外, 如果一系列參數本身就有一定的結構, 可以酌情地按其結構來決定參數格式:

// 通過 3x3 矩陣轉換 widget.
my_widget.Transform(x1, x2, x3,
                    y1, y2, y3,
                    z1, z2, z3);

8.6. 列表初始化格式

總述

您平時怎么格式化函數調用, 就怎么格式化 列表初始化.

說明

如果列表初始化伴隨着名字, 比如類型或變量名, 格式化時將將名字視作函數調用名, [{}]{.title-ref} 視作函數調用的括號. 如果沒有名字, 就視作名字長度為零.

// 一行列表初始化示范.
return {foo, bar};
functioncall({foo, bar});
pair<int, int> p{foo, bar};

// 當不得不斷行時.
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 = {  // 注意了, 您可以在 { 前斷行.
    superlongvariablename1,
    superlongvariablename2,
    {short, interior, list},
    {interiorwrappinglist,
     interiorwrappinglist2}};

8.7. 條件語句

總述

不在圓括號內使用空格. 關鍵字 ifelse 另起一行,如果有else if 在最后要加一個 else.

//允許格式
if (condition) 
{  // 圓括號里沒有空格.
  ...  // 2 空格縮進.
} 
else if (...) 
{  // else 與 if 的右括號同一行.
  ...
} 
else 
{
  ...
}

如果你更喜歡在圓括號內部加空格:

//不允許
if ( condition ) 
{  // 圓括號與空格緊鄰 - 不常見
  ...  // 2 空格縮進.
} 
else 
{  // else 與 if 的右括號同一行.
  ...
}

注意所有情況下 if 和左圓括號間都有個空格:

if(condition)     // 差 - IF 后面沒空格.

if (condition)  // 好

8.8. 循環和開關選擇語句

總述

switch 語句使用大括號分段, 以表明 cases 之間不是連在一起的. 在單語句循環里,要加括號. 空循環體應使用 {} .

說明

switch 語句中的 case 塊使用大括號, 要按照下文所述的方法.

如果有不滿足 case 條件的枚舉值, switch 應該總是包含一個 default 匹配 (如果有輸入值沒有 case 去處理, 編譯器將給出 warning). 如果 default 應該永遠執行不到, 簡單的加條 assert ,兩條case之間沒有break需要注釋加以說明:

switch (var) 
{
  case 0: 
  {  // 2 空格縮進
    ...      // 4 空格縮進
    break;
  }
  case 1: // 沒有break
  case 2:
  {
    ...
    break;
  }
  default: 
  {
    assert(false);
  }
}

在單語句循環里,加括號:


for (int i = 0; i < kSomeNumber; ++i) 
{
  printf("I take it back\n");
}

空循環體應使用 {} , 而不是一個簡單的分號.

while (condition) 
{
  // 反復循環直到條件失效.
}
for (int i = 0; i < kSomeNumber; ++i) {}  // 可 - 空循環體.

while (condition);  // 差 - 看起來僅僅只是 while/loop 的部分之一.

8.9. 指針和引用表達式

總述

句點或箭頭前后不要有空格. 指針/地址操作符 (*, &) 之后不能有空格.

說明

下面是指針和引用表達式的正確使用范例:

x = *p;
p = &x;
x = r.y;
x = r->y;

注意:

  • 在訪問成員時, 句點或箭頭前后沒有空格.
  • 指針操作符 *& 后沒有空格.

在聲明指針變量或參數時, 星號與變量名緊挨:

// 好, 空格前置.
char *c;
const string &str;

// 壞, 空格后置.
char* c;
const string& str;

int x, *y;  // 不允許 - 在多重聲明中不能使用 & 或 *
char * c;  // 差 - * 兩邊都有空格
const string & str;  // 差 - & 兩邊都有空格.

在單個文件內要保持風格一致, 所以, 如果是修改現有文件, 要遵照該文件的風格.

8.10. 布爾表達式

總述

如果一個布爾表達式超過 標准行寬, 斷行方式要統一一下.==與常量比較時常量要寫在前面,為了增加可讀性每個表達式要加括號.

說明

常量放在==前面,防止==誤寫成=

if (NULL == p)
{
    ...
}

布爾表達式斷行方式, 邏輯與 (&&) 操作符總位於行頭:

if ((this_one_thing > this_other_thing) 
    && (a_third_thing == a_fourth_thing) 
    && yet_another 
    && last_one) 
{
  ...
}

8.11. 函數返回值

總述

不要在 return 表達式里加上非必須的圓括號.

說明

只有在寫 x = expr 要加上括號的時候才在 return expr; 里使用括號.

return result;                  // 返回值很簡單, 沒有圓括號.
// 可以用圓括號把復雜表達式圈起來, 改善可讀性.
return (some_long_condition &&
        another_condition);

return (value);                // 畢竟您從來不會寫 var = (value);
return(result);                // return 可不是函數!

8.12. 變量及數組初始化

總述

=, (){} 均可.

說明

您可以用 =, (){}, 以下的例子都是正確的:

int x = 3;
int x(3);
int x{3};
string name("Some Name");
string name = "Some Name";
string name{"Some Name"};

請務必小心列表初始化 {...}std::initializer_list 構造函數初始化出的類型. 非空列表初始化就會優先調用 std::initializer_list, 不過空列表初始化除外, 后者原則上會調用默認構造函數. 為了強制禁用 std::initializer_list 構造函數, 請改用括號.

vector<int> v(100, 1);  // 內容為 100 個 1 的向量.
vector<int> v{100, 1};  // 內容為 100 和 1 的向量.

此外, 列表初始化不允許整型類型的四舍五入, 這可以用來避免一些類型上的編程失誤.

int pi(3.14);  // 好 - pi == 3.
int pi{3.14};  // 編譯錯誤: 縮窄轉換.

8.13. 預處理指令

總述

預處理指令不要縮進, 從行首開始.

說明

即使預處理指令位於縮進代碼塊中, 指令也應從行首開始.

// 好 - 指令從行首開始
  if (lopsided_score) 
  {
#if DISASTER_PENDING      // 正確 - 從行首開始
    DropEverything();
# if NOTIFY               // 非必要 - # 后跟空格
    NotifyClient();
# endif
#endif
    BackToNormal();
  }

// 差 - 指令縮進
  if (lopsided_score) 
  {
    #if DISASTER_PENDING  // 差 - "#if" 應該放在行開頭
    DropEverything();
    #endif                // 差 - "#endif" 不要縮進
    BackToNormal();
  }

8.14. 類格式

總述

訪問控制塊的聲明依次序是 public:, protected:, private:, 不縮進.

說明

類聲明 (下面的代碼中缺少注釋, 參考 類注釋) 的基本格式如下:

class CMyClass : public COtherClass 
{
public:      // 注意沒有縮進
  CMyClass();  // 標准的兩空格縮進
  explicit CMyClass(int var);
  ~CMyClass() {}

  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) 外, 其他關鍵詞前要空一行.
  • 這些關鍵詞后不要保留空行.
  • public 放在最前面, 然后是 protected, 最后是 private.
  • 關於聲明順序的規則請參考 聲明順序 一節.

8.15. 構造函數初始值列表

總述

構造函數初始化列表放在同一行或按四格縮進並排多行.

說明

下面兩種初始值列表方式都可以接受:

// 如果所有變量能放在同一行:
CMyClass::CMyClass(int var) : some_var_(var) 
{
  DoSomething();
}

// 如果不能放在同一行,
// 必須置於冒號后, 並縮進 4 個空格
CMyClass::CMyClass(int var)
    : some_var_(var), some_other_var_(var + 1) 
{
  DoSomething();
}

// 如果初始化列表需要置於多行, 將每一個成員放在單獨的一行
// 並逐行對齊
CMyClass::CMyClass(int var)
    : some_var_(var),             // 4 space indent
      some_other_var_(var + 1) 
{  // lined up
  DoSomething();
}

// 右大括號 } 可以和左大括號 { 放在同一行
// 如果這樣做合適的話
CMyClass::CMyClass(int var)
    : some_var_(var) {}

8.16. 命名空間格式化

總述

命名空間內容不縮進.

說明

命名空間 不要增加額外的縮進層次, 例如:

namespace 
{

void foo() 
{  // 正確. 命名空間內沒有額外的縮進.
  ...
}

}  // namespace

不要在命名空間內縮進:

namespace 
{

  // 錯, 縮進多余了.
  void foo() 
  {
    ...
  }

}  // namespace

聲明嵌套命名空間時, 每個命名空間都獨立成行.

namespace foo 
{
namespace bar 
{

8.17. 水平留白

總述

水平留白的使用根據在代碼中的位置決定. 永遠不要在行尾添加沒意義的留白.

說明

通用

int i = 0;  // 分號前不加空格.
// 列表初始化中大括號內的空格是可選的.
// 如果加了空格, 那么兩邊都要加上.
int x[] = { 0 };
int x[] = {0};

// 繼承與初始化列表中的冒號前后恆有空格.
class CFoo : public CBar 
{
public:
  // 對於單行函數的實現, 在大括號內加上空格
  // 然后是函數實現
  CFoo(int b) : CBar(), baz_(b) {}  // 大括號里面是空的話, 不加空格.
  void Reset() { baz_ = 0; }  // 用括號把大括號與實現分開.
  ...

添加冗余的留白會給其他人編輯時造成額外負擔. 因此, 行尾不要留空格. 如果確定一行代碼已經修改完畢, 將多余的空格去掉; 或者在專門清理空格時去掉(尤其是在沒有其他人在處理這件事的時候). (注: 現在大部分代碼編輯器稍加設置后, 都支持自動刪除行首/行尾空格)

循環和條件語句

if (b) 
{          // if 條件語句和循環語句關鍵字后均有空格.
}
while (test) {}   // 圓括號內部不緊鄰空格.
switch (i)
for (int i = 0; i < 5; ++i) 
switch ( i ) 
{    // 循環和條件語句的圓括號里可以與空格緊鄰.
if ( test ) 
{     // 圓括號, 但這很少見. 總之要一致.
for ( int i = 0; i < 5; ++i ) 
{
for ( ; i < 5 ; ++i) 
{  // 循環里內 ; 后恆有空格, ;  前可以加個空格.
switch (i) 
{
  case 1:         // switch case 的冒號前無空格.
    ...
  case 2: break;  // 如果冒號有代碼, 加個空格.

操作符

// 賦值運算符前后總是有空格.
x = 0;

// 其它二元操作符也前后恆有空格, 不過對於表達式的子式可以不加空格.
// 圓括號內部沒有緊鄰空格.
v = (w * x) + (y / z);

v = w * (x + z);

// 在參數和一元操作符之間不加空格.
x = -5;
++x;
if (x && !y)
  ...

模板和轉換

// 尖括號(< and >) 不與空格緊鄰, < 前沒有空格, > 和 ( 之間也沒有.
vector<string> x;
y = static_cast<char*>(x);

// 在類型與指針操作符之間留空格也可以, 但要保持一致.
vector<char *> x;

8.18. 垂直留白

總述

垂直留白越少越好.

說明

這不僅僅是規則而是原則問題了: 不在萬不得已, 不要使用空行. 尤其是: 兩個函數定義之間的空行不要超過 2 行, 函數體首尾不要留空行, 函數體中也不要隨意添加空行.

基本原則是: 同一屏可以顯示的代碼越多, 越容易理解程序的控制流. 當然, 過於密集的代碼塊和過於疏松的代碼塊同樣難看, 這取決於你的判斷. 但通常是垂直留白越少越好.

下面的規則可以讓加入的空行更有效:

  • 函數體內開頭或結尾的空行可讀性微乎其微.
  • 在多重 if-else 塊里加空行或許有點可讀性.

9. 規則特例

前面說明的編程習慣基本都是強制性的. 但所有優秀的規則都允許例外, 這里就是探討這些特例.

9.1. 現有不合規范的代碼

總述

對於現有不符合既定編程風格的代碼可以網開一面.

說明

當你修改使用其他風格的代碼時, 為了與代碼原有風格保持一致可以不使用本指南約定. 如果不放心, 可以與代碼原作者或現在的負責人員商討. 記住, 一致性 也包括原有的一致性.

9.2. Windows 代碼

總述

Windows 程序員有自己的編程習慣, 主要源於 Windows 頭文件和其它 Microsoft 代碼. 我們希望任何人都可以順利讀懂你的代碼, 所以針對所有平台的 C++ 編程只給出一個單獨的指南.

說明

如果你習慣使用 Windows 編碼風格, 這兒有必要重申一下某些你可能會忘記的指南:

  • Windows 定義了很多原生類型的同義詞 , 如 DWORD, HANDLE 等等. 在調用 Windows API 時這是完全可以接受甚至鼓勵的. 即使如此, 還是盡量使用原有的 C++ 類型, 例如使用 const TCHAR * 而不是 LPCTSTR.
  • 使用 Microsoft Visual C++ 進行編譯時, 將警告級別設置為 3 或更高, 並將所有警告(warnings)當作錯誤(errors)處理.
  • 不要使用 #pragma once; 而應該使用 Google 的頭文件保護規則. 頭文件保護的路徑應該相對於項目根目錄 .
  • 除非萬不得已, 不要使用任何非標准的擴展, 如 #pragma__declspec. 使用 __declspec(dllimport)__declspec(dllexport) 是允許的, 但必須通過宏來使用, 比如 DLLIMPORTDLLEXPORT, 這樣其他人在分享使用這些代碼時可以很容易地禁用這些擴展.

然而, 在 Windows 上仍然有一些我們偶爾需要違反的規則:

  • 通常我們 禁止使用多重繼承, 但在使用 COM 和 ATL/WTL 類時可以使用多重繼承. 為了實現 COM 或 ATL/WTL 類/接口, 你可能不得不使用多重實現繼承.
  • 雖然代碼中不應該使用異常, 但是在 ATL 和部分 STL(包括 Visual C++ 的 STL) 中異常被廣泛使用. 使用 ATL 時, 應定義 _ATL_NO_EXCEPTIONS 以禁用異常. 你需要研究一下是否能夠禁用 STL 的異常, 如果無法禁用, 可以啟用編譯器異常. (注意這只是為了編譯 STL, 自己的代碼里仍然不應當包含異常處理).
  • ·通常為了利用頭文件預編譯, 每個每個源文件的開頭都會包含一個名為 StdAfx.hprecompile.h 的文件. 為了使代碼方便與其他項目共享, 請避免顯式包含此文件 (除了在 precompile.cc 中), 使用 /FI 編譯器選項以自動包含該文件.
  • 資源頭文件通常命名為 resource.h 且只包含宏, 這一文件不需要遵守本風格指南.

除非第三方庫用到CString,不允許使用CString等MFC不能跨平台的類和函數,我們的代碼必須考慮可移植性。

總結

指針形參需要判空
函數用到句柄需要判斷是否有效
函數只能有一個return(大眾)
基類析構需要寫成虛函數,子類重寫需要添加虛關鍵字
delete之后要置空
NULL和nullptr取決於項目規范,c++11用nullptr
svn的提交message需要在代碼中注釋
注釋修改記錄


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM