一般比较规范的项目都有一个代码规范,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是一个成熟,比较靠谱,容易实施,与时俱进的代码规范,还是很实用的。