Esercitazione: Esplorare i costruttori primari

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

In questa esercitazione si apprenderà:

  • Quando dichiarare un costruttore primario nel tipo
  • Come chiamare i costruttori primari da altri costruttori
  • Come usare i parametri dei costruttori primari nei membri del tipo
  • Dove vengono archiviati i parametri dei costruttori primari

Prerequisiti

È necessario configurare il computer per eseguire .NET 8 o versione successiva, incluso il compilatore C# 12 o versione successiva. Il compilatore C# 12 è disponibile a partire da Visual Studio 2022 versione 17.7 o .NET 8 SDK.

Costruttori primari

È possibile aggiungere parametri a una dichiarazione struct o class per creare un costruttore primario. I parametri del costruttore primario si applicano a tutta la definizione della classe. È importante pensare ai parametri del costruttore primario come a veri e propri parametri anche se si applicano in tutta la definizione della classe. Ci sono diverse regole che fanno capire che si tratta di parametri:

  1. I parametri del costruttore primario non devono necessariamente essere archiviati, se ciò non è richiesto.
  2. I parametri del costruttore primario non sono membri della classe. Non è ad esempio possibile accedere a un parametro del costruttore primario denominato param usando this.param.
  3. I parametri del costruttore primario possono essere assegnati.
  4. I parametri del costruttore primario non diventano proprietà, ad eccezione dei tipi record.

Queste regole sono valide per i parametri di qualsiasi metodo, incluse altre dichiarazioni del costruttore.

Gli usi più comuni di un parametro del costruttore primario sono i seguenti:

  1. Come argomento di una chiamata di un costruttore base().
  2. Per inizializzare un campo o una proprietà di un membro.
  3. Per 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 del costruttore this(). Ciò assicura che i parametri del costruttore primario vengano assegnati ovunque nel corpo del tipo.

Inizializzare una proprietà

Il codice seguente inizializza due proprietà di sola lettura 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);
}

Il codice precedente illustra un costruttore primario usato per inizializzare proprietà calcolate di sola lettura. Gli inizializzatori di campo per Magnitude e Direction usano i parametri del costruttore primario. I parametri del costruttore primario non vengono usati altrove nello struct. Lo struct precedente corrisponde a scrivere il codice 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);
    }
}

La nuova 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 proprietà di sola lettura. È anche possibile usare i costruttori primari quando le proprietà 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) { }
}

Nell'esempio precedente il metodo Translate modifica i componenti dx e dy. Ciò richiede che le proprietà Magnitude e Direction vengano calcolate al momento dell'accesso. L'operatore => definisce una funzione di accesso get con corpo di espressione, mentre l'operatore = definisce un inizializzatore. Questa versione aggiunge un costruttore senza parametri allo struct. Il costruttore senza parametri deve richiamare il costruttore primario, in modo che tutti i parametri del costruttore primario vengano inizializzati.

Nell'esempio precedente, viene eseguito l'accesso alle proprietà del costruttore primario in un metodo. Il compilatore crea quindi campi nascosti per rappresentare ogni parametro. Il codice seguente mostra approssimativamente ciò che il compilatore genera. I nomi di campo effettivi sono identificatori CIL 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) { }
}

È importante comprendere che il primo esempio non richiede la creazione di un campo da parte del compilatore per archiviare il valore dei parametri del costruttore primario. Il secondo esempio usa il parametro del costruttore primario all'interno di un metodo e quindi richiede che il compilatore crei lo spazio di archiviazione. Il compilatore crea lo spazio di archiviazione per i costruttori primari solo quando si accede al parametro nel corpo di un membro del tipo. In caso contrario, i parametri del costruttore primario non vengono archiviati nell'oggetto.

Inserimento delle dipendenze

Un altro uso comune per i costruttori primari consiste nello 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 di base

È possibile richiamare un costruttore primario di una classe di base dal costruttore primario della classe derivata. È il modo più semplice per scrivere una classe derivata che deve richiamare un costruttore primario nella classe di base. Si consideri, ad esempio, una gerarchia di classi che rappresentano tipi di conto diversi di una banca. La classe di base sarà simile al codice seguente:

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 proprietà relative al numero di conto e al proprietario. Nell'applicazione completa, alla classe di base verranno aggiunte altre funzionalità comuni.

Molti tipi richiedono una convalida più specifica sui parametri del costruttore. L'oggetto BankAccount ha ad esempio requisiti specifici per i parametri owner e accountID: owner non deve essere null o uno spazio vuoto e 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));
}

L'esempio precedente mostra come convalidare i parametri del costruttore prima di assegnarli alle proprietà. È possibile usare metodi predefiniti, ad esempio String.IsNullOrWhiteSpace(String), o un metodo di convalida personalizzato, ad esempio ValidAccountNumber. Nell'esempio precedente vengono generate eccezioni dal costruttore quando vengono richiamati 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 presenta 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 derivata CheckingAccount ha un costruttore primario che accetta tutti i parametri necessari nella classe di base e un altro parametro con un valore predefinito. Il costruttore primario chiama il costruttore di base usando 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 della classe di 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 in quanto viene usato sia nelle classi derivate che in quelle di base. L'esempio di codice seguente crea due copie di ognuno dei campi 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 mostra che il metodo ToString usa i parametri del costruttore primario (owner e accountID) anziché le proprietà della classe di base (Owner e AccountID). Di conseguenza, la classe derivata SavingsAccount crea spazio di archiviazione per tali copie. La copia nella classe derivata è diversa dalla proprietà nella classe di base. Se la proprietà della classe di base potesse essere modificata, l'istanza della classe derivata non vedrebbe tale modifica. Il compilatore genera un avviso per i parametri del costruttore primario usati in una classe derivata e passati a un costruttore della classe di base. In questo caso, la correzione consiste nell'usare le proprietà della classe di base.

Riepilogo

È possibile usare i costruttori primari più adatti alla progettazione specifica. Per le classi e gli struct, i parametri del costruttore primario sono parametri di un costruttore che deve essere richiamato. È possibile usarli per inizializzare le proprietà. È possibile inizializzare i campi. Tali proprietà o campi possono essere non modificabili o modificabili. È possibile usarli nei metodi. Si tratta di parametri che è possibile usare nel modo più adatto alla progettazione. Per altre informazioni sui costruttori primari, vedere l'articolo della guida per programmatori C# sui costruttori di istanze e la specifica del costruttore primario proposta.