GoogleCpp風格指南 5) 其他特性_part1

5 其他C++特性 Other C++ Features

5.1 引用參數 Reference Arguments

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

定義: 

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

優點: 

定義引用參數防止出現 (*pval)++ 這樣醜陋的代碼; 像拷貝構造函數這樣的應用也是必需的, 而且更明確, Note 不接受NULL指針; 

缺點: 

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

結論: 

函數參數列表中, 所有引用參數都必須是 const;

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

事實上這在 Google Code是一個硬性約定: 輸入參數是值參或const引用, 輸出參數爲指針; 輸入參數可以是const指針, 但決不能是非const的引用參數; 

[Add] 除非是慣例, 如swap() [新慣例~] <<<

把輸入參數定義爲const指針的情況: 強調參數不是拷貝而來的, 在對象生存週期內必須一直存在; 最好同時在註釋中詳細說明一下; Note bind2nd和 mem_fun等STL適配器不接受引用參數, 這種情況下你也必須把函數參數聲明成指針類型;

[Add] 

在以下情況輸入參數定義爲 const T*比 const T&要更合適: 

- 傳遞一個空指針 null

- 函數保存了一個輸入參數的指針或引用;

記住大多數時間裏輸入參數應該是const T&; 使用 const T*作爲替代來和使用者reader通信的話, 輸入參數應該以不同方式處理; 因此如果選擇了const T*, 一定要有個確定的理由; 否則使用者會感到困惑, 也找不到解釋;

<<<


[Add] 

右值引用 Rvalue References

使用 rvalue reference僅僅是爲了定義move構造和move賦值操作; 不要使用 std::forward [ c++11 http://en.cppreference.com/w/cpp/utility/forward ]

定義:

rvalue reference是引用的一種類型, 可以僅僅綁定臨時對象; 語法與傳統的引用語法相似; 例如, void f(string&& s); 聲明一個函數的參數是一個string的rvalue reference.

優點:

- 定義一個move構造(), 可以將一個值move而不用copy它; 如果 v1是一個 vector<string>, 例如 auto v2(std::move(v1)) 可能只是會作爲簡單的指針來操作, 而不是copy大量的數據; 在一些情況下, 這樣可以大大優化性能; 

結論: 

只有在定義move ctor和move assign operator的時候才使用 rvalue ref, 就像Copyable and Movable Types裏描述的; 不要使用std::forward工具函數; 你可以使用 std::move來表達將一個值從一個對象move到另一個, 而不是copy它;

<<<


5.2 函數重載 Function Overloading

Tip 僅在輸入參數類型不同, 功能相同時使用重載函數(含構造函數), 不要用函數重載模擬缺省函數參數;

[Add] 對於使用者來說, 需要在調用點很容易能明白髮生了什麼, 而不是不得不去查詢哪個重載被調用了;

定義: 

可以編寫一個參數類型爲 const string&的函數,  然後用另一個參數類型爲 const char*的函數重載它:

1
2
3
4
5
class MyClass {
    public:
    void Analyze(const string &text);
    void Analyze(const char *text, size_t textlen);
};

優點: 

通過重載參數不同的同名函數, 令代碼更加直觀, 對於模板化的代碼可能重載是必須的, 同時它也爲使用者帶來便利;

缺點: 

限制使用重載的一個原因是在某個特定調用點很難確定到底調用的是哪個函數; 另一個原因是當派生類只覆蓋override了某個函數的部分變體, 會令很多人對繼承的語義產生困惑; 此外在閱讀庫的用戶代碼時, 可能會因反對使用缺省函數參數造成不必要的費解;

[Add] 如果一個函數只是以參數類型這一個屬性來進行重載, 使用者需要理解C++複雜的配對規則才能知道具體發生了什麼; 有許多人會被繼承的語義搞糊塗:  <<<

結論: 

如果想重載一個函數, 考慮讓函數名包含參數信息, 例如, 使用 AppendString(), AppendInt(), 而不是 Append();


5.3 缺省參數 Default Arguments

Tip 我們不允許使用缺省函數參數 [Add] 除了下面解釋的幾種有限的情況; 可以的話, 使用函數重載來模擬; <<<

優點: 

多數情況下, 你寫的函數可能會用到很多的缺省值, 但偶爾你也會修改這些缺省值; 無須爲了這些偶爾情況定義很多的函數, 用缺省參數就能輕鬆的做到這點; [Add] 和重載函數相比, 默認參數的語法更清晰, 減少重複代碼, 而且可以清晰區分參數的'required'和'optional'的性質;<<<

缺點: 

大家通常都是通過查看別人的代碼來推斷如何使用API; 用了缺省參數的代碼更難維護; 從老代碼複製粘貼來的新代碼可能只包含部分參數; 當缺省參數不適用於新代碼時可能會導致重大問題; 

[缺省爲空的一般沒問題, 否則就不行, 或者使用Java的缺省參數方式, 定義兩個函數, 一個轉發另一個, 加入缺省參數值; 缺省函數不能用於虛函數, 默認參數是按類型靜態綁定的]

[Add] 函數指針在默認參數中是容易產生混淆的, 函數簽名常常和調用簽名不匹配; 在現有函數中添加一個默認參數會改變它的類型, 如果代碼是取的函數地址會造成問題; 用函數重載的話可以避免這類問題; 另外, 默認參數可能會帶來大量代碼, 它們在每個調用點都被複制--其實對於默認參數只存在於函數定義中的情況下, 應該使用重載函數;<<<

結論: 

我們規定所有參數必須明確指定, 迫使程序員理解API和各參數值的意義, 避免默默地使用他們可能都還沒意識到的缺省參數;

[Add] 即使對上述的缺點描述情況還不那麼嚴重的時候, 它們依然超過默認參數帶來的(小小的)益處; 所以除了下述情況, 我們要求所有的參數都要顯式地指定:

- 一個指定特例是對於.cc文件中的static函數(或者在一個匿名空間中的函數); 這種情況下, 上述缺點不會發生, 因爲函數使用是局部化的; 

- 另外, 默認函數參數在ctor中被允許; 上述列出的缺點大多多cotr不適用, 因爲無法取得ctor的地址;

- 還有一個特例是當默認參數用來模擬變長參數列表的時候:

1
2
3
4
5
// Support up to 4 params by using a default empty AlphaNum.
string StrCat(const AlphaNum &a,
              const AlphaNum &b = gEmptyAlphaNum,
              const AlphaNum &c = gEmptyAlphaNum,
              const AlphaNum &d = gEmptyAlphaNum);

<<<


5.4 變長數組和 alloca() Variable-Length Arrays and alloca()

Tip 我們不允許使用變長數組和 alloca(); 

[http://stackoverflow.com/questions/1018853/why-is-alloca-not-considered-good-practicehttp://c-faq-chn.sourceforge.net/ccfaq/node121.html ]

優點: 

變長數組具有渾然天成的語法; 變長數組和 alloca()也都很高效; 

缺點: 

變長數組和 alloca()不是標準C++的組成部分; 更重要的是, 它們根據數據大小動態分配堆棧內存, 會引起難以發現的內存越界錯誤overwriting bugs: "在我的機器上運行得好好的, 發佈後卻莫名其妙的掛掉了";

結論: 

使用安全的內存分配器, [Remove ]如 scoped_ptr, scoped_array; <<<

[Add] std::vector 或 std::unique_ptr<T[]><<


5.5 友元 Friends

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

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

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


5.6 異常 Exceptions

Tip 我們不使用C++異常;

優點:

- 異常允許上層應用決定如何處理在底層嵌套函數中"不可能出現的"失敗, 不像錯誤碼記錄error-prone bookkeeping那麼含糊又容易出錯;

- 很多現代語言都使用異常, 引入異常使得 C++與 Python, Java以及其他 C++相近的語言更加兼容;

- 許多第三方 C++庫使用異常, 禁用異常將導致很難集成integrate這些庫;

- 異常是處理構造函數失敗的唯一方法; 雖然可以通過工廠函數或 Init()方法替代異常, 但它們分別需要堆分配heap allocation或新的"無效invalid"狀態;

- 在測試框架中使用異常確實很方便;

缺點: 

- 在現有函數中添加 throw語句時, 你必須檢查所有調用點; 所有調用點得至少有基本的異常安全保證exception safety guarantee, 否則永遠捕獲不到異常, 只好"開心的"接受程序終止的結果; 例如, 如果f()調用了g(), g()又調用了h(), h()拋出的異常被f()捕獲, g()要當心, 很可能會因疏忽而未被妥善清理; 

- 更普遍的情況是, 如果使用異常, 光憑查看代碼是很難評估程序的控制流: 函數返回點可能在你意料之外; 這會導致代碼管理和調試困難, 你可以通過規定何時何地如何使用異常來降低維護開銷, 但是讓開發人員必須掌握並理解這些規定帶來的代價更大;

- 異常安全Exception safety要求同時採用 RAII和不同的編程實踐; 要想輕鬆編寫正確的異常安全代碼, 需要大量的支撐機制配合; 另外, 要避免代碼使用者去理解整個調用結構圖, 異常安全代碼必須把寫持久化狀態的邏輯部分隔離到"提交"階段; 它在帶來好處的同時, 還有成本(也許你不得不爲了隔離"提交"而整出令人費解的代碼); 允許使用異常會驅使我們不斷爲此付出代碼, 即使我們覺得這很不划算;

- 啓用異常使生成的二進制文件體積變大, 延長了編譯時間(或許影響不大), 還可能增加地址空間壓力; [32位內存不夠, Virtual Address Space]

- 異常的實用性可能會慫恿開發人員在不恰當的時候拋出異常, 或者在不安全的地方從異常中恢復; 例如, 處理非法用戶輸入時就不應該拋出異常; 如果我們要完全列出這些約束, 這份風格指南會長出很多!

[還有異常在邊界拋出的情況]

結論:

從表面上看, 使用異常利大於弊, 尤其是在新項目中; 但是對於現有代碼, 引入異常會牽連到所有相關代碼; 如果新項目允許異常向外擴散propagated, 在跟以前未使用異常的代碼整合時也將是個麻煩; 因爲Google現有的大多數C++代碼都沒有異常處理, 引入帶有異常處理的新代碼相當困難;

[可以將異常的使用限制在某個適用的模塊中, 比如Http, RestAPI處理反饋]

鑑於Google現有代碼不接受異常, 在現有代碼中使用異常比在新項目中使用的代價多少要大一些; 遷移過程也比較慢, 容易出錯; 我們不相信異常的使用有效替代方案, 如錯誤碼error code, 斷言等會造成嚴重負擔; 

我們並不是基於哲學或道德層面反對使用異常, 而是在實踐的基礎上; 我們希望在Google使用自己的開源項目, 但項目中使用異常會爲此帶來不便, 因此也建議不要在Google的開源項目中使用異常; 如果需要把這些項目推倒重來顯然不太現實;

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

(譯註: 對於異常處理, 不是短短几句能說清的, 以構造函數爲例, 很多C++書上都提到當構造失敗時只有異常可以處理, Google禁止使用異常這一點, 僅僅是爲了自身的方便; 無非是基於軟件管理成本上, 實際使用中還是自己決定); 

[個人項目隨意, 公司項目還是要看成本, 以及和其他項目的兼容性]


5.7 運行時類型識別 Run-Time Type Information (RTTI)

Tip 我們禁止使用RTTI;

定義: 

RTTI允許程序員在運行時識別C++類對象的類型; 這是由 typeid或 dynamic_cast完成的;

缺點: 

在運行時判斷類型通常意味着設計問題; 如果需要在運行期間確定一個對象的類型, 這通常說明類的層次結構有缺陷, 需要重新設計;

[Add] 任意地undisciplined使用RTTI使得代碼難以維護; 將會導致基於類型的判別樹或者switch語句在代碼中各處分佈, 所有這些都將在今後產生修改時一一檢查 <<<

優點: 

[Add] RTTI的標準替代方案(下面描述)需要修改或者重新設計類層次還在討論中in question; 有時候這種修改是不可行infeasible或者不可取的undesirable, 特別是對於廣泛使用的或者成熟的代碼中; <<

RTTI在某些單元測試中非常有用; 比如對於工廠類的測試, 用來驗證一個新建對象是否爲期望的動態類型; 除測試外, 極少用到;

[Add] 在管理對象和它們的mock之間的關係方面也很有用;

RTTI在考慮多重抽象對象multiple abstract objects的時候很有用:

1
2
3
4
5
6
7
bool Base::Equal(Base* other) = 0;
bool Derived::Equal(Base* other) {
  Derived* that = dynamic_cast<Derived*>(other);
  if (that == NULL)
    return false;
  ...
}

<<<

結論:

RTTI有合理的用處, 但它容易被濫用; 除單元測試外, 不要使用RTTI; 如果你發現自己不得不寫一些行爲邏輯取決於對象類型的代碼, 考慮換一種方式判斷對象類型; 

- 如果要實現根據子類類型subclass來確定執行不同邏輯的代碼, 虛函數無疑更合適; 在對象內部就可以處理類型識別問題;

- 如果要在對象外部的代碼中判斷類型, 考慮使用雙重分派double-dispatch方案, 如訪問者Vistor模式; [函數按類型重載: virtual void visit(const Class& obj) = 0;] 可以方便地在對象本身之外利用內置built-in的類型系統確定類的類型; 

[Add]

當程序邏輯能保證一個給出的基類的實例實際上是一個特定派生類的實例, 那麼dynamic_cast可以在這個對象上自由使用; 通常這種情況下也可以使用static_cast;

基於類型的判斷樹Decision tree 是個強烈的信號, 表示你的代碼在向錯誤的方向發展wrong track;

1
2
3
4
5
6
if (typeid(*data) == typeid(D1)) {
  ...
else if (typeid(*data) == typeid(D2)) {
  ...
else if (typeid(*data) == typeid(D3)) {
...

這樣的代碼通常在新的subclass添加到類的繼承體系後就被破壞了; 而且, 當一個subclass的屬性改變了, 很難找到以及修改受影響的代碼塊;

<<<

不要試圖手工實現一個貌似RTTI的替代方案workaround, 反對使用RTTI的理由, 同樣適用於那些在類型繼承體系上使用類型標籤的替代方案; 而且, workaround會掩蓋你真實的意圖;


5.8 類型轉換 Casting

Tip 使用C++的類型轉換, 如 static_cast<>(); 不要使用 int y = (int)x; 或 int y = int(x);等轉換方式;

定義: 

C++採用了有別於C的類型轉換機制, 對轉換操作進行歸類區分;

優點: 

C語言的類型轉換問題在於模棱兩可的操作; 有時是在做強制轉換(如 (int)3.5), 有時是在做類型轉換(如 (int)"hello"); C++的轉換可以防止這些; 另外, C++的類型轉換在查找時更醒目;

缺點: 

噁心的語法;

結論: 

不要使用C風格類型轉換, 而應該使用C++風格; 

- 用 static_cast替代C風格C-style的值轉換, 或某個類指針需要明確的向上轉換up-cast爲父類指針時; 

- 用 const_cast去掉 const限定符; [以及volatile..儘量不要這麼做]

- 用 reinterpret_cast指針類型整型其他指針之間進行不安全的相互轉換; 僅在你對所做的一切瞭然於心而且理解別名問題的時候使用; [不安全的類型轉換, 按位轉換]

[Remove] 

- dynamic_cast測試代碼之外不要使用; 除非是單元測試, 如果你需要在運行時確定類型信息, 說明有設計缺陷; [針對於RTTI, 多態使用, 即要有 vitrual關鍵字] <<<

[對於有些簡單的 (int)var_float, 如果你知道自己在做什麼, 這麼寫節省時間, 看起來省力些] [Qt中有個qobject_cast()對應 dynamic_cast, 但不需要RTTI支持]


5.9 流 Streams

Tip 只在記錄日誌logging時使用流;

定義: 

流用來替代 printf()和 scanf();

優點: 

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

缺點: 

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

[pread, pwrite - read from or write to a file descriptor at a given offset -- LINUX]

結論: 

不要使用流, 除非是日誌接口需要; 使用 printf之類的printf-like routine代替;

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


拓展討論 Extended Discussion

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

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

1
2
cout << this;   // Prints the address
cout << *this;  // Prints the contents

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

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

1
2
3
4
5
6
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還有長度安全性, 緩衝區溢出等問題 -- Exceptional C++ Style, 2]

還會有這樣那樣的問題出現; (你可能會說"把流封裝一下就會比較好了", 這兒可以, 其他地方呢? 而且不要忘了, 我們的目標是使語言更緊湊, 而不是添加一些別人需要學習的新機制;)

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

---TBC---YCR
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章