一般比較規范的項目都有一個代碼規范,Google C++ Style Guide(以下簡稱GCSG)是比較流行的C++代碼規范,為什么我會分析它?因為我們現在就在用。
C++代碼規范一般有兩個方向,一個方向是很保守,基本把C++降級回c with classes的年代。我記得前幾年我在某公司某項目中時,曾有領導建議代碼規范中不要使用STL。還有個團隊,老大禁用STL,於是組員把VC的STL代碼扒過來改一下名字,比如vector改為Array,map改為Map,begin改為Begin,然后就允許用了。
另一種是偏前衛的方式,boost的應該算是其中的代表。C++之父搞的C++ Core Guidelines也算是這個流派。
GCSG算是這兩者的折中,也就是說,在比較“土”的使用者看來,還是比較時尚的。在比較前衛的使用者看來,卻偏保守,即使在業界其他大公司中算比較保守的,比如和Apple LLVM和Facebook比起來。
前幾年一個毛子程序員就狠狠地吐槽了一把:Why Google Style Guide for C++ is a deal-breaker,原因是這哥們是boost愛好者,自稱boost programmer,你就知道它屬於什么流派了。他在多達6次收到google招聘人員的聯系后怒了,寫文章發泄了一把,還引起了負責人撕逼:The Philosophy of Google's C++ Code。
扯得有點遠了……
整體來說,GCSG還算是比較實用的,很詳細,覆蓋面很廣,而且還有一個利器cpplint.py來搭配,方便實時自動檢查。
GCSG的另一個好處是更新很及時,自從2008年以來,已經更新了好幾百次。
C++11剛確定的時候,Google C++ Style Guide就做了更新,當時是這么寫的:
只能使用批准的特性,然后來了一句,“目前,只批准了auto”。
又過去五年了,語言和編譯器都有了很大的進展,C++14標准發布了,C++17標准也在制定中,gcc/clang等的跟進也都比較及時,所以Google也與時俱進地更新了代碼規范。
上周末在手機上把最新版的GCSG看了一遍,發現還是有不少明顯的變化值得講一下的。
閑話不提,下面逐條分析:
舊條款的更新
前向聲明
前向聲明是指一個使用類型時,如果只需要它的指針,引用,返回值,參數,而沒有實際實例化對象時,可以用 class Foo; 這樣的語法,避免包含其定義所在的頭文件。
舊的代碼規范里,鼓勵使用前向聲明,好處是減少依賴加快編譯速度,減少代碼修改時的重新編譯,隱藏實現細節。
在新的規范里,已經改為了盡量避免使用前向聲明,需要時直接包含其定義的頭文件即可。原因:
- 容易出現不一致,比如引發錯誤
-
// 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*)
-
- 妨礙接口升級,比如一個類,原來覺的名字不好或者命名空間不恰當,改為了新名字或者換了命名空間,如果代碼中用了前向聲明,就需要修改所有用到的地方。而如果不用,就可以用typedef或者using之類的方式兼容舊代碼。
-
// 舊代碼: class OldClassName {}; // 舊代碼: class NewClassName {}; __attribute__((__deprecated("這個類名廢棄了,請改用NewClassName"))) typedef NewClassName OldClassName; // 兼容舊代碼
- 對性能有一定的影響。比如類成員本來可以用的對象的,必須用指針,不可避免的帶來動態內存分配開銷。
前向聲明在有些時候還是不可避免的,比如兩個類相互引用時。
這個改變,除了正確性問題外,還估計跟其分布式編譯越來越快有關。
這個問題上,我個人的實踐是
- 一個項目內,可以使用前向聲明,跨項目的別人的庫,就要避免前向聲明,直接包含頭文件。
- 值類型,特別是在運行時構造很頻繁類型,不采用前向聲明,比較重而復雜的類,需要對外屏蔽實現細節時,才考慮使用前向聲明。
- 使用前向聲明隱藏實現時,用pimpl方式,比各個成員都用性能上會好一些。
// 傳統方式: // my_class.h // 兩次內存分配,引入了兩個不完整類型Foo, Bar。 class Foo; class Bar; class MyClass { public: MyClass(); private: Foo* foo_; Bar* bar_; }; // =================================== // pimpl方式 // 一次內存分配,不引入任何無關符號。 // my_class.h class MyClass { public: MyClass(); private: struct Impl; std::unique_ptr<Impl> impl_; }; // my_class.cc #include "foo.h" #include "bar.h" struct MyClass::Impl { Foo foo; Bar bar; }; MyClass::MyClass() : impl_(new Impl) {}
-inl.h
舊的規范中,當定義復雜的inline函數或者函數模版時,鼓勵把這部分代碼從頭文件中提取出來,放到單獨的filename-inl.h中。這個實踐在過去很常見,現在不允許了。
嵌套類
舊規范中禁止,新規范中取消了,估計跟不再鼓勵前向聲明有關,因為嵌套類不能前向聲明。
嵌套類可以使接口定義層次化,減少不必要的關注點。
函數重載
舊規范中不鼓勵函數重載,要求函數行為相同時才允許重載,新規范中有所放寬,只要讀代碼時能看比較容易看出調了那個函數,就允許重載。
這個要求依然比較保守,畢竟構造函數天然都是重載的。
默認參數
舊規范中,禁止使用默認參數。新規范中,除了虛函數外,允許使用默認參數,何時使用的決策原則和函數重載的原則一樣。
運算符重載
舊規范中,禁止重載運算符;新規范中,改為了“審慎地”重載運算符。
運算符重載是C++中比較有特色的部分,一棒子打死顯然是過於保守的。
指針和引用的選擇
當一個常量可以是引用也可以是指針時,如何選擇,舊規范中提,新規范中作了規定,這些情況下用指針更合適:
- 當參數可以是null時
- 當參數在函數內會被保存下來以后用時
流
在舊規范中,禁止使用流(iostream),說法是保持一致,只用FILE/printf。新規范中允許了:“恰當地”使用流,保持簡單的方式使用。
所謂簡單的方式使用,就是涉及復雜格式控制時最好不要用,因為不但代碼更啰嗦,還會改變流的狀態。
枚舉值命名
最早的規范規定枚舉值采用全大些下划線分割的方式,2009年以后改為了k開頭,跟大小寫混合的方式,以和宏名做區分。
關於C++11的新條款
auto類型
auto使代碼更清晰時,局部變量鼓勵使用auto,比如迭代器:
// C++03 Style for (std::map<int, std::string>::iterator i = m.begin(); i != m.end(); ++i) { std::cout << i->second; } // C++11 Style for (auto i = m.begin(); i != m.end(); ++i) { std::cout << i->second; }
尤其是當訪問map時,特別推薦用auto:
for (const auto& item : some_map) { const KeyType& key = item.first; const ValType& value = item.second; // The rest of the loop can now just refer to key and value, // a reader can see the types in question, and we've avoided // the too-common case of extra copies in this iteration. }
因為很多人可能不知道map的value_type是std::pair<const KeyType, MappedType>而不是std::pair<KeyType, MappedType>,但是當你用后者時,編譯是能通過的,因為存在隱式構造類型轉換。但是轉換后的對象就不再是map中存的那個。
新的函數定義語法
C++11引入了一種新的函數定義語法
auto foo() -> int { return 0; }
規范規定,只有必須使用這種語法才行時,才能用,常規情況下還是要使用普通的方式。
template <typename T, typename U>
<這里填什么類型合適呢??> add(T t, U u) { return t + u; }
// 新語法解決了這個問題
template <typename T, typename U>
auto add(T t, U u) -> decltype(t+u) {
return t + u;
}
右值引用
右值引用允許用於移動構造函數和移動賦值函數以及完美轉發。
C++03中,對象的拷貝構造函數可能是個很大的開銷,代碼風格不鼓勵返回復雜對象。比如
std::vector<int> foo() { std::vector<int> v; ... return v; } std::vector<int> v = foo();
盡管編譯器普遍支持(匿名和命名的)返回值優化,但是還是有很多時候這種拷貝不可消除。C++11引入了右值引用,函數重載時,臨時對象優先匹配綁右值引用的版本,這樣的函數知道其參數是臨時對象,就可以把其資源直接“移動”過來,避免拷貝。
C++標准庫組件比如string和stl容器,大范圍支持了基於右值引用的移動構造和賦值,即使你自己的代碼沒有對右值引用做任何處理,很多涉及這些對象拷貝和賦值的場景也自動得到了優化。
如果掌握了右值引用,這條規范就允許你針對自定義類做移動構造和賦值優化,從而進一步提高代碼性能。
大括號初始化語法
鼓勵使用,能簡化代碼
auto p = new vector<string>{"foo", "bar"}; // A map can take a list of pairs. Nested braced-init-lists work. map<int, string> m = {{1, "one"}, {2, "2"}}; // A braced-init-list can be implicitly converted to a return type. vector<int> test_function() { return {1, 2, 3}; } // Iterate over a braced-init-list. for (int i : {-1, -2, -3}) {} // Call a function using a braced-init-list. void TestFunction2(vector<int> v) {} TestFunction2({1, 2, 3}); A user-defined type can also define a constructor and/or assignment operator that take std::initializer_list<T>, which is automatically created from braced-init-list:
constexpr
鼓勵使用
在C++中,const關鍵字實際有兩種含義:
const int N = 100; // 編譯期間常量,可以做數組緯度,可以做模板非類型參數。 const int N = rand(); // 運行期常量,不能進行上述用途,只能保證不能被修改。 int a[N]; // 第一種定義OK,第二種編譯出錯。
C++11中,引入了constexpr關鍵字,用來定義“真正”的常量,可以確保是編譯期間就能確定的。
在C++03中,const能用於函數,但是返回的不是編譯期常量。
const int size() { return 1000; } const int Size = size(); // Size不是編譯期常量
constexpr不但可以用於常量,還能用於函數。
constexpr int size() { return 1000; } const int Size = size(); // Size是編譯期常量 int a[Size]; // OK
nullptr
鼓勵使用nullptr代替NULL
好處:有類型,可重載。
這段代碼在C++03中會引發編譯錯誤,因為NULL實際定義為0(gcc的NULL定義為(__null),不影響這里的行為)。
void f(int); void f(void*); f(NULL);
C++11中,用nullptr就沒有歧義
f(nullptr); // 調 f(void*)
由於nullptr是有類型的,在某些情況下用於重載:
template <template T> class shared_ptr { public: constexpr shared_ptr(std::nullptr_t); explicit shared_ptr(T*); }; constexpr shared_ptr<int> EMPTY(nullptr);
sizeof
舊規范中說,盡量用sizeof(變量名)而不是sizeof(類型名),因為這樣當類型改變時,可以避免改動多處。新規范對此作了進一步的完善,規定當代碼跟具體的某個變量無關的場合時,還是要用sizeof(類型的):
if (raw_size < sizeof(int)) { LOG(ERROR) << "compressed record not big enough for count: " << raw_size; return false; }
lambda表達式
恰當使用lambda表達式。lambda在結構復雜的代碼中,可以減少回調函數的定義,使STL中基於謂詞的各種算法(foreach, find_if等)真正好用起來。
override關鍵字
鼓勵使用
用C++的人都遇到過這種情況,基類定義了一個虛函數,我們在派生類中覆蓋了這個虛函數,結果由於失誤,簽名沒弄對
class Shape { public: virtual void Rotate(double radians) = 0; }; class Circle : public Shape { public: virtual void Rotate(float radians); // 錯誤情況1:類型搞錯了 virtual void Rotete(double radians); // 錯誤情況2:函數名寫錯了 };
這兩種錯誤情況都會導致虛函數沒有真正被覆蓋,運行時才能發現。
針對第一種情況,gcc有個編譯警告選項,-Woverride-virtual,能發現大多數錯誤。
第二中情況就麻煩了,畢竟編譯器不會替我們做拼寫檢查,一種不完善的方案是,在基類中把虛函數聲明成純虛函數,但是不是所有的場合都適合把虛函數定義為純虛函數。
C++11引入了override關鍵字,來解決這個問題:
class Circle : public Shape { public: void Rotate(double radians) override; };
override關鍵字表明這個函數是覆蓋基類中同簽名的函數,如果基類中不存在同簽名的函數,編譯期間就會報錯。
總結
GCSG對C++11的新特性做了不少有價值的分析,完善了新的規范,另外隨着C++語言新風格的普及,保守程度也下降了不少。
整體看來,GCSG是一個成熟,比較靠譜,容易實施,與時俱進的代碼規范,還是很實用的。