Menu

Record in C#: uguaglianza per valore, espressioni with e record struct

Cosa sono i record in C# (da C# 9): sintassi posizionale, i membri generati dal compilatore, uguaglianza basata sui valori, copie non distruttive con with, il ToString integrato, record struct di C# 10, ereditarietà tra record e la classe equivalente scritta a mano.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

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 with faccia una copia profonda. Oggetti annidati e collezioni sono condivisi tra l'originale e la copia.
  • Un record struct posizionale modificabile per sbaglio. Aggiungi readonly a 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.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA