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

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

本文中的許多範例程式碼都使用了 C# 7.2 新增功能。 若要使用這些功能,請確定您的專案未設定為使用較早的版本。 如需詳細資訊,請參閱 設定語言版本

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

本文中的部分指引是指一律建議的程式碼撰寫做法,而不只是為了效能優勢。 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 可變結構的成員

在 c # 8.0 和更新版本中,當結構類型可變動時,請宣告不會將狀態修改為 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 至不會改變狀態的成員會提供兩個相關的優點。 首先,編譯器會強制執行您的意圖。 該成員無法改變結構的狀態。 其次,在存取成員時,編譯器不會建立參數的 防禦性複本 in readonly 。 編譯器可以安全地進行這項優化,因為它可確保 struct 成員不會修改 readonly

Use ref readonly return 語句

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

  • 傳回值大於 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 參考。 靜態分析規則會判斷結構是否可以修改。 當結構為 readonly struct 或成員是結構的成員時,編譯器不會建立防禦性複本 readonly 。 不需要防禦性複本就能將結構當作 in 引數傳遞。

使用 in 參數修飾詞

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

outrefin 關鍵字

in關鍵字 ref 可補充和關鍵字, out 以傳址方式傳遞引數。 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 。 針對簡單類型 (例如、、、、、、、、、、 sbyte byte short ushort int uint long ulong char float double 、、 decimalbool enum 類型) ,任何潛在的效能提升都很基本。 某些簡單類型(例如 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

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

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

省略 in 呼叫位置上的修飾詞會通知編譯器,基於下列原因,可以為引數建立複本:

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

當您更新現有的程式碼以使用唯讀參考引數時,這些規則相當實用。 在呼叫的方法內,您可以呼叫任何使用傳值參數的實例方法。 在那些執行個體中,會建立 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 以修飾詞宣告, in readonly 或方法只存取結構的成員時,才會將做為參數的引數傳遞 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 structthis 參考 (而非接收器的複本) 一律都是以參考型式傳遞至成員方法的 in 參數。 當您使用 readonly struct 作為 in 引數時,這項最佳化可避免進行複製。

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

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

使用 ref struct 類型

使用 ref struct 或( readonly ref struct 例如 Span<T> 或), ReadOnlySpan<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 使用這些功能,您將會看到應用程式的效能改進。

另請參閱