解决由于类之间的循环依赖而导致的构建错误

Posted

技术标签:

【中文标题】解决由于类之间的循环依赖而导致的构建错误【英文标题】:Resolve build errors due to circular dependency amongst classes 【发布时间】:2017-07-10 13:46:55 【问题描述】:

我经常发现自己在一个 C++ 项目中面临多个编译/链接器错误,这是由于一些糟糕的设计决策(由其他人做出的 :))导致不同头文件中 C++ 类之间的循环依赖关系 (也可以发生在同一个文件中)。但幸运的是(?)这种情况并不经常发生,以至于我在下次再次发生时记住这个问题的解决方案。

因此,为了方便以后回忆,我将发布一个有代表性的问题和解决方案。当然欢迎更好的解决方案。


A.h

class B;
class A

    int _val;
    B *_b;
public:

    A(int val)
        :_val(val)
    
    

    void SetB(B *b)
    
        _b = b;
        _b->Print(); // COMPILER ERROR: C2027: use of undefined type 'B'
    

    void Print()
    
        cout<<"Type:A val="<<_val<<endl;
    
;

B.h

#include "A.h"
class B

    double _val;
    A* _a;
public:

    B(double val)
        :_val(val)
    
    

    void SetA(A *a)
    
        _a = a;
        _a->Print();
    

    void Print()
    
        cout<<"Type:B val="<<_val<<endl;
    
;

main.cpp

#include "B.h"
#include <iostream>

int main(int argc, char* argv[])

    A a(10);
    B b(3.14);
    a.Print();
    a.SetB(&b);
    b.Print();
    b.SetA(&a);
    return 0;

【问题讨论】:

使用 Visual Studio 时,/showIncludes 标志对调试此类问题有很大帮助。 Visual Studio 代码有类似的东西吗? 【参考方案1】:

如果从头文件中删除方法定义并让类仅包含方法声明和变量声明/定义,则可以避免编译错误。方法定义应该放在一个 .cpp 文件中(就像最佳实践指南所说的那样)。

以下解决方案的缺点是(假设您已将方法放在头文件中以内联它们)编译器不再内联方法并且尝试使用 inline 关键字会产生链接器错误。

//A.h
#ifndef A_H
#define A_H
class B;
class A

    int _val;
    B* _b;
public:

    A(int val);
    void SetB(B *b);
    void Print();
;
#endif

//B.h
#ifndef B_H
#define B_H
class A;
class B

    double _val;
    A* _a;
public:

    B(double val);
    void SetA(A *a);
    void Print();
;
#endif

//A.cpp
#include "A.h"
#include "B.h"

#include <iostream>

using namespace std;

A::A(int val)
:_val(val)



void A::SetB(B *b)

    _b = b;
    cout<<"Inside SetB()"<<endl;
    _b->Print();


void A::Print()

    cout<<"Type:A val="<<_val<<endl;


//B.cpp
#include "B.h"
#include "A.h"
#include <iostream>

using namespace std;

B::B(double val)
:_val(val)



void B::SetA(A *a)

    _a = a;
    cout<<"Inside SetA()"<<endl;
    _a->Print();


void B::Print()

    cout<<"Type:B val="<<_val<<endl;


//main.cpp
#include "A.h"
#include "B.h"

int main(int argc, char* argv[])

    A a(10);
    B b(3.14);
    a.Print();
    a.SetB(&b);
    b.Print();
    b.SetA(&a);
    return 0;

【讨论】:

谢谢。这很容易解决了这个问题。我只是将循环包含移动到 .cpp 文件中。 如果有模板方法怎么办?除非手动实例化模板,否则无法将其真正移动到 CPP 文件中。 您总是将“A.h”和“B.h”放在一起。为什么不在“B.h”中包含“A.h”,然后在“A.cpp”和“B.cpp”中只包含“B.h”? 谢谢,对于那些需要 2 个类之间的这种相互依赖并且不能以不同方式重构它的人来说,这是一个很好的答案【参考方案2】:

要记住的事情:

如果class Aclass B 的对象作为成员,这将不起作用,反之亦然。 前向声明是可行的。 声明的顺序很重要(这就是您要移出定义的原因)。 如果两个类都调用另一个类的函数,则必须将定义移出。

阅读常见问题解答:

How can I create two classes that both know about each other? What special considerations are needed when forward declarations are used with member objects? What special considerations are needed when forward declarations are used with inline functions?

【讨论】:

你提供的链接失效了,你知道新的可以参考吗?【参考方案3】:

思考这个问题的方法是“像编译器一样思考”。

假设您正在编写一个编译器。你会看到这样的代码。

// file: A.h
class A 
  B _b;
;

// file: B.h
class B 
  A _a;
;

// file main.cc
#include "A.h"
#include "B.h"
int main(...) 
  A a;

当您编译 .cc 文件时(记住 .cc 而不是 .h 是编译单元),您需要为对象A 分配空间。那么,那么,有多少空间呢?足够存储B!那么B 的大小是多少?足够存储A!哎呀。

显然是你必须打破的循环引用。

您可以通过允许编译器保留尽可能多的预先知道的空间来打破它 - 例如,指针和引用将始终是 32 位或 64 位(取决于体系结构),因此如果您替换(或者一)通过指针或引用,事情会很棒。假设我们替换为A:

// file: A.h
class A 
  // both these are fine, so are various const versions of the same.
  B& _b_ref;
  B* _b_ptr;
;

现在情况好多了。有些。 main() 仍然说:

// file: main.cc
#include "A.h"  // <-- Houston, we have a problem

#include,对于所有范围和目的(如果您取出预处理器)只需将文件复制到 .cc 中。真的,.cc 看起来像:

// file: partially_pre_processed_main.cc
class A 
  B& _b_ref;
  B* _b_ptr;
;
#include "B.h"
int main (...) 
  A a;

您可以看到为什么编译器无法处理这个问题 - 它不知道 B 是什么 - 它甚至从未见过这个符号。

让我们告诉编译器B。这称为forward declaration,并在this answer 中进一步讨论。

// main.cc
class B;
#include "A.h"
#include "B.h"
int main (...) 
  A a;

有效。这不是伟大。但是此时您应该了解循环引用问题以及我们为“修复”它所做的工作,尽管修复很糟糕。

这个修复不好的原因是因为#include "A.h" 的下一个人必须在使用它之前声明B,并且会得到一个可怕的#include 错误。因此,让我们将声明移入 A.h 本身。

// file: A.h
class B;
class A 
  B* _b; // or any of the other variants.
;

而在B.h中,此时你可以直接#include "A.h"

// file: B.h
#include "A.h"
class B 
  // note that this is cool because the compiler knows by this time
  // how much space A will need.
  A _a; 

HTH。

【讨论】:

“告诉编译器关于 B”被称为 B 的前向声明。 天哪!完全忽略了引用在占用空间方面已知的事实。终于,现在我可以正常设计了! 但是您仍然不能在 B 上使用任何功能(如问题 _b->Printt()) 这是我遇到的问题。如何在不完全重写头文件的情况下通过前向声明引入函数? @sydan:你不能。 Resolving circular dependencies requires out-of-class definitions.【参考方案4】:

我曾经通过将所有 inlines 移动到类定义之后并将其他类的 #include 放在头文件中的 inlines 之前来解决此类问题.这样可以确保在解析内联之前设置所有定义+内联。

这样做使得在两个(或多个)头文件中仍然有一堆内联成为可能。但必须有包括警卫

像这样

// File: A.h
#ifndef __A_H__
#define __A_H__
class B;
class A

    int _val;
    B *_b;
public:
    A(int val);
    void SetB(B *b);
    void Print();
;

// Including class B for inline usage here 
#include "B.h"

inline A::A(int val) : _val(val)



inline void A::SetB(B *b)

    _b = b;
    _b->Print();


inline void A::Print()

    cout<<"Type:A val="<<_val<<endl;


#endif /* __A_H__ */

...在B.h中做同样的事情

【讨论】:

为什么?我认为这是一个解决棘手问题的优雅解决方案......当一个人想要内联时。如果不想要内联,就不应该像从一开始就写的那样编写代码...... 如果用户首先包含B.h会怎样? 请注意,您的标头保护使用的是保留标识符,任何带有双相邻下划线的内容都是保留的。【参考方案5】:

我曾经写过一篇关于这个的帖子:Resolving circular dependencies in c++

基本技术是使用接口解耦类。所以在你的情况下:

//Printer.h
class Printer 
public:
    virtual Print() = 0;


//A.h
#include "Printer.h"
class A: public Printer

    int _val;
    Printer *_b;
public:

    A(int val)
        :_val(val)
    
    

    void SetB(Printer *b)
    
        _b = b;
        _b->Print();
    

    void Print()
    
        cout<<"Type:A val="<<_val<<endl;
    
;

//B.h
#include "Printer.h"
class B: public Printer

    double _val;
    Printer* _a;
public:

    B(double val)
        :_val(val)
    
    

    void SetA(Printer *a)
    
        _a = a;
        _a->Print();
    

    void Print()
    
        cout<<"Type:B val="<<_val<<endl;
    
;

//main.cpp
#include <iostream>
#include "A.h"
#include "B.h"

int main(int argc, char* argv[])

    A a(10);
    B b(3.14);
    a.Print();
    a.SetB(&b);
    b.Print();
    b.SetA(&a);
    return 0;

【讨论】:

请注意,使用接口和virtual 会影响运行时性能。【参考方案6】:

Wikipedia 上提供的简单示例对我有用。 (你可以在http://en.wikipedia.org/wiki/Circular_dependency#Example_of_circular_dependencies_in_C.2B.2B阅读完整的描述)

文件'''a.h''':

#ifndef A_H
#define A_H

class B;    //forward declaration

class A 
public:
    B* b;
;
#endif //A_H

文件'''b.h''':

#ifndef B_H
#define B_H

class A;    //forward declaration

class B 
public:
    A* a;
;
#endif //B_H

文件'''main.cpp''':

#include "a.h"
#include "b.h"

int main() 
    A a;
    B b;
    a.b = &b;
    b.a = &a;

【讨论】:

【参考方案7】:

我迟到了回答这个问题,但到目前为止还没有一个合理的答案,尽管它是一个受欢迎的问题,并且得到了高度评​​价......

最佳实践:前向声明标头

正如标准库的&lt;iosfwd&gt; 标头所示,为其他人提供前向声明的正确方法是使用前向声明标头。例如:

a.fwd.h:

#pragma once
class A;

啊哈:

#pragma once
#include "a.fwd.h"
#include "b.fwd.h"

class A

  public:
    void f(B*);
;

b.fwd.h:

#pragma once
class B;

b.h:

#pragma once
#include "b.fwd.h"
#include "a.fwd.h"

class B

  public:
    void f(A*);
;

AB 库的维护者应该各自负责保持其前向声明头与其头和实现文件同步,因此 - 例如 - 如果“B”的维护者出现并重写代码...

b.fwd.h:

template <typename T> class Basic_B;
typedef Basic_B<char> B;

b.h:

template <typename T>
class Basic_B

    ...class definition...
;
typedef Basic_B<char> B;

...然后“A”代码的重新编译将由对包含的b.fwd.h 的更改触发,并且应该干净地完成。


糟糕但常见的做法:前向声明其他库中的内容

说 - 而不是使用前面解释的前向声明头 - a.ha.cc 中的代码而不是前向声明 class B; 本身:

如果a.ha.cc 稍后确实包含b.h: A 的编译将在遇到 B 的声明/定义冲突时终止并出现错误(即上述对 B 的更改破坏了 A 和任何其他滥用前向声明的客户端,而不是透明地工作)。 否则(如果 A 最终没有包含 b.h - 如果 A 只是通过指针和/或引用存储/传递 Bs 则可能) 依赖#include 分析和更改文件时间戳的构建工具在更改为B 后不会重建A(及其进一步依赖的代码),从而导致链接时或运行时出错。如果 B 作为运行时加载的 DLL 分发,则“A”中的代码可能无法在运行时找到不同损坏的符号,这可能会或可能不会被处理得足够好以触发有序关闭或可接受地减少功能。

如果 A 的代码具有旧 B 的模板特化/“特征”,它们将不会生效。

【讨论】:

这是一种处理前向声明的非常干净的方法。唯一的“缺点” 将在额外文件中。我假设您总是在a.h 中包含a.fwd.h,以确保它们保持同步。在使用这些类的地方缺少示例代码。 a.hb.h 都需要包含在内,因为它们不会单独运行:``` //main.cpp #include "ah" #include "bh" int main() ... ` `` 或者其中一个需要完全包含在另一个中,就像在开始的问题中一样。其中b.h 包括a.hmain.cpp 包括b.h @Farway Right 在所有方面。我没有费心显示main.cpp,但很高兴你已经记录了它应该包含在你的评论中的内容。干杯 更好的答案之一,很好地详细解释了为什么由于利弊而做和不做... @RezaHajianpour:为所有需要前向声明的类(无论是否循环)都有一个前向声明头是有意义的。也就是说,您只会在以下情况下才需要它们:1) 包含实际声明是(或预计以后会变得)成本高昂(例如,它包含许多您的翻译单元可能不需要的标头),以及 2) 客户端代码是可能能够使用对象的指针或引用。 &lt;iosfwd&gt; 是一个经典的例子:可以有一些从很多地方引用的流对象,而 &lt;iostream&gt; 包含很多。 @RezaHajianpour:我认为您的想法是正确的,但是您的陈述存在术语问题:“我们只需要声明的类型”是对的。 declared 的类型表示已经看到前向声明;它在完整的定义被解析后定义(为此你可能需要更多#includes)。【参考方案8】:

这里是模板的解决方案:How to handle circular dependencies with templates

解决这个问题的线索是在提供定义(实现)之前声明这两个类。无法将声明和定义拆分为单独的文件,但您可以将它们构造为在单独的文件中。

【讨论】:

【参考方案9】:

不幸的是,之前的所有答案都缺少一些细节。正确的解决方案有点麻烦,但这是正确执行此操作的唯一方法。它可以轻松扩展,也可以处理更复杂的依赖关系。

您可以这样做,完全保留所有细节和可用性:

解决方案与最初的预期完全相同 内联函数仍然内联 AB 的用户可以按任意顺序包含 A.h 和 B.h

创建两个文件,A_def.h,B_def.h。这些将仅包含 AB 的定义:

// A_def.h
#ifndef A_DEF_H
#define A_DEF_H

class B;
class A

    int _val;
    B *_b;

public:
    A(int val);
    void SetB(B *b);
    void Print();
;
#endif

// B_def.h
#ifndef B_DEF_H
#define B_DEF_H

class A;
class B

    double _val;
    A* _a;

public:
    B(double val);
    void SetA(A *a);
    void Print();
;
#endif

然后,A.h 和 B.h 将包含以下内容:

// A.h
#ifndef A_H
#define A_H

#include "A_def.h"
#include "B_def.h"

inline A::A(int val) :_val(val)



inline void A::SetB(B *b)

    _b = b;
    _b->Print();


inline void A::Print()

    cout<<"Type:A val="<<_val<<endl;


#endif

// B.h
#ifndef B_H
#define B_H

#include "A_def.h"
#include "B_def.h"

inline B::B(double val) :_val(val)



inline void B::SetA(A *a)

    _a = a;
    _a->Print();


inline void B::Print()

    cout<<"Type:B val="<<_val<<endl;


#endif

请注意,A_def.h 和 B_def.h 是“私有”标头,AB 的用户不应使用它们。公共标头是 A.h 和 B.h。

【讨论】:

这比Tony Delroy's solution 有什么优势吗?两者都基于“帮助”标头,但托尼的更小(它们只包含前向声明)并且它们似乎以相同的方式工作(至少乍一看)。 那个答案并没有解决原来的问题。它只是说“将声明提交到单独的标题中”。没有关于解决循环依赖的问题(问题需要一个解决方案,其中AB 的定义可用,前向声明是不够的)。【参考方案10】:

在某些情况下,可以在类 A 的头文件中定义类 B 的方法或构造函数,以解决涉及定义的循环依赖关系。 通过这种方式,您可以避免将定义放在.cc 文件中,例如,如果您想实现仅标头库。

// file: a.h
#include "b.h"
struct A 
  A(const B& b) : _b(b)  
  B get()  return _b; 
  B _b;
;

// note that the get method of class B is defined in a.h
A B::get() 
  return A(*this);


// file: b.h
class A;
struct B 
  // here the get method is only declared
  A get();
;

// file: main.cc
#include "a.h"
int main(...) 
  B b;
  A a = b.get();


【讨论】:

【参考方案11】:

很遗憾,我无法评论 geza 的答案。

他不只是说“将声明放入单独的标题中”。他说你必须将类定义头和内联函数定义溢出到不同的头文件中,以允许“延迟依赖”。

但他的插图不是很好。因为两个类(A 和 B)只需要彼此不完整的类型(指针字段/参数)。

为了更好地理解它,假设类 A 有一个类型为 B 而不是 B* 的字段。另外 A 类和 B 类想要定义一个带有其他类型参数的内联函数:

这个简单的代码行不通:

// A.h
#pragme once
#include "B.h"

class A
  B b;
  inline void Do(B b);


inline void A::Do(B b)
  //do something with B


// B.h
#pragme once
class A;

class B
  A* b;
  inline void Do(A a);


#include "A.h"

inline void B::Do(A a)
  //do something with A


//main.cpp
#include "A.h"
#include "B.h"

这将导致以下代码:

//main.cpp
//#include "A.h"

class A;

class B
  A* b;
  inline void Do(A a);


inline void B::Do(A a)
  //do something with A


class A
  B b;
  inline void Do(B b);


inline void A::Do(B b)
  //do something with B

//#include "B.h"

此代码无法编译,因为 B::Do 需要稍后定义的完整类型 A。

为了确保它编译源代码应该是这样的:

//main.cpp
class A;

class B
  A* b;
  inline void Do(A a);


class A
  B b;
  inline void Do(B b);


inline void B::Do(A a)
  //do something with A


inline void A::Do(B b)
  //do something with B


对于需要定义内联函数的每个类的这两个头文件,这完全可以实现。 唯一的问题是循环类不能只包含“公共标头”。

为了解决这个问题,我想建议一个预处理器扩展:#pragma process_pending_includes

该指令应该推迟当前文件的处理并完成所有待处理的包含。

【讨论】:

以上是关于解决由于类之间的循环依赖而导致的构建错误的主要内容,如果未能解决你的问题,请参考以下文章

解决由于类之间的循环依赖而导致的构建错误

解决由于类之间的循环依赖而导致的构建错误

解决由于类之间的循环依赖而导致的构建错误

C++20 模块“循环依赖”

由于循环依赖错误,无法添加对项目的引用

Spring中-IOC-Bean的初始化-循环依赖的解决