为什么不在编译之前连接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 ,可以减less我编写的代码量。

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

但是我意识到C是一种非常成熟的编程语言,我在想象别人比我更聪明,已经有了这个想法,并决定不使用它。 为什么不?

有些软件是以这种方式构build的。

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

但是这种做法有利有弊。

很显然,编译时间会增加很多。 所以只有在很less编译这些东西的时候才是实用的。

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

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

这是一个错误的方法(每个函数有一个头文件)。 对于一个单人项目(less于十万行代码,又称KLOC =千行代码 ),对于小型项目来说是相当合理的 – 只有一个共同的头文件如果使用GCC ,则进行编译 ),它将包含所有公共函数和types的声明,也许还包括static inline函数的定义 (足够小,足够频繁地从内联中获益)。 例如, sash是按照这种方式组织的(也就是lout格式化程序 ,有52个KLOC)。

你也可能有一些头文件,也许有一些单独的“分组”标题,它们#include所有的头文件(你可以预编译)。 例如,参见jansson (实际上有一个公共头文件)和GTK (它有很多内部头文件,但是大多数使用它的应用程序只有一个#include <gtk/gtk.h> ,它包含所有内部头文件) 。 另一方面, POSIX拥有大量的头文件,它logging了哪些文件应该被包含在哪个顺序中。

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

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

请记住,C是在20世纪70年代devise的,对于计算机而言,比现在的笔记本电脑小得多,速度也慢得多(当时的内存通常最多是几兆字节,甚至几百千字节,计算机速度至less慢了一千倍比你的手机今天)。

我强烈build议研究源代码并构build一些现有的 免费软件项目 (例如GitHub或SourceForge或您最喜欢的Linux发行版)。 你会知道他们是不同的方法。 请记住, 在C 惯例习惯在实践中很重要 ,所以.c.h文件中组织项目有不同的方法 。 阅读有关C预处理器 。

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

你包括头文件,而不是库(但你应该链接库)。 但是你可以在每个.c文件中包含它们(许多项目都这样做),或者你可以将它们包含在一个头文件中并预编译这个头文件,或者你可以有十几个头文件,每个编译单元。 因人而异。 请注意,在今天的计算机上预处理时间很快(至less,当您要求编译器进行优化时,因为优化需要比parsing和预处理更多的时间)。

注意到一些#include -d文件是常规的 (并且没有被C规范定义)。 有些程序在某些文件中有一些代码(这些文件不应该被称为“头文件”,只是一些“包含文件”;然后不应该有.h后缀,而是其他类似的.inc )。 以XPM文件为例。 另一方面,你可能原则上没有任何自己的头文件(你仍然需要来自实现的头文件,如POSIX系统中的<stdio.h><dlfcn.h> ),并复制和粘贴重复的代码在你的.c文件中,例如: int foo(void); 在每个.c文件中,但是这是非常糟糕的做法,并且被皱起了眉头。 但是,一些程序正在生成共享一些常见内容的C文件。

顺便说一句,C或C + + 14没有模块(如OCaml有)。 换句话说,在C中,模块大多是一个惯例

(注意,每个只有几十行的数千个非常小的 .h.c文件可能会大大减慢构build时间;在编译时间方面,每个数百行的数百个文件更合理。 )

如果你开始用C编写一个单人项目,我会build议首先有一个头文件(并预编译它)和几个.c翻译单元。 在实践中,你会比.h更频繁地更改.c文件。 一旦你有超过10 KLOC你可能会重构成几个头文件。 这样的重构很难devise,但很容易做到(只是大量的复制和粘贴代码)。 其他人会有不同的build议和提示(这是可以的!)。 但是不要忘记在编译时启用所有的警告和debugging信息(所以用gcc -Wall -g编译,也许在Makefile设置CFLAGS= -Wall -g )。 使用gdbdebugging器(和valgrind …)。 当您对已debugging的程序进行基准testing时,请求优化( -O2 )。 也可以使用像Git这样的版本控制系统。

相反,如果你正在devise一个可以工作的大型项目,最好有几个文件,甚至是几个头文件(直观地说,每个文件都有一个主要负责人,其他人主要负责这个文件)对该文件的贡献)。

在评论中,您添加:

我正在谈论编写我的代码在许多不同的文件,但使用Makefile来连接它们

我不明白为什么这将是有用的(除非在非常奇怪的情况下)。 将每个翻译单元(例如每个.c文件)编译成其目标文件 (Linux上的.o ELF文件)并且稍后将它们链接起来会好得多(也是非常常见和常见的做法)。 这样做很容易(实际上,当你只更改一个.c文件,比如修复一个bug,只有这个文件被编译,增量构build真的很快),你可以让它并行编译目标文件使用make -j (然后你的内核在你的多核处理器上运行得非常快)。

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

  1. 它加快构build。 您只需要重build已经更改的文件,并且可以将其与其他已编译的文件相链接以形成最终的程序。

  2. C标准库由预编译的组件组成。 你真的想要重新编译所有的?

  3. 如果代码库被拆分成不同的文件,与其他程序员进行协作将变得更加容易。

  • 借助模块化,您可以共享您的图书馆,而无需共享代码。
  • 对于大型项目,如果更改单个文件,则最终将编译完整的项目。
  • 当您尝试编译大型项目时,您可能更容易耗尽内存。
  • 你可能在模块中有循环依赖,模块化有助于维护这些模块。

你的方法可能会有一些好处,但对于像C这样的语言,编译每个模块更有意义。

因为把事情分开是好的程序devise。 好的程序devise是关于模块化,自治的代码模块和代码重用性的。 事实certificate,在进行程序devise时,常识会让你走得很远:不属于一起的东西不应该放在一起。

将不相关的代码放在不同的翻译单元中意味着您可以尽可能地本地化variables和函数的范围。

合并在一起会产生紧密的耦合 ,这意味着代码文件之间的尴尬的依赖关系实际上甚至不必知道彼此的存在。 这就是为什么包含项目中所有内容的“global.h”是一件坏事,因为它会在整个项目中的每个非相关文件之间产生紧密的耦合。

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

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

如果一个模块中的一个错误完全违反了非相关的事情,几乎可以肯定是因为糟糕的程序devise。 而实现糟糕的程序devise的一个方法是将项目中的所有东西合并成一个大块。

连接.c文件的方法已完全破解:

  • 即使命令cat *.c > to_compile.c将所有函数放入单个文件中, 顺序也很重要:每个函数必须在第一次使用之前声明。

    也就是说,你的.c文件之间有依赖关系,强制执行某个命令。 如果连接命令无法遵守这个顺序,你将无法编译结果。

    另外,如果你有两个recursion地相互使用的函数,那么绝对没有办法为这两个函数中的至less一个写一个前向声明。 您也可以将这些前向声明放入一个人们期望find的头文件中。

  • 将所有内容连接成单个文件时, 只要项目中的一行发生更改就会强制执行完整重build。

    使用经典的.c / .h分割编译方法,只需要重新编译一个文件就可以改变函数的实现,而标题的变化则需要重新编译实际包含这个头文件的文件。 这可以很容易地加快重build后的一个小的变化100倍或更多(取决于.c文件的数量)。

  • 将所有内容连接成单个文件时,您将无法进行并行编译

    有一个大的胖12核心处理器启用超线程? 可惜,你的连接的源文件是由单个线程编译的。 你只是失去了一个超过20的因素加速…好吧,这是一个极端的例子,但我已经用make -j16构build软件,我告诉你,它可以有很大的不同。

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

    通常,编译器至less包含一些具有二次运行行为的algorithm。 因此,汇总汇编实际上通常有一些门槛比编制独立部分要慢。

    显然,这个阈值的确切位置取决于你传递给它的编译器和优化标志,但是我看到一个编译器在一个巨大的源文件上占用了半个多小时。 你不想在你的改变编译testing循环中有这样的障碍。

请不要误解:即使有这些问题,也有人在实践中使用.c文件连接,而一些C ++程序员通过将所有东西都移动到模板中而获得了相同的结果(这样可以在.hpp文件,没有关联的.cpp文件),让预处理器进行连接。 我看不出他们怎么能忽略这些问题,但是他们确实如此。

另外请注意,这些问题中的许多问题只有在较大的项目规模时才会显现 如果你的项目less于5000行代码,那么编译它还是相对无关紧要的。 但是当你有超过50000行的代码时,你肯定需要一个支持增量和并行构build的构build系统。 否则,你正在浪费你的工作时间。

头文件应该定义接口 – 这是一个可取的约定。 它们并不是要声明所有在相应的.c文件或一组.c文件中的所有内容。 相反,他们在.c文件中声明其用户可用的所有function。 一个精心devise的.h文件包含一个由.c文件中的代码公开的接口的基本文档,即使其中没​​有单个注释。 处理C模块devise的一种方法是首先编写头文件,然后将其实现在一个或多个.c文件中。

推论:执行.c文件内部的函数和数据结构通常不属于头文件。 您可能需要前向声明,但是这些声明应该是本地的,所有声明和定义的variables和函数都应该是static :如果它们不是接口的一部分,链接器就不应该看到它们。

主要原因是编译时间。 编辑一个小文件,当你改变它可能需要很短的时间。 但是,如果您在更改单行时编译整个项目,那么您将每次编译(例如)10,000个文件,这可能需要更长的时间。

如果你有 – 如上面的例子 – 10,000个源文件和编译一个需要10毫秒,那么整个项目build立增量(在改变单个文件后)(10毫秒+链接时间),如果你只编译这个改变的文件,或(10毫秒* 10000 +短链接时间),如果你编译一切为一个单一的连接blob。

虽然仍然可以用模块化方式编写程序并将其构build为单个转换单元,但是您将错过C提供的所有机制来执行该模块化 。 通过使用多个翻译单元,您可以使用例如externstatic关键字来良好地控制模块的接口。

通过将代码合并到单个翻译单元中,您将错过任何模块性问题,因为编译器不会警告您。 在一个大项目中,这最终会导致意想不到的依赖关系蔓延。 最后,如果不在其他模块中创build全局副作用,您将无法更改任何模块。

如果你把所有的包括在一个地方,你只需要定义你需要的东西,而不是所有的源文件。

这是.h文件的目的,所以你可以定义你需要的东西,并将它包含在任何地方。 有些项目甚至有一个包含每个.h文件的everything.h头文件。 所以,你的可以用单独的.c文件来实现。

这意味着我不必为我创build的每个函数写一个头文件[…]

不pipe怎样,你不应该为每个函数写一个头文件。 你应该有一个相关函数的头文件。 所以你的骗局也是无效的。

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

你注意到的专业人员实际上是一个为什么有时这样做的原因是规模较小。

对于大型节目来说,这是不切实际的。 像其他提到的好的答案一样,这可以大大增加构build时间。

然而,它可以用来将翻译单元拆分成更小的位,它们以一种让人想起Java的包可访问性的方式共享对函数的访问。

上述方法的实现需要预处理器的一些训练和帮助。

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

 // ac static void utility() { } static void a_func() { utility(); } // bc static void b_func() { utility(); } 

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

 // ab.c static void utility(); #include "ac" #include "bc" 

而你的编译系统不会build立acbc ,而只是build立ab.o而不是ab.c

ab.c完成什么?

它包括生成单个翻译单元的两个文件,并提供该实用程序的原型。 所以acbc中的代码都可以看到它,不pipe它们被包含的顺序如何,也不需要函数是extern