Tutorial: Erkunden von primären Konstruktoren

C# 12 führt primäre Konstruktoren ein, eine prägnante Syntax zum Deklarieren von Konstruktoren, deren Parameter überall im Hauptteil des Typs verfügbar sind.

In diesem Tutorial lernen Sie Folgendes:

  • Wann müssen Sie einen primären Konstruktor für Ihren Typ deklarieren?
  • Wie rufen Sie primäre Konstruktoren aus anderen Konstruktoren auf?
  • Wie verwenden Sie primäre Konstruktorparameter in Membern des Typs?
  • Wo werden primäre Konstruktorparameter gespeichert?

Voraussetzungen

Sie müssen Ihren Computer für die Ausführung von .NET 8 oder höher einrichten, einschließlich des Compilers für C# 12 oder höher. Der C# 12-Compiler steht ab Visual Studio 2022 Version 17.7 oder mit dem .NET 8 SDK zur Verfügung.

Primäre Konstruktoren

Sie können Parameter zu einer struct- oder class-Deklaration hinzufügen, um einen primären Konstruktor zu erstellen. Primäre Konstruktorparameter befinden sich in der gesamten Klassendefinition im Bereich. Es ist wichtig, primäre Konstruktorparameter als Parameter zu betrachten, auch wenn sie in der gesamten Klassendefinition im Bereich sind. Verschiedene Regeln verdeutlichen, dass es sich um Parameter handelt:

  1. Primäre Konstruktorparameter werden möglicherweise nicht gespeichert, wenn sie nicht benötigt werden.
  2. Primäre Konstruktorparameter sind keine Member der Klasse. Auf einen primären Konstruktorparameter namens param kann beispielsweise nicht als this.param zugegriffen werden.
  3. Primären Konstruktorparameter kann etwas zugewiesen werden.
  4. Primäre Konstruktorparameter werden nicht zu Eigenschaften, außer in record-Typen.

Diese Regeln sind identisch mit Parametern für eine beliebige Methode, einschließlich weiterer Konstruktordeklarationen.

So werden primäre Konstruktorparameter am häufigsten verwendet:

  1. Als Argument für einen base()-Konstruktoraufruf.
  2. Zum Initialisieren eines Memberfelds oder einer Membereigenschaft.
  3. Zum Verweisen auf den Konstruktorparameter in einem Instanzmember.

Jeder andere Konstruktor für eine Klasse muss den primären Konstruktor direkt oder indirekt über einen this()-Konstruktoraufruf aufrufen. Diese Regel stellt sicher, dass primäre Konstruktorparameter an einer beliebigen Stelle im Hauptteil des Typs zugewiesen werden.

Initialisieren von Eigenschaften

Der folgende Code initialisiert zwei schreibgeschützte Eigenschaften, die aus primären Konstruktorparametern berechnet werden:

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

Der obige Code zeigt einen primären Konstruktor, der zum Initialisieren berechneter schreibgeschützter Eigenschaften verwendet wird. Die Feldinitialisierer für Magnitude und Direction verwenden die primären Konstruktorparameter. Die primären Konstruktorparameter werden an keiner anderen Stelle in der Struktur verwendet. Die obige Struktur entspricht folgendem Code:

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

Das neue Feature vereinfacht die Verwendung von Feldinitialisierern, wenn Sie Argumente zum Initialisieren eines Felds oder einer Eigenschaft benötigen.

Erstellen eines veränderlichen Zustands

Die vorherigen Beispiele verwenden primäre Konstruktorparameter, um schreibgeschützte Eigenschaften zu initialisieren. Sie können auch primäre Konstruktoren verwenden, wenn die Eigenschaften nicht schreibgeschützt sind. Betrachten Sie folgenden Code:

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) { }
}

Im obigen Beispiel ändert die Translate-Methode die Komponenten dx und dy. Dafür müssen beim Zugriff die Eigenschaften Magnitude und Direction berechnet werden. Der =>-Operator gibt eine get-Zugriffsmethode an, deren Hauptteil ein Ausdruck ist, der =-Operator dagegen gibt einen Initialisierer an. Diese Version fügt der Struktur einen parameterlosen Konstruktor hinzu. Der parameterlose Konstruktor muss den primären Konstruktor aufrufen, damit alle primären Konstruktorparameter initialisiert werden.

Im obigen Beispiel wird in einer Methode auf die Eigenschaften des primären Konstruktors zugegriffen. Daher erstellt der Compiler verborgene Felder, um die einzelnen Parameter zu repräsentieren. Der folgende Code zeigt, was der Compiler in etwa generiert. Die tatsächlichen Feldnamen sind gültige CIL-Bezeichner, aber keine gültigen C#-Bezeichner.

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) { }
}

Es ist wichtig zu verstehen, dass der Compiler im ersten Beispiel kein Feld erstellen musste, um den Wert der primären Konstruktorparameter zu speichern. Im zweiten Beispiel wurde der primäre Konstruktorparameter innerhalb einer Methode verwendet, daher musste der Compiler Speicher dafür erstellen. Der Compiler erstellt nur dann Speicher für primäre Konstruktoren, wenn im Hauptteil eines Members Ihres Typs auf diesen Parameter zugegriffen wird. Andernfalls werden die primären Konstruktorparameter nicht im Objekt gespeichert.

Abhängigkeitsinjektion

Eine weitere häufige Verwendung für primäre Konstruktoren besteht darin, Parameter für eine Abhängigkeitsinjektion anzugeben. Der folgende Code erstellt einen einfachen Controller, der zur Verwendung eine Dienstschnittstelle erfordert:

public interface IService
{
    Distance GetDistance();
}

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

Der primäre Konstruktor gibt eindeutig die Parameter an, die in der Klasse benötigt werden. Sie verwenden die primären Konstruktorparameter wie jede andere Variable in der Klasse.

Initialisieren der Basisklasse

Sie können den primären Konstruktor einer Basisklasse aus dem primären Konstruktor der abgeleiteten Klasse aufrufen. Am einfachsten schreiben Sie eine abgeleitete Klasse, die einen primären Konstruktor in der Basisklasse aufrufen muss. Betrachten Sie beispielsweise eine Hierarchie von Klassen, die verschiedene Kontotypen als Bank darstellen. Die Basisklasse würde etwa wie der folgende Code aussehen:

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

Alle Bankkonten verfügen unabhängig vom Typ über Eigenschaften für die Kontonummer und einen Kontobesitzer. In der vollständigen Anwendung würden der Basisklasse weitere allgemeine Funktionen hinzugefügt.

Viele Typen erfordern eine spezifischere Validierung für Konstruktorparameter. Beispielsweise hat BankAccount bestimmte Anforderungen an die Parameter owner und accountID: owner darf nicht null oder ein Leerraum sein, und accountID muss eine Zeichenfolge aus 10 Ziffern sein. Sie können diese Validierung hinzufügen, wenn Sie die entsprechenden Eigenschaften zuweisen:

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

Das obige Beispiel zeigt, wie Sie die Konstruktorparameter validieren können, bevor Sie sie den Eigenschaften zuweisen. Sie können integrierte Methoden wie String.IsNullOrWhiteSpace(String) oder eigene Validierungsmethoden wie ValidAccountNumber verwenden. Im obigen Beispiel werden alle Ausnahmen vom Konstruktor ausgelöst, wenn er die Initialisierer aufruft. Wenn kein Konstruktorparameter zum Zuweisen eines Felds verwendet wird, werden eventuelle Ausnahmen beim ersten Zugriff auf den Konstruktorparameter ausgelöst.

Eine abgeleitete Klasse würde beispielsweise ein Girokonto darstellen:

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

Die abgeleitete CheckingAccount-Klasse enthält einen primären Konstruktor, der alle in der Basisklasse benötigten Parameter akzeptiert, und einen weiteren Parameter mit einem Standardwert. Der primäre Konstruktor ruft den Basiskonstruktor über die : BankAccount(accountID, owner)-Syntax auf. Dieser Ausdruck gibt sowohl den Typ für die Basisklasse als auch die Argumente für den primären Konstruktor an.

Für die Verwendung eines primären Konstruktors ist Ihre abgeleitete nicht Klasse erforderlich. Sie können einen Konstruktor in der abgeleiteten Klasse erstellen, der den primären Konstruktor der Basisklasse aufruft, wie im folgenden Beispiel gezeigt:

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

Bei Klassenhierarchien und primären Konstruktoren gibt es ein potenzielles Problem: Es ist möglich, mehrere Kopien eines primären Konstruktorparameters zu erstellen, da er sowohl in abgeleiteten als auch in Basisklassen verwendet wird. Das folgende Codebeispiel erstellt je zwei Kopien der Felder owner und accountID:

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

Die hervorgehobene Zeile zeigt, dass die ToString-Methode die primären Konstruktorparameter (owner und accountID) verwendet, nicht die Basisklasseneigenschaften (Owner und AccountID). Das führt dazu, dass die abgeleitete Klasse, SavingsAccount, Speicher für diese Kopien erstellt. Die Kopie in der abgeleiteten Klasse unterscheidet sich von der Eigenschaft in der Basisklasse. Sollte die Basisklasseeigenschaft geändert werden, würde diese Änderung der Instanz der abgeleiteten Klasse nicht angezeigt. Der Compiler gibt eine Warnung für primäre Konstruktorparameter aus, die in einer abgeleiteten Klasse verwendet und an einen Basisklassenkonstruktor übergeben werden. In diesem Fall lässt sich das Problem beheben, indem die Eigenschaften der Basisklasse verwendet werden.

Zusammenfassung

Sie können die primären Konstruktoren so verwenden, wie sie sich für Ihren Entwurf am besten eignen. Für Klassen und Strukturen sind primäre Konstruktorparameter Parameter für einen Konstruktor, der aufgerufen werden muss. Sie können sie zum Initialisieren von Eigenschaften verwenden. Sie können Felder initialisieren. Diese Eigenschaften oder Felder können unveränderlich oder veränderlich sein. Sie können sie in Methoden verwenden. Es sind Parameter, und Sie können sie auf die Weise verwenden, die am besten in Ihren Entwurf passt. Weitere Informationen zu primären Konstruktoren finden Sie im C#-Programmierhandbuch im Artikel zu Instanzkonstruktoren und in der Spezifikation zu primären Konstruktoren.