教學課程:探索主要建構函式

C# 12 引進主要建構函式,這是一種簡潔的語法,可宣告其參數在型別主體中任何位置都可以使用的建構函式。

在本教學課程中,您將了解:

  • 在型別中宣告主要建構函式的時機
  • 如何從其他建構函式呼叫主要建構函式
  • 如何在型別的成員中使用主要建構函式參數
  • 儲存主要建構函式參數的位置

必要條件

您需要設定機器執行 .NET 8 或更新版本,包括 C# 12 或更新版本的編譯器。 從 Visual Studio 2022 17.7 版.NET 8 SDK 開始,可以使用 C# 12 編譯器。

主要建構函式

您可以將參數新增至 structclass 宣告,以建立主要建構函式。 主要建構函式參數在類別定義的範圍內。 請務必將主要建構函式參數視為參數,即使它們在整個類別定義範圍內也一樣。 有幾個規則可以釐清它們是參數:

  1. 如果不需要主要建構函式參數,就不會儲存。
  2. 主要建構函式參數不是類別的成員。 例如,以 this.param 無法存取命名為 param 的主要建構函式參數。
  3. 可以指派主要建構函式參數。
  4. 主要建構函式參數除了在 record 中,否則不會成為屬性。

這些規則與任何方法的參數相同,包括其他建構函式宣告。

主要建構函式參數最常見的用法如下:

  1. 做為 base() 建構函式調用的引數。
  2. 初始化成員欄位或屬性。
  3. 參考執行個體成員中的建構函式參數。

類別的所有其他建構函式都必須透過 this() 建構函式調用直接或間接呼叫主要建構函式。 該規則可確保在型別主體中的任何位置都能指派主要建構函式參數。

初始化屬性

下列程式碼會初始化兩個從主要建構函式參數計算的唯讀屬性:

public readonly struct Distance(double dx, double dy)
{
    public readonly double Magnitude { get; } = Math.Sqrt(dx * dx + dy * dy);
    public readonly double Direction { get; } = Math.Atan2(dy, dx);
}

上述程式碼示範用來初始化計算唯讀屬性的主要建構函式。 MagnitudeDirection 的欄位初始設定式會使用主要建構函式參數。 結構中的其他位置都不會使用主要建構函式參數。 上述結構就像您撰寫下列程式碼一樣:

public readonly struct Distance
{
    public readonly double Magnitude { get; }

    public readonly double Direction { get; }

    public Distance(double dx, double dy)
    {
        Magnitude = Math.Sqrt(dx * dx + dy * dy);
        Direction = Math.Atan2(dy, dx);
    }
}

當您需要引數來初始化欄位或屬性時,新功能可讓您更輕鬆地使用欄位初始設定式。

建立可變狀態

上述範例使用主要建構函式參數初始化唯讀屬性。 屬性不是唯讀時,也可以使用主要建構函式。 請考慮下列程式碼:

public struct Distance(double dx, double dy)
{
    public readonly double Magnitude => Math.Sqrt(dx * dx + dy * dy);
    public readonly double Direction => Math.Atan2(dy, dx);

    public void Translate(double deltaX, double deltaY)
    {
        dx += deltaX;
        dy += deltaY;
    }

    public Distance() : this(0,0) { }
}

在上述範例中,Translate 方法會變更 dxdy 元件。 這需要在存取時計算 MagnitudeDirection 屬性。 => 運算子會指定以表示式為主體的 get 存取子,而 = 運算子則會指定初始設定式。 此版本會將無參數建構函式新增至結構。 無參數建構函式必須叫用主要建構函式,以便初始化所有主要建構函式參數。

在上述範例中,會在方法中存取主要建構函式屬性。 因此,編譯器會建立隱藏欄位來代表每個參數。 下列程式碼顯示編譯器產生的內容。 實際的欄位名稱是有效的 CIL 識別碼,但不是有效的 C# 識別碼。

public struct Distance
{
    private double __unspeakable_dx;
    private double __unspeakable_dy;

    public readonly double Magnitude => Math.Sqrt(__unspeakable_dx * __unspeakable_dx + __unspeakable_dy * __unspeakable_dy);
    public readonly double Direction => Math.Atan2(__unspeakable_dy, __unspeakable_dx);

    public void Translate(double deltaX, double deltaY)
    {
        __unspeakable_dx += deltaX;
        __unspeakable_dy += deltaY;
    }

    public Distance(double dx, double dy)
    {
        __unspeakable_dx = dx;
        __unspeakable_dy = dy;
    }
    public Distance() : this(0, 0) { }
}

請務必了解第一個範例不需要編譯器建立欄位來儲存主要建構函式參數的值。 第二個範例使用方法內的主要建構函式參數,因此需要編譯器為其建立儲存體。 只有在型別成員主體中存取該參數時,編譯器才會為任何主要建構函式建立儲存體。 否則,主要建構函式參數不會儲存在物件中。

相依性插入

主要建構函式的另一個常見用途是指定相依性插入的參數。 下列程式碼會建立需要服務介面的簡單控制器,以供其使用:

public interface IService
{
    Distance GetDistance();
}

public class ExampleController(IService service) : ControllerBase
{
    [HttpGet]
    public ActionResult<Distance> Get()
    {
        return service.GetDistance();
    }
}

主要建構函式清楚指出類別中所需的參數。 使用主要建構函式參數的方式,如同在類別中使用其他任何變數一樣。

初始化基底類別

您可以從衍生類別的主要建構函式叫用基底類別的主要建構函式。 如果要撰寫必須叫用基底類別中主要建構函式的衍生類別,這是最簡單方式。 例如,考慮表示銀行不同帳戶型別的類別階層。 基底類別看起來類似下列程式碼:

public class BankAccount(string accountID, string owner)
{
    public string AccountID { get; } = accountID;
    public string Owner { get; } = owner;

    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}";
}

不論型別為何,所有銀行帳戶都有帳戶號碼和擁有者的屬性。 在已完成的應用程式中,其他一般功能會新增至基底類別。

許多型別需要對建構函式參數進行更具體的驗證。 例如,BankAccountowner 參數有特定的需求accountIDowner 不得為 null 或空格,且 accountID 必須是包含 10 位數的字串。 當您指派對應的屬性時,您可以新增此驗證:

public class BankAccount(string accountID, string owner)
{
    public string AccountID { get; } = ValidAccountNumber(accountID) 
        ? accountID 
        : throw new ArgumentException("Invalid account number", nameof(accountID));

    public string Owner { get; } = string.IsNullOrWhiteSpace(owner) 
        ? throw new ArgumentException("Owner name cannot be empty", nameof(owner)) 
        : owner;

    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}";

    public static bool ValidAccountNumber(string accountID) => 
    accountID?.Length == 10 && accountID.All(c => char.IsDigit(c));
}

上述範例示範如何在將建構函式參數指派給屬性之前先加以驗證。 您可以使用內建方法,例如 String.IsNullOrWhiteSpace(String),或您自己的驗證方法,例如 ValidAccountNumber。 在上述範例中,當建構函式叫用初始設定式時,會從建構函式擲回任何例外狀況。 如果未使用建構函式參數指派欄位,則第一次存取建構函式參數時,會擲回任何例外狀況。

一個衍生類別會顯示支票帳戶:

public class CheckingAccount(string accountID, string owner, decimal overdraftLimit = 0) : BankAccount(accountID, owner)
{
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < -overdraftLimit)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }
    
    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}, Balance: {CurrentBalance}";
}

CheckingAccount 衍生類別有一個主要建構函式,採用基底類別所需的所有參數,以及有預設值的另一個參數。 主要建構函式會使用 : BankAccount(accountID, owner) 語法呼叫基底建構函式。 這個運算式同時指定基底類別的型別,以及主要建構函式的引數。

不需要您的衍生類別就能使用主要建構函式。 您可以在衍生類別中建立建構函式來叫用基底類別的主要建構函式,如下列範例所示:

public class LineOfCreditAccount : BankAccount
{
    private readonly decimal _creditLimit;
    public LineOfCreditAccount(string accountID, string owner, decimal creditLimit) : base(accountID, owner)
    {
        _creditLimit = creditLimit;
    }
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < -_creditLimit)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }

    public override string ToString() => $"{base.ToString()}, Balance: {CurrentBalance}";
}

類別階層和主要建構函式有一個潛在問題:在衍生類別和基底類別中同時使用主要建構函式參數時,可以建立多個複本。 下列程式碼範例會為每個 owneraccountID 欄位建立兩個複本:

public class SavingsAccount(string accountID, string owner, decimal interestRate) : BankAccount(accountID, owner)
{
    public SavingsAccount() : this("default", "default", 0.01m) { }
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < 0)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }

    public void ApplyInterest()
    {
        CurrentBalance *= 1 + interestRate;
    }

    public override string ToString() => $"Account ID: {accountID}, Owner: {owner}, Balance: {CurrentBalance}";
}

醒目提示的行顯示 ToString 方法使用主要建構函式參數 (owneraccountID),而不是基底類別屬性 (OwnerAccountID)。 結果是衍生類別 SavingsAccount 會為這些複本建立儲存體。 衍生類別中的複本與基底類別中的屬性不同。 如果可以修改基底類別屬性,則衍生類別的執行個體不會看到該修改內容。 編譯器會針對衍生類別中使用並傳遞至基底類別建構函式的主要建構函式參數發出警告。 在這種情況下,修正方式是使用基底類別的屬性。

摘要

您可以使用主要建構函式,因為最符合您的設計。 對於類別和結構,主要建構函式參數是必須叫用之建構函式的參數。 您可以使用它們來初始化屬性。 您可以初始化欄位。 這些屬性或欄位可以是不可變或可變的。 您可以在方法中使用它們。 它們是參數,您可以採用最適合您設計的方式使用。 您可以在針對執行個體建構函式的 C# 程式設計手冊文章建議的主要建構函式規格深入了解主要建構函式。