为什么只读结构体上的突变不会破坏结构体?

14

在C#中,如果你有一个struct,如下所示:

struct Counter
{
    private int _count;

    public int Value
    {
        get { return _count; }
    }

    public int Increment()
    {
        return ++_count;
    }
}

然后您有一个类似于以下代码的程序:

static readonly Counter counter = new Counter();

static void Main()
{
    // print the new value from the increment function
    Console.WriteLine(counter.Increment());
    // print off the value stored in the item
    Console.WriteLine(counter.Value);
}
程序的输出结果将会是:
1
0

这个代码似乎完全错误。我原本期望输出是两个1(如果 Counter 是一个 class 或者如果 struct Counter : ICounter,并且 counter 是一个 ICounter),否则应该会产生编译错误。我知道在编译时检测这个问题是非常困难的,但这个行为似乎违背了逻辑。

除了实现上的困难外,这种行为是否有其他原因呢?


1
你为什么期望有两个1?你说你想让它是只读的,那你为什么想要它改变呢? - Eric Lippert
@EricLippert - 如果我有一个readonly数组,我仍然可以更改数组的元素。这个readonly struct对我来说似乎是违反直觉的。它似乎是将readonly关键字推入结构中的某种方式。 - Enigmativity
请查看Eric Lippert的“Mutating Readonly Structs”文章。 - voidp
2个回答

8

structs是值类型,因此具有值类型语义。这意味着每次访问结构体时,您基本上使用结构体值的副本。

在您的示例中,您没有更改原始struct,而只更改了临时副本。

请参阅此处以获取进一步解释:

为什么可变的结构体是邪恶的


6
但如果去掉readonly关键字,那么输出两个1的结果就会出现。因此,在这种情况下显然没有复制结构体。这是否意味着在只读结构体上调用的所有函数都会创建结构体的副本(因为C#没有可变函数的概念)? - Travis Gockel
4
是的,根据C#语言规范第7.5.4节的规定,情况确实如此。请参考Eric Lippert关于该主题的帖子以获取更多详细信息。 - Dirk Vollmar
1
.NET 中一个不幸的设计缺陷是,结构体方法无法指示它们是否会修改 this。由于如果只读结构上不能使用任何方法或属性将会很烦人,而且如果结构上的 readonly 修饰符没有被遵守也会很烦人,因此 C# 和 vb.net 通过在只读结构上调用方法时在调用方法之前复制结构体来进行“妥协”。请注意,如果直接公开结构体字段,则编译器将能够区分读取访问和写入访问。... - supercat
1
如果只想在只读结构上允许前者,则应该将一个方法定义为静态方法,它以ref参数形式接收结构体,以便在原地修改结构体。因为C#不允许将只读结构体作为ref传递,除非作为隐式(有问题的)this参数。 - supercat

4
在 .net 中,结构体实例方法在语义上等同于带有额外的结构体类型 ref 参数的静态结构体方法。因此,考虑以下声明:
struct Blah { 
   public int value;
   public void Add(int Amount) { value += Amount; }
   public static void Add(ref Blah it; int Amount; it.value += Amount;}
}

方法调用:

someBlah.Add(5);
Blah.Add(ref someBlah, 5);

除了一个区别,它们在语义上是等效的:只有当someBlah是可变存储位置(变量、字段等)而不是只读存储位置或临时值(读取属性的结果等)时,后者才会被允许。

这给.net语言的设计者带来了一个问题:禁止在只读结构上使用任何成员函数会很麻烦,但他们不想允许成员函数写入只读变量。于是他们决定"拖延",使在只读结构上调用实例方法将复制结构,对其进行调用,然后将其丢弃。这导致调用不写基础结构的实例方法变慢,并使尝试在只读结构上使用更新基础结构的方法产生与直接传递结构时不同的错误语义。请注意,复制所需的额外时间几乎永远不会在没有复制的情况下产生正确的语义。

我在.net中最大的抱怨之一是,仍然没有(至少在4.0版本和可能的4.5版本中)任何属性可以指示结构成员函数是否修改this。人们谴责结构应该是不可变的,而不是提供工具以允许结构安全地提供变异方法。这是因为所谓的"不可变"结构是一个谎言。所有在可变存储位置中的非平凡值类型都是可变的,所有装箱值类型也是可变的。使结构"不可变"可能会迫使人们重写整个结构,而当人们只想更改一个字段时,struct1 = struct2通过复制来自struct2的所有公共和私有字段来改变struct1,而结构类型定义无法阻止它(除非没有任何字段),它对于防止结构成员的意外变异毫无作用。此外,由于线程问题,结构在强制执行其字段之间的任何不变关系方面的能力非常有限。在我看来,允许任意字段访问的结构通常比试图防止不符合条件的结构的形成更好,从而清楚地表明接收结构的任何代码都必须检查其字段是否满足所有所需条件。


struct1 = struct2通过复制改变了struct1” - 这是正确的,但除了它通常不是原子操作之外,还有什么实际问题吗? struct1struct2都是填充了实际数据的变量或字段,因此在这种情况下应该期望进行复制,因为这是C和C ++中结构体赋值的默认方式。除非struct1是一个readonly字段,在这种情况下它将在编译时失败。 - vgru
无论如何,感谢提供有用的信息,但我会稍微改一下你所说的“在只读结构上调用实例方法会复制该结构”的部分,因为是访问readonly结构字段会创建该结构的副本。如果您首先将值读入局部变量中,则可以对其进行更改(只要其内部字段是可变的),并且.NET将直接在堆栈上操作结构(“实现细节”,我知道)。当字段不是readonly时,方法可以直接在字段上操作而无需复制它。 - vgru
@Groo:并不是有很多情况下struct1=struct2会改变现有的struct1通过覆盖所有字段,但正确理解这些情况的唯一方法是了解实际发生的情况。关于您的第二点,我不太清楚您的建议是什么;“只读结构”一词既指具有readonly限定符的变量,也指函数或属性返回值等临时存储位置;我需要注意即使是读/写属性的返回值也是只读的吗? - supercat
没问题,很清楚,我想说的是不是方法调用导致了复制,而是对字段的任何访问,但我想这已经很清楚了。 - vgru
@Groo:如果存在一个结构体“readonly ExposedFieldStruct s;”,我认为编译器不会在代码需要读取其中一个字段时复制整个“s”;但是,如果代码需要读取其属性(因为属性实际上是伪装成方法的属性),则会复制整个“s”。 - supercat

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