C# 8: 可變結構體中的只讀例項成員

技術譯民發表於2020-11-11

在之前的文章中我們介紹了 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 引數呼叫結構體中的非只讀例項成員,因為會對效能造成負面影響。

作者 : 技術譯民
出品 : 技術譯站


  1. https://www.cnblogs.com/ittranslator/p/13876180.html C# 中的只讀結構體 ↩︎

  2. https://www.cnblogs.com/ittranslator/p/13919691.html C# 中的 in 引數和效能分析 ↩︎

相關文章