| 目录 |

17 多文件项目

到目前为止,我们看的都是些玩具程序,它们绝大部分都能塞进单个文件里。但复杂的 C 程序由许多文件组成,这些文件会被一起编译、链接成一个可执行文件。

本章我们来看看把较大的项目组织起来时,一些常见的模式和做法。

17.1 包含文件与函数原型

一种非常常见的情况是:你的某些函数定义在一个文件里,而你想从另一个文件调用它们。

这其实开箱即用,只是会带一条警告……我们先试一下,然后再看修正这条警告的正确做法。

要编译它们,你需要在命令行上指定所有源文件:

# 输出文件      源文件
#     v            v
#   |----| |---------|
gcc -o foo foo.c bar.c

在那个例子里,foo.c 和 bar.c 会被构建成名为 foo 的可执行文件。

在这些例子里,我们会把文件名写成源代码里的第一条注释。来看看源文件 bar.c:

// 文件 bar.c

int add(int x, int y)
{
    return x + y;
}

再看包含 main 的 foo.c 文件:

// 文件 foo.c

#include <stdio.h>

int main(void)
{
    printf("%d\n", add(2, 3));  // 5!
}

看到了吗?在 main() 里我们调用了 add()——可 add() 在一个完全不同的源文件里!它在 bar.c 里,而对它的调用却在 foo.c 里!

如果我们这样构建:

gcc -o foo foo.c bar.c

就会得到这个错误:

error: implicit declaration of function 'add' is invalid in C99

(或者你得到的可能是一条警告。这你可不该无视。在 C 里永远不要无视警告;把它们统统处理掉。)

如果你还记得讲函数原型的那一节,现代 C 已经禁止隐式声明,也没有任何正当理由把它引入新代码。我们应该把它修好。

implicit declaration 的意思是:我们在使用一个函数——这里就是 add()——却没有事先让 C 知道关于它的任何信息。C 想知道它返回什么、接受哪些类型作为实参,诸如此类。

我们前面已经看到如何用函数原型来修正这个问题。确实,只要在调用之前往 foo.c 里加一个函数原型,一切就都正常了:

// 文件 foo.c

#include <stdio.h>

int add(int, int);  // 添加函数原型

int main(void)
{
    printf("%d\n", add(2, 3));  // 5!
}

不再有错误了!

但这很麻烦——每次想用某个函数都得把原型敲一遍。我是说,我们刚才就用了 printf(),也没见需要敲原型啊;这是怎么回事?

其实我们确实包含了 printf() 的原型!它就在 stdio.h 文件里!而我们是使用 #include 把它包含进来的!

我们能不能对自己的 add() 函数也这么做?为它写一个原型,然后放进头文件里?

当然可以!

按照惯例,C 的头文件使用 .h 扩展名。而且它们通常(但并非总是)与对应的 .c 文件同名。所以我们为 bar.c 建一个 bar.h 文件,把原型放进去:

// 文件 bar.h

int add(int, int);

现在我们来修改 foo.c,让它包含那个文件。假设它就在同一目录下,我们用双引号把它包起来包含(而不是用尖括号):

// 文件 foo.c

#include <stdio.h>

#include "bar.h"  // 从当前目录包含

int main(void)
{
    printf("%d\n", add(2, 3));  // 5!
}

注意,foo.c 里已经不再有原型了——我们是从 bar.h 把它包含进来的。现在任何想要那份 add() 功能的文件,只要 #include "bar.h" 就能拿到,你不用再操心去敲函数原型。

你可能已经猜到了,#include 就是字面意义上把指定的文件包含到源代码里的那个位置,就像你自己把它敲进去一样。

然后构建并运行:

./foo
5

果然,我们得到了 \(2+3\) 的结果!好耶!

不过先别急着开你那罐心头好。就快好了!我们还差最后一块样板代码要加。

17.2 处理重复包含

头文件自己再用 #include 包含一些头文件,供对应的 C 文件实现其功能,这并不罕见。我是说,有何不可呢?

而且同一个头文件很可能被不同地方 #include 了多次。也许这没问题,但也可能引发编译器错误。而我们根本控制不了有多少地方会 #include 它!

更糟的是,我们还可能陷入一种疯狂的局面:头文件 a.h 包含头文件 b.h,而 b.h 又包含 a.h!这是一个 #include 的死循环!

试着构建这样的东西会报错:

error: #include nested depth 200 exceeds maximum of 200

也许到第 201 步它就能解开这个循环了……

我们需要做的是:让一个文件在被包含过一次之后,后续针对它的 #include 都被忽略。

我们接下来要做的这套东西实在太常见了,常见到你每次新建头文件时都应该条件反射地做一遍!

常见做法是使用一个预处理器变量:第一次 #include 该文件时把它设置好。之后再遇到 #include 时,我们先检查这个变量确实还没有被定义。

至于变量名,非常常见的做法是取头文件的名字,比如 bar.h,改成大写,再把句点换成下划线:BAR_H。

所以在文件的最最开头放一个检查,判断它是否已经被包含过;如果已经包含过,就实际上把整个文件注释掉。

(不要加前导下划线(因为前导下划线后跟大写字母是保留的),也不要加双前导下划线(因为那也是保留的)。)

#ifndef BAR_H   // 如果 BAR_H 还没有定义……
#define BAR_H   // 定义它(不指定具体值)

// 文件 bar.h

int add(int, int);

#endif          // #ifndef BAR_H 的结尾

这样一来,无论有多少地方试图 #include 这个头文件,它实际上都只会被包含一次。

17.3 static 与 extern

你可以用 extern 引用其他文件中的对象。

用 static 关键字,你可以确保文件作用域的变量和函数不会在其他源文件中可见(即使使用了 extern)。

更多信息请参阅本书中关于 static 和 extern 存储类说明符的章节。

17.4 用目标文件编译

这不属于规范的一部分,但在 C 的世界里 99.999% 的情况下都会这么做。

你可以把 C 文件编译成称为目标文件的中间表示。它们保存着尚未被构建成可执行文件的机器码(也就是实际指令的那些 1 和 0)。

Windows 上的目标文件扩展名是 .OBJ;在类 Unix 系统上是 .o。

在 gcc 中,我们可以用 -c(只编译!)标志这样构建:

gcc -c foo.c     # 生成 foo.o
gcc -c bar.c     # 生成 bar.o

然后我们可以把它们链接成一个可执行文件:

gcc -o foo foo.o bar.o

瞧,我们用这两个目标文件生成了可执行文件 foo。

但你在想:何必呢?我们直接这样不就行了:

gcc -o foo foo.c bar.c

然后一块石头打下两只 boid118,一石二鸟,不好吗?

对小程序来说,这样没问题。我一直都这么干。

但对于较大的程序,我们可以利用这样一个事实:从源代码编译到目标文件相对较慢,而把一堆目标文件链接起来相对较快。

这一点在 make 工具上体现得淋漓尽致,它只重新构建那些比其输出更新的源文件。

假设你有一千个 C 文件。你可以先把它们全部编译成目标文件(慢),然后把所有这些目标文件合并成一个可执行文件(快)。

现在假设你只修改了其中一个 C 源文件——神奇的地方来了:你只需要重新构建那个源文件对应的那一个目标文件!然后重新构建可执行文件(快)。其他所有 C 文件都不必动。

换句话说,只重新构建需要重建的目标文件,我们就能大幅缩短编译时间。(当然,除非你是在做一次“干净”的构建,那样所有目标文件都必须重新生成。)


| 目录 |