为什么一个对象引用另一个引用第一个对象的对象设计不好?
Answers:
类之间的循环依赖关系不一定有害。实际上,在某些情况下它们是可取的。例如,如果您的应用程序处理了宠物及其所有者,则您希望Pet类具有获取宠物所有者的方法,而Owner类具有返回宠物列表的方法。当然,这会使内存管理更加困难(使用非GC语言)。但是,如果圆度是问题固有的,那么试图消除它可能会导致更多问题。
另一方面,模块之间的循环依赖关系是有害的。通常表明模块的结构考虑不周,和/或无法坚持最初的模块化。通常,与具有干净的分层模块结构的代码库相比,具有不受控制的交叉依赖性的代码库将更难以理解和维护。如果没有合适的模块,则很难预测更改的影响。这样会使维护工作变得更加困难,并导致因补丁设计不当而导致的“代码衰减”。
(此外,诸如Maven之类的构建工具也无法处理具有循环依赖性的模块(工件)。)
getOwner()
返回Object
而不是来实现Owner
。但是,与您消除的循环依赖关系相比,IMO这样的事情可能会导致更严重的问题。
循环引用并不总是有害的-在某些用例中,它们可能非常有用。双链表,图形模型和计算机语言语法浮现在脑海。但是,作为一般惯例,您可能有几个原因需要避免对象之间的循环引用。
数据和图形的一致性。使用循环引用更新对象可能会带来挑战,以确保在所有时间点对象之间的关系都是有效的。这种类型的问题通常出现在对象关系建模实现中,在这种情况下,在实体之间找到双向的循环引用并不罕见。
确保原子操作。确保对循环引用中的两个对象的更改都是原子操作可能会变得很复杂-尤其是在涉及多线程时。确保可从多个线程访问的对象图的一致性需要特殊的同步结构和锁定操作,以确保没有线程看到不完整的更改集。
身体上的分离挑战。如果两个不同的类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 ++库)中,循环引用有可能导致内存泄漏。由于每个对象相互依赖,因此它们的引用计数永远不会达到零,因此也永远不会成为收集和清理的候选对象。
从维基百科:
循环依赖性可能会在软件程序中引起许多不良影响。从软件设计的角度来看,最成问题的是相互依存的模块之间的紧密耦合,这减少了或不可能单独重用单个模块。
当一个模块中的一个小的局部更改扩展到其他模块中并且具有有害的全局影响(程序错误,编译错误)时,循环依赖项可能导致多米诺效应。循环依赖关系还可能导致无限递归或其他意外失败。
循环依赖性还可能通过阻止某些非常原始的自动垃圾收集器(使用引用计数的垃圾收集器)释放未使用的对象来导致内存泄漏。
这样的对象可能很难创建和销毁,因为要非原子地进行操作,必须先破坏引用完整性,才能先创建/销毁一个对象,然后再破坏(例如,您的SQL数据库可能对此不满意)。这可能会使您的垃圾收集器感到困惑。Perl 5使用简单的引用计数进行垃圾回收,但不能(没有帮助)因此导致内存泄漏。如果这两个对象属于不同的类别,则它们将紧密耦合且无法分开。如果您有程序包管理器来安装这些类,则循环依赖会扩展到它。它必须知道在测试这两个软件包之前先安装它们,这两个软件包(作为构建系统的维护者)就是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方法中的一个或另一个将在未初始化的对象上被调用,从而导致错误的行为并很可能引发异常。可以通过多种方法解决此问题,但是它们都很丑陋,并且都需要使用非构造函数初始化器。
问题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方法,因为它简单。