将结构体声明为只读(C# 7.2)

in参数的主要作用是减少对结构体的复制从而提升性能。听起来很不错,但是关于C#,还有一个隐蔽的阻碍,需要格外小心。本节首先明确问题,然后介绍C# 7.2是如何解决它的。

长期以来,C#都对结构体进行隐式复制。虽然语言规范中写明了这一点,但如果不是在Noda Time项目中忘记给一个字段添加只读属性而导致性能异常提升,我大概完全不会注意到这一点。

看一个简单的例子。首先声明一个有3个只读属性的结构体YearMonthDay,3个属性分别为YearMonthDay。这里不采用内建的DateTime类型,到后面自然就知道原因了。代码清单13-15是关于YearMonthDay的,相当简单。(这段代码仅用作展示,因此并没有任何校验逻辑。)

代码清单13-15 一个简单的year/month/day结构体

public struct YearMonthDay
{
public int Year { get; }
public int Month { get; }
public int Day { get; }

public YearMonthDay(int year, int month, int day) =>
    (Year, Month, Day) = (year, month, day);
}

然后创建一个包含两个YearMonthDay字段的类:一个只读,另一个可读写。之后会访问这两个字段的Year属性。

代码清单13-16 通过只读或读写字段访问属性

class ImplicitFieldCopy
{
private readonly YearMonthDay readOnlyField =
    new YearMonthDay(2018, 3, 1);
private YearMonthDay readWriteField =
    new YearMonthDay(2018, 3, 1);

public void CheckYear()
{
    int readOnlyFieldYear = readOnlyField.Year;
    int readWriteFieldYear = readWriteField.Year;
}
}

这两个属性访问操作所生成的IL代码虽然只是略有差别,但意义重大。下面是只读字段的IL代码,简单起见,略去了相应的命名空间:

ldfld valuetype YearMonthDay ImplicitFieldCopy::readOnlyField
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()

这段代码首先载入字段的值,然后将其复制到栈内存中,之后才调用了get_Year()成员,这个正是Year属性的getter方法。与之相对的读写字段的IL代码如下:

ldflda valuetype YearMonthDay ImplicitFieldCopy::readWriteField
call instance int32 YearMonthDay::get_Year()

其中使用了ldflda指令来将字段的地址加载到栈内存,而ldfld指令是把值加载到栈内存中。当然,这只是IL代码,还不是计算机最终执行的指令。有时JIT编译器可以优化这部分,但是就Noda Time项目来说,当把字段声明为读写属性时(通过一个attribute来解释为什么不是只读),性能提升显著。

编译器之所以复制字段,就是为了防止只读字段在属性(或者方法)中被修改。只读字段的本意就是禁止修改其值。如果readOnlyField.SomeMethod()可以修改该字段就不正常了。按照C#的语言设计,任何属性setter都会修改数据,因此禁止setter访问器操作只读字段。可即便是getter访问器,也可能会修改字段值,所以为字段备份是安全之举。

隐式复制只影响值类型

需要注意:对于只读字段,如果它是引用类型,那么在方法内可以修改该引用类型所指向的对象。例如有一个只读的StringBuilder字段,对该StringBuilder依然可以执行append操作。该字段的值是引用,只要引用本身不被改变即可。

这部分着重讨论类似于decimal或者DateTime这样的值类型,至于字段属于类还是结构体,无关紧要。

在C# 7.2之前,只有字段可以设为只读,现在又增加了ref readonly局部变量以及in参数。下面编写一个方法,该方法根据其值参数来打印年月日信息。

private void PrintYearMonthDay(YearMonthDay input) =>
Console.WriteLine($"{input.Year} {input.Month} {input.Day}");

这段代码的IL代码使用了栈内存中已有的地址。每个属性访问都很简单:

ldarga.s input
call instance int32 Chapter13.YearMonthDay::get_Year()

它不创建任何额外的复制。它假定如果属性修改了值,那么input变量的值也可以修改,因为它是一个读写属性的变量。但是如果给参数添加in修饰符,情况就不同了:

private void PrintYearMonthDay(in YearMonthDay input) =>
Console.WriteLine($"{input.Year} {input.Month} {input.Day}");

这样IL代码中的每个属性访问就变成了:

ldarg.1
ldobj Chapter13.YearMonthDay
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()

ldobj指令从参数地址中把值复制到了栈内存中。我们本想使用in参数来避免调用方的第一次复制操作,结果方法内部增加了3次复制操作,对于ref readonly局部变量也是一样,事与愿违。读者可能已经猜到了,C# 7.2给出了一个解决方案:使用只读结构体。

回顾一下前面的重点,C#编译器之所以要复制只读值类型的变量,是为了防止代码篡改该变量的值。如果结构体可以保证变量的值不会被修改会怎么样呢?毕竟大部分结构体是不可变结构体。在C# 7.2中,可以在声明结构体时添加readonly修饰符来实现这一目标。

下面使用readonly结构体来改写前面年月日的代码。这段代码已经满足了相关语义要求,只需直接添加readonly修饰符即可:

public readonly struct YearMonthDay
{
public int Year { get; }
public int Month { get; }
public int Day { get; }

public YearMonthDay(int year, int month, int day) =>
    (Year, Month, Day) = (year, month, day);
}

无须修改使用结构体的代码,只需在声明结构体时做一点小小的改动,PrintYearMonthDay(in YearMonthDay input)生成的IL代码就变得高效了。每个属性访问的代码如下:

ldarg.1
call instance int32 YearMonthDay::get_Year()

终于实现了不复制整个结构体这一目标。

在本书附带的源码中,这段代码位于一个单独的结构体声明ReadOnlyYearMonthDay中。源码之所以把只读结构体的声明单独拿出来,旨在对比前后两个声明。读者编写代码时可以直接在现有结构体中添加readonly,这样做不会造成任何源码和二进制码的兼容问题。如果反过来,就可能是破坏性修改:比如移除现有的readonly修饰符并修改现有的某个成员值,那么之前编译的代码(把结构体按照只读处理)将修改只读变量,这可糟了。

只有当目标结构体本身是只读的时,才能为其添加readonly修饰符,因此必须满足以下条件。

  • 每个实例字段和自动实现的实例属性必须是只读的。静态字段和属性可以不做要求。
  • 只能在构造器中为this赋值。用语言规范中的术语来说:this在构造器中按照out参数来处理,在普通结构体成员中按照ref参数来处理,在只读结构体成员中按照in参数来处理。

如果当前结构体想按照只读处理,那么为它添加readonly修饰符就可以让编译器帮忙检查是否存在修改结构体的代码。用户自定义的结构体大都可以正常应用该特性。不过依然存在一个潜在问题,该问题影响了Noda Time项目,也可能影响读者的某些代码。

目前Noda Time中的大部分结构体实现自IXmlSerializable接口,然而XML序列化的定义对于编写只读结构体很不友好。Noda Time中的实现一般形式如下:

void IXmlSerializable.ReadXml(XmlReader reader)
{
var pattern = /* some suitable text parsing pattern for the type */;
var text = /* extract text from the XmlReader */;
this = pattern.Parse(text).Value;
}

能发现其中的问题吗?最后一行代码是把结果赋值给this,这样就不能把结构体声明为readonly了,实为困扰。目前对此只有3个选择。

  • 放任不管,但这样的话in参数和ref readonly局部变量的效率会降低。
  • 在Noda Time的下一个主版本中移除XML序列化。
  • ReadXml中使用非安全的代码破坏readonly规则。使用System.Runtime.CompilerServices包可以简化这一过程。

以上选项都不太完美,也没有什么办法可以同时解决上述3个问题。目前我选择接受实现IXmlSerializable接口的结构体天生不能使用只读属性。当然,在实现结构体时还可能遇到其他接口,也像IXmlSerializable一样不支持只读,但IXmlSerializable肯定更常见。

好在大部分读者不会遇到这个问题。我认为只要可以把结构体声明为只读,就尽量这么做。但请记住,这项改动不可逆。只有在能够保证将来即使移除readonly修饰符也能重新编译调用代码的情况下,才可以为现有结构体添加readonly修饰符。下面要介绍的特性为C#语言的一致性添上了最后一块砖:为结构体的扩展方法添加和实例方法相同的功能。

(完)

相关阅读:


C# 回顾:ref 知多少

C# ref 局部变量和 ref return

in 参数(C# 7.2)

将结构体声明为只读(C# 7.2)

使用 ref 参数或者in参数的扩展方法(C# 7.2)

类 ref 结构体(C# 7.2)

评论

此博客中的热门博文

in 参数(C# 7.2)

C# ref 局部变量和 ref return

类 ref 结构体(C# 7.2)