0%

语言高阶-循环依赖

一、循环依赖

在 C 和 CPP 这种有 include 机制的语言中,当项目复杂时,很容易出现一种头文件相互包含的错误,如下所示:

A.h 内容如下:

#ifndef _A_H_
#define _A_H_

#include "B.h"

class A
{
    B* pb;
    void doSomethingA() {
        pb->method();
    }
};

#endif // _A_H_

B.h 内容如下:

#ifndef _B_H_
#define _B_H_

#include "A.h"

class B
{
    A* pa;
    void doSomethingB() {
        pa->method();
    }
};

#endif /* _B_H_ */

总结下来就是 A.hincludeB.h ,而 B.h 同样 includeA.h 。而由于条件编译的存在,在预处理后导致 AB 必然有一个在前,而一个在后,位置靠前的 class 就没有办法检测到后面的 class 这样会报一个 incomplete type 的编译错误。也就是说,在有了条件编译保护后,其实循环依赖不会导致与编译期崩溃,只会导致一些声明不会被看到。

上面提出的是一种简化版本(甚至是可能可以通过编译的),实际情况更为复杂,因为 include 具有传递性,所以最终可能虽然代码里看不出这种互相 include 的情况,但是实际上依然是循环 include 了。

二、本质分析

我觉得这个问题的产生有语义工程两个方面的原因。

在工程方面,导致这个问题的原因是头文件的 include 机制include 会无脑复制头文件内容到 include 的地方,那么复制就很容易导致循环依赖问题。

而在语义方面,问题变成了 A 需要 B 的声明,B 同样需要 A 的声明的问题。

三、解决办法

同样解决办法也对应分为两种:

3.1 工程方式

在工程方面,我们可以避免这种循环 include 的写法,这是一定可以做到的,但是这样语义要求的 AB 相互包含的问题并没有得到解决,但是我们可以进行如下操作解决它:

  • 去掉一个 include:本质是解决循环依赖
  • 在去掉 include 的文件中增加前向声明(forward declaration):让编译起可以正确处理包含关系,但是能力较弱
  • 将方法实现移动到对应的 cc 文件中,并重新 include :补偿较弱的能力

比如说我们要修改 B.h 文件,那么应当如下所示

A.h 内容如下:

#ifndef _A_H_
#define _A_H_

#include "B.h"

class A
{
    B* pb;
    void doSomethingA() {
        pb->method();
    }
};

#endif // _A_H_

B.h 内容如下:

#ifndef _B_H_
#define _B_H_

class A; // forward declaration
class B
{
    A* pa;
    void doSomethingB(); // just declaration, not definition
};

#endif /* _B_H_ */

B.c 中的内容:

#include "A.h"
#include "B.h"

void B::doSomethingB() {
    pa->method();
}

这种方法的本质是利用前向声明代替互相 include ,前向声明可以满足编译器的要求,使其不会报错(因为在 B 中能找到 A 的符号),但是这种方法只是声明了一个 class AA 具体是什么样子是并不明晰的,所以在 B 中使用的时候,只能用 A*, A& 。不能包含一个具体的 A a; ,也不能调用方法 pa->method() ,因为前向声明的 A 的数据布局和方法都是不明的。

之所以只可以使用 A*, A& 是因为编译器在编译 B 的时候要确定 B 的内存布局,但是前向声明只能提供一个符号,如果 B 中使用了 A a; 这种写法,那么编译器因为没法确定 A 的内存布局,进而无法确定 B 的内存布局,而 A*, A& 都可以被理解成一个指针,指针的宽度是确定的(32 或者 64),所以并不存在问题。

为了弥补这种缺失,我们在 c 文件中 include "A.h" 并进行具体方法的实现。

根据这种方法,我们可以总结在写代码的时候的一个范式,就是尽量减少 h 文件中的 include ,而在 c 中 include 文件

3.2 语义方法

基本上使用工程方法就可以解决大部分的循环依赖问题。但是如果我们深刻去思考这个问题呢?也就是不只是仅仅解决它,而是去思考到底为什么?

当我们使用 include 的时候,在语义上我们其实在 include 一组责任 。不是说要实现这组责任,而是使用这组责任。一个 class 被 include 了,就是这个 class 对应的所有责任都可以被使用了。

当出现循环依赖时,就是说 A 可以使用 B所有责任B 同样可以使用 A所有责任。这是非常奇怪的,如果两者可以如此亲密无间而且必须如此亲密无间,那么就应该将两者写到同一个类中啊。这种莫名其妙的分割毫无意义。

当然更有可能的是,设计者在语义上犯了错误,真实的情况应该是 A 使用 B部分责任B 使用 A部分责任 。但是因为 include 很生硬,就导致了局部责任变成全部责任。那么应当如何解决呢?就是使用抽象类将部分责任分离出来,某个抽象类 AbstractB 有原来 B 的部分责任,是一个接口,A 只使用这个 AbstractB ,新的 B 实现这个 AbstractB 定义的虚方法即可。

基于语义的解决方案其实是在类撰写之初就避免循环引用的一种思路。而且并非有了工程方法就可以解决一切问题,语义方法其实可以避免前置声明的使用,前置声明是有优点的,最大的一个是避免不必要的编译,如下所示,

A.h 发生变动的时候,如果使用 include

#include "A.h"

class B
{
    A* pa;
};

会导致这个文件重新编译(因为 A.h 重新编译了)。而如果使用前置引用,则不会有这个问题

class A;

class B
{
    A* pa
};

这种写法就不会导致问题。

但是前置声明也有缺点,基本上也是基于声明只是 incomplete type, 所以并不支持别名,比如如下所示:

A.h 内容:

class A
{
    int a;
};

using X = A;

这里我们除了声明了一个类型 A 外,还给 A 取了个别名 X 。用 typedef 也是同样的。

然后我们在 B.h 中使用前置声明来使用 X

class X;
class B
{
    X * px;
};

这种就会编译错误,也就是说别名不能前置声明。

除此之外,在使用模板时因为无法使用 c 文件,所以之前前置声明的方法也不奏效,需要新的方法 。这里就不赘述了。