Tutoriel : Exploration des constructeurs principaux

C# 12 introduit les constructeurs principaux, une syntaxe concise permettant de déclarer des constructeurs dont les paramètres sont disponibles n’importe où dans le corps du type.

Ce tutoriel vous apprendra à effectuer les opérations suivantes :

  • Quand déclarer un constructeur principal sur le type
  • Comment appeler des constructeurs principaux à partir d’autres constructeurs
  • Comment utiliser les paramètres du constructeur principal dans les membres du type
  • Où sont stockés les paramètres du constructeur principal

Prérequis

Vous devez configurer votre ordinateur pour exécuter .NET 8 (ou une version ultérieure), avec le compilateur C# 12 (ou une version ultérieure). Le compilateur C# 12 est disponible à partir de la version 17.7 de Visual Studio 2022 ou de la version 8 du kit SDK .NET.

Constructeurs principaux

Vous pouvez ajouter des paramètres à une déclaration struct ou class pour créer un constructeur principal. Les paramètres du constructeur principal se trouvent dans l’étendue de la définition de classe. Il est important de considérer les paramètres du constructeur principal comme des paramètres même s’ils sont inclus dans toute l’étendue de la définition. Plusieurs règles explicitent le fait qu’il s’agit de paramètres :

  1. Les paramètres du constructeur principal peuvent ne pas être stockés s’ils ne sont pas nécessaires.
  2. Les paramètres du constructeur principal ne constituent pas membres de la classe. Par exemple, un paramètre de constructeur principal nommé param n’est pas accessible en tant que this.param.
  3. Il est possible d’affecter des paramètres de constructeur principal.
  4. Les paramètres du constructeur principal ne deviennent pas des propriétés, excepté dans les types record.

Ces règles sont les mêmes que pour les paramètres des autres méthodes, y compris d’autres déclarations de constructeur.

Les utilisations les plus courantes d’un paramètre de constructeur principal sont les suivantes :

  1. En tant qu’argument d’appel d’un constructeur base().
  2. Pour initialiser un champ ou une propriété membre.
  3. Pour faire référence au paramètre du constructeur dans un membre d’instance.

Tout autre constructeur d’une classe doit appeler le constructeur principal, directement ou indirectement, par le biais d’une invocation de constructeur this(). Cette règle garantit que les paramètres du constructeur principal sont attribués n’importe où dans le corps du type.

Initialisation de la propriété

Le code suivant initialise deux propriétés readonly qui sont calculées à partir des paramètres du constructeur principal :

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

Le code précédent illustre un constructeur principal utilisé pour initialiser des propriétés readonly calculées. Les initialiseurs de champs de Magnitude et de Direction utilisent les paramètres du constructeur principal. Ces paramètres ne sont employés nulle part ailleurs dans le struct. Le struct précédent revient au code suivant :

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 nouvelle fonctionnalité facilite l’utilisation des initialiseurs de champs lorsque des arguments sont nécessaires pour initialiser un champ ou une propriété.

Création d’un état modifiable

Les exemples précédents utilisent des paramètres de constructeur principal pour initialiser des propriétés readonly. Il est également possible d’avoir recours à des constructeurs principaux lorsque les propriétés ne sont pas readonly. Prenez le code suivant :

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

Dans l’exemple précédent, la méthode Translate modifie les composants dx et dy. Les propriétés Magnitude et Direction doivent pour cela être calculées lors de l’accès. L’opérateur => désigne un accesseur get à corps d’expression, tandis que l’opérateur = représente un initialiseur. Cette version ajoute un constructeur sans paramètre au struct. Le constructeur sans paramètre doit appeler le constructeur principal, afin que tous les paramètres du constructeur principal soient initialisés.

Dans l’exemple précédent, les propriétés du constructeur principal sont accessibles dans une méthode. Par conséquent, le compilateur crée des champs masqués pour représenter chaque paramètre. Le code suivant montre approximativement ce que génère le compilateur. Les noms de champs réels constituent des identificateurs CIL valides, mais non des identificateurs C# valides.

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

Il est important de comprendre que le premier exemple ne demande pas au compilateur de créer un champ pour stocker la valeur des paramètres du constructeur principal. Le deuxième exemple utilisait les paramètres du constructeur principal dans une méthode, donc le compilateur devait leur créer un stockage. Le compilateur crée un espace de stockage pour tous les constructeurs principaux si et seulement si ce paramètre est accessible dans le corps d’un membre du type en question. Dans le cas contraire, les paramètres du constructeur principal ne sont pas stockés dans l’objet.

Injection de dépendances

Une autre utilisation courante des constructeurs principaux consiste à spécifier des paramètres pour l’injection de dépendances. Le code suivant crée un contrôleur simple dont l’utilisation implique une interface de service :

public interface IService
{
    Distance GetDistance();
}

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

Le constructeur principal indique clairement les paramètres nécessaires dans la classe. Les paramètres du constructeur principal s’utilisent comme toute autre variable de la classe.

Initialisation de la classe de base

Il est possible d’appeler le constructeur principal d’une classe de base à partir du constructeur principal de la classe dérivée. Il s’agit du moyen le plus simple d’écrire une classe dérivée qui doit invoquer un constructeur principal dans la classe de base. Prenons par exemple une hiérarchie de classes représentant les différents types de comptes d’une banque. La classe de base se présente comme le code suivant :

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

Tous les comptes bancaires, quel que soit leur type, présentent des propriétés de numéro de compte et de propriétaire. Dans l’application complète, d’autres fonctionnalités courantes sont ajoutées à la classe de base.

De nombreux types imposent une validation plus spécifique des paramètres du constructeur. Par exemple, BankAccount comporte des exigences spécifiques pour les paramètres owner et accountID : owner ne doit pas être null ni un espace blanc, et accountID doit être une chaîne contenant 10 chiffres. Vous pouvez ajouter cette validation lorsque vous affectez les propriétés correspondantes :

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’exemple précédent montre comment valider les paramètres du constructeur avant de les affecter aux propriétés. Vous pouvez utiliser des méthodes intégrées (par exemple String.IsNullOrWhiteSpace(String)) ou votre propre méthode de validation (par exemple ValidAccountNumber). Dans l’exemple précédent, toutes les exceptions sont levées à partir du constructeur lorsqu’il appelle les initialiseurs. Si aucun paramètre de constructeur n’est utilisé pour affecter un champ, elles sont lancées lors du premier accès au paramètre du constructeur.

Une classe dérivée présente un compte de vérification :

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 dérivée CheckingAccount comporte un constructeur principal qui prend tous les paramètres nécessaires dans la classe de base, et un autre paramètre doté d’une valeur par défaut. Le constructeur principal appelle le constructeur de base selon la syntaxe : BankAccount(accountID, owner). Cette expression spécifie à la fois le type de la classe de base et les arguments du constructeur principal.

La classe dérivée n’est pas requise pour utiliser un constructeur principal. Vous pouvez créer un constructeur dans la classe dérivée qui appelle le constructeur principal de la classe de base, comme dans l’exemple suivant :

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

Les hiérarchies de classes et les constructeurs principaux présentent un problème potentiel : il est possible de créer plusieurs copies d’un paramètre de constructeur principal, car il est utilisé à la fois dans les classes dérivées et de base. L’exemple de code suivant crée deux copies chacune du champ owner et 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 ligne mise en surbrillance indique que la méthode ToString utilise les paramètres du constructeur principal (owner et accountID) plutôt que les propriétés de la classe de base (Owner et AccountID). Il en résulte que la classe dérivée SavingsAccount crée un espace de stockage pour ces copies. La copie qui se trouve dans la classe dérivée est différente de la propriété située dans la classe de base. Si la propriété de la classe de base était modifiée, l’instance de la classe dérivée ne verrait pas le changement. Le compilateur émet un avertissement lorsque des paramètres du constructeur principal sont employés dans une classe dérivée et transmis à un constructeur de classe de base. Dans cette instance, le correctif consiste à utiliser les propriétés de la classe de base.

Résumé

Vous pouvez utiliser les constructeurs principaux les plus adaptés à votre conception. Pour les classes et les structs, les paramètres du constructeur principal sont des paramètres qui doivent être appelés vis-à-vis d’un constructeur. Ils permettent d’initialiser des propriétés et des champs, modifiables ou non. Vous pouvez les utiliser dans des méthodes. Il s’agit de paramètres, qui peuvent être utilisés de différentes manières en fonction de la conception. Pour plus d’informations sur les constructeurs principaux, consultez l’article du guide de programmation C# sur les constructeurs d’instances et la spécification du constructeur principal proposée.