Non-Profit, International

Spirit unsterblich.

C++ 多文件编译注意事项

字数统计:1577 blog

C/C++ 从设计之初就支持模块化编译,通过模块化编译,代码可以被更灵活的组合和复用,提高效率。但是由于实际需求和历史原因,导致使用模块化编译的过程中会出现一些问题,本文旨在从编译原理来介绍如何正确的使用模块化编译以及将代码模块化。

C/C++ 从语言设计角度来讲,从源代码变为二进制文件有三个阶段:预处理,编译,链接。

预处理阶段结束后,代码还是以二进制形式表现的,经过编译阶段,代码会成为一个个松散的二进制文件,由链接器将这些松散二进制文件拼接在一起产生可执行文件。

预处理指令

预处理指令实际上是在真正的编译之前对于当前文件做的一个预备操作,任何一个 C/C++ 程序都或多或少的使用预处理指令,但是预处理指令也需要合理使用。

预处理指令仅限于当前文件,并且是简单并且机械的替换文本,因此存在一些缺陷。

define

宏可能是 C 程序中最常用的预处理指令之一,但是 C++ 中并不提倡使用宏定义常量或者函数。

  1. 宏在出生时似乎并没有考虑 // 开头的注释,所以你不应在宏的同一行使用类似的注释,即使某些编译器可以“很智能”的纠正这个错误。
  2. 宏仅仅是简单的文本替换,所以宏定义的“常量”很可能会带来不明确的类型转换。
  3. C++ 里可以使用 const 关键字定义常量,舍弃了 C 中使用宏定义常量的陋习,虽然这种情况一般用于声明数组维度。
  4. 宏定义的函数参数很可能在使用时发生未定义行为,例如 #define max(a,b) (a > b ? a : b),在替换过程中 a 和 b 会按原样复制,如果传入 ++a,则结果可能并不如你所想象的一样。
  5. 相同宏必须保证内容一致,否则会发生错误,给宏起名的时候需要要避免冲突。

include

include 预处理指令和宏类似,只不过 include 是按文件替换文本,在不经意的情况下,可能重复引入了同一个 .h 文件。

虽然函数声明是可以重复的,但是函数的实现和全局变量是不允许重复的,因此许多文档会强调 .h 文件不能包含函数实现和全局变量。

编译

C++ 中大部分类或者函数的声明可以直接确定该类或者函数所占或者所操作的所有类型的空间占用,所以对于多文件编译,编译器会选择先编译后链接的形式。但是对于模板而言,这种情况就大不相同。

由于模板只不过是模板,模板需要依赖于实例化中的具体代码来确定参数类型,但上面又说了,编译器会分别编译每一个源文件,这就造成了:

  1. 普通类和普通函数可以分别写在两个源文件中并且被单独编译。
  2. 类模板和函数模板必须和具体的实例化代码放在一个文件中进行编译。

有经验的开发者都会知道,.h 的文件有一个目的是让程序可以不直接包含全部的可执行数据,而是通过外部链接的方式从另外一个程序中查找函数,.h 中函数的声明仅仅作为查找的索引使用,可以不存在函数实现。

但是由于函数模板定义的通用数据类型不是一个具体类型,所以函数模板实际上无法单独编译为二进制数据,这就代表着函数模板和类模板必须使用 include 将整个模板的声明和具体实现都引入到每个源文件内。

C++ 默认所有源文件中的函数都是 extern 的,即可被其他编译单元(源文件)使用。有时候也可以用 static 关键字使某一函数的作用域是当前文件。

为了防止重复引入,C++ 一般采用两种策略来让编译器只引入一次头文件:


_Pragma("once");

//使用宏将头文件包裹起来进行保护,请确保宏名称唯一

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif

链接

由于 C++ 支持函数重载,意味着 C++ 编译器编译出来的二进制中的函数的签名是支持函数重载的版本,而 C 不支持该特性。所以对于同一个函数声明,C++ 和 C 会生成不一样的签名。

有时候需要使用动态的链接库或者静态的链接库去构建程序,很多情况下你并没有这些库的源码,只有头文件或者头文件和二进制文件,那么你得到或者使用的库的二进制文件很可能是使用 C 编译器编译的。

此时如果你使用 C++ 编译器来编译头文件,那么在链接过程中,由于签名不一样,会导致链接错误无法找到函数。

此时就需要 extern "C" 使用 extern 操作符去让 C++ 编译器生成 C 一样的函数签名,这时才能成功链接。


若无特殊声明,本文以 CC BY-SA 3.0 许可协议 提供。