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

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

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


  • < p > 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;
    }
    
276425 次浏览

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

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

//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;
}

需要记住的事情:

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

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

// 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。这被称为前置声明,并将在这个答案中进一步讨论。

// 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。

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

这样做可以使两个(或多个)头文件中仍然有大量内联。但是必须有包括警卫

像这样

// 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中执行相同的操作

我曾经写过一篇关于这个的文章:在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;
}
维基百科上的简单例子对我很有用。 (你可以在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;
}

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

最佳实践:向前声明标头

正如标准库的<iosfwd>标头所说明的那样,为其他人提供前向声明的正确方法是拥有前向声明头 .。例如:

a.fwd.h:

#pragma once
class A;

a.h:

#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的更改触发,并应该干净地完成。


糟糕但常见的做法:在其他lib中向前声明东西

例如-而不是像上面解释的那样使用前向声明头文件-在a.ha.cc中使用前向声明class B;本身:

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

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

下面是模板的解决方案:如何处理模板的循环依赖关系

解决这个问题的线索是在提供定义(实现)之前声明两个类。不能将声明和定义分离到单独的文件中,但是可以将它们作为单独的文件进行结构。

不幸的是,之前所有的答案都遗漏了一些细节。正确的解有点麻烦,但这是唯一正确的方法。而且它易于扩展,也能处理更复杂的依赖关系。

以下是如何做到这一点,准确地保留所有细节和可用性:

  • 解决方案与最初的计划完全相同
  • 内联函数仍然是内联的
  • 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。

在某些情况下,可以在类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();
}


不幸的是,我不能评论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

这个指令应该延迟当前文件的处理,并完成所有挂起的include。

首先,我们需要一些定义。

定义

<强>声明< / >强

extern int n;
int f();
template<typename T> int g(T);
struct A;
template<typename T> struct B;

<强> < /强>定义

int n;
int f() { return 42; }
template<typename T> int g(T) { return 42; }
struct A { int f(); };
template<typename T> struct B { int g(T*); };

区别在于重复定义会导致违反一个定义规则 (ODR)。编译器将给出一个“error: redefinition of '...'"”之类的错误。

请注意,“远期申报”;只是一个声明。声明可以重复,因为它们没有定义任何东西,因此不会导致ODR。

注意,默认参数只能给出一次,可能是在声明期间,但如果有多个声明,则只能给出其中一个。因此,有人可能会说这是一个定义,因为它可能不会被重复(在某种意义上它是:它定义了默认参数)。但是,由于它没有定义函数或模板,我们无论如何都将其称为声明。下面将忽略默认参数。

函数定义

(成员)函数定义生成代码。有多个这样的(在不同的翻译单元(TU)中,否则你会在编译时得到一个ODR冲突)通常会导致链接器错误;除非链接器解决了冲突,就像它处理内联函数和模板函数那样。两者都可以内联,也可以不内联;如果他们不是100%的时间内联,那么一个正常的函数(实例化)需要存在;这可能会导致我所说的碰撞。

非内联、非模板(成员)函数只需要存在于单个TU中,因此应该在单个.cpp中定义。

然而,内联和/或模板(成员)函数定义在头文件中,可能被多个TU包含,因此需要链接器进行特殊处理。然而,它们也被认为是生成代码的。

类定义

类定义可能生成代码,也可能不生成代码。如果有,那是针对链接器将解决冲突的函数。

当然,任何在类内部定义的成员函数都是按定义“内联”的。如果在类声明期间定义了这样一个函数,那么可以简单地将它移到类声明之外。

相反的,

struct A {
int f() const { return 42; }
};

struct A {
inline int f() const;
}; // struct declaration ends here.


int A::f() const { return 42; }

因此,我们最感兴趣的是代码生成(函数实例化),可以移动到类声明之外而且需要一些其他定义才能被实例化。

事实证明,这通常涉及智能指针和默认析构函数。假设struct B不能定义,只能声明,并且struct A看起来如下:

struct B;
struct A { std::unique_ptr<B> ptr; };

A的实例化而B的定义不可见(一些编译器可能不介意稍后在同一TU中定义B)将导致错误,因为A的默认构造函数和析构函数都会导致生成unique_ptr<B>的析构函数,这需要B的定义[例如error: invalid application of ‘sizeof’ to incomplete type ‘B’]。不过,还是有办法解决这个问题:不要使用生成的默认构造函数/析构函数。

例如,

struct B;
struct A {
A();
~A();
std::unique_ptr<B> ptr;
};

将编译并且只有A::A()A::~A()两个未定义的符号,你仍然可以像以前一样在A定义之外内联编译(前提是你在这样做之前定义了B)。

三个部分,三个文件?

因此,我们可以区分结构/类定义的三个部分,分别放在不同的文件中。

  1. (forward)声明:

    A.fwd.h

  2. 类定义:

    A.h

  3. 内联和模板成员函数定义:

    A.inl.h

当然,还有带有非内联和非模板成员函数定义的A.cpp;但这些与循环头依赖关系无关。

忽略默认参数,声明将不需要任何其他声明或定义。

类定义可能需要声明某些其他类,也可能需要定义其他类。

内联/模板成员函数可能需要其他定义。

因此,我们可以创建以下示例来展示所有可能性:

struct C;
struct B
{
B();
~B();
std::unique_ptr<C> ptr;  // Need declaration of C.
};


struct A
{
B b;    // Needs definition of B.
C f();  // Needs declaration of C.
};


inline A g()  // Needs definition of A.
{
return {};
}


struct D
{
A a = g();  // Needs definition of A.
C c();      // Needs declaration of C.
};

其中B::B()B::~B()C A::f()C D::c()在一些.cpp中定义。

但是,我们把它们也内联起来;在这一点上,我们需要定义C,因为所有四个都需要它(B::BB::~B因为unique_ptr,见上文)。在这个TU中这样做,突然就没有必要把B::B()B::~B()放在B的定义之外(至少在我使用的编译器中是这样)。尽管如此,让我们保持B原样。

然后我们得到:

// C.fwd.h:
struct C;


// B.h:
struct B
{
inline B();
inline ~B();
std::unique_ptr<C> ptr;
};


// A.h:
struct A
{
B b;
inline C f();
};


// D.h:
inline A g()
{
return {};
}
struct D
{
A a = g();
inline C c();
};


// C.h:
struct C {};


// B.inl.h:
B::B() {}
B::~B() {}


// A.inl.h:
C A::f()
{
D d;
return d.c();
}


// D.inl.h:
C D::c()
{
return {};
}

换句话说,A的定义是这样的:

// A.fwd.h:
struct A;
// A.h:
#include "B.h"      // Already includes C.fwd.h, but well...
#include "C.fwd.h"  // We need C to be declared too.
struct A
{
B b;
inline C f();
};
// A.inl.h:
#include "A.h"
#include "C.h"
#include "D.inl.h"
C A::f()
{
D d;
return d.c();
}

请注意,理论上我们可以创建多个.inl.h头文件:每个函数一个头文件,如果不这样做,它会拖入超过所需的数量,从而导致问题。

禁止模式

注意,所有#include都位于所有文件的顶部。

(理论上).fwd.h头文件不包括其他头文件。因此,可以随意包含它们,而不会导致循环依赖。

.h定义头可能包含.inl.h头,但如果这导致循环头依赖,那么总是可以通过将使用内联函数的函数从.inl.h移动到当前类的.inl.h来避免;在智能指针的情况下,可能还需要将析构函数和/或构造函数移动到.inl.h

因此,唯一剩下的问题是循环包含.h定义头,即A.h包含B.h,而B.h包含A.h。在这种情况下,必须通过用指针替换类成员来解耦循环。

最后,不可能有纯.inl.h文件的循环。如果有必要,你可能应该把它们移动到一个文件中,在这种情况下,编译器可能无法解决问题;但显然,当它们相互使用时,你不能让所有函数都内联,所以你不妨手动决定哪些可以非内联。