使用公共只读字段来创建不可变结构体是否有效?

60

这是声明不可变结构体的正确方式吗?

public struct Pair
{
    public readonly int x;
    public readonly int y;

    // Constructor and stuff
}

我想不出为什么会遇到问题,但我只是想确认一下。

在这个例子中,我使用了整数。如果我使用了一个类,但是这个类也是不可变的,像这样?那也应该可以正常工作,对吗?

public struct Pair
{
    public readonly (immutableClass) x;
    public readonly (immutableClass) y;

    // Constructor and stuff
}

(顺便提一句:我知道使用属性更加通用并允许更改,但这个结构体只是用于存储两个值。我在这里只关注不可变性的问题。)


1
readonly 属性/成员只能在构造函数内(最晚)设置。它们不能使用属性初始化语法进行设置。 - user166390
你可能想检查不可变类型:了解它们的好处并使用它们 - YetAnotherUser
readonly 只影响赋值运算符。它的语义不像 C++ 的 const 关键字那样强。 - Etienne de Martel
4个回答

122

如果您要使用结构体,最佳实践是使它们不可变。

将所有字段设置为只读是一种很好的方式,可以帮助 (1) 说明结构体是不可变的,以及 (2) 防止意外更改。

但是,有一个问题,我正巧打算在下周写博客讨论它。那就是:在结构体字段上使用readonly是一个谎言。人们期望一个只读字段不能改变,但它实际上是可以改变的。在结构体字段上标记“readonly”,就像支票账户没钱写支票一样。 结构体不拥有它的存储空间,而正是这个存储空间可以发生变化。

例如,让我们看看您的结构体:

public struct Pair
{
    public readonly int x;
    public readonly int y;
    public Pair(int x, int y)
    {
        this.x = x;
        this.y = y;
    }
    public void M(ref Pair p)
    {
        int oldX = x;
        int oldY = y;
        // Something happens here
        Debug.Assert(x == oldX);
        Debug.Assert(y == oldY);
    }
}

“在这里发生的某些事情”可能会导致调试断言被违反吗?当然。

    public void M(ref Pair p)
    {
        int oldX = this.x;
        int oldY = this.y;
        p = new Pair(0, 0);
        Debug.Assert(this.x == oldX);
        Debug.Assert(this.y == oldY);
    }
...
    Pair myPair = new Pair(10, 20);
    myPair.M(ref myPair);

现在会发生什么?断言被违反了!"this"和"p"引用同一个存储位置。该存储位置被改变,所以"this"的内容也被改变了,因为它们是同一件事。结构体不能强制 x 和 y 的只读性,因为结构体不拥有存储;存储是一个局部变量,可以自由地进行突变。

您不能 依赖 于这样一个不变式:结构体中的只读字段永远不会改变;您唯一可以依赖的是您无法编写直接更改它的代码。但是通过像这样的一些偷偷摸摸的工作,您可以间接地随意更改它。

另请参见乔·达菲(Joe Duffy)在此问题上的卓越博客文章:

http://joeduffyblog.com/2010/07/01/when-is-a-readonly-field-not-readonly/


19
真是太神奇了,你展示了如此多的边缘情况,让我想到,“谁会这样做呢?” - Joel B Fant
4
当我在调试别人的代码时,我发现你可以使用 StructLayout.Explicit 来做类似的事情。 - Jim Mischel
10
@Joel: 假设我们有一种方法可以让一对数相乘,但是有时会失败。所以你有了一个方法 "bool MultiplyBy(Pair x, out Pair result)" ,它将 "this" 与 x 相乘,返回成功或失败的布尔值,并将结果写入别名变量中。现在你有一个数对,想要平方它并用平方替换前一个值,所以你说 "myPair.MultiplyBy(myPair, out myPair)"。突然间,你就陷入了这个可怕的陷阱。每一步都很合理,但加起来却成了一件可怕的事情。 - Eric Lippert
3
@BillAskaga: 我同意人们经常对值和变量之间的区别感到困惑,但我不认为这个答案应该受到责备;问题似乎更普遍和广泛。我认为你没有理解我的答案重点。我并不是在暗示会被改变,正如你所指出的,值只是值;我指出的是只读字段的内容可能随时间而变化,即使在构造函数之外,这是反直觉的。 - Eric Lippert
3
@EricLippert:我认为你应该更清楚地解释一下,即 x/y 字段实际上并没有改变,但基础的 this 引用确实发生了变化,从而导致 x/y 字段看起来发生了改变。因此,主要问题是 C# 不允许我们将 myPair 字段设为只读 - 这样我们就无法在第一次重新分配它时进行赋值。当 Pair 是一个类时,相同的代码行为不同的事实当然是相当反直觉的。 - Askaga
显示剩余8条评论

7
从C#7.2开始,您现在可以声明整个结构体为不可变的:
public readonly struct Pair
{
    public int x;
    public int y;

    // Constructor and stuff
}

这将产生与将所有字段标记为readonly相同的效果,并将文档化给编译器,表明该结构体是不可变的。这将通过减少编译器进行的防御性复制次数来增加使用该结构体的区域的性能。
正如Eric Lippert在他的回答中所指出的那样,这并不能完全防止结构本身被重新分配,从而导致其字段在你使用时发生更改的影响。可以通过传值或使用新的in参数修饰符来防止这种情况。
public void DoSomething(in Pair p) {
    p.x = 0; // illegal
    p = new Pair(0, 0); // also illegal
}

将结构体设为“只读”并不会自动将所有字段标记为“只读”。您仍然需要手动进行标记,但这可以让编译器帮助您查找任何您可能忘记的情况。否则,您将收到错误CS8340:“只读结构体的实例字段必须是只读的。” - rbwhitaker
这将产生与将所有字段标记为只读相同的效果。 不,除非您使x和y都为只读,否则此代码示例将无法编译。 - Lucas

5
那确实会使它变成不可改变的。我想你最好添加一个构造函数。
如果它的所有成员也都是不可变的,那么它就完全是不可变的。这些成员可以是类或简单值。

当然,我只是为了简单起见而省略了它。 - Mike
如果它们是不可变类而不是整数,那怎么样?也应该没问题吧? - Mike
一个构造函数会很有帮助,除非你一直想让x和y为零。 - Nicholas Carey

1
编译器将禁止对readonly字段以及只读属性进行赋值。
我建议主要出于公共接口和数据绑定(无法在字段上工作)的原因使用只读属性。如果这是我的项目,我会要求结构体/类是公共的,如果它将成为程序集内部或类内部的私有类型,则可以先忽略此问题,稍后再将其重构为只读属性。

2
编译器能够在编译时检测到对readonly字段的赋值,并在检测到此类情况时引发编译器错误。 - dtb
运行时也会强制实施 readonly 语义。也就是说,如果 x 是类型为 MyStructreadonly 字段,sMyStruct 的一个实例,那么 dynamic d = s; d.x = 42; 将抛出异常。 - Jim Mischel
“public interface reasons”是什么意思? - TheQuickBrownFox

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