Google C++ Style文档及常用代码规范(二):头文件、作用域、类、函数

英文原版:https://google.github.io/styleguide/cppguide.html
GitHub仓库:https://github.com/google/styleguide

中文翻译:https://zh-google-styleguide.readthedocs.io/en/latest/
GitHub仓库:https://github.com/zh-google-styleguide/zh-google-styleguide

以下是从中文翻译版中截取了一部分常用内容,并附上了原文链接:

头文件

#define保护

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/headers/#define

所有头文件都应该有 #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_

如果启用了AI代码补全插件,例如Copilot,插件会根据头文件开头的 #ifndef 自动补全后面的文件路径

#include 的路径及顺序

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/headers/#include

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

#include "base/logging.h"

又如, dir/foo.ccdir/foo_test.cc 的主要作用是实现或测试 dir2/foo2.h 的功能, foo.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"

例外:

有时,平台特定(system-specific)代码需要条件编译(conditional includes),这些代码可以放到其它 includes 之后。当然,您的平台特定代码也要够简练且独立,比如:

#include "foo/public/fooserver.h"

#include "base/port.h"  // For LANG_CXX11.

#ifdef LANG_CXX11
#include <initializer_list>
#endif  // LANG_CXX11

内联函数

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/headers/#section-3

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

另一个实用的经验准则: 内联那些包含循环或 switch 语句的函数常常是得不偿失 (除非在大多数情况下, 这些循环或 switch 语句从不被执行).

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

作用域

命名空间

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/scoping/#section-2

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

用命名空间把文件包含 gflags 的声明/定义, 以及类的前置声明以外的整个源文件封装起来, 以区别于其它命名空间,在命名空间的最后注释出命名空间的名字。

// .h 文件
namespace mynamespace {

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

} // namespace mynamespace

// .cc 文件
namespace mynamespace {

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

} // namespace mynamespace

更复杂的 .cc 文件包含更多, 更复杂的细节, 比如 gflags 或 using 声明。

#include "a.h"

DEFINE_FLAG(bool, someflag, false, "dummy flag");

namespace a {

...code for a...                // 左对齐

} // namespace a

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

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

// 禁止 —— 污染命名空间
using namespace foo;

不要在头文件中使用 命名空间别名 除非显式标记内部命名空间使用。因为任何在头文件中引入的命名空间都会成为公开API的一部分。

// 在 .cc 中使用别名缩短常用的命名空间
namespace baz = ::foo::bar::baz;
// 在 .h 中使用别名缩短常用的命名空间
namespace librarian {
namespace impl {  // 仅限内部使用
namespace sidetable = ::pipeline_diagnostics::sidetable;
}  // namespace impl

inline void my_inline_function() {
  // 限制在一个函数中的命名空间别名
  namespace baz = ::foo::bar::baz;
  ...
}
}  // namespace librarian

禁止用内联命名空间

匿名命名空间和静态变量

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/scoping/#section-3

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

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

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

namespace {
...
}  // namespace

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

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/scoping/#section-4

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

局部变量

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/scoping/#section-5

提倡在尽可能小的作用域中声明变量, 离第一次使用越近越好. 这使得代码浏览者更容易定位变量声明的位置, 了解变量的类型和初始值. 特别是,应使用初始化的方式替代声明再赋值, 比如:

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, while 和 for 语句的变量应当在这些语句中正常地声明,这样子这些变量的作用域就被限制在这些语句中了,举例而言:

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);
}

静态和全局变量

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/scoping/#section-6

禁止使用类的 静态储存周期 变量:由于构造和析构函数调用顺序的不确定性,它们会导致难以发现的 bug 。不过 constexpr 变量除外,毕竟它们又不涉及动态初始化或析构。

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

如果您确实需要一个 class 类型的静态或全局变量,可以考虑在 main() 函数或 pthread_once() 内初始化一个指针且永不回收。注意只能用 raw 指针,别用智能指针,毕竟后者的析构函数涉及到上文指出的不定顺序问题。

结构体 VS. 类

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#vs

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

继承

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-5

使用组合 (YuleFox 注: 这一点也是 GoF 在《Design Patterns》里反复强调的) 常常比使用继承更合理. 如果使用继承的话, 定义为 public 继承.

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

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

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

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

多重继承

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-6

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

接口

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-7

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

  • 只有纯虚函数 (”=0”) 和静态函数 (除了下文提到的析构函数).
  • 没有非静态数据成员.
  • 没有定义任何构造函数. 如果有, 也不能带有参数, 并且必须为 protected.
  • 如果它是一个子类, 也只能从满足上述条件并以 Interface 为后缀的类继承.

接口类不能被直接实例化, 因为它声明了纯虚函数. 为确保接口类的所有实现可被正确销毁, 必须为之声明虚析构函数 (作为上述第 1 条规则的特例, 析构函数不能是纯虚函数). 具体细节可参考 Stroustrup 的 The C++ Programming Language, 3rd edition 第 12.4 节.

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

存取控制

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-9

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

声明顺序

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-10

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

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

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

构造函数的职责

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-2

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

隐式类型转换

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-3

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

一个例外是, 拷贝和移动构造函数不应当被标记为 explicit, 因为它们并不执行类型转换. 对于设计目的就是用于对其他类型进行透明包装的类来说, 隐式类型转换有时是必要且合适的. 这时应当联系项目组长并说明特殊情况.

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

可拷贝类型和可移动类型

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/classes/#section-4

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

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

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;

函数

函数返回类型后置语法

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/functions/#section-7

只有在常规写法 (返回类型前置) 不便于书写或不便于阅读时使用返回类型后置语法.

C++ 现在允许两种不同的函数声明方式. 以往的写法是将返回类型置于函数名之前. 例如:

int foo(int x);

C++11 引入了这一新的形式. 现在可以在函数名前使用 auto 关键字, 在参数列表之后后置返回类型. 例如:

auto foo(int x) -> int;

在必需的时候 (如 Lambda 表达式) 或者使用后置语法能够简化书写并且提高易读性的时候才使用新的返回类型后置语法

输入和输出

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/functions/#section-2

我们倾向于按值返回, 否则按引用返回避免返回指针, 除非它可以为空.

C++ 函数由返回值提供天然的输出, 有时也通过输出参数(或输入/输出参数)提供. 我们倾向于使用返回值而不是输出参数: 它们提高了可读性, 并且通常提供相同或更好的性能.

C/C++ 中的函数参数或者是函数的输入, 或者是函数的输出, 或兼而有之.
非可选输入参数通常是值参const 引用
非可选输出参数输入/输出参数通常应该是引用 (不能为空).
对于可选的参数, 通常使用 std::optional 来表示可选的按值输入
使用 const 指针来表示可选的其他输入
使用非常量指针来表示可选输出和可选输入/输出参数

引用参数

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/functions/#section-4

函数参数列表中,所有按引用传递的参数必须加上 const

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

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

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

  • 可能会传递空指针.
  • 函数要把指针或对地址的引用赋值给输入形参.

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

缺省参数

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/functions/#section-6

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

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

由于缺点并不是很严重,有些人依旧偏爱缺省参数胜于函数重载。所以除了以下情况,我们要求必须显式提供所有参数(acgtyrant 注:即不能再通过缺省参数来省略参数了)。

其一,位于 .cc 文件里的静态函数或匿名空间函数,毕竟都只能在局部文件里调用该函数了。

其二,可以在构造函数里用缺省参数,毕竟不可能取得它们的地址。

其三,可以用来模拟变长数组。

// 通过空 AlphaNum 以支持四个形参
string StrCat(const AlphaNum &a,
              const AlphaNum &b = gEmptyAlphaNum,
              const AlphaNum &c = gEmptyAlphaNum,
              const AlphaNum &d = gEmptyAlphaNum);

优点

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

缺点

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

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

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

缺省参数会干扰函数指针, 导致函数签名与调用点的签名不一致,即在一个现有函数添加缺省参数,就会改变它的类型,那么调用其地址的代码可能会出错,而函数重载不会导致这样的问题.

编写简短函数

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/functions/#section-3

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

06-20 22:53