为什么对象引用另一个引用第一个对象的对象是不好的设计?
答案 0 :(得分:70)
类之间的循环依赖关系不一定有害。实际上,在某些情况下,它们是可取的。例如,如果您的应用程序处理宠物及其所有者,您可能希望Pet类具有获取宠物所有者的方法,并且Owner类具有返回宠物列表的方法。当然,这可能会使内存管理变得更加困难(使用非GC语言)。但如果循环是问题所固有的,那么试图摆脱它可能会导致更多的问题。
另一方面,模块之间的循环依赖关系是有害的。它通常表示思维模糊的结构,和/或未能坚持原始的模块化。通常,具有不受控制的交叉依赖性的代码库将比具有干净的分层模块结构的代码库更难理解并且难以维护。没有像样的模块,预测变化的影响可能要困难得多。这使得维护更加困难,并导致由于构思错误而导致的“代码衰减”。
(另外,像Maven这样的构建工具不会处理具有循环依赖关系的模块(artefacts)。)
答案 1 :(得分:58)
循环引用并不总是有害的 - 在某些用例中它们非常有用。我想到了双重链接列表,图形模型和计算机语言语法。但是,作为一般惯例,有几个原因可能导致您避免在对象之间进行循环引用。
数据和图形一致性。使用循环引用更新对象可能会产生挑战,确保在所有时间点对象之间的关系有效。这种类型的问题经常出现在对象关系建模实现中,其中在实体之间找到双向循环引用并不罕见。
确保原子操作。确保循环引用中两个对象的更改都是原子的可能会变得复杂 - 尤其是涉及多线程时。确保可从多个线程访问的对象图的一致性需要特殊的同步结构和锁定操作,以确保没有线程看到一组不完整的更改。
物理隔离挑战。如果两个不同的类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 ++库),循环引用可能导致内存泄漏。由于每个对象都保留在另一个对象上,因此它们的引用计数永远不会达到零,因此永远不会成为收集和清理的候选对象。
答案 2 :(得分:8)
因为现在他们真的是一个单一的对象。你不能孤立地测试任何一个。
如果你修改一个,那么你可能也会影响它的同伴。
答案 3 :(得分:7)
来自维基百科:
循环依赖可能导致很多 软件程序中的不良影响。 最软问题 设计观点是紧张的 相互依赖的耦合 减少或制造的模块 不可能单独重复使用一个 单个模块。
循环依赖可能会导致 当地的小多米诺骨牌效应 一个模块的变化传播到 其他模块,并有不必要的全局 效果(程序错误,编译 错误)。循环依赖可以 也导致无限递归或 其他意想不到的失败。
循环依赖也可能导致 通过阻止某些内存泄漏 非常原始的自动垃圾 收藏家(那些使用参考 从未释放未使用的 对象。
答案 4 :(得分:5)
这样的对象可能很难被创建和销毁,因为为了非原子地执行你必须违反引用完整性来首先创建/销毁一个,然后另一个(例如,你的SQL数据库可能会犹豫不决这个)。它可能会混淆你的垃圾收集器。 Perl 5,它使用简单的引用计数进行垃圾收集,不能(没有帮助)因此它的内存泄漏。如果这两个对象现在属于不同的类,则它们是紧密耦合的,不能分开。如果您有一个包管理器来安装这些类,则循环依赖关系会扩展到它。它必须知道在测试它们之前安装两个包,它们(作为构建系统的维护者说话)是PITA。
尽管如此,这些都可以克服,而且通常需要有循环数据。现实世界不是由整齐的有向图组成。许多图表,树木,地狱,双链表都是循环的。
答案 5 :(得分:2)
它会损害代码的可读性。从循环依赖到意大利面条代码只有一小步。
答案 6 :(得分:2)
以下是一些可能有助于说明循环依赖性为何不好的示例。
问题#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垃圾收集器声称能够处理这个,但我不确定我是否相信它,因为这是一个非常棘手的问题。另一方面,通过根本不允许循环引用来解决问题。十年前,我会更喜欢.NET方法的灵活性。这些天,我发现自己更喜欢简洁的Go方法。
答案 7 :(得分:1)
具有圆形参考的对象是完全正常的,例如在具有双向关联的域模型中。具有正确编写的数据访问组件的ORM可以处理该问题。
答案 8 :(得分:1)
参考Lakos的书,在C ++软件设计中,循环物理依赖是不可取的。有几个原因:
答案 9 :(得分:1)
循环引用似乎是一种合法的域建模方案。一个例子是Hibernate和许多其他ORM工具鼓励实体之间的这种交叉关联以启用双向导航。在线拍卖系统中的典型示例,卖方权利可以保持对他/她正在销售的实体列表的引用。每件商品都可以保留对相应卖家的参考。
答案 10 :(得分:-2)
.NET垃圾收集器可以处理循环引用,因此不必担心在.NET框架上运行的应用程序会发生内存泄漏。