在 C++ 项目开发中,如果同一个头文件在单个 .cpp 源文件里展开了多次,里面的结构体或类定义就会重复出现,编译时就会报出 redefinition(重复定义)错误。
为了避免这个问题,我们在编写 .h 头文件时都需要加上一层 “头文件保护”,确保同一个头文件在单个源文件中最多只展开一次。
为什么需要头文件保护?
在实际项目中,同一个头文件之所以会重复展开,通常不是因为我们粗心写了两遍相同的 #include 指令,而是因为头文件之间的 “嵌套包含”。
举个常见的例子,假设我们在 point.h 中定义了表示坐标的结构体 Point,代码如下:
// point.h
struct Point {
int x;
int y;
};接着,玩家头文件 player.h 需要使用 Point 来表示玩家位置,于是在内部引入了 point.h:
// player.h
#include "point.h"
struct Player {
Point pos;
};到了主程序 main.cpp 中,由于同时用到了 Point 和 Player,我们在开头把这两个头文件都引入了进来:
// main.cpp
#include "point.h"
#include "player.h"此时在预处理阶段,point.h 在 main.cpp 中的展开路径就有两条:
路径 1:main.cpp -> point.h
路径 2:main.cpp -> player.h -> point.h可以看到,虽然我们在 main.cpp 里只写了一行 #include "point.h",但 point.h 先直接展开了一次,随后又跟着 player.h 间接展开了一次。最终 struct Point 在 main.cpp 里出现了两遍,直接导致编译报错。
当项目里有几十个头文件互相引用时,靠人工去排查谁包含了谁是不现实的。在 C++ 中,我们通常会在每个头文件内部加上头文件保护,常用的有以下 2 种方式:
- 使用 #ifndef 宏保护:C++ 标准原生的传统方式,也叫做 “包含保护(include guard)”。
- 使用 #pragma once 指令:现代主流编译器广泛支持的简写方式。
使用 #ifndef 宏保护
#ifndef 宏保护是 C++ 中跨平台兼容性最好的传统方式。它通过 #ifndef、#define 和 #endif 这 3 条预处理指令互相配合,给头文件打上 “是否已经展开过” 的标记。
语法:
#ifndef 宏名
#define 宏名
// 头文件的实际内容
#endif说明:
这 3 条预处理指令各自的作用如下:
- #ifndef 宏名:
#ifndef是 “if not defined(如果未定义)” 的缩写,用来判断后面的宏名是不是还没有定义过。 - #define 宏名:如果上面的判断成立,说明是第 1 次遇到这个头文件。此时我们通过
#define把该宏名定义下来,相当于贴上一张 “已展开” 的标签,再正常保留下面的头文件内容。 - #endif:写在头文件的最末尾,用来标志
#ifndef判断范围的结束。
关于宏定义与条件编译的更多用法,后面两节会详细介绍,这里先把这 3 行当成固定搭配即可。下面我们给 point.h 加上 #ifndef 头文件保护,代码如下:
#ifndef POINT_H
#define POINT_H
struct Point {
int x;
int y;
};
#endif平时写项目时,我们只需要像上面这样在 point.h 里写一遍保护代码就可以了。为了方便小伙伴们直接在线运行观察效果,下面我们把预处理器在 main.cpp 中连续展开两次 point.h 后的样子模拟出来,请看示例 1。
示例 1:使用 #ifndef 防止重复展开
#include <iostream>
// 模拟第 1 次展开 point.h
#ifndef POINT_H
#define POINT_H
struct Point {
int x;
int y;
};
#endif
// 模拟第 2 次展开 point.h
#ifndef POINT_H
#define POINT_H
struct Point {
int x;
int y;
};
#endif
int main() {
Point p = {3, 5};
std::cout << p.x + p.y << '\n';
return 0;
}运行结果如下。
8分析:
从运行结果可以看出,带有 #ifndef 保护的代码块虽然连续出现了两次,但程序依然顺利通过编译并输出了 8。预处理器在背后的处理过程分为以下 2 步:
- 第 1 次遇到 #ifndef POINT_H:此时
POINT_H这个宏还不存在,条件成立。预处理器接着执行#define POINT_H把宏定义好,并将中间的struct Point结构体保留下来。 - 第 2 次遇到 #ifndef POINT_H:由于第 1 次已经定义过
POINT_H了,此时条件不成立。预处理器会直接跳过中间的所有代码,一直跳到对应的#endif,这样就避免了struct Point重复定义。
注意 在使用 #ifndef 保护头文件时,每个头文件的宏名在整个项目中都必须是唯一的。我们通常按照 “文件名全大写_H” 的格式来命名,比如 POINT_H、MATH_UTILS_H。千万不要让两个不同的头文件使用相同的宏名,否则第 1 个头文件展开后,预处理器就会误以为第 2 个头文件也展开过了,直接把它的内容跳过。
使用 #pragma once 指令(推荐)
使用 #ifndef 虽然兼容性好,但每次都要手写首尾 3 行指令,还得留心防止宏名重名或拼写不一致。在现代 C++ 开发中,GCC、Clang、MSVC 等主流编译器都支持更简洁的头文件保护指令:#pragma once。
语法:
#pragma once
// 头文件的实际内容说明:
#pragma once 只需要写在头文件的第一行,注意行尾不要加英文分号(;)。它的作用是直接告诉编译器:在编译当前 .cpp 源文件时,这个头文件最多只包含一次。
下面我们使用 #pragma once 来改写 point.h,代码如下:
#pragma once
struct Point {
int x;
int y;
};接着我们在同一个文件夹下新建 main.cpp,故意连续引入两次 point.h,模拟它在复杂项目中多次嵌套包含的场景。
示例 2:#pragma once 保护头文件
#include <iostream>
#include "point.h"
#include "point.h"
int main() {
Point p = {3, 5};
std::cout << p.x + p.y << '\n';
return 0;
}在本地终端进入该文件夹,执行 g++ main.cpp -o app 编译并运行后,运行结果如下。
8分析:
与前面的 #ifndef 方式相比,#pragma once 省去了末尾的 #endif,也不需要我们手动去起宏名,从根源上避免了宏名冲突或手误拼错宏名的问题。
在实际开发中,我们可以按以下 2 种情况来选择:
- 新建现代 C++ 项目:优先使用
#pragma once,只需在头文件顶部写一行,既干净利落又不容易出错。 - 维护已有项目或公共库:如果项目原本统一使用的是
#ifndef,或者需要兼容极少数不支持#pragma once的专用编译器,就继续沿用#ifndef,保持团队风格一致。
常见问题
1. 加了头文件保护,就能把普通函数的函数体直接写进 .h 头文件里吗?
不能。小伙伴们一定要分清一个重要边界:头文件保护只能防止 “同一个头文件在单个 .cpp 源文件内重复展开”,但防不住 “多个不同的 .cpp 源文件各自包含一次该头文件”。
在多文件编程中,假如我们在 math_utils.h 里写了 add() 的函数体,而 main.cpp 和 other.cpp 都包含了它。由于这两个 .cpp 文件是各自独立编译的,它们都会把 add() 的函数体编译进各自的 .o 目标文件中。
等到最后链接器把所有 .o 文件组装在一起时,就会发现两份相同的 add() 实现撞车了,依然会报出 multiple definition(重复定义)错误。因此,即使加了头文件保护,普通函数的函数体仍然要放在 .cpp 源文件中。
2. 使用 #ifndef 时,为什么不建议把宏名写成下划线开头?
有些早期代码喜欢把头文件保护宏写成 __POINT_H__ 或 _POINT_H。但在 C++ 标准规范中,凡是以双下划线(__)开头,或者以 “单下划线 + 大写字母” 开头的名称,都是专门留给编译器和标准库内部使用的保留名称。
为了避免和系统底层宏发生命名冲突,平时我们定义头文件保护宏时,直接写 POINT_H 或加上项目前缀写成 DEMO_POINT_H 即可,不要在开头加下划线。
