将结构体声明为只读(C# 7.2)
in
参数的主要作用是减少对结构体的复制从而提升性能。听起来很不错,但是关于C#,还有一个隐蔽的阻碍,需要格外小心。本节首先明确问题,然后介绍C# 7.2是如何解决它的。
一、 背景:只读变量的隐式复制
长期以来,C#都对结构体进行隐式复制。虽然语言规范中写明了这一点,但如果不是在Noda Time项目中忘记给一个字段添加只读属性而导致性能异常提升,我大概完全不会注意到这一点。
看一个简单的例子。首先声明一个有3个只读属性的结构体YearMonthDay
,3个属性分别为Year
、Month
和Day
。这里不采用内建的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项目,也可能影响读者的某些代码。
三、 XML序列化是隐式读写属性
目前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)
评论
发表评论