C++ header-only

原文地址

1. C++ 中提供某个库,往往有三种方法

  • 头文件(.h) + 静态库(.a)
  • 头文件(.h) + 动态库(.so)
  • 头文件(.h) + 源代码(.cpp)

这三种方法的共同的缺点是,函数的原型在头文件和实现(静态库、动态库、源代码)中都会出现,因此每次对函数原型进行修改时,都需要手工维护头文件和实现文件中函数原型的一致性,这挺麻烦的,增加了程序员的工作负担。更严重的是,在某些情况下,开发者可能因疏忽让头文件和实现中的函数原型不一致,而这种错误不一定会被编译器识别出来,这会隐藏一些很严重的 bug。

2. 直接将函数的定义放到头文件中的问题

直接将函数的定义放到头文件中是不行,会出现编译错误。比如,假设我们有一个提供数学函数的库,直接将函数放在头文件 math.h 中,项目中有两个源文件 main.cpp 和 foo.cpp 都用到 math.h,如下图:

由于 include 预处理,实际做的是将文件的内容塞到当前文件中,因此预处理之后 main.cpp 和 foo.cpp 中都会有 int add(int l, int r) 的定义。这在编译时中不会出问题,但是在链接的过程中,链接器会发现 main.o 和 foo.o 两个目标文件中都有 add() 函数的符号,因此会函数重定义的错误。

如果想通过头文件来提供库, 必须避免多个源文件使用该头文件时,出现函数重定义的错误。

3. 方法 1 将函数声明为内联函数(inline function)

这其实是 C++ STL 标准库所采用的方法。我们知道 C++ STL 其实只有一堆头文件,没有 cpp 或者 cc 文件,使用的时候只需要 include 相关头文件即可。

函数调用在执行时,首先要在栈中为形参和局部变量分配存储空间,然后还要将实参的值复制给形参,接下来还要将函数的返回地址(该地址指明了函数执行结束后,程序应该回到哪里继续执行)放入栈中,最后才跳转到函数内部执行,这个过程是要耗费时间的。另外,函数执行 return 语句时,需要从栈中回收形参和局部变量占用的存储空间,然后从栈中取出返回地址,在跳转到该地址执行,这个过程也要耗费时间。

这部分的详细过程可参考 函数的调用过程

由于内联函数在编译的时候,会将函数调用的地方直接展开:当编译器处理调用内联函数的语句时,不会将该语句翻译成函数调用的指令,而是直接将整个函数体的代码插入调用语句处,就像整个函数体在调用处被重写了一样。所以编译之后实际上不会真实存在这个函数,就不会出现上述的函数重定义问题。

这里有三点需要注意:

  1. 因为每个函数调用的地方都会展开,因此如果函数在很多地方被调用,编译后的文件会变得比较大。这是以空间换时间的做法。
  2. 调用内联函数的语句前必须已经出现内联函数的定义,而不能只出现内联函数的声明。
  3. 内联只是对编译器的“建议”,编译器会根据实际情况来决定是否对函数进行展开,当函数非常复杂的时候,可能会将函数当作一个非内联函数进行编译。那这种情况下,是否会导致上述函数重定义的错误呢?根据 wikipedia 上的说法(In C++, a function defined inline will, if required, emit a function shared among translation units, typically by putting it into the common section of the object file for which it is needed),如果函数没法内联编译,会生成一个函数供所有的编译单元(translation units,也即源文件)一起使用。因此也不会出现函数重定义的错误。

4. 方法 2 将函数声明成静态函数(static function)

C++中函数默认都是 extern 的,即可以被其他编译单元使用的。C++ 不允许多个编译单元中出现相同的函数,否则链接器不知道应该链接哪一个编译单元中的函数。

而如果将函数声明成 static 的,则限定了函数只能在本编译单元中使用。C++ 允许不同的编译单元中有相同的 static 函数或者 static 全局变量,因为这些函数和全局变量,只在自己的编译单元中使用,在链接的时候不会造成冲突。

5. 方法 3 将函数的实现放到类的定义

这个方法本质上和方法 1 是一样的,在类里面定义的函数,如果不包含循环等控制结构,就是内联函数。

这个方法的缺点是调用的时候比较麻烦,需要先声明一个类的对象,然后使用这个对象来调用函数。

当然也可以将函数声明成类的静态成员函数,这样就可以用 类::函数名 的方式来调用。

6. 方法 4 使用匿名空间(anonymous namespace)

C++ 的命名空间可以解决重名函数、全局变量之间的冲突问题。将相同的函数、全局变量放在不同的命名空间中,就不会出现冲突问题了。名空间使用 namespace xxx {}; 的方式申明。

同时 C++ 也支持匿名空间,即在声明时不指定空间的名称:namespace {}; 对于匿名空间,C++ 在编译的时候会为其生成一个全局唯一的空间名称。

因此将函数置于匿名空间中,相当于为其声明了一个全局唯一的空间名称,这样同样避免了链接时函数重定义的错误。

7. 更多待补充

C++ 类定义 header only 是一种值得推崇的做法吗?