包含源文件 —— 是奇技淫巧还是饮鸩止渴?

作者:starwing83网友
  C语言如此流行,有一个很重要的原因是C语言志向于“抽象、泛化”概念而不是为了一个应用就加一个特殊的概念。这样的思想使得C语言的概念通用而有生命力。然而,正是这个原因,也导致了C语言极易被误用。因此教材就应该在这方面尽一本教材的引导的责任——告诉你什么是应该做的,什么是容易出错的。而至少在预处理这个方面,谭老并没有尽到自己该进的责任。

  C语言的预处理是很通用的,因为它在文本上操作,进行简单的文本替换。所以它和C的语法并不在一个逻辑层次上。其中有一种预处理操作就是这里我们要讨论的包含:#include 预处理符。#include 预处理符并不一定非要出现在程序的开头,事实上它可以出现在程序文本的任何地方,只要它出现在行首即可。包含本质上就是把一个文件原封不动地“粘贴”到 #include 预处理符所在的地方。包含提供了强大的功能,比如可以在文件间共享一系列的信息。因此谭老就“灵活运用”了这个概念:

图11.2(a)为文件file1.c,该文件中有一个#include <file2.c>指令
——谭浩强 ,《C程序设计(第四版)学习辅导》,清华大学出版社,2010年7月,p186

  我们知道,C语言有个约定俗成的惯例——被包含的文件应该以 .h 后缀名结尾,这些文件被称为头文件。而谭老在这里却在教材里面堂而皇之地包含了一个通常我们认为是被直接编译(而不是用作包含目的)的源代码文件。在一本面向初学者的教材中这样不顾约定肆意书写代码正确合适吗?
  首先,我们要知道,为什么C语言会有“.h文件用于包含,而.c文件用于编译”这么一个约定俗成的规定。这里我们需要介绍一个基本的概念:翻译单元(translation unit),并了解C程序到底是怎么被“编译”出来的。
  C语言的程序是以翻译单元为单位编译的。通常一个翻译单元就是一个源程序文件。这个源程序文件可以利用包含的方法去包含其他的文件。那些被包含的文件就被直接“复制”到包含的地方了。你可以认为是一堆的文件,比如 stdio.h 啦,stdlib.h 啦,这里的 file2.c 啦“拼接”成了一个非常非常大的文件,这个过程是编译系统中的“预处理器”完成的。然后系统中的“编译器”就在这个预处理器生成的非常大的文件上工作了,并将其翻译成一个“目标文件”。
  在这个目标文件中,会有一些C语言的对象(如函数,全局变量等等,他们在C语言中统称“对象”,注意和C++对象不同)是“有名字的”,你也会用到一些有名字却不知道在哪儿的对象,比如 printf ,那么为了整个程序最后能执行,C编译器就需要去查找哪儿有这个 printf ,它按照这个名字去一些预定的地方,以及你提供给它的地方去找各种做好的“目标文件”,然后在这些目标文件中查找有没有一个东西叫做 printf  ,一旦找到了,系统中的“链接器”就会用这个目标文件中导出的 printf 的接口在你自己的目标文件里面替换,最后把所有的目标文件合起来做成一个大文件,这个文件就是可执行程序了。
  C程序都是通过预处理(前面说的拼接)、编译(将拼接后的翻译单元变成目标文件)、链接(在各个目标文件中根据名字查找需要的对象)三个过程,利用预处理器,编译器和链接器三个工具才会变成最终可以被执行的程序的【注】。
  这里我们可以把一个又一个的目标文件看做是一个个的黑盒子,把有名字的对象当作这个黑盒子上的“接口”,我们将不同的黑盒子接在一起,然后整个黑盒子组合就能工作了,这就是链接过程。而编译就是从被处理好的程序文本中生成黑盒子的过程,当然我们这里提到的包含就是处理程序文本的其中一个过程。
  在C语言里面,什么样的对象有资格可以在目标文件间有一个名字呢?答案是“外部链接性对象”,C语言的对象都是有链接性的。这又是一个大话题。我们现在只需要知道有办法让C的函数或者全局变量等等这些对象的名字被其他目标文件“看到”即可。这种让其他目标文件看到自己的名字的对象所具有的链接性,就叫做“外部链接性”。C语言的函数默认具有外部链接性。而特殊的静态函数不具有外部链接性。
  从上面的介绍中,大家可以看到,如果你在 .c 文件中写了一些函数定义,而将其include 到其他的 .c 文件中去了,那么如果同时编译这两个 .c 文件。那么就在两个目标文件中有相同的名字被所有人知道。编译器不知道该选择那个对象有资格拥有这个名字,于是链接器拒绝进行链接操作,这就是链接错误。
  也就是说,谭老的这种写法是很容易出问题的。如果你只编译链接 file2.c,那么一切都没有问题,但是如果你同时编译这两个C文件,那么就会导致一个链接错误。这也就是为什么前面提到的,被包含的文件必须是 .h 文件的原因。因为我们约定俗成不会在.h 文件里面书写定义,我们只是在 .h 文件里面告诉编译器:在某个目标文件中有一个对象,它的名字叫做 printf 或者 rand ,所以即使多个 .c 文件包含了同一个 .h 文件,也不会造成链接错误。最重要的是,约定俗成地,我们默认一个翻译单元就是一个 .c文件,而 .h 文件除非被主动包含否则是不参与编译过程的。这样就很安全了。
  我们来总结一下,在C语言的世界里,我们约定俗成:
  - .c 文件约定俗成地是作为一个独立翻译单元存在的。它的功能就是真正的生成一系列对象,让链接器“有米可炊”。
  - .h 文件是用于向其他模块导出,告诉别人“我这里有这么一个对象,快来用吧”的。它存在的目的是告诉编译器“这个就是米”- 而至于“米在哪儿”的问题,则是链接器的工作,由链接器自行寻找,我们只是偶尔提供一些启示,告诉它在哪儿寻找而已。
  谭老完全无视掉了这种约定俗成的规则,这会对初学者造成很大的误导。这实际上是饮鸩止渴,谭老自己是自圆其说了,但是会给初学者造成很大的困扰和危害,甚至会导致初学者走很多的弯路而不自知。
  那么,这种约定俗成是不是就是金科玉律呢?我们可不可以包含其他的非 .h 文件呢?特别的,我们可不可以包含 .c 文件呢?
  虽然我们不提倡初学者这么做,但事实是,在很多情况下,这种现象是会出现的。很多实际的项目都用到了这个技术。这个技术的出现是为了解决一些C语言在实际开发中的固有问题。
  一种很通常的情况出现在一些小项目里,为了方便编译,这样的小项目通常会提供一个所谓的 amalgamation 文件,在这个文件中,将所有的 .c 源代码包含进来,然后要编译这个项目,只需要编译这个单个的 amalgamation 文件即可。这种技术的出现或者是为了方便小项目被嵌入其他工程中,或者简化编译过程,或者方便编译器优化,或者加快编译速度等等原因。但是我们要知道,这种做法和一个一个地编译 .c 文件在语法上是完全等价的。它更多地是一种为了方便考虑而出现的解决方案。
  还有一种情况是某个实现需要跨平台,或者支持多种不同的实现。这个时候,我们会将各种不同的实现或者针对不同操作系统的部分写成一个 .c 文件,并在主文件里面进行选择性的包含。这种情况其实是可以通过编译指令来完成的,不过如果结合下面的这种情况后,包含 .c 文件的方案能获得额外的好处。另外的好处是,如果程序需要同时支持多个后端,则可以通过声明预处理符来改变一个 .c 文件的行为,并通过多次包含而获得多个后端。Lua 的 lmd5 模块就是这么做的。
  另外一种情况是项目很复杂,而由很多很多的函数组成,我们知道C的函数默认是具有外部链接性的。但是项目维护者恰巧不愿意这些小函数被外界知道了——也就是说,项目维护者不希望链接器“看得到”有这些函数存在,于是维护者将其声明为静态函数,即不允许它们被其他翻译单元(目标文件)“看见”。然而我们知道C语言的编译是以翻译单元为单位的,既然不允许被其他文件所看到,那么这些静态函数就不得不被写在同一个文件里面了。当项目很大时,这可能会造成一个文件就有几十万行代码。为了避免这个问题,项目维护者将这些内部的小函数分别写在独立的文件中,将其声明为静态的,然后利用包含让他们实际上属于同一个翻译单元。这样就能同时解决上述的两个问题了。
  但是,即便是这种情况,起一个不容易被误解的后缀名,比如 .inc 也是很有益的。
  如果想要文件能够自动被编辑器认出是C文件,那么在后缀名非末尾的部分带一个 .c 就行了(比如 .c.inc 这种),大多数的编辑器都会读取所有的后缀并选择一个最合适的。
  虽然只是改一个后缀名,这样还是会给项目维护带来好处。而直接包含 .c 文件仍然是被认为很不自然,也很违反直觉的。
  这里要提到一点,这里修改后缀名只是为了习惯考虑。如果不改后缀名,这些 .c 文件即使被编译也不会造成恶劣的影响,因为这些文件中的函数都是隐藏的。实际上最后编译出来的目标文件根本不会具有任何的接口,也不可能被链接器选中链接的。
  对于后面提出的情况,这里仍然有一个很不错的解决方案,它比直接包含 .c 文件更好,比包含 .inc 文件要清晰要约定俗成得多。这个方案就是将这些小函数写成静态函数,并放到私有头文件中,然后由需要的文件去包含这些私有的头文件。因为私有的头文件比私有的源代码文件要安全得多,也好管理得多,也不容易造成上面提到的编辑器不认的问题。最后,这些私有头文件可以起很明显的名字,有助于项目维护,甚至这些私有头文件可以和公开头文件放在不同的地方,更不容易造成误解。
  我们知道,C语言是很自由的一门语言,那么为了更好地合作和沟通,我们在C语言之外就会有很多约定俗成的规则。所谓“规则的出现就是为了被打破的”,我们并不是要读者去墨守成规。但是,读者在做开发的时候,千万千万记住自己到底要做什么,不要为了图个新鲜,或者为了打破规则而去打破规则。如果做得每件事情都有自己的道理,都经过了自己独立的思考,那么打破规则也是可以的。
  然而,教科书要有教科书自己的考虑。教科书要告诉读者这些规则是什么,为什么会产生这些规则,不遵守这些规则会怎么样,什么情况下可以打破这些规则。谭老的书里却完全没有提到这些。我想是因为谭老自己都不知道有这些规则吧。谭书的这种做法,实在不是一本稍微入流的教科书的做法。读者如果没有自我清晰的认识,是很容易被误导的。
  最后再强调一次,奇技淫巧是可以的,但是读者一定要非常明确“自己要做什么,除此以外有没有更好的办法做这件事,有没有标准和自然的方法做这件事”。C语言如此自由,所以需要开发者更清醒地去驾驭它。

【注】这里要提到的是,大多数现代的编译系统中的“编译器”的部分,实际上是由两个部分组成的:一个叫做编译器,功能是将预处理器处理后的结果变成汇编语言代码,一个叫做汇编器,功能是将汇编代码变成最后的目标文件。这部分内容和主题无关,这里就省略掉了。

posted @ 2012-04-23 08:30  garbageMan  阅读(5383)  评论(18编辑  收藏  举报