C#、不可变性和只读字段...是谎言吗?

45

我发现人们声称在类中使用全部只读字段并不一定意味着该类的实例是不可变的,因为在初始化(构造)后仍然有“方法”可以更改只读字段的值。

如何更改? 有哪些方法?

所以我的问题是,在C#中我们什么时候能够真正拥有一个“真正”的不可变对象,我可以在多线程中安全地使用?

而匿名类型是否创建不可变对象?有些人说LINQ在内部使用不可变对象。 究竟是怎么回事?

5个回答

106

你在这里问了五个问题。我回答第一个问题:

仅仅将一个类中的所有只读字段设置为只读并不能使该类的实例成为不可变对象,因为即使在构造之后仍然有“方法”可以更改只读字段的值。如何做到的?

在构造之后能否更改只读字段的值?

是的,如果你受足够信任可以打破只读性规则

那么如何实现呢?

你进程中的每一位用户内存都是可变的。像只读字段这样的约定可能会使某些位看起来是不可变的,但是如果你足够努力,你可以改变它们。例如,你可以取一个不可变对象实例,获取它的地址,并直接改变其原始位。这可能需要很多巧妙的技巧和对内存管理器的内部实现细节的知识,但由于内存管理器设法改变了那块内存,所以你也可以这样做。如果你受到足够的信任,你也可以使用“私有反射”来打破各种安全系统的部分。

根据定义,完全受信任的代码允许打破安全系统的规则。这就是“完全受信任”的意思。如果你的完全受信任的代码选择使用诸如私有反射或不安全的代码来违反内存安全规则,那么完全受信任的代码是允许这样做的。

请不要这样做。这样做是危险和令人困惑的。内存安全系统旨在使您可以更轻松地推断您的代码的正确性;故意违反其规则是一个坏主意。

那么,“只读”是谎言吗?嗯,假设我告诉你 如果每个人都遵守规则,那么每个人都能得到一片蛋糕。蛋糕是谎言吗?该说法并不是“你将得到一片蛋糕”的说法。这是“如果每个人都遵守规则”,你会得到一片蛋糕的说法。如果有人作弊并拿走了你的那片,你就没有蛋糕了。

一个类的只读字段是只读的吗?是的,但前提是只要每个人都遵守规则。因此,只读字段不是“谎言”。合同规定,如果每个人都遵守系统规则,则该字段被认为是只读的。如果有人违反规则,那么可能就不是只读的了。这并不意味着说“如果每个人都遵守规则,该字段就是只读的”是一种谎言!

你没有问的问题,但也许应该问的是,结构体的字段上的“readonly”也是一种“谎言”吗?请参见使用不可变结构体的公共只读字段是否有效?,以获取对该问题的一些想法。与类上的只读字段相比,结构体上的只读字段更像是谎言。

至于你其他的问题——我认为如果每个问题只问一个问题,而不是一个问题里包含五个问题,那么你会得到更好的结果。


1
谢谢Eric,你的博客也非常有用...我知道这个是因为我最近参加了一次面试,当我说只读字段提供不可变性时,面试官完全持怀疑态度。 - WPF-it
1
从另一个角度来看,private 字段也是虚假的,因为它们可以通过迂回的方式访问。我相信我们都可以想到其他的例子。在实践中,我认为大多数情况下 readonly 字段是不可变的,因为只有愚蠢或天才才会证明更改旨在不可变的字段是合理的。但从安全性的角度来看,我不认为它们是不可变的,因为恶意带来了不同的动机,对攻击者而言,通常会出现漏洞的技术可能成为攻击的巧妙手段。 - Jon Hanna
1
@Jon:说得好;但是,如果您可以改变只读字段,那么您的代码已经完全受信任,在这种情况下,它不需要巧妙的攻击手段。完全受信任的代码可以开始删除您的文件或者它想要做的任何事情。 - Eric Lippert
1
@Andrei:有没有什么神秘的力量阻止你在写入之前调用VirtualProtect来更改保护方式? - Eric Lippert
@Aaron:非常好的观点。但请注意,原帖中明确指出是“构造之后”。同时,在 ctor 中传递“this”给一个函数并改变其状态是一种不好的做法;通常你应该在调用任何函数之前将整个对象初始化为正确的初始状态。还要注意,如果只读字段使用字段初始化程序进行初始化,我们保证该字段在 ctor 中的代码被调用之前已经有了它的值。 - Eric Lippert
显示剩余5条评论

14

编辑:我专注于在系统内部工作的代码,而不是像Eric提到的使用反射等。

这取决于字段的类型。如果字段本身是不可变类型(例如String),那就没问题。如果是StringBuilder,那么对象可以看起来发生变化,即使字段本身没有改变它们的值,因为“嵌入”的对象可以改变。

匿名类型完全相同。例如:

var foo = new { Bar = new StringBuilder() };
Console.WriteLine(foo); // { Bar = }
foo.Bar.Append("Hello");
Console.WriteLine(foo); // { Bar = Hello }

所以,基本上如果你有一个想要完全不可变的类型,你需要确保它只引用不可变数据。

还有一些结构体可以拥有只读字段,但仍然公开可以通过重新分配this来改变自身的方法。这样的结构体在调用它们的确切情况下会表现得有些奇怪。不好 - 不要这样做。

Eric Lippert写了很多关于不可变性的文章 - 都是金玉良言,正如你所期望的那样...去读吧 :)(当我写这段话时,我没有注意到Eric正在回答这个问题。显然也要阅读他的答案!)


Jon - 你所说的“匿名类型完全相同”,是指实际类型是不可变的,而内容可以是可变的吗?我在阅读(包括你和Eric的)书时也看到了类似的例子。http://books.google.co.il/books?id=s-IH_x6ytuQC&pg=PT65&lpg=PT65&dq=%22readonly+protects+the+location+of+the+field+from+being+changed%22&source=bl&ots=lra68VePaW&sig=NqrwogiRfLlglV8SDmmI0nLuNjE&hl=en&sa=X&ei=EifgUKrmDKnC0AWT24DIBw&redir_esc=y - Royi Namir
@RoyiNamir: 是的。C#中的所有匿名类型都具有只读属性,但是如果您使用StringBuilder(例如)创建一个匿名类型,则没有任何阻止您改变实例引用的StringBuilder的方法。 - Jon Skeet

13

我认为Eric的回答远远超出了原问题的范围,甚至没有回答它,所以我来试着解释一下:

什么是只读(readonly)?如果我们谈论值类型,那就很简单:一旦值类型被初始化并赋值,它就不能改变(至少在编译器看来是这样)。

但当我们谈到在引用类型中使用readonly时,混淆就开始出现了。此时我们需要区分引用类型的两个组成部分:

  • “引用”(变量、指针),它指向内存中你的对象所在的地址
  • 包含引用所指的数据的内存

指向对象的引用本身是一个值类型。 当你在引用类型中使用readonly时,你正在使得指向对象的引用是不可变的,而不是强制让对象所占据的内存也不可变。

现在,考虑一个包含值类型和对其他对象的引用的对象,以及这些对象包含值类型和对其他对象的引用。如果你将你的对象组合在这样一种方式,使得所有对象的所有字段都是readonly的,你可以实现所期望的不可变性。


将您的对象引用设置为不可变 - 构造函数可以修改它,因此它不是不可变的。请参阅我的帖子。 - Aaron
3
没错,但我认为你有点过于追求小节了。一个不可变的值需要在某种程度上产生,而那个时刻就是对象构造的时候。一旦对象被构造,这个值就是不可变的(虽然为了迎合追求小节的人们,应该说明一下,不安全的代码和反射仍然可以改变这些不可变值)。 - w.brian

8

只读和线程安全是两个不同的概念,正如Eric Lippert所解释的那样。


0

也许“人们”听到了第三手的消息,并且对于构造函数在“只读”字段方面获得的特殊权限传达得不好。它们并不是只读的。

不仅如此,它们也不是单赋值的。类的构造函数可以随意多次修改该字段,而不违反任何“规则”。

构造函数还可以调用其他任意方法,并将自身作为参数传递。因此,如果您是那个其他方法,您无法确定该字段是否不可变,因为也许您是由该对象的构造函数调用的,而构造函数将在您完成后立即修改该字段。

您可以自己尝试:

using System;
class App
{
    class Foo 
    {
        readonly int x;
        public Foo() {  x = 1; Frob(); x = 2; Frob(); }
        void Frob() { Console.WriteLine(x); }
    }
    static void Main()
    {
        new Foo();
    }
}

这个程序打印1、2。在Frob读取'x'后,它被修改。

现在,在任何单个方法体内,该值必须是常量 - 构造函数不能委派修改访问其他方法或委托,因此直到方法返回,我相当确定该字段需要保持稳定。

以上所有内容都是关于类的。结构体则完全不同。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接