为什么C/C++要分为头文件和源文件?

这是否和外部调用有关?为什么现在大多数语言都没有采用这种设计?为什么调用dll有时需要使用Windows提供的API导出函数或者结构,而不能直接inc…
关注者
1,530
被浏览
497,398

46 个回答

我试着从C/C++历史演变的角度回答下这个问题。

上世纪70年代初,C语言初始版本被设计出来时,是没有头文件的。这一点与后世的Java只有 .java 文件,C#只有 .cs 文件很相似。即使是现代的C编译器,头文件也不是必须的。我使用下面这个例子说明:

// alpha.c

int main() {
    print_hello();
}

// beta.c

void print_hello() {
    puts("hello");
}

上例只有两个源文件,alpha.c 与 beta.c 。其中 alpha.c 使用了一个自定义函数 print_hello ,beta.c 中使用了标准库函数 puts 。注意:alpha.c 与 beta.c 都没有包含任何头文件。

你可以使用MS CL编译器来编译:

cl /Fe:program.exe alpha.c beta.c

或者 GCC 以及 Clang:

clang -o program alpha.c beta.c

这样会得到一个名为 program 的可执行文件,并且它可以正常工作。

以 beta.c 为例:当 beta.c 被编译时,编译器解析到名为 puts 的符号,虽然它是未定义的,但从语法上可以判断 puts 是一个函数,故而将其认定为函数,作为外部符号等待链接就可以了(倘若 alpha ,beta 是 C++ 源文件,编译无法通过,这个后文会做解释)。

下面我用ASCII字符绘制的“编译”与“链接”流程图:

alpha.c -> alpha.obj
                            \
                             program.exe
                            /
beta.c  -> beta.obj

相信这个流程作为基础知识已广为人知,我就不再赘述了。问题在于:当初为什么要采用这样的设计 ?将“编译”、“链接”两个步骤区分开,并让用户可知是什么意图 ?

其实这是上世纪60、70年代各语言的“套路”做法,因为各个 obj 文件可能并不是同一种语言源文件编译得到的,它们可能来自于 C,可能是汇编、也可能是 Fortran 这样与 C 一样的高级语言。即是说“编译”、“链接”的流程其实是这样的:

alpha.c    -> alpha.obj
                                \
beta.asm -> beta.obj     --> program.exe
                                /
gamma.f -> gamma.obj

所以,编译阶段C源文件(当然也包括其它语言的源文件)是不与其它源文件产生关系的,因为编译器(这里指的是狭义的编译器,不包括链接器)本身有可能并不能识别其它源。

说到这里,定然有人要问:连函数参数和返回值都不知道,直接链接然后调用,会不会出现问题。答案是:不会,至少当时不会。因为当时的C只有一种数据类型,即“字长”(同时代的大多数语言也一样)。

我们考虑这样一个函数调用:

n = add(1, 2, 3, 4);

[1] 首先,add函数的调用者,将4个参数自右向左压入栈,即是说压栈完成后 1 在栈顶,4在栈底;[2] 然后,add被调用,对于被调用者(也就是 add)而言,栈长度是不可知的,但第一个参数在栈顶,往下一个字长就是第二个参数,以此类推,所以栈长度不可知并不会带来问题;[3] add 处理完成后,将返回值放入数据寄存器,并返回;[4] 调用者弹栈,因为压栈操作是调用者实施的,故而栈长度、压栈前栈顶位置等信息调用者是可知的,可以调用者有能力保持栈平衡。

这里说一个题外话:倘若 调用者 压栈的参数不够,那会如何?答案是 被调用者 会在栈上读到垃圾数据;又问:倘若 被调用者 没有返回值,那会如何?答案是 调用者 会在寄存器得到垃圾数据;再问:如此在代码维护上不会有问题吗?答案是从后来的实践上看,问题不大,其实可以对比下如今python、lua等弱类型语言。

通过上面的论述,我们得知C语言设计之初是没有头文件的,调用某个函数也不需要提前声明。

不过好景不长,后来出现了不同的数据类型。例如出于可移植性和内存节省的考虑,出现了 short int 、long int ;为了加强对块处理的 IO 设备的支持,出现了 char 。如此就带来了一个问题,即函数的调用者不知道压栈的长度。例如有函数调用:

add(x, y);

调用者知道 add 是一个函数,也知道需要将 x、y 压栈,但应该是先压2个字节、再压4个字节喃,还是先压4个字节,再压2个字节喃;还是连续压2个4字节喃?

这里需要说明一下,在上世纪80年代intel 8084系的处理器普及以前,并没有公认的“字节(byte)”概念,以上只是我举例方便。

紧接着结构体等特性陆续引入,问题变得更复杂。在这种情况下,函数调用需要提前声明,以便让调用者得知函数的参数与返回值尺寸(结构体使用也需要提前声明,以便让调用者知道其成员、尺寸、内存对其规则等,这里不赘述了)。

于是,头文件就出现了。这里有人可能就会问了:为什么在编译一个源文件时,不去其它源文件查找声明,就如后世的Java、C#一样。主要原因上文已经说过:C源文件在编译时不与其它源产生关系,因为其它源可能根本就不是C;此外使用 include 将声明插入到源文件中,技术实现毕竟很简单,也可以说是一种技术惯性。

又后来出现了C++,由于函数重载、模板等特性,当编译器识别到一个函数,不仅是参数与返回值尺寸,连调用哪一个函数都无法从函数名辨别了(即上文的“倘若 alpha ,beta 是 C++ 源文件,编译无法通过,这个后文会做解释”一语)。函数与数据结构需要提前声明才能使用更是不可或缺。

以上。共勉。

因为编译出来的二进制码(比如.o,.obj,.lib,.dll)不包含全部自我描述的符号信息,要复用这种可执行码的话得另外的文件(比如调用约定、参数类型、返回值类型什么的)。C#和Java的可执行码自带元数据信息,但是这也意味着运行时的内存需求增加,毕竟这种自我描述的数据对最终用户来说是无用。当然在它们被发明的时候这些空间已经很便宜了,运行的时候浪费个几十K内存不是个事;但是C被发明的时候,64K的内存是四百多美元,给机器添加16或32KB内存的扩展槽,价格是三百美元(1980 Radio Shack Catalog Low-res page 171 of 176),一个程序经常几十个模块,运行的时候为每个模块去浪费几十K的内存是不可能的事情。

C++ 的Module包含元数据信息,不过提出来好几年了到现在还在讨论TS v1,不知道哪年才能进入标准……