
Ho già parlato in un precedente articolo delle probabili novità che sarebbero state introdotte con la nuova versione del linguaggio di casa Microsoft C# 9.
Tra queste, quella che per molti sviluppatori risulta essere la più interessante è l’introduzione dei Record.
Un tipo Record ci fornisce un modo più semplice per creare un tipo di riferimento immutabile in .NET. Per impostazione predefinita, infatti, i valori delle proprietà di un’istanza di un Record non possono cambiare dopo la sua inizializzazione.
Il passaggio dei dati avviene in base al valore e l’uguaglianza tra due Record è verificata confrontando il valore delle loro proprietà.
I Record possono essere quindi utilizzati efficacemente in casi in cui abbiamo bisogno di immutabilità, come quando dobbiamo inviare o ricevere dati, o quando abbiamo bisogno di confrontare i valori di proprietà di oggetti dello stesso tipo.
Prima di approfondire l’argomento, parliamo di un’altra funzionalità introdotta con questa versione del linguaggio e strettamente correlata ai Record, ovvero la parola chiave init che va associata a proprietà e indicizzatori.
public class Person
{
public string Name { get; init; }
public string Surname { get; init; }
public Person()
{
}
public Person(string name, string surname)
{
Name = name;
Surname = surname;
}
}
Grazie a questa nuova funzionalità, come suggerisce il nome, le proprietà di sola inizializzazione possono essere settate solo quando l’oggetto viene inizializzato, ma non possono essere modificate successivamente; in questo modo, è possibile disporre di un modello immutabile:
var person = new Person //Object Initializer
{
Name = "Francesco",
Surname = "Vas"
};
var otherPerson = new Person("Adolfo", "Arnold"); //Constructor
person.Surname = "de Vicariis"; //Compile error
Da notare che, essendo presente nella classe un costruttore parametrizzato, il frammento di codice dell’Object Initializer compila solo se è presente esplicitamente anche il costruttore senza parametri.
Torniamo ora a parlare di Record e vediamo come poterli definire utilizzando la sintassi di default. I Record scritti in questo modo, cioè con un elenco di parametri, vengono detti Record Posizionali:
public record Person(string Name, string Surname);
Vediamo subito cosa c’è dietro le quinte!
Analizzando il codice IL generato da questa sintassi, vediamo che l’istruzione viene interpretata come una classe Person che implementa l’interfaccia IEquatable<T>.
La classe contiene due campi privati di tipo stringa e un costruttore parametrizzato con i due relativi argomenti di tipo stringa:
.class public auto ansi beforefieldinit Person extends [System.Runtime]System.Object
implements class [System.Runtime]System.IEquatable`1<class Person>
{
.field private initonly string '<Name>k__BackingField'
.field private initonly string '<Surname>k__BackingField'
.method public hidebysig specialname rtspecialname instance void
.ctor(string Name, string Surname) cil managed
{
string Person::'<Name>k__BackingField'
string Person::'<Surname>k__BackingField'
instance void [System.Runtime]System.Object::.ctor()
}
}
Vediamo anche i metodi pubblici getter e setter delle proprietà, ma se prestiamo attenzione, notiamo che i metodi setter, hanno l’attributo IsExternalInit:
.method public hidebysig specialname instance void modreq ([System.Runtime]System.Runtime.CompilerServices.IsExternalInit)
set_Name(string 'value') cil managed
{ //...}
Questo perché viene utilizzata la parola chiave init, di cui abbiamo parlato ad inizio articolo, per ogni proprietà del Record dichiarato con la sintassi di default:
public string Name { get; init; }
Per maggior chiarezza, vi mostrerò ora un file prodotto con un software di analisi del codice IL (ILSpy), che interpreta la nostra definizione in questo modo:
public class Person : IEquatable<Person>
{
private readonly string <Name>k__BackingField;
private readonly string <Surname>k__BackingField;
protected virtual Type EqualityContract
{
get { return typeof(Person); }
}
public string Name { get; init; }
public string Surname { get; init; }
public Person(string Name, string Surname)
{
this.Name = Name;
this.Surname = Surname;
base..ctor();
}
public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("Person");
stringBuilder.Append(" { ");
if (PrintMembers(stringBuilder))
{
stringBuilder.Append(" ");
}
stringBuilder.Append("}");
return stringBuilder.ToString();
}
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("Name");
builder.Append(" = ");
builder.Append((object?)Name);
builder.Append(", ");
builder.Append("Surname");
builder.Append(" = ");
builder.Append((object?)Surname);
return true;
}
public static bool operator !=(Person? r1, Person? r2)
{
return !(r1 == r2);
}
public static bool operator ==(Person? r1, Person? r2)
{
return (object)r1 == r2 || (r1?.Equals(r2) ?? false);
}
public override int GetHashCode()
{
return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 +
EqualityComparer<string>.Default.GetHashCode(Name)) * -1521134295 +
EqualityComparer<string>.Default.GetHashCode(Surname);
}
public override bool Equals(object? obj)
{
return Equals(obj as Person);
}
public virtual bool Equals(Person? other)
{
return (object)other != null && EqualityContract == other!.EqualityContract
&& EqualityComparer<string>.Default.Equals(Name, other!.Name)
&& EqualityComparer<string>.Default.Equals(Surname, other!.Surname);
}
public virtual Person<Clone>$()
{
return new Person(this);
}
protected Person(Person original)
{
Name = original.Name;
Surname = original.Surname;
}
public void Deconstruct(out string Name, out string Surname)
{
Name = this.Name;
Surname = this.Surname;
}
}
Possiamo quindi vedere che, con una sola riga di codice, avremo a disposizione:
- Proprietà di sola inizializzazione che ci garantiscono un’istanza di tipo immutabile senza dichiarazioni aggiuntive.
- Un costruttore con tutte le proprietà come argomenti, chiamato Costruttore Primario.
- Un metodo PrintMembers() ed un override del metodo ToString() che ci forniscono una rappresentazione testuale del tipo e dei valori delle proprietà dell’oggetto.
- Controlli di uguaglianza basati sul valore senza la necessità di sovrascrivere i metodi GetHashCode() ed Equals().
- Un’implementazione del metodo Deconstruct(), che ci consente di utilizzare la decostruzione di oggetti per accedere alle singole proprietà come valori individuali.
Facciamo qualche esempio concreto. Inizializziamo un oggetto di tipo Record utilizzando un costruttore come se creassimo un’istanza di una classe:
var person = new Person("Francesco", "Vas");
Non è possibile utilizzare un Object Initializer definendo un Record con la sintassi di default, perché, come abbiamo visto dal codice IL, la classe ha solo il costruttore parametrizzato.
Manca invece un costruttore senza parametri, che è necessario per il suo funzionamento:
var personWithInitializer = new Person { Name = "Francesco", Surname = "Vas" }; //Compile error
Se proviamo a modificare il valore di una proprietà dopo l’inizializzazione dell’oggetto, otteniamo un errore di compilazione.
Come abbiamo detto, non è possibile modificare il valore di un’istanza esistente di un tipo record:
person.Name = "Adolfo"; //Compile error
È possibile creare una copia dell’istanza del record modificando tutte o solo alcune delle sue proprietà:
var otherPerson = person with { Surname = "de Vicariis" };
In questo modo, creiamo un nuovo Record otherPerson di tipo Person con gli stessi valori dell’istanza person esistente, ad eccezione dei valori che forniamo dopo l’istruzione with.
Se proviamo ora ad utilizzare l’override del metodo ToString() che la definizione del Record stesso ci fornisce, possiamo verificare che i risultati siano quelli attesi:
Console.WriteLine(person.ToString()); // Person { Name = Francesco, Surname = Vas }
O semplicemente possiamo scrivere:
Console.WriteLine(otherPerson); // Person { Name = Francesco, Surname = de Vicariis }
Come ci aspettavamo, otteniamo la rappresentazione testuale del tipo, dei valori delle proprietà dei due Record e l’istanza del record originale è stata clonata e modificata.
Proviamo ora a confrontare due Record:
var person = new Person("Francesco", "Vas");
var otherPerson = new Person("Francesco", "Vas");
Console.WriteLine(person.Equals(otherPerson)); //Returns True
Console.WriteLine(person == otherPerson); //Returns True
Console.WriteLine(person.GetHashCode() == otherPerson.GetHashCode()); //Returns True
A differenza di una classe, i Record seguono l’uguaglianza strutturale anziché l’uguaglianza referenziale. L’uguaglianza strutturale garantisce che due Record siano considerati uguali se il loro tipo è uguale e i valori di tutte le proprietà sono uguali.
Parliamo brevemente anche del decostruttore, che non è certamente una novità introdotta con C# 9, ma che ci viene messo a disposizione in modo “gratuito” dalla definizione del Record Posizionale.
Come abbiamo visto dal codice IL, è presente un metodo Deconstruct con tanti parametri quante sono le proprietà del Record che abbiamo creato. Questo ci permette di accedere a tutte le proprietà individualmente:
var person = new Person ("Francesco", "Vas", 20);
var (name, surname, id) = person;
Console.WriteLine(name + " " + surname + ", Id: " + id); // Francesco Vas, Id: 20
Oppure possiamo utilizzare le variabili discard per ignorare elementi restituiti da un metodo Deconstruct:
var (_, _, onlyId) = person;
Console.WriteLine(onlyId); // 20
Ogni variabile discard è definita da una variabile denominata “_”, ed una singola operazione di decostruzione può includere diverse variabili discard.
I Record possono essere una valida alternativa alle classi quando dobbiamo inviare o ricevere dati. Lo scopo stesso di un DTO è trasferire i dati da una parte del codice a un’altra e l’immutabilità in molti casi ci può far comodo. Potremmo quindi utilizzarli per ritornare dati da una Web API o per rappresentare eventi nella nostra applicazione.
Possono essere utilizzati facilmente nei casi in cui abbiamo bisogno di confrontare i valori di proprietà di oggetti dello stesso tipo. Inoltre, l’immutabilità ci può aiutare nell’accesso simultaneo ai dati: non abbiamo bisogno di sincronizzare l’accesso ai dati, se i dati sono immutabili.
Cosa ne pensate di tutte queste funzionalità in una singola riga di codice? Io lo trovo davvero pratico!
Ci sarebbe ancora tanto da dire, ad esempio è anche possibile scrivere un nostro Record e personalizzarlo, i Record supportano l’ereditarietà da altri Record… ma ne parleremo magari un’altra volta.
Al prossimo articolo! Stay Tuned!