Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0 et C# 10.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
.
C# 10 complète les fonctionnalités des records en permettant de créer des records de type valeur similaires aux structs. Les records de type valeur peuvent être définis en utilisant le mot-clé record struct
. A partir de C# 10, il est aussi possible d’utiliser la notation record class
(notation équivalente à record
).
Cet article a pour but de passer en revue les propriétés des objets record et record struct.
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
Immutabilité
record readonly struct
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
.
A partir de C# 10, on peut utiliser la notation record class
(notation équivalente à record
), par exemple:
public record class Car(string Brand, string Model);
ou
public record class Car
{
// ...
}
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 ou structure
Un objet record peut être compilé sous forme d’une classe ou d’une struct (à partir de C# 10).
Si on déclare un record avec le mot-clé record
, le code MSIL (i.e. MicroSoft Intermediate Language) obtenu si on compile le code C# suivant, contient des des instructions quasi similaires à celles d’une classe:
Code C# | Instructions MSIL |
---|---|
|
|
|
|
Notation possible à partir de C# 10:
|
En déclarant un record de type valeur avec record struct
(à partir de C# 10), le code MSIL est similaire à celui d’une struct:
Code C# | Instructions MSIL |
---|---|
|
|
|
|
La différence la plus notable entre les 2 couples d’objets est que le compilateur génère implicitement davantage de fonctions pour les objets record
et record struct
comme par exemple ToString()
, PrintMembers()
, Equals()
, GetHashCode()
etc…
Déclaration et construction
Comme indiqué plus haut, il existe 2 syntaxes pour déclarer des objets record ou record struct:
- Une syntaxe explicite similaire à celle des classes ou structs: 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 };
Tous les éléments de syntaxe valable pour les records sont aussi valables pour les record structs, on peut utiliser:
- Une syntaxe explicite similaire à celle des structs:
public record struct Car { public string Brand { get; set; } public string Model { get; set; } }
- Une syntaxe condensée (i.e. positional record):
public record struct Car(string Brand, string Model);
Construction en utilisant une expression avec with
On peut instancier des objets record et record structs à 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" };
Depuis C# 10, il est possible d’utiliser l’opérateur with
avec des structs (voir Amélioration des structures) et par suite avec les record structs:
public record struct 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);
Même si les objets record sont des classes, une classe ne peut pas hériter d’un record.
⚠ L’héritage n’est pas supportée par les structures, par suite il n’est pas possible d’effectuer des héritages avec des record structs.
Comparaison
Comme indiqué plus haut, la comparaison entre des objets record (en tant qu’objet de type référence) 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
Dans le cas des objets record struct, comme il s’agit d’objet de type valeur, le comportement est le même que pour les structs: les 2 objets sont égaux s’ils sont du même type et si leurs membres sont de même valeur. Toutefois la différence d’implémentation est que pour les structs, la comparaison se fait par réflexion alors que pour les record structs, le compilateur rajoute une fonction Equals()
.
Ainsi pour la comparaison, le comportement est le même pour les records et les records struct.
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 ou record struct est le même que pour, respectivement, une classe ou une structure. Un objet record ou record struct 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
record readonly struct
A partir de C# 10, il est possible de définir des record structs immutables avec la notation readonly record struct
. Le comportement est semblable aux objets readonly struct
(apparus en C# 7.2): le compilateur vérifie que les membres de l’objet ne peuvent pas être modifiés en dehors d’une auto-initialisation directe d’un membre ou d’une initialisation dans le constructeur.
Ainsi pour un objet readonly record struct
:
- Une propriété ne pourra pas avoir d’accesseurs en écriture:
public readonly record struct Car { public string Brand { get; set; } // ERREUR public string Model { get; } // OK }
- Les variables membres publiques doivent utiliser le mot-clé
readonly
:public readonly record struct Car { public string Brand; // ERREUR public readonly string Model; // OK }
- On ne peut pas modifier un membre même dans une fonction membre (en dehors du constructeur):
public readonly record struct Car { public readonly string Brand; public readonly string Model; public void ChangeMe(string newBrand) { Brand = newBrand; // ERREUR } }
- La déclaration d’évènements dans la structure n’est pas autorisée:
public readonly record struct Car { public event EventHandler Event; // ERREUR }
- Certaines modifications restent toutefois possibles comme, par exemple, modifier une liste:
public readonly record struct Car { public readonly List Items = new List(); } // ... var car = new Car(); car.Items.Add("new Item"); // OK
Implémentation implicite de ToString()
Pour les objets record et record struct, 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()
{
// ...
}
}
La surcharge de ToString()
est aussi possible pour les objets record struct.
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 un record qui hériterait du record où se trouve PrintMembers()
. Ainsi 2 solutions sont possibles pour cette implémentation:
- Implémenter une fonction
PrintMembers()
virtuelle etprotected
dans le cas des record etprivate
dans le cas des record structs:public record Car { public string Brand { get; set; } public string Model { get; set; } protected virtual bool PrintMembers(StringBuilder stringBuilder) { // ... } }
Ou
public record struct Car { public string Brand { get; set; } public string Model { get; set; } private 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(StringBuilder builder) { //... } } public record Car : Vehicle { public string Brand { get; init; } public string Model { get; init; } protected override bool PrintMembers(StringBuilder builder) { //... } }
- 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(StringBuilder builder) { //... } } public sealed record Car : Vehicle { public string Brand { get; init; } public string Model { get; init; } protected sealed override bool PrintMembers(StringBuilder builder) { //... } }
Implémentation d’un déconstructeur avec la syntaxe positional record
Si on utilise la syntaxe positional record pour déclarer un objet record ou record struct, 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 déconstructeur
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 ou une structure dans laquelle le compilateur rajoute implicitement une implémentation pour des fonctions usuelles:
- En C# 9: il est seulement possible de créer des objets record qui sont des objets de type référence avec la notation
record
. - A partir de C# 10: on peut utiliser la notation
record class
pour créer des objets record de type référence et on peut utiliser des records de type valeur avec la notationrecord struct
. - Enfin, C# 10 permet aussi de créer des records de type valeur immutables avec la notation
readonly record struct
.
Les fonctions usuelles pouvant être rajoutées par le compilateur dans le cas des objets record ou record struct sont: des constructeurs, Equals()
, GetHashCode()
, ToString()
, PrintMembers()
, 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 et record struct:
- Une syntaxe classique proche de celle des classes ou des structures: dans ce cas, l’objet record ou record struct 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 pour déclarer un objet record:
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");
Pour déclarer un objet record struct:
public record struct Car(string Brand, string Model);
Classe ( class ) |
Record ( record ) |
Structure ( struct ) |
Record struct ( record 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
|
||
Héritage |
Supporté
|
Non
|
||
Constructeur sans paramètre | Implicite en cas d’absence d’implémentation de constructeur | Avant C# 11: un constructeur sans paramètre ne peut pas être implémenté. | Implicite en cas d’absence d’implémentation de constructeur | |
Constructeur de copie |
Non
(A implémenter explicitement) |
Oui
si on utilise |
Toute affectation est une copie | |
Immutable |
Non
|
Non
|
Non
|
Non
|
Oui
si on utilise la syntaxe positional record |
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
|
Impossible
|
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 |
Chaine contenant un affichage amélioré des valeurs des membres |
Type d’objet dans le code MSIL | .class |
.class |
.class |
.class |
Comportement en cas d'absence d'opérateur de portée des membres | Privé 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
- What's new in C# 10: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-10#record-structs
Super
Good job