不可变对象相互引用?

Sti*_*gar 76 c# immutability

今天,我试图将我的头围绕在彼此引用的不可变对象上.我得出的结论是,如果不使用惰性评估,你不可能做到这一点,但在这个过程中我写了这个(在我看来)有趣的代码.

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;
    }
}
Run Code Online (Sandbox Code Playgroud)

我觉得有趣的是,我想不出另一种在尚未完全构造且包含线程的状态下观察A类对象的方法.为什么这甚至有效?有没有其他方法来观察未完全构造的物体的状态?

Eri*_*ert 105

为什么这甚至有效?

为什么你认为它无效?

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

正确.但编译器不负责维护该不变量.你是.如果你编写的代码打破了那个不变量,当你这样做时会很痛,那就停止这样做吧.

有没有其他方法来观察未完全构造的物体的状态?

当然.对于引用类型,所有这些都涉及以某种方式将"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);
Run Code Online (Sandbox Code Playgroud)

你已经完成了,你有一个图表,其中A和B相互"引用".

当然,问题在于,如果没有手中的G,你就无法从A到达B. 拥有额外的间接水平可能是不可接受的.

  • 是的,你似乎做得很好,我以为你会阻止我编写上面的代码. (3认同)

Jon*_*eet 47

是的,这是两个不可变对象相互引用的唯一方法 - 至少其中一个必须以未完全构造的方式看到另一个.

通常是一个坏主意,让this您的构造函数中逃脱,但在你自信的东西两个构造做的情况下,这是对可变性的唯一选择,我不认为这是糟糕.

  • 我在[这个答案](http://stackoverflow.com/questions/4556393/how-can-i-instantiate-immutable-mutually-recursive-objects/4556681#4556681)中使用`this`提供了一个相互引用的例子来另一个问题. (2认同)

Hen*_*man 22

"完全构造"由您的代码定义,而不是由语言定义.

这是从构造函数调用虚方法的变体,
一般准则是:不要这样做.

要正确实现"完全构造"的概念,请不要传递this出构造函数.


Mar*_*ell 8

实际上,this在构造函数中泄露引用将允许您执行此操作; 如果在不完整的对象上调用方法,它可能会导致问题.至于"观察未完全构造的物体状态的其他方法":

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


xan*_*tos 6

如果考虑初始化顺序

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

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