32.程序员应如何理解头文件?

相信很多同学在学习C/C++后都有这样的疑问,#include这句话到底是怎么意思?这句话的背后隐含了什么?我们常用的stdio.h存放在了哪里?

这篇文章就来解答这个问题。

谁来处理头文件

有上述疑问的同学很可能是因为不熟悉一个叫预编译器(preprocessor)的东西。

让我们简单的了解一下可执行程序的生成过程。

程序员写的大家都可读的代码是不能被CPU直接执行的,CPU可以执行的代码是二进制机器指令,因此一定有某个过程将程序员写的程序转换为了机器指令,这就是编译器。

32.程序员应如何理解头文件? - 图1

以上大部分同学应该都知道,但是你知道编译器在将代码翻译成机器指令前其实还有一个步骤吗?这个步骤就是预编译。

那么预编译都用来做什么呢?请注意,接下来是重点

预编译的工作非常简单,预编译器找到源文件中#include指定的文件,然后copy这些文件的内容并粘贴到#include这一行所在的位置

假设在源文件a.c的第一行有一句#include \,那么预编译器怎么处理?

预编译找到stdio.h,把stdio.h的内容粘贴到a.c的第一行中。

是不是很简单,完成这一过程后才是编译器的任务。

32.程序员应如何理解头文件? - 图2

因此我们知道,原来#include其实是告诉预编译器把指定的头文件内容粘贴到当前include所在的位置,也就是进行文本替换。

头文件是不会被编译的

从上一节中我们知道头文件原来是被预编译器处理的,编译器在编译源文件时拿到的是已经被预编译器处理过后的源文件,因此头文件是不会被编译器直接处理的。

这一点要能意识到。

#include可以被放到源文件的任意位置

实际上#include可以出现在代码中的任意一行,只不过我们习惯了在开头使用#include,这是因为变量在声明之前是不能被使用的。

但是我们已经知道了#include其实就是告诉预编译器做一个简单的文本替换,因此任何需要进行文本替换的需求其实都可以通过#include来完成的,记得博主很早在阅读C代码看到#include用作文本替换时大吃一惊,原来#include还可以这样使用,类似这样:

  1. typedef enum {
  2. #include <test.h>
  3. enum1,
  4. enum2,
  5. } test_enum;

实际上就是test.h中包含了一系列可以放到enum中的名字而已,预编译器在处理时会把test.h中的内容在这一行展开,这样编译器拿到的就是完整的enum定义了。

如何查看预编译器处理后的文件

一些好奇心强的同学可能会问那我们能不能看到预编译器处理后的文件吗?

答案是可以的。

假设使用的编译器是gcc,那么使用-E选项就可以,-E选项告诉编译器在处理源文件时不要编译、不要汇编和链接,仅预处理。

  1. $ gcc -E test.c

使用上述命令就可以看到预处理后的文件是什么样子的。

其它编译器肯定也能找到类似的支持。

两种使用头文件的方式

你一定注意到了,其实#include有两种写法,一种是#include<>;另一种是#include “”,即:

  1. #include <code.h>
  2. #include "code.h"

那么这两种使用方法有什么区别吗?

注意,知道这两种用法背后的含义对于程序员来说是非常重要的。

预编译器要想处理头文件首先必须要能找到这个头文件

如果一个头文件放到了<>中,那么预编译器会在系统头文件所在的路径下开始找,在Linux下这个路径是/usr/include,让我们来看一下/usr/include这个文件夹下都有什么:

32.程序员应如何理解头文件? - 图3

我们可以看到这里有很多头文件,注意划红线的位置,原来我们常用的#include\就放在了这里,现在终于解答一个困扰了我们很久的问题。

实际上这里存放的就是标准库头文件,关于标准库参见《程序员应如何理解标准库》。

接下来就简单了,如果头文件被放到了双引号“”中呢?

很显然只不过就是预编译器搜索路径不再是系统头文件所在路径了,而是以源文件所在位置开始查找,当然不同的编译器策略可能稍有差别。

当在这些路径中找不到include的头文件时就会抛出错误“fatal error: ***.h: No such file or directory”,这是程序员经常遇到的错误,现在你应该知道怎么排查这类问题了吧。

为什么要使用头文件

最后来回答一下为什么的问题,是啊,程序员为什么要使用头文件这种东西呢?

还记得开始学编程时用的经典的HelloWorld程序吗?

  1. #include <stdio.h>
  2. int main() {
  3. printf("hello world\n");
  4. return 0;
  5. }

在这段简单的代码中实际上如果我们不使用printf函数打印东西的话根本就不需要stdio.h,那么程序就变成了这样:

  1. int main() {
  2. return 0;
  3. }

注意该代码不依赖任何头文件,不要怀疑,该代码可以正确的编译运行

如果程序员愿意的话可以把项目所有实现代码都放到这个文件中,就像这样:

  1. void funA() {
  2. ...
  3. }
  4. void funB() {
  5. ...
  6. }
  7. int main() {
  8. ...
  9. funA();
  10. funB();
  11. }

该程序不依赖任何头文件,所有实现代码都放到了一个源文件,而对于现在稍微有规模的软件项目其代码量都在十几万、甚至上百万,你能想象一个包含十几万行代码的源文件是一种怎样的场景吗?

这样的代码有没有可能写出呢?

答案是有的,只要这个项目的程序员对于所有使用到的轮子都从头到尾重复打造一遍,比如自己实现用到的标准库中的函数,比如printf,自己实现各种数据结构等等等等。

而且这样的项目有没有办法维护呢?

答案是有的,重赏之下必有勇夫,只要开出百万年薪必然有人入坑。

因此,我们发现这样写代码不但要重复造轮子还极其难以维护。

所以现在的软件工程一项原则就是复用,能用其它人的代码绝不会自己重复写一份。

那么问题来了,我们该怎样使用其它人写好的代码呢?

我们该调用哪些函数,这些函数的返回值是什么?参数是什么?

头文件帮程序员解决了上述问题。

头文件里仔仔细细的写好了该模块有哪些函数可供使用者调用、返回值是什么、参数是什么,但头文件中并不会包含实现,这是因为C/C++语言不要求函数的声明和实现必须呆在同一个地方。

因此你会看到,头文件的作用其实是和我们常用的说明书没什么区别。

现在问题就简单了,我们再也不需要一个包含几十万代码的源文件了,程序员可以将其模块化,各个团队负责一个模块,每个模块会编写一些头文件供其它人调用,同时每个模块只写一次,其它团队有需要可以直接使用,最后再把各个模块组合起来,这样大家各司其职又能最大程度实现代码复用。

最后值得注意的一点就是,头文件其实是让编译器知道该怎样生成调用函数的机器指令,而真正将相关代码打包到可执行程序的是链接器,因此作为程序员不仅需要指定用哪些头文件,还要指定头文件中函数的实现代码,也就是程序员常说的库在哪里。

总结

现在大家应该对头文件有一个全面的认知了吧,原来include只是告诉预编译器在当前位置展开头文件,同时我们也知道了两种include的使用方法及其区别,最后我们了解了为什么要发明头文件这种技术,希望这篇文章能帮你彻底理解头文件。