为什么't my include保护防止递归包含和多个符号定义

Why aren't my include guards preventing recursive inclusion and multiple symbol definitions?

本文关键字:包含 递归 定义 符号 my 保护 include 为什么      更新时间:2023-10-16

关于include guards的两个常见问题:

  1. 第一个问题:

    为什么include保护程序不保护我的头文件不受相互递归包含的影响?每次我写这样的东西时,我都会收到关于不存在的符号的错误,这些符号显然存在,甚至更奇怪的语法错误:

    "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"时出错?我需要做些什么来解决我的问题?


  • 第二个问题:

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

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

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

  • 第一个问题:

    为什么include保护程序不保护我的头文件不受相互递归包含的影响?

    它们是

    它们对相互包含的头中的数据结构定义之间的依赖性没有帮助。要了解这意味着什么,让我们从一个基本场景开始,看看为什么include guard对相互包含有帮助。

    假设相互包含的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定义,因为指向Apointer被声明为成员变量,而不是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将不得不被修改为#include——a.hb.h(如果需要后者的话),因为b.h不再是通过a.h间接的#included
    2. 虽然类A的前向声明足以让编译器声明指向该类的指针(或在可接受不完整类型的任何其他上下文中使用它),但对不完整类型取消引用指向A的指针(例如调用成员函数)或计算其大小是非法的操作:如果需要,A的完整定义需要对编译器可用,这意味着必须包括定义它的头文件。这就是为什么类定义及其成员函数的实现通常被拆分为该类的头文件和实现文件(class模板是该规则的例外):实现文件从来不是项目中其他文件的#included,可以安全地#include所有必要的头文件,使定义可见。另一方面,头文件不会#include其他头文件,除非它们真的需要这样做(例如,使基类的定义可见),并且将尽可能/实用地使用前向声明

    第二个问题:

    为什么不包括防止多个定义的保护?

    它们是

    他们没有保护您免受不同翻译单元中的多个定义的影响。这也在这个问答中得到了解释;StackOverflow上的一个

    请注意,尝试删除include保护并编译以下source1.cpp(或source2.cpp,重要的是)的修改版本:

    //================================================
    // source1.cpp
    //
    // Good luck getting this to compile...
    #include "header.h"
    #include "header.h"
    int main()
    {
    ...
    }
    

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

    尽管如此,即使包含保护存在并且编译器将停止用错误消息困扰您,链接器也会坚持在合并从source1.cppsource2.cpp的编译中获得的目标代码时发现多个定义,并拒绝生成您的可执行文件。

    为什么会发生这种情况?

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

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

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

    然而,当合并从项目的所有.cpp文件的编译中生成的目标代码时,链接器看到同一个符号被定义了多次,因为这违反了One Definition Rule。根据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关键字。

    fioretinoing的回答在Git 2.24(2019年第四季度)中得到了回应,Git代码库中也进行了类似的代码清理。

    参见RenéScharfe提交的2fe4439(2019年10月3日)(rscharfe)
    (由Junio C Hamano合并——gitster——提交a4c5d9f,2019年10月11日)

    树范围:删除重复的#include指令

    找到:

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

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

    使用此命令

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

    您将1)突出显示所有include保护,获得每个include名称带有counter的唯一行,对结果进行排序,只打印counter和include名称,并删除真正唯一的。

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