第九章 链接 选读

我们在第三章的重新认识 C++ 程序和第六章的类外定义成员函数提到了关于 C++ 翻译过程的内容。在我们有了充足的知识准备之后,我打算在这一章重点来展开这个翻译过程。如无特殊说明,本章中的所有“编译”都指代从源代码到二进制指令序列这一翻译阶段,而非整个翻译过程。

翻译过程最难以理解的就是“编译”和“链接”的关系。为什么 C/C++ 要将翻译分成编译和链接两阶段呢?每一阶段具体都在做些什么呢?

考虑一段很简单的代码:

void f() {
    /* 大段大段代码 */
}
void g() {
    /* 也是大段大段代码 */
}
int main() {
    f();
    g();
}

这段代码没什么问题,但一旦出现了改动的需求,就有点麻烦。如果我想要改动 g 函数的内容,则 f 函数又得重新编译一遍。f 函数好长啊!大量的时间就被浪费掉了。所以,人们希望能够不做没有必要的重复工作。它们想了这样的办法:把三个函数分开编译。一个文件只放 f 函数定义,一个文件只放 g 函数定义,而最后一个文件放 main 函数定义。

// 以下为 f.cpp
void f() { /* [...] */ }
// 以下为 g.cpp
void g() { /* [...] */ }
// 以下为 main.cpp
int main() {
    f();
    g();
}

然后对 f.cpp 编译形成一系列存放 f 函数内容的二进制指令,对 g.cpp 编译形成又一堆二进制指令。但编译 main.cpp 时出现了问题:main.cpp 的编译过程中并不知道 fg 都是什么。是函数吗?接受什么参数?返回值类型是什么样的?

为了解决这个问题,不得不在 main.cpp 中引入函数 f g 不带定义的声明。

// main.cpp
void f();
void g();
int main() {
    f();
    g();
}

有了声明,编译器至少知道了 f g 是什么东西,能够生成 main 函数的二进制指令了。

然后现在我手头有三个二进制文件 f.o g.o main.o。我现在要做的事情就是把这三个二进制文件“合起来”,并且把程序的执行入口设在 main 函数上。这个过程就是链接了。链接是由链接器完成的,而链接器一般是编译器调用的。

链接内部是很复杂的过程。但它主要会做这些事情:寻找使用了但缺失定义的地方,再找到它所对应的定义(可能是在别的 .o 文件),然后把使用的地方和定义的地方连起来。这里,main.o 中使用了 fg,但它们只有声明,没有定义。在语法上,没有定义的名字是不能正常使用的,所以链接器要在别的地方寻找这两个定义。很幸运,f.o 里有 f 的定义,g.o 里有 g 的定义。OK,链接所需的物质基础都有了,然后经过一系列复杂的过程,链接完成。最终,我们得到了可执行文件 a.exe

当用这种方式来翻译代码时,如果修改了 g 函数的内容,则只需重新生成 g.o ——即重新生成 g 所对应的二进制指令,然后重新链接一下就可以了。这个过程无需再次编译 f 函数,从而整体的翻译效率得到了提升。

类外定义成员函数中,将类成员函数定义和类定义分成两个文件的做法正是这个过程的一个实践。

在介绍了多文件参与翻译过程的必要性后,我们将在后续的章节中讲解如何正确地让多个文件编译并链接。

最近更新:
代码未运行