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


78

每当一个类声明仅将另一个类用作指针时,使用一个类正向声明而不是包括头文件是否有意义,以抢先避免循环依赖问题?因此,与其具有:

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

有什么理由不这样做吗?


5
嗯-这个问题的答案在顶部还是底部?
Mat

1
您真正的问题(在底部)-AFAIK在这种情况下没有理由不使用前向声明……
Nim 2012年

13
它在某种程度上取决于您“仅将另一个类用作指针”的含义。在一个讨厌的情况下,您可以delete仅使用前向声明来使用指针,但是如果该类实际上具有一个非平凡的析构函数,则您将得到UB。因此,如果delete“仅使用指针”,那么是的,这是有原因的。如果不算数,那就不算什么了。
史蒂夫·杰索普

循环依存性还不存在并且只是对编译器隐藏了吗?如果是的话,这两种策略总是包含并且总是进行前向声明,这两种方法都不会教您如何避免循环依赖。但是,我必须承认,使用前向声明可能更容易找到它们。
grenix

1
如果将class实际上定义为A结构,则某些编译器可能会抱怨。如果A类是派生类,那么您会遇到问题。如果在另一个名称空间中定义了类A,并且标头只是使用using声明将其拉入该名称空间,则可能会遇到问题。如果A类实际上是别名(或宏!),那么您会遇到问题。如果类A实际上是一个typedef,那么您就会遇到问题。如果类A实际上是具有默认模板参数的类模板,则您会遇到问题。是的,有理由不转发声明:它破坏了实现细节的封装。
阿德里安·麦卡锡

Answers:


60

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

前向声明类没有不利之处,但是我可以想到不必要地包含标头的不利之处:

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

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

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

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


11
另外,重新编译的机会也增加了。
彼得·伍德

9
“我不认为包含可以使用前向声明的文件更好的情况”-当前向声明产生UB时,请参阅我对主要问题的评论。我认为您应该保持谨慎:-)
史蒂夫·杰索普

1
@Luchian:因为是否回答取决于提问者最初的含义,所以我不想发布构成疑问的“答案”。也许发问者永远不会梦想用英文写delete陈述C.h
史蒂夫·杰索普

2
缺点是更多的工作和更多的代码!而且更加脆弱。您不可能说没有缺点。
usr

2
如果有5个班级怎么办?如果以后需要添加一些怎么办?您只是针对您的观点着力于最好的情况。
usr

37

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

他们提供的一些优势是:

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

但是,向前声明一个类会使该特定类成为不完整类型,并且严格地限制了您可以对不完整类型执行的操作。
您无法执行需要编译器知道类布局的任何操作。

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

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

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

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

1
“但是,向前声明一个类会使该特定类成为不完整类型,并且严格地限制了您可以对不完整类型执行的操作。” 是的,但是,如果您可以转发声明它,则意味着您不需要在标头中使用完整的类型。而且,如果确实需要包含该标头的文件中的完整类型,则只需包含所需类型的标头即可。IMO,这是一个优势-它迫使您在实现文件中包括所需的内容,而不是依赖于将其包含在其他位置。
Luchian Grigore 2012年

假设有人更改了该标头,并用转发声明替换了include。然后,您必须去更改所有包含该标头的文件,使用缺少的类型,但不要自己包含丢失的类型的标头(尽管它们应该)。
Luchian Grigore 2012年

1
@LuchianGrigore:..但是如果您可以向前声明它...,则必须尝试将其签出。因此,没有固定的规则只需要进行Forward声明且不包含标头,因为知道规则有助于组织实现.Forward声明的最常用用法是打破循环依赖关系,这就是不完整类型通常无法解决的地方。每个源文件和头文件都应包含编译所需的所有头文件,因此第二个参数不应用,一开始只是一个组织不好的代码。
Alok保存

1
对于PIMPL,仅在您班级的私有部分中使用前向声明也可能很有意义
grenix 2016年

21

有什么理由不这样做吗?

方便。

如果您提前知道该头文件的任何用户一定也需要包括A做任何事情的定义(或者可能是大多数时候)。然后,一劳永逸地将其包括在内很方便。

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


5
这是指出这样做的唯一成本答案。+1
usr

从用户的角度来看。如果您转发声明所有内容,则意味着用户不能只包含该文件并立即开始工作。他们必须弄清楚什么是依赖项(可能是由于编译器抱怨类型不完整),并且在开始使用您的类之前还必须包括这些文件。一种替代方法是为您的库创建一个“ shared.hpp”文件,或将所有标头都放在该文件中的文件(例如上述的boost)。他们可以轻松地包含它,而无需弄清楚为什么他们不能仅仅“包含并运行”。
Todd

11

您不想使用前向声明的一种情况是它们本身很棘手。如果您的某些类是模板化的,则可能发生这种情况,如以下示例所示:

// 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破坏了前向声明严格总是更好的共识:-) IIRC通过typedefs对“秘密”模板实例化的类型发生相同的问题。namespace std { class string; }即使允许将类声明放在命名空间std中,也是错误的,因为(我认为)您不能合法地将typedef向前声明为类。
史蒂夫·杰索普


8

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

不,明确的前向声明不应视为一般准则。前向声明本质上是复制和粘贴或拼写错误的代码,如果发现其中的错误,则需要在使用前向声明的所有位置进行修复。这可能容易出错。

为了避免“转发”声明与其定义之间的不匹配,请将声明放在头文件中,并将该头文件包括在定义文件和使用声明的源文件中。

但是,在这种特殊情况下,仅前向声明一个不透明的类,可以使用此前向声明,但是一般来说,“尽可能使用前向声明而不是包含”,例如该线程的标题可以冒险。

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

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

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

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

文件ac:

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

文件密送:

#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 ++不会提供编译器或链接器错误/警告。
注意:由于c ++名称修改,省略extern "C"会导致链接错误function()

运行程序:

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

5

有什么理由不这样做吗?

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

转发声明函数:

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

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

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

  • 可能会失去在线优化功能。

如果使用方代码依赖于标头,则功能提供者可以更改所有这些实现细节,而不会破坏您的代码。

转发声明一个类:

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

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

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

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

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

如果需要减少头文件的依赖关系以加快编译时间,请获取类/函数/库的提供者以提供特殊的前向声明头文件。标准库使用<iosfwd>。该模型保留了实现细节的封装,并使库维护者可以在不破坏代码的情况下更改这些实现细节,同时减少编译器的负担。

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


最后,建议您使用pimpl惯用语,但是该惯用语的全部用途是基于前向声明。前向声明和pimpl习惯用法几乎是同一回事。
user2445507 '19

@ user2445507:问题是“尽可能”。我的观点是,“尽可能”通常不是一个好主意。正如我在倒数第二段所述,接口的所有者可以提供转发声明,因为他们可以使转发声明与实际接口保持同步。使用pimpl习惯用法,由同一位程序员负责正向声明和impl对象的实现,因此这很好。
阿德里安·麦卡锡

2

有什么理由不这样做吗?

我想到的唯一原因是保存一些键入内容。

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


1
@Luchian Grigore:也许可以使用一些简单的测试程序
ks1322 2012年

-1

有什么理由不这样做吗?

是的-性能。类对象及其数据成员一起存储在内存中。使用指针时,指向实际对象的内存存储在堆中的其他位置,通常很远。这意味着访问该对象将导致缓存未命中并重新加载。在性能至关重要的情况下,这可以带来很大的不同。

在我的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


9
您正在回答一个不同的问题(“我应该使用指针吗?”),而不是被问到的问题(“当我仅使用指针时,有什么理由不使用前向声明吗?”)。
没人

@AndrewMedico我认为这是一个很好的答案,指出了性能上的不足,确实回答了这个问题。“转发声明==使用指针”
埃里克
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.