第一个问题:
为什么头文件的包含保护不能防止相互递归包含?
它们是可以的。
它们无法帮助处理相互包含头文件中数据结构定义之间的依赖关系。为了理解这意味着什么,让我们从一个基本场景开始,并看看为什么包含保护可用于相互包含。
假设您的相互包含的a.h
和b.h
头文件有微不足道的内容,即问题文本中代码部分的省略号被替换为空字符串。在这种情况下,您的main.cpp
将愉快地编译。这完全得益于您的包含保护!
如果您还不确定,请尝试删除它们:
#include "b.h"
#include "a.h"
#include "a.h"
int main()
{
...
}
当编译器达到包含深度限制时,会报告失败。该限制是与实现相关的。根据C++11标准第16.2/6段的规定:
“一个#include预处理指令可以出现在已经由另一个文件的#include指令读取的源文件中,最多可以嵌套到一个实现定义的限制。”
那么,到底发生了什么?
- 在解析
main.cpp
时,预处理器会遇到指令#include "a.h"
。该指令告诉预处理器处理头文件a.h
,取得处理结果,并用该结果替换字符串#include "a.h"
;
- 在处理
a.h
时,预处理器会遇到指令#include "b.h"
,同样的机制适用:预处理器将处理头文件b.h
,取得处理结果,并用该结果替换#include
指令;
- 在处理
b.h
时,指令#include "a.h"
会告诉预处理器处理a.h
,并替换该指令的结果;
- 预处理器将再次开始解析
a.h
,再次遇到#include "b.h"
指令,这将设置一个潜在的无限递归过程。当达到关键嵌套级别时,编译器将报告错误。
然而,如果存在预处理命令,则不会在第4步设置无限递归。下面看看原因:
- (同上) 在解析
main.cpp
时,预处理器会遇到指令 #include "a.h"
。这告诉预处理器要处理头文件 a.h
,并将处理结果替换字符串 #include "a.h"
;
- 在处理
a.h
时,预处理器会遇到指令 #ifndef A_H
。由于宏 A_H
尚未被定义,它将继续处理以下文本。随后的指令 (#defines A_H
) 定义了宏 A_H
。然后,预处理器会遇到指令 #include "b.h"
:预处理器现在应该处理头文件 b.h
,并且用其处理结果替换 #include
指令;
- 在处理
b.h
时,预处理器会遇到指令 #ifndef B_H
。由于宏 B_H
尚未被定义,它将继续处理以下文本。随后的指令 (#defines B_H
) 定义了宏 B_H
。然后,指令 #include "a.h"
告诉预处理器要处理 a.h
并将 b.h
中的 #include
指令替换为 a.h
预处理的结果;
- 编译器将再次开始预处理
a.h
,并再次遇到 #ifndef A_H
指令。然而,在之前的预处理中,宏 A_H
已经被定义了。因此,编译器这次会跳过以下文本,直到找到匹配的 #endif
指令,并且该处理的输出是空字符串(当然,假设没有在 #endif
指令之后有其他内容)。因此,预处理器将用空字符串替换 b.h
中的 #include "a.h"
指令,并追溯执行,直到替换原始的 #include
指令在 main.cpp
中。
因此,
预处理器的#include保护确实可以防止相互包含。然而,它们无法解决相互包含文件中类定义之间的
依赖关系问题。
#ifndef A_H
#define A_H
#include "b.h"
struct A
{
};
#endif
#ifndef B_H
#define B_H
#include "a.h"
struct B
{
A* pA;
};
#endif
#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
并让编译器知道它的存在就足够了。
#ifndef B_H
#define B_H
struct A;
struct B
{
A* pA;
};
#endif
您的main.cpp
现在肯定会编译。以下是一些备注:
- 仅仅通过在b.h中使用前向声明来替换
#include
指令以打破相互包含是不足以有效地表达B
对A
的依赖的:尽可能使用/实用前向声明也被认为是一种良好的编程习惯,因为它有助于避免不必要的包含,从而减少总体编译时间。然而,在消除相互包含之后,main.cpp
将不得不修改以同时#include
a.h
和b.h
(如果后者需要的话),因为b.h
不再是通过a.h
间接#include
的;
- 虽然对类
A
进行前向声明就足以让编译器声明该类的指针(或在任何其他接受不完整类型的上下文中使用它),但对A
的指针进行解引用(例如调用成员函数)或计算其大小都是对不完整类型的非法操作:如果需要这样做,则编译器需要可用A
的完整定义,这意味着定义它的头文件必须被包括。这就是为什么类定义及其成员函数的实现通常被分为一个头文件和一个实现文件的原因(类模板是这个规则的例外):实现文件从未被项目中的其他文件#include
,可以安全地#include
所有必要的头文件以使定义可见。另一方面,头文件不会#include
其他头文件,除非它们真的需要这样做(例如,为了使基类的定义可见),并且尽可能/实用时使用前向声明。
第二个问题:
为什么包含保护没有防止多重定义?
它们是有用的。
它们不能保护你免受在不同编译单元中出现的多个定义。这也在StackOverflow上的此问答中有所解释。
为了看到这一点,请尝试删除包含保护并编译以下修改后的source1.cpp
(或者对于它来说,source2.cpp
):
#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关键字。