【问题标题】:Immutable objects that reference each other?相互引用的不可变对象?
【发布时间】:2011-10-05 12:49:36
【问题描述】:

今天,我试图围绕相互引用的不可变对象展开思考。我得出的结论是,如果不使用惰性求值,你不可能做到这一点,但在这个过程中,我编写了这个(在我看来)有趣的代码。

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 类型对象。为什么这甚至是有效的?是否有其他方法可以观察未完全构造的对象的状态?

【问题讨论】:

  • 为什么你认为它是无效的?
  • 因为我的理解是构造函数应该保证它包含的代码在外部代码可以观察到对象的状态之前执行。
  • 代码有效,但不是很可靠。 Stilgar 是对的 - 传递给 B.ctor 的类 A 的实例没有完全初始化。在 A 的实例完全初始化后,您必须在 A 中创建 B 的新实例 - 它应该是 .ctor 中的最后一行。

标签: c# immutability


【解决方案1】:

为什么这甚至是有效的?

为什么你认为它是无效的?

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

正确。但编译器不负责维护该不变量。 你是。如果您编写的代码破坏了该不变量,并且这样做会很痛苦,那么停止这样做

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

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

  • 将“this”放入静态字段并从另一个线程引用它
  • 进行方法调用或构造函数调用并将“this”作为参数传递
  • 进行虚拟调用 - 如果虚拟方法被派生类覆盖,则尤其令人讨厌,因为它会在派生类 ctor 主体运行之前运行。

我说过唯一持有引用的用户代码是ctor,但当然垃圾收集器也持有引用。因此,可以观察到对象处于半构造状态的另一种有趣方式是,如果对象具有析构函数,并且构造函数抛出异常(或像线程中止一样获得异步异常;稍后会详细介绍。 ) 在那种情况下,对象即将死亡,因此需要被终结,但终结器线程可以看到对象的半初始化状态。现在我们又回到了可以看到半构建对象的用户代码中!

面对这种情况,析构函数必须是健壮的。析构函数不能依赖于被维护的构造函数设置的对象的任何不变量,因为被销毁的对象可能永远不会有已完全建成。

外部代码可以观察到半构造对象的另一种疯狂方式当然是,如果析构函数在上面的场景中看到半初始化的对象,然后复制一个引用到那个对象到一个静态场,从而确保半构建,半完成的对象从死亡中解救出来。 请不要那样做。就像我说的,如果疼,就不要那样做。

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

请注意,由于不保证结构副本是原子的,因此 另一个线程可能会看到处于半变异状态的存储;如果您处于这种情况,请正确使用锁。此外,像线程中止这样的异步异常可能会在结构副本的中途被抛出。无论副本是来自 ctor 临时副本还是“常规”副本,都会出现这些非原子性问题。通常,如果存在异步异常,则很少维护不变量。

在实践中,C# 编译器会优化掉临时分配和复制,如果它可以确定没有办法出现这种情况。例如,如果新值正在初始化一个未被 lambda 封闭且不在迭代器块中的局部变量,则 S s = new S(123); 只会直接改变 s

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

Debunking another myth about value types

有关 C# 语言语义如何尝试将您从自己手中拯救出来的更多信息,请参阅:

Why Do Initializers Run In The Opposite Order As Constructors? Part One

Why Do Initializers Run In The Opposite Order As Constructors? Part Two

我似乎偏离了手头的话题。在结构中,您当然可以以相同的方式观察对象的半构造——将半构造的对象复制到静态字段,以“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。拥有这种额外的间接级别可能是不可接受的。

【讨论】:

  • 非常感谢。我已经阅读了有关值类型的文章,它们是我认为该语言试图保证对象在被观察之前的完整构造的部分原因。毕竟这就是值被复制的原因。
  • @Stilgar:我们试图成为一种“质量坑”的语言,你真的必须努力工作来编写一个疯狂的程序。不幸的是,很难设计一种有用的语言来保证一个对象将永远在不一致的状态下被观察到,所以我们不尝试保证。我们只是试图在这个方向上大力推动你。 (这基本上就是为什么不可空引用类型在 .NET 中不起作用的原因;很难保证 在类型系统中 不可空引用类型的字段是 never 观察到为空。)
  • 是的,看来你做得很好,我以为你会阻止我编写上面的代码。
  • @Stilgar:问题是如果我们这样做了,那么我们也会阻止你编写很多有用的代码。有时能够将“this”传递给另一个类的方法或构造函数非常有用,尤其是在这类初始化场景中。我每天都写这样的代码:在编译器中,我们经常遇到不可变的“代码分析器”正在构造不可变的“符号”的情况,它们必须能够相互引用。
  • @EricLippert:它实际上很有用。在某些情况下,将 this 引用从构造函数内部传递给其他对象很有用。但是为什么编译器不允许在字段初始化器中使用 this 关键字呢?两者都可以看到部分构造的对象。 This question 并没有真正为这种限制提供理由。如果你碰巧知道原因,请分享。
【解决方案2】:

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

它是generally a bad idea to let this escape from your constructor,但如果您对两个构造函数的工作充满信心,并且它是可变性的唯一替代方案,我认为它不好。

【讨论】:

  • 我提供了一个在 this answer 中使用 this 对另一个问题的相互引用的示例。
【解决方案3】:

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

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

要正确实现“完全构造”的概念,请不要将this 从构造函数中传递出去。

【讨论】:

    【解决方案4】:

    确实,在构造函数期间泄漏this 引用将允许您执行此操作;如果在不完整的对象上调用方法,显然可能会导致问题。至于“观察未完全构造的对象状态的其他方法”:

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

    【讨论】:

    • @Stilgar 如果它能让我观察到一个未完全构造的对象的状态,那么......嗯
    【解决方案5】:

    如果考虑初始化顺序

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

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

    【讨论】:

      【解决方案6】:

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

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

      如果你的建议是不可能的,那么 A 就不是不可变的。

      编辑:已修复,感谢 leppie。

      【讨论】:

      • 您在构造函数中最后编写实例化 B,但在示例中您首先启动它,就像在 OP 的代码中一样。错字?
      • 我认为 OP 知道这一点,并提出了一个更基本的问题。
      • @Nick : 很好,直到你有 3 个不可变的类 :) ` public A() { Name = "test"; B = 新 B(这个); C = 新 C(这个); }`
      【解决方案7】:

      原则是不要让你的 this 对象从构造函数体中逃逸。

      观察此类问题的另一种方法是在构造函数中调用虚方法。

      【讨论】:

        【解决方案8】:

        如前所述,编译器无法知道对象在什么时候被构造得足够好,可以派上用场。因此,它假定从构造函数传递this 的程序员将知道对象的构造是否足够好以满足他的需求。

        然而,我要补充一点,对于真正不可变的对象,必须避免将this 传递给任何会在字段被分配最终值之前检查字段状态的代码。这意味着 this 不会被传递给任意外部代码,但并不意味着让正在构建的对象将自身传递给另一个对象 以存储实际上不会的反向引用有什么问题在第一个构造函数完成之前一直使用.

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

        顺便说一句,一个有用的相关功能是能够将参数和函数返回声明为临时的、可返回的或(默认的)可持久的。如果参数或函数返回被声明为临时的,则它不能被复制到任何字段,也不能作为可持久参数传递给任何方法。此外,将临时或可返回值作为可返回参数传递给方法将导致函数的返回值继承该值的限制(如果函数有两个可返回参数,则其返回值将从其参数)。 Java 和 .net 的一个主要弱点是所有对象引用都是混杂的。一旦外部代码得到了它,就不知道谁最终会得到它。如果参数可以被声明为短暂的,那么拥有对某事物的唯一引用的代码通常可以知道它拥有唯一的引用,从而避免不必要的防御性复制操作。此外,如果编译器知道在它们返回后不存在对它们的引用,则可以回收闭包之类的东西。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2013-06-04
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2017-08-16
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多