这是否和外部调用有关?为什么现在大多数语言都没有采用这种设计?为什么调用dll有时需要使用Windows提供的API导出函数或者结构,而不能直接include xxxx.h或者像C#写的dll那样在项目中添加引用然后直接using xxxx。
我试着从C/C 历史演变的角度回答下这个问题。
上世纪70年代初,C语言初始版本被设计出来时,是没有头文件的。这一点与后世的Java只有 .java 文件,C#只有 .cs 文件很相似。即使是现代的C编译器,头文件也不是必须的。我使用下面这个例子说明:
// alpha.cint main() { print_hello();}// beta.cvoid 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 源文件,编译无法通过,这个后文会做解释”一语)。函数与数据结构需要提前声明才能使用更是不可或缺。
以上。共勉。
学习从来不是一个人的事情,要有个相互监督的伙伴,工作需要学习C/C 或者为了入行、转行学习C/C 的伙伴可以私信回复小编“学习”领取全套免费C/C 学习资料、