在可能的情况下,是否应该使用前向声明而不是包含头文件?

97

如果一个类声明只使用另一个类作为指针,那么使用类前向声明而不是包含头文件有意义吗?目的是为了预防循环依赖问题。所以,可以这样写:

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

无论何时都不这样做有什么原因吗?


6
那个回答是针对上面的问题还是下面的问题? - Mat
1
据我所知,在这种情况下使用前向声明没有任何不合适的理由...你真正的问题是什么? - Nim
15
“仅将另一个类用作指针”具体含义有所不同。存在一种情况,即你可以仅使用前向声明就能够delete一个指针,但是如果该类实际上具有非平凡析构函数,则会导致未定义行为。因此,如果“仅使用指针”就能够进行delete操作,那么这是有原因的,否则就不那么重要。 - Steve Jessop
1
循环依赖关系还存在,只是被编译器隐藏了吗?如果是的话,这两种策略都无法教你如何避免循环依赖关系。然而,我必须承认,使用前向声明可能更容易找到它们。 - grenix
2
如果类A实际上被定义为结构体,一些编译器可能会报错。如果类A是派生类,你会遇到问题。如果类A在另一个命名空间中定义,并且头文件只是通过using声明将其引入到此命名空间中,则可能会出现问题。如果类A实际上是别名(或宏!),你会遇到问题。如果类A实际上是typedef,你会遇到问题。如果类A实际上是具有默认模板参数的类模板,你会遇到问题。是的,不使用前向声明是有原因的:它破坏了实现细节的封装。 - Adrian McCarthy
相关链接:https://softwareengineering.stackexchange.com/q/388952/322162 - anatolyg
9个回答

72

使用前向声明方法几乎总是更好的选择(我想不出在可以使用前向声明的情况下包含文件更好的情况,但为了谨慎起见,我不会说它始终更好)。

前向声明类没有任何负面影响,但是包含不必要的头文件可能会有以下几个缺点:

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

  • 可能会间接地包含其他无需的头文件。

  • 会向翻译单元中添加不必要的符号。

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


13
同时,重新编译的机会增加了。 - Peter Wood
10
“我想不出任何情况,在那种情况下,包含一个可以使用前向声明的文件会更好” - 如果前向声明产生UB,请参见我在主问题中的评论。你谨慎是正确的,我认为 :-) - Steve Jessop
3
缺点是工作量和代码增加了!还有更多的脆弱性。你不可能说没有任何缺点。 - usr
3
如果有5个班级怎么办?如果之后需要添加一些呢?你只是聚焦于最有利的情况来支持你的观点。 - usr
2
我能想到一些可能会出现问题的情况: 向前声明那些最初是“struct”类型但后来变成“class”类型的类型,这可能会触发某些静态分析工具中的警告。 向前声明使用模板的类型,例如“typedef std::vector<MyType>;”,如果模板类型稍后需要更改,则需要更改所有向前声明。 - XKpe
显示剩余5条评论

45

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

它们提供的一些优点包括:

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

然而,前向声明一个类会使该特定类成为一个不完全类型,并严重限制您可以在不完全类型上执行的操作。
您不能执行任何需要编译器知道类布局的操作。

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

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

使用不完全类型,您不能:

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

1
然而,前向声明一个类会使该特定类成为不完整类型,并严重限制您可以对不完整类型执行的操作。是的,但如果您可以前向声明它,则意味着您不需要在头文件中使用完整类型。如果您确实需要在包含该头文件的文件中使用完整类型,请只包含所需类型的头文件。在我看来,这是一个优点-它强制您在实现文件中包含所需的任何内容,而不是依赖于它在其他地方被包含。 - Luchian Grigore
假设有人更改了头文件,并用前向声明替换了include。那么你就必须去更改所有包含该头文件的文件,使用缺失的类型但不包括缺失类型的头文件(尽管它们应该包括)。 - Luchian Grigore
1
@LuchianGrigore: 但是如果你可以前向声明它...,你将不得不尝试一下来检查。因此,并没有固定的规则只是采用前向声明而不包括头文件,知道规则有助于组织你的实现。前向声明的最常见用途是打破循环依赖关系,这就是通常会让你遇到不能用不完全类型做什么事情的地方。每个源文件和头文件都应包含其编译所需的所有头文件,因此第二个参数无效,这只是一个组织不良的代码开始。 - Alok Save
1
对于 PIMPL,仅在类的私有部分使用前向声明可能也是有意义的。 - grenix

24

在任何可能的情况下,为什么不这样做呢?

方便。

如果您预先知道该头文件的任何用户必须还需要包含 A 的定义才能执行任何操作(或者大多数情况下都需要),那么一劳永逸地将其包含进来是很方便的。

这是一个相当微妙的问题,因为过度使用这个经验法则会导致无法编译的代码。请注意,Boost通过提供特定的“方便”头文件来解决此问题,其中捆绑了几个密切相关的功能。


7
这是唯一指出这样做会有生产力成本的答案。+1 - usr
1
从用户的角度来看,如果您提前声明了所有内容,这意味着用户不能只包含该文件并立即开始工作。他们必须去找出依赖关系(可能是由于编译器抱怨不完整类型),并在使用您的类之前还要包含那些文件。另一种选择是为您的库创建一个“shared.hpp”文件或类似的文件,其中所有头文件都在该文件中(就像上面提到的boost)。他们可以轻松地包含它,而不必弄清楚为什么他们不能只是“包含和运行”。 - Todd

14

有一种情况下你不想使用前向声明,那就是当它们本身很棘手时。如果你的某些类是模板类,就像以下示例中一样:

// 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个或更多地方进行更改,这是不好的。


2
+1 破坏了前向声明始终更好的共识 :-) 如果我没记错,对于通过typedefs“秘密”实例化的类型也会出现同样的问题。即使允许在命名空间std中放置类声明,namespace std { class string; } 也是错误的,因为(我认为)你不能合法地将typedef作为类来前向声明。 - Steve Jessop

12

请参阅以下 Stack Overflow 问题及其答案:https://dev59.com/UFoV5IYBdhLWcg3wLsP5 - jciloa

9
在所有情况下都不应该将显式的前置声明视为一般性的准则。前向声明本质上是复制和粘贴或拼写错误的代码,如果在其中发现一个bug,需要到使用前向声明的每个位置都去修复这个问题,这可能会出错。为了避免“前向”声明和其定义之间的不匹配,在头文件中放置声明,并将该头文件包含在定义和使用声明的源文件中。
然而,在特定的情况下,只有一个不透明类被前置声明,这个前向声明就可以使用,但是通常来说,“尽可能使用前向声明而不是包含头文件”,就像本主题的标题所说,可能会带来相当大的风险。
以下是涉及前向声明的“隐形风险”的一些例子(隐形风险=编译器或链接器未检测到的声明不匹配的风险):
- 表示数据的符号的显式前向声明可能是不安全的,因为这样的前向声明可能需要正确了解数据类型的占用空间(大小)。 - 表示函数的符号的显式前向声明也可能是不安全的,例如参数类型和参数数量。
下面的示例说明了这一点,例如两个危险的数据以及一个函数的前向声明:
文件a.c:
#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

6
无论何时何地都应该这样做,有什么理由不这样做吗?
当然有:这会打破封装性,要求类或函数的使用者知道并复制实现细节。如果这些实现细节发生变化,前向声明的代码可能会出错,而依赖头文件的代码将继续工作。
前向声明函数:
- 需要知道它是作为函数实现的,而不是静态函数对象的实例或(天哪!)宏定义。 - 需要复制默认参数的默认值。 - 需要知道它的实际名称和命名空间,因为它可能只是一个 `using` 声明,将其拉入另一个命名空间,可能是别名。 - 可能会失去内联优化。
如果消费代码依赖于头文件,则函数提供者可以更改所有这些实现细节,而不会破坏您的代码。
前向声明类:
- 需要知道它是否是派生类以及它所继承的基类。 - 需要知道它是一个类,而不仅仅是 typedef 或类模板的特定实例(或者知道它是类模板并正确获取所有模板参数和默认值)。 - 需要知道类的真实名称和命名空间,因为它可能是一个 `using` 声明,将其拉入另一个命名空间,可能是别名。 - 需要知道正确的属性(也许它有特殊的对齐要求)。
同样,前向声明打破了这些实现细节的封装性,使您的代码更加脆弱。
如果需要削减头文件依赖以加快编译时间,则可以让类/函数/库的提供者提供一个特殊的前向声明头文件。标准库使用 `iosfwd` 就是这么做的。这种模式保留了实现细节的封装性,并使库维护者能够更改这些实现细节而不会破坏您的代码,同时减轻了编译器的负担。
另一种选择是使用 pimpl 模式,它可以更好地隐藏实现细节并加快编译速度,但代价是小的运行时开销。

最后您建议使用pimpl惯用语,但该惯用语的整个实用性基于前向声明。前向声明和pimpl惯用语几乎是相同的东西。 - user2445507
@user2445507:问题是“只要可能的话。”我的观点是通常情况下不应该这样做。正如我在倒数第二段中所说,对于接口所有者提供转发声明是可以的,因为他们可以保持前向声明与实际接口同步。使用pimpl惯用语法,同一程序员负责前向声明和impl对象实现,因此完全没有问题。 - Adrian McCarthy
你的原始陈述有点含糊不清。它可以被解释为pimpl不使用前向声明(即它们是另一个选项)。我之前遇到过这种误解,有人建议使用pimpl而不是前向声明,却不理解其中的矛盾之处。无论如何,感谢您的澄清。 - user2445507

2

在任何可能的情况下,为什么不这样做呢?

我所想到的唯一原因是为了节省一些打字时间。

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


1
@Luchian Grigore:对于一些简单的测试程序来说可能没问题。 - ks1322

-1
有没有任何理由不在可能的情况下这样做?
有 - 性能。类对象与它们的数据成员一起存储在内存中。当您使用指针时,指向的实际对象的内存存储在堆上的其他位置,通常很远。这意味着访问该对象将导致缓存未命中和重新加载。在对性能至关重要的情况下,这可能会产生很大的差异。
在我的电脑上,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

9
你回答的是一个不同的问题(“我应该使用指针吗?”),而不是被问到的问题(“当我仅使用指针时,是否有任何理由不使用前向声明?”)。 - nobody
1
@AndrewMedico 我认为这是一个很好的答案,指出了性能上的缺陷,这确实回答了问题。“前向声明 == 使用指针”。 - Eric

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