1、C语言模块化编程中的头文件
实际开发中一般是将函数和变量的声明放到头文件,再在当前源文件中 #include 进来。如果变量的值是固定的,最好使用宏来代替。
.c和.h文件都是源文件,除了后缀不一样便于区分外和管理外,其他的都是相同的,在.c中编写的代码同样也可以写在.h中,包括函数定义、变量定义、预处理等。
但是,.h 和 .c 在项目中承担的角色不一样:.c 文件主要负责实现,也就是定义函数和变量;.h 文件主要负责声明(包括变量声明和函数声明)、宏定义、类型定义等。这些不是C语法规定的内容,而是约定成俗的规范,或者说是长期形成的事实标准。
根据这份规范,头文件可以包含如下的内容:
可以声明函数,但不可以定义函数。
可以声明变量,但不可以定义变量。
可以定义宏,包括带参的宏和不带参的宏。
结构体的定义、自定义数据类型一般也放在头文件中。
在项目开发中,我们可以将一组相关的变量和函数定义在一个 .c 文件中,并用一个同名的 .h 文件(头文件)进行声明,其他模块如果需要使用某个变量或函数,那么引入这个头文件就可以。
这样做的另外一个好处是可以保护版权,我们在发布相关模块之前,可以将它们都编译成目标文件,或者打包成静态库,只要向用户提供头文件,用户就可以将这些模块链接到自己的程序中。
2、C语言标准库以及标准头文件
源文件通过编译可以生成目标文件(例如 GCC 下的 .o 和 Visual Studio 下的 .obj),并提供一个头文件向外暴露接口,除了保护版权,还可以将散乱的文件打包,便于发布和使用。
实际上我们一般不直接向用户提供目标文件,而是将多个相关的目标文件打包成一个静态链接库(Static Link Library),例如 Linux 下的 .a 和 Windows 下的 .lib。
打包静态库的过程很容易理解,就是将多个目标文件捆绑在一起形成一个新的文件,然后再加上一些索引,方便链接器找到,这和压缩文件的过程非常类似。
C语言在发布的时候已经将标准库打包到了静态库,并提供了相应的头文件,例如 stdio.h、stdlib.h、string.h 等。
Linux 一般将静态库和头文件放在/lib和/user/lib目录下,C语言标准库的名字是libc.a,大家可以通过locate命令来查找它的路径:
复制代码
$ locate libc.a
/usr/lib/x86_64-redhat-linux6E/lib64/libc.a
$ locate stdio.h
/usr/include/stdio.h
/usr/include/bits/stdio.h
/usr/include/c++/4.8.2/tr1/stdio.h
/usr/lib/x86_64-redhat-linux6E/include/stdio.h
/usr/lib/x86_64-redhat-linux6E/include/bits/stdio.h
在 Windows 下,标准库由 IDE 携带,如果你使用的是 Visual Studio,那么在安装目录下的\VC\include文件夹中会看到很多头文件,包括我们常用的 stdio.h、stdlib.h 等;在\VC\lib文件夹中有很多 .lib 文件,这就是链接器要用到的静态库。
大家也可以在当前工程的属性面板(在工程名处单击鼠标右键选择“属性”)中查看路径:
ANSI C 标准共定义了 15 个头文件,称为“C标准库”,所有的编译器都必须支持,如何正确并熟练的使用这些标准库,可以反映出一个程序员的水平:
合格程序员:、、、
熟练程序员:、、、
优秀程序员:、、、、、、
C语言共有两套标准,也就是 ANSI C 和 C99。ANSI C 是较早的标准,各种编译器都能很好的支持,C99 是后来的标准,编译器对它的支持不尽相同,请大家阅读《C语言的三套标准:C89、C99和C11》了解更多细节。
除了C标准库,编译器一般也会附带自己的库,以增加功能,方便用户开发,争夺市场份额。这些库中的每一个函数都在对应的头文件中声明,可以通过 #include 预处理命令导入,编译时会被合并到当前文件。
3、细说C语言头文件的路径
我们常说,引入编译器自带的头文件(包括标准头文件)用尖括号,引入程序自定义的头文件用双引号,例如:
include //引入标准头文件
include "myFile.h" //引入自定义的头文件
使用尖括号< >,编译器会到系统路径下查找头文件;而使用双引号" ",编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。也就是说,使用双引号比使用尖括号多了一个查找路径,它的功能更为强大,我们完全可以使用双引号来包含标准头文件,例如:
include "stdio.h"
include "stdlib.h"
那么,这里所说的“系统路径”和“当前路径”是什么意思呢?
3.1、绝对路径和相对路径
理论上讲,我们可以将头文件放在磁盘上的任意位置,只要带路径包含进来就可以。以 Windows 为例,在 D 盘下创建一个自定义的文件夹,名字为abc,它里面有一个头文件叫做xyz.h,那么在程序开头使用#include "D:\abc\xyz.h"就能够引入该头文件。
现在不妨假设 xyz.h 中有一个宏定义和一个变量:
define NAME "C语言中文网"
int age = 5;
我们不鼓励在头文件中定义变量,否则多次引入后会出现重复定义错误,这里仅是一个演示案例,并不规范。
下面的代码会输出头文件中的宏和变量:
include
include "D:\abc\xyz.h"
int main()
{//代码效果参考:http://www.zidongmutanji.com/zsjx/394573.html
printf("%s已经 %d 岁了!\n", NAME, age);
return 0;
}
运行结果:
C语言中文网已经 5 岁了!
(1)绝对路径
像D:\abc\xyz.h这种从盘符开始、完整地描述文件位置的路径就是绝对路径(Absolute Path)。
绝对路径从文件系统的“根部”开始查找文件:
1) 在 Windows 下,根部就是 C、D、E 这样的盘符,例如D:\a.h、E:\images\123.jpg、E:/videos/me.mp4、D://abc/xyz.h等,分隔符可以是正斜杠/也可以是反斜杠\,盘符后面的斜杠可以有一个也可以有两个。
2) Linux 没有盘符,根部就是/,例如/home/xxx/abc.h、/user/include/module.h等,分隔符只能是正斜杠/,比 Windows 简洁很多。
为了增强代码的可移植性,引入头文件时请尽量使用正斜杠/。
(2)相对路径
相对路径(relative path)是从当前目录(文件夹)开始查找文件;当前目录是指需要引入头文件的源文件所在的目录,这也是本文开头提到的“当前路径”。
以 Windows 为例,假设在E:/cDemo/中有源文件 main.c 和头文件 xyz.h,那么在 main.c 中使用#include "./xyz.h"语句就可以引入 xyz.h,其中./表示当前目录,也即E:/cDemo/。
如果将 xyz.h 移动到E:/cDemo/include/(main.c 所在目录的下级目录),那么包含语句就应该修改为#include "./include/xyz.h";对于 main.c 来说,此时的“当前目录”依然是E:/cDemo/。
如果将 xyz.h 移动到E:/(main.c 所在目录的上级目录),那么包含语句就应该修改为#include "./../xyz.h",其中../表示上级目录。./../xyz.h的意思是,在当前目录的上级目录中查找 xyz.h 文件。
如果将 xyz.h 移动到E:/include目录,那么包含语句就应该修改为#include "./../include/xyz.h"。
需要注意的是,我们可以将./省略,此时默认从当前目录开始查找,例如#include "xyz.h"、#include "include/xyz.h"、#include "../xyz.h"、#include "../include/xyz.h"。
上面介绍的相对路径的写法同样适用于 Linux,请大家亲自测试,这里不再赘述。
在实际开发中,我们都是将头文件放在当前工程目录下,非常建议大家使用相对路径,这样即使后来改变了工程所在目录,也无需修改包含语句,因为源文件的相对位置没有改变。
3.2、系统路径
Windows 下的C语言标准库由 IDE 自己携带,Linux 下的C语言标准库一般在固定的路径下,总起来说,标准库不在工程目录下,要使用绝对路径才能引入头文件,这样每次切换平台或者 IDE 都要修改包含路径,非常不方便。
为了让头文件更加具有实践意义,Windows 下的 IDE 都可以为静态库和头文件设置默认目录。以 Visual Studio 为例,在当前工程名处单击鼠标右键,选择“属性”,在弹出的对话框中就可以看到已经设置好的路径,如下图所示:
这些已经设置好的路径就是本文开头提到的“系统路径”。
当使用相对路径的方式引入头文件时,如果使用< >,那么“相对”的就是系统路径,也就是说,编译器会直接在这些系统路径下查找头文件;如果使用" ",那么首先“相对”的是当前路径,然后“相对”的才是系统路径,也就是说,编译器首先在当前路径下查找头文件,找不到的话才会继续在系统路径下查找。
而使用绝对路径的方式引入头文件时,< >和" "没有任何区别,因为头文件路径已经写死了(从根部开始查找),不需要“相对”任何路径。
总起来说,相对路径要有“相对”的目标,这个目标可以是当前路径,也可以是系统路径,< >和" "决定了到底相对哪个目标。
4、防止C语言头文件被重复包含
头文件包含命令 #include 的效果与直接复制粘贴头文件内容的效果是一样的,预处理器实际上也是这样做的,它会读取头文件的内容,然后输出到 #include 命令所在的位置。
头文件包含是一个递归(循环)的过程,如果被包含的头文件中还包含了其他的头文件,预处理器会继续将它们也包含进来;这个过程会一直持续下去,直到不再包含任何头文件,这与递归的过程颇为相似。
递归包含会导致一个问题,就是重复引入同一个源文件。例如在某个自定义头文件 xyz.h 中声明了一个 FILE 类型的指针,以使得所有的模块都能使用它,如下所示:
extern FILE *fp;
FILE 是在 stdio.h 中自定义的一个类型(本质上是一个结构体),要想使用它,必须包含 stdio.h,因此 xyz.h 中完整的代码应该是这样的:
include
现在假设程序的主模块 main.c 中需要使用 fp 变量和 printf() 函数,那么就需要同时引入 xyz.h 和 stdio.h:
include "xyz.h"
if( (fp = fopen("demo.txt", "r")) == NULL )
{
printf("File open failed!\n");
}
//TODO:
return 0;
这样一来,对于 main.c 这个模块,stdio.h 就被包含了两次。stdio.h 中除了有函数声明,还有宏定义、类型定义、结构体定义等,它们都会出现两次,如果不做任何处理,不仅会出现重复定义错误,而且不符合编程规范。
有人说,既然已经知道 xyz.h 中包含了 stdio.h,那么在 main.c 中不再包含 stdio.h 不就可以了吗?是的,确实如此,这样做就不会出现任何问题!
现在我们不妨换一种场景,假设 xyz1.h 中定义了类型 RYPE1,xyz2.h 中定义了类型 TYPE2,并且它们都包含了 stdio.h,如果主模块需要同时使用 TYPE1 和 TYPE2,就必须将 xyz1.h 和 xyz2.h 都包含进来,这样也会导致 stdio.h 被重复包含,并且无法回避,上面的方案解决不了问题。
实际上,头文件的交叉包含是非常普遍的现象,不仅我们自己创建的头文件是这样,标准头文件也是如此。例如,标准头文件 limits.h 中定义了一些与数据类型相关的宏(最大值、最小值、一个字节所包含的比特位等),stdlib.h 就包含了它。
我们必须找到一种行之有效的方案,使得头文件可以被包含多次,但效果与只包含一次相同。
在实际开发中,我们往往使用宏保护来解决这个问题。例如,在 xyz.h 中可以添加如下的宏定义:
ifndef _XYZ_H
define _XYZ_H
/ 头文件内容 /
endif
第一次包含头文件,会定义宏 _XYZ_H,并执行“头文件内容”部分的代码;第二次包含时因为已经定义了宏 _XYZ_H,不会重复执行“头文件内容”部分的代码。也就是说,头文件只在第一次包含时起作用,再次包含无效。
标准头文件也是这样做的,例如在 Visual Studio 2010 中,stdio.h 就有如下的宏定义:
ifndef _INC_STDIO
define _INC_STDIO
这种宏保护方案使得程序员可以“任性”地引入当前模块需要的所有头文件,不用操心这些头文件中是否包含了其他的头文件。