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

82

包含保护的两个常见问题:

  1. 第一个问题:

    为什么包含保护没有保护我的头文件免受相互递归包含的影响?每次我编写类似以下代码的内容时,都会收到有关不存在的符号(明显存在)或更奇怪的语法错误的错误消息:

    "a.h"

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

    “b.h”

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

    "main.cpp"

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

    为什么我编译 "main.cpp" 时会出现错误?我需要做什么来解决这个问题?


  1. 第二个问题:

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

    "header.h"

#ifndef HEADER_H
#define HEADER_H

int f()
{
    return 0;
}

#endif // HEADER_H

"source1.cpp"

#include "header.h"
...

"source2.cpp"

#include "header.h"
...

为什么会发生这种情况?我需要做什么来解决我的问题?


3
我不明白这与https://dev59.com/JXRB5IYBdhLWcg3wro6B和https://dev59.com/o2Yq5IYBdhLWcg3whQ00有什么不同。 - Luchian Grigore
1
@LuchianGrigore:第一个问答与包含保护直接相关,或者至少在我看来它没有解释为什么包含保护会给依赖关系带来麻烦。第二个问答确实回答了其中的一个问题(第二个),但是回答得不够详细和充分。我想将这两个有关包含保护的问答组合在一起,因为它们似乎存在着密切的联系。 - Andy Prowl
1
@sbi:你去掉标签我没意见,没问题。只是我认为既然这是关于C ++的经常被问到的问题,应该将其标记为faq-c++。 - Andy Prowl
1
@sbi:嗯,在过去的几天里,我在SO上看到至少4个初学者困惑于多重定义或相互包含的问题,所以从我的角度来看,这确实是一个经常出现的问题。这就是为什么我首先要写这整篇文章的原因:否则我为什么要为初学者写一篇问答呢?但当然,我理解每个人对“频繁”的主观感受都不同,我的感受可能与你的不同。虽然我仍然认为这应该被标记为c++-faq,但我并不反对更有经验的高声望用户强制执行他的观点。 - Andy Prowl
1
对我来说,这似乎是一个常见问题解答。 - Jonathan Wakely
显示剩余6条评论
3个回答

136

第一个问题:

为什么头文件的包含保护不能防止相互递归包含

它们是可以的

它们无法帮助处理相互包含头文件中数据结构定义之间的依赖关系。为了理解这意味着什么,让我们从一个基本场景开始,并看看为什么包含保护可用于相互包含。

假设您的相互包含的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"指令,这将设置一个潜在的无限递归过程。当达到关键嵌套级别时,编译器将报告错误。

然而,如果存在预处理命令,则不会在第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 并将 b.h 中的 #include 指令替换为 a.h 预处理的结果;
  4. 编译器将再次开始预处理 a.h,并再次遇到 #ifndef A_H 指令。然而,在之前的预处理中,宏 A_H 已经被定义了。因此,编译器这次会跳过以下文本,直到找到匹配的 #endif 指令,并且该处理的输出是空字符串(当然,假设没有在 #endif 指令之后有其他内容)。因此,预处理器将用空字符串替换 b.h 中的 #include "a.h" 指令,并追溯执行,直到替换原始的 #include 指令在 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,而正是由于包含保护机制,此前从未遇到过类A
当然,在声明先前未声明的类型的成员变量时会出现错误,编译器会礼貌地指出这一点。
我需要做什么来解决我的问题?
你需要使用前向声明
事实上,为了定义类B,并不需要定义类A的定义,因为一个指向A指针被声明为成员变量,而不是A类型的对象。由于指针具有固定的大小,编译器不需要知道A的确切布局或计算其大小来正确地定义类B。因此,在b.h中进行前置声明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的依赖的:尽可能使用/实用前向声明也被认为是一种良好的编程习惯,因为它有助于避免不必要的包含,从而减少总体编译时间。然而,在消除相互包含之后,main.cpp将不得不修改以同时#includea.hb.h(如果后者需要的话),因为b.h不再是通过a.h间接#include的;
  2. 虽然对类A进行前向声明就足以让编译器声明该类的指针(或在任何其他接受不完整类型的上下文中使用它),但对A的指针进行解引用(例如调用成员函数)或计算其大小都是对不完整类型的非法操作:如果需要这样做,则编译器需要可用A的完整定义,这意味着定义它的头文件必须被包括。这就是为什么类定义及其成员函数的实现通常被分为一个头文件和一个实现文件的原因(类模板是这个规则的例外):实现文件从未被项目中的其他文件#include,可以安全地#include所有必要的头文件以使定义可见。另一方面,头文件不会#include其他头文件,除非它们真的需要这样做(例如,为了使基类的定义可见),并且尽可能/实用时使用前向声明。

第二个问题:

为什么包含保护没有防止多重定义?

它们是有用的

它们不能保护你免受在不同编译单元中出现的多个定义。这也在StackOverflow上的此问答中有所解释。

为了看到这一点,请尝试删除包含保护并编译以下修改后的source1.cpp(或者对于它来说,source2.cpp):

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

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

int main()
{
    ...
}

The compiler will report an error when f() is redefined, as its definition is being included twice. However, if the proper include guards are added in header.h, source1.cpp can be compiled without issues. Even with include guards, the linker may still find multiple definitions when merging object code from source1.cpp and source2.cpp, preventing the creation of an executable.
基本上,你项目中的每个.cpp文件(在这个上下文中的技术术语是翻译单元)都会被分别和独立地编译。解析.cpp文件时,预处理器将处理所有#include指令并展开所有遇到的宏调用,这个纯文本处理的输出将作为输入提供给编译器进行转换成目标代码。一旦编译器完成了一个翻译单元的目标代码生成,它就会继续处理下一个,而在处理前一个翻译单元时遇到的所有宏定义都将被遗忘。
事实上,使用n个翻译单元(.cpp文件)编译一个项目就像执行相同程序(编译器)n次,每次使用不同的输入:同一程序的不同执行不会共享前一个程序执行的状态。因此,每个翻译都是独立执行的,编译一个翻译单元时遇到的预处理符号在编译其他翻译单元时将不会被记住(如果你仔细想想,你很容易意识到这实际上是一种理想的行为)。
因此,即使包含保护可以帮助您防止递归互相包含和同一头文件在一个翻译单元中的冗余包含,它们无法检测是否在不同的翻译单元中包含了相同的定义。
然而,当合并编译所有项目的所有.cpp文件生成的目标代码时,链接器将会发现同一符号被定义了多次,由于这违反了一次定义规则(ODR)。根据C++11标准第3.2/3段:

每个程序都必须包含在该程序中使用的每个非内联函数或变量的确切定义;无需诊断。定义可以在程序中显式出现,在标准库或用户定义的库中找到,或者(在适当的情况下)是隐式定义的(请参见12.1、12.4和12.8)。内联函数必须在使用该函数的每个翻译单元中定义

因此,链接器将会发出错误并拒绝生成程序的可执行文件。
"我需要做什么来解决我的问题?"
如果您想将函数定义保留在一个头文件中,该头文件被多个翻译单元包含(请注意,如果您的头文件只被一个翻译单元包含,则不会出现问题),则需要使用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关键字。

1
不错。在讨论ODR的两种风格时,我会补充指出引用的3.2 / 3列出了我们通常放在头文件中的定义以及所有其他具有外部链接的定义都属于源文件。然后列出一个“我的定义适用哪种ODR?”的简明语言检查表。 - aschepler
4
通常来说,这种情况的答案是“反社会人格障碍”。不要因此而沮丧。很棒的帖子...点赞。 - Jim Balter
我想让你知道,你的回答太长了,我实际上考虑过是否值得花费精力滚动到顶部来点赞它...但我还是点了赞。 - Andrew
1
@Andrew:谢谢,我很高兴你找到了动力:D - Andy Prowl
3
@AndyProwl,感谢你抽出时间撰写如此有用且详尽的解释,给你点赞。 - v.tralala
显示剩余7条评论

-1

-2
首先,您应该百分之百确定在“包含保护”中没有重复项。
使用此命令:
grep -rh "#ifndef" * 2>&1 | uniq -c | sort -rn | awk '{print $1 " " $3}' | grep -v "^1\ "

你需要完成以下任务:1)高亮所有的include guards,获取每个include名称的唯一行数和计数器,对结果进行排序,仅打印计数器和include名称,并删除那些真正唯一的。

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接