你真的理解“循环引用”了嘛?

你真的理解“循环引用”了嘛?

这篇文章将带你解析循环引用是如何发生的,以及如何彻底解决循环引用问题。

初生牛犊

首先,来看以下如下的一个例子。我们有三个代码文件,定义了两个类,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,会段错误。

总结

我们是如何解决循环引用问题的:

  1. 在头文件中前向声明;
  2. 替换不完全类型的成员对象为对应类型指针;
  3. 将类成员方法的声明和定义分开;

其实这些都不是解决循环引用的最好方法。
最好的解决方法就是杜绝循环引用问题!一个设计良好的代码模块应该避免循环引用,在写代码前就规划好类的依赖关系。当然这往往很困难。如果想在代码设计上更加精进,你可能需要学习一下设计模式

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值