Random Tech Thoughts

The title above is not random

C 语言头文件的问题

今年 11 月的 LLVM 开发者会议上 Apple 工程师 Doug Gregor 给出了一个为 C 引入 module 系统的提案,演讲视频幻灯片均可下载。

在看这个演讲之前,我并没有意识到 C 的头文件存在如此之大的问题。我对 C (以及 C++) 头文件最大的抱怨在于需要额外维护一份函数接口文件。

从 Gregor 的演讲来看,C 头文件最大的两个问题有两个:

  • 易于出错
  • 导致巨大的编译开销

这两个问题的根源都在于 C 头文件的工作方式:#include 只是简单的将指定文件包含进来,而没有程序任何的语义信息

先来看第一个问题,采用幻灯片里的例子,下面的 C 代码定义了 FILE 这个宏,然后包含了头文件 stdio.h

1
2
3
4
5
6
#define FILE “MyFile.txt”
#include <stdio.h>

int main() {
  printf(Hello, world!\n);
}

尝试编译这个文件会遇到一大堆的编译错误,原因很简单,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。

Comments