为什么不在编译之前连接 C 源文件?

我来自一个脚本背景,C 语言的预处理器对我来说总是很难看。尽管如此,当我学习编写小型 C 程序时,我还是接受了它。我只是真正使用预处理器来包含我为自己的函数编写的标准库和头文件。

我的问题是,为什么 C 程序员不跳过所有的包含,只是连接他们的 C 源文件,然后编译它?如果您将所有的包含放在一个地方,那么您只需要定义一次您需要的内容,而不是放在所有的源文件中。

下面是我所描述的一个例子,这里我有三个文件:

// includes.c
#include <stdio.h>
// main.c
int main() {
foo();
printf("world\n");
return 0;
}
// foo.c
void foo() {
printf("Hello ");
}

通过在 Makefile 中执行类似 cat *.c > to_compile.c && gcc -o myprogram to_compile.c的操作,我可以减少编写的代码量。

这意味着我不必为我创建的每个函数写一个头文件(因为它们已经在主源文件中了) ,这也意味着我不必在我创建的每个文件中包含标准库。我觉得这是个好主意!

然而,我意识到 C 是一种非常成熟的编程语言,我想象着其他比我聪明得多的人已经有了这个想法,并决定不使用它。为什么不呢?

9743 次浏览

主要原因是编译时间。更改时编译一个小文件可能需要很短的时间。但是,如果每次更改单行代码时都要编译整个项目,那么每次将编译(例如)10,000个文件,这可能会花费更长的时间。

如果你有——就像上面的例子一样——10,000个源文件,编译一个需要10毫秒,那么整个项目会在(10毫秒 + 链接时间)内增量构建(在更改单个文件之后) ,如果你只编译这个更改过的文件,或者(10毫秒 * 10000 + 短链接时间) ,如果你把所有东西都编译成一个连接的 blob。

有些软件就是这样构建的。

一个典型的例子是 SQLite。它有时被编译为 合并(在构建时从许多源文件完成)。

但这种做法有利也有弊。

显然,编译时间会增加很多。因此,只有在很少编译这些内容的情况下,它才是实用的。

也许,编译器可能会优化得更多一些。但是通过链接时间优化(例如,如果使用 最近 GCC,编译并链接到 gcc -flto -O2) ,您可以获得同样的效果(当然,代价是增加构建时间)。

我不必为每个函数编写头文件

这是一种错误的方法(每个函数只有一个头文件)。对于一个单人项目(少于10万行代码,也就是 KLOC = 千行的 密码) ,至少对于小型项目来说,有一个 单身通用头文件(如果使用 海湾合作委员会,你可以使用 预编译)是相当合理的,它将包含所有公共函数和类型的声明,也许还有 static inline函数的 定义(那些足够小,调用频率足以从 内嵌中获益的函数)。例如,sash外壳就是这样组织的(sash0也是这样,52 KLOC)。

您可能还有一些头文件,并且可能有一些单独的“分组”头文件,#include-s 所有这些头文件(并且您可以预编译)。例如,Jansson(实际上只有一个 公众人士头文件)和 GTK(有内部头文件的 很多,但是大多数使用它的应用程序只有一个 #include <gtk/gtk.h>,而这个 #include <gtk/gtk.h>又包含所有的内部头文件)。另一方面,POSIX有大量的头文件,它记录了应该包含哪些头文件以及按照什么顺序。

有些人喜欢拥有大量的头文件(有些人甚至喜欢将单个函数声明放在自己的头文件中)。我不(对于个人项目,或者只有两三个人提交代码的小项目) ,但 这是一个品味的问题。顺便说一句,当一个项目发展很大时,经常会发生头文件(和翻译单元)的集合发生显著变化。再看看 REDIS(它有139个 .h头文件和214个 .c文件,即总计126 KLOC 的翻译单元)。

拥有一个或几个 翻译小组也是一个品味问题(以及方便性、习惯和惯例)。我倾向于使用不太小的源文件(即翻译单元) ,通常每个源文件有几千行,并且常常(对于小于60 KLOC 的小项目)有一个通用的单一头文件。不要忘记使用一些 组建自动化工具,比如 GNU 制造(通常使用通过 make -j构建的 平行; 然后您将同时运行 好几个编译过程)。拥有这样一个源文件组织的优点是编译相当快。顺便说一句,在某些情况下,使用 元编程的方法是值得的: 你的一些(内部头文件,或者翻译单元) C“源”文件可以是 产生的其他文件(例如,AWK中的一些脚本,一些专门的 C 程序,如 野牛或者你自己的东西)。

请记住,C 是在20世纪70年代设计的,适用于比你现在最喜欢的笔记本电脑更小更慢的电脑(通常,当时的内存最多只有一兆字节,甚至几百千字节,而且电脑的速度至少比你现在的手机慢一千倍)。

我强烈建议使用 研究源代码,构建一些现有的项目(例如 GitHubSourceForge或您最喜欢的 Linux 发行版)。你会发现它们是不同的方法。记住 在 C < em > 约定 和 < em > 习惯 在实践中很重要,所以 在 ABC0和 .h文件中有不同的组织项目的方法。阅读有关 C 预处理器的资料。

这也意味着我不必在创建的每个文件中都包含标准库

包含头文件,而不是库(但应该包含 < em > 连结 库)。但是你可以在每个 .c文件中包含它们(许多项目正在这样做) ,或者你可以在一个头中包含它们并预编译那个头,或者你可以有十几个头并在每个编译单元中的系统头之后包含它们。YMMV.注意,在今天的计算机上,预处理时间很快(至少,当你要求编译器进行优化时,因为优化比解析和预处理花费更多的时间)。

请注意,进入某些 #include-d 文件的是 传统(并且不是由 C 规范定义的)。一些程序在这样的文件中有一些代码(这些文件不应该被称为“头文件”,只是一些“包含的文件”; 这些文件不应该有 .h后缀,而是类似于 .inc的其他文件)。例如,查看 XPM文件。在另一个极端情况下,你可能在原则上没有任何自己的头文件(你仍然需要来自实现的头文件,比如来自 POSIX 系统的 <stdio.h><dlfcn.h>) ,并且在你的 .c文件中复制和粘贴重复的代码——例如在每个 .c文件中都有一行 int foo(void);,但这是非常糟糕的做法,并且是不被赞成的。但是,有些程序是 .h0C 文件,共享一些通用的内容。

顺便说一下,C 或 C + + 14没有模块(像 OCaml 一样)。换句话说,在 C 语言中,模块主要是 大会

(请注意,拥有成千上万的 非常小 .h.c文件,每个文件只有几十行,这可能会大大减慢构建时间; 就构建时间而言,拥有成百上千的 非常小 .h.c文件,每个文件只有几百行更合理。)

如果您开始使用 C 语言进行单人项目,我建议您首先使用一个头文件(并预编译它)和几个 .c翻译单元。实际上,更改 .c文件的频率要比更改 .h文件的频率高得多。一旦你有超过10 KLOC,你可能会重构到几个头文件。这样的重构很难设计,但是很容易实现(只需要大量的代码复制粘贴)。其他人会有不同的建议和提示(这没关系!).但是不要忘记在编译时启用所有警告和调试信息(因此使用 gcc -Wall -g编译,也许在 Makefile中设置 CFLAGS= -Wall -g)。使用 gdb调试器(和 瓦尔格林...)。请求优化(-O2)当您基准测试一个已经调试的程序。也可以使用像 饭桶这样的版本控制系统。

相反,如果你正在设计一个更大的项目,几个人将工作,这可能是更好的有几个文件-甚至几个头文件-(直观地说,每个文件有一个人主要负责它,其他人对该文件作出微小的贡献)。

在评论中,你补充道:

我说的是在许多不同的文件中编写代码,但是使用 Makefile 来连接它们

我不明白这有什么用(除了在非常奇怪的情况下)。将每个翻译单元(例如每个 .c文件)编译成它的 目标文件(Linux 上的一个 .o 精灵文件) ,然后将它们编译成 链接,这样会更好(也是非常常见和常见的做法)。这对于 make来说很简单(在实践中,当你只修改一个 .c文件例如修复一个 bug 时,只有这个文件被编译,而且增量构建非常快) ,你可以要求它使用 make -j平行中编译对象文件(然后你的构建在你的多核心上进行得非常快)。

可以会这样做,但是我们喜欢把 C 程序分成单独的 翻译小组,主要是因为:

  1. 它能加速建造。您只需要重新生成已更改的文件,这些文件可以是 联系在一起和其他已编译的文件,以形成最终的程序。

  2. C 标准库由预编译的组件组成。您真的希望重新编译所有这些组件吗?

  3. 如果代码库被分割成不同的文件,那么与其他程序员协作就更容易了。

  • 通过模块化,您可以在不共享代码的情况下共享库。
  • 对于大型项目,如果您更改单个文件,那么您最终将 编译完整的项目。
  • 尝试编译大型项目时,可能更容易耗尽内存。
  • 您可能在模块中有循环依赖关系,模块化有助于维护这些依赖关系。

您的方法可能会有一些收获,但是对于像 C 这样的语言来说,编译每个模块更有意义。

这意味着我不必为我创建的每个函数写一个头文件(因为它们已经在主源文件中了) ,这也意味着我不必在我创建的每个文件中包含标准库。我觉得这是个好主意!

你注意到的优点实际上是为什么有时这样做的规模较小的原因。

对于大型程序来说,这是不切实际的。

然而,它可以用来将转换单元分解成更小的比特,以一种让人想起 Java 的包可访问性的方式共享对函数的访问。

实现上述目标的方法需要预处理器的一些规程和帮助。

例如,可以将翻译单元分成两个文件:

// a.c


static void utility() {
}


static void a_func() {
utility();
}


// b.c


static void b_func() {
utility();
}

现在为您的翻译单元添加一个文件:

// ab.c


static void utility();


#include "a.c"
#include "b.c"

您的构建系统不构建 a.cb.c,而是仅从 ab.c构建 ab.o

ab.c完成了什么?

它包含两个文件以生成单个翻译单元,并为实用程序提供了一个原型。这样 a.cb.c中的代码都可以看到它,不管它们包含的顺序如何,而且不需要函数是 extern

因为把东西分开是很好的程序设计。好的程序设计包括模块化、自治代码模块和代码的可重用性。事实证明,在进行程序设计时,常识会让你走得很远: 不应该放在一起的东西不应该放在一起。

将不相关的代码放在不同的翻译单元中意味着您可以尽可能地本地化变量和函数的作用域。

将一些东西合并在一起会产生 紧密连接,这意味着代码文件之间的尴尬依赖关系,这些代码文件甚至不需要知道彼此的存在。这就是为什么包含项目中所有包含的“ global.h”是一件坏事,因为它在整个项目中的每个非相关文件之间创建了一个紧密耦合。

假设您正在编写控制汽车的固件。程序中的一个模块控制汽车调频收音机。然后在另一个项目中重用无线电代码,以便在智能手机中控制调频无线电。然后你的无线电代码就不能编译了,因为它找不到刹车、轮子、齿轮等等。这些东西对调频收音机来说毫无意义,更不用说智能手机了。

更糟糕的是,如果您有紧密耦合,bug 会在整个程序中升级,而不是停留在 bug 所在的模块本地。这使得错误的后果严重得多。你在调频收音机代码里写了一个错误然后突然车的刹车失灵了。即使你没有触及刹车代码与您的更新,其中包含的错误。

如果一个模块中的错误破坏了完全不相关的东西,那几乎可以肯定是因为程序设计不好。实现糟糕的程序设计的一个特定方法是将项目中的所有内容合并到一个大块中。

虽然您仍然可以以模块化的方式编写程序并将其构建为单个翻译单元,但是您将错过所有的 C 提供的强制模块化的机制。使用多个翻译单元,通过使用例如 externstatic关键字,可以很好地控制模块的界面。

通过将代码合并到一个单独的翻译单元中,您将错过任何可能出现的模块化问题,因为编译器不会提醒您这些问题。在一个大型项目中,这最终将导致意想不到的依赖性扩散。最后,如果不在其他模块中创建全局副作用,您将很难更改任何模块。

如果您将所有的包含放在一个地方,那么您只需要定义一次您需要的内容,而不是放在所有的源文件中。

这就是 .h文件的用途,因此您可以定义一次所需的内容并将其包含在所有位置。有些项目甚至有一个包含每个 .h文件的 everything.h头。因此,您的 赞成也可以通过单独的 .c文件来实现。

这意味着我不必为我创建的每个函数[ ... ]编写头文件

无论如何,您不应该为每个函数编写一个头文件。应该为一组相关函数提供一个头文件。所以你的 骗局也是无效的。

头文件应该定义接口——这是一个需要遵循的约定。它们并不意味着声明相应的 .c文件或一组 .c文件中的所有内容。相反,它们在 .c文件中声明用户可以使用的所有功能。一个设计良好的 .h文件包含一个由 .c文件中的代码公开的接口的基本文档,即使该文件中没有任何注释。设计 C 模块的一种方法是首先编写头文件,然后在一个或多个 .c文件中实现它。

推论: .c文件实现内部的函数和数据结构通常不属于头文件。您可能需要转发声明,但这些声明应该是局部的,因此声明和定义的所有变量和函数都应该是 static: 如果它们不是接口的一部分,链接器就不应该看到它们。

您连接.c 文件的方法完全失败了:

  • 即使命令 cat *.c > to_compile.c将把所有函数放到一个文件中,< strong > order matter: 您必须在每个函数第一次使用之前声明它们。

    也就是说,您的。强制执行某种命令的文件。如果您的连接命令无法执行此订单,则无法编译结果。

    另外,如果你有两个递归使用对方的函数,那么至少要为其中一个函数编写一个前向声明是绝对不可能的。您也可以将这些转发声明放到一个头文件中,以便人们能够找到它们。

  • 当您将所有内容连接到一个文件中时,< strong > 每当项目中的一行发生更改时,您都会强制执行完全重新生成。

    用经典的。转交。H 拆分编译方法中,函数实现的改变需要重新编译恰好一个文件,而头部的改变需要重新编译实际包含该头部的文件。这可以很容易地加快重建后,一个因素的100或更多的小变化(取决于计数。文件)。

  • 当您将所有内容连接到一个文件中时,您失去了所有并行编译 的能力。

    是否有一个启用了超线程的12核处理器?遗憾的是,您的连接源文件是由一个线程编译的。你刚刚失去了一个大于20倍的加速系数... ... 好吧,这是一个极端的例子,但我已经用 make -j16构建了软件,我告诉你,它可以产生巨大的差异。

  • 编译时间通常是 没有线性的。

    通常编译器至少包含一些具有二次运行时行为的算法。因此,通常存在一个阈值,从这个阈值开始,聚合编译实际上比独立部分的编译慢。

    显然,这个阈值的精确位置取决于编译器和传递给它的优化标志,但是我见过一个编译器在一个巨大的源文件上花费了半个多小时。您不希望在变更-编译-测试循环中出现这样的障碍。

毫无疑问: 即使它带来了所有这些问题,有人谁使用。C 文件连接实践中,一些 C + + 程序员通过将所有内容移动到模板中(因此实现可以在。Hpp 文件,并且没有关联的。Cpp 文件) ,让预处理器执行连接。我看不出他们怎么能忽视这些问题,但他们确实忽视了。

还要注意,许多这样的问题只有在项目规模较大时才会显现出来。如果您的项目的代码少于5000行,那么如何编译它仍然是相对无关的。但是当您有超过50000行代码时,您肯定需要一个支持增量和并行构建的构建系统。否则,你就是在浪费你的工作时间。