为什么我的 include 保护不能防止递归包含和多个符号定义?

关于 包括警卫有两个常见的问题:

  1. 第一个问题:

    为什么不包括保护我的头文件从 相互递归包含的守卫?每次我写下面这样的东西时,我都会不断地遇到不存在的符号的错误,这些符号显然是存在的,甚至是更奇怪的语法错误:

    “啊”

    #ifndef A_H
    #define A_H
    
    
    #include "b.h"
    
    
    ...
    
    
    #endif // A_H
    

    “ BH”

    #ifndef B_H
    #define B_H
    
    
    #include "a.h"
    
    
    ...
    
    
    #endif // B_H
    

    “ main.cpp”

    #include "a.h"
    int main()
    {
    ...
    }
    

    为什么我在编译“ main.cpp”时会出错? 我需要做什么来解决我的问题?


  1. 第二个问题:

    为什么不包括防止 多重定义的警卫?例如,当我的项目包含两个包含相同标头的文件时,有时链接器会抱怨某个符号被多次定义。例如:

    “头 H”

    #ifndef HEADER_H
    #define HEADER_H
    
    
    int f()
    {
    return 0;
    }
    
    
    #endif // HEADER_H
    

    “ source 1.cpp”

    #include "header.h"
    ...
    

    “ source 2.cpp”

    #include "header.h"
    ...
    

    为什么会这样? 我需要做什么来解决我的问题?

30151 次浏览

第一个问题:

为什么不包括保护我的头文件从 相互递归包含的守卫?

他们很强大。

他们没有帮助的是 数据结构定义之间的依赖关系。为了了解这意味着什么,让我们从一个基本的场景开始,看看为什么包括守卫有助于相互包容。

假设相互包含的 a.hb.h头文件的内容很简单,即问题文本中代码部分的省略号被替换为空字符串。在这种情况下,您的 main.cpp将很高兴地编译。这都要感谢你们的保镖!

如果你不相信,试着去除它们:

//================================================
// a.h


#include "b.h"


//================================================
// b.h


#include "a.h"


//================================================
// main.cpp
//
// Good luck getting this to compile...


#include "a.h"
int main()
{
...
}

您会注意到,当编译器达到包含深度限制时,将报告失败。此限制是特定于实现的。根据 C + + 11标准第16.2/6段:

一个 # include 预处理指令可能会出现在一个源文件中,这个源文件已经被读取,因为在另一个文件 达到实现定义的嵌套限制中有一个 # include 指令。

那么发生了什么?

  1. 在解析 main.cpp时,预处理器将满足指令 #include "a.h"。该指令告诉预处理器处理头文件 a.h,取得该处理的结果,并用该结果替换字符串 #include "a.h";
  2. 在处理 a.h时,预处理器将满足指令 #include "b.h",同样的机制适用于: 预处理器将处理头文件 b.h,取其处理结果,并用该结果替换 #include指令;
  3. 在处理 b.h时,指令 #include "a.h"将告诉预处理器处理 a.h并用结果替换该指令;
  4. 预处理器将再次开始解析 a.h,再次满足 #include "b.h"指令,这将建立一个潜在的无限递归过程。当达到关键嵌套级别时,编译器将报告错误。

然而,当 include 警卫出现 时,在步骤4中不会设置无限递归。让我们看看为什么:

  1. (和以前一样)解析 main.cpp时,预处理器将满足指令 #include "a.h"。这告诉预处理器处理头文件 a.h,取得该处理的结果,并用该结果替换字符串 #include "a.h";
  2. 在处理 a.h时,预处理器将满足指令 #ifndef A_H。由于宏 A_H尚未定义,它将继续处理以下文本。后续指令(#defines A_H)定义宏 A_H。然后,预处理器将满足指令 #include "b.h": 预处理器现在处理头文件 b.h,取其处理结果,并用该结果替换 #include指令;
  3. 在处理 b.h时,预处理器将满足指令 #ifndef B_H。由于宏 B_H尚未定义,它将继续处理以下文本。后续指令(#defines B_H)定义宏 B_H。然后,指令 #include "a.h"告诉预处理器对 a.h进行预处理,并用预处理 a.h的结果替换 b.h中的 #include指令;
  4. 编译器将再次开始预处理 a.h,并再次满足 #ifndef A_H指令。但是,在以前的预处理过程中,定义了宏 A_H。因此,这次编译器将跳过以下文本,直到找到匹配的 #endif指令,并且该处理的输出是空字符串(当然,假设没有遵循 #endif指令)。因此,预处理器将用空字符串替换 b.h中的 #include "a.h"指令,并将追踪执行,直到它替换 main.cpp中的原始 #include指令。

然而,它们不能在相互包含的文件中帮助 类定义之间的依赖关系:

//================================================
// a.h


#ifndef A_H
#define A_H


#include "b.h"


struct A
{
};


#endif // A_H


//================================================
// b.h


#ifndef B_H
#define B_H


#include "a.h"


struct B
{
A* pA;
};


#endif // B_H


//================================================
// main.cpp
//
// Good luck getting this to compile...


#include "a.h"
int main()
{
...
}

鉴于上述标题,main.cpp将无法编译。

为什么会这样?

要了解发生了什么,只需再次执行步骤1-4即可。

很容易看出,前三个步骤和第四个步骤中的大部分都没有受到这种变化的影响(只要通读一下就会明白)。然而,在步骤4的末尾发生了一些不同的事情: 在用空字符串替换了 b.h中的 #include "a.h"指令之后,预处理器将开始解析 b.h的内容,特别是 B的定义。不幸的是,B的定义提到了类 A,它在包含保护的 因为之前从来没有被满足过!

声明一个之前没有声明过的类型的成员变量当然是一个错误,编译器会礼貌地指出这一点。

我需要做什么来解决我的问题?

你需要 前进声明

实际上,定义类 B并不需要类 A定义,因为 指针A被声明为成员变量,而不是 A类型的对象。因为指针的大小是固定的,所以编译器不需要知道 A的确切布局,也不需要计算它的大小来正确定义类 B。因此,对于 b.h中的 B0类 A,并让编译器知道它的存在就足够了:

//================================================
// b.h


#ifndef B_H
#define B_H


// Forward declaration of A: no need to #include "a.h"
struct A;


struct B
{
A* pA;
};


#endif // B_H

你的 main.cpp现在肯定能编译了。一些注意事项:

  1. b.h中用一个前向声明代替 #include指令不仅打破了相互包含,还有效地表达了 BA的依赖: 尽可能使用转发声明/实际上也被认为是 b.h1,因为它有助于避免不必要的包含,从而减少整体编译时间。然而,在消除相互包含之后,main.cpp将不得不修改为 #include a.hb.h(如果完全需要后者的话) ,因为 b.h不再是通过 a.h间接的 #included;
  2. 虽然类 A的前向声明足以让编译器声明指向该类的指针(或者在任何其他可以接受不完全类型的上下文中使用它) ,但是解引用指向 A的指针(例如调用成员函数)或计算其大小是对不完全类型的 非法的操作: 如果需要,编译器需要获得完整的 A定义,这意味着定义它的头文件必须包含在内。这就是为什么类定义和它们的成员函数的实现通常被分割成一个头文件和该类的一个实现文件(类 模板是这个规则的例外) : 实现文件,它们从来没有被项目中的其他文件 #included,可以安全地 #include所有必要的头使定义可见。另一方面,头文件不会使用 #include其他头文件 除非,它们确实需要这样做(例如,使 基础课程的定义可见) ,并且只要可能/实际可行,就会使用正向声明。

第二个问题:

为什么不包括防止 多重定义的警卫?

他们很强大。

它们没有保护您免受多个定义 在不同的翻译单位的影响。这也在 StackOverflow 的 这个问与答中解释过。

也看到了,尝试删除包含保护,并编译以下修改版本的 source1.cpp(或 source2.cpp,因为它很重要) :

//================================================
// source1.cpp
//
// Good luck getting this to compile...


#include "header.h"
#include "header.h"


int main()
{
...
}

编译器肯定会在这里抱怨 f()被重新定义。这是显而易见的: 它的定义被包括两次!然而,以上的 source1.cpp 将编译没有问题时,header.h包含适当的包含保护。这是意料之中的。

尽管如此,即使包含保护程序已经存在,编译器也不会再用错误信息打扰你,连接器仍然会坚持这样一个事实,即在合并从 source1.cppsource2.cpp的编译中获得的对象代码时,会发现多个定义,并且会拒绝生成可执行文件。

为什么会这样?

基本上,项目中的每个 .cpp文件(这里的技术术语是 翻译小组)都是单独编译的,而 独立的是单独编译的。当解析一个 .cpp文件时,预处理器将处理所有的 #include指令并展开它遇到的所有宏调用,这个纯文本处理的输出将被输入到编译器中,以便将其转换成目标代码。一旦编译器完成了为一个翻译单元生成目标代码的工作,它将继续下一个翻译单元的工作,在处理前一个翻译单元时遇到的所有宏定义都将被遗忘。

实际上,用 n翻译单元(.cpp文件)编译一个项目就像执行同一个程序(编译器) n一样,每次都有不同的输入: 执行同一个程序 不会共享以前程序执行的状态的方式不同。因此,每个翻译都是独立执行的,在编译一个翻译单元时遇到的预处理器符号在编译其他翻译单元时不会被记住(如果你仔细想想,你会很容易意识到这实际上是一个可取的行为)。

因此,即使 include Guard 帮助您防止在一个翻译单元中同一头的递归互包和 多余的包含,它们也无法检测 与众不同翻译单元中是否包含相同的定义。

然而,当合并从项目的所有 .cpp文件的编译中生成的对象代码时,链接器 威尔会看到同一个符号被定义了不止一次,因为这违反了 一个定义规则。根据 C + + 11标准第3.2/3段:

每个程序必须包含每个 非内联函数或变量的一个定义,这些函数或变量在该程序中使用不当; 不需要诊断。定义可以显式地出现在程序中,可以在标准库或用户定义库中找到,或者(在适当的时候)它是隐式定义的(参见12.1、12.4和12.8)。内联函数应定义在每一个翻译单元,其中它是或使用.

因此,链接器将发出一个错误并拒绝生成程序的可执行文件。

我需要做什么来解决我的问题?

如果 希望将函数定义保存在一个头文件中,该头文件是 多个翻译单元的 #included (注意,如果头文件是 翻译单元的 #included,则不会出现任何问题) ,则需要使用 inline关键字。

否则,您只需要在 header.h中保留函数的 声明,将其定义(主体)放入 独立的 .cpp文件中(这是经典的方法)。

inline关键字表示对编译器的一个非绑定请求,请求编译器直接在调用站点内联函数体,而不是为常规函数调用设置堆栈框架。虽然编译器不必满足您的请求,但是 inline关键字确实成功地告诉链接器容忍多个符号定义。根据 C + + 11标准第3.2/5段:

类类型(条款9)、枚举类型(7.2)、 带外部连接的内联函数(7.1.2)、类模板(条款14)、非静态函数模板(条款14.5.6)、类模板的静态数据成员(条款14.5.1.3)、类模板的成员函数(条款14.5.1.1)或者在程序中没有指定某些模板参数的模板专门化(条款14.7.14.5.5)可以有多个定义,前提是每个定义出现在不同的翻译单元中,并且定义满足以下要求[ ... ]

以上段落基本上列出了通常放在头文件 中的所有定义,因为它们可以安全地包含在多个翻译单元中。所有其他具有外部链接的定义都属于源文件。

使用 static关键字而不是 inline关键字也可以通过给函数 内部联系来抑制连接器错误,从而使每个翻译单元保存该函数(及其局部静态变量)的私有 收到。但是,这最终会产生一个更大的可执行文件,通常应该首选使用 inline

实现与 static关键字相同结果的另一种方法是将函数 f()放在 未命名的命名空间中。根据 C + + 11标准第3.5/4段:

未命名的命名空间或在未命名的命名空间中直接或间接声明的命名空间具有内部链接。所有其他名称空间都有外部链接。如果一个名称的命名空间范围没有提供上面的内部链接,那么该名称与封闭的命名空间具有相同的链接,如果该名称是:

ー变数; 或

一个函数; 或

ー命名类(条款9) ,或在 typedef 声明中定义的未命名类,其中该类具有用于链接目的的 typedef 名称(7.1.3) ; 或

ー命名枚举(7.2) ,或在 typedef 声明中定义的未命名枚举,其中的枚举具有用于链接目的的 typedef 名称(7.1.3) ; 或

ー属于有联系的计数的计数器; 或

一个模板。

出于上面提到的同样原因,首选 inline关键字。

首先,你应该100% 确定你没有重复的“包括警卫”。

执行这个命令

grep -rh "#ifndef" * 2>&1 | uniq -c | sort -rn | awk '{print $1 " " $3}' | grep -v "^1\ "

您将1)突出显示所有包含保护,得到每个包含名称的计数器唯一行,排序结果,打印只计数器和包含名称,并删除那些真正唯一的。

提示: 这相当于获取重复的包含名称列表

Fiorentinoing 的答案在 Git 2.24(Q42019)中得到了回应,在 Git 代码库中也进行了类似的代码清理。

提交2044年4月39日(2019年10月3日) by 勒内 · 沙夫(rscharfe)
(由 朱尼奥 · C · 哈马诺 gitster于2019年10月11日在 提交 a4c5d9f合并)

Treewide: 删除重复的 #include指令

发现:

git grep '^#include ' '*.c' | sort | uniq -d