Cet article fait partie d’une série d’articles sur les apports fonctionnels de 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.
Déclaration et construction
Construction en utilisant une expression avec with
Comparaison
Surcharger Equals() et les opérateurs d’égalité et d’inégalités
Comparaison prend en compte le type
Implémentation implicite de ToString()
PrintMembers()
PrintMembers() dans le cas d’héritage
Implémentation d’un destructeur avec la syntaxe positional 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
etCar.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 string Brand { get; } public string 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 |
---|---|
|
|
|
|
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. avecsealed
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 accesseurinit
, 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
dePrintMembers()
: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émenterPrintMembers()
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 êtreprotected 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 fonctionprotected 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
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:
|
|
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 |
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 |
- Records (C# reference): https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record
- Introducing C# 9: Records: https://anthonygiretti.com/2020/06/17/introducing-c-9-records/
- C# 9: https://morioh.com/p/8bb2b55e618d
- C# 9.0 on the record: https://devblogs.microsoft.com/dotnet/c-9-0-on-the-record/
- What’s new in C# 9.0: https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9
Super
Good job