1. 头文件

通常每个 .cc 文件应该有一个配套的 .h 文件. 常见的例外情况包括单元测试和仅有 main() 函数的 .cc 文件.

正确使用头文件会大大改善代码的可读性和执行文件的大小、性能.

下面的规则将带你规避头文件的各种误区.

1.1. 自给自足的头文件

Tip

头文件应该自给自足 (self-contained, 也就是可以独立编译), 并以 .h 为扩展名. 给需要被导入 (include) 但不属于头文件的文件设置为 .inc 扩展名, 并尽量避免使用.

所有头文件应该自给自足, 也就是头文件的使用者和重构工具在导入文件时无需任何特殊的前提条件. 具体来说, 头文件要有头文件防护符 (header guards, 1.2. #define 防护符),并导入它所需的所有其它头文件.

若头文件声明了内联函数 (inline function) 或模版 (template), 而且头文件的使用者需要实例化 (instantiate) 这些组件时, 头文件必须直接或通过导入的文件间接提供这些组件的实现 (definition). 不要把这些实现放到另一个头文件里 (例如 -inl.h 文件) 再导入进来; 这是过去的惯例, 但现在被禁止了. 若模版的所有实例化过程都仅出现在一个 .cc 文件中, 比如采用显式 (explicit) 实例化, 或者只有这个 .cc 文件会用到模版定义 (definition), 那么可以把模版的定义放在这个文件里.

在少数情况下, 用于导入的文件不能自给自足. 它们通常是要在特殊的地方导入, 例如另一个文件中间的某处. 此类文件不需要使用头文件防护符, 也不需要导入它的依赖 (prerequisite). 此类文件应该使用 .inc 扩展名. 尽量少用这种文件, 可行时应该采用自给自足的头文件.

1.2. #define 防护符

Tip

所有头文件都应该用 #define 防护符来防止重复导入. 防护符的格式是: <项目>_<路径>_<文件名>_H_ .

为了保证符号的唯一性, 防护符的名称应该基于该文件在项目目录中的完整文件路径. 例如, foo 项目中的文件 foo/src/bar/baz.h 应该有如下防护:

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

1.3. 导入你的依赖

Tip

若代码文件或头文件引用了其他地方定义的符号 (symbol), 该文件应该直接导入 (include) 提供该符号的声明 (declaration) 或者定义 (definition) 的头文件. 不应该为了其他原因而导入头文件.

不要依赖间接导入. 这样, 人们删除不再需要的 #include 语句时, 才不会影响使用者. 此规则也适用于配套的文件: 若 foo.cc 使用了 bar.h 的符号, 就需要导入 bar.h, 即使 foo.h 已经导入了 bar.h.

1.4. 前向声明

Tip

尽量避免使用前向声明. 应该 导入你所需的头文件

定义:

前向声明 (forward declaration) 是没有对应定义 (definition) 的声明.

// 在 C++ 源码文件中:
class B;
void FuncInB();
extern int variable_in_b;
ABSL_DECLARE_FLAG(flag_in_b);

优点:

  • 使用前向声明能节约编译时间, 因为 #include 会迫使编译器打开更多的文件并处理更多的输入.

  • 使用前向声明能避免不必要的重复编译. 若使用 #include, 头文件中无关的改动也会触发重新编译.

缺点:

  • 前向声明隐藏了依赖关系, 可能会让人忽略头文件变化后必要的重新编译过程.

  • 相比 #include, 前向声明的存在会让自动化工具难以发现定义该符号的模块.

  • 修改库 (library) 时可能破坏前向声明. 函数或模板的前向声明会阻碍头文件的负责人修改 API, 例如拓宽 (widening) 参数类型, 为模版参数添加默认值, 迁移到新的命名空间等等, 而这些操作本是无碍的.

  • std:: 命名空间的符号提供前向声明会产生未定义行为 (undefined behavior).

  • 很难判断什么时候该用前向声明, 什么时候该用 #include . 用前向声明代替 #include 时, 可能会悄然改变代码的含义:

    // 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); }  // 调用 f(B*)
    

若用 BD 的前向声明替代 #include, test() 会调用 f(void*) .

  • 为多个符号添加前向声明比直接 #include 更冗长.

  • 为兼容前向声明而设计的代码 (比如用指针成员代替对象成员) 更慢更复杂.

结论:

尽量避免为其他项目定义的实体提供前向声明.

1.5. 内联函数

Tip

只把 10 行以下的小函数定义为内联 (inline).

定义:

你可以通过声明让编译器展开内联函数, 而不是使用正常的函数调用机制.

优点:

只要内联函数体积较小, 内联函数可以令目标代码 (object code) 更加高效. 我们鼓励对存取函数 (accessors)、变异函数 (mutators) 和其它短小且影响性能的函数使用内联展开.

缺点:

滥用内联将拖慢程序. 根据函数体积, 内联可能会增加或减少代码体积. 通常, 内联展开非常短小的存取函数会减少代码大小, 但内联一个巨大的函数将显著增加代码大小. 在现代处理器上, 通常代码越小执行越快, 因为指令缓存利用率高.

结论:

合理的经验法则是不要内联超过 10 行的函数. 谨慎对待析构函数. 析构函数往往比表面上更长, 因为会暗中调用成员和基类的析构函数!

另一个实用的经验准则: 内联那些有循环或 switch 语句的函数通常得不偿失 (除非这些循环或 switch 语句通常不执行).

应该注意, 即使函数被声明为内联函数, 也不一定真的会被内联; 比如, 通常虚函数和递归函数不会被内联. 通常不应该声明递归函数为内联. (YuleFox 注: 递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的, 大多数编译器都不支持内联递归函数). 为虚函数声明内联的主要目的是在类 (class) 中定义该函数, 以便于使用该函数或注释其行为. 这常用于存取函数和变异函数.

1.6. #include 的路径及顺序

Tip

推荐按照以下顺序导入头文件: 配套的头文件, C 语言系统库头文件, C++ 标准库头文件, 其他库的头文件, 本项目的头文件.

头文件的路径应相对于项目源码目录, 不能出现 UNIX 目录别名 (alias) . (当前目录) 或 .. (上级目录). 例如, 应该按如下方式导入 google-awesome-project/src/base/logging.h:

#include "base/logging.h"

dir/foo.ccdir/foo_test.cc 这两个实现或测试 dir2/foo2.h 内容的文件中, 按如下顺序导入头文件:

  1. dir2/foo2.h.

  2. 空行

  3. C 语言系统文件 (确切地说: 用使用方括号和 .h 扩展名的头文件), 例如 <unistd.h><stdlib.h>.

  4. 空行

  5. C++ 标准库头文件 (不含扩展名), 例如 <algorithm><cstddef>.

  6. 空行

  7. 其他库的 .h 文件.

  8. 空行

  9. 本项目的 .h 文件.

每个非空的分组之间用空行隔开.

这种顺序可以确保在 dir2/foo2.h 缺少必要的导入时, 构建 (build) dir/foo.ccdir/foo_test.cc 会失败. 这样维护这些文件的人会首先发现构建失败, 而不是维护其他库的无辜的人.

dir/foo.ccdir2/foo2.h 通常位于同一目录下 (如 base/basictypes_unittest.ccbase/basictypes.h), 但有时也放在不同目录下.

注意 C 语言头文件 (如 stddef.h) 和对应的 C++ 头文件 (cstddef) 是等效的. 两种风格都可以接受, 但是最好和现有代码保持一致.

每个分组内部的导入语句应该按字母序排列. 注意旧代码可能没有遵守这条规则, 应该在方便时进行修正.

举例来说, google-awesome-project/src/foo/internal/fooserver.cc 的导入语句如下:

#include "foo/server/fooserver.h"

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

#include <string>
#include <vector>

#include "base/basictypes.h"
#include "foo/server/bar.h"
#include "third_party/absl/flags/flag.h"

例外:

有时平台相关的 (system-specific) 代码需要有条件地导入 (conditional include),此时可以在其他导入语句后放置条件导入语句. 当然, 尽量保持平台相关的代码简洁且影响范围小. 例如:

#include "foo/public/fooserver.h"

#include "base/port.h"  // 为了 LANG_CXX11.

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

译者 (YuleFox) 笔记

  1. 避免多重包含是学编程时最基本的要求;

  2. 前置声明是为了降低编译依赖,防止修改一个头文件引发多米诺效应;

  3. 内联函数的合理使用可提高代码执行效率;

  4. -inl.h 可提高代码可读性 (一般用不到吧:D);

  5. 标准化函数参数顺序可以提高可读性和易维护性 (对函数参数的堆栈空间有轻微影响, 我以前大多是相同类型放在一起);

  6. 包含文件的名称使用 ... 虽然方便却易混乱, 使用比较完整的项目路径看上去很清晰, 很条理, 包含文件的次序除了美观之外, 最重要的是可以减少隐藏依赖, 使每个头文件在 “最需要编译” (对应源文件处 :D) 的地方编译, 有人提出库文件放在最后, 这样出错先是项目内的文件, 头文件都放在对应源文件的最前面, 这一点足以保证内部错误的及时发现了.

译者(acgtyrant)笔记

  1. 原来还真有项目用 #includes 来插入文本,且其文件扩展名 .inc 看上去也很科学。

  2. Google 已经不再提倡 -inl.h 用法。

  3. 注意,前置声明的类是不完全类型(incomplete type),我们只能定义指向该类型的指针或引用,或者声明(但不能定义)以不完全类型作为参数或者返回类型的函数。毕竟编译器不知道不完全类型的定义,我们不能创建其类的任何对象,也不能声明成类内部的数据成员。

  4. 类内部的函数一般会自动内联。所以某函数一旦不需要内联,其定义就不要再放在头文件里,而是放到对应的 .cc 文件里。这样可以保持头文件的类相当精炼,也很好地贯彻了声明与定义分离的原则。

  5. #include 中插入空行以分割相关头文件, C 库, C++ 库, 其他库的 .h 和本项目内的 .h 是个好习惯。