Google C++ Style Guide在C++11普及后的变化

一般比较规范的项目都有一个代码规范,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 LLVMFacebook比起来。

 

前几年一个毛子程序员就狠狠地吐槽了一把: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是一个成熟,比较靠谱,容易实施,与时俱进的代码规范,还是很实用的。

posted on 2016-10-18 19:42 chen3feng 阅读(...) 评论(...) 编辑 收藏

导航

公告

统计