【问题标题】:Should one use forward declarations instead of includes wherever possible?是否应该尽可能使用前向声明而不是包含?
【发布时间】:2023-11-19 04:35:01
【问题描述】:

当一个类声明仅使用另一个类作为指针时,使用类前向声明​​而不是包含头文件以预先避免循环依赖问题是否有意义?所以,而不是:

//file C.h
#include "A.h"
#include "B.h"

class C{
    A* a;
    B b;
    ...
};

改为这样做:

//file C.h
#include "B.h"

class A;

class C{
    A* a;
    B b;
    ...
};


//file C.cpp
#include "C.h"
#include "A.h"
...

有什么理由不尽可能地这样做吗?

【问题讨论】:

  • 嗯 - 这是顶部还是底部问题的答案?
  • 你真正的问题(在底部) - AFAIK 在这种情况下没有理由不使用前向声明......
  • 这稍微取决于“仅将另一个类用作指针”的意思。有一个令人讨厌的情况,您可以仅使用前向声明来delete 指针,但如果该类实际上具有非平凡的析构函数,那么您将得到 UB。所以如果delete“只使用指针”那么是的,这是有原因的。如果不算数,也不算数。
  • 循环依赖不是还在,只是对编译器隐藏了吗?如果是的话,这两种策略总是包括并总是做前向声明并不会教你如何避免循环依赖。但是我必须承认,使用前向声明它们可能更容易找到。
  • 如果类是 A 实际定义为结构,一些编译器可能会抱怨。如果 A 类是派生类,那么您就有问题了。如果类 A 定义在另一个命名空间中,并且标头只是使用 using 声明将其拉入此命名空间,则您可能会遇到问题。如果 A 类实际上是别名(或宏!),那么您就有问题了。如果 A 类实际上是一个 typedef,那么你就有问题了。如果 A 类实际上是具有默认模板参数的类模板,那么您就有问题了。是的,不转发声明是有原因的:它破坏了实现细节的封装。

标签: c++ forward-declaration


【解决方案1】:

有什么理由不尽可能地这样做吗?

绝对:它通过要求类或函数的用户知道和复制实现细节来打破封装。如果这些实现细节发生变化,前向声明的代码可能会被破坏,而依赖于标头的代码将继续工作。

转发声明函数:

  • 需要知道它是作为函数实现的,而不是静态函子对象或(喘气!)宏的实例,

  • 需要复制默认参数的默认值,

  • 需要知道它的实际名称和命名空间,因为它可能只是一个 using 声明,将其拉入另一个命名空间,可能在别名下,并且

  • 可能会失去在线优化。

如果消费代码依赖于标头,那么函数提供者可以更改所有这些实现细节,而不会破坏您的代码。

转发声明一个类:

  • 需要知道它是否是派生类以及派生它的基类,

  • 需要知道它是一个类,而不仅仅是 typedef 或类模板的特定实例化(或知道它是类模板并正确获取所有模板参数和默认值),

  • 需要知道类的真实名称和命名空间,因为它可能是 using 声明,将其拉入另一个命名空间,可能在别名下,并且

  • 需要知道正确的属性(也许它有特殊的对齐要求)。

同样,前向声明会破坏这些实现细节的封装,使您的代码更加脆弱。

如果您需要减少头文件依赖以加快编译时间,那么请让类/函数/库的提供者提供一个特殊的前向声明头文件。标准库使用<iosfwd> 执行此操作。该模型保留了实现细节的封装,并使库维护者能够在不破坏代码的情况下更改这些实现细节,同时减少编译器的负载。

另一种选择是使用 pimpl 习惯用法,它可以更好地隐藏实现细节并以少量运行时开销为代价加快编译速度。

【讨论】:

  • 最后,你推荐使用 pimpl idiom,但是这个 idiom 的全部用处是基于前向声明。前向声明和 pimpl idiom 几乎是一回事。
  • @user2445507:问题是“尽可能”。我的观点是,“尽可能”通常不是一个好主意。正如我在倒数第二段中所说,接口的所有者可以提供转发声明,因为他们可以使转发声明与实际接口保持同步。使用 pimpl 惯用语,前向声明和 impl 对象的实现由同一个程序员负责,所以这很好。
  • 你原来的说法有点模棱两可。它可以被解释为意味着 pimpl 不使用前向声明(即它们是另一种选择)。我之前遇到过这种误解,有人建议使用 pimpl INSTEAD 代替 fwd 声明,但不理解其中的矛盾。无论如何,感谢您的澄清。
【解决方案2】:

有趣的是,in its C++ styleguide,Google 建议在任何地方都使用 #include,但要避免循环依赖。

【讨论】:

【解决方案3】:

是否应该尽可能使用前向声明而不是包含?

不,不应将显式前向声明视为一般准则。前向声明本质上是复制和粘贴或拼写错误的代码,如果您在其中发现错误,则需要在使用前向声明的任何地方进行修复。这很容易出错。

为避免“前向”声明与其定义不匹配,请将声明放在头文件中,并将该头文件包含在定义源文件和使用声明的源文件中。

然而,在这种特殊情况下,只有一个不透明的类被前向声明,这个前向声明可能可以使用,但一般来说,“尽可能使用前向声明而不是包含”,就像这个线程的标题说,可能是相当冒险的。

以下是一些关于前向声明的“不可见风险”示例(不可见风险 = 编译器或链接器未检测到的声明不匹配):

  • 表示数据的符号的显式前向声明可能不安全,因为此类前向声明​​可能需要正确了解数据类型的占用空间(大小)。

  • 表示函数的符号的显式前向声明也可能是不安全的,例如参数类型和参数数量。

下面的例子说明了这一点,例如,数据和函数的两个危险的前向声明:

文件交流:

#include <iostream>
char data[128][1024];
extern "C" void function(short truncated, const char* forgotten) {
  std::cout << "truncated=" << std::hex << truncated
            << ", forgotten=\"" << forgotten << "\"\n";
}

文件 b.c:

#include <iostream>
extern char data[1280][1024];           // 1st dimension one decade too large
extern "C" void function(int tooLarge); // Wrong 1st type, omitted 2nd param

int main() {
  function(0x1234abcd);                         // In worst case: - No crash!
  std::cout << "accessing data[1270][1023]\n";
  return (int) data[1270][1023];                // In best case:  - Boom !!!!
}

用g++ 4.7.1编译程序:

> g++ -Wall -pedantic -ansi a.c b.c

注意:无形的危险,因为 g++ 没有给出编译器或链接器错误/警告
注意:省略 extern "C" 会导致 function() 的链接错误,因为 c++ 名称重整。

运行程序:

> ./a.out
truncated=abcd, forgotten="♀♥♂☺☻"
accessing data[1270][1023]
Segmentation fault

【讨论】:

    【解决方案4】:

    有什么理由不尽可能地这样做吗?

    是的 - 性能。类对象与它们的数据成员一起存储在内存中。当您使用指针时,指向实际对象的内存存储在堆上的其他地方,通常很远。这意味着访问该对象将导致缓存未命中并重新加载。在性能至关重要的情况下,这可能会产生很大的不同。

    在我的 PC 上,Faster() 函数的运行速度比 Slower() 函数快大约 2000 倍:

    class SomeClass
    {
    public:
        void DoSomething()
        {
            val++;
        }
    private:
        int val;
    };
    
    class UsesPointers
    {
    public:
        UsesPointers() {a = new SomeClass;}
        ~UsesPointers() {delete a; a = 0;}
        SomeClass * a;
    };
    
    class NonPointers
    {
    public:
        SomeClass a;
    };
    
    #define ARRAY_SIZE 100000
    void Slower()
    {
        UsesPointers list[ARRAY_SIZE];
        for (int i = 0; i < ARRAY_SIZE; i++)
        {
            list[i].a->DoSomething();
        }
    }
    
    void Faster()
    {
        NonPointers list[ARRAY_SIZE];
        for (int i = 0; i < ARRAY_SIZE; i++)
        {
            list[i].a.DoSomething();
        }
    }
    

    在对性能至关重要的部分应用程序或在特别容易出现缓存一致性问题的硬件上工作时,数据布局和使用会产生巨大的影响。

    这是一个关于主题和其他性能因素的很好的介绍: http://research.scee.net/files/presentations/gcapaustralia09/Pitfalls_of_Object_Oriented_Programming_GCAP_09.pdf

    【讨论】:

    • 您正在回答一个不同的问题(“我应该使用指针吗?”),而不是被问到的问题(“当我只使用指针时,是否有任何理由不使用前向声明?”)。
    • @AndrewMedico 我认为这是一个很好的答案,指出了性能缺陷,它确实回答了这个问题。 “前向声明 == 使用指针”
    【解决方案5】:

    有什么理由不尽可能地这样做吗?

    我想到的唯一原因是节省一些打字。

    如果没有前向声明,你可以只包含一次头文件,但由于其他人指出的缺点,我不建议在任何相当大的项目上这样做。

    【讨论】:

    • @Luchian Grigore:对于一些简单的测试程序可能没问题
    【解决方案6】:

    是的,使用前向声明总是更好。

    它们提供的一些优势是:

    • 缩短编译时间。
    • 没有命名空间污染。
    • (在某些情况下)可能会减小生成的二进制文件的大小。
    • 可以显着减少重新编译时间。
    • 避免预处理器名称的潜在冲突。
    • 实现 PIMPL Idiom 从而提供一种从接口隐藏实现的方法。

    但是,Forward 声明一个类会使该特定类成为不完整类型,这严重限制了您可以对不完整类型执行的操作。
    您不能执行任何需要编译器知道类的布局的操作。

    使用不完整类型,您可以:

    • 将成员声明为指向不完整类型的指针或引用。
    • 声明接受/返回不完整类型的函数或方法。
    • 定义接受/返回指向不完整类型的指针/引用的函数或方法(但不使用其成员)。

    对于不完整类型,您不能:

    • 将其用作基类。
    • 用它来声明一个成员。
    • 使用此类型定义函数或方法。

    【讨论】:

    • “但是,转发声明一个类会使该特定类成为不完整类型,并且严重限制了您可以对不完整类型执行的操作。”是的,但是如果您 can 转发声明它,则意味着您不需要在标头中包含完整的类型。如果您确实需要包含该标题的文件中的完整类型,只需包含您需要的类型的标题。 IMO,这是一个优势 - 它迫使您在实现文件中包含您需要的任何内容,而不是依赖它被包含在其他地方。
    • 假设有人更改了该标题,并将包含替换为前向声明。然后你必须去更改所有包含该标题的文件,使用缺失的类型,但不要自己包含缺失类型的标题(尽管它们应该包含)。
    • @LuchianGrigore: ..但是如果你可以转发声明它......,你将不得不尝试它来检查。所以没有固定的规则去对于前向声明和不包含标题,了解规则有助于组织您的实现。前向声明的最常见用途是打破循环依赖关系,这就是 你不能用不完整类型做什么通常会咬你的地方.每个源文件和头文件都应该包含编译所需的所有头文件,因此第二个参数不适用,它只是一个组织不良的代码。
    • 对于 PIMPL,仅在类的私有部分使用前向声明也可能有意义
    【解决方案7】:

    您不希望前向声明的一种情况是它们本身很棘手。如果您的某些类被模板化,就会发生这种情况,如下例所示:

    // Forward declarations
    template <typename A> class Frobnicator;
    template <typename A, typename B, typename C = Frobnicator<A> > class Gibberer;
    
    // Alternative: more clear to the reader; more stable code
    #include "Gibberer.h"
    
    // Declare a function that does something with a pointer
    int do_stuff(Gibberer<int, float>*);
    

    前向声明与代码重复相同:如果代码倾向于大量更改,则每次都必须更改 2 处或更多处,这是不好的。

    【讨论】:

    • +1 破坏了前向声明严格总是更好的共识:-) IIRC 通过 typedefs 的“秘密”模板实例化类型也会出现同样的问题。 namespace std { class string; } 即使允许将类声明放在命名空间 std 中也是错误的,因为(我认为)你不能合法地将 typedef 声明为一个类。
    【解决方案8】:

    有什么理由不尽可能地这样做吗?

    方便。

    如果您提前知道此头文件的任何用户都必须包含A 的定义才能执行任何操作(或者大多数情况下)。那么只需要一劳永逸地包含它就很方便了。

    这是一个相当敏感的主题,因为过于随意地使用这个经验法则会产生几乎无法编译的代码。请注意,Boost 通过提供特定的“便利”标头以不同的方式解决问题,这些标头将几个紧密的功能捆绑在一起。

    【讨论】:

    • 这是唯一指出这样做会产生生产力成本的答案。 +1
    • 从用户的角度。如果您转发声明所有内容,则意味着用户不能只包含该文件并立即开始工作。他们必须弄清楚依赖关系是什么(可能是由于编译器抱怨类型不完整),并且还必须包含这些文件,然后才能开始使用您的类。另一种方法是为您的库创建一个“shared.hpp”文件或其他文件,其中所有标题都在该文件中(如上面提到的 boost)。他们可以轻松地包含它,而无需弄清楚为什么不能“包含并使用”。
    【解决方案9】:

    前向声明方法几乎总是更好。 (我想不出包含一个可以使用前向声明的文件更好的情况,但我不会说它总是更好以防万一)。

    前向声明类没有缺点,但我可以想到不必要地包含标头的一些缺点:

    • 编译时间更长,因为包括C.h 在内的所有翻译单元也将包括A.h,尽管他们可能不需要它。

    • 可能包括您间接不需​​要的其他标头

    • 用你不需要的符号污染翻译单元

    • 如果包含该标头的源文件发生更改,您可能需要重新编译 (@PeterWood)

    【讨论】:

    • 另外,增加了重新编译的机会。
    • “我想不出在其中包含可以使用前向声明的文件更好的情况” - 当前向声明产生 UB 时,请参阅我对主要问题的评论。我认为你谨慎是对的:-)
    • @Luchian:因为是否是答案取决于提问者最初的意思,我不想发布相当于狡辩的“答案”。也许提问者永远不会梦想在C.h 中写delete 声明。
    • 缺点是更多的工作和更多的代码!并且更加脆弱。你不可能说没有缺点。
    • 如果有5个类怎么办?如果您以后需要添加一些怎么办?您只是专注于您的观点的最佳案例。