2-12 头文件保护机制

重复定义问题

第2.7节——前向声明与定义中,我们指出变量或函数标识符只能有一个定义(唯一定义规则)。因此,若程序中多次定义同一个变量标识符,将导致编译错误:

int main()
{
    int x; // this is a definition for variable x
    int x; // compile error: duplicate definition

    return 0;
}

同样地,在程序中多次定义同一个函数也会导致编译错误:

#include <iostream>

int foo() // this is a definition for function foo
{
    return 5;
}

int foo() // compile error: duplicate definition
{
    return 5;
}

int main()
{
    std::cout << foo();
    return 0;
}

虽然这些程序很容易修复(删除重复定义),但在使用头文件时,很容易出现头文件中的定义被多次包含的情况。当一个头文件包含另一个头文件时(这种情况很常见),就会发生这种情况。

作者注

在后续示例中,我们将把某些函数定义在头文件内。通常不建议这样做。

此处之所以采用此方式,是因为这是运用已讲解功能来阐释某些概念的最有效途径。

考虑以下学术示例:

square.h:

int getSquareSides()
{
    return 4;
}

wave.h:

#include "square.h"

main.cpp:

#include "square.h"
#include "wave.h"

int main()
{
    return 0;
}

image

这个看似无害的程序无法编译!原因如下:首先,main.cpp包含了square.h,将函数getSquareSides的定义复制到main.cpp中。随后main.cpp又包含了wave.h,而wave.h本身也包含了square.h。这将 square.h 的内容(包括函数 getSquareSides 的定义)复制到 wave.h 中,而 wave.h 又被复制到 main.cpp 中。

因此,在解析所有 #include 指令后,main.cpp 最终呈现如下形态:

int getSquareSides()  // from square.h
{
    return 4;
}

int getSquareSides() // from wave.h (via square.h)
{
    return 4;
}

int main()
{
    return 0;
}

重复定义导致编译错误。每个文件单独编译均无问题。但由于main.cpp最终两次包含了square.h的内容,从而引发冲突。若wave.h需要getSquareSides()函数,而main.cpp同时依赖wave.h和square.h,该如何解决此问题?


头文件保护机制

好消息是,我们可以通过一种称为头文件保护header guard(也称包含保护include guard)的机制来避免上述问题。头文件保护是条件编译指令,其形式如下:

#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE

// your declarations (and certain types of definitions) here

#endif

当包含此头文件时,预处理器将检查SOME_UNIQUE_NAME_HERE是否已在本翻译单元中定义。若为首次包含该头文件,则SOME_UNIQUE_NAME_HERE尚未被定义。因此,它会#定义SOME_UNIQUE_NAME_HERE并包含文件内容。若在同一文件中再次包含该头文件,由于#ifndef机制,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

对于高级读者

在大型程序中,可能存在两个独立的头文件(分别从不同目录包含),最终却拥有相同的文件名(例如目录A\config.h和目录B\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 included from main.cpp
#define WAVE_H
#ifndef SQUARE_H // square.h included from wave.h, SQUARE_H is already defined from above
#define SQUARE_H // so none of this content gets included

int getSquareSides()
{
    return 4;
}

#endif // SQUARE_H
#endif // WAVE_H

int main()
{
    return 0;
}

image

让我们看看这个表达式的评估过程。

首先,预处理器评估 #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.h

#include "square.h"  // square.h is included once here

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;
}

请注意,square.h 同时被 main.cpp 和 square.cpp 包含。这意味着 square.h 的内容将被 square.cpp 包含一次,同时也被 main.cpp 包含一次。

让我们更详细地分析这种现象的原因。当 square.cpp 包含 square.h 时,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(); // forward declaration for getSquareSides
int getSquarePerimeter(int sideLength); // forward declaration for getSquarePerimeter

#endif

square.cpp

#include "square.h"

int getSquareSides() // actual definition for 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 与头文件保护机制具有相同目的:防止头文件被多次包含。传统头文件保护机制要求开发者自行负责保护头文件(通过使用预处理指令 #ifndef、#define 和 #endif)。而使用 #pragma once 时,我们要求编译器来保护头文件。其具体实现方式属于特定实现细节。

对于高级读者
存在一种已知情况,此时#pragma once通常会失效。若某个头文件被复制到文件系统中的多个位置,且两份头文件副本均被包含时,头文件保护机制虽能成功去重相同的头文件,但#pragma once却无法生效(因为编译器无法识别它们实际内容完全相同)。

对于大多数项目而言,#pragma once 都能正常工作,如今许多开发者更倾向于使用它,因为它更简单且不易出错。许多集成开发环境(IDE)还会在新生成的头文件顶部自动添加 #pragma once。

警告
#pragma 指令的设计初衷是供编译器实现者根据自身需求使用。因此,哪些指令被支持以及这些指令的具体含义完全取决于具体实现。除 #pragma once 之外,请勿期望某编译器支持的指令能在其他编译器上运行。

由于#pragma once未被C++标准定义,某些编译器可能未实现该指令。因此,部分开发机构(如谷歌)建议采用传统的头文件保护机制。在本教程系列中,我们将优先使用头文件保护,因其是最常规的头文件防护方式。不过当前#pragma once的支持已相当普遍,若您希望使用该指令,在现代C++中通常也是被接受的。


概要

头文件保护机制旨在确保特定头文件的内容不会被重复复制到任何单个文件中,从而避免重复定义。

重复声明是允许的——即使您的头文件仅包含声明(无定义),添加头文件保护仍是最佳实践。

需注意:头文件保护不会阻止头文件内容被(一次)复制到不同项目文件中。这其实是好事,因为我们常需从不同项目文件中引用特定头文件的内容。


测验时间

问题 #1

为这个头文件添加头文件保护:

add.h:

int add(int x, int y);

显示方案

#ifndef ADD_H
#define ADD_H

int add(int x, int y);

#endif
posted @ 2026-02-10 05:42  游翔  阅读(0)  评论(0)    收藏  举报