为啥循环引用被认为是有害的? [关闭]

Posted

技术标签:

【中文标题】为啥循环引用被认为是有害的? [关闭]【英文标题】:Why are circular references considered harmful? [closed]为什么循环引用被认为是有害的? [关闭] 【发布时间】:2010-12-26 05:37:27 【问题描述】:

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

【问题讨论】:

【参考方案1】:

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

另一方面,模块之间的循环依赖是有害的。这通常表明模块结构考虑不周,和/或未能坚持最初的模块化。一般来说,具有不受控制的交叉依赖关系的代码库比具有干净、分层的模块结构的代码库更难理解和维护。如果没有像样的模块,就很难预测变化的影响。这使得维护变得更加困难,并导致因错误修补而导致的“代码衰减”。

(此外,像 Maven 这样的构建工具不会处理具有循环依赖关系的模块(人工制品)。)

【讨论】:

或许可以用经典的SQL关系表来消除宠物和主人之间的依赖循环?一个对象持有对所有者/宠物关系的引用,并支持 set 方法以在任一方向返回子集元组(以及添加和删除此类关系的方法)。在该方案中,宠物和所有者类每个都依赖于这个关系类。但是,它不必依赖于使用它的任何一个类。 @Jim - 我并不是说你不能删除类之间的循环依赖。例如,您可以通过声明(比如)getOwner() 来返回 Object 而不是 Owner。但是,IMO 这种事情可能会导致比您消除的循环依赖更严重的问题。【参考方案2】:

循环引用似乎是一种合法的域建模方案。一个例子是 Hibernate 和许多其他 ORM 工具鼓励实体之间的这种交叉关联以启用双向导航。在线拍卖系统中的典型示例,卖方实体可能会维护对他/她正在销售的实体列表的引用。并且每个 Item 都可以维护对其对应卖家的引用。

【讨论】:

【参考方案3】:

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

这使得它们难以测试并且无法独立重用。 这使人们难以理解和维护它们。 这会增加链接时间成本。

【讨论】:

【参考方案4】:

循环引用并不总是有害的 - 在某些用例中它们可能非常有用。我想到了双向链表、图模型和计算机语言语法。但是,作为一般做法,您可能希望避免对象之间的循环引用有几个原因。

    数据和图形的一致性。 使用循环引用更新对象可能会在确保对象之间的关系在所有时间点都有效方面带来挑战。这种类型的问题经常出现在对象关系建模实现中,在实体之间找到双向循环引用的情况并不少见。

    确保原子操作。 确保对循环引用中的两个对象的更改都是原子的可能会变得复杂 - 特别是在涉及多线程时。确保可从多个线程访问的对象图的一致性需要特殊的同步结构和锁定操作,以确保没有线程看到不完整的更改集。

    物理分离挑战。如果两个不同的类 A 和 B 以循环方式相互引用,则将这些类分成独立的程序集可能会变得很有挑战性。当然可以使用 A 和 B 实现的接口 IA 和 IB 创建第三个程序集;允许每个人通过这些接口引用另一个人。也可以使用弱类型引用(例如对象)作为打破循环依赖的一种方式,但随后无法轻松访问此类对象的方法和属性——这可能会破坏拥有引用的目的。

    强制执行不可变循环引用。 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 ); 
    
    

    程序的可读性和可维护性。 循环引用本质上是脆弱的,很容易被破坏。这部分源于这样一个事实,即阅读和理解包含循环引用的代码比避免它们的代码更难。确保您的代码易于理解和维护有助于避免错误并允许更轻松、更安全地进行更改。具有循环引用的对象更难进行单元测试,因为它们不能相互隔离。

    对象生命周期管理。 虽然 .NET 的垃圾收集器能够识别和处理循环引用(并正确处理此类对象),但并非所有语言/环境都可以。在将引用计数用于垃圾回收方案的环境中(例如 VB6、Objective-C、一些 C++ 库),循环引用可能会导致内存泄漏。由于每个对象都相互依赖,因此它们的引用计数永远不会达到零,因此永远不会成为收集和清理的候选对象。

【讨论】:

【参考方案5】:

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

【讨论】:

【参考方案6】:

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

问题 #1:首先初始化/构造什么?

考虑以下示例:

class A

  public A()
  
    myB.DoSomething();
  

  private B myB = new B();


class B

  public B()
  
    myA.DoSomething();
  

  private A myA = new A();

首先调用哪个构造函数?真的没有办法确定,因为它完全模棱两可。将在未初始化的对象上调用 DoSomething 方法中的一个或另一个,从而导致不正确的行为并且很可能引发异常。有一些方法可以解决这个问题,但它们都很丑陋,而且都需要非构造函数初始化器。

问题 #2:

在这种情况下,我已更改为非托管 C++ 示例,因为 .NET 的实现在设计上隐藏了您的问题。但是,在下面的示例中,问题将变得非常清楚。我很清楚 .NET 并没有真正使用引擎盖下的引用计数来进行内存管理。我在这里仅使用它来说明核心问题。另请注意,我在这里展示了问题 #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 时,它最初的引用计数为“1”。但是,封装的 myB 变量会增加引用计数,使其计数为“2”。当 localA 被释放时,计数会减少,但只会回到“1”。因此,该对象被挂起并且永远不会被删除。

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

【讨论】:

.NET 的垃圾收集使用标记和清除算法,该算法从所有已知的根(静态成员等)开始,并标记从那里可访问的每个引用。任何无法访问的引用(无论它们是否相互引用)都不会被标记,因此有资格被收集。为什么你觉得这对循环引用有问题? 当您不再需要对象引用时,您不能将它分配给 null 吗?【参考方案7】:

.NET 垃圾收集器可以处理循环引用,因此在 .NET 框架上运行的应用程序无需担心内存泄漏。

【讨论】:

“没有应用程序内存泄漏的恐惧”我不确定这句话是否正确。 我同意strager;尤其是在忘记分离事件处理程序时 .NET 中可能存在应用程序范围内存泄漏;然而,这里描述的类型应该由 GC 处理,如上所述,事件处理程序是另一回事。【参考方案8】:

这样的对象可能很难被创建和销毁,因为要以非原子方式执行任一操作,您必须违反参照完整性首先创建/销毁一个,然后再创建/销毁另一个(例如,您的 SQL 数据库可能会阻止这)。它可能会使您的垃圾收集器感到困惑。 Perl 5 使用简单的引用计数进行垃圾收集,不能(没有帮助)所以它是内存泄漏。如果这两个对象现在属于不同的类,它们是紧密耦合的,不能分开。如果你有一个包管理器来安装这些类,循环依赖就会传播到它。它必须知道安装两个在测试它们之前,它(作为构建系统的维护者)是一个PITA。

也就是说,这些都是可以克服的,而且通常需要循环数据。现实世界不是由整齐的有向图组成的。很多图,树,地狱,一个双链表是循环的。

【讨论】:

【参考方案9】:

这会损害代码的可读性。而从循环依赖到意大利面条式代码,只需一小步。

【讨论】:

【参考方案10】:

来自***:

循环依赖会导致很多 软件程序中的不良影响。 来自软件的最大问题 设计角度是紧 相互依赖的耦合 减少或制造的模块 不可能单独重复使用 单个模块。

循环依赖会导致 小地方时的多米诺骨牌效应 一个模块的变化蔓延到 其他模块并且有不需要的全局 效果(程序错误,编译 错误)。循环依赖可以 也会导致无限递归或 其他意外失败。

循环依赖也可能导致 通过防止某些内存泄漏 非常原始的自动垃圾 收藏家(那些使用参考 计数)从释放未使用的 对象。

【讨论】:

这更多的是关于程序集引用而不是对象引用。 多么美妙的“元”。此 SO 页面与 Wikipedia 有循环引用。这个答案引用了***,***页面链接回到这里:en.wikipedia.org/wiki/Circular_dependency(请参阅该维基页面上的“外部链接”)【参考方案11】:

因为现在它们实际上是一个单一的对象。你不能孤立地测试任何一个。

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

【讨论】:

如果他们实现相同的接口呢? @Pierreten:即便如此,出于同样的原因,这也是个坏主意。 @RCIX - 小心。您刚刚排除了图表、一些树和双向链表。

以上是关于为啥循环引用被认为是有害的? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章

Rspec/Capybara 正在加载,循环要求被认为是有害的

PHP忽略从被认为有害的函数返回的引用?

Dagger 2 - 为啥我收到循环引用错误?

循环引用,看我就对了

为啥弱/强之舞解决了这个强参考循环?我不明白

FastJson 在不关闭循环引用检测的情况下,替换或者消除$ref