Un record est un type dont le rôle principal est de contenir des données, et dont l'égalité est définie par ces données. Les records sont arrivés avec C# 9. Vous écrivez une ligne, et le compilateur génère les membres dont a besoin une classe de données : propriétés, constructeur, Equals et == basés sur les valeurs, GetHashCode, un ToString lisible, Deconstruct et la prise en charge des copies avec with.
Les records exigent C# 9 ou plus (.NET 5+), donc le code de record de cette page est présenté en C# simple, avec sa sortie en commentaires. La dernière section écrit les mêmes membres à la main en C# 7, que vous pouvez exécuter.
Records positionnels
La forme la plus courte liste les propriétés entre parenthèses après le nom. Chaque paramètre devient une propriété publique init-only du même nom :
public record Product(string Sku, string Name, decimal Price);
var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name); // Mug
Console.WriteLine(mug); // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m; // error CS8852: init-only property
var (sku, name, price) = mug; // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50
À partir de cette seule ligne, le compilateur génère :
- un constructeur qui prend
(string Sku, string Name, decimal Price); - trois propriétés
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()et les opérateurs==et!=, qui comparent tous les trois propriétés ;ToString(), qui affiche le nom du type et chaque propriété publique ;Deconstruct(out string Sku, out string Name, out decimal Price);- un constructeur de copie (protected, ou private dans un record sealed) et une méthode de clonage cachée qu'utilise
with.
Un record peut aussi s'écrire avec un corps normal, ce qui est pratique quand les propriétés ont besoin de valeurs par défaut ou de validation :
public record Customer
{
public required string Email { get; init; } // required: C# 11
public string Name { get; init; } = "";
}
var c = new Customer { Email = "ana@example.com" };
Et les deux formes peuvent se combiner : des paramètres positionnels plus des membres supplémentaires entre accolades.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
Égalité par valeur
Pour une classe normale, == demande « est-ce le même objet ? ». Pour un record, il demande « ont-ils les mêmes valeurs ? » :
var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False: still two objects
GetHashCode est généré en cohérence, donc les records fonctionnent correctement comme clés de dictionnaire et dans un HashSet<T> : un second record aux mêmes valeurs trouve l'entrée du premier.
L'égalité compare chaque champ (pour les records positionnels, le champ derrière chaque propriété) avec EqualityComparer<T>.Default, qui appelle le propre Equals du type. Pour une propriété de collection, c'est l'égalité de référence, ce qui surprend :
public record Basket(string Owner, List<string> Items);
var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y); // False: two different List objects
Si un record contient une collection et doit se comparer selon son contenu, redéfinissez Equals(Basket other) et GetHashCode(), ou utilisez une collection immuable dotée d'une sémantique de valeur de votre choix.
Expressions with : des modifications non destructives
Les records sont en général immuables, donc on en « modifie » un en créant une copie modifiée. with copie chaque propriété, puis applique les affectations entre accolades :
var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };
Console.WriteLine(sale); // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price); // 8.50: the original is untouched
La copie est superficielle. Une propriété de type référence est copiée comme une référence, donc les deux records partagent l'objet :
public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }
var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");
Console.WriteLine(string.Join(",", c1.Tags)); // vip,newsletter
Gardez les propriétés des records immuables jusqu'au bout (IReadOnlyList<T> remplie une fois, ou ImmutableList<T>), ou créez une nouvelle liste dans le with : c1 with { Tags = new List<string>(c1.Tags) }.
ToString
Le ToString généré affiche le nom du type et chaque propriété publique, ce qui rend les records agréables dans les logs et le débogueur :
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
Les collections s'affichent sous forme de leur nom de type (System.Collections.Generic.List`1[System.String]), et les records imbriqués s'affichent récursivement. Vous pouvez remplacer toute la sortie en redéfinissant ToString :
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
record seul signifie record class : un type référence. C# 10 a ajouté record struct, un type valeur doté des mêmes membres générés :
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4)); // True
Console.WriteLine(q); // Point { X = 3, Y = 10 }
La différence de comportement par défaut mérite d'être retenue : un record struct positionnel a des propriétés modifiables ({ get; set; }), conformément au comportement habituel des structs, alors que readonly record struct et record class ont des propriétés init-only. Choisissez entre eux comme entre une struct et une classe : les petites valeurs copiées librement conviennent à readonly record struct ; tout le reste, à record.
Héritage
Un record peut hériter d'un autre record (pas d'une classe, et une classe ne peut pas hériter d'un record). Les paramètres positionnels sont transmis à la base comme des arguments de constructeur :
public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);
Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False: different runtime types are never equal
Console.WriteLine(a); // Circle { Color = red, Radius = 2 }
L'égalité inclut le type à l'exécution, via une propriété générée EqualityContract. C'est pourquoi un Circle n'est jamais égal à un Square de même Color, même quand les deux sont comparés via Shape, et pourquoi ToString et with fonctionnent sur le type dérivé même quand la variable est typée avec la base.
La même chose en C# 7 : une classe à égalité par valeur
Les records génèrent du code que vous pouvez écrire vous-même, et le voir explique leur comportement. Voici une classe C# 7 équivalente à public record Point(int X, int Y); : propriétés en lecture seule, constructeur, Deconstruct, égalité par valeur, code de hachage cohérent, ==, ToString, et une méthode With qui tient lieu d'expression with.
Sortie :
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
Environ 30 lignes pour deux propriétés, et chaque nouvelle propriété oblige à retoucher le constructeur, Deconstruct, Equals, GetHashCode et ToString. En oublier un est un bug classique (deux points qui sont == mais dont les hachages diffèrent, si bien qu'un HashSet les perd de vue). C'est cette maintenance que suppriment les records.
La classe est sealed volontairement : l'égalité par valeur combinée à l'héritage demande la vérification de type supplémentaire que les records génèrent via EqualityContract, et sceller la classe contourne le problème.
Quand utiliser un record
Les records conviennent aux données définies par leurs valeurs et qui ne changent pas après leur création :
- les modèles de requête et de réponse des API web ;
- les messages, commandes et événements échangés entre les parties d'un système ;
- les objets de configuration et d'options ;
- les clés de dictionnaire composites (
record CacheKey(string Region, int Year)) ; - les résultats d'un calcul (
record PriceQuote(decimal Net, decimal Tax)).
Ils conviennent mal là où l'identité compte plus que les valeurs : une entité Entity Framework reste « le client 42 » même après un changement de nom, et le suivi des modifications d'EF Core repose sur l'identité de référence. Utilisez une classe dans ce cas.
Erreurs courantes
- S'attendre à une égalité profonde pour les collections. Une propriété
List<T>se compare par référence. Deux records aux listes d'apparence identique ne sont pas égaux. - S'attendre à ce que
withfasse une copie profonde. Les objets imbriqués et les collections sont partagés entre l'original et la copie. - Un
record structpositionnel modifiable par accident. Ajoutezreadonly, sauf si vous voulez des propriétés modifiables. - Utiliser des records comme entités EF Core. L'égalité par valeur et la copie entrent en conflit avec le suivi des modifications.
- Ajouter un record à un projet C# 8. Les records exigent C# 9 (la valeur par défaut pour .NET 5 et plus). Sur des cibles plus anciennes, écrivez la classe à la main comme ci-dessus.
Questions fréquentes
Qu'est-ce qu'un record en C# ?
Un record (C# 9) est une classe, ou avec record struct (C# 10) une struct, pour laquelle le compilateur génère une égalité par valeur, un ToString() lisible, une méthode Deconstruct et la prise en charge des copies avec with. public record Product(string Sku, decimal Price); est un type complet avec deux propriétés init-only. Deux records aux valeurs de propriétés égales sont égaux, même s'il s'agit d'objets différents.
Quelle est la différence entre un record et une classe en C# ?
Un record est une classe en dessous, donc un type référence qui peut hériter d'autres records. Les différences viennent des membres générés : les records se comparent par valeur (== et Equals vérifient chaque champ), affichent leurs propriétés via ToString() et prennent en charge with. Une classe normale se compare par référence et affiche son nom de type, sauf si vous écrivez vous-même ces membres.
Que fait l'expression with en C# ?
var sale = product with { Price = 6.00m }; crée un nouveau record qui copie toutes les propriétés de product puis définit celles qui sont listées. L'original reste inchangé. La copie est superficielle : une propriété List<T> est partagée par les deux records, donc ajouter un élément via l'un est visible via l'autre.
Qu'est-ce qu'un record struct en C# ?
record struct (C# 10) est un type valeur doté des mêmes membres générés qu'une classe record : égalité par valeur, ToString, Deconstruct et with. Contrairement à une classe record, ses propriétés positionnelles sont modifiables par défaut ; déclarez-le readonly record struct pour les rendre init-only. Utilisez-le pour de petites valeurs comme des coordonnées ou des montants.
Quand utiliser un record en C# ?
Pour des données dont l'identité est leur valeur : DTO, modèles de requête et de réponse d'API, messages et événements, configuration, et clés de dictionnaire. Évitez les records pour les entités qui changent au fil du temps et sont identifiées par un identifiant, comme les entités Entity Framework, car l'égalité par valeur et les copies avec with vont à l'encontre du suivi des modifications.