Les records (C# 9.0)

C# 9 introduit un nouveau type d’objets dont le but est de fournir une syntaxe simple pour déclarer des objets de type référence contenant des propriétés. Ces objets peuvent être définis en utilisant le mot-clé record.

Cet article a pour but de passer en revue les propriétés des objets record.

Suivant la façon dont ces objets sont instanciés, le compilateur peut rendre ces objets immutables et ajouter des méthodes à l’implémentation existante.

Il existe 2 syntaxes pour déclarer des objets records:

  • Une syntaxe condensée appelée positional record, par exemple:
    public record Car(string Brand, string Model); 
    

    Cette syntaxe permet de générer implicitement un objet immutable: les propriétés Car.Brand et Car.Model ne sont accessibles qu’en lecture seule.

  • Une syntaxe plus classique avec un constructeur:
    public record Car
    {
        public Car(string brand, string model)
        {
            this.Brand = brand;
            this.Model = model;
        }
    
        public Car Brand { get; }
        public Car Model { get; }
    }
    

    Les caractéristiques de l’objet record généré avec cette syntaxe sont explicites: les propriétés sont en lecture seule parce-qu’il n’existe que l’accesseur get.

De base, le compilateur rajoute des méthodes à l’implémentation des objets record de façon à faciliter leur utilisation et à étendre leurs caractéristiques:

  • La comparaison d’objets record se fait en comparant les propriétés membre des objets et non en comparant les références des objets (comme c’est le cas par défaut pour les objets de type référence).
  • Un constructeur est implicitement rajouté pour facilement instancier les objets record. Le constructeur rajouté permet d’affecter toutes les propriétés du record.
  • Ces objets peuvent être affichés directement avec ToString() sans avoir à surcharger cette méthode.
  • Ces objets supportent la déconstruction de ses propriétés sans effectuer d’implémentation particulière.

Ainsi si on définit un objet record de cette façon (syntaxe positional record):

public record Car(string Brand, string Model); 

Le compilateur va complêter l’implémentation pour produire une classe dont l’implémentation équivalente pourrait être:

public class Car 
{ 
    public string Brand { get; } // Propriétés accessibles en lecture seule
    public string Model { get; } 

    // Ajout d'un constructeur implicite
    public Car(string Brand, string Model) 
    {
        this.Brand = Brand;
        this.Model = Model;
    }   

    // Implémentation de ToString() 
    public override string ToString() 
    { 
        return $"Car {{ Brand = {this.Brand}, Model = {this.Model} }}"; 
    } 

    // Implémentation de Equals() 
    public override bool Equals(object obj) { ... } 
    public override int GetHashCode() { ... } 

    // Surcharge d'opérateurs 
    public static bool operator ==(object a, Car b) 
    { ... }; 

    public static bool operator !=(object a, Car b) 
    { ... }; 

    // Implémentation d'un déconstructeur
    public void Deconstruct(out string brand, out string model)  
        => (brand, model) = (this.Brand, this.Model); 
} 

Ainsi l’intérêt principal des objets record est de complêter l’implémentation en rajoutant des fonctions courantes sans avoir à surcharger la syntaxe.

Même si l’implémentation des objets record est complêtée par un constructeur ou des méthodes à la compilation, il reste possible d’implémenter d’autres constructeurs ou d’autres méthodes comme pour une classe:

public record Car 
{ 
    public string Brand { get; set; } 
    public string Model { get; set; } 

    public Car(string brand) 
    { 
        this.Brand = brand; 
        this.Model = string.Empty; 
    } 

    public string GetCompleteName() 
    { 
        return $"{this.Brand} {this.Model}"; 
    } 
} 

Un record est une classe

Un objet record est compilé sous forme d’une classe. Si on regarde le code MSIL (i.e. MicroSoft Intermediate Language) obtenu si on compile le code C# suivant, on remarque que les instructions sont quasiment les mêmes que pour une classe:

Code C# Instructions MSIL
public class CarAsClass
{ 
    public string Brand { get; init; }  
    public string Model { get; init; }  
} 
.class public auto ansi beforefieldinit Cs9.CarAsClass 
    extends [System.Runtime]System.Object
{ 
    //...  
} // End of class Cs9.CarAsClass 
public record CarAsRecord
{ 
    public string Brand { get; init; }  
    public string Model { get; init; }  
} 
.class public auto ansi beforefieldinit Cs9.CarAsRecord 
    extends [System.Runtime]System.Object 
    implements [System.Runtime]System.IEquatable`1<Cs9.CarAsRecord>
{ 
    // ... 
} // End of class Cs9.CarAsRecord 

La différence entre le 2 objets est que le compilateur génère implicitement davantage de fonctions pour l’objet record comme par exemple ToString(), PrintMembers(), Equals(), GetHashCode() etc…

Déclaration et construction

Comme indiqué plus haut, il existe 2 syntaxes pour déclarer un objet record:

  • Une syntaxe explicite similaire à celle des classes: avec cette syntaxe, les accès aux propriétés sont explicitement implémentés.

    Par exemple:

    public record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    } 
    

    Un objet record déclaré de cette façon peut être instancié en utilisant des initializers:

    var car = new Car{ Brand = "Renault", Model = "4L" }; 
    
  • Une syntaxe condensée (i.e. positional record): les accès aux propriétés sont implicitement en lecture seule.

    Par exemple:

    public record Car(string Brand, string Model); 
    

    Dans ce cas, cet objet peut être instancié en utilisant un constructeur:

    var car  = new Car("Renault", "4L"); 
    

    Ou en omettant le type après new:

    Car car  = new ("Renault", "4L"); 
    

    Avec cette construction, les propriétés sont en lecture seule:

    var brand = car.Brand; // OK 
    car.Brand = "Peugeot"; // ERREUR
    

    D’autres constructions sont possibles pour préciser d’autres propriétés ou des méthodes en dehors du constructeur, par exemple:

    public record Car(string Brand, string Model) 
    { 
        public int Power { get; set; } 
    
        public string GetCompleteName() 
        { 
            return $"{this.Brand} {this.Model}"; 
        } 
    } 
    

    Avec cette dernière déclaration, on peut instancier en utilisant un initializer:

    var car  = new Car("Renault", "4L") { Power = 40 }; 
    

Construction en utilisant une expression avec with

On peut instancier des objets record à partir d’autres objets en utilisant with, par exemple:

public record Car(string Brand, string Model); 

// ...

var sedan = new Car("Tesla", "3"); 
var suv = sedan with { Model = "X" }; 

Héritage

Les objets record supportent l’héritage ce qui est un avantage par rapport aux objets struct, par exemple:

public record Vehicle 
{ 
    public Vehicle(int wheelCount, int doorCount) 
    { 
        this.WheelCount = wheelCount; 
        this.DoorCount = doorCount; 
    } 

    public int WheelCount { get; } 
    public int DoorCount { get; } 
} 

public record Car : Vehicle 
{ 
    public Car(string brand, string model, int wheelCount, int doorCount):  
        base(wheelCount, doorCount) 
    { 
        this.Brand = brand; 
        this.Model = model; 
    } 

    public string Brand { get; } 
    public string Model { get; } 
} 

Si on utilise la syntaxe positional record, l’implémentation équivalente est:

public record Vehicle(int WheelCount, int DoorCount); 

public record Car(string Brand, string Model, int WheelCount, int DoorCount): 
    Vehicle(WheelCount, DoorCount); 

Comparaison

Comme indiqué plus haut, la comparaison entre des objets record est facilitée puisque qu’il n’est pas nécessaire de surcharger explicitement la fonction Equals(). Implicitement, le compilateur rajoute une implémentation de la fonction Equals() permettant de comparer toutes les propriétés, par exemple:

var car1 = new Car("Tesla", "3"); 
var car2 = new Car("Tesla", "3"); 
 
Console.WriteLine(car1.Equals(car2)); // true 

Les objets sont égaux par comparaison des valeurs des propriétés toutefois ils sont bien distincts en comparant les références:

Console.WriteLine(ReferenceEquals(car1, car2)); // false 

Les opérateurs d’égalité et d’inégalité sont surchargés:

Console.WriteLine(car1 == car2); // true 
Console.WriteLine(car1 != car2); // false 

Surcharger Equals() et les opérateurs d’égalité et d’inégalités

Même si le compilateur rajoute implicitement une implémentation pour les fonctions:

  • bool Equals(Object object),
  • int GetHashCode() et
  • Des opérateurs == et =!.

Il est possible de proposer une autre implémentation pour GetHashCode() par override toutefois ce n’est pas possible pour bool Equals(Object object) et pour les opérateurs == et =!:

public record Car(string Brand, string Model) 
{ 
    public override bool Equals(Object obj) { ... } // ERREUR
    public override int GetHashCode() {... } //OK 

    public static bool operator ==(Car car1, Car car2) => { ... } // ERREUR
    public static bool operator !=(Car car1, Car car2) => { ... } // ERREUR
} 

Il est possible de proposer une nouvelle implémentation Equals() si:

  • La signature n’est pas Equals(Object obj) et
  • Si la nouvelle implémentation est une fonction virtuelle ou si l’objet record est sealed (i.e. avec sealed il n’est pas possible d’hériter de l’objet record).

Par exemple, pour proposer une nouvelle implémentation de Equals() l’argument ne doit pas être de type object. Dans ce cas, on définit une nouvelle surcharge de Equals(), il n’y a plus d’override:

public record Car(string Brand, string Model) 
{ 
    public virtual bool Equals(Car car) { ... } // OK 
    public override int GetHashCode() {... }  
} 

Ou l’objet record doit être sealed:

public sealed record Car(string Brand, string Model) 
{ 
    public bool Equals(Car car) { ... } // OK 
    public override int GetHashCode() {... }  
} 

Dans ce cas, si on ne propose pas une implémentation pour GetHashCode(), un warning est généré à la compilation.

La comparaison prend en compte le type

Dans le cas de l’implémentation par défaut de Equals(), la comparaison implique la prise en compte des types des objets record. Ainsi même si les valeurs des membres sont identiques, le type des objets est pris en compte.

Par exemple si on considère des objets record héritant du même objet, possédant des propriétés identiques et dont les valeurs sont aussi identiques, les objets ne pourront être égaux puisqu’ils ne sont pas de même type.

Par exemple:

public record Vehicle(int WheelCount, int DoorCount); 

public record Car(string Brand, string Model, int WheelCount, int DoorCount): Vehicle(WheelCount, DoorCount); 

public record Truck(string Brand, string Model, int WheelCount, int DoorCount): Vehicle(WheelCount, DoorCount); 
 
// ...

var car = new Car { Brand = "Tesla",  Model = "3", WheelCount = 4, DoorCount = 4}; 
var truck = new Truck { Brand = "Tesla",  Model = "3", WheelCount = 4, DoorCount = 4}; 

Console.WriteLine(car.Equals(truck)); // False 
Console.WriteLine(car == truck); // False 

Immutabilité

L’accès en écriture des propriétés d’un objet record est le même que pour une classe. Un objet record n’est pas forcément immatuble. On peut le rendre immutable comme pour une classe en n’utilisant des accesseurs n’autorisant que l’accès en lecture après la construction.

Plusieurs possibilités:

  • En omettant l’accesseur set; la propriété ne peut être initilisée que dans le constructeur:
    public record Car 
    { 
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand { get; } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ERREUR 
    
    var car2  = new Car{ Brand = "Nio" }; // ERREUR 
    
  • Un accesseur init; pour ne permettre l’initialisation qu’avec un constructeur ou un initializer.
    public record Car 
    { 
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand { get; init; } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ERREUR 
    
    var car2  = new Car{ Brand = "Tesla" }; // OK 
    
  • Utiliser un champ readonly et un accesseur init, l’affectation n’est possible que dans un constructeur ou avec un initializer:
    public record Car 
    { 
        private readonly string brand; 
    
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand  
        {  
            get => this.brand; 
            init => this.brand = value; 
        } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ERREUR 
     
    var car2 = new Car { Brand = "Nio" }; //OK  
    

Implémentation implicite de ToString()

Pour les objets record, le compilateur génère une implémentation de ToString() de façon à afficher facilement les valeurs des propriétés.

Par exemple, si on considère l’objet suivant:

public record Car 
{ 
    public string Brand { get; init; } 
    public string Model { get; init; } 
} 

// ...

var car = new Car{ Brand = "Tesla", Model = "3" }; 
Console.WriteLine(car); 

On obtient:

Car { Brand = Tesla, Model = 3 } 

Il est possible de surcharger la méthode ToString():

public record Car 
{ 
    public string Brand { get; init; } 
    public string Model { get; init; } 

    public override string ToString() 
    { 
        // ... 
    } 
} 

PrintMembers()

Dans l’implémentation par défaut de ToString(), pour afficher les valeurs des propriétés, une fonction dont la signature est bool PrintMembers(StringBuilder stringBuilder) est rajoutée à la compilation et est appelée par ToString(). Cette fonction ajoute dans l’objet stringbuilder, les valeurs des propriétés.

Un exemple de l’implémentation de ToString() appelant PrintMembers() est:

public override string ToString() 
{ 
    StringBuilder stringBuilder = new StringBuilder(); 
    stringBuilder.Append("Car");  
    stringBuilder.Append(" { "); 
  
    if (PrintMembers(stringBuilder)) 
    { 
        stringBuilder.Append(" "); 
    } 
 
    stringBuilder.Append("}"); 
    
    return stringBuilder.ToString(); 
} 

On peut proposer une autre implémentation pour PrintMembers() toutefois il faut permettre que cette méthode soit surchargée dans une classe qui hériterait de la classe où se trouve PrintMembers(). Ainsi 2 solutions sont possibles pour cette implémentation:

  • Implémenter une fonction virtuelle et protected de PrintMembers():
    public record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    
        protected virtual bool PrintMembers(StringBuilder stringBuilder) 
        { 
            // ... 
        } 
    } 
    
  • Si l’objet record est sealed (on ne peut donc pas en hériter), il faut implémenter PrintMembers() sous forme d’une fonction privée:

    public sealed record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    
        private bool PrintMembers(StringBuilder stringBuilder) 
        { 
            // ... 
        } 
    }
    

PrintMembers() dans le cas d’héritage

Dans le cas où l’objet record dérive d’un autre objet, les implémentations de PrintMembers() changent suivant si l’objet record est sealed ou non:

  • Si l’objet n’est pas sealed (c’est-à-dire qu’on peut en hériter), PrintMembers() doit être protected override:
    public record Vehicle 
    { 
        public int WheelCount { get; init; } 
        public int DoorCount { get; init; } 
    
        protected virtual bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
    public record Car : Vehicle 
    { 
        public string Brand { get; init; } 
        public string Model { get; init; } 
    
        protected override bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
  • Si l’objet est sealed (c’est-à-dire qu’on ne peut pas en hériter), PrintMembers() doit être une fonction protected sealed override:
    public record Vehicle 
    { 
        public int WheelCount { get; init; } 
        public int DoorCount { get; init; } 
    
        protected virtual bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
    public sealed record Car : Vehicle 
    { 
        public string Brand { get; init; } 
        public string Model { get; init; } 
    
        protected sealed override bool PrintMembers() 
        { 
            //...  
        } 
    } 
    

Implémentation d’un destructeur avec la syntaxe positional record

Si on utilise la syntaxe positional record pour déclarer un objet record, le compilateur rajoute une implémentation pour un constructeur et pour un deconstructeur.

Ainsi, si on considère un record défini de la façon suivante:

public record Car(string Brand, string Model); // déclaration avec la syntaxe positional record 

Un constructeur et un deconstructeur sont ajoutés à la compilation, on peut donc écrire:

var car = new Car("Tesla", "3"); // OK 

(string brand, string model) = car; // OK  
Ajout du constructeur et du deconstructeur par le compilateur si la syntaxe utilisée est positional record

L’ajout du constructeur et du deconstructeur n’est effectué par le compilateur que si on utilise la syntaxe positional record pour déclarer le record.

Si on définit le record de cette façon:

public record Car 
{ 
    public string Brand { get; set; } 
    public string Model { set; set; } 
} 

var car = new Car("Tesla", "3"); // ERREUR, pas de constructeur 
(string brand, string model) = car; // ERREUR, pas de destructeur 

Il est possible de rajouter un deconstructeur explicitement à un objet record (au même titre que le constructeur):

public record Car 
{ 
    public Car(string brand, string model)  
    { 
        this.Brand = brand; 
        this.Model = model; 
    } 

    public string Brand { get; set; } 
    public string Model { set; set; } 
    
    public void Deconstruct(out string brand, out string model)  
        => (brand, model) = (this.Brand, this.Model); 
} 

Pour résumer…

Un objet record est une classe dans laquelle le compilateur rajoute implicitement une implémentation pour des fonctions usuelles comme Equals(), ToString(), un déconstructeur et des surcharges pour les opérateurs d’égalité et d’inégalité.
2 syntaxes permettent de déclarer des objets record:

  • Une syntaxe classique proche de celle des classes: dans ce cas, l’objet record n’est pas forcément immutable, c’est l’implémentation qui détermine explicitement les propriétés de l’objet.
  • Une syntaxe condensée appelée positional record qui permet d’implémenter un objet immutable avec un constructeur permettant d’affecter toutes les propriétés, par exemple:
    public record Car(string Brand, string Model); 
    

    Ce record peut être instancié en utilisant le constructeur implicite:

    var car = new Car("Car brand", "Car model");
    
Classe
(class)
Record
(record)
Structure
(struct)
Type d’objet Objet de type référence Objet de type valeur
Manipulation des variables, passage de paramètre Par copie de référence Par copie de valeur
Stockage Dans le tas managé Dans la pile mais peut être stocké dans le tas managé, par exemple:

  • si la struct contient une référence
  • en cas de boxing
Peut être statique
Oui
Non
Non
Héritage
Supporté
Non
Constructeur sans paramètre Implicite en cas d’absence d’implémentation de constructeur Un constructeur sans paramètre ne peut pas être implémenté.
Constructeur de copie
Non

(A implémenter explicitement)

Oui

si on utilise with

Toute affectation est une copie
Immutable
Non
Non
Oui
Oui si on utilise la syntaxe positional record
Comportement par défaut en cas de comparaison (avec ==, != ou Equals()) Comparaison des références Comparaison des données membre
Surchage de Equals(Object obj)
Possible
Impossible
Possible
Comportement par défaut de ToString() Chaine contenant le type de la classe Chaine contenant un affichage amélioré des valeurs des membres Chaine contenant le type de la struct
Type d’objet dans le code MSIL class struct
Comportement en cas d’absence d’opérateur de portée des membres Privé par défaut Public par défaut
Durée de vie gérée par le Garbage Collector Oui Non
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Leave a Reply