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