
In a previous article, I have talked about the probable innovations that would be introduced with the new version of the Microsoft language C# 9.
Among these, the one that seems to be the most interesting for many developers is the introduction of Records.
A Record type provides us an easier way to create an immutable reference type in .NET. In fact, by default, the Record’s instance property values cannot change after its initialization.
The data are passed by value and the equality between two Records is verified by comparing the value of their properties.
Therefore, Records can be used effectively when we need immutability, as we need to send or receive data, or when we need to compare the property values of objects of the same type.
Before exploring the topic, let’s talk about another feature introduced with this version of the language and closely related to the Records: the init keyword, which should be associated with properties and indexers.
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;
}
}
Thanks to this new functionality, as the name suggests, the init only properties can be set only when the object is initialized, but they cannot be changed later; in this way, it is possible to have an immutable model:
var person = new Person //Object Initializer
{
Name = "Francesco",
Surname = "Vas"
};
var otherPerson = new Person("Adolfo", "Arnold"); //Constructor
person.Surname = "de Vicariis"; //Compile error
Please note: since there is a parameterized constructor in the class, the Object Initializer code fragment compiles only if the parameterless constructor is explicitly present.
Let’s go back to Records and see how to define them using the default syntax. Those Records written in this way, i.e. with a list of parameters, are called Positional Records:
public record Person(string Name, string Surname);
Let’s see what’s behind the scenes!
Analyzing the IL code generated by this syntax, we see that the instruction is interpreted as a Person class that implements the IEquatable<T> interface.
The class contains two private fields of the string type and a parameterized constructor with the two related arguments of the string type:
.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()
}
}
We also see the public getter and setter methods of the properties, but if we pay attention, we note that the setter methods have the IsExternalInit attribute:
.method public hidebysig specialname instance void modreq ([System.Runtime]System.Runtime.CompilerServices.IsExternalInit)
set_Name(string 'value') cil managed
{ //...}
This is because the keyword init, which we talked about at the beginning of the article, is used for each property of the Record declared with the default syntax:
public string Name { get; init; }
To let you understand better, I will show you a file produced with an IL code analysis software (ILSpy), which interprets our definition as follows:
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;
}
}
We can see that, with a single line of code, we will have:
- Init only properties that guarantee us an immutable type instance without additional declarations.
- A constructor with all its properties as arguments, called Primary Constructor.
- A PrintMembers() method and an override of the ToString() method that provide us with a textual representation of the type and values of the object’s properties.
- Value-based equality checks with no need to override the GetHashCode() and Equals() methods.
- An implementation of the Deconstruct() method, which allows us to use object deconstruction to access individual properties as individual values.
Let’s take some concrete examples. We initialize a Record type object using a constructor as if we were creating an instance of a class:
var person = new Person("Francesco", "Vas");
It’s not possible to use an Object Initializer by defining a Record with the default syntax. As we saw from the IL code, the class has only the parameterized constructor.
But instead, a parameterless constructor is missing, which is necessary for its operation:
var personWithInitializer = new Person { Name = "Francesco", Surname = "Vas" }; //Compile error
If we try to change the value of a property after the object is initialized, we get a compile error.
As we said, it is not possible to change the value of an existing instance of a record type:
person.Name = "Adolfo"; //Compile error
We can create a copy of the record instance by changing all or some of its properties:
var otherPerson = person with { Surname = "de Vicariis" };
In this way, we create a new otherPerson Record of type Person with the same values as the existing instance person, except for the values we supply after the with statement.
If we now try to use the override of the ToString() method that the definition of the Record provides, we can verify that the results are as expected:
Console.WriteLine(person.ToString()); // Person { Name = Francesco, Surname = Vas }
Or we can simply write:
Console.WriteLine(otherPerson); // Person { Name = Francesco, Surname = de Vicariis }
As expected, we get the textual representation of the type, property values of the two Records, and the the original record instance has been cloned and modified.
Let’s now try to compare two records:
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
Unlike a class, Records follow structural equality rather than referential equality. Structural equality ensures that two records are considered equal if their type is equal and all properties’ values are equal.
Let’s also briefly talk about the deconstructor, which is not a novelty introduced with C# 9, but is made available to us in a “free” way by the definition of the Positional Record.
As we have seen from the IL code, there is a Deconstruct method with as many parameters as the properties of the Record we have created. This allows us to access all properties individually:
var person = new Person ("Francesco", "Vas", 20);
var (name, surname, id) = person;
Console.WriteLine(name + " " + surname + ", Id: " + id); // Francesco Vas, Id: 20
Or we can use discard variables to ignore elements returned by a Deconstruct method:
var (_, _, onlyId) = person;
Console.WriteLine(onlyId); // 20
Each discard variable is defined by a variable named “_”, and a single deconstruction operation can include several discard variables.
Records can be a valid alternative to classes when we have to send or receive data. The very purpose of a DTO is to transfer data from one part of the code to another, and immutability in many cases can be useful. We could use them to return data from a Web API or to represent events in our application.
They can be used easily when we need to compare property values of objects of the same type. Furthermore, immutability can help us in simultaneous access to data: we do not need to synchronize access to data if the data is immutable.
What do you think of all these features in a single line of code? I find it really practical!
There would still be so much to say. For example, it is also possible to write our own Record and customize it, or that the Records support inheritance from other Records… but we will talk about it maybe another time.
See you in the next article! Stay Tuned!