我来自脚本背景,C中的预处理器对我来说一直都很难看。然而,当我学习编写小型C程序时,我已经接受了它。我只是真的使用预处理器来包含我为自己的函数编写的标准库和头文件。
我的问题是为什么C程序员不会跳过所有包含并简单地连接他们的C源文件然后编译它?如果您将所有包含放在一个地方,则只需要定义一次所需内容,而不是在所有源文件中定义。
以下是我所描述的一个例子。这里我有三个文件:
// main.c
int main() {
foo();
printf("world\n");
return 0;
}
// foo.c
void foo() {
printf("Hello ");
}
cat *.c > to_compile.c && gcc -o myprogram to_compile.c
通过在Makefile中执行类似var sheet, jsonData,
excelString = new Buffer(base64, 'base64').toString('binary'),
workbook = excelParser.read(excelString, {type: 'binary'});
if (workbook.Sheets['Sheet1']) {
sheet = workbook.Sheets['Sheet1'];
workbook.Sheets['Sheet1'] = sheet;
jsonData = excelParser.utils.sheet_to_json(workbook.Sheets['Sheet1'], {});
的操作,我可以减少我编写的代码量。
这意味着我不必为我创建的每个函数编写头文件(因为它们已经在主源文件中),这也意味着我不必在每个文件中包含标准库我创造。这对我来说似乎是一个好主意!
然而我意识到C是一种非常成熟的编程语言,我想象的是比我聪明的其他人已经有了这个想法,并决定不使用它。为什么不呢?
答案 0 :(得分:26)
你可以这样做,但我们喜欢将C程序分成单独的翻译单元,主要是因为:
加快构建速度。您只需要重建已更改的文件,这些文件可以链接与其他编译文件一起构成最终程序。
C标准库由预编译的组件组成。你真的想要重新编译所有这些吗?
如果将代码库分成不同的文件,与其他程序员协作会更容易。
答案 1 :(得分:16)
您的方法可能会有所收获,但对于像C这样的语言,编译每个模块更有意义。
答案 2 :(得分: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行代码时,你肯定需要一个支持增量和并行构建的构建系统。 否则,你在浪费你的工作时间。
答案 3 :(得分:15)
因为拆分是好的程序设计。良好的程序设计完全是关于模块化,自治代码模块和代码可重用性。事实证明,在进行程序设计时,常识会让你走得很远:不属于一起的东西不应该放在一起。
将不相关的代码放在不同的翻译单元中意味着您可以尽可能地本地化变量和函数的范围。
将各种东西合并在一起会产生紧密耦合,这意味着代码文件之间的尴尬依赖关系,甚至不需要知道彼此的存在。这就是为什么包含项目中所有包含的“global.h”是一件坏事,因为它会在整个项目中的每个非相关文件之间建立紧密耦合。
假设您正在编写固件来控制汽车。程序中的一个模块控制汽车FM收音机。然后,您在另一个项目中重新使用无线电代码,以控制智能手机中的FM收音机。然后你的无线电代码将无法编译,因为它无法找到制动器,车轮,齿轮等等。对FM收音机没有任何意义的东西,更不用说智能手机了解。
更糟糕的是,如果你有紧密耦合,bug会在整个程序中升级,而不是保持本地到bug所在的模块。这使得bug的后果更加严重。你在FM收音机代码中写了一个错误,然后突然刹车停止工作。即使您没有触及包含该错误的更新的刹车代码。
如果一个模块中的错误完全打破了与之无关的问题,那几乎可以肯定是因为程序设计不佳。实现糟糕的程序设计的某种方法是将项目中的所有内容合并为一个大的blob。
答案 4 :(得分:11)
头文件应该定义接口 - 这是一个理想的约定。它们并不意味着声明对应的.c
文件或一组.c
文件中的所有内容。相反,他们声明了.c
文件中可供其用户使用的所有功能。精心设计的.h
文件包含.c
文件中代码公开的界面的基本文档,即使其中没有单个注释也是如此。接近C模块设计的一种方法是首先编写头文件,然后在一个或多个.c
文件中实现它。
推论:.c
文件实现内部的函数和数据结构通常不属于头文件。您可能需要前向声明,但那些应该是本地的,因此声明和定义的所有变量和函数都应该是static
:如果它们不是接口的一部分,则链接器不应该看到它们。
答案 5 :(得分:8)
主要原因是编译时间。更改它时编译一个小文件可能需要很短的时间。但是,如果您在更改单行时编译整个项目,那么您将编译 - 例如 - 每次10,000个文件,这可能需要更长的时间。
如果你有 - 如上例所示 - 10,000个源文件并且编译一个需要10毫秒,那么整个项目以递增方式(在更改单个文件之后)建立(10毫秒+链接时间)如果你只是编译这个改变了如果将所有内容编译为单个连续blob,则为文件或(10 ms * 10000 +短链接时间)。
答案 6 :(得分:7)
虽然您仍然可以以模块化方式编写程序并将其构建为单个翻译单元,但您将错过所有 C提供的机制来强制执行该模块化。使用多个翻译单元,您可以通过使用例如对模块的接口进行精确控制。 extern
和static
个关键字。
通过将您的代码合并到一个翻译单元中,您将错过任何模块化问题,因为编译器不会向您发出警告。在一个大项目中,这最终会导致意外的依赖性蔓延。最后,如果不在其他模块中创建全局副作用,则无法更改任何模块。
答案 7 :(得分:4)
如果您将所有包含放在一个地方,则只需要定义一次所需内容,而不是在所有源文件中定义。
这是.h
文件的目的,因此您可以定义您需要的内容并将其包含在任何地方。有些项目甚至有一个everything.h
标题,其中包含每个.h
个文件。因此,您的专家也可以使用单独的.c
文件来实现。
这意味着我不必为我创建的每个函数编写头文件[...]
你不应该为每个函数编写一个头文件。您应该为一组相关函数设置一个头文件。因此, con 也无效。
答案 8 :(得分:2)
这意味着我不必为我创建的每个功能编写头文件(因为它们已经在主源文件中),这也意味着我不必包括我创建的每个文件中的标准库。这对我来说似乎是一个好主意!
你注意到的专业人士实际上是为什么有时会以较小的规模进行的。
对于大型节目,这是不切实际的。与其他提到的好答案一样,这可能会大大增加构建时间。
但是,它可以用来将翻译单元分解成更小的位,这些位以一种让人联想到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.c
或b.c
,而是仅构建ab.o
ab.c
。
ab.c
完成了什么?
它包括生成单个翻译单元的两个文件,并提供该实用程序的原型。因此a.c
和b.c
中的代码都可以看到它,无论它们的顺序如何,并且不需要函数extern
。