LV030-头文件
一、头文件
我们写程序的时候,会加上 #include <stdio.h> 这样的头文件, C 语言中 .h 结尾的文件叫做头文件。 .c 和 .h 文件都是源文件,除了后缀不一样便于区分外和管理外,其他的都是相同的。
在 .c 中编写的代码同样也可以写在 .h 中,包括函数定义、变量定义、预处理等。但是 .h 和 .c 承担的角色不一样: .c 文件主要负责实现,也就是定义函数和变量; .h 文件主要负责声明(包括变量声明和函数声明)、宏定义、类型定义等。这些不是 C 语法规定的内容,而是约定成俗的规范,或者说是长期形成的事实标准。
在实际开发中,我们会将函数、变量等的声明放在 .h 文件中,以便于其他程序调用。这样做有什么好处呢?源文件通过编译可以生成目标文件(例如 GCC 下的 .o 和 Visual Studio 下的 .obj ),或者打包成静态库,只要向用户提供头文件,用户就可以将这些模块链接到自己的程序中。这样既可以保护版权,也便于发布和使用。
1. C 语言标准库
在 C 语言编程的时候,我们一般是看不到自带函数的具体实现的,例如 printf 函数,我们即便是跳转到定义,也只能看到一个函数生命罢了。这是因为 C 语言允许将多个相关的目标文件打包成一个静态链接库( Static Link Library ),例如 Linux 下的 .a 文件和 Windows 下的 .lib 文件。 C 语言在发布的时候已经将标准库打包到了静态库,并提供了相应的头文件,例如 stdio.h 、 stdlib.h 、 string.h 等。
Linux 一般将静态库和头文件放在 /lib 和 /user/lib 目录下, C 语言标准库的名字是 libc.a 。 Windows 下,标准库由 IDE 携带,像 Visual Studio ,在安装目录下的 \VC\include 文件夹中会看到很多头文件,包括常用的 stdio.h 、 stdlib.h 等, 在 \VC\lib 文件夹中有很多 .lib 文件,这就是链接器要用到的静态库。这里有两个网站,里边对 C 语言的标准库有一些很详细的说明,甚至还有一些函数的使用例子可以参考:
| C library | http://www.cplusplus.com/reference/clibrary/ |
| C Standard Library header files | https://en.cppreference.com/w/c/header |
2. 自定义头文件
2.1 包含哪些内容
一般来说我们自己实现了某些模块的功能,我们就可以自己来写相应的头文件,便于自己的调用。根据大家约定俗成的规范,自定义的头文件一般可以包含如下内容:
- 声明函数,但不可以定义函数(但也不一定,其实内敛函数一般都是直接定义在头文件)。
- 声明变量,但不可以定义变量。
- 定义宏,包括带参的宏和不带参的宏。
- 结构体的定义、自定义数据类型一般也放在头文件中。
Tips:在头文件声明函数的时候不用像声明变量一样加上 extern,即便不加也不会造成混乱,为了简便,往往是不用加的。
(1)为什么不用加?简单直接的回答是:因为在 C 语言标准中,函数声明默认就是
extern的。C 语言中,标识符(变量名、函数名)有两个重要的属性:作用域 和 链接属性。对于函数声明(注意是声明,不是定义)默认链接属性 为
external(外部的)。这意味着,当我们写一个函数原型时,编译器默认认为这个函数的定义可能在当前文件之外,或者将来会在某处提供。因此,extern关键字对于函数声明来说是 多余的,它是默认行为。c// 这两种写法完全等价 int add(int a, int b); // 隐式 extern extern int add(int a, int b); // 显式 extern(2)为什么函数和变量不一样?在头文件中声明 全局变量 时,必须显式写
extern,否则会出问题。区别在于“声明”和“定义”的判定:
- 对于变量
C 语言中,变量比较“特殊”。
int x;—— 在全局作用域下,这不仅是声明,还是 暂定定义,它会在目标文件中分配空间。extern int x;—— 这才是纯粹的 声明,告诉编译器“x 存在,但别在这里分配空间,去别处找”。如果在头文件里写int x;而不是extern int x;,那么每个包含这个头文件的.c文件都会定义一个名为x的变量。链接时就会报错。所以,变量声明 必须 加extern来抑制定义。
- 对于函数
函数的语法形式让编译器很容易区分“声明”和“定义”,有函数体
{ ... }的是 定义 并且以分号;结尾、没有函数体的是 声明。编译器能通过语法一眼看出这只是个声明,不需要分配空间,所以它不需要extern来帮忙区分。它直接默认这个声明是给外部用的。
2.2 使用实例
【说明】引入自定义头文件,一般格式如下:
#include "filename.h"- main.c
#include <stdio.h>
#include "fun1.h"
int main(int argc, char *argv[])
{
fun1();
printf("a = %d\n", a);
return 0;
}- fun1.c
#include <stdio.h>
int a = 100;
void fun1(void)
{
printf("fun1 a = %d\n", a);
}- fun1.h
extern int a;
extern void fun1(void);- 编译
在终端执行以下命令:
gcc *.c -Wall # 编译程序
./a.out # 运行可执行程序会看到有如下信息输出:
fun1 a = 100
a = 1003. 头文件路径
头文件在被包含的时候有两种格式,一种使用 <> ,另一种使用 " " :
#include <filename.h>
#include "filename.h"两者区别如下:
| < > | 编译器会到系统路径(C 语言标准静态链接库所在路径,Linux 中常见路径为/usr/include、/usr/local/include)下查找头文件。 |
| " " | 编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。 |
也就是说,使用双引号比使用尖括号多了一个查找路径,它的功能更为强大,所以完全可以使用双引号来包含标准头文件。但为了代码规范和编译效率,标准库通常还是使用 < >。
无论是尖括号还是双引号,所谓的“系统路径”和“用户路径”实际上是可以配置的。GCC / Clang 使用 -I (大写 i) 参数指定的路径,会被添加到搜索列表中。
- 对于
< >:编译器会搜索-I指定的路径。 - 对于
" ":编译器在查找完当前目录后,也会搜索-I指定的路径。
引入头文件时可以使用绝对路径,也可以使用相对路径。当使用相对路径的方式引入头文件时,如果使用 < > ,那么相对的就是系统路径,也就是说,编译器会直接在这些系统路径下查找头文件;如果使用 " " ,那么首先相对的是当前路径,然后相对的才是系统路径,而使用绝对路径的方式引入头文件时, < > 和 " " 没有任何区别,因为头文件路径已经固定了(从根目录开始查找),不需要相对任何路径。
总起来说,相对路径要有相对的目标,这个目标可以是当前路径,也可以是系统路径, < > 和 " " 决定了到底相对哪个目标。一般来说,自己编写的头文件在引用时最好使用相对路径,这样即便工程进行了移动,文件路径也不会出现问题。
二、重复引入头文件
头文件包含命令 #include 与直接复制粘贴头文件内容的效果是一样的,预处理器会读取头文件的内容,然后将文件内容插入到 #include 命令所在的位置。如果被包含的头文件中还包含了其他的头文件,预处理器会继续将它们也包含进来;这个过程会一直持续下去,直到不再包含任何头文件。
1. 有什么问题?
不懂就问,文件重复引入的话会有什么问题呢,为什么要关心这个问题?接下来我们看一个实例。
1.1 示例源码
- main.c
#include <stdio.h>
#include "func1.h"
#include "func2.h"
int main(int argc, char *argv[])
{
func1();
printf("main:a = %d\n", a);
return 0;
}- func1.c
#include "func1.h"
void func1(void)
{
printf("func1:a = %d\n", a);
}- func1.h
#include <stdio.h>
#include "func2.h"
extern void func1(void);- func2.h
int a;1.2 编译测试
在终端命令行执行以下命令:
gcc -E main.c -o main.i
vim main.i
会发现,变量 a 在预处理后,出现了两次定义。然后我们全部编译链接一下,让它生成可执行程序,在终端执行以下命令:
gcc *.c -Wall # 编译程序这个时候我们就会发现,编译似乎并没有报错,甚至于还可以正常执行,个中缘由嘛想一下是为啥嘞?前边提到了弱符号,未赋值的全局变量属于弱符号,它在程序中并不会报错,根据处理规则,会选择其中的一个,但若是提前初始化了,就会导致出现两个强符号,这在 GCC 中是不被允许的。接下来我们可以修改 func2.h 中 a 的定义,我们直接进行赋值,然后再进行编译,会看到有如下提示:
In file included from main.c:3:0:
fun2.h:3:5: error: redefinition of ‘a’
int a = 10;
^
In file included from fun1.h:3:0,
from main.c:2:
fun2.h:3:5: note: previous definition of ‘a’ was here
int a = 10;
^这是直接报了一个错误,是说 a 重复定义了。
1.3 总结
通过上述例子,我们会发现如果 a.h 里又包含了 b.h,而 main.c 里再次包含 b.h,那么编译器在预处理阶段会把 b.h 的内容展开多次,就会造成:
编译错误:类、结构体、函数原型重复声明。
链接错误:某些情况下如果在头文件里定义了全局变量或函数体,多次展开后编译多个副本,最终链接时出现 “multiple definition” 错误。
编译缓慢:如果一个头文件非常大,而又被反复包含和展开,就会造成编译过程非常缓慢。
不符合编程规范。
2. 如何解决?
2.1 使用宏 ifndef
2.1.1 基本格式
上边我们看到了头文件重复包含导致的问题,但是我们会发现,为什么 stdio.h 文件也被包含了很多次,但是却没有报错呢,并且里边的内容似乎也仅仅只被包含了一次而已?这是因为标准库头文件使用了宏保护来放置重复引入头文件,一般格式如下:
#ifndef __FILENAME_H
#define __FILENAME_H
/* 需要引入的头文件 */
#endif第一次包含头文件,会定义宏 __FILENAME_H ,并执行 "需要引入的头文件" 部分的代码;第二次包含时因为已经定义了宏 __FILENAME_H ,不会重复执行 "需要引入的头文件" 部分的代码。所以头文件只在第一次包含时起作用,再次包含无效。
注意,#ifndef 保护只能防止同一个 .c 文件中重复包含同一个头文件,但无法防止多个.c 文件各自包含这个头文件。另外#ifndef 这个语法是 C 语言支持的,并不依赖编译器,所以编写跨平台的代码的时候使用#ifndef 会很合适。
2.2.2 使用实例
- main.c
#include <stdio.h>
#include "func1.h"
#include "func2.h"
int main(int argc, char *argv[])
{
func1();
printf("main:a = %d\n", a);
return 0;
}- func1.c
#include "func1.h"
void func1(void)
{
printf("func1:a = %d\n", a);
}- func1.h
#ifndef _FUNC1_H
#define _FUNC1_H
/* 需要引入的头文件 */
#include <stdio.h>
#include "func2.h"
extern void func1(void);
#endif- func2.h
#ifndef _FUNC2_H
#define _FUNC2_H
/* 需要引入的头文件 */
int a;
#endif- 编译测试
在终端命令行执行以下命令:
gcc -E main.c -o main.i
vim main.i
从图中可以推断出,文件只被包含了一次。
2.2.3 强符号的变量
上面我们只是定义了 a 变量,它是一个弱符号,不管出现几次,都不会报错,那么,若是在头文件中把 a 定义成强符号呢?我们修改 func2.h 如下:
#ifndef _FUNC2_H
#define _FUNC2_H
/* 需要引入的头文件 */
int a = 10;
#endif然后执行下面的命令:
gcc -E main.c -o main.i
vim main.i会发现只被包含了一次:

然后我们全部编译链接一下,让它生成可执行程序,在终端执行以下命令:
gcc *.c -Wall # 编译程序会看到如下报错:
/tmp/ccLQGYqr.o:(.data+0x0): a 被多次定义
/tmp/cctZ4oU9.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status核心原因在于:头文件保护宏无法解决“跨文件”的变量定义问题。我们有 main.c 和 func1.h 两个文件,它们都包含了 func2.h。 func1.h 被 func1.c 所包含,
- 编译器处理
main.c时:
(1)预处理器将 func2.h 的内容复制进来。
(2)#ifndef _FUNC2_H 判断未定义,于是定义 _FUNC2_H。
(3)编译单元 1 中出现了变量定义:int a = 10;。编译器为此分配了内存地址,生成了目标文件 main.o,其中包含一个名为 a 的全局符号。
- 编译器处理
func.c时:
(1)预处理器将 func2.h 的内容复制进来。
(2)注意:此时是新的编译单元,之前的 _FUNC2_H 定义已经失效(编译器是分别编译每个 .c 文件的)。
(3)#ifndef _FUNC2_H 再次判断未定义,于是再次定义。
(4)编译单元 2 中也出现了变量定义:int a = 10;。编译器再次分配内存地址,生成了目标文件 func.o,其中也包含一个名为 a 的全局符号。
- 链接阶段:
链接器试图将 main.o 和 func.o 合并成一个可执行文件。它发现了两个名为 a 的全局变量,而且都是强符号(因为都有初始化)。链接器不知道该用哪一个,于是报错:“重定义”。
Tips:这就好比在两张不同的图纸(.c 文件)上都写着“在这里放一个箱子叫 a”。虽然每张图纸只写了一次(头文件保护生效了),但当工人(链接器)要把两张图纸合在一起盖房子时,发现有两个箱子都叫 a,这就冲突了。
从上边的例子中可以看出,宏保护可以有效防止文件的重复引入,但是对于定义在头文件中的变量依然会有问题,所以最好的方法当然就是规范编程习惯啦,将变量定义到 .c 文件中去, .h 文件只负责声明就没得问题啦。
2.2.4 小结
这种宏保护方案使得程序员可以“任性”地引入当前模块需要的所有头文件,不用操心这些头文件中是否包含了其他的头文件但也不是没有缺点。
#ifndef 的方式依赖于宏名不能冲突,这不光可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件不会被不小心同时包含。缺点就是如果不同头文件的宏名不小心“撞车”,可能就会导致头文件明明存在,编译器却硬说找不到声明的状况。意思就是 ifndef 后面的宏名同名,导致找不到声明的状态,使得编译器无法正确处理头文件的包含关系。
2.2 #pragma once
2.1.1 基本格式
在文件开头写上:
#pragma once#pragma 带来的好处是:我们不必再费劲想个宏名了,当然也就不会出现宏名碰撞引发的奇怪问题。缺点是无法保证多份拷贝重复包含。
#pragma once 是由 编译器提供保证:同一个文件不会被包含多次。注意这里所说的“同一个文件”是指 物理上的一个文件,而不是指内容相同的两个文件,如果某个头文件有多份拷贝,这个方法就不能保证他们不被重复包含。当然,相比宏名碰撞引发的“找不到声明”的问题,重复包含更容易被发现并修正。
2.1.2 物理上的同一个文件
什么是物理上的一个文件?是指 文件在计算机文件系统中的唯一身份标识(通常指文件的绝对路径或 inode 编号),而不是指文件里的 内容。
- “物理上的同一个文件”
假设项目目录结构如下:
project/
├── main.c
└── include/
└── myheader.h在 main.c 中,我们写了两次包含:
#include "include/myheader.h"
#include "./include/myheader.h" // 虽然写法不同,但指向的是同一个物理文件物理上:这两个路径最终指向硬盘上的同一个数据块(同一个 inode)。#pragma once 的行为:编译器第一次读取该文件时记录下“这个文件我已经处理过了”。当遇到第二次引用时,编译器发现路径指向的是刚才那个文件,于是直接忽略。这就是“同一个文件不会被包含多次”。
- “内容相同的两个文件”(物理上的不同文件)
假设我们把 myheader.h 复制了一份,放在了另一个目录下:
project/
├── main.c
├── include_v1/
│ └── config.h <-- 内容是: #pragma once int a = 10;
└── include_v2/
└── config.h <-- 内容完全一样: #pragma once int a = 10;注意:这两个 config.h 内容虽然一模一样,但它们在硬盘上是 两个独立的文件(两个不同的物理位置)。
现在在 main.c 中这样写:
#include "include_v1/config.h"
#include "include_v2/config.h"(1)编译器处理 include_v1/config.h,发现里面有 #pragma once,于是记录:文件 A 已处理。
(2)编译器处理 include_v2/config.h,编译器检查:这是一个新的物理文件(路径不同),虽然内容一样,但我之前没见过这个路径。
(3)结果:编译器会再次读取并处理这个文件。
(4)报错:因为内容里都有 int a = 10;,所以会导致重定义错误。
对比
#ifndef的行为(有效):(1)编译器处理
include_v1/config.h,内容是#ifndef _CONFIG_H ... #define _CONFIG_H。宏_CONFIG_H被定义。(2)编译器处理
include_v2/config.h,内容也是#ifndef _CONFIG_H。此时编译器发现_CONFIG_H已经被定义了。(3)结果:预处理器直接跳过第二次的内容。不会报重定义错误。
三、extern 与 .h
从上边可以看出, extern 与 .h 头文件有些功能是很类似的,那他们有什么不同嘛?既然用 #include 可以包含其他头文件中变量、函数的声明,为什么还要 extern 关键字嘞?
1. 头文件
不管是 C 还是 C++ ,我们要是把函数,变量或者结构体,类啥的放在 .c 或者 .cpp 文件里。然后编译成 lib 、 dll 、 obj 、 .o 等等,然后其他人用的时候,最基本的就是直接 gcc hisfile.c ourfile.o 等等。但对于其他开发者来说,他们怎么知道我们的 lib 、 dll 、 obj 或者 .o 里面到底有什么东西?这个时候要看头文件啦。头文件就需要有对用户的说明,函数,参数,各种各样的接口的说明等。既然是说明,那么头文件里面放的自然就是关于函数,变量,类的 "声明"(对函数来说,也叫函数原型)了。
我们可以将头文件后缀改为 .text ,在引用该头文件的地方用 #include "xxx.txt" ,然后再去编译链接程序,会发现,依然可以全部通过,这就说明,其实头文件仅仅是通过被包含将自己里边的内容插入到被需要的地方去,没有其他的作用。
还记得上边重复引入的问题吧,在头文件中定义的变量即便我们用上了宏保护,但是依然在生成可执行文件的过程中出现了错误,严格来讲应该是在链接的过程中会报重复定义的错误。这是因为多个 c 文件包含这个头文件时,因为宏名有效范围仅限于本 c 源文件,所以在这多个 c 文件编译时是不会出错的,但在链接时就会报错:就像上边一样:
/tmp/ccLQGYqr.o:(.data+0x0): a 被多次定义
/tmp/cctZ4oU9.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status2. extern
在定义变量的时候,这个 extern 可以被省略(定义时,默认均省略);在声明变量的时候,这个 extern 必须添加在变量前,所以有时会让我们搞不清楚到底是声明还是定义。或者说,变量前有 extern 不一定就是声明,而变量前无 extern 就只能是定义。注意,定义要是为变量分配内存空间的;而声明不需要。
- 变量
对于变量来说,有如下几种形式,
extern int a; /* 声明一个全局变量 a */
int a; /* 定义一个全局变量 a */
extern int a = 0 ; /* 定义一个全局变量 a 并赋初值 */
int a =0; /* 定义一个全局变量 a, 并赋初值 */其中, int a; extern int a = 0; 还有 int a = 0; 都 只能出现一次,而那个 extern int a; 可以出现很多次。我们需要引用一个全局变量的时候,就必须要声明 extern int a; 这时候 extern 不能省略,若是省略了,就变成 int a; 这是一个定义,不是声明。注意, extern int a; 中类型 int 可省略,即 extern a; 但其他类型则不能省略。
- 函数
对于函数也一样,也是定义和声明,定义的时候用 extern ,说明这个函数是可以被外部引用的,声明的时候用 extern 说明这是一个声明。 但由于函数的定义和声明是有区别的,定义函数要有函数体,声明函数没有函数体(还有以分号结尾),所以函数定义和声明时都可以将 extern 省略掉,有没有 extern 其他文件也都是知道这个函数是在其他地方定义的,所以不加 extern 也行,两者很容易区分,所以省略了 extern 也不会有问题。
- 总而言之
对变量,如果想在本源文件 A 中使用另一个源文件 B 中的变量,方法有两种:
(1)在 A 文件中必须用 extern 声明在 B 文件中定义的变量(当然是全局变量);
(2)在 A 文件中添加 B 文件对应的头文件,这个头文件需要包含 B 文件中的变量声明,即在这个头文件中必须用 extern 声明该变量,否则,该变量又会被定义一次。
对函数,如果想在本源文件 A 中使用另一个源文件 B 的函数,方法也有两种:
(1)在 A 文件中用 extern 声明在 B 文件中定义的函数(其实也可省略 extern,只需在 A 文件中出现 B 文件定义函数原型即可,这样似乎也不会有什么问题);
(2)在 A 文件中添加 B 文件对应的头文件,当然这个头文件包含 B 文件中的函数原型,在头文件中函数可以不用加 extern。
3. 两者联系
上边看完,有两个问题:
(1)用 #include 可以包含其他头文件中变量、函数的声明,为什么还要 extern 关键字?
(2)如果想引用一个全局变量或函数 a ,只要直接在源文件中包含 #include ( xxx.h 包含了 a 的声明)不就可以了么,为什么还要用 extern 呢?
如果一个文件 A 要大量引用另一个文件 B 中定义的变量或函数,则使用头文件效率更高,程序结构也更规范。其他文件(例如文件名 C 、 D 等)要引用文件名 B 中定义的变量或函数,则只需用 #include 包含文件 B 对应的头文件(要注意这个头文件只有对变量或函数的声明,绝不能有定义)即可。
很久很久以前,有一个编译器 😁,它只认识 .c (或 .cpp )文件,而不知道 .h 是什么。那时的人们写了很多的 .c (或 .cpp )文件,渐渐地,人们发现在很多 .c (或 .cpp )文件中的声明变量或函数原型是相同的,但他们却不得不一个字一个字地重复地将这些内容敲入每个 .c (或 .cpp )文件。但更为恐怖的是,当其中一个声明有变更时,就需要检查所有的 .c (或 .cpp )文件,并修改其中的声明,额,这就太恐怖了 😇。
后来终于,有人或许是一些人再不能忍受这样的折磨,他们将重复的部分提取出来,放在一个新文件里,然后在需要的 .c (或 .cpp )文件中敲入 #include XXX 这样的语句。这样即使某个声明发生了变更,也再不需要到处寻找与修改了 😃.
这个新文件,经常被放在 .c (或 .cpp )文件的头部,所以就给它起名叫做 头文件,扩展名是 .h 。从此,编译器(其实是其中预处理器)就知道世上除了 .c (或 .cpp )文件,还有 .h 文件,以及一个叫做 #include 命令。
参考资料:
防止 C 语言头文件被重复包含 — ifndef & #pragma once - rainbow70626 - 博客园