1. Perché sono nati i record?
In C# (e in .NET in generale) per tanto tempo i mattoncini base erano classi (class) e struct (struct). Ma ognuno di loro è incompleto per certi task. Le classi sono reference type, mutabili e il confronto è per riferimento (tranne rari casi). Le struct sono value type (si copiano quando le passi), di default si confrontano byte a byte e quasi sempre sono mutabili (almeno finché non sono arrivate le readonly struct).
Ma se volevi una semplice e naturale rappresentazione dei dati, facile da confrontare per valore, clonare al volo e senza preoccuparti che qualcuno ti cambi l’oggetto da qualche parte — dovevi inventarti mille workaround o usare librerie tipo ValueTuple o anche System.Tuple. Ma nessuna di queste era davvero bella e chiara come avresti voluto.
Ecco perché in C# 9 è arrivato record — un tipo di dato che unisce dichiarazione compatta, sicurezza dell’immutabilità e comportamento orientato al confronto per valore.
2. Tutti e quattro i tipi su una sola pagina
| class | struct | record | record struct | |
|---|---|---|---|---|
| Categoria | Reference type | Value type | Reference type | Value type |
| Mutabilità | Di default — sì | Di default — sì | Di default — no (init) | Di default — no (init) |
| Confronto | Per riferimento (==) | Per valore (campi) | Per valore (fields/properties) | Per valore (fields) |
| Clonazione | Solo a mano | Solo a mano | Supporto built-in (with) | Supporto built-in (with) |
| Ereditarietà | Sì | No | Sì | No |
| Immutabilità | Devi implementarla | Devi implementarla | Facilissimo da fare | Facilissimo da fare |
| Sintassi | La più lunga | Corta | La più corta (posizionale) | Abbastanza corta |
| Uso nelle collezioni | Per riferimento | Copie | Per riferimento | Copie |
Schema visivo
+----------------+ +----------------+ +--------------------+
| class | | struct | | record |
+----------------+ +----------------+ +--------------------+
| Reference Type | | Value Type | | Reference Type |
| Mutable | | Mutable | | Immutable (init) |
| == : Reference | | == : By Fields | | == : By Value |
+----------------+ +----------------+ +--------------------+
3. Il succo delle differenze tra record, class e struct
Comportamento in memoria: riferimento o valore?
- class e record sono reference type. Quando li passi a una funzione, si copia solo il riferimento all’oggetto.
- struct e record struct sono value type. Si copiano sempre byte a byte (a meno che non li passi esplicitamente per riferimento).
class PointClass { public int X; public int Y; }
struct PointStruct { public int X; public int Y; }
record PointRecord(int X, int Y);
record struct PointRecordStruct(int X, int Y);
void Demo()
{
var pc = new PointClass { X = 1, Y = 2 };
var ps = new PointStruct { X = 1, Y = 2 };
var pr = new PointRecord(1, 2);
var prs = new PointRecordStruct(1, 2);
ChangeY(pc); // pc.Y cambierà!
ChangeY(ps); // ps.Y non cambierà — è una copia!
ChangeY(pr); // pr.Y cambierà!
ChangeY(prs); // prs.Y non cambierà — è una copia!
}
void ChangeY(dynamic p) { p.Y = 99; }
Se ti sembra che dynamic qui sia magia — sì, è solo per esempio, così puoi vedere che con le struct il valore non cambia, mentre con class o record (reference) sì.
Confronto: come capire se due oggetti sono uguali?
- class: si confrontano per riferimento (== — true solo se è lo stesso oggetto in memoria), a meno che non fai override di Equals.
- struct: si confrontano per valore di tutti i campi (di default).
- record: si confrontano per valore di tutti i campi/proprietà dichiarati nel costruttore primario.
class Foo { public int A; public int B; }
record Bar(int A, int B);
var foo1 = new Foo { A = 42, B = 1 };
var foo2 = new Foo { A = 42, B = 1 };
var bar1 = new Bar(42, 1);
var bar2 = new Bar(42, 1);
Console.WriteLine(foo1 == foo2); // False! Oggetti diversi
Console.WriteLine(bar1 == bar2); // True! I valori coincidono
Curiosità:
Con record struct è ancora più figo: il confronto è "per valore", come le struct normali, ma con la sintassi e le feature dei record.
4. Immutabilità: chi garantisce cosa?
Confrontiamo la sicurezza degli oggetti:
- class: Di default facilmente mutabile, a meno che non rendi tutti i campi readonly.
- struct: Stessa cosa, ma puoi dichiarare readonly struct, così né campi né proprietà si possono cambiare.
- record: Di solito i campi sono dichiarati con init, cioè li puoi settare solo nel costruttore o nell’inizializzatore (with). Comodo per passare dati in modo sicuro.
- record struct: Idem, puoi fare readonly record struct e avere immutabilità dei value type con i bonus dei record.
record Person(string Name, int Age);
var p1 = new Person("Aleksej", 23);
// p1.Age = 24; // Errore! Solo init
var p2 = p1 with { Age = 24 }; // Funziona! Crea una copia con i nuovi dati
Quando hai un progetto grosso e decine di entity, record ti evitano un sacco di bug tipo "qualcuno ha cambiato un campo da qualche parte — e tutto si è rotto".
5. Sintassi: come si dichiarano e come non confondersi
// class
public class Product
{
public int Id { get; init; }
public string Name { get; init; }
}
// struct
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
// record
public record Product(int Id, string Name);
// record struct
public record struct Point(int X, int Y);
Come vedi, la dichiarazione più corta è quella del tipo record. Ottieni costruttore, deconstruct, uguaglianza, ToString() e molto altro "out of the box".
| Sintassi | Cosa ottieni gratis | Si può ereditare? |
|---|---|---|
| class | quasi niente | Sì |
| struct | quasi niente | No |
| record | ToString, Equals, Deconstruct, with | Sì |
| record struct | ToString, Equals, Deconstruct, with | No |
6. Clonazione e operatore with
Solo record e record struct hanno l’operatore speciale with, che ti permette di copiare un oggetto cambiando solo alcune proprietà senza sbatti.
record User(string Name, int Age);
var user1 = new User("Irina", 28);
var user2 = user1 with { Age = 29 }; // user2.Name == "Irina", user2.Age == 29
Con una classe (class) devi implementare il metodo di copia a mano, e se ti dimentichi di copiare un campo — ciao bug.
7. Ereditarietà: chi eredita da chi
- class: Supporta ereditarietà standard (gerarchie di classi, metodi virtuali, astrazioni ecc).
- struct: Non supporta (può solo implementare interfacce).
- record: Supporta ereditarietà, ma con alcune limitazioni (ad esempio, solo tra record, non tra record e class).
- record struct: Non supporta, come le struct normali.
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name); // Ok!
class Vehicle { }
class Car : Vehicle { } // Ok!
// struct non può ereditare da struct
Più dettagli sull’ereditarietà al prossimo livello :P
8. Dove si usano questi tipi nella pratica
- class: Oggetti grandi con tanto comportamento, ciclo di vita lungo, stato mutabile, gerarchie (tipo logica di business, componenti UI).
- struct: Oggetti piccoli-value, dove conta la copia super veloce, niente GC e overhead minimo (tipo coordinate, colori, somme — roba che cloni al volo).
- record: DTO (Data Transfer Object), oggetti-parametro, parametri di configurazione, stati immutabili, risultati di calcoli che vuoi confrontare per contenuto.
- record struct: Value type dove vuoi immutabilità e comportamento da record, ma senza allocazioni inutili sull’heap.
9. Errori tipici e trappole
A volte i dev pensano che record sia solo "un sostituto della classe". Non è così! Se scrivi un oggetto che deve cambiare stato durante la vita — usa class.
Se vuoi confrontare oggetti per riferimento (tipo nel pattern "singleton" o quando il ciclo di vita è critico) — usa class.
Se fai un oggetto-value che deve comportarsi come un numero o un punto su una griglia — struct o record struct.
Se lavori con oggetti immutabili, facilmente confrontabili, che passano spesso tra layer dell’app, stanno in collezioni, vengono loggati e serializzati — la tua scelta è record.
Ricorda anche: se dichiari un record-class con proprietà di sola lettura, ma ti dimentichi dei campi annidati — i campi annidati possono comunque essere cambiati se sono mutabili.
record Student(string Name, List<int> Grades);
var s1 = new Student("Anton", new List<int>() {5,5,5});
var s2 = s1 with { };
s2.Grades.Add(2); // Entrambi gli oggetti sono "maledetti" dal 2! s1.Grades == s2.Grades
GO TO FULL VERSION