关于c#:const,readonly和mutable值类型


const, readonly and mutable value types

我正在继续学习C和语言规范,下面是另一个我不太理解的行为:

C语言规范在第10.4节中明确说明了以下内容:

The type specified in a constant declaration must be sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, bool, string, an enum-type, or a reference-type.

第4.1.4节还规定了以下内容:

Through const declarations it is possible to declare constants of the simple types (§10.4). It is not possible to have constants of other struct types, but a similar effect is provided by static readonly fields.

好的,所以使用静态只读可以获得类似的效果。我读了这篇文章,尝试了以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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();
}

此代码的输出为:

Static Point: X=0;Y=0

Static readonly Point: X=0;Y=0

Offsetting...

Static Point: X=1;Y=1

Static readonly Point: X=0;Y=0

Hit a key to exit...

我真的希望编译器能给我一些关于改变静态只读字段或失败的警告,像改变引用类型那样改变字段。

我知道可变值类型是邪恶的(为什么微软会实现Point,因为可变是一个谜),但编译器不应该在某种程度上警告您试图改变静态只读值类型吗?或者至少警告你,你的Offset()方法不会有"期望的"副作用?


埃里克·利珀特解释了这里发生的事情:

...if the field is readonly and the reference occurs outside an
instance constructor of the class in which the field is declared, then
the result is a value, namely the value of the field I in the object
referenced by E.

The important word here is that the result is the value of the field,
not the variable associated with the field. Readonly fields are not
variables outside of the constructor. (The initializer here is
considered to be inside the constructor; see my earlier post on that
subject.)

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

This is yet another reason why mutable value types are evil. Try to
always make value types immutable.


只读的点是不能重新分配引用或值。

换句话说,如果你试图这样做

1
staticReadOnlyPoint = new Point(1, 1);

您将得到一个编译器错误,因为您正试图重新分配staticReadOnlyPoint。编译器将阻止您执行此操作。

但是,readonly并不强制值或被引用对象本身是否是可变的——这是由创建它的人设计到类或结构中的行为。

[编辑:正确处理所描述的奇怪行为]

您看到staticReadOnlyPoint似乎不可变的行为的原因不是因为它本身是不可变的,而是因为它是一个只读结构。这意味着每次访问它时,您都会获取它的完整副本。

所以你的路线

1
staticReadOnlyPoint.Offset(1, 1);

正在访问和变异字段的副本,而不是字段中的实际值。当您随后写出值时,您将写出另一个原始副本(而不是变异副本)。

您对Offset的调用改变的副本将被丢弃,因为它从未被分配给任何对象。


编译器没有足够的关于方法的信息来知道方法会改变结构。方法可能有一个很有用的副作用,但不会改变结构的任何成员。如果技术上可以将这种分析添加到编译器中。但这对任何生活在另一个组件中的类型都不起作用。

缺少的成分是一个元数据标记,它指示一个方法不会改变任何成员。就像C++中的const关键字一样。无法使用的。如果将其添加到原始设计中,它将完全不符合CLS。很少有语言支持这个概念。我只能想到C++,但我不怎么懂。

在fwiw中,编译器会生成显式代码,以确保语句不会意外地修改readonly。本声明

1
staticReadOnlyPoint.Offset(1, 1);

转换为

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

添加代码,然后比较值并生成运行时错误,在技术上也是可能的。它太贵了。


所观察到的行为是一个不幸的结果,因为框架和C都没有提供任何方法,通过这些方法成员函数声明可以指定this是否应通过ref、const-ref或value传递。相反,值类型总是通过(非常量限制)ref传递this,引用类型总是通过值传递this

编译器的"正确"行为是禁止非常量受限引用传递不可变或临时值。如果可以实施此类限制,则确保可变值类型的正确语义意味着遵循一个简单规则:如果您隐式复制结构,则说明您做错了什么。不幸的是,成员函数只能接受非常量限制引用的this,这意味着语言设计者必须做出三种选择之一:

  • 假设成员函数不会修改"this",只通过"ref"传递不可变或临时变量。对于那些实际上不修改"this"但可能危险地暴露于不可变的修改内容的函数来说,这将是最有效的。
  • 不允许在不可变或临时实体上使用成员函数。这将避免不正确的语义,但这将是一个非常恼人的限制,特别是在大多数成员函数不修改"this"的情况下。
  • 允许使用成员函数(被认为最有可能修改"this"的函数除外)(例如属性设置器),但不要直接通过引用传递不可变的实体,而是将它们复制到临时位置并传递它们。

    微软的选择可以保护常量不被不当修改,但不幸的是,当调用不修改this的函数时,代码运行速度会不必要地慢,而对于那些不修改this的函数,代码运行通常是不正确的。

    考虑到实际处理this的方式,最好避免在结构成员函数(而不是属性设置器)中对其进行任何更改。拥有属性设置器或可变字段是可以的,因为编译器将正确地禁止对不可变或临时对象使用属性设置器,或修改其中的任何字段。



    这种效果是由于几个定义明确的特性结合在一起造成的。

    readonly表示该字段不能更改,但不能更改该字段的目标。对于可变引用类型的readonly字段,这更容易理解(并且在实践中更常用),您可以在其中执行x.SomeMutatingMethod(),但不能执行x = someNewObject

    所以,第一项是:你可以改变readonly字段的目标。

    第二项是,当您访问非变量值类型时,您将获得该值的副本。最不令人困惑的例子是giveMeAPoint().Offset(1, 1),因为我们没有一个已知的位置可以稍后观察到giveMeAPoint()返回的值类型可能已经或可能没有突变。

    这就是为什么价值类型不是邪恶的,但在某些方面更糟。真正邪恶的代码没有明确的行为,所有这些都是明确的。不过,它仍然令人困惑(对我来说,第一个答案就足够让人困惑了),而当你试图编码时,困惑比邪恶更糟糕。容易理解的邪恶更容易避免。


    如果您查看IL,您将看到在使用readonly字段时,在调用Offset之前会生成一份副本:

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

    为什么会这样,我无法理解。

    它可能是规范的一部分,也可能是编译器bug(但对于后者来说,它看起来有点过于刻意)。