为什么循环引用被认为有害?

为什么一个对象引用另一个引用第一个对象的对象是一个糟糕的devise?

之间的循环依赖不一定是有害的。 事实上,在某些情况下,他们是可取的。 例如,如果您的应用程序处理了宠物及其所有者,您会期望Pet类拥有一个获取宠物所有者的方法,而Owner类具有一个返回宠物列表的方法。 当然,这会使内存pipe理变得更加困难(以非GC语言)。 但是,如果问题是固有的循环,那么试图摆脱它可能会导致更多的问题。

另一方面, 模块之间的循环依赖性是有害的。 这通常表示模块结构devise得不好,和/或不能坚持原来的模块化。 一般而言,具有不受控制的交叉依赖性的代码库比拥有干净,分层的模块结构的代码库更难理解和难以维护。 没有体面的模块,预测变化的影响可能会更困难。 这使得维护变得更加困难,并且导致了由于不适当的修补而导致的“代码衰减”。

(另外,像Maven这样的构build工具不能处理具有循环依赖的模块(artefact)。)

循环引用并不总是有害的 – 有些用例可能非常有用。 双链表,graphics模型和计算机语言语法浮现在脑海。 但是,作为一般惯例,您可能想要避免在对象之间进行循环引用有几个原因。

  1. 数据和graphics一致性。 使用循环引用更新对象可能会造成挑战,确保在所有时间点上对象之间的关系都是有效的。 这种types的问题经常出现在对象关系build模实现中,在实体间find双向的循环引用并不罕见。

  2. 确保primefaces操作。 确保在循环引用中对这两个对象的更改都是primefaces的可能会变得复杂 – 特别是涉及multithreading时。 确保可以从多个线程访问的对象图的一致性需要特殊的同步结构和locking操作,以确保没有线程看到一组不完整的变化。

  3. 物理分离的挑战。 如果两个不同的类A和B以循环方式相互引用,将这些类分离为独立程序集可能会变得具有挑战性。 用A和B实现的接口IA和IB创build第三个程序集当然是可能的; 允许每个通过这些接口引用另一个。 也可以使用弱types的引用(例如对象)作为打破循环依赖的一种方式,但是访问这样的对象的方法和属性不能被轻易访问 – 这可能会失去引用的目的。

  4. 执行不可变的循环引用。 像C#和VB这样的语言提供的关键字允许对象内的引用是不可变的(只读)。 不可变的引用允许程序确保引用在对象的生命周期中引用同一个对象。 不幸的是,使用编译器强制的不可变性机制来确保循环引用不能被改变是不容易的。 只有当一个对象实例化另一个对象时才能完成(参见下面的C#示例)。

     class A { private readonly B m_B; public A( B other ) { m_B = other; } } class B { private readonly A m_A; public A() { m_A = new A( this ); } } 
  5. 程序的可读性和可维护性。 循环参考本质上是脆弱的,易于打破。 这部分原因在于阅读和理解包含循环引用的代码比避免它们的代码更难。 确保您的代码易于理解和维护,有助于避免错误,并允许更轻松,安全地进行更改。 循环引用的对象更难以进行unit testing,因为它们不能彼此独立地进行testing。

  6. 对象生命周期pipe理 虽然.NET的垃圾收集器能够识别和处理循环引用(并正确处理这些对象),但并不是所有的语言/环境都可以。 在对垃圾收集scheme使用引用计数的环境中(例如VB6,Objective-C,某些C ++库),循环引用可能导致内存泄漏。 由于每个对象都保持在另一个对象上,因此它们的引用计数将永远不会达到零,因此永远不会成为收集和清理的候选对象。

因为现在他们真的是一个单一的对象。 你不能孤立地testing任何一个。

如果你修改一个,你很可能会影响到它的同伴。

维基百科:

循环依赖可能会在软件程序中造成许多不需要的影响。 从软件devise观点来看,最成问题的是相互依赖模块的紧密耦合,这减less或使单个模块的单独再使用成为不可能。

当一个模块中的一个小的局部变化扩展到其他模块并且具有不希望的全局效应(程序错误,编译错误)时,循环依赖可能会导致多米诺效应。 循环依赖也可能导致无限的recursion或其他意想不到的失败。

循环依赖也可以通过阻止某些非常原始的自动垃圾收集器(那些使用引用计数的)来释放未使用的对象,从而导致内存泄漏。

这样的对象可能很难被创build和销毁,因为为了非primefaces性地执行,你必须违反参照完整性来首先创build/销毁一个对象,然后另一个对象(例如,你的SQL数据库可能会不知所措)。 它可能会混淆你的垃圾收集器。 对垃圾回收使用简单的引用计数的Perl 5不能(没有帮助),所以它的内存泄漏。 如果两个对象现在是不同的类,它们是紧密耦合的,不能分开。 如果你有一个软件包pipe理器来安装这些类,循环依赖会扩展到它。 它必须知道testing之前安装这两个软件包,(作为构build系统的维护者)是PITA。

也就是说,这些都可以克服,通常需要有循环数据。 现实世界不是由纯粹的有向图组成的。 许多图表,树木,地狱,双链表是循环的。

它损害了代码的可读性。 从循环依赖到意大利面代码只需要一小步。

这里有几个例子可以帮助说明为什么循环依赖是不好的。

问题1:什么是初始化/构造?

考虑下面的例子:

 class A { public A() { myB.DoSomething(); } private B myB = new B(); } class B { public B() { myA.DoSomething(); } private A myA = new A(); } 

哪个构造函数被首先调用? 真的没有办法确定,因为它是完全不明确的。 DoSomething方法中的一个或另一个将被调用到未初始化的对象上,导致不正确的行为,并且很可能引发exception。 有这个问题的方法,但他们都丑,他们都需要非构造函数初始值设定项。

问题2:

在这种情况下,我已经更改为非托pipeC ++示例,因为按devise.NET的实现将问题隐藏起来。 但是,在下面的例子中,问题将变得非常清楚。 我很清楚,.NET并没有真正使用引用计数来进行内存pipe理。 我在这里仅仅用来说明核心问题。 还要注意,我已经在这里展示了一个可能的解决scheme来解决问题1。

 class B; class A { public: A() : Refs( 1 ) { myB = new B(this); }; ~A() { myB->Release(); } int AddRef() { return ++Refs; } int Release() { --Refs; if( Refs == 0 ) delete(this); return Refs; } B *myB; int Refs; }; class B { public: B( A *a ) : Refs( 1 ) { myA = a; a->AddRef(); } ~B() { myB->Release(); } int AddRef() { return ++Refs; } int Release() { --Refs; if( Refs == 0 ) delete(this); return Refs; } A *myA; int Refs; }; // Somewhere else in the code... ... A *localA = new A(); ... localA->Release(); // OK, we're done with it ... 

乍一看,有人可能会认为这个代码是正确的。 引用计数代码非常简单和直接。 但是,此代码导致内存泄漏。 当A被构build时,它最初具有“1”的引用计数。 但是,封装的myBvariables会增加引用计数,并将其计数为“2”。 当localA被释放时,计数递减,但只返回到“1”。 因此,该对象被挂起,从不删除。

正如我上面提到的,.NET并没有真正使用引用计数来进行垃圾回收。 但是它确实使用类似的方法来确定一个对象是否仍在使用,或者是否可以删除它,几乎所有这些方法都可能被循环引用所困惑。 .NET的垃圾收集器声称能够处理这个,但我不知道我相信它,因为这是一个非常棘手的问题。 另一方面,通过简单地不允许循环引用来解决问题。 十年前,我更喜欢.NET方法的灵活性。 这些天,我发现自己更喜欢Go的方法,因为它的简单性。

具有循环引用的对象例如在具有双向关联的域模型中是完全正常的。 具有正确写入的数据访问组件的ORM可以处理这个问题。

参考Lakos的书,在C ++软件devise中,循环物理依赖是不可取的。 有几个原因:

  • 这使得他们很难testing,也不可能独立重用。
  • 这使人们难以理解和维护。
  • 这会增加链接时间成本。

循环引用似乎是合法的领域build模scheme。 Hibernate和其他许多ORM工具都鼓励这种实体之间的交叉关联来启用双向导航。 在线拍卖系统中的典型例子是,卖方实体可以维持对他/她正在销售的实体列表的参考。 而且每件商品都可以保留对相应的卖家的引用。

.NET垃圾收集器可以处理循环引用,所以不用担心在.NET框架上工作的应用程序的内存泄漏。