C# 8: 可變結構體中的只讀實例成員
在之前的文章中我們介紹了 C# 中的 只讀結構體(readonly struct)[1] 和與其緊密相關的 in
參數[2]。
今天我們來討論一下從 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();
}
}
在沒有使用 in
參數的方法中,意味著每次調用傳入的是變數的一個新副本; 而在使用 in
修飾符的方法中,每次不是傳遞變數的新副本,而是傳遞同一副本的只讀引用。
DoNormalLoop
方法,參數不加修飾符,傳入一般結構體,調用可變結構體的非只讀方法,這是以前比較常見的做法。DoNormalLoopByIn
方法,參數加in
修飾符,傳入一般結構體,調用可變結構體的非只讀方法。DoReadOnlyLoopByIn
方法,參數加in
修飾符,傳入一般結構體,調用可變結構體的只讀方法。
使用 BenchmarkDotNet 工具測試三個方法的運行時間,結果如下:
Method | Mean | Error | StdDev | Ratio | RatioSD |
---|---|---|---|---|---|
DoNormalLoop | 2.034 s | 0.0392 s | 0.0348 s | 1.00 | 0.00 |
DoNormalLoopByIn | 3.490 s | 0.0667 s | 0.0557 s | 1.71 | 0.03 |
DoReadOnlyLoopByIn | 1.041 s | 0.0189 s | 0.0202 s | 0.51 | 0.01 |
從結果可以看出,當結構體可變時,使用 in
參數調用結構體的只讀方法,性能高於其他兩種; 使用 in
參數調用可變結構體的非只讀方法,運行時間最長,嚴重影響了性能,應該避免這樣調用。
總結
- 當結構體為可變類型時,應將不會引起變化(即不會改變結構體狀態)的成員聲明為
readonly
。 - 當僅調用結構體中的只讀實例成員時,使用
in
參數,可以有效提升性能。 readonly
修飾符在只讀屬性上是必需的。編譯器不會假定 getter 訪問者不修改狀態。因此,必須在屬性上顯式聲明。- 自動屬性可以省略
readonly
修飾符,因為不管readonly
修飾符是否存在,編譯器都將所有自動實現的 getter 視為只讀。 - 不要使用
in
參數調用結構體中的非只讀實例成員,因為會對性能造成負面影響。
作者 : 技術譯民
出品 : 技術譯站
-
//www.cnblogs.com/ittranslator/p/13876180.html C# 中的只讀結構體 ↩︎
-
//www.cnblogs.com/ittranslator/p/13919691.html C# 中的 in 參數和性能分析 ↩︎