3、包含卫士(Include Guard):#ifndef/#define/#endif 与 #pragma once 的对比与选择

说到头文件,有个老生常谈但又极其重要的问题——重复包含。

你想想看,一个工程里几十上百个头文件,A包含了B,B又包含了C,C又回头包含了A……编译器看到同一个头文件被展开两次,立马翻脸,给你一堆「redefinition」错误。这时候,包含卫士就是你的救星。

我个人习惯,在每一个头文件里都加上包含卫士。这不是可选项,是必须项。哪怕你确定这个头文件只会被包含一次,也请加上。为什么?因为需求会变,代码会重构,你今天觉得不会重复,明天可能就被别人间接包含了两次。

3.1 两种主流方案

目前业界有两种做法:

  • 传统宏定义法#ifndef / #define / #endif
  • 编译器指令法#pragma once

先看代码,直观感受一下区别。

3.1.1 宏定义法

// file: my_module.h
#ifndef MY_MODULE_H
#define MY_MODULE_H

/* 头文件内容 */

#endif /* MY_MODULE_H */

这是C89标准就支持的方式,跨平台、跨编译器,没有任何兼容性问题。它的原理很简单:第一次包含时,MY_MODULE_H未定义,进入条件编译块,定义宏,然后展开内容。第二次再包含时,宏已定义,直接跳过整个文件。

3.1.2 #pragma once 法

// file: my_module.h
#pragma once

/* 头文件内容 */

简洁,非常简洁。一行搞定,不用费心去想宏名字,也不用担心宏名字冲突。编译器自己会记住这个文件是否已经被包含过。

我在项目中遇到过,有些新手同事觉得#pragma once写起来爽,所有头文件都用它。嗯,大部分情况下确实没问题。但这里有个坑——它不是C标准的一部分。

3.2 对比表格

对比维度 #ifndef / #define / #endif #pragma once
标准支持 C89/C99/C11/C17 全支持 非标准,编译器扩展
编译器兼容性 所有编译器 主流编译器均支持(GCC、Clang、MSVC、IAR、ARMCC等)
代码简洁性 需要3行 + 宏命名 1行搞定
宏名冲突风险 有(需规范命名)
对符号链接/硬链接的处理 可靠(基于宏判断) 部分编译器可能误判(基于文件路径)
跨平台能力 完美 主流平台没问题,小众编译器可能不支持

3.3 我的选择建议

说实话,这两个方案我都在用,但场景不同。

场景一:通用库、跨平台项目、开源项目

我建议用#ifndef / #define / #endif。为什么?因为你不知道用户会用哪个编译器。万一有人用了个老掉牙的编译器,或者某个嵌入式小众编译器,#pragma once直接报错,那你的库就废了。我曾经在一个ARM Cortex-M0的项目里,用的编译器就不支持#pragma once,结果同事写的头文件全得改,那叫一个痛苦。

场景二:内部项目、团队可控、编译器固定

我个人习惯用#pragma once。简洁、省事、不容易出错。比如我们团队做STM32项目,统一用GCC或ARMCC,那#pragma once完全没问题。少写三行代码,少一个宏命名的烦恼,何乐而不为?

核心原则:如果你不确定目标编译器的支持情况,请使用#ifndef方案。如果你100%确定编译器支持#pragma once,且项目不会移植,那用#pragma once更清爽。

3.4 避坑指南

我曾经踩过的坑:
  • 宏名字太随意,比如#ifndef _HEADER_H_,结果另一个头文件也用了这个名字,两个文件互相覆盖,编译报错找了一下午。
  • 在同一个文件里混用两种方案,先写#pragma once,又写#ifndef,虽然不会报错,但多此一举。
  • 忘记在#endif后面加注释,代码量大了之后,根本不知道每个#endif对应哪个头文件。
我的命名规范:
宏名 = 项目名_模块名_文件名_H
例如:PROJ_DRV_UART_H
全大写,下划线分隔,末尾加_H。这样基本不会冲突。

3.5 总结

说白了,包含卫士这件事,没有绝对的对错。我个人的经验是:

  • 新项目、团队可控 → #pragma once(省事)
  • 开源库、跨平台、不确定编译器 → #ifndef(安全)
  • 嵌入式裸机项目,尤其涉及多种工具链 → 老老实实用#ifndef

最后说一句:不管你选哪种,一定要加。不加包含卫士的头文件,就像没锁门的房子——迟早出事。