Google C++ 程式設計風格指南:作用域

readthedocs發表於2017-02-03

2.1. 名字空間

鼓勵在 .cc 檔案內使用匿名名字空間. 使用具名的名字空間時, 其名稱可基於專案名或相對路徑. 禁止使用 using 指示(using-directive)。禁止使用內聯名稱空間(inline namespace)。

定義:

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

優點:

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

舉例來說, 兩個不同專案的全域性作用域都有一個類 Foo, 這樣在編譯或執行時造成衝突. 如果每個專案將程式碼置於不同名字空間中, project1::Foo 和 project2::Foo 作為不同符號自然不會衝突.

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

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

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

缺點:

名字空間具有迷惑性, 因為它們和類一樣提供了額外的 (可巢狀的) 命名軸線.

名稱空間很容易令人迷惑,畢竟它們不再受其宣告所在名稱空間的限制。內聯名稱空間只在大型版本控制裡有用。

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

結論:

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

2.1.1. 匿名名字空間

在 .cc 檔案中, 允許甚至鼓勵使用匿名名字空間, 以避免執行時的命名衝突:

namespace {                             // .cc 檔案中

// 名字空間的內容無需縮排
enum { kUNUSED, kEOF, kERROR };         // 經常使用的符號
bool AtEof() { return pos_ == kEOF; }   // 使用本名字空間內的符號 EOF

} // namespace

然而, 與特定類關聯的檔案作用域宣告在該類中被宣告為型別, 靜態資料成員或靜態成員函式, 而不是匿名名字空間的成員. 如上例所示, 匿名空間結束時用註釋 // namespace 標識.

不要在 .h 檔案中使用匿名名字空間.

2.1.2. 具名的名字空間

具名的名字空間使用方式如下:
用名字空間把檔案包含, gflags 的宣告/定義, 以及類的前置宣告以外的整個原始檔封裝起來, 以區別於其它名字空間:

// .h 檔案
namespace mynamespace {

// 所有宣告都置於名稱空間中
// 注意不要使用縮排
class MyClass {
    public:
    …
    void Foo();
};

} // namespace mynamespace
// .cc 檔案
namespace mynamespace {

// 函式定義都置於名稱空間中
void MyClass::Foo() {
    …
}

} // namespace mynamespace

通常的 .cc 檔案包含更多, 更復雜的細節, 比如引用其他名字空間的類等.

#include “a.h”

DEFINE_bool(someflag, false, “dummy flag”);

class C;                    // 全域性名字空間中類 C 的前置宣告
namespace a { class A; }    // a::A 的前置宣告

namespace b {

…code for b…                // b 中的程式碼

} // namespace b

不要在名字空間 std 內宣告任何東西, 包括標準庫的類前置宣告. 在 std 名字空間宣告實體會導致不確定的問題, 比如不可移植. 宣告標準庫下的實體, 需要包含對應的標頭檔案.

最好不要使用 using 指示,以保證名字空間下的所有名稱都可以正常使用.

// 禁止 —— 汙染名字空間
using namespace foo;

在 .cc 檔案, .h 檔案的函式, 方法或類中, 可以使用 using 宣告。

// 允許: .cc 檔案中
// .h 檔案的話, 必須在函式, 方法或類的內部使用
using ::foo::bar;

在 .cc 檔案, .h 檔案的函式, 方法或類中, 允許使用名字空間別名.

// 允許: .cc 檔案中
// .h 檔案的話, 必須在函式, 方法或類的內部使用

namespace fbz = ::foo::bar::baz;

// 在 .h 檔案裡
namespace librarian {
//以下別名在所有包含了該標頭檔案的檔案中生效。
namespace pd_s = ::pipeline_diagnostics::sidetable;

inline void my_inline_function() {
  // namespace alias local to a function (or method).
  namespace fbz = ::foo::bar::baz;
  ...
}
}  // namespace librarian

注意在 .h 檔案的別名對包含了該標頭檔案的所有人可見,所以在公共標頭檔案(在專案外可用)以及它們遞迴包含的其它標頭檔案裡,不要用別名。畢竟原則上公共 API 要儘可能地精簡。

禁止用內聯名稱空間

2.2. 巢狀類

當公有巢狀類作為介面的一部分時, 雖然可以直接將他們保持在全域性作用域中, 但將巢狀類的宣告置於 2.1. 名字空間 內是更好的選擇.

定義: 在一個類內部定義另一個類; 巢狀類也被稱為 成員類 (member class).

class Foo {

private:
    // Bar是巢狀在Foo中的成員類
    class Bar {
        …
    };

};

優點:

當巢狀 (或成員) 類只被外圍類使用時非常有用; 把它作為外圍類作用域內的成員, 而不是去汙染外部作用域的同名類. 巢狀類可以在外圍類中做前置宣告, 然後在 .cc 檔案中定義, 這樣避免在外圍類的宣告中定義巢狀類, 因為巢狀類的定義通常只與實現相關.

缺點:

巢狀類只能在外圍類的內部做前置宣告. 因此, 任何使用了 Foo::Bar* 指標的標頭檔案不得不包含類 Foo 的整個宣告.

結論:

不要將巢狀類定義成公有, 除非它們是介面的一部分, 比如, 巢狀類含有某些方法的一組選項.

2.3. 非成員函式、靜態成員函式和全域性函式

使用靜態成員函式或名字空間內的非成員函式, 儘量不要用裸的全域性函式.

優點:

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

缺點:

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

結論:

有時, 把函式的定義同類的例項脫鉤是有益的, 甚至是必要的. 這樣的函式可以被定義成靜態成員, 或是非成員函式. 非成員函式不應依賴於外部變數, 應儘量置於某個名字空間內. 相比單純為了封裝若干不共享任何靜態資料的靜態成員函式而建立類, 不如使用 2.1. 名字空間。

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

如果你必須定義非成員函式, 又只是在 .cc 檔案中使用它, 可使用匿名 namespaces`或 “static` 連結關鍵字 (如 static int Foo() {...}) 限定其作用域.

2.4. 區域性變數

將函式變數儘可能置於最小作用域內, 並在變數宣告時進行初始化.

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 一開始就初始化

注意, GCC 可正確實現了 for (int i = 0; i < 10; ++i) (i 的作用域僅限 for 迴圈內), 所以其他 for迴圈中可以重新使用 i. 在 if 和 while 等語句中的作用域宣告也是正確的, 如:

while (const char* p = strchr(str, ‘/’)) str = p + 1;

Warning

如果變數是一個物件, 每次進入作用域都要呼叫其建構函式, 每次退出作用域都要呼叫其解構函式.

// 低效的實現
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. 靜態和全域性變數

禁止使用 class 型別的靜態或全域性變數:它們會導致難以發現的 bug 和不確定的構造和解構函式呼叫順序。不過 constexpr 變數除外,畢竟它們又不涉及動態初始化或析構。

靜態生存週期的物件,即包括了全域性變數,靜態變數,靜態類成員變數和函式靜態變數,都必須是原生資料型別 (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 指標,別用智慧指標,畢竟後者的解構函式涉及到上文指出的不定順序問題。

Yang.Y 譯註:

上文提及的靜態變數泛指靜態生存週期的物件, 包括: 全域性變數, 靜態變數, 靜態類成員變數, 以及函式靜態變數.

譯者 (YuleFox) 筆記

  1. cc 中的匿名名字空間可避免命名衝突, 限定作用域, 避免直接使用 using 關鍵字汙染名稱空間;
  2. 巢狀類符合區域性使用原則, 只是不能在其他標頭檔案中前置宣告, 儘量不要 public;
  3. 儘量不用全域性函式和全域性變數, 考慮作用域和名稱空間限制, 儘量單獨形成編譯單元;
  4. 多執行緒中的全域性變數 (含靜態成員變數) 不要使用 class 型別 (含 STL 容器), 避免不明確行為導致的 bug.
  5. 作用域的使用, 除了考慮名稱汙染, 可讀性之外, 主要是為降低耦合, 提高編譯/執行效率.

譯者(acgtyrant)筆記

  1. 注意「using 指示(using-directive)」和「using 宣告(using-declaration)」的區別。
  2. 匿名名字空間說白了就是檔案作用域,就像 C static 宣告的作用域一樣,後者已經被 C++ 標準提倡棄用。
  3. 區域性變數在宣告的同時進行顯式值初始化,比起隱式初始化再賦值的兩步過程要高效,同時也貫徹了計算機體系結構重要的概念「區域性性(locality)」。
  4. 注意別在迴圈犯大量構造和析構的低階錯誤。

本系列文章

相關文章