Un record è un tipo il cui compito principale è contenere dati, e la cui uguaglianza è definita da quei dati. I record sono arrivati in C# 9. Scrivi una riga, e il compilatore genera i membri di cui ha bisogno una classe di dati: proprietà, un costruttore, Equals e == basati sui valori, GetHashCode, un ToString leggibile, Deconstruct e il supporto per le copie con with.
I record richiedono C# 9 o successivo (.NET 5+), quindi il codice con i record in questa pagina è mostrato come C# semplice, con l'output nei commenti. L'ultima sezione scrive a mano gli stessi membri in C# 7, e quel codice puoi eseguirlo.
Record posizionali
La forma più breve elenca le proprietà tra parentesi dopo il nome. Ogni parametro diventa una proprietà pubblica init-only con lo stesso nome:
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
Da quella sola riga il compilatore genera:
- un costruttore che riceve
(string Sku, string Name, decimal Price); - tre proprietà
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()e gli operatori==e!=, che confrontano tutti le tre proprietà;ToString(), che stampa il nome del tipo e tutte le proprietà pubbliche;Deconstruct(out string Sku, out string Name, out decimal Price);- un costruttore di copia (protected, o private in un record sealed) e un metodo di clonazione nascosto usato da
with.
Un record si può scrivere anche con un corpo normale, cosa comoda quando le proprietà hanno bisogno di valori predefiniti o di validazione:
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" };
E le due forme si possono combinare: parametri posizionali più membri aggiuntivi tra graffe.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
Uguaglianza per valore
Per una classe normale, == chiede "sono lo stesso oggetto?". Per un record chiede "hanno gli stessi valori?":
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 viene generato in modo coerente, quindi i record funzionano correttamente come chiavi di un dizionario e in un HashSet<T>: un secondo record con gli stessi valori trova la voce del primo.
L'uguaglianza confronta ogni campo (per i record posizionali, il campo dietro ogni proprietà) con EqualityComparer<T>.Default, che chiama l'Equals del tipo stesso. Per una proprietà che è una collezione, questo significa uguaglianza per riferimento, cosa che sorprende molti:
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
Se un record contiene una collezione e deve essere confrontato in base al suo contenuto, ridefinisci Equals(Basket other) e GetHashCode(), oppure usa una tua collezione immutabile con semantica di valore.
Espressioni with: modifiche non distruttive
I record di solito sono immutabili, quindi ne "modifichi" uno creando una copia modificata. with copia tutte le proprietà, poi applica le assegnazioni tra graffe:
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 copia è superficiale. Una proprietà di tipo riferimento viene copiata come riferimento, quindi i due record condividono l'oggetto:
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
Oppure mantieni le proprietà del record immutabili fino in fondo (IReadOnlyList<T> riempita una volta sola, o ImmutableList<T>), oppure crea una nuova lista nel with: c1 with { Tags = new List<string>(c1.Tags) }.
ToString
Il ToString generato stampa il nome del tipo e tutte le proprietà pubbliche, il che rende i record comodi nei log e nel debugger:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
Le collezioni vengono stampate con il nome del loro tipo (System.Collections.Generic.List`1[System.String]), e i record annidati vengono stampati ricorsivamente. Puoi sostituire l'intero output ridefinendo ToString:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
record da solo significa record class: un tipo riferimento. C# 10 ha aggiunto record struct, un tipo valore con gli stessi membri generati:
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 }
Vale la pena ricordare la differenza nei default: un record struct posizionale ha proprietà modificabili ({ get; set; }), in linea con il comportamento abituale delle struct, mentre readonly record struct e record class le hanno init-only. Scegli tra i due come sceglieresti tra una struct e una classe: i valori piccoli che vengono copiati liberamente si adattano a readonly record struct; tutto il resto, a record.
Ereditarietà
Un record può ereditare da un altro record (non da una classe, e una classe non può ereditare da un record). I parametri posizionali vengono passati alla base come argomenti del costruttore:
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'uguaglianza include il tipo in esecuzione, tramite una proprietà EqualityContract generata. Ecco perché un Circle non è mai uguale a uno Square con lo stesso Color, anche se entrambi sono confrontati attraverso Shape, e perché ToString e with funzionano sul tipo derivato anche quando la variabile è del tipo base.
La stessa cosa in C# 7: una classe con uguaglianza per valore
I record generano codice che puoi scrivere da solo, e vederlo ne spiega il comportamento. Ecco una classe C# 7 equivalente a public record Point(int X, int Y);: proprietà di sola lettura, un costruttore, Deconstruct, uguaglianza per valore, un hash code coerente, ==, ToString e un metodo With che fa le veci dell'espressione with.
Output:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
Circa 30 righe per due proprietà, e ogni nuova proprietà obbliga a rimettere mano a costruttore, Deconstruct, Equals, GetHashCode e ToString. Dimenticarne uno è un bug classico (due punti che sono == ma hanno hash diversi, così un HashSet li perde di vista). È questa manutenzione che i record eliminano.
La classe è sealed di proposito: l'uguaglianza per valore combinata con l'ereditarietà richiede il controllo di tipo aggiuntivo che i record generano tramite EqualityContract, e rendere la classe sealed aggira il problema.
Quando usare un record
I record si adattano a dati definiti dai loro valori e che non cambiano dopo la creazione:
- modelli di richiesta e risposta per le API web;
- messaggi, comandi ed eventi scambiati tra parti di un sistema;
- oggetti di configurazione e di opzioni;
- chiavi composte per i dizionari (
record CacheKey(string Region, int Year)); - risultati di un calcolo (
record PriceQuote(decimal Net, decimal Tax)).
Si adattano male dove l'identità conta più dei valori: un'entità di Entity Framework è "il cliente 42" anche dopo che il suo nome cambia, e il change tracking di EF Core si basa sull'identità dei riferimenti. Lì usa una classe.
Errori comuni
- Aspettarsi l'uguaglianza profonda per le collezioni. Una proprietà
List<T>si confronta per riferimento. Due record con liste apparentemente uguali non sono uguali. - Aspettarsi che
withfaccia una copia profonda. Oggetti annidati e collezioni sono condivisi tra l'originale e la copia. - Un
record structposizionale modificabile per sbaglio. Aggiungireadonlya meno che tu non voglia proprietà impostabili. - Usare i record come entità di EF Core. L'uguaglianza per valore e la copia sono in conflitto con il change tracking.
- Aggiungere un record a un progetto C# 8. I record richiedono C# 9 (il default per .NET 5 e successivi). Con target più vecchi, scrivi la classe a mano come mostrato sopra.
Domande frequenti
Cos'è un record in C#?
Un record (C# 9) è una classe, o con record struct (C# 10) una struct, per cui il compilatore genera l'uguaglianza basata sui valori, un ToString() leggibile, un metodo Deconstruct e il supporto per le copie con with. public record Product(string Sku, decimal Price); è un tipo completo con due proprietà init-only. Due record con valori delle proprietà uguali sono uguali, anche se sono oggetti diversi.
Che differenza c'è tra un record e una classe in C#?
Sotto sotto un record è una classe, quindi è un tipo riferimento e può ereditare da altri record. Le differenze sono i membri generati: i record si confrontano per valore (== ed Equals controllano ogni campo), stampano le loro proprietà con ToString() e supportano with. Una classe normale si confronta per riferimento e stampa il nome del suo tipo, a meno che quei membri non li scriva tu.
Cosa fa l'espressione with in C#?
var sale = product with { Price = 6.00m }; crea un nuovo record che copia tutte le proprietà di product e poi imposta quelle elencate. L'originale resta invariato. La copia è superficiale: una proprietà List<T> è condivisa dai due record, quindi aggiungere elementi attraverso uno è visibile anche attraverso l'altro.
Cos'è un record struct in C#?
record struct (C# 10) è un tipo valore con gli stessi membri generati di una record class: uguaglianza per valore, ToString, Deconstruct e with. A differenza di una record class, le sue proprietà posizionali sono modificabili di default; dichiaralo readonly record struct per renderle init-only. Usalo per valori piccoli come coordinate o importi di denaro.
Quando conviene usare un record in C#?
Per dati la cui identità coincide con i loro valori: DTO, modelli di richiesta e risposta delle API, messaggi ed eventi, configurazione e chiavi per i dizionari. Evita i record per entità che cambiano nel tempo e sono identificate da un id, come le entità di Entity Framework, perché l'uguaglianza per valore e le copie con with vanno contro il change tracking.