互相引用的不可变对象?


77

今天,我试图将自己的头放在相互引用的不可变对象上。我得出的结论是,如果不使用惰性评估就无法做到这一点,但是在此过程中(我认为)我编写了此有趣的代码。

public class A
{
    public string Name { get; private set; }
    public B B { get; private set; }
    public A()
    {
        B = new B(this);
        Name = "test";
    }
}

public class B
{
    public A A { get; private set; }
    public B(A a)
    {
        //a.Name is null
        A = a;
    }
}

我发现有趣的是,我无法想到在尚未完全构造且包括线程的状态下观察类型A的对象的另一种方法。为什么这甚至有效?还有其他方法可以观察未完全构造的对象的状态吗?


17
您为什么认为它无效?
2011年

1
因为我的理解是,构造函数应该保证它包含的代码在外部代码可以观察对象状态之前执行。
史迪加2011年

1
该代码是有效的,但不是很可靠。Stilgar是正确的-A传递给B.ctor的类的实例未完全初始化。您必须B在完全实例化A之后创建in的新实例A-它应该是.ctor中的最后一行。
卡雷尔·弗莱塔克(KarelFrajták),2011年

Answers:


106

为什么这甚至有效?

您为什么认为它无效?

因为构造函数应该保证它包含的代码在外部代码可以观察对象状态之前执行。

正确。但是编译器不负责维护该不变性。你是。如果您编写的代码破坏了该不变式,并且在这样做时会造成伤害,那么请停止这样做

还有其他方法可以观察未完全构造的对象的状态吗?

当然。对于引用类型,所有这些都涉及以某种方式将“ this”传递到构造函数之外,因为唯一持有对存储的引用的用户代码就是构造函数。构造函数可以泄漏“ this”的一些方法是:

  • 将“ this”放在静态字段中,并从另一个线程引用它
  • 进行方法调用或构造函数调用,并将“ this”作为参数传递
  • 进行虚拟调用-如果虚拟方法被派生类覆盖,则特别麻烦,因为它随后在派生类ctor主体运行之前运行。

我说过,唯一拥有引用的用户代码是ctor,但是垃圾回收器当然也拥有引用。因此,可以观察到对象处于半构造状态的另一种有趣的方式是,如果对象具有析构函数,并且构造函数抛出异常(或获取异步异常,例如线程中止);稍后将对此进行更多介绍。 )在那种情况下,对象将要死掉,因此需要完成,但是终结器线程可以看到对象的半初始化状态。现在,我们回到了可以看到一半构造对象的用户代码中!

面对这种情况,析构函数必须具有鲁棒性。析构函数不得依赖于要维护的构造函数设置的对象的任何不变性,因为被破坏的对象可能永远不会被完全构造。

当然,如果析构函数在上面的场景中看到一半初始化的对象,然后将对该对象的引用复制到静态字段中,则外部代码可以观察到另一半疯狂对象的疯狂方式是构造的半成品最终被救出死亡。请不要这样做。就像我说的那样,如果很疼,那就不要做。

如果您使用的是值类型的构造函数,那么事情基本上是相同的,但是机制上有一些细微的差异。该语言要求对值类型的构造函数调用创建一个临时变量,只有ctor可以访问该临时变量,对该变量进行变异,然后将变异值的结构副本复制到实际存储中。这样可以确保如果构造函数抛出异常,则最终存储不会处于半突变状态。

应当指出,由于结构的副本都不能保证是原子,它可能的另一个线程看到半突变状态的存储; 在这种情况下,请正确使用锁。同样,异步异常(例如线程中止)有可能在结构副本中途抛出。无论副本是来自临时副本还是“常规”副本,都会出现这些非原子性问题。通常,如果存在异步异常,则很少维护不变式。

实际上,如果C#编译器可以确定没有办法解决这种情况,它将优化掉临时分配并进行复制。例如,如果新值正在初始化未被lambda关闭且不在迭代器块中的局部变量,则S s = new S(123);直接进行突变s即可。

有关值类型构造函数如何工作的更多信息,请参见:

揭穿关于价值类型的另一个神话

有关C#语言语义如何试图使您摆脱困境的更多信息,请参见:

为什么初始化程序作为构造函数以相反的顺序运行?第一部分

为什么初始化程序作为构造函数以相反的顺序运行?第二部分

我似乎偏离了当前的话题。当然,在结构中,您可以用相同的方式观察到对象是半结构化的-将半结构化的对象复制到静态字段中,以“ this”作为参数调用方法,依此类推。(显然,在更多派生类型上调用虚拟方法对结构没有问题。)而且,正如我所说,从临时存储区到最终存储区的复制不是原子的,因此另一个线程可以观察到半复制的结构。


现在,让我们考虑一下问题的根本原因:如何使互相引用的不可变对象?

通常,正如您所发现的,您不会。如果您有两个互相引用的不可变对象,那么从逻辑上讲,它们将形成有向循环图。您可能会考虑简单地构建一个不变的有向图!这样做很容易。一个不变的有向图包括:

  • 不可变节点的不可变列表,每个节点包含一个值。
  • 不可变节点对的不可变列表,每个节点对都有图边的起点和终点。

现在,使节点A和B相互“引用”的方式是:

A = new Node("A");
B = new Node("B");
G = Graph.Empty.AddNode(A).AddNode(B).AddEdge(A, B).AddEdge(B, A);

完成后,您得到了一个图,其中A和B相互“引用”。

当然,问题在于没有手握G便无法从A到达B。具有额外的间接级别可能是不可接受的。


非常感谢。我已经阅读了有关值类型的文章,这也是我认为该语言试图在观察到对象之前保证其完整结构的部分原因。毕竟这就是为什么要复制值的原因。
斯蒂尔加2011年

1
@Stilgar:我们试图成为一种“质量之门”的语言,在这里,您真的必须努力编写一个疯狂的程序。不幸的是,要设计一种有用的语言来保证在一个不一致的状态下永远不会观察到一个对象,这是非常困难的,因此我们不做任何保证。我们只是试图朝着这个方向大力推动您。(这基本上就是为什么非空引用类型不能在.NET中工作的原因;很难在类型系统中保证从未观察到非空引用类型的字段永远不会为空。)
Eric Lippert,

3
是的,看来您做得很好,以至于我期望您阻止我编写上面的代码。
斯蒂尔加2011年

@Stilgar:问题是,如果这样做,那么我们还会阻止您编写很多有用的代码。有时将“ this”传递给另一个类的方法或构造函数非常有用,尤其是在这类初始化方案中。我每天都这样编写代码:在编译器中,我们经常处于不可变的“代码分析器”构造不可变的“符号”的情况,并且它们必须能够相互引用。
埃里克·利珀特

@EricLippert:实际上很有用。在某些情况下,this从构造函数内部传递对其他对象的引用很有用。但是,为什么编译器不允许使用this字段初始值设定项中的关键字?两者都可以看到部分构造的对象。这个问题并没有真正为这个限制提供合理的理由。如果碰巧知道原因,请分享。
Allon Guralnek


22

“完全构建”是由您的代码定义的,而不是由语言定义的。

这是从构造函数调用虚拟方法的一种变体,
一般准则是:请勿这样做

要正确实现“完全构建”的概念,请不要this超出构造函数的范围。


8

实际上,this在构造函数期间泄漏引用将使您能够做到这一点;显然,如果对不完整的对象调用方法,可能会导致问题。至于“观察未完全构造的对象状态的其他方法”:

  • virtual在构造函数中调用方法;子类构造函数尚未被调用,因此override可以尝试访问不完整状态(在子类中声明或初始化的字段,等等)
  • 反射,也许使用FormatterServices.GetUninitializedObject(它创建一个对象而根本不调用构造函数)

1
@Stilgar,如果它允许我观察未完全构建的对象的状态,则.... meh
Marc Gravell

6

如果考虑初始化顺序

  • 派生静态字段
  • 派生静态构造函数
  • 派生实例字段
  • 基本静态字段
  • 基本静态构造函数
  • 基本实例字段
  • 基本实例构造函数
  • 派生实例构造函数

显然,通过向上转换,可以在调用派生实例构造函数之前访问该类(这是您不应该使用构造函数中的虚方法的原因。它们可以轻松访问未由构造函数/派生类中的构造函数初始化的派生字段不能使派生类处于“一致”状态)


4

您可以通过在构造函数中实例化B来避免该问题:

 public A() 
    { 
        Name = "test"; 
        B = new B(this); 
    } 

如果您的建议不可能实现,那么A将不会一成不变。

编辑:固定,感谢leppie。


您最后写了在构造函数中实例化B的实例,但是在示例中您首先将其初始化,就像OP中的代码一样。错别字?
Avada Kedavra

2
我认为OP知道这一点,并提出了一个更基本的问题。
汉克·霍尔特曼

@Nick:好了,直到您有3个不可变的类:)`public A(){Name =“ test”; B =新的B(this); C =新的C(this); }`
VMykyt 2011年

3

原理是不要让对象从构造函数主体中逸出。

观察此类问题的另一种方法是通过在构造函数内部调用虚拟方法。


1

如前所述,编译器无法知道对象在什么时候已经构造得足够有用。因此,它假定this从构造函数传递过来的程序员将知道对象的构造是否足以满足其需求。

但是,我要补充一点,对于旨在真正实现不可变的对象,必须避免传递this给将在为其分配最终值之前检查字段状态的任何代码。这意味着this不要将其传递给任意外部代码,但并不意味着将正在构造的对象本身传递给另一个对象以存储向后引用(直到第一个之后才实际使用)是有问题的。构造函数已完成

如果正在设计一种语言来促进不可变对象的构造和使用,则将方法声明为仅在构造期间,构造之后或两者中都可用的方法可能会有所帮助。可以在构造期间将字段声明为不可取消引用,之后再声明为只读;同样可以标记参数以指示该参数不可取消引用。在这样的系统下,编译器有可能允许构造相互引用的数据结构,但是在观察到该属性后,该属性就不会改变。至于这种静态检查的好处是否会超过成本,我不确定,但这可能很有趣。

顺便说一句,一个有用的相关功能是声明参数和函数返回为短暂的,可返回的或(默认)可持久的功能。如果参数或函数返回被声明为短暂的,则不能将其复制到任何字段,也不能作为可持久参数传递给任何方法。此外,将短暂或可返回值作为可返回参数传递给方法将导致函数的返回值继承该值的限制(如果函数具有两个可返回参数,则其返回值将从其返回值中继承更严格的约束参数)。Java和.net的主要缺点是所有对象引用都是混杂的。一旦外部代码付诸实践,就不会告诉谁最终会使用它。如果可以将参数声明为短暂的,拥有唯一引用的代码更有可能知道唯一引用,从而避免不必要的防御性复制操作。此外,如果编译器在返回后不知道对它们的引用,则可以将其关闭。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.