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


74

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

  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. 第二个问题:

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

    “ 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"
    ...
    

    为什么会这样呢?我该怎么做才能解决我的问题?



1
@LuchianGrigore:第一个问题与A并没有直接关系到包含保护,或者至少是IMO,它没有解释为什么包含保护给依赖项带来麻烦。第二个问题的确解决了两个问题之一(第二个问题),但是所涉及的内容不够详尽。我想将关于保镖的这两个问答集归为一类,因为在我看来,它们之间有着密切的联系。
Andy Prowl

1
@sbi:我可以删除标签,没问题。我只是因为这是有关C ++的常见问题,所以应该将其标记为faq-c ++。
Andy Prowl

1
@sbi:好吧,最近几天,我看到至少有4个关于SO的问题,这些问题让初学者感到困惑,因为它们有多个定义或相互包含,所以从我的观点来看,这一个反复出现的问题。这就是为什么我一开始就写这整本书的原因:为什么我要为初学者写一个问答集呢?但是,当然,我知道每个人对什么是“频繁”都有主观的认识,而我的看法可能与您的看法不符。尽管我仍然认为应该将其标记为c ++-faq,但是我对拥有更多经验的高级用户没有什么意见。
安迪·普罗

1
对我来说似乎是一个常见问题解答
Jonathan Wakely

Answers:


130

第一个问题:

为什么不包括保护我的头文件不被相互递归包含的保护措施?

他们是

他们没有帮助的是相互包含的标头中数据结构的定义之间的依赖关系。要了解这意味着什么,让我们从一个基本的场景开始,看看为什么包含保护确实有助于相互包含。

假设您的相互包含文件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并用预处理结果替换该#include指令;b.ha.h
  4. 编译器将a.h再次开始预处理,并再次满足#ifndef A_H指令。但是,在先前的预处理过程中,A_H已定义了宏。因此,编译器这次将跳过以下文本,直到#endif找到匹配的伪指令为止,并且此处理的输出是空字符串(#endif当然,假设该伪指令没有任何内容)。因此,预处理器将用空字符串替换#include "a.h"in中的指令b.h,并将追溯执行直到它替换中的原始#include指令main.cpp

因此,包括卫队确实防止相互包容。但是,它们不能帮助相互包含文件中的类定义之间的依赖关系

//================================================
// 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的末尾会发生一些变化:用空字符串替换#include "a.h"in指令之后b.h,预处理器将开始解析的内容b.h,尤其是的定义B。不幸的是,B提到class的定义,A正是由于包含防护措施才被实现!

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

我该怎么做才能解决我的问题?

您需要前向声明

实际上,定义类的A,以限定类不需要B的,因为一个指针A正在被声明作为成员变量,而不是类型的对象A。由于指针具有固定的大小,因此编译器不需要知道的确切布局,A也不需要计算其大小即可正确定义class B。因此,它是足够前瞻性声明Ab.h,使编译器知道它的存在:

//================================================
// 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. 通过用#includein的前向声明替换指令来打破相互包含不仅b.h足以有效地表达Bon的依赖关系A:在可能/可行的情况下使用前向声明也被认为是良好的编程习惯,因为它有助于避免不必要的包含,因此减少整体编译时间。然而,消除了相互夹杂物后,main.cpp将必须进行修改,以#include两者a.hb.h(如果后者需要在所有),因为b.h没有更多的间接#include至D a.h;
  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()
{
    ...
}

编译器肯定会在这里抱怨f()重新定义。显而易见:其定义被两次包含!但是,source1.cpp header.h包含适当的包含保护时,以上内容将毫无问题地进行编译。那是意料之中的。

不过,即使在包括警卫都存在,编译器将停止与错误消息困扰你,则连接器将坚持一个事实,即合并来自汇编得到的目标代码时,被人发现多个定义source1.cppsource2.cpp,并拒绝生成您可执行文件。

为什么会这样呢?

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

实际上,使用n翻译单元(.cpp文件)编译项目就像执行同一程序(编译器)一样n,每次都使用不同的输入:同一程序的不同执行不会共享先前程序执行的状态)。因此,每个翻译都是独立执行的,并且在编译其他翻译单元时,将不会记住在编译一个翻译单元时遇到的预处理器符号(如果暂时考虑一下,您将很容易意识到这实际上是一种理想的行为)。

因此,即使包含保护有助于您防止一个翻译单元中同一标头的递归互斥和冗余包含,它们也无法检测到同一定义是否包含在不同的翻译单元中。

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

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

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

我该怎么做才能解决我的问题?

如果要将函数定义保留在#include多个转换单元d组成的头文件中(请注意,如果您的标头#include仅由一个转换单元d不会出现问题),则需要使用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);要么

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

—属于具有链接的枚举的枚举数;要么

—模板。

出于与上述相同的原因,inline应首选关键字。


真好 在讨论ODR的两种形式的某个地方,我要指出一点,引号3.2 / 3列出了我们通常放在头文件中的定义,而所有其他具有外部链接的定义都在源文件中。然后是一个简单的语言清单:“哪种ODR适用于我的定义?”
aschepler

@aschepler:您的意思是3.2 / 4(“如果...,则必须完成T类型”)还是3.2 / 5(“类类型可以有多个定义(第9条),枚举类型(7.2)” ),具有外部链接的内联函数(7.1.2),类模板(第14条),并且提供的定义满足以下要求[...]?我认为同时提及这两者将是有用的,另一方面,很难在短时间内做到这一点,并且在经过长时间的解释之后,重点将转移到包含警卫队,这是本问答的主题。也许有与此相关的新的常见问题解答条目?
安迪·普罗

4
@AndyProwl-通常的答案是社会病。是不是让你失望。很棒的职位... +1
吉姆·巴尔特

1
@Andrew:谢谢你,我很高兴你找到了能量:D
Andy Prowl

3
@AndyProwl感谢您抽出宝贵的时间,并写了这么有用且广泛的解释,+ 1
v.tralala


-2

首先,您应该100%确保在“ include guards”中没有重复项。

使用此命令

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

您将1)突出显示所有包含防护,针对每个包含名称获得带有计数器的唯一行,对结果进行排序,仅打印计数器和包含名称,然后删除真正唯一的计数器。

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.