C++头文件保护(Header Guard),也被称为预处理器宏,是一种用于防止同一个头文件被多次包含的机制,以避免重复定义和编译错误。头文件保护使用预处理器的条件编译指令来实现。
重复定义问题
在前向声明和定义的课程中,我们注意到变量或函数标识符只能有一个定义(一个定义规则)。因此,多次定义变量标识符的程序将导致编译错误:
int main()
{
int x; // 这是变量 x 的定义
int x; // 编译错误:重复定义
return 0;
}
样,多次定义函数的程序也会导致编译错误:
#include <iostream>
int foo() // 这是函数 foo 的定义
{
return 5;
}
int foo() // 编译错误:重复定义
{
return 5;
}
int main()
{
std::cout << foo();
return 0;
}
虽然这些程序很容易修复(删除重复的定义),但使用头文件时,很容易出现头文件中的定义被多次包含的情况。当一个头文件 #include 另一个头文件时(这很常见),就会发生这种情况。
在接下来的示例中,我们将在头文件中定义一些函数。一般情况下你不应该这样做。在这里这样做是因为这是使用我们已经介绍过的功能来演示某些概念的最有效方法。
看以下示例:
square.h:
int getSquareSides()
{
return 4;
}
wave.h:
#include "square.h"
main.cpp:
#include "square.h"
#include "wave.h"
int main()
{
return 0;
}
这个程序无法编译! 首先,main.cpp #includes square.h,它将函数 getSquareSides 的定义复制到 main.cpp 中。 然后 main.cpp #includes wave.h,其中 #include square.h 本身。 这会将 square.h 的内容(包括函数 getSquareSides 的定义)复制到 wave.h 中,然后将其复制到 main.cpp 中。
因此,在解决所有#includes之后,main.cpp最终看起来像这样:
int getSquareSides() // 来自 square.h
{
return 4;
}
int getSquareSides() // 来自wave.h(通过square.h)
{
return 4;
}
int main()
{
return 0;
}
重复的定义和编译错误。 每个文件单独来说都很好。 然而,由于 main.cpp 最终 #include square.h 的内容两次,我们遇到了问题。 如果wave.h需要getSquareSides(),而main.cpp同时需要wave.h和square.h,你会如何解决这个问题?
头文件保护
好消息是,我们可以通过一种称为头文件保护(也称为包含保护)的机制来避免上述问题。 头文件保护是条件编译指令,采用以下形式:
#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE
// your declarations (and certain types of definitions) here
#endif
当此标头为 #included 时,预处理器会检查先前是否已定义 SOME_UNIQUE_NAME_HERE。 如果这是我们第一次包含标头,则 SOME_UNIQUE_NAME_HERE 将不会被定义。 因此,它 #defines SOME_UNIQUE_NAME_HERE 并包含文件的内容。 如果标头再次包含到同一个文件中,则 SOME_UNIQUE_NAME_HERE 从第一次包含标头内容时就已经被定义,并且标头的内容将被忽略。
所有头文件都应该有头保护。 SOME_UNIQUE_NAME_HERE 可以是您想要的任何名称,但按照约定设置为头文件的完整文件名,全部大写,使用下划线表示空格或标点符号。 例如,square.h 将具有标头保护:
square.h:
#ifndef SQUARE_H
#define SQUARE_H
int getSquareSides()
{
return 4;
}
#endif
甚至标准库头也使用头保护。 如果您要从 Visual Studio 查看 iostream 头文件,您将看到:
#ifndef _IOSTREAM_
#define _IOSTREAM_
// content here
#endif
在大型程序中,可能有两个单独的头文件(包含在不同的目录中),最终具有相同的文件名(例如directoryA\config.h 和directoryB\config.h)。 如果仅将文件名用于包含保护(例如 CONFIG_H),则这两个文件最终可能会使用相同的保护名称。 如果发生这种情况,任何包含(直接或间接)两个 config.h 文件的文件将不会收到第二个要包含的包含文件的内容。 这可能会导致编译错误。
由于防护名称冲突的可能性,许多开发人员建议在标头防护中使用更复杂/唯一的名称。 一些好的建议是 PROJECT_PATH_FILE_H、FILE_LARGE-RANDOM-NUMBER_H 或 FILE_CREATION-DATE_H 的命名约定。
使用头文件保护来更新之前的示例
让我们回到 square.h 示例,使用带有标头保护的 square.h。 为了获得良好的形式,我们还将向wave.h 添加标头防护。
square.h
#ifndef SQUARE_H
#define SQUARE_H
int getSquareSides()
{
return 4;
}
#endif
wave.h:
#ifndef WAVE_H
#define WAVE_H
#include "square.h"
#endif
main.cpp:
#include "square.h"
#include "wave.h"
int main()
{
return 0;
}
预处理器解析所有 #include 指令后,该程序如下所示:
main.cpp:
// Square.h included from main.cpp
#ifndef SQUARE_H // square.h included from main.cpp
#define SQUARE_H // SQUARE_H gets defined here
// and all this content gets included
int getSquareSides()
{
return 4;
}
#endif // SQUARE_H
#ifndef WAVE_H // wave.h 包含在 main.cpp 中
#define WAVE_H
#ifndef SQUARE_H // square.h 包含在 wave.h 中,SQUARE_H 已从上面定义
#define SQUARE_H // 所以这些内容都没有被包含在内
int getSquareSides()
{
return 4;
}
#endif // SQUARE_H
#endif // WAVE_H
int main()
{
return 0;
}
首先,预处理器评估#ifndef SQUARE_H。 SQUARE_H 尚未定义,因此包含#ifndef 到后续#endif 的代码进行编译。 此代码定义了 SQUARE_H,并具有 getSquareSides 函数的定义。
稍后,评估下一个#ifndef SQUARE_H。 这次,SQUARE_H 被定义了(因为上面已经定义了),所以从 #ifndef 到后续 #endif 的代码被排除在编译之外。
头文件保护可防止重复包含,因为第一次遇到保护时,未定义保护宏,因此包含受保护的内容。 过了该点,就会定义保护宏,因此受保护内容的任何后续副本都会被排除。
头文件防护不会阻止标头被包含到不同的代码文件中
请注意,头文件保护的目标是防止代码文件接收受保护标头的多个副本。 根据设计,标头防护不会阻止给定的标头文件被包含(一次)到单独的代码文件中。 这也可能会导致意想不到的问题。 考虑:
square.h:
#ifndef SQUARE_H
#define SQUARE_H
int getSquareSides()
{
return 4;
}
int getSquarePerimeter(int sideLength); // forward declaration for getSquarePerimeter
#endif
square.cpp:
#include "square.h" // square.h is included once here
int getSquarePerimeter(int sideLength)
{
return sideLength * getSquareSides();
}
main.cpp:
#include "square.h" // square.h 也包含在这里一次
#include <iostream>
int main()
{
std::cout << "a square has " << getSquareSides() << " sides\n";
std::cout << "a square of length 5 has perimeter length " << getSquarePerimeter(5) << '\n';
return 0;
}
请注意,main.cpp 和 square.cpp 中均包含 square.h。 这意味着 square.h 的内容将被包含到 square.cpp 和 main.cpp 中一次。
让我们更详细地研究一下为什么会发生这种情况。 当 square.h 包含在 square.cpp 中时,SQUARE_H 被定义到 square.cpp 末尾。 此定义可防止 square.h 再次包含到 square.cpp 中(这是标头防护的要点)。 然而,一旦 square.cpp 完成,SQUARE_H 就不再被视为已定义。 这意味着当预处理器在 main.cpp 上运行时,SQUARE_H 最初并未在 main.cpp 中定义。
最终结果是 square.cpp 和 main.cpp 都获得了 getSquareSides 定义的副本。 该程序将编译,但链接器会抱怨您的程序对标识符 getSquareSides 有多个定义!
解决此问题的最佳方法是将函数定义放入其中一个 .cpp 文件中,以便标头仅包含前向声明:
square.h:
#ifndef SQUARE_H
#define SQUARE_H
int getSquareSides(); // getSquareSides 的前向声明
int getSquarePerimeter(int sideLength); // getSquarePerimeter 的前向声明
#endif
square.cpp:
#include "square.h"
int getSquareSides() // getSquareSides 的实际定义
{
return 4;
}
int getSquarePerimeter(int sideLength)
{
return sideLength * getSquareSides();
}
main.cpp:
#include "square.h" // square.h is also included once here
#include <iostream>
int main()
{
std::cout << "a square has " << getSquareSides() << " sides\n";
std::cout << "a square of length 5 has perimeter length " << getSquarePerimeter(5) << '\n';
return 0;
}
现在,当程序编译时,函数 getSquareSides 将只有一个定义(通过 square.cpp),文件 main.cpp 能够调用此函数(即使它位于 square.cpp 中),因为它包含 square.h,该函数具有该函数的前向声明(链接器会将 main.cpp 中对 getSquareSides 的调用连接到 square.cpp 中 getSquareSides 的定义)。
不能避免头文件中的定义吗?
我们通常建议不要在头文件中包含函数定义,但你可能会想知道为什么在头文件中包含函数定义的情况下还需要使用头文件保护。
在以后的课程中,我们将向你展示一些必须将非函数定义放入头文件的情况。例如,C++允许你创建自己的数据类型。这些自定义类型通常在头文件中定义,以便类型定义可以传播到需要使用它们的代码文件中。如果没有头文件保护,一个代码文件可能会包含多个(相同的)给定类型定义的副本,这将被编译器标记为错误。
因此,尽管在本教程系列的当前阶段不严格需要使用头文件保护,但我们正在养成良好的习惯,以便以后不必去养成不良习惯。头文件保护对于确保代码的一致性和可维护性非常重要,特别是在大型项目中。
#pragma once
现代编译器使用 #pragma 预处理器指令支持更简单的替代形式的头文件保护:
#pragma once
// your code here
#pragma once
和头文件保护(header guards)具有相同的目的,即防止头文件被多次包含。使用传统的头文件保护,开发人员负责保护头文件(通过使用预处理器指令 #ifndef
、#define
和 #endif
)。而使用 #pragma once
,我们请求编译器来保护头文件。
已知有一种情况,#pragma once
通常会失败。如果一个头文件被复制到文件系统的多个位置,如果不知何故都包含了这两个头文件的副本,头文件保护将成功地去重相同的头文件,但 #pragma once
不会成功(因为编译器无法意识到它们实际上是相同的内容)。
对于大多数项目来说,#pragma once
工作得很好,许多开发人员现在更喜欢它,因为它更容易使用并且更不容易出错。许多集成开发环境(IDE)也会在通过 IDE 生成的新头文件的顶部自动包含 #pragma once
。
#pragma
指令是为编译器实现者设计的,可以用于他们所需的任何目的。因此,支持哪些 #pragma
和这些 #pragma
的含义完全是与实现相关的。除了 #pragma once
之外,不要期望一个编译器支持的 #pragma
在另一个编译器上也能支持。
因为 #pragma once
不是由C++标准定义的,所以可能有一些编译器没有实现它。因此,一些开发团队(如Google)建议使用传统的头文件保护。在本教程系列中,我们将更倾向于使用头文件保护,因为它们是保护头文件的最传统方式。然而,目前大多数编译器都支持 #pragma once
,如果你愿意使用 #pragma once
,在现代C++中通常也被接受。
总结
头文件保护的目的是确保给定头文件的内容不会被复制超过一次到任何单个文件中,以防止重复定义。
重复的声明是可以的,但即使你的头文件只包含所有的声明而没有定义,包含头文件保护仍然是一种最佳实践。
需要注意的是,头文件保护不会防止头文件的内容被复制(一次)到不同的项目文件中。这是好事,因为我们经常需要从不同的项目文件中引用给定头文件的内容。
原创文章,作者:jkhxw,如若转载,请注明出处:https://www.jkhxw.com/cpp-header-guards/