Condividi tramite


Dichiarare costruttori primari per classi e struct

C# 12 introduce costruttori primari, che forniscono una sintassi concisa per dichiarare costruttori i cui parametri sono disponibili ovunque nel corpo del tipo.

Questo articolo descrive come dichiarare un costruttore primario nel tipo e riconoscere dove archiviare i parametri del costruttore primario. È possibile chiamare costruttori primari da altri costruttori e usare i parametri del costruttore primario nei membri del tipo.

Prerequisiti

  • La versione più recente .NET SDK
  • editor di Visual Studio Code
  • Il DevKit C#

Informazioni sulle regole per i costruttori primari

È possibile aggiungere parametri a una dichiarazione di struct o class per creare un costruttore primario . I parametri del costruttore primario sono inclusi nell'ambito in tutta la definizione della classe. È importante visualizzare i parametri del costruttore primario come parametri anche se sono inclusi nell'ambito in tutta la definizione della classe.

Diverse regole chiariscono che questi costruttori sono parametri:

  • I parametri del costruttore primario potrebbero non essere archiviati se non sono necessari.
  • I parametri del costruttore primario non sono membri della classe . Ad esempio, non è possibile accedere a un parametro del costruttore primario denominato param come this.param.
  • I parametri del costruttore primario possono essere assegnati a .
  • I parametri del costruttore primario non diventano proprietà, ad eccezione dei record di tipo .

Queste regole sono le stesse regole già definite per i parametri di qualsiasi metodo, incluse altre dichiarazioni di costruttore.

Ecco gli usi più comuni per un parametro del costruttore primario:

  • Passare come argomento a una chiamata al costruttore base()
  • Inizializzare un campo o una proprietà membro
  • Fare riferimento al parametro del costruttore in un membro dell'istanza

Ogni altro costruttore per una classe deve chiamare il costruttore primario, direttamente o indirettamente, tramite una chiamata al costruttore this(). Questa regola garantisce che i parametri del costruttore primario vengano assegnati ovunque nel corpo del tipo.

Inizializzare proprietà o campi non modificabili

Il codice seguente inizializza due proprietà readonly (non modificabili) calcolate dai parametri del costruttore primario:

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

In questo esempio viene utilizzato un costruttore primario per inizializzare le proprietà di sola lettura calcolate. Gli inizializzatori di campo per le proprietà Magnitude e Direction usano i parametri del costruttore primario. I parametri del costruttore primario non vengono usati altrove nello struct. Il codice crea uno struct come se fosse scritto nel modo seguente:

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

Questa funzionalità semplifica l'uso degli inizializzatori di campo quando sono necessari argomenti per inizializzare un campo o una proprietà.

Creare uno stato modificabile

Negli esempi precedenti vengono usati i parametri del costruttore primario per inizializzare le proprietà di sola lettura. È anche possibile usare i costruttori primari per le proprietà che non sono di sola lettura.

Osservare il codice seguente:

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

In questo esempio, il metodo Translate modifica i componenti dx e dy, che richiedono che le proprietà Magnitude e Direction vengano calcolate quando si accede. L'operatore maggiore o uguale a (=>) definisce una funzione di accesso con corpo di espressione get, mentre l'operatore uguale a (=) definisce un inizializzatore.

Questa versione del codice aggiunge un costruttore senza parametri allo struct . Il costruttore senza parametri deve richiamare il costruttore primario, che garantisce che tutti i parametri del costruttore primario vengano inizializzati. Le proprietà del costruttore primario sono accessibili in un metodo e il compilatore crea campi nascosti per rappresentare ogni parametro.

Il codice seguente illustra un'approssimazione di ciò che il compilatore genera. I nomi di campo effettivi sono identificatori CIL (Common Intermediate Language) validi, ma non identificatori C# validi.

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

Archiviazione creata dal compilatore

Per il primo esempio di questa sezione, il compilatore non ha bisogno di creare un campo per archiviare il valore dei parametri del costruttore primario. Tuttavia, nel secondo esempio, il parametro del costruttore primario viene usato all'interno di un metodo, pertanto il compilatore deve creare spazio di archiviazione per i parametri.

Il compilatore crea memoria per i costruttori primari solo quando il parametro viene accesso nel corpo di un membro del tuo tipo. In caso contrario, i parametri del costruttore primario non vengono archiviati nell'oggetto .

Usare l'iniezione delle dipendenze

Un altro uso comune per i costruttori primari consiste nel specificare i parametri per l'inserimento delle dipendenze. Il codice seguente crea un controller semplice che richiede un'interfaccia del servizio per l'uso:

public interface IService
{
    Distance GetDistance();
}

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

Il costruttore primario indica chiaramente i parametri necessari nella classe . I parametri del costruttore primario vengono usati come qualsiasi altra variabile nella classe .

Inizializzare la classe base

È possibile richiamare il costruttore primario per una classe base dal costruttore primario della classe derivata. Questo approccio è il modo più semplice per scrivere una classe derivata che deve richiamare un costruttore primario nella classe base. Si consideri una gerarchia di classi che rappresentano tipi di conto diversi come banca. Il codice seguente mostra l'aspetto della classe di base:

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

Tutti i conti bancari, indipendentemente dal tipo, hanno le proprietà del numero di conto e del titolare. Nell'applicazione completata è possibile aggiungere altre funzionalità comuni alla classe base.

Molti tipi richiedono una convalida più specifica sui parametri del costruttore. Ad esempio, la classe BankAccount ha requisiti specifici per i parametri owner e accountID. Il parametro owner non deve essere null o spazi vuoti e il parametro accountID deve essere una stringa contenente 10 cifre. È possibile aggiungere questa convalida quando si assegnano le proprietà corrispondenti:

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

In questo esempio viene illustrato come convalidare i parametri del costruttore prima di assegnarli alle proprietà. È possibile usare metodi predefiniti come String.IsNullOrWhiteSpace(String) o il proprio metodo di convalida, ad esempio ValidAccountNumber. Nell'esempio, tutte le eccezioni vengono sollevate dal costruttore quando invoca gli inizializzatori. Se un parametro del costruttore non viene usato per assegnare un campo, vengono generate eccezioni quando si accede per la prima volta al parametro del costruttore.

Una classe derivata potrebbe rappresentare un conto corrente.

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

La classe CheckingAccount derivata ha un costruttore primario che accetta tutti i parametri necessari nella classe base e un altro parametro con un valore predefinito. Il costruttore primario chiama il costruttore di base con la sintassi : BankAccount(accountID, owner). Questa espressione specifica sia il tipo per la classe di base che gli argomenti per il costruttore primario.

La classe derivata non è necessaria per usare un costruttore primario. È possibile creare un costruttore nella classe derivata che richiama il costruttore primario per la classe base, come illustrato nell'esempio seguente:

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

C'è un potenziale problema con le gerarchie di classi e i costruttori primari. È possibile creare più copie di un parametro del costruttore primario perché il parametro viene usato nelle classi derivate e di base. Il codice seguente crea due copie di ognuno dei parametri owner e 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}";
}

La riga evidenziata in questo esempio mostra che il metodo ToString usa i parametri del costruttore primario (owner e accountID) anziché le proprietà della classe base (Owner e AccountID). Il risultato è che la classe derivata, SavingsAccount, crea l'archiviazione per le copie dei parametri. La copia nella classe derivata è diversa dalla proprietà nella classe base. Se la proprietà della classe di base può essere modificata, l'istanza della classe derivata non visualizza la modifica. Il compilatore genera un avviso per i parametri del costruttore primario usati in una classe derivata e passati a un costruttore della classe base. In questo caso, la correzione consiste nell'usare le proprietà della classe base.