今年 11 月的 LLVM 开发者会议上 Apple 工程师 Doug Gregor 给出了一个为 C 引入 module 系统的提案,演讲视频和幻灯片均可下载。
在看这个演讲之前,我并没有意识到 C 的头文件存在如此之大的问题。我对 C (以及 C++) 头文件最大的抱怨在于需要额外维护一份函数接口文件。
从 Gregor 的演讲来看,C 头文件最大的两个问题有两个:
- 易于出错
- 导致巨大的编译开销
这两个问题的根源都在于 C 头文件的工作方式:#include
只是简单的将指定文件包含进来,而没有程序任何的语义信息。
先来看第一个问题,采用幻灯片里的例子,下面的 C 代码定义了 FILE
这个宏,然后包含了头文件 stdio.h
1 2 3 4 5 6 |
|
尝试编译这个文件会遇到一大堆的编译错误,原因很简单,stdio.h
中出现的所有的字符串 FILE
都被替换成了 “MyFile.txt”。这里定义一个名为 FILE
的宏错误还是比较明显的,如果是使用其他库,还是很有可能不小心在引入头文件时发生名字冲突。引入名字冲突导致编译错误还好,最怕的就是 silient error,出错了得找半天。
编译开销的问题其实很简单,之所以以前没有意识到是因为小项目感觉不到这个问题,大项目上编译速度的影响才会比较严重。假设有 M 个头文件,被 N 个 C 文件包含,那么头文件在编译时总共被包含 M*N 次。这里的开销在于编译每个文件需要打开很多其他的头文件,而且每个头文件都需要重新解析。C++ 由于很多头文件中包含代码,所以使得编译速度的问题变得更加严重。Gregor 给的例子是 LLVM 中一个 469K 的实现文件在展开后达到了 3.8M,一个数量级的差异!
目前名字冲突的避免方案有几个:
- 给宏名字加 prefix,内部使用的名字前加下划线等等
- 对可能冲突的名字使用
#undef
- 这个方法虽然可以保证头文件内部不会由于外部定义导致名字冲突,但如果多个头文件都使用了
#undef
,每个文件内部都定义相同名字的宏,那么头文件包含顺序不同得到的同名宏定义就会不同,这是一个危险的 silient error
- 这个方法虽然可以保证头文件内部不会由于外部定义导致名字冲突,但如果多个头文件都使用了
编译速度的一个解决方案是预编译头文件,但是这个要求用户始终保持头文件和预编译版本的一致性。Gregor 认为这是一个糟糕的解决方案,我自己没有使用过这一技术,不加评论。
Gregor 对此提出了一个兼容现在 C 的使用习惯的方案,有兴趣的同学可以自己去看他的 slides。
在看这个 talk 时候经常想起 Rob Pike 在 SPLASH 2012 的演讲 Go at Google。其中也提到了头文件导致编译速度慢的问题。Go 的解决方案一方面是把 import
作为语法级别上的语句,另一方面是精心设计 package binary,其中包含解析过的代码信息,使得 import 任何一个 package 只要打开一个文件而且不需要重复编译。在后面的博客里打算介绍一下这次演讲的内容,其中包含很多 Go 的 design choice。