C# 8: 可变结构体中的只读实例成员

在之前的文章中我们介绍了 C# 中的 只读结构体(readonly struct) 和与其紧密相关的 in 参数。
今天我们来讨论一下从 C# 8 开始引入的一个特性:可变结构体中的只读实例成员(当结构体可变时,将不会改变结构体状态的实例成员声明为 readonly)。

引入只读实例成员的原因

简单来说,还是为了提升性能
我们已经知道了只读结构体(readonly struct)和 in 参数可以通过减少创建副本,来提高代码运行的性能。当我们创建只读结构体类型时,编译器强制所有成员都是只读的(即没有实例成员修改其状态)。但是,在某些场景,比如您有一个现有的 API,具有公开可访问字段或者兼有可变成员和不可变成员。在这种情形下,不能将类型标记为 readonly (因为这关系到所有实例成员)。

通常,这没有太大的影响,但是在使用 in 参数的情况下就例外了。对于非只读结构体的 in 参数,编译器将为每个实例成员的调用创建参数的防御性副本,因为它无法保证此调用不会修改其内部状态。这可能会导致创建大量副本,并且比直接按值传递结构体时的总体性能更差(因为按值传递只会在传参时创建一次副本)。

看一个例子您就明白了,我们定义这样一个一般结构体,然后将其作为 in 参数传递:

public struct Rect { public float w; public float h; public float Area { get { return w * h; } } } public class SampleClass { public float M(in Rect value) { return value.Area + value.Area; } }

编译后,类 SampleClass 中的方法 M 代码运行逻辑实际上是:

public float M([In] [IsReadOnly] ref Rect value) { Rect rect = value; //防御性副本 float area = rect.Area; rect = value; //防御性副本 return area + rect.Area; } 可变结构体中的只读实例成员

我们把上面的可变结构体 Rect 修改一下,添加一个 readonly 方法 GetAreaReadOnly,如下:

public struct Rect { public float w; public float h; public float Area { get { return w * h; } } public readonly float GetAreaReadOnly() { return Area; //警告 CS8656 从 "readonly" 成员调用非 readonly 成员 "Rect.Area.get" 将产生 "this" 的隐式副本。 } }

此时,代码是可以通过编译的,但是会提示一条这样的的警告:从 "readonly" 成员调用非 readonly 成员 "Rect.Area.get" 将产生 "this" 的隐式副本。
翻译成大白话就是说,我们在只读方法 GetAreaReadOnly 中调用了非只读 Area 属性将会产生 "this" 的防御性副本。用代码演示一下编译后方法 GetAreaReadOnly 的方法体运行逻辑实际上是:

[IsReadOnly] public float GetAreaReadOnly() { Rect rect = this; //防御性副本 return rect.Area; }

所以为了避免创建多余的防御性副本而影响性能,我们应该给只读方法体中调用的属性或方法都加上 readonly 修饰符(在本例中,即给属性 Area 加上 readonly 修饰符)。

调用可变结构体中的只读实例成员

我们将上面的示例再修改一下:

public struct Rect { public float w; public float h; public readonly float Area { get { return w * h; } } public readonly float GetAreaReadOnly() { return Area; } public float GetArea() { return Area; } } public class SampleClass { public float CallGetArea(Rect vector) { return vector.GetArea(); } public float CallGetAreaIn(in Rect vector) { return vector.GetArea(); } public float CallGetAreaReadOnly(in Rect vector) { //调用可变结构体中的只读实例成员 return vector.GetAreaReadOnly(); } }

类 SampleClass 中定义三个方法:

第一个方法是以前我们常见的调用方式;

第二个以 in 参数传入可变结构体,调用非只读方法(可能修改结构体状态的方法);

第三个以 in 参数传入可变结构体,调用只读方法。

我们来重点看一下第二个和第三个方法有什么区别,还是把它们的 IL 代码逻辑翻译成易懂的执行逻辑,:

public float CallGetAreaIn([In] [IsReadOnly] ref Rect vector) { Rect rect = vector; //防御性副本 return rect.GetArea(); } public float CallGetAreaReadOnly([In] [IsReadOnly] ref Rect vector) { return vector.GetAreaReadOnly(); }

可以看出,CallGetAreaReadOnly 在调用结构体的(只读)成员方法时,相对于 CallGetAreaIn (调用结构体的非只读成员方法)少创建了一次本地的防御性副本,所以在执行性能上应该是有优势的。

只读实例成员的性能分析

性能的提升在结构体较大的时候比较明显,所以在测试的时候为了能够突出三个方法性能的差异,我在 Rect 结构体中添加了 30 个 decimal 类型的属性,然后在类 SampleClass 中添加了三个测试方法,代码如下所示:

public struct Rect { public float w; public float h; public readonly float Area { get { return w * h; } } public readonly float GetAreaReadOnly() { return Area; } public float GetArea() { return Area; } public decimal Number1 { get; set; } public decimal Number2 { get; set; } //... public decimal Number30 { get; set; } } public class SampleClass { const int loops = 50000000; Rect rectInstance; public SampleClass() { rectInstance = new Rect(); } [Benchmark(Baseline = true)] public float DoNormalLoop() { float result = 0F; for (int i = 0; i < loops; i++) { result = CallGetArea(rectInstance); } return result; } [Benchmark] public float DoNormalLoopByIn() { float result = 0F; for (int i = 0; i < loops; i++) { result = CallGetAreaIn(in rectInstance); } return result; } [Benchmark] public float DoReadOnlyLoopByIn() { float result = 0F; for (int i = 0; i < loops; i++) { result = CallGetAreaReadOnly(in rectInstance); } return result; } public float CallGetArea(Rect vector) { return vector.GetArea(); } public float CallGetAreaIn(in Rect vector) { return vector.GetArea(); } public float CallGetAreaReadOnly(in Rect vector) { return vector.GetAreaReadOnly(); } }

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wpssdg.html