常量、只读和可变值类型

本文关键字:类型 只读 常量 | 更新日期: 2023-09-27 18:21:09

我正在继续研究 C# 和语言规范和 这是另一种我不太理解的行为:

C# 语言规范在第 10.4 节中明确指出以下内容:

常量声明中指定的类型必须是 sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool、string、enum-type 或引用类型。

它还在第 4.1.4 节中指出以下内容:

通过 const 声明,可以声明简单类型的常量 (§10.4(。不可能有其他结构类型的常量,但静态只读字段提供了类似的效果。

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

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();
}

此代码的输出为:

静态点:X=0;Y=0

静态只读点:X=0;Y=0

抵消。。。

静态点:X=1;Y=1

静态只读点:X=0;Y=0

按键退出...

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

我知道可变值类型是邪恶的(为什么Microsoft曾经实现Point因为可变是一个谜(,但编译器不应该以某种方式警告您您正在尝试更改静态只读值类型吗?或者至少警告您,您的Offset()方法不会产生"预期"的副作用?

常量、只读和可变值类型

Eric Lippert解释了这里发生的事情:

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

这里重要的词是结果是字段的值, 不是与字段关联的变量。只读字段不是 构造函数外部的变量。(此处的初始值设定项是 被视为在构造函数内部;请参阅我之前的文章 主题。

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

这是可变值类型邪恶的另一个原因。尝试 始终使值类型不可变。

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

换句话说,如果您尝试过此操作

staticReadOnlyPoint = new Point(1, 1);

您会收到编译器错误,因为您正在尝试重新分配staticReadOnlyPoint 。编译器将阻止您执行此操作。

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

[编辑:妥善处理所描述的奇怪行为]

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

所以你的线

staticReadOnlyPoint.Offset(1, 1);

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

您通过调用 Offset 而发生变异的副本将被丢弃,因为它永远不会分配给任何内容。

编译器根本没有足够的关于方法的信息来知道该方法改变了结构。 方法很可能具有有用的副作用,但不会更改结构的任何成员。 如果技术上可以向编译器添加此类分析。 但这不适用于存在于另一个程序集中的任何类型。

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

Fwiw,编译器确实会生成显式代码,以确保语句不会意外修改只读。 此声明

staticReadOnlyPoint.Offset(1, 1);

被翻译成

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

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

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

编译器的"正确"行为是禁止通过不受常量限制的 ref 传递不可变或临时值。 如果可以施加这样的限制,那么确保可变值类型的正确语义将意味着遵循一个简单的规则:如果你做了一个结构的隐式副本,你就做错了什么。 不幸的是,成员函数只能接受非常量限制 ref this的事实意味着语言设计者必须做出以下三个选择之一:

  1. 猜测成员函数不会修改"this",而只是通过"ref"传递不可变或临时变量。 这对于实际上不修改"this"的函数来说是最有效的,但可能会危险地暴露给应该不可变的修改对象。
  2. 不允许在不可变实体或临时实体上使用成员函数。 这将避免不正确的语义,但将是一个非常烦人的限制,特别是考虑到大多数成员函数不修改"this"。允许使用成员函数,但被认为
  3. 最有可能修改"this"的函数(例如属性 setters(除外,但不是直接通过 ref 传递不可变实体,而是将它们复制到临时位置并传递这些实体。

Microsoft的选择可以保护常量免受不当修改,但不幸的是,当调用不修改this函数时,代码将不必要地运行缓慢,而对于那些修改的函数,代码通常会错误地工作。

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

如果您查看 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

为什么会发生这种情况,我无法理解。

可能是规范的一部分,也可能是编译器错误(但对于后者来说,它看起来有点太有意了(。

这种效果是由于几个定义明确的功能结合在一起。

readonly意味着不能更改相关字段,但不能更改字段的目标。使用可变引用类型的readonly字段,这更容易理解(在实践中更常用(,您可以在其中执行x.SomeMutatingMethod()但不能执行x = someNewObject

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

第二项是,当您访问非变量值类型时,您将获得该值的副本。最不容易混淆的例子是giveMeAPoint().Offset(1, 1)因为没有一个已知的位置供我们稍后观察giveMeAPoint()返回的值类型可能已经发生突变,也可能没有发生突变。

这就是为什么值类型不是邪恶的,但在某些方面更糟。真正的邪恶代码没有明确定义的行为,所有这些都是明确定义的。虽然它仍然令人困惑(令人困惑到足以让我在第一个答案上弄错这一点(,当你尝试编码时,混乱比邪恶更糟糕。容易理解的邪恶更容易避免。