为什么这甚至是有效的?
为什么你认为它是无效的?
因为构造函数应该保证它包含的代码在外部代码可以观察到对象的状态之前执行。
正确。但编译器不负责维护该不变量。 你是。如果您编写的代码破坏了该不变量,并且这样做会很痛苦,那么停止这样做。
还有其他方法可以观察未完全构造的对象的状态吗?
当然。对于引用类型,所有这些都涉及以某种方式将“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。拥有这种额外的间接级别可能是不可接受的。