Una delle novità più interessanti introdotte con C# 9 è la possibilità di gestire l’immutabilità out-of-the-box grazie all’utilizzo dei record type.
Il concetto di immutabilità, fondamentale nel paradigma funzionale e ampiamente utilizzato in linguaggi come F#, consiste nella creazione di oggetti che, una volta istanziati, non possono più modificare il loro stato.
I record sono utili per creare strutture la cui responsabilità è l’incapsulamento di dati. Sono comunemente utilizzati come Data Transfer Object (DTO) per il trasporto di informazioni tra le varie componenti di un sistema software.
Indipendentemente dall’architettura utilizzata, la natura immutabile e leggera dei record li rende adatti per rappresentare i DTO, facilitarne il trasporto e garantire l’integrità dei dati durante il passaggio tra le boundaries del sistema.
In C#, prima dei record era già possibile creare strutture immutabili utilizzando design patterns e alcune best practices della OOP, ma questo richiedeva la scrittura di una quantità significativa di boilerplate.
I record implementano nativamente lo stesso principio, fornendo inoltre una serie di benefici e funzionalità come:
- Sintassi posizionale concisa
- Uguaglianza e confronto basati sul valore (value equality)
- Metodi per la rappresentazione dei dati
- Copia dei record (non-destructive mutation)
- Estrazione dei valori delle proprietà in variabili separate (destructuring)
In questo articolo illustreremo tutte le caratteristiche dei record, come utilizzarli al meglio e quali sono i loro limiti.
Anatomia di un Record
Un record type è una classe leggera che viene utilizzata principalmente per modellare dati e si dichiara utilizzando le keywords record [class]. La keyword class è stata messa tra parentesi perché è opzionale e può essere omessa in fase di dichiarazione.
Questo è dovuto al fatto che, di default, quando ci si riferisce in modo generico a un record, si parla di record [class], un reference type analogo alle classi.
C# 10 ha introdotto la possibilità di dichiarare record struct, permettendo ai record di essere istanziati anche come value type. I record struct si dichiarano utilizzando le keywords record struct.
La definizione di un record può avvenire in modo manuale, strutturando il tipo in modo esplicito, oppure sfruttando la sintassi posizionale concisa.
Definizione esplicita del tipo
Prevede la definizione manuale del record, esattamente come faremmo per la definizione di una classe, andando a specificare le auto-implemented properties che compongono lo stato della struttura.
// La keyword [class] viene omessa poiché opzionale
public record Veicolo {
public string Marca { get; init; } = default!;
public string Modello { get; set; } = default!;
public string Targa { get; init; } = default!;
}C#In questo caso l’immutabilità viene ottenuta grazie agli init-only setter (altra funzionalità introdotta con C# 9), che permettono la modifica del valore della proprietà solamente all’interno del costruttore o nell’object initializer.
Se necessario, possiamo comunque definire proprietà read-write, come nel caso della proprietà “Modello” dell’esempio precedente, andando a creare un contesto di definizione ibrido.
Dichiarando un record in questo modo, il compilatore sintetizzerà un costruttore di default e procederemo all’istanza come di consueto.
var golf = new Veicolo {
Marca = "Volkswagen",
Targa = "XX123XX",
Modello = "Golf"
};
golf.Modello = "Golf 6";C#Notate che, siccome la proprietà “Modello” è marcata come set, sarà possibile modificarne il valore dopo l’istanza dell’oggetto. Il compilatore, al contrario, ci darebbe errore se provassimo a fare la stessa cosa con le proprietà definite come init-only.
Sintassi posizionale concisa
È con la sintassi concisa che emergono le vere potenzialità dei record. Di seguito la definizione dell’esempio precedente espressa in una riga di codice con sintassi concisa.
public record Veicolo(string Marca, string Modello, string Targa);C#Dichiarando un record con questa sintassi il compilatore andrà a sintetizzare per noi:
- Le auto-implemented properties corrispondenti ai parametri definiti nella dichiarazione del record
- Un costruttore parametrico per l’idratazione dei valori delle proprietà
Le auto-implemented properties generate saranno:
- init-only se dichiariamo readonly record struct o record [class]
- read-write se dichiariamo record struct
Non verrà invece generato il costruttore di default; Questo ci obbligherà ad istanziare gli oggetti utilizzando il costruttore parametrico.
var golf = new Veicolo("Volkswagen", "XX123XX", "Golf");C#La sintassi concisa è detta anche posizionale perché l’ordine dei parametri nel costruttore sintetizzato corrisponde all’ordine dei parametri nella dichiarazione del record.
È comunque consentito l’utilizzo dei named arguments nel costruttore sintetizzato. Ciò significa che è possibile specificare i valori dei parametri utilizzando il nome del parametro, anziché basarsi sull’ordine dei parametri nella dichiarazione del record.
var golf = new Veicolo(
Targa: "XX123XX",
Marca: "Volkswagen",
Modello: "Golf"
);C#Questo approccio può migliorare la leggibilità del codice, soprattutto quando si lavora con record molto strutturati, e offre la flessibilità di variare l’ordine dei parametri nel costruttore.
Se necessario, potremo andare a definire proprietà read-write all’interno della dichiarazione del tipo espresso con sintassi concisa. Queste proprietà modificabili potranno essere valorizzate sfruttando il setter oppure con object initializer.
public record Veicolo(string Marca, string Targa) {
public string Modello { get; set; } = default!;
}
var golf = new Veicolo("Volksvagen", "XX123XX") { Modello = "Golf" };
golf.Modello = "Golf 6";C#Ereditarietà
I record supportano l’ereditarietà soltanto per record [class]. L’ereditarietà non è invece applicabile a record struct o tra class e record.
Come per le class, un record può essere inoltre definito come:
- abstract se intendiamo utilizzarlo come record base da usare in sottotipi specializzati
- sealed se vogliamo impedire meccanismi di ereditarietà sul tipo
Rispetto alla sintassi tradizionale, la sintassi concisa semplifica la dichiarazione e l’ereditarietà di record derivati.
public record Veicolo(string Marca, string Targa);
public record Automobile(string Marca, string Modello, string Targa, int Porte) : Veicolo(Marca, Targa);C#Nell’esempio precedente il record derivato “Automobile” specifica nella sua firma tutti i parametri che serviranno:
- alla definizione del suo stato
- alla creazione del costruttore parametrico, per idratare le sue proprietà e quelle del record base “Veicolo”
In questi casi, il compilatore non farà hiding delle proprietà del record base ma si limiterà a generare quelle mancanti per differenza, evitando quelle già definite dal record base.
I campi ereditati dal record base saranno inclusi nel calcolo di uguaglianza strutturale e nel confronto tra record del record derivato.
Uguaglianza e confronto basati sul valore
Quando si confrontano due istanze di reference type senza modificarne i metodi predefiniti, il confronto avviene basandosi sull’indirizzo di memoria occupato nello heap dagli oggetti (reference equality).

public class Veicolo {
public string Marca { get; init; } = default!;
public string Modello { get; init; } = default!;
public string Targa { get; init; } = default!;
}
var golf = new Veicolo() {
Marca = "Volkswagen",
Modello = "Golf 6",
Targa = "XX123XX"
};
var golf2 = new Veicolo() {
Marca = "Volkswagen",
Modello = "Golf 6",
Targa = "XX123XX"
};
// Reference equality: il confronto è basato sull’indentità dell’istanza
Console.WriteLine(golf == golf2); // false
Console.WriteLine(ReferenceEquals(golf, golf2)); // falseC#Pur essendo un reference type, i record si distinguono dalle class perché seguono una semantica di uguaglianza basata sul valore (value-based equality).
Due record, quindi, si dicono uguali se sono dello stesso tipo e i valori contenuti nelle loro proprietà sono uguali. Questa caratteristica rende i record dei veri e propri value objects.
public record Veicolo(string Marca, string Modello, string Targa);
var tesla = new Veicolo("Tesla", "Model 3", "XX123XX");
var tesla2 = new Veicolo("Tesla", "Model 3", "XX123XX");
// Value equality: il confronto è basato sui valori delle proprietà, pur essendo istanze differenti
Console.WriteLine(tesla == tesla2); // True
Console.WriteLine(ReferenceEquals(tesla, tesla2)); //falseC#Per implementare questo meccanismo di confronto il compilatore andrà a sintetizzare per noi:
- gli operatori == e !=
- un override del metodo Object.Equals()
- un override del metodo Object.GetHashCode() che restituisce un hash basato sul contenuto delle proprietà del record
- l’interfaccia System.IEquatable<T>, marcando come virtual il metodo Equals() per eventuali overrides
Su questo tema, record e struct si comportano apparentemente allo stesso modo. La differenza sta nella modalità di implementazione dei meccanismi di confronto: la value equality di struct si basa sulla reflection (con un costo computazionale aggiuntivo da parte del CLR), mentre record utilizza metodi di confronto ed operatori sintetizzati dal compilatore.
Ereditarietà ed uguaglianza
Anche se il confronto tra record ha una semantica value-based, uno dei requisiti per l’uguaglianza tra due istanze è che gli oggetti, anche se record derivati dallo stesso record base, siano dello stesso tipo.
public record Persona(string Cognome, string Nome);
public record Studente(string Cognome, string Nome, string Matricola) : Persona(Cognome, Nome);
public record Professore(string Cognome, string Nome, string Matricola) : Persona(Cognome, Nome);
var studente = new Studente("Rossi", "Mario", "XX123");
var professore = new Professore("Rossi", "Mario", "XX123");
// Il confronto restituirà False perché il tipo di studente è diverso dal tipo di professore
Console.WriteLine(studente == professore); // falseC#Per fare questo, il compilatore sintetizza la proprietà di sola lettura EqualityContract, che restituiste il Type del record, che verrà utilizzato dagli operatori e dai metodi di confronto come parte integrante del processo di comparazione.
Copia dei record
Poiché i record sono immutabili di default, se volessimo modificare uno o più valori delle proprietà, sarebbe necessario creare una nuova istanza partendo dai valori dell’oggetto originale.
Per semplificare quest’operazione, è possibile utilizzare la keyword with ottenendo una shallow copy dell’istanza originale, in quella che viene definita una “non-destructive mutation”.
L’operatore ci permette di creare una nuova istanza in modo implicito, andando a modificare solamente i valori delle proprietà che cambiano e lasciando inalterati i valori delle altre proprietà.
public record Veicolo(string Modello, string Marca, string Targa);
var golf = new Veicolo("Volkswagen", "Golf", "XX123XX");
var polo = golf with { Modello = "Polo" };
// Il confronto value-based restituirà False perché le due istanze hanno proprietà con valori differenti
Console.WriteLine(golf == polo); // False
Console.WriteLine(ReferenceEquals(golf, polo)); // falseC#Per avere un’ulteriore riprova del fatto che il confronto è basato sul valore, possiamo fare una non-destructive mutation di un record senza modificare alcun valore delle proprietà e poi fare un confronto.
public record Veicolo(string Marca, string Modello, string Targa);
var golf = new Veicolo("Volkswagen", "Golf", "XX123XX");
var golf2 = golf with { };
// Il confronto value-based continuerà a restituire true anche se la reference dei due oggetti è differente
Console.WriteLine(golf == golf2); // true
Console.WriteLine(ReferenceEquals(golf, golf2)); // falseC#Per implementare questo meccanismo il compilatore sintetizza un copy constructor protected e un metodo Clone() ad uso interno. Questo è il motivo per cui non sarà possibile creare un metodo Clone() nei nostri record, né fare override di quello sintetizzato dal compilatore.
Destructuring
Come avviene per le tuple, anche i record consentono il destructuring, ovvero la capacità di estrarre i valori dei membri e assegnarli a variabili separate in modo semplice e diretto.
public record Veicolo(string Marca, string Modello, string Targa);
var golf = new Veicolo("Volkswagen", "Golf", "XX123XX");
var (marca, modello, targa) = golf;
Console.Write($"{marca} {modello} - {targa}");C#Come si può notare nell’esempio precedente, il destructuring è posizionale e dipende dall’ordine in cui vengono descritte le proprietà nella firma del record dichiarato con sintassi concisa.
A differenza di altri linguaggi come Typescript, in C# non è possibile fare destructuring solamente di alcuni membri del record. È possibile invece ometterli inserendo “_” alla posizione del membro che si intende scartare.
var golf = new Veicolo("Volkswagen", "Golf", "XX123XX");
var (marca, _, targa) = golf;
Console.Write($"{marca} - {targa}");C#In questo esempio i valori delle proprietà “Marca” e “Targa” del record vengono assegnati alle nuove variabili, mentre il valore della proprietà “Modello” viene scartato.
È possibile ottenere lo stesso risultato del destructuring sfruttando il metodo Deconstruct(). Il metodo Deconstruct() viene sintetizzato automaticamente dal compilatore, a meno che non venga definito manualmente, e la sua firma riflette l’ordine e il numero di proprietà del record.
public record Veicolo(string Marca, string Modello, string Targa);
var golf = new Veicolo("Volkswagen", "Golf", "XX123XX");
string marca, modello, targa;
golf.Deconstruct(out marca, out modello, out targa);
Console.Write($"{marca} {modello} - {targa}");C#Metodi per la rappresentazione dei dati
Tra le varie funzionalità sintetizzate dal compilatore, ci sono due metodi generati come facilitatori per la rappresentazione di un record:
- l’override del metodo Object.ToString()
- il metodo virtual PrintMembers()
In generale, il metodo ToString() restituisce una rappresentazione in formato stringa dell’oggetto corrente. Nel contesto dei Reference type, l’implementazione predefinita di ToString() restituisce il nome completo del tipo dell’oggetto.
public class Veicolo {
public string Marca { get; init; } = default!;
public string Modello { get; init; } = default!;
public string Targa { get; init; } = default!;
}
var golf = new Veicolo() {
Marca = "Volkswagen",
Modello = "Golf 6",
Targa = "XX123XX"
};
Console.Write(golf);C#L’output di questo esempio sarà:

Quando si lavora con i record, il metodo ToString() è implementato per restituire una rappresentazione in formato stringa dei valori e delle proprietà del record. L’output generato elenca i nomi delle proprietà e i relativi valori, separati da virgole.
I valori associati alle proprietà sono ottenuti chiamando il metodo ToString() del tipo associato alla proprietà.
public record Veicolo(string Marca, string Modello, string Targa);
var golf = new Veicolo("Volkswagen", "Golf", "XX123XX");
Console.Write(golf);C#L’output di questo esempio sarà:

La rappresentazione delle coppie proprietà\valore è demandata al metodo PrintMembers(), un metodo protected che può essere sovrascritto nel caso in cui avessimo bisogno di rappresentazioni personalizzate.
public record Veicolo(string Marca, string Modello, string Targa) {
protected virtual bool PrintMembers(StringBuilder builder) {
builder.Append($" Marca: {Marca} - ");
builder.Append($" Modello: {Modello} - ");
builder.Append($" Targa: {Targa}");
return true;
}
}C#Questa caratteristica dei record è utile per ottenere una rappresentazione intelligibile di un oggetto e può essere utilizzata per:
- logging
- visualizzazione dei dati a scopo di debug
- presentazione dell’oggetto all’utente
Record in azione
I record sono una potente feature che offre numerosi vantaggi per la gestione dei dati immutabili. Tuttavia, esistono alcune considerazioni specifiche da fare quando si utilizzano i record types in scenari avanzati.
Record e LINQ
La value-based equality e l’implementazione dei metodi di default consentono ai record di essere utilizzati come chiavi nelle operazioni LINQ.
Quando viene utilizzato un record come chiave in operazioni come GroupBy(), Distinct(), Union() o Join(), LINQ utilizza il metodo GetHashCode(), descritto in precedenza, per determinare l’uguaglianza dei record durante le operazioni di confronto.
public record Veicolo(string Marca, string Modello, string Targa);
List<Veicolo> automobili = new() {
new Veicolo("Volkswagen", "Golf", "XX123XX"),
new Veicolo("Fiat", "Tipo", "YY999XX"),
new Veicolo("Tesla", "Model 3", "XX666XX"),
new Veicolo("Volkswagen", "Golf", "XX123XX"),
new Veicolo("Citroen", "C1", "WW433XX"),
new Veicolo("Volkswagen", "Golf", "XX123XX")
};
// Ecco l'effetto sul conteggio degli elementi nella lista usando il record come chiave nella Distinct()
Console.WriteLine($"La lista contiene {automobili.Count()} elementi");
Console.WriteLine($"La lista senza duplicati contiene {automobili.Distinct().Count()} elementi" );
// Un record può anche essere utilizzato come chiave di raggruppamento nelle operazioni GroupBy di LINQ
var autoGrouped = automobili.GroupBy(x => x).Select(x => (key: x.Key, count: x.Count()));
foreach(var auto in autoGrouped)
Console.WriteLine($"il record {auto.key} compare {auto.count} volte");C#Purtroppo, i record non implementano l’interfaccia IComparable questo significa che Liste di oggetti di tipo di record non sono ordinabili tramite il metodo OrderBy() di LINQ.
public record Veicolo(string Marca, string Modello, string Targa);
List<Veicolo> automobili = new() {
new Veicolo("Volkswagen", "Golf", "XX123XX"),
new Veicolo("Fiat", "Tipo", "YY999XX"),
new Veicolo("Tesla", "Model 3", "XX666XX"),
new Veicolo("Volkswagen", "Golf", "XX123XX"),
new Veicolo("Citroen", "C1", "WW433XX"),
new Veicolo("Volkswagen", "Golf", "XX123XX")
};
// Chiamando il metodo OrderBy solleveremo un'eccezione
foreach(var auto in automobili.OrderBy(x => x))
Console.WriteLine(auto);C#Se provassimo a eseguire l’esempio precedente, otterremmo l’eccezione “Failed to compare two elements in the array. At least one object must implement IComparable“.
Record ed Entity Framework
Pur garantendo un certo grado di compatibilità, i record non sono la scelta più indicata nella rappresentazione delle Entity usate per manipolare la nostra base dati.
I record comportano infatti una serie di limitazioni che ora andiamo ad analizzare.
Entity Framework Core sfrutta la reference equality per garantire che venga utilizzata una sola istanza per un’entità concettuale specifica del domain model.
Utilizzando Entity immutabili che non consentono la modifica dei loro attributi dopo la creazione, Entity Framework Core incontra difficoltà nella gestione del Change Tracking.
Il Change Tracking è il meccanismo utilizzato per rilevare le modifiche apportate alle entità e sincronizzarle con il database.
Poiché Entity Framework Core si basa sulla modifica degli oggetti per rilevare le modifiche, l’immutabilità dei record e la non-destructive mutation possono rendere complesso questo processo e l’aggiornamento del database.Inoltre, Entity Framework Core utilizza dei proxy per consentire il lazy loading delle proprietà di navigazione e il supporto alle relazioni. Questi proxy richiedono che le proprietà siano di tipo virtual e che siano read-write, il che è incompatibile con la natura immutabile dei record.
Pertanto, i record non sono adatti a essere utilizzati come tipi di entità in Entity Framework Core.
Record e multi-threading
L’immutabilità di default rende i record sicuri, perché elimina la possibilità di modifiche accidentali o non intenzionali alle loro proprietà. Ciò implica che i record possono essere utilizzati in modo affidabile e prevedibile senza il rischio di effetti collaterali indesiderati.
Inoltre, questa caratteristica li rende thread-safe, il che significa che possono essere utilizzati in modo sicuro in contesti concorrenti senza la necessità di sincronizzazione esplicita.
Un ulteriore beneficio dell’utilizzo dei record è la prevenzione delle race condition, situazioni in cui più thread o processi cercano di modificare gli stessi dati contemporaneamente, causando risultati imprevedibili o indesiderati.
Poiché i record sono di default immutabili non possono essere soggetti a race condition e quindi evitano i problemi associati a questa situazione.
Conclusioni
In definitiva, i record sono una potente funzionalità introdotta con C# 9 e offrono la possibilità di creare in modo semplice strutture leggere per il trasporto dei dati, fornendone un’astrazione che può essere facilmente manipolata, serializzata e deserializzata.
Per riepilogare, essi semplificano la gestione dei dati grazie a una serie di funzionalità predefinite:
- immutabilità
- sintassi concisa
- operatori di copia
- confronto value-based
- funzioni per la rappresentazione
Queste caratteristiche rendono più robuste le nostre soluzioni, riducono gli errori e migliorano la chiarezza del codice. I record types sono particolarmente indicati in scenari in cui è richiesta una manipolazione sicura dei dati e una gestione semplificata dello stato dell’oggetto.
Il codice utilizzato come esempio nell’articolo è consultabile su GitHub a questo indirizzo.
