const、readonly和mutable值类型

23
我正在继续学习C#和语言规范,这里又有一个我不太理解的行为:
C#语言规范在第10.4节中清楚地说明了以下内容:
常量声明中指定的类型必须是sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool、string、枚举类型或引用类型。
同时,在第4.1.4节中它也阐述了以下内容:
通过const声明,可以声明简单类型(§10.4)的常量。不可能使用其他结构体类型来声明常量,但是可以通过static readonly字段实现类似的效果。
好的,所以使用static readonly可以获得类似的效果。看完后我试了以下代码:
static void Main()
{
    OffsetPoints();
    Console.Write("Hit a key to exit...");
    Console.ReadKey();
}

static Point staticPoint = new Point(0, 0);
static readonly Point staticReadOnlyPoint = new Point(0, 0);

public static void OffsetPoints()
{
    PrintOutPoints();
    staticPoint.Offset(1, 1);
    staticReadOnlyPoint.Offset(1, 1);
    Console.WriteLine("Offsetting...");
    Console.WriteLine();
    PrintOutPoints();
}

static void PrintOutPoints()
{
    Console.WriteLine("Static Point: X={0};Y={1}", staticPoint.X, staticPoint.Y);
    Console.WriteLine("Static readonly Point: X={0};Y={1}", staticReadOnlyPoint.X, staticReadOnlyPoint.Y);
    Console.WriteLine();
}

这段代码的输出结果是:

静态 Point: X=0;Y=0

静态 readonly Point: X=0;Y=0

偏移...

静态 Point: X=1;Y=1

静态 readonly Point: X=0;Y=0

按任意键继续...

我本来期望编译器会给我一些关于修改 静态只读 字段的警告,或者至少像对待引用类型一样修改字段。

我知道可变值类型很麻烦(微软为什么要把 Point 实现成可变的是个谜),但是难道编译器不应该以某种方式警告你正在尝试修改一个 静态只读 值类型吗?或至少警告你的 Offset() 方法不会产生“预期”的副作用吗?


我没有看到你在改变一个“静态只读字段”。我怀疑你误解了这个概念。 - leppie
这很有趣。我没想到输出会不同。事实上两者都应该被改变,但我看到的结果和你一样。奇怪... - leppie
我对这个问题的想法是专注于 const 和 readonly 类型的使用(const 在设计时已知,而 readonly 不是)。这可能会让你更好地了解为什么没有编译器响应(因为已知 readonly 可以被重新分配)。 - Roi Shabtai
@leppie: staticReadOnlyPoint.Offset(1, 1) 正在改变一个 静态只读 字段。如果你从我的代码中去掉 readonly,那么两个 Offset 调用的输出将是相同的。void Offset(int, int) 改变了值类型。 - InBetween
3
@leppie 是的,这也是为什么 Stack Overflow 如此棒的原因之一,它让你每天都能学到新东西,而且通常是一些书本上找不到的。有时候答案来自大师们(比如 Lippert、Skeet 等等),但有时候解决方案则是由所有回答问题的人构建出来的,这真的很棒 :) - vc 74
显示剩余4条评论
6个回答

11
埃里克·利珀特(Eric Lippert)在这里解释了正在发生的事情:

……如果字段是只读的,并且该引用发生在声明该字段的类的实例构造函数之外,则结果是一个值,即由 E 引用的对象中字段 I 的值。

这里的重要词语是结果是该字段的值,而不是与该字段相关联的变量。只读字段在构造函数之外不是变量。(这里的初始化程序被认为是在构造函数内部;请参见我先前关于这个主题的帖子。)

哦,只是为了强调可变结构的邪恶性,以下是他的结论:

这是可变值类型的又一个原因为什么它们是邪恶的。尝试始终使值类型是不可变的。


抢先一步了!我正要发布这个。 - Igal Tabachnik
由于它是第一篇指出值类型的只读字段如何工作的帖子,因此被接受。谢谢! - InBetween
@InBetween,非常欢迎您。感谢您发布一个有趣的问题 :) - vc 74

7

readonly 的作用是您无法重新分配引用或值。

换句话说,如果您尝试这样做:

staticReadOnlyPoint = new Point(1, 1);

如果您尝试重新分配staticReadOnlyPoint,则会收到编译器错误。编译器将阻止您这样做。

然而,readonly并不强制执行值或引用对象本身是否可变 - 这是由创建它的人设计的行为。

[编辑:为了正确解释所描述的奇怪行为]

您看到staticReadOnlyPoint似乎是不可变的原因并不是因为它本身是不可变的,而是因为它是一个只读结构体。这意味着每次访问它时,您都会完全复制它。

因此,您的代码行

staticReadOnlyPoint.Offset(1, 1);

访问和更改的是字段的副本,而不是字段中的实际值。当您随后写出该值时,您实际上又写出了原始值(而不是更改后的副本)的另一个副本

使用Offset调用所更改的副本将被丢弃,因为它从未被分配给任何东西。


2
这仍然无法解释代码的(意外)输出。 - leppie
1
@确实如此!但事实仍然存在,鉴于代码的输出结果,该字段实际上是不可变的。 - InBetween

5
编译器对方法所操作的结构体缺少足够的信息,无法知道该方法是否会改变结构体的成员。一个方法可能有一个有用的副作用,但不会改变结构体的任何成员。理论上,将这种分析添加到编译器是可能的。但是,对于任何存储在其他程序集中的类型都不起作用。
缺失的元数据标记指示方法不会改变任何成员。像 C++ 中的 const 关键字一样,但它不可用。如果在原始设计中添加,则极其不符合 CLS 规范。很少有语言支持此概念。我只能想到 C++,但我经常不出门。
顺便说一下,编译器确实生成显式代码以确保语句不能意外修改 readonly。 此语句:
staticReadOnlyPoint.Offset(1, 1);

被翻译成

Point temp = staticReadOnlyPoint;   // makes a copy
temp.Offset(1, 1);

添加比较值并生成运行时错误的代码也仅在技术上可行。这样做成本太高。


+1:很好的解释。老实说,我仍然觉得这种行为很容易误导人,但目前还没有好的解决方案。也许最好的解决方案是在设计C# 1.0时考虑一些可变关键字来处理值类型。 - InBetween
直到今天它仍然不太符合CLS标准。 - Hans Passant
允许成员函数指示是否应通过引用、常量引用或值传递this并不意味着这是一个坏主意 - 只是CLS存在严重缺陷。 - supercat

3
观察到的行为是一个不幸的结果,因为Framework和C#都没有提供任何方法使成员函数声明可以指定是否应该按引用传递this、按const-ref传递this还是按值传递this。相反,值类型总是通过(非const-restricted)ref来传递this,而引用类型总是通过值传递this。如果编译器能禁止使用非const-restricted ref来传递不可变或临时值,那么“正确”的行为应该是这样的。如果能够实施这样的限制,确保可变值类型的正确语义意味着遵循一个简单的规则: 如果你做了一个结构体的隐式副本,你做错了什么。不幸的是,成员函数只能通过非const-restricted ref接受this,这意味着语言设计者必须做出三个选择之一: 1. 猜测成员函数不会修改"this",并简单地将不可变或临时变量传递给ref。这对于那些实际上不修改"this"的函数来说是最有效的,但可能会危险地暴露应该是不可变的东西的修改。 2. 不允许在不可变或临时实体上使用成员函数。这样可以避免不当语义,但这样的限制会非常烦人,特别是考虑到大多数成员函数都不修改"this"。 3. 允许使用成员函数,除了那些被认为最有可能修改"this"的函数(例如属性设置器),但不是直接通过ref传递不可变实体,而是将其复制到临时位置并传递这些实体。
微软的选择保护常量免受不当修改,但不幸的是,当调用不修改"this"的函数时,代码将不必要地运行缓慢,同时通常在修改"this"的函数上工作时会出现错误。鉴于实际处理"this"的方式,一个人最好避免在结构体成员函数中对其进行任何更改,除了属性设置器之外。拥有属性设置器或可变字段是可以的,因为编译器将正确地禁止任何尝试对不可变或临时对象使用属性设置器或修改它们的任何字段。

2

如果您查看IL,您会发现在使用readonly字段时,在调用Offset之前会先进行复制:

IL_0014: ldsfld valuetype [System.Drawing]System.Drawing.Point 
                    Program::staticReadOnlyPoint
IL_0019: stloc.0
IL_001a: ldloca.s CS$0$0000

我不知道为什么会发生这种情况。

可能是规范的一部分,也可能是编译器的bug(但看起来有点过于故意了)。


2
效果是由几个定义明确的特点共同形成的。
“readonly”表示所讨论的字段不可更改,但并非字段的目标不能更改。在可变的引用类型的 “readonly” 字段中,这更易理解(并且在实践中更常用),您可以执行 “x.SomeMutatingMethod()”,但不能执行 “x = someNewObject”。
因此,第一项是:您可以更改“readonly”字段的目标。
第二项是,当您访问“非变量”值类型时,您会获得该值的副本。最不令人困惑的示例是 “giveMeAPoint().Offset(1, 1)” ,因为我们没有已知的位置可以稍后观察到由“giveMeAPoint()”返回的值类型是否已经被修改。
这就是为什么值类型不邪恶,但在某些方面更糟糕。真正邪恶的代码没有明确定义的行为,而所有这些都有明确定义。尽管如此仍然很困惑(足以让我在第一个答案中弄错了),而当您尝试编码时,困惑比邪恶更糟糕。易于理解的邪恶要容易避免得多。

我不同意第一个人的行为。IL 不支持那种语义。SomeMethod 只是在原始值类型 (myValueType) 上调用。 - leppie
@leppie 当我谈论省略复制时,这恰恰是我所想要的。观察到的行为不管哪种方式都是一样的,因此为什么要增加额外的工作而没有收益呢。我的理解是,值类型必须像基于值的类型一样被访问(但字段的ref确实绕过了),但不是必须发出代码,这些代码除了减速事物之外没有任何作用(并可能引入剪切读取)。 - Jon Hanna
第二个问题是,当您访问值类型时,您会获得该值的副本。因此,对Offset的两次调用都会获得该本地变量(即this.whatever)的副本,并在其上调用Offset。这并非完全正确,或者我可能误解了您的意思。事实证明,对于只读字段确实如此,但对于一般的值类型并非如此,因为如果未将其定义为“readonly”,则Offset(int, int)会改变该值类型。 - InBetween
@InBetween 这并不矛盾,一个(概念上的,计算机不需要真的这样做)复制-修改-复制的过程会改变非只读字段。 - Jon Hanna
“邪恶”的不是值类型可变,而是成员函数无法通知它们修改了this。如果直接或间接修改值类型的唯一公开方式是覆盖实例、修改公开字段或调用公开属性设置器,则不会出现问题。 - supercat

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