チュートリアル: プライマリ コンストラクターを詳しく学習する

C# 12 では "プライマリ コンストラクター" が導入されています。これは、型の本体の任意の場所でパラメーターを使用できるコンストラクターを宣言するための簡潔な構文です。

このチュートリアルでは、次の事項について説明します。

  • 型でプライマリ コンストラクターを宣言するタイミング
  • 他のコンストラクターからプライマリ コンストラクターを呼び出す方法
  • 型のメンバーでプライマリ コンストラクター パラメーターを使用する方法
  • プライマリ コンストラクター パラメーターが格納される場所

前提条件

C# 12 以降のコンパイラが含まれる .NET 8 以降が実行されるように、コンピューターを設定する必要があります。 C# 12 コンパイラは、Visual Studio 2022 バージョン 17.7 以降または .NET 8.0 SDK 以降で使用できます。

プライマリ コンストラクター

struct または class 宣言にパラメーターを追加して、"プライマリ コンストラクター" を作成できます。 プライマリ コンストラクター パラメーターは、クラス定義全体のスコープ内にあります。 プライマリ コンストラクター パラメーターは、クラス定義全体でスコープ内にある場合でも、"パラメーター" として表示することが重要です。 パラメーターであることを明確にするルールがいくつかあります。

  1. プライマリ コンストラクター パラメーターは、必要ない場合は格納されないことがあります。
  2. プライマリ コンストラクター パラメーターは、クラスのメンバーではありません。 たとえば、param という名前のプライマリ コンストラクター パラメーターに this.param としてアクセスすることはできません。
  3. プライマリ コンストラクター パラメーターは割り当て先になることができます。
  4. record 型の場合を除き、プライマリ コンストラクター パラメーターはプロパティになりません。

これらの規則は、他のコンストラクター宣言を含め、任意のメソッドのパラメーターと同じです。

プライマリ コンストラクター パラメーターの最も一般的な用途は次のとおりです。

  1. base() コンストラクター呼び出しの引数として。
  2. メンバー フィールドまたはプロパティを初期化する。
  3. インスタンス メンバー内のコンストラクター パラメーターを参照する。

クラスの他のすべてのコンストラクターは、this() コンストラクター呼び出しを介して、直接または間接的にプライマリ コンストラクターを呼び出す必要があります。 この規則により、プライマリ コンストラクター パラメーターが型の本体の任意の場所に確実に割り当てられます。

プロパティを初期化する

次のコードでは、プライマリ コンストラクター パラメーターから計算される 2 つの読み取り専用プロパティを初期化します。

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 メソッドによって dx コンポーネントと dy コンポーネントが変更されます。 これには、Magnitude プロパティと Direction プロパティがアクセス時に計算される必要があります。 => 演算子は式形式の 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) { }
}

最初の例では、プライマリ コンストラクター パラメーターの値を格納するフィールドをコンパイラが作成する必要がなかったことを理解しておくことが重要です。 2 番目の例では、メソッド内でプライマリ コンストラクター パラメータを使用したため、コンパイラでそれらのためのストレージを作成する必要がありました。 コンパイラは、そのパラメーターが型のメンバーの本体でアクセスされた場合にのみ、プライマリ コンストラクターのストレージを作成します。 それ以外の場合、プライマリ コンストラクター パラメーターはオブジェクトに格納されません。

依存関係の挿入

プライマリ コンストラクターのもう 1 つの一般的な用途は、依存関係の挿入のパラメーターを指定することです。 次のコードでは、その使用にサービス インターフェイスを必要とする単純なコントローラーを作成します。

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}";
}

型に関係なく、すべての銀行口座には、口座番号と所有者のプロパティがあります。 完成したアプリケーションでは、他の一般的な機能が基底クラスに追加されます。

多くの型では、コンストラクター パラメーターに対してより具体的な検証が必要です。 たとえば、BankAccount には owneraccountID パラメーターに対して固有の要件があります。ownernull または空白でなく、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 などの独自の検証メソッドを使用できます。 前の例では、初期化子が呼び出されると、コンストラクターから例外がスローされます。 コンストラクター パラメーターを使用してフィールドを割り当てない場合、コンストラクター パラメーターに最初にアクセスしたときに例外がスローされます。

1 つの派生クラスに、口座チェックが存在します。

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}";
}

クラス階層とプライマリ コンストラクターには潜在的な問題が 1 つあります。派生クラスと基底クラスの両方で使用されるため、プライマリ コンストラクター パラメーターの複数のコピーを作成できます。 次のコード例では、owner フィールドと accountID フィールドのそれぞれに 2 つのコピーを作成します。

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# プログラミング ガイドに関する記事と提案されたプライマリ コンストラクターの仕様に関する記事を参照してください。