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

74 c compilation c-preprocessor

我来自脚本背景,C中的预处理器对我来说总是很难看.然而,当我学习编写小型C程序时,我已经接受了它.我只是真的使用预处理器来包含我为自己的函数编写的标准库和头文件.

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

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

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

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

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

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

Bas*_*tch 103

有些软件是以这种方式构建的.

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

但这种方法有利有弊.

显然,编译时间会增加很多.所以只有你很少编译那些东西才是实用的.

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

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

这是一种错误的方法(每个函数有一个头文件).对于单人项目(小于十万行的代码,又名KLOC =的千行代码),这是很合理的-至少对于小项目-有一个单一的公共头文件(你可以如果使用GCC 编译,它将包含所有公共函数和类型的声明,以及可能的函数定义static inline(那些足够小并且经常被调用以从内联中获利).例如,sashshell以这种方式组织(lout格式化程序也是如此,有52个KLOC).

您可能还有一些头文件,并且可能有一些单独的"分组"标题 - #include所有这些(以及您可以预编译的).例如见杨松(实际上有一个公共的头文件)和GTK(其中有大量的使用它内部接头连接,但大多数应用程序只是一个#include <gtk/gtk.h>这又包括所有的内部接头).另一方面,POSIX有很多头文件,它会记录哪些应该包含在哪个文件中.

有些人喜欢有很多头文件(有些人甚至喜欢在自己的头文件中放置一个函数声明).我不(对于个人项目,或只有两三个人会提交代码的小项目),但这是一个品味问题.顺便说一句,当一个项目增长很多时,它经常发生头文件集(和翻译单元)的变化.另请参阅REDIS(它有139 .h个头文件和214个.c文件,即翻译单元总计126个KLOC).

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

请记住,C是在20世纪70年代设计的,对于比你今天最喜欢的笔记本电脑小得多且速度慢的计算机(通常,内存最多只有几兆字节,甚至几百千字节,计算机的速度至少要慢一千倍)比你今天的手机).

我强烈建议研究源代码并构建一些现有的 免费软件项目(例如GitHubSourceForge或您最喜欢的Linux发行版).你会发现它们是不同的方法.请记住,用C 惯例习惯的问题很多在实践中,所以不同的方式来组织你的项目.c.h文件.阅读有关C预处理器的信息.

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

您包含头文件,而不是库(但您应该链接库).但是您可以将它们包含在每个.c文件中(并且许多项目正在执行此操作),或者您可以将它们包含在一个标头中并预编译该标头,或者您可以拥有十几个标头并在每个编译中的系统标头之后包含它们单元.因人而异.请注意,今天的计算机上的预处理时间很快(至少,当您要求编译器进行优化时,因为优化需要比解析和预处理更多的时间).

请注意,某些#include-d文件中的内容是常规的(并且未由C规范定义).有些程序在一些这样的文件中有一些代码(不应该被称为"标题",只是一些"包含文件";然后它们不应该有.h 后缀,而是其他类似的东西.inc).查找XPM文件中的示例.在另一个极端,您原则上可能没有任何自己的头文件(您仍然需要来自实现的头文件,如POSIX系统<stdio.h><dlfcn.h>来自您的POSIX系统)并在您的.c文件中复制和粘贴重复的代码- 例如int foo(void);,每行都有一行代码.c文件,但这是非常糟糕的做法,并且不满意.但是,某些程序正在生成共享一些常见内容的C文件.

BTW,C或C++ 14没有模块(如OCaml所有).换句话说,在C中,模块主要是约定.

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

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

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

在评论中,您添加:

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

我不明白为什么那会有用(除非是非常奇怪的情况).将每个翻译单元(例如每个.c文件)编译到其目标文件(Linux上的.o ELF文件)并稍后链接它们要好得多(并且非常通常和通用的做法).这很容易make(实际上,当您只更改一个.c文件时,例如修复错误,只编译该文件并且增量构建非常快),您可以要求它使用(并行)并行编译目标文件.make -j然后你的构建在你的多核处理器上变得非常快.

  • @BasileStarynkevitch:拥有一个_public_标头与拥有单个标头不同.并且gtk.h标头什么都不做,但包括200多个其他标头. (24认同)
  • 在任何项目上都有一个头文件是一个糟糕的主意. (13认同)
  • @OhFiddyYouSoWiddy SQLite没有"那样做".这是一种分发sqlite的方法,使其更容易包含在其他项目中:您可以使用一个`.c`文件和一个`.h`文件来代替一堆本身没有人关心的实现细节的文件.但合并并不是项目的开发方式.合并是分发构建过程的产物 - 它是由机器生成的. (12认同)
  • 这取决于项目和标题.这是常见的做法,只要您考虑标头预编译就必须这样做. (7认同)
  • @JackAidley所以你的"hello world"至少有两个头文件? (4认同)
  • 你可能想要补充一点,预处理器本身允许某些类型的元编程(例如,基于`#define`s的头改变的内容).多次包含标题(每次可能使用不同的`#define`s)不仅仅是"复制和粘贴"标题内容.Boost有时会滥用这个. (2认同)
  • @hoffmale:_"多次包含一个标题(每次可能使用不同的#defines)不仅仅是"复制并粘贴"标题内容"_不,它没有.您所看到的效果是"复制/粘贴"结果中"#simic"的行为的结果.`#include`是一个"复制和粘贴",期间. (2认同)

Bat*_*eba 26

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

  1. 它加快了构建速度.您只需要重建已更改的文件,这些文件可以与其他编译文件链接以形成最终程序.

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

  3. 如果将代码库拆分为不同的文件,则与其他程序员协作会更容易.

  • 1)并非总是如此,特别是对于C++; 我已经看到连接的主要构建时间减少了."翻译单元"不是需要教程的东西,它只是一种说"C文件+所有包含文件"的方式.这是一个在C标准中经常使用的短语 - 定义一直持续到翻译单元结束. (5认同)
  • @ pjc50:C++,其模板,编译时评估功能和函数重载是一个完全不同的野兽.(对于C++,我使用分布式构建环境,但在编译期间仍然在此站点上花费了过多的时间.) (3认同)
  • 我以前从未听说过翻译单位.谢谢,我会去了解他们.你头脑中有什么好的教程吗? (2认同)

Moh*_*ain 16

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

您的方法可能会有所收获,但对于像C这样的语言,编译每个模块更有意义.


cma*_*ter 16

您连接.c文件的方法完全被破坏:

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

    也就是说,您的.c文件之间存在依赖关系,这会强制执行某个顺序.如果您的串联命令无法遵守此顺序,您将无法编译结果.

    此外,如果你有两个以递归方式互相使用的函数,那么绝对没有办法为至少两个函数编写前向声明.您也可以将这些前向声明放入人们期望找到它们的头文件中.

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

    使用经典的.c/.h拆分编译方法,函数实现的更改需要重新编译一个文件,而标题的更改需要重新编译实际包含此标头的文件.这可以很容易地在一次小的改变之后加速重建100倍或更多(取决于.c文件的数量).

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

    有一个大胖12核处理器启用超线程?可惜,你的串联源文件是由一个线程编译的.你刚刚失去了一个大于20的因子的加速......好吧,这是一个极端的例子,但我已经建立了软件make -j16,我告诉你,它可以产生巨大的差异.

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

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

    显然,这个阈值的精确位置取决于编译器和传递给它的优化标志,但我看到编译器在一个巨大的源文件上花了半个多小时.你不希望在你的change-compile-test循环中遇到这样的障碍.

毫无疑问:即使它带来了所有这些问题,也有人在实践中使用.c文件连接,并且一些C++程序员通过将所有内容移动到模板中来获得相同的点(以便在.hpp文件并没有关联的.cpp文件),让预处理器进行连接.我没有看到他们如何能够忽视这些问题,但他们确实如此.

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


Lun*_*din 15

因为拆分是好的程序设计.良好的程序设计完全是关于模块化,自治代码模块和代码可重用性.事实证明,在进行程序设计时,常识会让你走得很远:不属于一起的东西不应该放在一起.

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

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

假设您正在编写固件来控制汽车.程序中的一个模块控制汽车FM收音机.然后,您在另一个项目中重新使用无线电代码,以控制智能手机中的FM收音机.然后你的无线电代码将无法编译,因为它无法找到制动器,车轮,齿轮等等.对FM收音机没有任何意义的事情,更不用说智能手机了解.

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

如果一个模块中的错误完全打破了与之无关的问题,那几乎可以肯定是因为程序设计不佳.实现糟糕程序设计的某种方法是将项目中的所有内容合并为一个大blob.


Rei*_*ica 11

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

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


Fre*_*pin 8

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

如果您有 - 如上例所示 - 10,000个源文件并且编译一个需要10毫秒,那么如果您只编译此更改的文件,整个项目将在(10毫秒+链接时间)内逐步构建(在更改单个文件之后),或者(10 ms*10000 +短链接时间)如果将所有内容编译为单个连续blob.


Dmi*_*yev 7

虽然您仍然可以以模块化方式编写程序并将其构建为单个翻译单元,但您将错过C提供的所有机制来强制执行该模块化.使用多个翻译单元,您可以使用eg externstatickeywords 来精确控制模块的界面.

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

  • 在C中,**模块化**主要是**常规**(与文件中的组织无关). (5认同)