你真的理解“循环引用”了嘛?
这篇文章将带你解析循环引用是如何发生的,以及如何彻底解决循环引用问题。
初生牛犊
首先,来看以下如下的一个例子。我们有三个代码文件,定义了两个类,A和B,A类中有一个B类的成员,B类中也有一个A类的成员。或许你已经发现了,这个程序明显会发生循环引用问题啊。但我们还是尝试编译以下,看看究竟会发生什么错误。
// A.h
#pragma once
#include "B.h"
class A {
public:
A() : val_(1) {}
void doSomething() {
b_.doSomething();
}
private:
int val_;
B b_;
};
// B.h
#pragma once
#include "A.h"
class B {
public:
B() : val_(1) {}
void doSomething() {
a_.doSomething();
}
private:
int val_;
A a_;
};
// main.cc
#include "B.h"
int main() {
B b;
b.doSomething();
return 0;
}
使用如下命令编译上述代码:
g++ -c main.cc -o main.o # 编译
g++ main.o -o main # 链接
注:为了更好地理解错误发生在编译或者链接中的哪一个阶段,在本文中,我们总是将编译和链接分开进行。
编译阶段报错:
error: ‘B’ does not name a type
这个错误似乎在意料之中。但你想知道这个错是怎么发生的吗?
我们可以从编译器的角度来理解。c++的文件在正式编译前,会有一步预处理的操作,其中就包含了对头文件的处理。
使用如下命令查看预处理后的cpp代码文件:
g++ -E main.cc -o main_pre.cc
main_pre.cc
的内容如下所示:
// main_pre.cc
class A {
public:
A() : val_(1) {}
void doSomething() {
b_.doSomething();
}
private:
int val_;
B b_;
};
class B {
public:
B() : val_(1) {}
void doSomething() {
a_.doSomething();
}
private:
int val_;
A a_;
};
int main() {
B b;
b.doSomething();
return 0;
}
不难发现,在预处理main.cc
时,编译器先把#include "B.h"
替换为B.h
中文件的内容;B.h
也#include "A.h"
,所以继续把这一行替换为A.h
的内容;虽然A.h
中#include "B.h"
了,但因为现在的文件已经包含了B.h
的内容,所以不会继续引入了;预处理也到此结束了。
最终,我们发现,在定义class A
时,因为没有声明class B
,在定义A的成员b_
时,编译器也就无法识别出它的类型了。
使用前向声明
聪明的你,通过丰富的debug和搜索经验,很容易就找到了这个错误的应对执法——前向声明。
看起来,只需要在定义A前,前向声明以下B就行了。我们照这个思路改写程序如下:
// A.h
#pragma once
#include "B.h"
class B;
class A {
public:
A() : val_(1) {}
void doSomething() {
b_.doSomething();
}
private:
int val_;
B b_;
};
// B.h
#pragma once
#include "A.h"
class B {
public:
B() : val_(1) {}
void doSomething() {
a_.doSomething();
}
private:
int val_;
A a_;
};
// main.cc
#include "B.h"
int main() {
B b;
b.doSomething();
return 0;
}
按照之前的方式编译,很不幸,编译阶段报错:
field ‘b_’ has incomplete type
这是因为前向声明的对象是不完全类型。
不完全类型指声明但又没有定义的类型,它只能以指针/引用的形式定义。
为什么不完全类型会有这种要求呢?
从编译器的角度也不难理解。在定义A时,编译器必须知道A的空间分配。比如A的成员val_
是int
类型,编译器知道改给它分配4个字节的空间。但不完全类型只有声明,没有定义,编译器不知道应该给他分配多大的空间,这就让编译器为难了啊(这活我干不了),所以只能给你报错咯。
把成员对象换成指针
既然不完全类型不能这么定义,你不禁怀疑,这玩意到底有什么用呢?
虽然不能直接定义不完全类型的对象,但我们可以定义指向不完全类型的指针啊!这也算曲线救国了吧。
把成员对象换为指针,修改代码如下:
// A.h
#pragma once
#include "B.h"
class B;
class A {
public:
A() : val_(1) {}
void setB(B* b) { b_ = b;}
void doSomething() {
b_->doSomething();
}
private:
int val_;
B *b_;
};
// B.h
#pragma once
#include "A.h"
class B {
public:
B() : val_(1) { a_.setB(this); }
void doSomething() {
a_.doSomething();
}
private:
int val_;
A a_;
};
// main.cc
#include "B.h"
int main() {
B b;
b.doSomething();
return 0;
}
因为A的成员对象变成了指针,所以A增加了新成员函数
setB
来初始化指针。
B的初始化方法也做了相应调整
但是很不幸,编译器还是报错:
invalid use of incomplete type ‘class B’
这次你反应很快,一下就发现还是不完全类型的问题:不完全类型不能定义该类型的对象,也不能访问它的成员和方法。道理都是相通的嘛,因为只有声明没有定义,编译器不知道它有哪些成员和方法。
将成员函数的定义和声明分开
这个问题也不难解决,将类的声明和定义分开就行了,这样只在成员函数的定义(另一个cpp文件)中包含B.h
,不就避免了不完全类型的问题嘛。
于是将原本A.h
拆成了两个文件:
// A.h
#pragma once
#include "B.h"
class B;
class A {
public:
A() : val_(1), b_(0x0) {}
void setB(B* b);
void doSomething();
private:
int val_;
B *b_;
};
// A.cc
#include "A.h"
void A::doSomething() {
b_->doSomething();
}
void A::setB(B* b) {
b_ = b;
}
编译main.cc
:
gcc -c main.cc -o main.o
没有报错。
编译A.cc
:
gcc -c A.cc -o A.o
编译器报错:
error: ‘A’ does not name a type
这个错误有点熟悉,和刚开始的错误是一样的,也是循环引用问题,不过这次是发生在了A.cc
文件上,而且报错的类也从B变成了A。原来main.cc
的头文件先引入了B.h
,而A.cc
的头文件则是先引入A.h
,所以这两次错误正好让报错的类颠倒了。
最终版本
于是,按照改写类A的经验重写类B,最终的代码变成了这样:
// A.h
#pragma once
#include "B.h"
class B;
class A {
public:
A() : val_(1), b_(0x0) {}
void setB(B* b);
void doSomething();
private:
int val_;
B *b_;
};
// A.cc
#include "A.h"
void A::doSomething() {
b_->doSomething();
}
void A::setB(B* b) {
b_ = b;
}
// B.h
#pragma once
#include "A.h"
class A;
class B {
public:
B();
void doSomething();
void setA(A* a);
private:
int val_;
A *a_;
};
// B.cc
#include "B.h"
B::B() {
val_ = 2;
a_->setB(this);
}
void B::doSomething() {
a_->doSomething();
}
void B::setA(A* a) {
a_ = a;
}
// main.cc
#include "B.h"
int main() {
A *a = new A();
B b;
b.setA(a);
b.doSomething();
return 0;
}
编译链接:
g++ -c A.cc -o A.o
g++ -c B.cc -o B.o
g++ -c main.cc -o main.o
g++ main.o A.o B.o -o main
编译链接都没出错!最终,我们解决了循环引用问题。
注:这个程序仅作为示例使用,千万别尝试跑这个程序,存在逻辑BUG,会段错误。
总结
我们是如何解决循环引用问题的:
- 在头文件中前向声明;
- 替换不完全类型的成员对象为对应类型指针;
- 将类成员方法的声明和定义分开;
其实这些都不是解决循环引用的最好方法。
最好的解决方法就是杜绝循环引用问题!一个设计良好的代码模块应该避免循环引用,在写代码前就规划好类的依赖关系。当然这往往很困难。如果想在代码设计上更加精进,你可能需要学习一下设计模式。