撰寫安全且有效率的 C# 程式碼

C# 提供的功能可讓您以更佳的效能撰寫可驗證的安全程式碼。 若您小心套用這些技術,則需要不安全程式碼的案例將減少。 這些功能可讓使用實值型別參考作為方法引數和方法傳回值的過程變得更為容易。 當以安全的方式執行時,這些技術可最小化複製實值型別的次數。 透過使用實值型別,您可以最小化傳遞的配置數及記憶體回收數。

使用實值型別的一個優點是它們通常可避免堆積配置。 相對地,缺點則是它們是以實值複製。 這種取捨讓優化對大量資料運作的演算法會比較困難。 本文所強調的語言功能提供使用實值型別參考來啟用安全有效程式碼的機制。 若能善用這些功能,即可同時最小化配置及複製作業。

本文中的一些指引是指一律建議的程式碼撰寫做法,不僅適用于效能優勢。 正確表達設計意圖時, readonly 請使用 關鍵字:

本文也說明當您執行分析工具併發現瓶頸時,建議使用的一些低階優化:

這些技術會平衡兩個競爭目標:

  • 將堆積上的配置降到最低。

    屬於參考型別的變數會保存記憶體中位置的參考,並配置在 Managed 堆積上。 當參考型別當做引數傳遞至方法或從方法傳回時,只會複製參考。 每個新物件都需要新的配置,之後必須回收。 垃圾收集需要一段時間。

  • 將值的複製降到最低。

    屬於 實值型 別的變數會直接包含其值,而且值通常會在傳遞至方法或從方法傳回時複製。 此行為包括呼叫反覆運算器和結構的非同步實例方法時複製 的值 this 。 複製作業需要時間,視類型的大小而定。

本文使用下列 3D 點結構的範例概念來說明其建議:

public struct Point3D
{
    public double X;
    public double Y;
    public double Z;
}

不同範例會使用此概念不同的實作。

將不可變的結構宣告為 readonly

readonly struct宣告 ,表示類型為不可變。 修飾 readonly 詞會通知編譯器您的意圖是建立不可變的類型。 編譯器會實行包含下列規則的設計決策:

  • 所有欄位成員都必須是唯讀的。
  • 所有屬性都必須是唯讀屬性,包含自動實作的屬性。

這兩項規則便足以確保沒有任何 readonly struct 的成員會修改該結構狀態。 struct 是固定的。 Point3D 結構可定義為固定結構,如下列範例所示:

readonly public struct ReadonlyPoint3D
{
    public ReadonlyPoint3D(double x, double y, double z)
    {
        this.X = x;
        this.Y = y;
        this.Z = z;
    }

    public double X { get; }
    public double Y { get; }
    public double Z { get; }
}

每當您的設計意圖是要建立固定實值型別時,請遵循此建議。 任何效能上的改善都會帶來效益。 關鍵字 readonly struct 會清楚表達您的設計意圖。

宣告 readonly 可變動結構的成員

當結構類型為可變動時,請將不會修改狀態的成員宣告為readonly 成員

請考慮需要 3D 點結構但必須支援可變動性的不同應用程式。 下列 3D 點結構版本只會將 修飾詞新增 readonly 至未修改結構的成員。 當您的設計必須支援某些成員對結構的修改時,請遵循此範例,但您仍希望對某些成員強制執行 readonly 的優點:

public struct Point3D
{
    public Point3D(double x, double y, double z)
    {
        _x = x;
        _y = y;
        _z = z;
    }

    private double _x;
    public double X
    {
        readonly get => _x;
        set => _x = value;
    }

    private double _y;
    public double Y
    {
        readonly get => _y;
        set => _y = value;
    }

    private double _z;
    public double Z
    {
        readonly get => _z;
        set => _z = value;
    }

    public readonly double Distance => Math.Sqrt(X * X + Y * Y + Z * Z);

    public readonly override string ToString() => $"{X}, {Y}, {Z}";
}

上述範例顯示許多您可以套用 readonly 修飾詞的位置:方法、屬性和屬性存取子。 如果您使用自動實作的屬性,編譯器會將 readonly 修飾詞新增至存取子, get 以取得讀寫屬性。 編譯器會將 readonly 修飾詞新增至只有 get 存取子之屬性的自動實作屬性宣告。

readonly 修飾詞新增至未變動狀態的成員提供兩個相關優點。 首先,編譯器會強制執行您的意圖。 該成員無法改變結構的狀態。 其次,編譯器不會在存取 readonly 成員時建立參數的 in防禦複本。 編譯器可以安全地進行這項優化,因為它保證 struct 成員不會修改 readonly

Use ref readonly return 語句

ref readonly當下列兩個條件成立時,請使用傳回:

  • 傳回值 struct 大於 IntPtr.Size
  • 儲存體存留期大於傳回值的方法。

當所傳回值不屬於傳回方法的區域時,您可以以參考的型式傳回值。 以參考的型式傳回,表示只會複製參考,而非結構。 在下列範例中,Origin 屬性無法使用 ref 傳回,因為傳回的值是區域變數:

public Point3D Origin => new Point3D(0,0,0);

但是,下列屬性定義則可以參考的型式傳回,因為傳回值是靜態成員:

public struct Point3D
{
    private static Point3D origin = new Point3D(0,0,0);

    // Dangerous! returning a mutable reference to internal storage
    public ref Point3D Origin => ref origin;

    // other members removed for space
}

您不希望呼叫者修改原點,因此您應以 ref readonly 的方式傳回值:

public struct Point3D
{
    private static Point3D origin = new Point3D(0,0,0);

    public static ref readonly Point3D Origin => ref origin;

    // other members removed for space
}

傳回 ref readonly 可讓您避免複製較大的結構,並保留您內部資料成員的不變性。

在呼叫位置,呼叫者會選擇使用 Origin 屬性作為 ref readonly 或作為值:

var originValue = Point3D.Origin;
ref readonly var originReference = ref Point3D.Origin;

在上述程式碼中的第一項指派,會建立 Origin 常數的複本,並指派該複本。 第二項指派則會指派參考。 請注意,readonly 修飾詞必須是變數宣告的一部分。 其參考項目無法修改。 如果嘗試修改,會導致編譯時期錯誤。

readonly 修飾詞在 originReference 的宣告上是必要的。

編譯器會強制呼叫者不可修改該參考。 若嘗試直接指派到值,則會產生編譯時間錯誤。 在其他情況下,編譯器會配置 防禦性複本 ,除非它可以安全地使用唯讀參考。 靜態分析規則會判斷結構是否可以修改。 當結構為 readonly struct 或 成員是 readonly 結構的成員時,編譯器不會建立防禦性複本。 不需要防禦性複本,即可將結構傳遞為 in 引數。

in使用參數修飾詞

下列各節說明修飾詞的 in 用途、如何使用它,以及何時使用它來進行效能優化:

outrefin 關鍵字

關鍵字 in 會補充 和 out 關鍵字, ref 以傳址方式傳遞引數。 in關鍵字會指定以傳址方式傳遞引數,但呼叫的方法不會修改值。 in修飾詞可以套用至任何接受參數的成員,例如方法、委派、Lambda、區域函式、索引子和運算子。

新增 關鍵字後 in ,C# 會提供完整的詞彙來表達您的設計意圖。 當您未在下列方法簽章中指定下列任一修飾詞時,會在傳遞至呼叫的方法時,複製實值型別。 每個修飾詞都會指定以參考型式來傳遞變數以避免複製。 每個修飾詞皆表示不同之目的:

  • out:此方法會設定用作為此參數的引數值。
  • ref:這個方法可能會修改做為此參數使用之引數的值。
  • in:這個方法不會修改做為此參數使用的引數值。

當您新增 in 修飾詞來利用參考傳遞引數時,即表明您的設計目的是利用參考傳遞引數,來避免不必要的複製。 您不打算修改用來作為該引數的物件。

in 修飾詞也可於其他方面補足 outref。 您無法針對差異僅為是否出現 inoutref 的方法來建立其多載。 這些新規則沿用一直以來為 outref 參數所定義的相同行為。 與 outref 修飾詞相似,實值型別並非 Boxed,因為已套用了 in 修飾詞。 參數的另一個功能 in 是您可以將常值或常數用於參數的引數 in

in修飾詞也可以與參考型別或數值搭配使用。 不過,在這些情況下的優點是最少的,如果有的話。

編譯器強制 in 引數唯讀性質的方式有數種。 首先,呼叫的方法不可直接指派到 in 參數。 當該值為 struct 類型時,該方法不可直接指派到 in 參數的任何欄位。 此外,您也無法使用 refout 修飾詞,將 in 參數傳遞至任何方法。 這些規則適用於所有 in 參數的欄位,提供的欄位為 struct 類型,且參數也為 struct 類型。 實際上,這些規則適用於成員存取的多個層級,提供所有成員存取層級的類型為 structs。 編譯器會 struct 強制執行傳遞為 in 引數的類型,而且當做其他方法的引數使用時,其 struct 成員是唯讀變數。

針對大型結構使用 in 參數

您可以將 修飾詞套用 in 至任何 readonly struct 參數,但這個做法可能只會針對大於 IntPtr.Size 的值型別改善效能。 對於簡單類型 (,例如 、、、、、、 floatdoubleulongdecimalcharuintlongenumbool 類型) ,任何潛在的效能提升都最少。 intushortshortbytesbyte 某些簡單類型,例如 decimal 大小為 16 位元組的簡單類型大於 4 位元組或 8 位元組參考,但不足以在大部分情況下產生可測量的效能差異。 對於小於 IntPtr.Size 的類型使用傳遞參考,效能可能會降低。

下列程式碼示範計算 3D 空間中不同兩點間距離的方法。

private static double CalculateDistance(in Point3D point1, in Point3D point2)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

引數為雙結構,每個結構皆包含三個雙精度浮點數。 一個雙精度浮點數是 8 個位元組,因此每個引數是 24 個位元組。 透過指定 in 修飾詞,將 4 或 8 個位元組參考傳遞到這些引數,位元組大小取決於電腦的架構。 大小的差異很小,但當您的應用程式使用許多不同的值在緊密迴圈中呼叫此方法時,可能會加總。

不過,應該測量任何低階優化的影響,例如使用 in 修飾詞來驗證效能優勢。 例如,您可能會認為在 inGuid 參數上使用 會很有説明。 此 Guid 類型的大小為 16 位元組,大小為 8 位元組參考的兩倍。 但是,這種小差異不太可能會產生可測量的效能優勢,除非它是應用程式在時間關鍵熱路徑中的方法。

在通話月臺選擇性使用 in

ref不同于 或 out 參數,您不需要在呼叫月臺套用 in 修飾詞。 下列程式碼顯示呼叫 方法的 CalculateDistance 兩個範例。 第一種使用兩個利用參考傳遞的區域變數。 第二種則包含建立為方法呼叫之一部份的暫存變數。

var distance = CalculateDistance(pt1, pt2);
var fromOrigin = CalculateDistance(pt1, new Point3D());

in 略呼叫月臺上的 修飾詞會通知編譯器,因為下列任何原因,允許它複製引數:

  • 從引數型別至參數型別存在隱含轉換,但沒有識別轉換。
  • 引數為運算式,但不具有已知的儲存體變數。
  • 存在受 in 存在與否影響的多載。 在此情況下,傳值多載是最佳相符項目。

當您更新現有的程式碼以使用唯讀參考引數時,這些規則相當實用。 在呼叫的方法內,您可以呼叫任何使用 by-value 參數的實例方法。 在那些執行個體中,會建立 in 參數的複本。

因為編譯器可為任何 in 參數建立暫存變數,所以您也可以為任何 in 參數指定預設值。 下列程式碼會將來源 (點 0,0,0) 指定為第二個點的預設值:

private static double CalculateDistance2(in Point3D point1, in Point3D point2 = default)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

若要強制編譯器以參考型式來傳遞唯讀引數,請在呼叫位置於引數上指定 in 修飾詞,如下列程式碼所示:

distance = CalculateDistance(in pt1, in pt2);
distance = CalculateDistance(in pt1, new Point3D());
distance = CalculateDistance(pt1, in Point3D.Origin);

此行為能在可提升效能時,使在大型程式碼基底中採用 in 參數一段時間更加輕鬆。 您必須先將 in 修飾詞新增至方法簽章。 然後,您可以在呼叫月臺新增 in 修飾詞並建立 readonly struct 類型,讓編譯器避免在更多位置建立參數的 in 防禦複本。

避免防禦性複本

struct只有在使用 readonly 修飾詞宣告參數時,才會將 當做參數的引數 in 傳遞,否則方法只會 readonly 存取結構的成員。 否則,編譯器必須在許多情況下建立 防禦性複本 ,以確保引數不會變動。 請考慮下列範例,該範例會計算原點至 3D 點的距離:

private static double CalculateDistance(in Point3D point1, in Point3D point2)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

結構 Point3D不是 唯讀結構。 此方法的主體中有六個不同屬性存取呼叫。 在第一次檢查時,您可能會認為這些存取是安全的。 畢竟,get 存取子應該不會修改物件的狀態。 但是沒有任何語言規則強制該行為。 它只是一個常見的慣例。 任何型別都可實作修改內部狀態的 get 存取子。

如果沒有某些語言保證,編譯器必須先建立引數的暫存複本,再呼叫未標記修飾 readonly 詞的任何成員。 暫存位置會在堆疊上建立,引數的值則會複製到暫存位置,而該值則會針對每個成員存取,作為 this 引數複製到堆疊。 在許多情況下,當引數類型不是 readonly struct 且方法呼叫未標示 readonly 的成員時,這些複本會危害效能,讓傳遞值的速度比傳遞唯讀參考快。 如果您將所有未修改結構狀態的方法標示為 readonly ,編譯器可以安全地判斷結構狀態未修改,而且不需要防禦性複本。

如果距離計算使用不可變的結構, ReadonlyPoint3D 則不需要暫存物件:

private static double CalculateDistance3(in ReadonlyPoint3D point1, in ReadonlyPoint3D point2 = default)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

當您呼叫 的成員時,編譯器會產生更有效率的程式 readonly struct 代碼。 this 參考 (而非接收器的複本) 一律都是以參考型式傳遞至成員方法的 in 參數。 當您使用 readonly struct 作為 in 引數時,這項最佳化可避免進行複製。

請勿傳遞可為 Null 的實值型別作為 in 引數。 此 Nullable<T> 類型不會宣告為唯讀結構。 這表示,編譯器編譯器必須針對使用參數宣告上 in 修飾詞傳遞給方法之任何可為 Null 的實值型別引數來產生防禦性複本。

您可以在 GitHub 上的範例存放庫中使用BenchmarkDotNet來示範效能差異的範例程式。 它會比較以值型式和以參考型式傳遞可變動結構,以及以值型式和以參考型式傳遞固定結構的差異。 使用固定結構並以參考型式傳遞的速度最快。

使用 ref struct 類型

ref struct使用 或 ,例如 Span<T>readonly ref structReadOnlySpan<T> ,以位元組序列來處理記憶體區塊。 範圍所使用的記憶體受限於單一堆疊框架。 此限制可讓編譯器進行幾項最佳化。 此功能的主要動機是 Span<T> 及相關的結構。 您可以透過使用新的及已更新 .NET API,利用 Span<T> 型別以透過這些增強功能來改善效能。

宣告結構為 readonly ref 會結合 ref structreadonly struct 宣告的優點與限制。 唯讀範圍所使用的記憶體會限制在單一堆疊框架,且唯讀範圍使用的記憶體無法修改。

當您使用以 stackalloc 建立的記憶體,或使用來自 Interop API 的記憶體時,可能會有類似需求。 您可依照那些需求定義自己的 ref struct 類型。

使用 nintnuint 類型

原生大小的整數類型 是 32 位進程中的 32 位整數,或 64 位進程中的 64 位整數。 將它們用於 Interop 案例、低階程式庫,以及在廣泛使用整數數學的案例中優化效能。

結論

使用實值型別可將配置作業的次數降至最低:

  • 實數值型別的儲存體會針對區域變數和方法引數配置堆疊配置。
  • 作為其他物件成員的實值型別,其儲存體會作為該物件的一部分進行配置,而非個別配置。
  • 實值型別傳回值的儲存體是所配置堆疊。

在相同情況下,與參考型別的對比是:

  • 參考類型的儲存體是配置給區域變數和方法引數的堆積。 參考會儲存在堆疊上。
  • 作為其他物件成員的參考型別,其儲存體會在堆積上個別配置。 包含該型別的物件會儲存參考。
  • 參考型別傳回值的儲存體是所配置堆積。 該儲存體的參考會儲存在堆疊上。

最小化配置也包含了取捨。 您會在 struct 的大小大於參考的大小時複製更多記憶體。 參考通常是 64 位元或 32 位元,取決於目標電腦的 CPU。

這些取捨通常只會對效能造成極小的影響。 但是,針對大型結構或較大的集合,對效能產生的影響便會增加。 在緊密迴圈或程式的最忙碌路徑中,影響可能會很大。

這些 C# 語言的增強功能專為注重效能的演算法設計,對於這些演算法來說,最小化記憶體配置在達到所需效能的過程中扮演了重要角色。 您會發現到您不常在您撰寫的程式碼中使用這些功能。 但是,您已透過 .NET 採用了這些增強功能。 隨著更多 API 利用這些功能,您會看到應用程式的效能有所改善。

另請參閱