C++ 是 Google 大部分开源项目的主要编程语言。正如每个 C++ 程序员都知道的, C++ 有很多强大的特性, 但这种强大不可避免的导致它走向复杂,使代码更容易产生 bug, 难以阅读和维护。

本指南的目的是通过详细阐述 C++ 注意事项来驾驭其复杂性。这些规则在保证代码易于管理的同时, 也能高效使用 C++ 的语言特性。

风格, 亦被称作可读性, 也就是指导 C++ 编程的约定. 使用术语 “风格” 有些用词不当, 因为这些习惯远不止源代码文件格式化这么简单。

使代码易于管理的方法之一是加强代码一致性。让任何程序员都可以快速读懂你的代码这点非常重要。保持统一编程风格并遵守约定意味着可以很容易根据 “模式匹配” 规则来推断各种标识符的含义。创建通用, 必需的习惯用语和模式可以使代码更容易理解。在一些情况下可能有充分的理由改变某些编程风格, 但我们还是应该遵循一致性原则,尽量不这么做。

头文件

一个.cc/.cpp文件都应该对应一个.h文件。也有些常见例外,例如单元测试代码和只包含main()入口函数的源文件。

Self-contained 头文件

Tip: 头文件应该能够自给自足(self-contained,也就是可以作为第一个头文件被引入),简单来说就是头文件中依赖的其他声明要在头文件中定义清楚,而不能依赖在.cc文件中调整引入顺序解决依赖。

如果.h文件声明了一个模板或内联函数,同时也在该文件加以定义。凡是有用到这些的.cc文件,就得统统包含该头文件,否则程序可能会在构建中链接失败。

有个例外:如果某函数模板为所有相关模板参数显式实例化,或本身就是某类的一个私有成员,那么它就只能定义在实例化该模板的 .cc 文件里。

#define保护

Tip: 所有头文件都应该使用 #define 来防止头文件被多重包含, 命名格式当是: <PROJECT>_<PATH>_<FILE>_H_

为保证唯一性, 头文件的命名应该基于所在项目源代码树的全路径. 例如, 项目 foo中的头文件foo/src/bar/baz.h可按如下方式保护:

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_

#define#pragma once区别

#pragma once是编译相关,就是说这个编译系统上能用,但在其他编译系统不一定可以,也就是说移植性差。

所以尽量使用#ifndef来避免头文件重复引用。

前置声明

Tip: 尽可能地避免使用前置声明。使用 #include 包含需要的头文件即可。

所谓「前置声明」(forward declaration)是类、函数和模板的纯粹声明,没伴随着其定义。

  • 优点

    • 前置声明能够节省编译时间,多余的#include会迫使编译器展开更多的文件,处理更多的输入。
    • 前置声明能够节省不必要的重新编译的时间。#include使代码因为头文件中无关的改动而被重新编译多次。
  • 缺点

    • 前置声明隐藏了依赖关系,头文件改动时,用户的代码会跳过必要的重新编译过程。

    • 前置声明可能会被库的后续更改所破坏。前置声明函数或模板有时会妨碍头文件开发者变动其API。例如扩大形参类型,加个自带默认参数的模板形参等等。

    • 前置声明来自命名空间 std::的symbol时,其行为未定义

    • 很难判断什么时候该用前置声明,什么时候该用#include。极端情况下,用前置声明代替includes甚至都会暗暗地改变代码的含义:

      // 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*)

      如果#include被 B 和 D 的前置声明替代,test()就会调用f(void*)

    • 前置声明了不少来自头文件的symbol时,就会比单单一行的include冗长

    • 仅仅为了能前置声明而重构代码(比如用指针成员代替对象成员)会使代码变得更慢更复杂。

  • 结论

    • 尽量避免前置声明那些定义在其他项目中的实体。
    • 函数:总是使用#include
    • 类模板:优先使用#include

内联函数

Tip: 只有当函数只有10行甚至更少时才将其定义为内联函数。

  • 定义

    当函数被声明为内联函数之后, 编译器会将其内联展开, 而不是按通常的函数调用机制进行调用。

  • 优点

    只要内联的函数体较小, 内联该函数可以令目标代码更加高效. 对于存取函数以及其它函数体比较短, 性能关键的函数, 鼓励使用内联。

  • 缺点

    滥用内联将导致程序变得更慢。内联可能使目标代码量或增或减, 这取决于内联函数的大小。内联非常短小的存取函数通常会减少代码大小, 但内联一个相当大的函数将戏剧性的增加代码大小。现代处理器由于更好的利用了指令缓存, 小巧的代码往往执行更快。

  • 结论

    一个较为合理的经验准则是, 不要内联超过10行的函数。谨慎对待析构函数, 析构函数往往比其表面看起来要更长, 因为有隐含的成员和基类析构函数被调用!

    有些函数即使声明为内联的也不一定会被编译器内联, 这点很重要; 比如虚函数递归函数就不会被正常内联。通常, 递归函数不应该声明成内联函数,递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的, 大多数编译器都不支持内联递归函数。虚函数内联的主要原因则是想把它的函数体放在类定义内, 为了图个方便, 抑或是当作文档描述其行为, 比如精短的存取函数.

#include的路径及顺序

Tip: 使用标准的头文件包含顺序可增强可读性, 避免隐藏依赖: 相关头文件, C 库, C++ 库, 其他库的 .h, 本项目内的 .h。

项目内头文件应按照项目源代码目录树结构排列, 避免使用 UNIX 特殊的快捷目录.(当前目录)或..(上级目录). 例如, google-awesome-project/src/base/logging.h应该按如下方式包含:

#include "base/logging.h"

又如,dir/foo.ccdir/foo_test.cc的主要作用是实现或测试dir2/foo2.h的功能,foo.cc中包含头文件的次序如下:

  1. dir2/foo2.h(优先位置,详情如下)
  2. C 系统文件
  3. C++ 系统文件
  4. 其他库的.h文件
  5. 本项目内.h文件

这种优先的顺序排序保证当dir2/foo2.h遗漏某些必要的库时,dir/foo.ccdir/foo_test.cc的构建会立刻中止。因此这一条规则保证维护这些文件的人们首先看到构建中止的消息而不是维护其他包的人们。

您所依赖的符号(symbols)被哪些头文件所定义,您就应该包含(include)哪些头文件,前置声明(forward declarations)情况除外。比如您要用到bar.h中的某个符号, 哪怕您所包含的 foo.h已经包含了bar.h, 也照样得包含bar.h, 除非 foo.h有明确说明它会自动向您提供bar.h中的symbol。 不过,凡是cc文件所对应的「相关头文件」已经包含的,就不用再重复包含进其cc文件里面了,就像foo.cc只包含foo.h就够了,不用再管后者所包含的其它内容。

举例来说,google-awesome-project/src/foo/internal/fooserver.cc的包含次序如下:

#include "foo/public/fooserver.h" // 优先位置

#include <sys/types.h>
#include <unistd.h>

#include <hash_map>
#include <vector>

#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/public/bar.h"

小结

  1. 避免多重包含
  2. 头文件尽量避免使用前置声明,直接include
  3. 内联函数最好少于10行。类内部的函数一般会自动内联。所以某函数一旦不需要内联,其定义就不要再放在头文件里,而是放到对应的.cc文件里
  4. 包含文件的次序除了美观之外, 最重要的是可以减少隐藏依赖,使每个头文件在“最需要编译”的地方编译。

作用域

命名空间

Tip: 鼓励在.cc文件内使用匿名命名空间或static声明. 使用具名的命名空间时,其名称可基于项目名或相对路径。**禁止使用using指示(using-directive e.g. using namespace foo;)**。禁止使用内联命名空间(inline namespace)。

  • 定义

    命名空间将全局作用域细分为独立的, 具名的作用域, 可有效防止全局作用域的命名冲突。

  • 优点

    类已经提将命名分割在不同类的作用域内, 命名空间在这基础上又封装了一层。

    举例来说, 两个不同项目的全局作用域都有一个类Foo, 这样在编译或运行时造成冲突。如果每个项目将代码置于不同命名空间中,project1::Fooproject2::Foo作为不同符号自然不会冲突。

    内联命名空间会自动把内部的标识符放到外层作用域,比如:

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

    X::Y::foo()X::foo()彼此可代替。内联命名空间主要用来保持跨版本的 ABI 兼容性。

  • 缺点

    命名空间具有迷惑性, 因为它们使得区分两个相同命名所指代的定义更加困难。

    内联命名空间很容易令人迷惑,毕竟其内部的成员不再受其声明所在命名空间的限制。内联命名空间只在大型版本控制里有用。

  • 结论

    根据下文将要提到的策略合理使用命名空间。

    • 遵守命名空间命名中的规则。

    • 像之前的几个例子中一样,在命名空间的最后注释出命名空间的名字。

    • 用命名空间把文件包含, 以及类的前置声明以外的整个源文件封装起来, 以区别于其它命名空间:

      // .h 文件
      namespace mynamespace {

      // 所有声明都置于命名空间中
      // 注意不要使用缩进
      class MyClass {
      public:
      ...
      void Foo();
      };

      } // namespace mynamespace
      // .cc 文件
      namespace mynamespace {

      // 函数定义都置于命名空间中
      void MyClass::Foo() {
      ...
      }

      } // namespace mynamespace
    • 不要在命名空间std内声明任何东西, 包括标准库的类前置声明。在std命名空间声明实体是未定义的行为, 会导致如不可移植。声明标准库下的实体, 需要包含对应的头文件。

    • 不应该使用using指示 引入整个命名空间的标识符号。

      // 禁止 —— 污染命名空间
      using namespace foo;
    • 不要在头文件中使用命名空间别名除非显式标记内部命名空间使用。因为任何在头文件中引入的命名空间都会成为公开API的一部分。

    • 禁止用内联命名空间

匿名命名空间和静态变量

Tip: 在.cc文件中定义一个不需要被外部引用的变量时,可以将它们放在匿名命名空间或声明为static。但是不要在.h文件中这么做。

  • 定义

    所有置于匿名命名空间的声明都具有内部链接性,函数和变量可以经由声明为static拥有内部链接性,这意味着你在这个文件中声明的这些标识符都不能在另一个文件中被访问。即使两个文件声明了完全一样名字的标识符,它们所指向的实体实际上是完全不同的。

  • 结论

    推荐、鼓励在.cc中对于不需要在其他地方引用的标识符使用内部链接性声明,但是不要在.h中使用。

    匿名命名空间的声明和具名的格式相同,在最后注释上namespace:

    namespace {
    ...
    } // namespace

非成员函数、静态成员函数和全局函数

Tip: 使用静态成员函数或命名空间内的非成员函数,尽量不要用裸的全局函数。将一系列函数直接置于命名空间中,不要用类的静态方法模拟出命名空间的效果,类的静态方法应当和类的实例或静态数据紧密相关。

  • 优点

    某些情况下,非成员函数和静态成员函数是非常有用的,将非成员函数放在命名空间内可避免污染全局作用域。

  • 缺点

    将非成员函数和静态成员函数作为新类的成员或许更有意义,当它们需要访问外部资源或具有重要的依赖关系时更是如此。

  • 结论

    有时,把函数的定义同类的实例脱钩是有益的,甚至是必要的。这样的函数可以被定义成静态成员,或是非成员函数。非成员函数不应依赖于外部变量,应尽量置于某个命名空间内。相比单纯为了封装若干不共享任何静态数据的静态成员函数而创建类,不如使用2.1命名空间。举例而言,对于头文件myproject/foo_bar.h,应当使用

    namespace myproject {
    namespace foo_bar {
    void Function1();
    void Function2();
    } // namespace foo_bar
    } // namespace myproject

    而非

    namespace myproject {
    class FooBar {
    public:
    static void Function1();
    static void Function2();
    };
    } // namespace myproject

    定义在同一编译单元的函数,被其他编译单元直接调用可能会引入不必要的耦合和链接时依赖;静态成员函数对此尤其敏感。可以考虑提取到新类中,或者将函数置于独立库的命名空间内。

    如果你必须定义非成员函数,又只是在.cc文件中使用它,可使用匿名命名空间static链接关键字(如static int Foo() {...}) 限定其作用域。

局部变量

Tip: 将函数变量尽可能置于最小作用域内, 并在变量声明时进行初始化。

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

属于if,whilefor语句的变量应当在这些语句中正常地声明,这样子这些变量的作用域就被限制在这些语句中了,举例而言:

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

有一个例外, 如果变量是一个对象, 每次进入作用域都要调用其构造函数, 每次退出作用域都要调用其析构函数。这会导致效率降低。

// 低效的实现
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);
}

静态和全局变量

Tip: 禁止定义静态储存周期非POD变量,禁止使用含有副作用的函数初始化POD全局变量,因为多编译单元中的静态变量执行时的构造和析构顺序是未明确的,这将导致代码的不可移植。

静态生存周期的对象,即包括了全局变量,静态变量,静态类成员变量和函数静态变量,都必须是原生数据类型(POD : Plain Old Data): 即int,charfloat, 以及 POD类型的指针数组结构体

静态变量的构造函数、析构函数和初始化的顺序在C++中是只有部分明确的,甚至随着构建变化而变化,导致难以发现的 bug。所以除了禁用类类型的全局变量,我们也不允许用函数返回值来初始化POD 变量,除非该函数(比如 getenv() 或 getpid() )不涉及任何全局变量。函数作用域里的静态变量除外,毕竟它的初始化顺序是有明确定义的,而且只会在指令执行到它的声明那里才会发生。

综上所述,我们只允许POD类型的静态变量,即完全禁用vector(使用 C 数组替代) 和string(使用const char [])。

小结

  1. cc中的匿名命名空间可避免命名冲突, 限定作用域, 避免直接使用using关键字污染命名空间
  2. 尽量不用全局函数和全局变量, 考虑作用域和命名空间限制, 尽量单独形成编译单元
  3. 多线程中的全局变量 (含静态成员变量) 不要使用class类型(含 STL 容器), 避免不明确行为导致的 bug
  4. 局部变量在声明的同时进行显式值初始化,比起隐式初始化再赋值的两步过程要高效

类是 C++ 中代码的基本单元. 显然, 它们被广泛使用. 本节列举了在写一个类时的主要注意事项.

构造函数的职责

  • 总述

    不要在构造函数中调用虚函数, 也不要在无法报出错误时进行可能失败的初始化.

  • 定义

    在构造函数中可以进行各种初始化操作.

  • 优点

    • 无需考虑类是否被初始化
    • 经过构造函数完全初始化后的对象可以为const类型, 也能更方便地被标准容器或算法使用
  • 缺点

    • 如果在构造函数内调用了自身的虚函数, 这类调用是不会重定向到子类的虚函数实现. 即使当前没有子类化实现, 将来仍是隐患.
    • 如果执行失败, 会得到一个初始化失败的对象, 这个对象有可能进入不正常的状态, 必须使用bool isValid()或类似这样的机制才能检查出来, 然而这是一个十分容易被疏忽的方法.
    • 构造函数的地址是无法被取得的, 因此, 举例来说, 由构造函数完成的工作是无法以简单的方式交给其他线程的.
  • 结论

    构造函数不允许调用虚函数. 如果代码允许, 直接终止程序是一个合适的处理错误的方式. 否则, 考虑用Init()方法或工厂函数.

隐式类型转换

  • 总述

    不要定义隐式类型转换. 对于转换运算符和单参数构造函数, 请使用explicit关键字.

  • 定义

    隐式类型转换允许一个某种类型 (称作 源类型) 的对象被用于需要另一种类型 (称作 目的类型) 的位置, 例如, 将一个int类型的参数传递给需要double类型的函数.

    explicit关键字可以用于构造函数或 (在 C++11 引入) 类型转换运算符, 以保证只有当目的类型在调用点被显式写明时才能进行类型转换,例如使用cast. 这不仅作用于隐式类型转换, 还能作用于 C++11 的列表初始化语法:

    class Foo {
    explicit Foo(int x, double y);
    ...
    };

    void Func(Foo f);

    此时下面的代码是不允许的:

    Func({42, 3.14});  // Error

    这一代码从技术上说并非隐式类型转换, 但是语言标准认为这是explicit应当限制的行为.

  • 优点

    • 有时目的类型名是一目了然的, 通过避免显式地写出类型名, 隐式类型转换可以让一个类型的可用性和表达性更强.
    • 隐式类型转换可以简单地取代函数重载.
    • 在初始化对象时, 列表初始化语法是一种简洁明了的写法.
  • 缺点

    • 隐式类型转换会隐藏类型不匹配的错误. 有时, 目的类型并不符合用户的期望, 甚至用户根本没有意识到发生了类型转换.
    • 隐式类型转换会让代码难以阅读, 尤其是在有函数重载的时候, 因为这时很难判断到底是哪个函数被调用.
    • 单参数构造函数有可能会被无意地用作隐式类型转换.
    • 如果单参数构造函数没有加上explicit关键字, 读者无法判断这一函数究竟是要作为隐式类型转换, 还是作者忘了加上explicit标记.
    • 并没有明确的方法用来判断哪个类应该提供类型转换, 这会使得代码变得含糊不清.
    • 如果目的类型是隐式指定的, 那么列表初始化会出现和隐式类型转换一样的问题, 尤其是在列表中只有一个元素的时候.
  • 结论

    在类型定义中, 类型转换运算符和单参数构造函数都应当用explicit进行标记. 一个例外是, 拷贝和移动构造函数不应当被标记为explicit, 因为它们并不执行类型转换.

    不能以一个参数进行调用的构造函数不应当加上explicit. 接受一个std::initializer_list作为参数的构造函数也应当省略 explicit, 以便支持拷贝初始化 (例如MyType m = {1, 2};)

可拷贝类型和可移动类型

  • 总述

    如果你的类型需要, 就让它们支持拷贝 / 移动. 否则, 就把隐式产生的拷贝和移动函数禁用.

  • 定义

    可拷贝类型允许对象在初始化时得到来自相同类型的另一对象的值, 或在赋值时被赋予相同类型的另一对象的值, 同时不改变源对象的值. 对于用户定义的类型, 拷贝操作一般通过拷贝构造函数与拷贝赋值操作符定义. string类型就是一个可拷贝类型的例子.

    可移动类型允许对象在初始化时得到来自相同类型的临时对象的值, 或在赋值时被赋予相同类型的临时对象的值 (因此所有可拷贝对象也是可移动的). std::unique_ptr<int>就是一个可移动但不可复制的对象的例子. 对于用户定义的类型, 移动操作一般是通过移动构造函数和移动赋值操作符实现的.

    拷贝 / 移动构造函数在某些情况下会被编译器隐式调用. 例如, 通过传值的方式传递对象.

  • 优点

    可移动及可拷贝类型的对象可以通过传值的方式进行传递或者返回, 这使得 API 更简单, 更安全也更通用. 与传指针和引用不同, 这样的传递不会造成所有权, 生命周期, 可变性等方面的混乱, 也就没必要在协议中予以明确. 这同时也防止了客户端与实现在非作用域内的交互, 使得它们更容易被理解与维护. 这样的对象可以和需要传值操作的通用 API 一起使用, 例如大多数容器.

    拷贝 / 移动构造函数与赋值操作一般来说要比它们的各种替代方案, 比如Clone(), CopyFrom() or Swap(), 更容易定义, 因为它们能通过编译器产生, 无论是隐式的还是通过= default. 这种方式很简洁, 也保证所有数据成员都会被复制. 拷贝与移动构造函数一般也更高效, 因为它们不需要堆的分配或者是单独的初始化和赋值步骤, 同时,对于类似省略不必要的拷贝这样的优化它们也更加合适.

    移动操作允许隐式且高效地将源数据转移出右值对象. 这有时能让代码风格更加清晰.

  • 缺点

    许多类型都不需要拷贝, 为它们提供拷贝操作会让人迷惑, 也显得荒谬而不合理. 单件类型(Registerer), 与特定的作用域相关的类型 (Cleanup), 与其他对象实体紧耦合的类型 (Mutex) 从逻辑上来说都不应该提供拷贝操作. 为基类提供拷贝 / 赋值操作是有害的, 因为在使用它们时会造成对象切割. 默认的或者随意的拷贝操作实现可能是不正确的, 这往往导致令人困惑并且难以诊断出的错误.

    拷贝构造函数是隐式调用的, 也就是说, 这些调用很容易被忽略. 这会让人迷惑, 尤其是对那些所用的语言约定或强制要求传引用的程序员来说更是如此. 同时, 这从一定程度上说会鼓励过度拷贝, 从而导致性能上的问题.

  • 结论

    如果需要就让你的类型可拷贝 / 可移动. 作为一个经验法则, 如果对于你的用户来说这个拷贝操作不是一眼就能看出来的, 那就不要把类型设置为可拷贝. 如果让类型可拷贝, 一定要同时给出拷贝构造函数和赋值操作的定义, 反之亦然. 如果让类型可拷贝, 同时移动操作的效率高于拷贝操作, 那么就把移动的两个操作 (移动构造函数和赋值操作) 也给出定义. 如果类型不可拷贝, 但是移动操作的正确性对用户显然可见, 那么把这个类型设置为只可移动并定义移动的两个操作.

    如果定义了拷贝/移动操作, 则要保证这些操作的默认实现是正确的. 记得时刻检查默认操作的正确性, 并且在文档中说明类是可拷贝的且/或可移动的.

    class Foo {
    public:
    Foo(Foo&& other) : field_(other.field) {}
    // 差, 只定义了移动构造函数, 而没有定义对应的赋值运算符.

    private:
    Field field_;
    };

    由于存在对象切割的风险, 不要为任何有可能有派生类的对象提供赋值操作或者拷贝 / 移动构造函数 (当然也不要继承有这样的成员函数的类). 如果你的基类需要可复制属性, 请提供一个public virtual Clone()和一个protected的拷贝构造函数以供派生类实现.

    如果你的类不需要拷贝 / 移动操作, 请显式地通过在public域中使用= delete或其他手段禁用之.

    // MyClass is neither copyable nor movable.
    MyClass(const MyClass&) = delete;
    MyClass& operator=(const MyClass&) = delete;

结构体 VS 类

  • 总述

    仅当只有数据成员时使用struct, 其它一概使用class.

  • 说明

    在 C++ 中structclass关键字几乎含义一样. 我们为这两个关键字添加我们自己的语义理解, 以便为定义的数据类型选择合适的关键字.

    struct用来定义包含数据的被动式对象, 也可以包含相关的常量, 但除了存取数据成员之外, 没有别的函数功能. 并且存取功能是通过直接访问位域, 而非函数调用. 除了构造函数, 析构函数, Initialize(), Reset(), Validate() 等类似的用于设定数据成员的函数外, 不能提供其它功能的函数.

    如果需要更多的函数功能, class更适合. 如果拿不准, 就用 class.

    为了和STL保持一致, 对于仿函数等特性可以不用class而是使用struct.

    注意: 类和结构体的成员变量使用不同的命名规则.

继承

  • 总述

    使用组合常常比使用继承更合理. 如果使用继承的话, 定义为 public 继承.

  • 定义

    当子类继承基类时, 子类包含了父基类所有数据及操作的定义. C++ 实践中, 继承主要用于两种场合: 实现继承, 子类继承父类的实现代码; 接口继承, 子类仅继承父类的方法名称.

  • 优点

    实现继承通过原封不动的复用基类代码减少了代码量. 由于继承是在编译时声明, 程序员和编译器都可以理解相应操作并发现错误. 从编程角度而言, 接口继承是用来强制类输出特定的 API. 在类没有实现 API 中某个必须的方法时, 编译器同样会发现并报告错误.

  • 缺点

    对于实现继承, 由于子类的实现代码散布在父类和子类间之间, 要理解其实现变得更加困难. 子类不能重写父类的非虚函数, 当然也就不能修改其实现. 基类也可能定义了一些数据成员, 因此还必须区分基类的实际布局.

  • 结论

    所有继承必须是public的. 如果你想使用私有继承, 你应该替换成把基类的实例作为成员对象的方式.

    不要过度使用实现继承. 组合常常更合适一些. 尽量做到只在 “是一个” (“is-a”, 其他 “has-a” 情况下请使用组合) 的情况下使用继承: 如果Bar的确 “是一种” Foo, Bar才能继承Foo.

    必要的话, 析构函数声明为virtual. 如果你的类有虚函数, 则析构函数也应该为虚函数.

    对于可能被子类访问的成员函数, 不要过度使用protected关键字. 注意, 数据成员都必须是私有的.

    对于重载的虚函数或虚析构函数, 使用override, 或 (较不常用的)final关键字显式地进行标记. 较早 (早于 C++11) 的代码可能会使用virtual关键字作为不得已的选项. 因此, 在声明重载时, 请使用 override, finalvirtual的其中之一进行标记. 标记为overridefinal的析构函数如果不是对基类虚函数的重载的话, 编译会报错, 这有助于捕获常见的错误. 这些标记起到了文档的作用, 因为如果省略这些关键字, 代码阅读者不得不检查所有父类, 以判断该函数是否是虚函数.

多重继承

  • 总述

    真正需要用到多重实现继承的情况少之又少. 只在以下情况我们才允许多重继承: 最多只有一个基类是非抽象类; 其它基类都是以Interface为后缀的纯接口类.

  • 定义

    多重继承允许子类拥有多个基类. 要将作为纯接口的基类和具有实现的基类区别开来.

  • 优点

    相比单继承, 多重实现继承可以复用更多的代码.

  • 缺点

    真正需要用到多重 实现 继承的情况少之又少. 有时多重实现继承看上去是不错的解决方案, 但这时你通常也可以找到一个更明确, 更清晰的不同解决方案.

  • 结论

    只有当所有父类除第一个外都是纯接口类时, 才允许使用多重继承. 为确保它们是纯接口, 这些类必须以Interface为后缀.

接口

  • 总述

    接口是指满足特定条件的类, 这些类以Interface为后缀 (不强制).

  • 定义

    当一个类满足以下要求时, 称之为纯接口:

    • 只有纯虚函数 (“=0”) 和静态函数 (除了下文提到的析构函数).

    • 没有非静态数据成员.

    • 没有定义任何构造函数. 如果有, 也不能带有参数, 并且必须为protected.

    • 如果它是一个子类, 也只能从满足上述条件并以Interface为后缀的类继承.

      接口类不能被直接实例化, 因为它声明了纯虚函数. 为确保接口类的所有实现可被正确销毁, 必须为之声明虚析构函数

  • 优点

    Interface为后缀可以提醒其他人不要为该接口类增加函数实现或非静态数据成员. 这一点对于多重继承尤其重要.

  • 缺点

    Interface后缀增加了类名长度, 为阅读和理解带来不便. 同时, 接口属性作为实现细节不应暴露给用户.

  • 结论

    只有在满足上述条件时, 类才以Interface结尾, 但反过来, 满足上述需要的类未必一定以Interface结尾.

运算符重载

  • 总述

    除少数特定环境外, 不要重载运算符. 也不要创建用户定义字面量.

  • 定义

    C++ 允许用户通过使用operator关键字对内建运算符进行重载定义, 只要其中一个参数是用户定义的类型. operator关键字还允许用户使用operator""定义新的字面运算符, 并且定义类型转换函数, 例如operator bool().

  • 优点

    重载运算符可以让代码更简洁易懂, 也使得用户定义的类型和内建类型拥有相似的行为. 重载运算符对于某些运算来说是符合符合语言习惯的名称 (例如==,<,=,<<), 遵循这些语言约定可以让用户定义的类型更易读, 也能更好地和需要这些重载运算符的函数库进行交互操作.

    对于创建用户定义的类型的对象来说, 用户定义字面量是一种非常简洁的标记.

  • 缺点

    • 要提供正确, 一致, 不出现异常行为的操作符运算需要花费不少精力, 而且如果达不到这些要求的话, 会导致令人迷惑的 Bug.
    • 过度使用运算符会带来难以理解的代码, 尤其是在重载的操作符的语义与通常的约定不符合时.
    • 函数重载有多少弊端, 运算符重载就至少有多少.
    • 运算符重载会混淆视听, 让你误以为一些耗时的操作和操作内建类型一样轻巧.
    • 对重载运算符的调用点的查找需要的可就不仅仅是像 grep 那样的程序了, 这时需要能够理解 C++ 语法的搜索工具.
    • 重载某些运算符本身就是有害的. 例如, 重载一元运算符 & 会导致同样的代码有完全不同的含义, 这取决于重载的声明对某段代码而言是否是可见的. 重载诸如&&,||, 会导致运算顺序和内建运算的顺序不一致.
  • 结论

    只有在意义明显, 不会出现奇怪的行为并且与对应的内建运算符的行为一致时才定义重载运算符. 例如, | 要作为位或或逻辑或来使用, 而不是作为 shell 中的管道.

    只有对用户自己定义的类型重载运算符. 更准确地说, 将它们和它们所操作的类型定义在同一个头文件中, .cc中和命名空间中. 这样做无论类型在哪里都能够使用定义的运算符, 并且最大程度上避免了多重定义的风险. 如果可能的话, 请避免将运算符定义为模板, 因为此时它们必须对任何模板参数都能够作用. 如果你定义了一个运算符, 请将其相关且有意义的运算符都进行定义, 并且保证这些定义的语义是一致的. 例如, 如果你重载了<, 那么请将所有的比较运算符都进行重载, 并且保证对于同一组参数,<>不会同时返回true.

    不要为了避免重载操作符而走极端. 比如说, 应当定义==,=,和<<而不是Equals(),CopyFrom()PrintTo(). 反过来说, 不要只是为了满足函数库需要而去定义运算符重载. 比如说, 如果你的类型没有自然顺序, 而你要将它们存入std::set中, 最好还是定义一个自定义的比较运算符而不是重载<.

    不要重载&&,||,,或一元运算符&. 不要重载operator"", 也就是说, 不要引入用户定义字面量.

存取控制

  • 总述

    所有数据成员声明为private, 除非是static const类型成员 (遵循 常量命名规则). 处于技术上的原因, 在使用Google Test时我们允许测试固件类中的数据成员为protected.

声明顺序

  • 总述

    将相似的声明放在一起, 将public部分放在最前.

  • 说明

    类定义一般应以public:开始, 后跟protected:, 最后是private:. 省略空部分.

    在各个部分中, 建议将类似的声明放在一起, 并且建议以如下的顺序: 类型 (包括typedef,using和嵌套的结构体与类), 常量, 工厂函数, 构造函数, 赋值运算符, 析构函数, 其它函数, 数据成员.

    不要将大段的函数定义内联在类定义中. 通常,只有那些普通的, 或性能关键且短小的函数可以内联在类定义中. 参见 内联函数 一节.

小结

  1. 不在构造函数中做太多逻辑相关的初始化
  2. 编译器提供的默认构造函数不会对变量进行初始化, 如果定义了其他构造函数, 编译器不再提供, 需要编码者自行提供默认构造函数
  3. 为避免隐式转换, 需将单参数构造函数声明为explicit
  4. 为避免拷贝构造函数, 赋值操作的滥用和编译器自动生成, 可将其声明为private且无需实现
  5. 仅在作为数据集合时使用struct
  6. 组合 > 实现继承 > 接口继承 > 私有继承, 子类重载的虚函数也要声明virtual关键字, 虽然编译器允许不这样做
  7. 避免使用多重继承, 使用时, 除一个基类含有实现外, 其他基类均为纯接口
  8. 接口类类名以Interface为后缀, 除提供带实现的虚析构函数, 静态成员函数外, 其他均为纯虚函数, 不定义非静态数据成员, 不提供构造函数, 提供的话, 声明为protected
  9. 为降低复杂性, 尽量不重载操作符, 模板, 标准类中使用时提供文档说明
  10. 存取函数一般内联在头文件中
  11. 声明次序:public -> protected -> private
  12. 函数体尽量短小, 紧凑, 功能单一

函数

参数顺序

  • 总述

    函数的参数顺序为: 输入参数在先, 后跟输出参数.

  • 说明

    C/C++ 中的函数参数或者是函数的输入, 或者是函数的输出, 或兼而有之. 输入参数通常是值参或const引用, 输出参数或输入/输出参数则一般为非const指针. 在排列参数顺序时, 将所有的输入参数置于输出参数之前. 特别要注意, 在加入新参数时不要因为它们是新参数就置于参数列表最后, 而是仍然要按照前述的规则, 即将新的输入参数也置于输出参数之前.

    这并非一个硬性规定. 输入/输出参数 (通常是类或结构体) 让这个问题变得复杂. 并且, 有时候为了其他函数保持一致, 你可能不得不有所变通.

编写简短函数

  • 总述

    我们倾向于编写简短, 凝练的函数.

  • 说明

    我们承认长函数有时是合理的, 因此并不硬性限制函数的长度. 如果函数超过40行, 可以思索一下能不能在不影响程序结构的前提下对其进行分割.

    即使一个长函数现在工作的非常好, 一旦有人对其修改, 有可能出现新的问题, 甚至导致难以发现的 bug. 使函数尽量简短,以便于他人阅读和修改代码.

    在处理代码时, 你可能会发现复杂的长函数. 不要害怕修改现有代码: 如果证实这些代码使用/调试起来很困难, 或者你只需要使用其中的一小段代码, 考虑将其分割为更加简短并易于管理的若干函数.

引用参数

  • 总述

    所有按引用传递的参数必须加上const.

  • 定义

    在 C 语言中, 如果函数需要修改变量的值, 参数必须为指针, 如int foo(int *pval). 在 C++ 中, 函数还可以声明为引用参数: int foo(int &val).

  • 优点

    定义引用参数可以防止出现(*pval)++这样丑陋的代码. 引用参数对于拷贝构造函数这样的应用也是必需的. 同时也更明确地不接受空指针.

  • 缺点

    容易引起误解, 因为引用在语法上是值变量却拥有指针的语义.

  • 结论

    函数参数列表中, 所有引用参数都必须是const:

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

    事实上这在Google Code是一个硬性约定: 输入参数是值参或const引用, 输出参数为指针. 输入参数可以是const指针, 但决不能是非const的引用参数, 除非特殊要求, 比如 swap().

    有时候, 在输入形参中用const T*指针比const T&更明智. 比如:

    • 可能会传递空指针.

    • 函数要把指针或对地址的引用赋值给输入形参.

      总而言之, 大多时候输入形参往往是const T&. 若用const T*则说明输入另有处理. 所以若要使用const T*, 则应给出相应的理由, 否则会使得读者感到迷惑.

函数重载

  • 总述

    若要使用函数重载, 则必须能让读者一看调用点就胸有成竹, 而不用花心思猜测调用的重载函数到底是哪一种. 这一规则也适用于构造函数.

  • 定义

    你可以编写一个参数类型为const string&的函数, 然后用另一个参数类型为const char*的函数对其进行重载:

    class MyClass {
    public:
    void Analyze(const string &text);
    void Analyze(const char *text, size_t textlen);
    };
  • 优点

    通过重载参数不同的同名函数, 可以令代码更加直观. 模板化代码需要重载, 这同时也能为使用者带来便利.

  • 缺点

    如果函数单靠不同的参数类型而重载, 读者就得十分熟悉 C++ 五花八门的匹配规则, 以了解匹配过程具体到底如何. 另外, 如果派生类只重载了某个函数的部分变体, 继承语义就容易令人困惑.

  • 结论

    如果打算重载一个函数, 可以试试改在函数名里加上参数信息. 例如, 用AppendString()AppendInt()等, 而不是一口气重载多个Append(). 如果重载函数的目的是为了支持不同数量的同一类型参数, 则优先考虑使用std::vector以便使用者可以用 列表初始化 指定参数.

缺省参数

  • 总述

    只允许在非虚函数中使用缺省参数, 且必须保证缺省参数的值始终一致. 缺省参数与 函数重载 遵循同样的规则. 一般情况下建议使用函数重载, 尤其是在缺省函数带来的可读性提升不能弥补下文中所提到的缺点的情况下.

  • 优点

    有些函数一般情况下使用默认参数, 但有时需要又使用非默认的参数. 缺省参数为这样的情形提供了便利, 使程序员不需要为了极少的例外情况编写大量的函数. 和函数重载相比, 缺省参数的语法更简洁明了, 减少了大量的样板代码, 也更好地区别了 “必要参数” 和 “可选参数”.

  • 缺点

    缺省参数实际上是函数重载语义的另一种实现方式, 因此所有 不应当使用函数重载的理由 也都适用于缺省参数.

    虚函数调用的缺省参数取决于目标对象的静态类型, 此时无法保证给定函数的所有重载声明的都是同样的缺省参数.

    缺省参数是在每个调用点都要进行重新求值的, 这会造成生成的代码迅速膨胀. 作为读者, 一般来说也更希望缺省的参数在声明时就已经被固定了, 而不是在每次调用时都可能会有不同的取值.

    缺省参数会干扰函数指针, 导致函数签名与调用点的签名不一致. 而函数重载不会导致这样的问题.

  • 结论

    对于虚函数, 不允许使用缺省参数, 因为在虚函数中缺省参数不一定能正常工作. 如果在每个调用点缺省参数的值都有可能不同, 在这种情况下缺省函数也不允许使用. (例如, 不要写像void f(int n = counter++); 这样的代码.)

    在其他情况下, 如果缺省参数对可读性的提升远远超过了以上提及的缺点的话, 可以使用缺省参数. 如果仍有疑惑, 就使用函数重载.

来自Google的奇技

Google 用了很多自己实现的技巧 / 工具使 C++ 代码更加健壮, 我们使用 C++ 的方式可能和你在其它地方见到的有所不同.

所有权与智能指针

  • 总述

    动态分配出的对象最好有单一且固定的所有主, 并通过智能指针传递所有权.

  • 定义

    所有权是一种登记/管理动态内存和其它资源的技术. 动态分配对象的所有主是一个对象或函数, 后者负责确保当前者无用时就自动销毁前者. 所有权有时可以共享, 此时就由最后一个所有主来负责销毁它. 甚至也可以不用共享, 在代码中直接把所有权传递给其它对象.

    智能指针是一个通过重载*->运算符以表现得如指针一样的类. 智能指针类型被用来自动化所有权的登记工作, 来确保执行销毁义务到位.std::unique_ptr是 C++11 新推出的一种智能指针类型, 用来表示动态分配出的对象的独一无二的所有权; 当std::unique_ptr离开作用域时, 对象就会被销毁.std::unique_ptr不能被复制, 但可以把它移动(move)给新所有主.std::shared_ptr同样表示动态分配对象的所有权, 但可以被共享, 也可以被复制; 对象的所有权由所有复制者共同拥有, 最后一个复制者被销毁时, 对象也会随着被销毁.

  • 优点

    • 如果没有清晰、逻辑条理的所有权安排, 不可能管理好动态分配的内存.
    • 传递对象的所有权, 开销比复制来得小, 如果可以复制的话.
    • 传递所有权也比”借用”指针或引用来得简单, 毕竟它大大省去了两个用户一起协调对象生命周期的工作.
    • 如果所有权逻辑条理, 有文档且不紊乱的话, 可读性会有很大提升.
    • 可以不用手动完成所有权的登记工作, 大大简化了代码, 也免去了一大波错误之恼.
    • 对于 const 对象来说, 智能指针简单易用, 也比深度复制高效.
  • 缺点

    • 不得不用指针(不管是智能的还是原生的)来表示和传递所有权. 指针语义可要比值语义复杂得许多了, 特别是在 API 里:这时不光要操心所有权, 还要顾及别名, 生命周期, 可变性以及其它大大小小的问题.
    • 其实值语义的开销经常被高估, 所以所有权传递带来的性能提升不一定能弥补可读性和复杂度的损失.
    • 如果 API 依赖所有权的传递, 就会害得客户端不得不用单一的内存管理模型.
    • 如果使用智能指针, 那么资源释放发生的位置就会变得不那么明显.
    • std::unique_ptr的所有权传递原理是 C++11 的 move 语法, 后者毕竟是刚刚推出的, 容易迷惑程序员.
    • 如果原本的所有权设计已经够完善了, 那么若要引入所有权共享机制, 可能不得不重构整个系统.
    • 所有权共享机制的登记工作在运行时进行, 开销可能相当大.
    • 某些极端情况下 (例如循环引用), 所有权被共享的对象永远不会被销毁.
    • 智能指针并不能够完全代替原生指针.
  • 结论

    如果必须使用动态分配, 那么更倾向于将所有权保持在分配者手中. 如果其他地方要使用这个对象, 最好传递它的拷贝, 或者传递一个不用改变所有权的指针或引用. 倾向于使用std::unique_ptr来明确所有权传递, 例如:

    std::unique_ptr<Foo> FooFactory();
    void FooConsumer(std::unique_ptr<Foo> ptr);

    如果没有很好的理由, 则不要使用共享所有权. 这里的理由可以是为了避免开销昂贵的拷贝操作, 但是只有当性能提升非常明显, 并且操作的对象是不可变的(比如说std::shared_ptr<const Foo>)时候, 才能这么做. 如果确实要使用共享所有权, 建议于使用std::shared_ptr.

    不要使用std::auto_ptr, 使用std::unique_ptr代替它.

Cpplint

  • 总述

    使用cpplint.py检查风格错误.

  • 说明

    cpplint.py是一个用来分析源文件, 能检查出多种风格错误的工具. 它不并完美, 甚至还会漏报和误报, 但它仍然是一个非常有用的工具. 在行尾加// NOLINT, 或在上一行加// NOLINTNEXTLINE, 可以忽略报错.

    某些项目会指导你如何使用他们的项目工具运行cpplint.py. 如果你参与的项目没有提供, 你可以单独下载cpplint.py.

其他C++特性

引用参数

Tip: 所有按引用传递的参数必须加上const.

  • 定义

    在 C 语言中, 如果函数需要修改变量的值, 参数必须为指针, 如int foo(int *pval). 在 C++ 中, 函数还可以声明引用参数:int foo(int &val).

  • 优点

    定义引用参数防止出现(*pval)++这样丑陋的代码. 像拷贝构造函数这样的应用也是必需的. 而且更明确, 不接受NULL指针.

  • 缺点

    容易引起误解, 因为引用在语法上是值变量却拥有指针的语义.

  • 结论

    函数参数列表中, 所有引用参数都必须是const:

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

    事实上这在 Google Code 是一个硬性约定: 输入参数是值参或const引用, 输出参数为指针. 输入参数可以是const指针, 但决不能是非const的引用参数,除非用于交换,比如swap().

    有时候,在输入形参中用const T*指针比const T&更明智。比如:

      - 您会传 null 指针。
      - 函数要把指针或对地址的引用赋值给输入形参。
    

    总之大多时候输入形参往往是const T&. 若用const T*说明输入另有处理。所以若您要用 const T*, 则应有理有据,否则会害得读者误解。

右值引用

Tip: 只在定义移动构造函数与移动赋值操作时使用右值引用. 不要使用std::forward.

  • 定义

    右值引用是一种只能绑定到临时对象的引用的一种, 其语法与传统的引用语法相似. 例如,void f(string&& s); 声明了一个其参数是一个字符串的右值引用的函数.

  • 优点

    用于定义移动构造函数 (使用类的右值引用进行构造的函数) 使得移动一个值而非拷贝之成为可能. 例如, 如果v1是一个vector<string>, 则auto v2(std::move(v1))将很可能不再进行大量的数据复制而只是简单地进行指针操作, 在某些情况下这将带来大幅度的性能提升.

  • 缺点

    右值引用是一个相对比较新的特性 (由 C++11 引入), 它尚未被广泛理解. 类似引用崩溃, 移动构造函数的自动推导这样的规则都是很复杂的.

  • 结论

    只在定义移动构造函数与移动赋值操作时使用右值引用, 不要使用std::forward功能函数. 你可能会使用std::move来表示将值从一个对象移动而不是复制到另一个对象.

函数重载

Tip: 若要用好函数重载,最好能让读者一看调用点(call site)就胸有成竹,不用花心思猜测调用的重载函数到底是哪一种。该规则适用于构造函数。

  • 定义

    你可以编写一个参数类型为const string&的函数, 然后用另一个参数类型为const char*的函数重载它:

    class MyClass {
    public:
    void Analyze(const string &text);
    void Analyze(const char *text, size_t textlen);
    };
  • 优点

    通过重载参数不同的同名函数, 令代码更加直观. 模板化代码需要重载, 同时为使用者带来便利.

  • 缺点

    如果函数单单靠不同的参数类型而重载,读者就得十分熟悉 C++ 五花八门的匹配规则,以了解匹配过程具体到底如何。另外,当派生类只重载了某个函数的部分变体,继承语义容易令人困惑。

  • 结论

    如果您打算重载一个函数, 可以试试改在函数名里加上参数信息。例如,用AppendString()AppendInt()等, 而不是一口气重载多个Append().

缺省参数

Tip: 我们不允许使用缺省函数参数,少数极端情况除外。尽可能改用函数重载。

  • 优点

    当您有依赖缺省参数的函数时,您也许偶尔会修改修改这些缺省参数。通过缺省参数,不用再为个别情况而特意定义一大堆函数了。与函数重载相比,缺省参数语法更为清晰,代码少,也很好地区分了「必选参数」和「可选参数」。

  • 缺点

    缺省参数会干扰函数指针,害得后者的函数签名(function signature)往往对不上所实际要调用的函数签名。即在一个现有函数添加缺省参数,就会改变它的类型,那么调用其地址的代码可能会出错,不过函数重载就没这问题了。此外,缺省参数会造成臃肿的代码,毕竟它们在每一个调用点(call site)都有重复。函数重载正好相反,毕竟它们所谓的「缺省参数」只会出现在函数定义里。

  • 结论

    由于缺点并不是很严重,有些人依旧偏爱缺省参数胜于函数重载。所以除了以下情况,我们要求必须显式提供所有参数。

    1. 位于.cc文件里的静态函数或匿名空间函数,毕竟都只能在局部文件里调用该函数了
    2. 可以在构造函数里用缺省参数,毕竟不可能取得它们的地址
    3. 可以用来模拟变长数组

变长数组和alloca()

Tip: 我们不允许使用变长数组和alloca().

  • 优点

    变长数组具有浑然天成的语法. 变长数组和alloca()也都很高效.

  • 缺点

    变长数组和alloca()不是标准 C++ 的组成部分. 更重要的是, 它们根据数据大小动态分配堆栈内存, 会引起难以发现的内存越界 bugs: “在我的机器上运行的好好的, 发布后却莫名其妙的挂掉了”.

  • 结论

    改用更安全的分配器(allocator),就像std::vectorstd::unique_ptr<T[]>.

友元

Tip: 我们允许合理的使用友元类及友元函数.

通常友元应该定义在同一文件内, 避免代码读者跑到其它文件查找使用该私有成员的类. 经常用到友元的一个地方是将FooBuilder声明为Foo的友元, 以便FooBuilder正确构造 Foo 的内部状态, 而无需将该状态暴露出来. 某些情况下, 将一个单元测试类声明成待测类的友元会很方便.

友元扩大了 (但没有打破) 类的封装边界. 某些情况下, 相对于将类成员声明为public, 使用友元是更好的选择, 尤其是如果你只允许另一个类访问该类的私有成员时. 当然, 大多数类都只应该通过其提供的公有成员进行互操作.

运行时类型识别

Tip: 禁止使用 RTTI(Run-Time Type Identification).

  • 定义

    RTTI 允许程序员在运行时识别 C++ 类对象的类型. 它通过使用typeid或者dynamic_cast完成.

  • 优点

    RTTI 的标准替代 (下面将描述) 需要对有问题的类层级进行修改或重构. 有时这样的修改并不是我们所想要的, 甚至是不可取的, 尤其是在一个已经广泛使用的或者成熟的代码中.

    RTTI 在某些单元测试中非常有用. 比如进行工厂类测试时, 用来验证一个新建对象是否为期望的动态类型. RTTI 对于管理对象和派生对象的关系也很有用.

    在考虑多个抽象对象时 RTTI 也很好用. 例如:

    bool Base::Equal(Base* other) = 0;
    bool Derived::Equal(Base* other) {
    Derived* that = dynamic_cast<Derived*>(other);
    if (that == NULL)
    return false;
    ...
    }
  • 缺点

    在运行时判断类型通常意味着设计问题. 如果你需要在运行期间确定一个对象的类型, 这通常说明你需要考虑重新设计你的类.

    随意地使用 RTTI 会使你的代码难以维护. 它使得基于类型的判断树或者 switch 语句散布在代码各处. 如果以后要进行修改, 你就必须检查它们.

  • 结论

    基于类型的判断树是一个很强的暗示, 它说明你的代码已经偏离正轨了. 不要像下面这样:

    if (typeid(*data) == typeid(D1)) {
    ...
    } else if (typeid(*data) == typeid(D2)) {
    ...
    } else if (typeid(*data) == typeid(D3)) {
    ...

    一旦在类层级中加入新的子类, 像这样的代码往往会崩溃. 而且, 一旦某个子类的属性改变了, 你很难找到并修改所有受影响的代码块.

    不要去手工实现一个类似 RTTI 的方案. 反对 RTTI 的理由同样适用于这些方案, 比如带类型标签的类继承体系. 而且, 这些方案会掩盖你的真实意图.

类型转换

Tip: 使用 C++ 的类型转换, 如static_cast<>(). 不要使用int y = (int)xint y = int(x)等转换方式

  • 定义

    C++ 采用了有别于 C 的类型转换机制, 对转换操作进行归类.

  • 优点

    C 语言的类型转换问题在于模棱两可的操作; 有时是在做强制转换 (如 (int)3.5), 有时是在做类型转换 (如 (int)"hello"). 另外, C++ 的类型转换在查找时更醒目.

  • 结论

    不要使用 C 风格类型转换. 而应该使用 C++ 风格.

    • static_cast替代 C 风格的值转换, 或某个类指针需要明确的向上转换为父类指针时.
    • const_cast去掉const限定符.
    • reinterpret_cast指针类型和整型或其它指针之间进行不安全的相互转换. 仅在你对所做一切了然于心时使用.

Tip: 只在记录日志时使用流.

  • 定义

    流用来替代printf()scanf().

  • 优点

    有了流, 在打印时不需要关心对象的类型. 不用担心格式化字符串与参数列表不匹配 (虽然在 gcc 中使用printf也不存在这个问题). 流的构造和析构函数会自动打开和关闭对应的文件.

  • 缺点

    流使得pread()等功能函数很难执行. 如果不使用printf风格的格式化字符串, 某些格式化操作 (尤其是常用的格式字符串 %.*s) 用流处理性能是很低的. 流不支持字符串操作符重新排序 (%1s), 而这一点对于软件国际化很有用.

  • 结论

    不要使用流, 除非是日志接口需要. 使用printf之类的代替.

    使用流还有很多利弊, 但代码一致性胜过一切. 不要在代码中使用流.

前置自增和自减减

Tip: 对于迭代器和其他模板对象使用前缀形式(++i)的自增, 自减运算符.

  • 定义

    对于变量在自增 (++ii++) 或自减 (--ii--) 后表达式的值又没有没用到的情况下, 需要确定到底是使用前置还是后置的自增 (自减).

  • 优点

    不考虑返回值的话, 前置自增 (++i) 通常要比后置自增 (i++) 效率更高. 因为后置自增 (或自减) 需要对表达式的值 i 进行一次拷贝. 如果 i 是迭代器或其他非数值类型, 拷贝的代价是比较大的. 既然两种自增方式实现的功能一样, 为什么不总是使用前置自增呢?

  • 缺点

    在 C 开发中, 当表达式的值未被使用时, 传统的做法是使用后置自增, 特别是在for循环中. 有些人觉得后置自增更加易懂, 因为这很像自然语言, 主语 (i) 在谓语动词 (++) 前.

  • 结论

    对简单数值 (非对象), 两种都无所谓. 对迭代器和模板类型, 使用前置自增 (自减).

const用法

Tip: 我们强烈建议你在任何可能的情况下都要使用const. 此外有时改用 C++11 推出的 constexpr 更好。

  • 定义

    在声明的变量或参数前加上关键字const用于指明变量值不可被篡改 (如 const int foo). 为类中的函数加上const限定符表明该函数不会修改类成员变量的状态 (如class Foo { int Bar(char c) const; };).

  • 优点

    大家更容易理解如何使用变量. 编译器可以更好地进行类型检测, 相应地, 也能生成更好的代码. 人们对编写正确的代码更加自信, 因为他们知道所调用的函数被限定了能或不能修改变量值. 即使是在无锁的多线程编程中, 人们也知道什么样的函数是安全的.

  • 缺点

    const是入侵性的: 如果你向一个函数传入const变量, 函数原型声明中也必须对应const参数 (否则变量需要const_cast类型转换), 在调用库函数时显得尤其麻烦.

  • 结论

    const变量, 数据成员, 函数和参数为编译时类型检测增加了一层保障; 便于尽早发现错误. 因此, 我们强烈建议在任何可能的情况下使用const:

    • 如果函数不会修改传你入的引用或指针类型参数, 该参数应声明为 const.
    • 尽可能将函数声明为const. 访问函数应该总是const. 其他不会修改任何数据成员, 未调用非const函数, 不会返回数据成员非 const指针或引用的函数也应该声明成const.
    • 如果数据成员在对象构造之后不再发生变化, 可将其定义为const.

constexpr用法

Tip: 在 C++11 里,用 constexpr 来定义真正的常量,或实现常量初始化。

  • 定义

    变量可以被声明成constexpr以表示它是真正意义上的常量,即在编译时和运行时都不变。函数或构造函数也可以被声明成constexpr, 以用来定义 constexpr变量。

  • 优点

    如今 constexpr 就可以定义浮点式的真・常量,不用再依赖字面值了;也可以定义用户自定义类型上的常量;甚至也可以定义函数调用所返回的常量。

  • 缺点

    若过早把变量优化成 constexpr 变量,将来又要把它改为常规变量时,挺麻烦的;当前对constexpr函数和构造函数中允许的限制可能会导致这些定义中解决的方法模糊。

  • 结论

    靠 constexpr 特性,方才实现了 C++ 在接口上打造真正常量机制的可能。好好用 constexpr 来定义真・常量以及支持常量的函数。避免复杂的函数定义,以使其能够与constexpr一起使用。 千万别痴心妄想地想靠 constexpr 来强制代码「内联」。

预处理宏

Tip: 使用宏时要非常谨慎, 尽量以内联函数, 枚举和常量代替之.

宏意味着你和编译器看到的代码是不同的. 这可能会导致异常行为, 尤其因为宏具有全局作用域.

值得庆幸的是, C++ 中, 宏不像在 C 中那么必不可少. 以往用宏展开性能关键的代码, 现在可以用内联函数替代. 用宏表示常量可被const变量代替. 用宏 “缩写” 长变量名可被引用代替. 用宏进行条件编译… 这个, 千万别这么做, 会令测试更加痛苦 (#define防止头文件重包含当然是个特例).

宏可以做一些其他技术无法实现的事情, 在一些代码库 (尤其是底层库中) 可以看到宏的某些特性 (如用#字符串化, 用##连接等等). 但在使用前, 仔细考虑一下能不能不使用宏达到同样的目的.

下面给出的用法模式可以避免使用宏带来的问题; 如果你要宏, 尽可能遵守:

  • 不要在.h文件中定义宏.
  • 在马上要使用时才进行#define, 使用后要立即 #undef.
  • 不要只是对已经存在的宏使用#undef,选择一个不会冲突的名称;
  • 不要试图使用展开后会导致 C++ 构造不稳定的宏, 不然也至少要附上文档说明其行为.
  • 不要用##处理函数,类和变量的名字。

nullptrNULL

Tip: 对于指针 (地址值), 到底是用0,NULL还是nullptr. C++11 项目用nullptr; C++03 项目则用NULL, 毕竟它看起来像指针。实际上,一些 C++ 编译器对NULL的定义比较特殊,可以输出有用的警告,特别是 sizeof(NULL)就和sizeof(0)不一样。

sizeof

Tip: 尽可能用sizeof(varname)代替sizeof(type).
使用sizeof(varname)是因为当代码中变量类型改变时会自动更新. 您或许会用sizeof(type)处理不涉及任何变量的代码,比如处理来自外部或内部的数据格式,这时用变量就不合适了。

auto

Tip: 用auto绕过烦琐的类型名,只要可读性好就继续用,别用在局部变量之外的地方。

  • 定义

C++11 中,若变量被声明成auto, 那它的类型就会被自动匹配成初始化表达式的类型。您可以用auto来复制初始化或绑定引用。

vector<string> v;
...
auto s1 = v[0]; // 创建一份 v[0] 的拷贝。
const auto& s2 = v[0]; // s2 是 v[0] 的一个引用。
  • 优点

C++ 类型名有时又长又臭,特别是涉及模板或命名空间的时候。就像:

sparse_hash_map<string, int>::iterator iter = m.find(val);

返回类型好难读,代码目的也不够一目了然。重构其:

auto iter = m.find(val);

好多了。

没有auto的话,我们不得不在同一个表达式里写同一个类型名两次,无谓的重复,就像:

diagnostics::ErrorStatus* status = new diagnostics::ErrorStatus("xyz");

有了 auto, 可以更方便地用中间变量,显式编写它们的类型轻松点。

  • 缺点

类型够明显时,特别是初始化变量时,代码才会够一目了然。但以下就不一样了:

auto i = x.Lookup(key);

看不出其类型是啥,x 的类型声明恐怕远在几百行之外了。

程序员必须会区分autoconst auto&的不同之处,否则会复制错东西。

auto 和 C++11 列表初始化的合体令人摸不着头脑:

auto x(3);  // 圆括号。
auto y{3}; // 大括号。

它们不是同一回事——xint,y则是std::initializer_list<int>. 其它一般不可见的代理类型也有大同小异的陷阱。

如果在接口里用auto, 比如声明头文件里的一个常量,那么只要仅仅因为程序员一时修改其值而导致类型变化的话——API 要翻天覆地了。

  • 结论

auto只能用在局部变量里用。别用在文件作用域变量,命名空间作用域变量和类数据成员里。永远别列表初始化auto变量。

auto还可以和 C++11 特性「尾置返回类型(trailing return type)」一起用,不过后者只能用在lambda表达式里。

列表初始化

早在 C++03 里,聚合类型(aggregate types)就已经可以被列表初始化了,比如数组和不自带构造函数的结构体:

struct Point { int x; int y; };
Point p = {1, 2};

C++11 中,该特性得到进一步的推广,任何对象类型都可以被列表初始化。示范如下:

// Vector 接收了一个初始化列表。
vector<string> v{"foo", "bar"};

// 不考虑细节上的微妙差别,大致上相同。
// 您可以任选其一。
vector<string> v = {"foo", "bar"};

// 可以配合 new 一起用。
auto p = new vector<string>{"foo", "bar"};

// 初始化列表也可以用在返回类型上的隐式转换。
vector<int> test_function() { return {1, 2, 3}; }

// 初始化列表可迭代。
for (int i : {-1, -2, -3}) {}

// 在函数调用里用列表初始化。
void TestFunction2(vector<int> v) {}
TestFunction2({1, 2, 3});

用户自定义类型也可以定义接收std::initializer_list<T>的构造函数和赋值运算符,以自动列表初始化:

class MyType {
public:
// std::initializer_list 专门接收 init 列表。
// 得以值传递。
MyType(std::initializer_list<int> init_list) {
for (int i : init_list) append(i);
}
MyType& operator=(std::initializer_list<int> init_list) {
clear();
for (int i : init_list) append(i);
}
};
MyType m{2, 3, 5, 7};

最后,列表初始化也适用于常规数据类型的构造,哪怕没有接收std::initializer_list<T>的构造函数。

double d{1.23};
// MyOtherType 没有 std::initializer_list 构造函数,
// 直接上接收常规类型的构造函数。
class MyOtherType {
public:
explicit MyOtherType(string);
MyOtherType(int, string);
};
MyOtherType m = {1, "b"};
// 不过如果构造函数是显式的(explict),您就不能用 `= {}` 了。
MyOtherType m{"b"};

Lambda表达式

Tip: 适当使用lambda表达式。别用默认lambda捕获,所有捕获都要显式写出来。

  • 定义

    Lambda 表达式是创建匿名函数对象的一种简易途径,常用于把函数当参数传,例如:

    std::sort(v.begin(), v.end(), [](int x, int y) {
    return Weight(x) < Weight(y);
    });

    C++11 首次提出 Lambdas, 还提供了一系列处理函数对象的工具,比如多态包装器(polymorphic wrapper)std::function.

  • 优点

    • 传函数对象给 STL 算法,Lambdas 最简易,可读性也好。
    • Lambdas,std::functionsstd::bind可以搭配成通用回调机制(general purpose callback mechanism);写接收有界函数为参数的函数也很容易了。
  • 缺点

    • Lambdas 的变量捕获略旁门左道,可能会造成悬空指针。
    • Lambdas 可能会失控;层层嵌套的匿名函数难以阅读。
  • 结论

    • 按 format 小用 lambda 表达式怡情。
    • **禁用默认捕获([=] [&])**,捕获都要显式写出来。打比方,比起[=](int x) {return x + n;}, 您该写成[n](int x) {return x + n;}才对,这样读者也好一眼看出n是被捕获的值。
    • 匿名函数始终要简短,如果函数体超过了五行,那么还不如起名,或改用函数。
    • 如果可读性更好,就显式写出 lambd 的尾置返回类型,就像auto.

命名约定

最重要的一致性规则是命名管理. 命名的风格能让我们在不需要去查找类型声明的条件下快速地了解某个名字代表的含义: 类型, 变量, 函数, 常量, 宏, 等等, 甚至. 我们大脑中的模式匹配引擎非常依赖这些命名规则.

命名规则具有一定随意性, 但相比按个人喜好命名, 一致性更重要, 所以无论你认为它们是否重要, 规则总归是规则.

通用命名规则

  • 总述

    函数命名, 变量命名, 文件命名要有描述性; 少用缩写

  • 说明

    尽可能使用描述性的命名, 别心疼空间, 毕竟相比之下让代码易于新读者理解更重要. 不要用只有项目开发者能理解的缩写, 也不要通过砍掉几个字母来缩写单词.

    int price_count_reader;    // 无缩写
    int num_errors; // "num" 是一个常见的写法
    int num_dns_connections; // 人人都知道 "DNS" 是什么
    int n;                     // 毫无意义.
    int nerr; // 含糊不清的缩写.
    int n_comp_conns; // 含糊不清的缩写.
    int wgc_connections; // 只有贵团队知道是什么意思.
    int pc_reader; // "pc" 有太多可能的解释了.
    int cstmr_id; // 删减了若干字母.

    注意, 一些特定的广为人知的缩写是允许的, 例如用 i 表示迭代变量和用 T 表示模板参数.

文件命名

  • 总述

    文件名要全部小写, 可以包含下划线_或连字符-, 依照项目的约定. 如果没有约定, 那么_更好.

  • 说明

    可接受的文件命名示例:

    • my_useful_class.cc

    • my-useful-class.cc

    • myusefulclass.cc

    • myusefulclass_test.cc // _unittest_regtest 已弃用

      C++ 文件要以.cc结尾, 头文件以.h结尾. 专门插入文本的文件则以.inc结尾

      不要使用已经存在于/usr/include下的文件名, 如db.h.

      通常应尽量让文件名更加明确.http_server_logs.h就比logs.h要好. 定义类时文件名一般成对出现, 如foo_bar.hfoo_bar.cc, 对应于类FooBar.

      内联函数必须放在.h文件中. 如果内联函数比较短, 就直接放在.h中.

类型命名

  • 总述

    类型名称的每个单词首字母均大写, 不包含下划线: MyExcitingClass, MyExcitingEnum.

  • 说明

    所有类型命名 —— 类, 结构体, 类型定义 (typedef), 枚举, 类型模板参数 —— 均使用相同约定, 即以大写字母开始, 每个单词首字母均大写, 不包含下划线. 例如:

    // 类和结构体
    class UrlTable { ...
    class UrlTableTester { ...
    struct UrlTableProperties { ...

    // 类型定义
    typedef hash_map<UrlTableProperties *, string> PropertiesMap;

    // using 别名
    using PropertiesMap = hash_map<UrlTableProperties *, string>;

    // 枚举
    enum UrlTableErrors { ...

变量命名

  • 总述

    变量 (包括函数参数) 和数据成员名一律小写, 单词之间用下划线连接. 类的成员变量以下划线结尾, 但结构体的就不用, 如: a_local_variable, a_struct_data_member, a_class_data_member_.

  • 说明

    • 普通变量命名

      string table_name;  // 好 - 用下划线.
      string tablename; // 好 - 全小写.
      string tableName; // 差 - 混合大小写
    • 类数据成员

      class TableInfo {
      ...
      private:
      string table_name_; // 好 - 后加下划线.
      string tablename_; // 好.
      static Pool<TableInfo>* pool_; // 好.
      };
    • 结构体变量

      struct UrlTableProperties {
      string name;
      int num_entries;
      static Pool<UrlTableProperties>* pool;
      };

常量命名

  • 总述

    声明为constexprconst的变量, 或在程序运行期间其值始终保持不变的,命名时以 “k” 开头, 大小写混合. 例如:

    const int kDaysInAWeek = 7;
  • 说明

所有具有静态存储类型的变量 (例如静态变量或全局变量) 都应当以此方式命名. 对于其他存储类型的变量, 如自动变量等, 这条规则是可选的. 如果不采用这条规则, 就按照一般的变量命名规则.

函数命名

  • 总述

    常规函数使用大小写混合, 取值和设值函数则要求与变量名匹配: MyExcitingFunction(), MyExcitingMethod(), my_exciting_member_variable(), set_my_exciting_member_variable().

  • 说明

    一般来说, 函数名的每个单词首字母大写 (即 “驼峰变量名” 或 “帕斯卡变量名”), 没有下划线. 对于首字母缩写的单词, 更倾向于将它们视作一个单词进行首字母大写 (例如, 写作StartRpc()而非StartRPC()).

    AddTableEntry()
    DeleteUrl()
    OpenFileOrDie()

    取值和设值函数的命名与变量一致. 一般来说它们的名称与实际的成员变量对应, 但并不强制要求. 例如int count()void set_count(int count).

命名空间命名

  • 总述

    命名空间以小写字母命名. 最高级命名空间的名字取决于项目名称. 要注意避免嵌套命名空间的名字之间和常见的顶级命名空间的名字之间发生冲突.

    顶级命名空间的名称应当是项目名或者是该命名空间中的代码所属的团队的名字. 命名空间中的代码, 应当存放于和命名空间的名字匹配的文件夹或其子文件夹中.

    要避免嵌套的命名空间与常见的顶级命名空间发生名称冲突. 由于名称查找规则的存在, 命名空间之间的冲突完全有可能导致编译失败. 尤其是, 不要创建嵌套的std命名空间. 建议使用更独特的项目标识符 (websearch::index,websearch::index_util) 而非常见的极易发生冲突的名称 (比如websearch::util).

    对于internal命名空间, 要当心加入到同一internal命名空间的代码之间发生冲突 (由于内部维护人员通常来自同一团队, 因此常有可能导致冲突). 在这种情况下, 请使用文件名以使得内部名称独一无二 (例如对于frobber.h, 使用websearch::index::frobber_internal).

枚举命名

  • 总述

    枚举的命名应当和 常量 或 宏 一致: kEnumName或是ENUM_NAME.

  • 说明

    单独的枚举值应该优先采用 常量 的命名方式. 但 宏 方式的命名也可以接受. 枚举名UrlTableErrors(以及 AlternateUrlTableErrors) 是类型, 所以要用大小写混合的方式.

    enum UrlTableErrors {
    kOK = 0,
    kErrorOutOfMemory,
    kErrorMalformedInput,
    };
    enum AlternateUrlTableErrors {
    OK = 0,
    OUT_OF_MEMORY = 1,
    MALFORMED_INPUT = 2,
    };

宏命名

你并不打算 使用宏, 对吧? 如果你一定要用, 像这样命名: MY_MACRO_THAT_SCARES_SMALL_CHILDREN.

注释

注释虽然写起来很痛苦, 但对保证代码可读性至关重要. 下面的规则描述了如何注释以及在哪儿注释. 当然也要记住: 注释固然很重要, 但最好的代码应当本身就是文档. 有意义的类型名和变量名, 要远胜过要用注释解释的含糊不清的名字.

注释风格

  • 总述

    使用///* */, 统一就好.

  • 说明

    ///* */都可以; 但//更 常用. 要在如何注释及注释风格上确保统一.

文件注释

  • 总述

    在每一个文件开头加入版权公告.

    文件注释描述了该文件的内容. 如果一个文件只声明, 或实现, 或测试了一个对象, 并且这个对象已经在它的声明处进行了详细的注释, 那么就没必要再加上文件注释. 除此之外的其他文件都需要文件注释.

  • 说明

    • 法律公告和作者信息

      每个文件都应该包含许可证引用. 为项目选择合适的许可证版本.(比如, Apache 2.0, BSD, LGPL, GPL)

      如果你对原始作者的文件做了重大修改, 请考虑删除原作者信息.

    • 文件内容

      如果一个.h文件声明了多个概念, 则文件注释应当对文件的内容做一个大致的说明, 同时说明各概念之间的联系. 一个一到两行的文件注释就足够了, 对于每个概念的详细文档应当放在各个概念中, 而不是文件注释中.

      不要在.h.cc之间复制注释, 这样的注释偏离了注释的实际意义.

类注释

  • 总述

    每个类的定义都要附带一份注释, 描述类的功能和用法, 除非它的功能相当明显.

    // Iterates over the contents of a GargantuanTable.
    // Example:
    // GargantuanTableIterator* iter = table->NewIterator();
    // for (iter->Seek("foo"); !iter->done(); iter->Next()) {
    // process(iter->key(), iter->value());
    // }
    // delete iter;
    class GargantuanTableIterator {
    ...
    };
  • 说明

    类注释应当为读者理解如何使用与何时使用类提供足够的信息, 同时应当提醒读者在正确使用此类时应当考虑的因素. 如果类有任何同步前提, 请用文档说明. 如果该类的实例可被多线程访问, 要特别注意文档说明多线程环境下相关的规则和常量使用.

    如果你想用一小段代码演示这个类的基本用法或通常用法, 放在类注释里也非常合适.

    如果类的声明和定义分开了(例如分别放在了.h.cc文件中), 此时, 描述类用法的注释应当和接口定义放在一起, 描述类的操作和实现的注释应当和实现放在一起.

函数注释

  • 总述

函数声明处的注释描述函数功能; 定义处的注释描述函数实现.

  • 说明

    • 函数说明

      基本上每个函数声明处前都应当加上注释, 描述函数的功能和用途. 只有在函数的功能简单而明显时才能省略这些注释(例如, 简单的取值和设值函数). 注释使用叙述式 (“Opens the file”) 而非指令式 (“Open the file”); 注释只是为了描述函数, 而不是命令函数做什么. 通常, 注释不会描述函数如何工作. 那是函数定义部分的事情.

      函数声明处注释的内容:

      • 函数的输入输出.

      • 对类成员函数而言: 函数调用期间对象是否需要保持引用参数, 是否会释放这些参数.

      • 函数是否分配了必须由调用者释放的空间.

      • 参数是否可以为空指针.

      • 是否存在函数使用上的性能隐患.

      • 如果函数是可重入的, 其同步前提是什么?

        举例如下

        // Returns an iterator for this table.  It is the client's
        // responsibility to delete the iterator when it is done with it,
        // and it must not use the iterator once the GargantuanTable object
        // on which the iterator was created has been deleted.
        //
        // The iterator is initially positioned at the beginning of the table.
        //
        // This method is equivalent to:
        // Iterator* iter = table->NewIterator();
        // iter->Seek("");
        // return iter;
        // If you are going to immediately seek to another place in the
        // returned iterator, it will be faster to use NewIterator()
        // and avoid the extra seek.
        Iterator* GetIterator() const;

        但也要避免罗罗嗦嗦, 或者对显而易见的内容进行说明. 下面的注释就没有必要加上 “否则返回 false”, 因为已经暗含其中了:

        // Returns true if the table cannot hold any more entries.
        bool IsTableFull();

        注释函数重载时, 注释的重点应该是函数中被重载的部分, 而不是简单的重复被重载的函数的注释. 多数情况下, 函数重载不需要额外的文档, 因此也没有必要加上注释.

        注释构造/析构函数时, 切记读代码的人知道构造/析构函数的功能, 所以 “销毁这一对象” 这样的注释是没有意义的. 你应当注明的是注明构造函数对参数做了什么 (例如, 是否取得指针所有权) 以及析构函数清理了什么. 如果都是些无关紧要的内容, 直接省掉注释. 析构函数前没有注释是很正常的.

    • 函数定义

      如果函数的实现过程中用到了很巧妙的方式, 那么在函数定义处应当加上解释性的注释. 例如, 你所使用的编程技巧, 实现的大致步骤, 或解释如此实现的理由. 举个例子, 你可以说明为什么函数的前半部分要加锁而后半部分不需要.

      不要 从.h文件或其他地方的函数声明处直接复制注释. 简要重述函数功能是可以的, 但注释重点要放在如何实现上.

变量注释

  • 总述

通常变量名本身足以很好说明变量用途. 某些情况下, 也需要额外的注释说明.

  • 说明

    • 类数据成员

      每个类数据成员 (也叫实例变量或成员变量) 都应该用注释说明用途. 如果有非变量的参数(例如特殊值, 数据成员之间的关系, 生命周期等)不能够用类型与变量名明确表达, 则应当加上注释. 然而, 如果变量类型与变量名已经足以描述一个变量, 那么就不再需要加上注释.

      特别地, 如果变量可以接受NULL-1等警戒值, 须加以说明. 比如:

      private:
      // Used to bounds-check table accesses. -1 means
      // that we don't yet know how many entries the table has.
      int num_total_entries_;
    • 全局变量

      和数据成员一样, 所有全局变量也要注释说明含义及用途, 以及作为全局变量的原因. 比如:

      // The total number of tests cases that we run through in this regression test.
      const int kNumTestCases = 6;

实现注释

  • 总述

对于代码中巧妙的, 晦涩的, 有趣的, 重要的地方加以注释.

  • 说明

    • 代码前注释

      巧妙或复杂的代码段前要加注释. 比如:

      // Divide result by two, taking into account that x
      // contains the carry from the add.
      for (int i = 0; i < result->size(); i++) {
      x = (x << 8) + (*result)[i];
      (*result)[i] = x >> 1;
      x &= 1;
      }
    • 行注释

      比较隐晦的地方要在行尾加入注释. 在行尾空两格进行注释. 比如:

      // If we have enough memory, mmap the data portion too.
      mmap_budget = max<int64>(0, mmap_budget - index_->length());
      if (mmap_budget >= data_size_ && !MmapData(mmap_chunk_bytes, mlock))
      return; // Error already logged.

      注意, 这里用了两段注释分别描述这段代码的作用, 和提示函数返回时错误已经被记入日志.

    • 函数参数注释

      如果函数参数的意义不明显, 考虑用下面的方式进行弥补:

      • 如果参数是一个字面常量, 并且这一常量在多处函数调用中被使用, 用以推断它们一致, 你应当用一个常量名让这一约定变得更明显, 并且保证这一约定不会被打破.
      • 考虑更改函数的签名, 让某个bool类型的参数变为enum类型, 这样可以让这个参数的值表达其意义.
      • 如果某个函数有多个配置选项, 你可以考虑定义一个类或结构体以保存所有的选项, 并传入类或结构体的实例. 这样的方法有许多优点, 例如这样的选项可以在调用处用变量名引用, 这样就能清晰地表明其意义. 同时也减少了函数参数的数量, 使得函数调用更易读也易写. 除此之外, 以这样的方式, 如果你使用其他的选项, 就无需对调用点进行更改.
      • 用具名变量代替大段而复杂的嵌套表达式.
      • 万不得已时, 才考虑在调用点用注释阐明参数的意义.
    • 不允许的行为

      不要描述显而易见的现象, 永远不要用自然语言翻译代码作为注释, 除非即使对深入理解 C++ 的读者来说代码的行为都是不明显的. 要假设读代码的人 C++ 水平比你高, 即便他/她可能不知道你的用意:

      你所提供的注释应当解释代码为什么要这么做和代码的目的, 或者最好是让代码自文档化.

      比较这样的注释:

      // Find the element in the vector.  <-- 差: 这太明显了!
      auto iter = std::find(v.begin(), v.end(), element);
      if (iter != v.end()) {
      Process(element);
      }

      和这样的注释:

      // Process "element" unless it was already processed.
      auto iter = std::find(v.begin(), v.end(), element);
      if (iter != v.end()) {
      Process(element);
      }

      自文档化的代码根本就不需要注释. 上面例子中的注释对下面的代码来说就是毫无必要的:

      if (!IsAlreadyProcessed(element)) {
      Process(element);
      }

TODO注释

  • 总述

    对那些临时的, 短期的解决方案, 或已经够好但仍不完美的代码使用TODO注释.

    TODO注释要使用全大写的字符串TODO,在随后的圆括号里写上你的名字, 邮件地址, bug ID, 或其它身份标识和与这一TODO相关的 issue. 主要目的是让添加注释的人 (也是可以请求提供更多细节的人) 可根据规范的TODO格式进行查找. 添加TODO注释并不意味着你要自己来修正, 因此当你加上带有姓名的TODO时, 一般都是写上自己的名字.

    // TODO(kl@gmail.com): Use a "*" here for concatenation operator.
    // TODO(Zeke) change this to use relations.
    // TODO(bug 12345): remove the "Last visitors" feature

    如果加TODO是为了在 “将来某一天做某事”, 可以附上一个非常明确的时间 “Fix by November 2005”), 或者一个明确的事项 (“Remove this code when all clients can handle XML responses.”).

小结

  1. 关于注释风格, 很多 C++ 的 coders 更喜欢行注释, C coders 或许对块注释依然情有独钟, 或者在文件头大段大段的注释时使用块注释
  2. 文件注释可以炫耀你的成就, 也是为了捅了篓子别人可以找你
  3. 注释要言简意赅, 不要拖沓冗余, 复杂的东西简单化和简单的东西复杂化都是要被鄙视的
  4. 对于 Chinese coders 来说, 用英文注释还是用中文注释, it is a problem, 但不管怎样, 注释是为了让别人看懂, 难道是为了炫耀编程语言之外的你的母语或外语水平吗
  5. 注释不要太乱, 适当的缩进才会让人乐意看. 但也没有必要规定注释从第几列开始 (我自己写代码的时候总喜欢这样), UNIX/LINUX 下还可以约定是使用 tab 还是 space, 个人倾向于 space
  6. TODO 很不错, 有时候, 注释确实是为了标记一些未完成的或完成的不尽如人意的地方, 这样一搜索, 就知道还有哪些活要干, 日志都省了

格式

行长度

  • 总述

    每一行代码字符数不超过 80.

    我们也认识到这条规则是有争议的, 但很多已有代码都遵照这一规则, 因此我们感觉一致性更重要.

  • 优点

    提倡该原则的人认为强迫他们调整编辑器窗口大小是很野蛮的行为. 很多人同时并排开几个代码窗口, 根本没有多余的空间拉伸窗口. 大家都把窗口最大尺寸加以限定, 并且 80 列宽是传统标准. 那么为什么要改变呢?

  • 缺点

    反对该原则的人则认为更宽的代码行更易阅读. 80 列的限制是上个世纪 60 年代的大型机的古板缺陷; 现代设备具有更宽的显示屏, 可以很轻松地显示更多代码.

  • 结论

    80 个字符是最大值.

    如果无法在不伤害易读性的条件下进行断行, 那么注释行可以超过 80 个字符, 这样可以方便复制粘贴. 例如, 带有命令示例或 URL 的行可以超过 80 个字符.

    包含长路径的#include语句可以超出80列.

非ASCII字符

  • 总述

    尽量不使用非 ASCII 字符, 使用时必须使用UTF-8编码.

  • 说明

    即使是英文, 也不应将用户界面的文本硬编码到源代码中, 因此非 ASCII 字符应当很少被用到. 特殊情况下可以适当包含此类字符. 例如, 代码分析外部数据文件时, 可以适当硬编码数据文件中作为分隔符的非 ASCII 字符串; 更常见的是 (不需要本地化的) 单元测试代码可能包含非 ASCII 字符串. 此类情况下, 应使用 UTF-8 编码, 因为很多工具都可以理解和处理 UTF-8 编码.

空格还是制表位

  • 总述

    只使用空格, 每次缩进2个空格.(如果你所在的项目使用4各空格缩进,那么请保持一致性)

  • 说明

    我们使用空格缩进. 不要在代码中使用制表符. 你应该设置编辑器将制表符转为空格.

函数声明与定义

  • 总述

    返回类型和函数名在同一行, 参数也尽量放在同一行, 如果放不下就对形参分行, 分行方式与函数调用一致.

  • 说明

    函数看上去像这样:

    ReturnType ClassName::FunctionName(Type par_name1, Type par_name2) {
    DoSomething();
    ...
    }

    如果同一行文本太多, 放不下所有参数:

    ReturnType ClassName::ReallyLongFunctionName(Type par_name1, Type par_name2,
    Type par_name3) {
    DoSomething();
    ...
    }

    甚至连第一个参数都放不下:

    ReturnType LongClassName::ReallyReallyReallyLongFunctionName(
    Type par_name1, // 4 space indent
    Type par_name2,
    Type par_name3) {
    DoSomething(); // 2 space indent
    ...
    }

    注意以下几点:

    • 使用好的参数名.

    • 只有在参数未被使用或者其用途非常明显时, 才能省略参数名.

    • 如果返回类型和函数名在一行放不下, 分行.

    • 如果返回类型与函数声明或定义分行了, 不要缩进.

    • 左圆括号总是和函数名在同一行.

    • 函数名和左圆括号间永远没有空格.

    • 圆括号与参数间没有空格.

    • 左大括号{总在最后一个参数同一行的末尾处, 不另起新行.

    • 右大括号总是单独位于函数最后一行, 或者与左大括号同一行.

    • 右圆括号和左大括号间总是有一个空格.

    • 所有形参应尽可能对齐.

    • 缺省缩进为 2 个空格.

    • 换行后的参数保持 4 个空格的缩进.

      未被使用的参数, 或者根据上下文很容易看出其用途的参数, 可以省略参数名:

      class Foo {
      public:
      Foo(Foo&&);
      Foo(const Foo&);
      Foo& operator=(Foo&&);
      Foo& operator=(const Foo&);
      };

      未被使用的参数如果其用途不明显的话, 在函数定义处将参数名注释起来:

      class Shape {
      public:
      virtual void Rotate(double radians) = 0;
      };

      class Circle : public Shape {
      public:
      void Rotate(double radians) override;
      };

      void Circle::Rotate(double /*radians*/) {}
      // 差 - 如果将来有人要实现, 很难猜出变量的作用.
      void Circle::Rotate(double) {}

      属性, 和展开为属性的宏, 写在函数声明或定义的最前面, 即返回类型之前:

      MUST_USE_RESULT bool IsOK();

Lambda表达式

  • 总述

    Lambda 表达式对形参和函数体的格式化和其他函数一致; 捕获列表同理, 表项用逗号隔开.

  • 说明

    若用引用捕获, 在变量名和&之间不留空格.

    int x = 0;
    auto add_to_x = [&x](int n) { x += n; };

    短 lambda 就写得和内联函数一样.

    std::set<int> blacklist = {7, 8, 9};
    std::vector<int> digits = {3, 9, 1, 8, 4, 7, 1};
    digits.erase(std::remove_if(digits.begin(), digits.end(), [&blacklist](int i) {
    return blacklist.find(i) != blacklist.end();
    }),
    digits.end());

函数调用

  • 总述

    要么一行写完函数调用, 要么在圆括号里对参数分行, 要么参数另起一行且缩进四格. 如果没有其它顾虑的话, 尽可能精简行数, 比如把多个参数适当地放在同一行里.

  • 说明

    函数调用遵循如下形式

    bool retval = DoSomething(argument1, argument2, argument3);

    如果同一行放不下, 可断为多行, 后面每一行都和第一个实参对齐, 左圆括号后和右圆括号前不要留空格:

    bool retval = DoSomething(averyveryveryverylongargument1,
    argument2, argument3);

    参数也可以放在次行, 缩进四格:

    if (...) {
    ...
    ...
    if (...) {
    DoSomething(
    argument1, argument2, // 4 空格缩进
    argument3, argument4);
    }

    如果一些参数本身就是略复杂的表达式, 且降低了可读性, 那么可以直接创建临时变量描述该表达式, 并传递给函数:

    int my_heuristic = scores[x] * y + bases[x];
    bool retval = DoSomething(my_heuristic, x, y, z);

    或者放着不管, 补充上注释:

    bool retval = DoSomething(scores[x] * y + bases[x],  // Score heuristic.
    x, y, z);

    如果某参数独立成行, 对可读性更有帮助的话, 那也可以如此做. 参数的格式处理应当以可读性而非其他作为最重要的原则.

    此外, 如果一系列参数本身就有一定的结构, 可以酌情地按其结构来决定参数格式:

    // 通过 3x3 矩阵转换 widget.
    my_widget.Transform(x1, x2, x3,
    y1, y2, y3,
    z1, z2, z3);

列表初始化

如果列表初始化伴随着名字, 比如类型或变量名, 格式化时将将名字视作函数调用名, {} 视作函数调用的括号. 如果没有名字, 就视作名字长度为零.

// 一行列表初始化示范.
return {foo, bar};
functioncall({foo, bar});
pair<int, int> p{foo, bar};

// 当不得不断行时.
SomeFunction(
{"assume a zero-length name before {"}, // 假设在 { 前有长度为零的名字.
some_other_function_parameter);
SomeType variable{
some, other, values,
{"assume a zero-length name before {"}, // 假设在 { 前有长度为零的名字.
SomeOtherType{
"Very long string requiring the surrounding breaks.", // 非常长的字符串, 前后都需要断行.
some, other values},
SomeOtherType{"Slightly shorter string", // 稍短的字符串.
some, other, values}};
SomeType variable{
"This is too long to fit all in one line"}; // 字符串过长, 因此无法放在同一行.
MyType m = { // 注意了, 您可以在 { 前断行.
superlongvariablename1,
superlongvariablename2,
{short, interior, list},
{interiorwrappinglist,
interiorwrappinglist2}};

条件语句

  • 总述

    倾向于不在圆括号内使用空格. 关键字ifelse另起一行.

  • 说明

    对基本条件语句有两种可以接受的格式. 一种在圆括号和条件之间有空格, 另一种没有.

    最常见的是没有空格的格式. 哪一种都可以, 最重要的是保持一致. 如果你是在修改一个文件, 参考当前已有格式. 如果是写新的代码, 参考目录下或项目中其它文件. 还在犹豫的话, 就不要加空格了.

    if (condition) {  // 圆括号里没有空格.
    ... // 2 空格缩进.
    } else if (...) { // else 与 if 的右括号同一行.
    ...
    } else {
    ...
    }

    注意所有情况下if和左圆括号间都有个空格. 右圆括号和左大括号之间也要有个空格:

    if(condition)     // 差 - IF 后面没空格.
    if (condition){ // 差 - { 前面没空格.
    if(condition){ // 变本加厉地差.
    if (condition) { // 好 - IF 和 { 都与空格紧邻.

    如果能增强可读性, 简短的条件语句允许写在同一行. 只有当语句简单并且没有使用else子句时使用:

    if (x == kFoo) return new Foo();
    if (x == kBar) return new Bar();

    如果语句有else分支则不允许:

    // 不允许 - 当有 ELSE 分支时 IF 块却写在同一行
    if (x) DoThis();
    else DoThat();

    通常, 单行语句不需要使用大括号, 如果你喜欢用也没问题; 复杂的条件或循环语句用大括号可读性会更好. 也有一些项目要求if必须总是使用大括号:

    if (condition)
    DoSomething(); // 2 空格缩进.

    if (condition) {
    DoSomething(); // 2 空格缩进.
    }

    但如果语句中某个if-else分支使用了大括号的话, 其它分支也必须使用:

    // 不可以这样子 - IF 有大括号 ELSE 却没有.
    if (condition) {
    foo;
    } else
    bar;

    // 不可以这样子 - ELSE 有大括号 IF 却没有.
    if (condition)
    foo;
    else {
    bar;
    }
    // 只要其中一个分支用了大括号, 两个分支都要用上大括号.
    if (condition) {
    foo;
    } else {
    bar;
    }

循环和开关选择语句

  • 总述

    switch语句可以使用大括号分段, 以表明 cases 之间不是连在一起的. 在单语句循环里, 括号可用可不用. 空循环体应使用{}continue.

  • 说明

    switch语句中的case块可以使用大括号也可以不用, 取决于你的个人喜好. 如果用的话, 要按照下文所述的方法.

    如果有不满足case条件的枚举值, switch应该总是包含一个default匹配 (如果有输入值没有 case 去处理, 编译器将给出 warning). 如果default应该永远执行不到, 简单的加条 assert:

    switch (var) {
    case 0: { // 2 空格缩进
    ... // 4 空格缩进
    break;
    }
    case 1: {
    ...
    break;
    }
    default: {
    assert(false);
    }
    }

    在单语句循环里, 括号可用可不用:

    for (int i = 0; i < kSomeNumber; ++i)
    printf("I love you\n");

    for (int i = 0; i < kSomeNumber; ++i) {
    printf("I take it back\n");
    }

    空循环体应使用{}continue, 而不是一个简单的分号.

    while (condition) {
    // 反复循环直到条件失效.
    }
    for (int i = 0; i < kSomeNumber; ++i) {} // 可 - 空循环体.
    while (condition) continue; // 可 - contunue 表明没有逻辑.

指针和引用表达式

  • 总述

    句点或箭头前后不要有空格. 指针/地址操作符 (*, &) 之后不能有空格.

  • 说明

    下面是指针和引用表达式的正确使用范例:

    x = *p;
    p = &x;
    x = r.y;
    x = r->y;

    注意

    • 在访问成员时, 句点或箭头前后没有空格.

    • 指针操作符 * 或 & 后没有空格.

      在声明指针变量或参数时, 星号与类型或变量名紧挨都可以:

      // 好, 空格前置.
      char *c;
      const string &str;

      // 好, 空格后置.
      char* c;
      const string& str;
      int x, *y;  // 不允许 - 在多重声明中不能使用 & 或 *
      char * c; // 差 - * 两边都有空格
      const string & str; // 差 - & 两边都有空格.

      在单个文件内要保持风格一致, 所以, 如果是修改现有文件, 要遵照该文件的风格.

布尔表达式

  • 总述

    如果一个布尔表达式超过标准行宽, 断行方式要统一一下.

  • 说明

    下例中, 逻辑与 (&&) 操作符总位于行尾:

    if (this_one_thing > this_other_thing &&
    a_third_thing == a_fourth_thing &&
    yet_another && last_one) {
    ...
    }

函数返回值

  • 总述

    不要在return表达式里加上非必须的圆括号.

  • 说明

    只有在写x = expr要加上括号的时候才在return expr;里使用括号.

    return result;                  // 返回值很简单, 没有圆括号.
    // 可以用圆括号把复杂表达式圈起来, 改善可读性.
    return (some_long_condition &&
    another_condition);

变量及数组初始化

  • 总述

    =,(){}均可.

  • 说明

    您可以用=,(){}, 以下的例子都是正确的:

    int x = 3;
    int x(3);
    int x{3};
    string name("Some Name");
    string name = "Some Name";
    string name{"Some Name"};

    请务必小心列表初始化{...}std::initializer_list构造函数初始化出的类型. 非空列表初始化就会优先调用std::initializer_list, 不过空列表初始化除外, 后者原则上会调用默认构造函数. 为了强制禁用std::initializer_list构造函数, 请改用括号.

    vector<int> v(100, 1);  // 内容为 100 个 1 的向量.
    vector<int> v{100, 1}; // 内容为 100 和 1 的向量.

    此外, 列表初始化不允许整型类型的四舍五入, 这可以用来避免一些类型上的编程失误.

    int pi(3.14);  // 好 - pi == 3.
    int pi{3.14}; // 编译错误: 缩窄转换.

预处理指令

  • 总述

    预处理指令不要缩进, 从行首开始.

  • 说明

    即使预处理指令位于缩进代码块中, 指令也应从行首开始.

    // 好 - 指令从行首开始
    if (lopsided_score) {
    #if DISASTER_PENDING // 正确 - 从行首开始
    DropEverything();
    # if NOTIFY // 非必要 - # 后跟空格
    NotifyClient();
    # endif
    #endif
    BackToNormal();
    }

构造函数初始值列表

  • 总述

    构造函数初始化列表放在同一行或按四格缩进并排多行.

  • 说明

    下面两种初始值列表方式都可以接受:

    // 如果所有变量能放在同一行:
    MyClass::MyClass(int var) : some_var_(var) {
    DoSomething();
    }

    // 如果不能放在同一行,
    // 必须置于冒号后, 并缩进 4 个空格
    MyClass::MyClass(int var)
    : some_var_(var), some_other_var_(var + 1) {
    DoSomething();
    }

    // 如果初始化列表需要置于多行, 将每一个成员放在单独的一行
    // 并逐行对齐
    MyClass::MyClass(int var)
    : some_var_(var), // 4 space indent
    some_other_var_(var + 1) { // lined up
    DoSomething();
    }

    // 右大括号 } 可以和左大括号 { 放在同一行
    // 如果这样做合适的话
    MyClass::MyClass(int var)
    : some_var_(var) {}

命名空间格式化

  • 总述

    命名空间内容不缩进.

  • 说明

    命名空间 不要增加额外的缩进层次, 例如:

    namespace {

    void foo() { // 正确. 命名空间内没有额外的缩进.
    ...
    }

    } // namespace

    不要在命名空间内缩进:

    namespace {

    // 错, 缩进多余了.
    void foo() {
    ...
    }

    } // namespace

    声明嵌套命名空间时, 每个命名空间都独立成行.

    namespace foo {
    namespace bar {

水平留白

  • 总述

    水平留白的使用根据在代码中的位置决定. 永远不要在行尾添加没意义的留白.

  • 说明

    • 通用

      void f(bool b) {  // 左大括号前总是有空格.
      ...
      int i = 0; // 分号前不加空格.
      // 列表初始化中大括号内的空格是可选的.
      // 如果加了空格, 那么两边都要加上.
      int x[] = { 0 };
      int x[] = {0};

      // 继承与初始化列表中的冒号前后恒有空格.
      class Foo : public Bar {
      public:
      // 对于单行函数的实现, 在大括号内加上空格
      // 然后是函数实现
      Foo(int b) : Bar(), baz_(b) {} // 大括号里面是空的话, 不加空格.
      void Reset() { baz_ = 0; } // 用括号把大括号与实现分开.
      ...

      添加冗余的留白会给其他人编辑时造成额外负担. 因此, 行尾不要留空格. 如果确定一行代码已经修改完毕, 将多余的空格去掉; 或者在专门清理空格时去掉(尤其是在没有其他人在处理这件事的时候).

    • 循环和条件语句

      if (b) {          // if 条件语句和循环语句关键字后均有空格.
      } else { // else 前后有空格.
      }
      while (test) {} // 圆括号内部不紧邻空格.
      switch (i) {
      for (int i = 0; i < 5; ++i) {
      switch ( i ) { // 循环和条件语句的圆括号里可以与空格紧邻.
      if ( test ) { // 圆括号, 但这很少见. 总之要一致.
      for ( int i = 0; i < 5; ++i ) {
      for ( ; i < 5 ; ++i) { // 循环里内 ; 后恒有空格, ; 前可以加个空格.
      switch (i) {
      case 1: // switch case 的冒号前无空格.
      ...
      case 2: break; // 如果冒号有代码, 加个空格.
    • 操作符

      // 赋值运算符前后总是有空格.
      x = 0;

      // 其它二元操作符也前后恒有空格, 不过对于表达式的子式可以不加空格.
      // 圆括号内部没有紧邻空格.
      v = w * x + y / z;
      v = w*x + y/z;
      v = w * (x + z);

      // 在参数和一元操作符之间不加空格.
      x = -5;
      ++x;
      if (x && !y)
      ...
    • 模板和转换

      // 尖括号(< and >) 不与空格紧邻, < 前没有空格, > 和 ( 之间也没有.
      vector<string> x;
      y = static_cast<char*>(x);

      // 在类型与指针操作符之间留空格也可以, 但要保持一致.
      vector<char *> x;

垂直留白

  • 总述

    垂直留白越少越好.

  • 说明

    这不仅仅是规则而是原则问题了: 不在万不得已, 不要使用空行. 尤其是: 两个函数定义之间的空行不要超过 2 行, 函数体首尾不要留空行, 函数体中也不要随意添加空行.

    基本原则是: 同一屏可以显示的代码越多, 越容易理解程序的控制流. 当然, 过于密集的代码块和过于疏松的代码块同样难看, 这取决于你的判断. 但通常是垂直留白越少越好.

    下面的规则可以让加入的空行更有效:

    • 函数体内开头或结尾的空行可读性微乎其微.
    • 在多重 if-else 块里加空行或许有点可读性.

总结

  • 对于代码格式, 因人, 系统而异各有优缺点, 但同一个项目中遵循同一标准还是有必要的;
  • 行宽原则上不超过 80 列, 把 22 寸的显示屏都占完, 怎么也说不过去;
  • 80 行限制事实上有助于避免代码可读性失控, 比如超多重嵌套块, 超多重函数调用等等.
  • 尽量不使用非 ASCII 字符, 如果使用的话, 参考 UTF-8 格式 (尤其是 UNIX/Linux 下, Windows 下可以考虑宽字符), 尽量不将字符串常量耦合到代码中, 比如独立出资源文件, 这不仅仅是风格问题了;
  • UNIX/Linux 下无条件使用空格, MSVC 的话使用 Tab 也无可厚非;
  • 函数参数, 逻辑条件, 初始化列表: 要么所有参数和函数名放在同一行, 要么所有参数并排分行;
  • 除函数定义的左大括号可以置于行首外, 包括函数/类/结构体/枚举声明, 各种语句的左大括号置于行尾, 所有右大括号独立成行;
  • Google 强调有一对 if-else 时, 不论有没有嵌套, 都要有大括号
  • ./-> 操作符前后不留空格, */& 不要前后都留, 一个就可, 靠左靠右依各人喜好;
  • 预处理指令/命名空间不使用额外缩进, 类/结构体/枚举/函数/语句使用缩进;
  • 初始化用=还是()依个人喜好, 统一就好;
  • return不要加();
  • 水平/垂直留白不要滥用, 怎么易读怎么来.