32

MutableSlabImmutableSlab实现之间的唯一区别是readonly应用于handle字段的修饰符:

using System;
using System.Runtime.InteropServices;

public class Program
{
    class MutableSlab : IDisposable
    {
        private GCHandle handle;

        public MutableSlab()
        {
            this.handle = GCHandle.Alloc(new byte[256], GCHandleType.Pinned);
        }

        public bool IsAllocated => this.handle.IsAllocated;

        public void Dispose()
        {
            this.handle.Free();
        }
    }

    class ImmutableSlab : IDisposable
    {
        private readonly GCHandle handle;

        public ImmutableSlab()
        {
            this.handle = GCHandle.Alloc(new byte[256], GCHandleType.Pinned);
        }

        public bool IsAllocated => this.handle.IsAllocated;

        public void Dispose()
        {
            this.handle.Free();
        }
    }

    public static void Main()
    {
        var mutableSlab = new MutableSlab();
        var immutableSlab = new ImmutableSlab();

        mutableSlab.Dispose();
        immutableSlab.Dispose();

        Console.WriteLine($"{nameof(mutableSlab)}.handle.IsAllocated = {mutableSlab.IsAllocated}");
        Console.WriteLine($"{nameof(immutableSlab)}.handle.IsAllocated = {immutableSlab.IsAllocated}");
    }
}

但它们会产生不同的结果:

mutableSlab.handle.IsAllocated = False
immutableSlab.handle.IsAllocated = True

GCHandle 是一个可变结构,当您复制它时,它的行为与immutableSlab.

修饰符是否会readonly创建字段的隐藏副本?这是否意味着它不仅仅是编译时检查?我在这里找不到有关此行为的任何信息。这种行为是否记录在案?

4

1 回答 1

32

修饰符是否会readonly创建字段的隐藏副本?

在常规结构类型(构造函数或静态构造函数之外)的只读字段上调用方法或属性首先复制该字段,是的。那是因为编译器不知道属性或方法访问是否会修改您调用它的值。

来自C# 5 ECMA 规范

第 12.7.5.1 节(会员访问,一般)

这对成员访问进行了分类,包括:

  • 如果我识别出一个静态字段:
    • 如果该字段是只读的并且引用发生在声明该字段的类或结构的静态构造函数之外,则结果是一个值,即E中的静态字段I的值。
    • 否则,结果是一个变量,即 E 中的静态字段 I。

和:

  • 如果 T 是结构类型并且 I 标识了该结构类型的实例字段:
    • 如果 E 是一个值,或者如果该字段是只读的并且引用发生在声明该字段的结构的实例构造函数之外,则结果是一个值,即由下式给出的结构实例中的字段 I 的值E.
    • 否则,结果是一个变量,即 E 给定的 struct 实例中的字段 I。

我不确定为什么实例字段部分专门引用结构类型,但静态字段部分没有。重要的部分是表达式是分类为变量还是值。这在函数成员调用中很重要......

第 12.6.6.1 节(函数成员调用,一般)

函数成员调用的运行时处理包括以下步骤,其中 M 是函数成员,如果 M 是实例成员,则 E 是实例表达式:

[...]

  • 否则,如果 E 的类型是值类型 V,并且在 V 中声明或覆盖了 M:
    • [...]
    • 如果 E 未被分类为变量,则创建 E 类型的临时局部变量,并将 E 的值分配给该变量。然后将 E 重新分类为对该临时局部变量的引用。临时变量可以在 M 中作为 this 访问,但不能以任何其他方式访问。因此,只有当 E 是一个真正的变量时,调用者才有可能观察到 M 对此所做的更改。

这是一个独立的示例:

using System;
using System.Globalization;

struct Counter
{
    private int count;

    public int IncrementedCount => ++count;
}

class Test
{
    static readonly Counter readOnlyCounter;
    static Counter readWriteCounter;

    static void Main()
    {
        Console.WriteLine(readOnlyCounter.IncrementedCount);  // 1
        Console.WriteLine(readOnlyCounter.IncrementedCount);  // 1
        Console.WriteLine(readOnlyCounter.IncrementedCount);  // 1

        Console.WriteLine(readWriteCounter.IncrementedCount); // 1
        Console.WriteLine(readWriteCounter.IncrementedCount); // 2
        Console.WriteLine(readWriteCounter.IncrementedCount); // 3
    }
}

这是调用的 IL readOnlyCounter.IncrementedCount

ldsfld     valuetype Counter Test::readOnlyCounter
stloc.0
ldloca.s   V_0
call       instance int32 Counter::get_IncrementedCount()

将字段值复制到堆栈上,然后调用属性......因此字段的值不会最终改变;count它在副本中递增。

将其与读写字段的 IL 进行比较:

ldsflda    valuetype Counter Test::readWriteCounter
call       instance int32 Counter::get_IncrementedCount()

这使得直接在字段上进行调用,因此字段值最终会在属性内发生变化。

当结构很大并且成员不改变它时,制作副本可能效率低下。这就是为什么在 C# 7.2 及更高版本中,readonly修饰符可以应用于结构。这是另一个例子:

using System;
using System.Globalization;

readonly struct ReadOnlyStruct
{
    public void NoOp() {}
}

class Test
{
    static readonly ReadOnlyStruct field1;
    static ReadOnlyStruct field2;

    static void Main()
    {
        field1.NoOp();
        field2.NoOp();
    }
}

使用readonly结构本身的修饰符,field1.NoOp()调用不会创建副本。如果您删除readonly修饰符并重新编译,您会看到它创建了一个副本,就像它在readOnlyCounter.IncrementedCount.

我有一篇 2014 年的博客文章,我发现这些readonly字段在 Noda Time 中导致了性能问题。幸运的是,现在使用readonly结构上的修饰符来解决这个问题。

于 2019-07-01T07:46:00.567 回答