A record is a data-centric type that usually doesn’t contain behaviors. C# 9 introduced the keyword record
to quickly declare a class primarily designed for data representation. In C# 10 and upper, the syntax record class
is equivalent to C# 9 record
and the syntax record struct
declares a structure-based record.
This tutorial dives into C# records’ syntax, applications and benefits.
Declaring a Record Type
In C# 9, the Primary Constructor syntax was introduced as a concise means to declare a record’s constructor and properties. This syntax avoids several lines of boilerplate code:
1 2 3 4 5 |
record Person(string FirstName, string LastName); ... var person = new Person("Bill", "Gates"); Assert.IsTrue(person.FirstName == "Bill"); Assert.IsTrue(person.LastName == "Gates"); |
The Primary Constructor syntax is optional and a record can be declared without it:
1 2 3 4 5 6 7 8 |
record Person { public Person(string firstName, string lastName) { this.FirstName = firstName; this.LastName = lastName; } public string FirstName { get; } public string LastName { get; } } |
The Primary Constructor syntax also applies to value-type record:
1 |
record struct Point(int X, int Y); |
Value-Based Semantics: Understanding What Truly Sets Records Apart
Up until this point, we haven’t discussed anything that distinguishes record class
and record struct
from regular class
and struct
. The key is that records have Value-Based Semantics: this semantics is the result of two characteristics: Immutability and Value-Based Equality.
Immutability
By default, a record class
declared with the Primary Constructor syntax is immutable. This means that its instances’ state cannot be modified after creation, ensuring data integrity and thread-safety:
Here the tooltip reverse-engineer the property FirstName { get; init; }
generated through the Primary Constructor syntax. Keep in mind that this compiler behavior is different for record struct
and we’ll explain it later.
Value-based equality
Value-based equality semantic means that two record instances are considered equal when their data matches. This stands in contrast to reference types like classes, where comparison is based on reference equality. Here is an example that demonstrates record’s Value-based equality:
1 2 3 4 |
var record1 = new Person("Bill", "Gates"); var record2 = new Person("Bill", "Gates"); Assert.IsFalse(ReferenceEquals(record1, record2)); // Different objects ... Assert.IsTrue(record1 == record2); // ... considered as equal because <strong>their data matches</strong> |
To achieve Value-based equality on record class
the compiler overrides the virtual methods Object.Equals(Object)
and Object.GetHashCode()
. The compiler also overrides the operators ==
and !=
.
Practical Application of Value-Based Semantics in Everyday Life
As a .NET developer, you rely implicitly on value-based semantics on a daily basis. Indeed the System.String
class adheres to Value-Based Semantics.
1 2 3 4 5 6 7 8 9 10 11 |
var string1 = "Bill Gates"; var string2 = string1.Substring(0, 5) + string1.Substring(5); // Clone string1 into another string object // string Value-Base Equality Assert.IsFalse(ReferenceEquals(string1, string2)); // Different string objects ... Assert.IsTrue(string1 == string2); // ... considered as equal because their data matches // string Immutability var string3 = string1.Replace("Gates", "Clinton"); Assert.IsFalse(ReferenceEquals(string1, string3)); // When changing a string, a new string is created Assert.IsTrue(string1 == "Bill Gates"); // string1 hasn't been changed |
Record struct specificities
Unlike record class
, by default a record struct
is mutable. This means that a setter is generated for each property declared through the Primary Constructor syntax:
1 2 3 4 5 |
var point = new Point(1, 2); point.X = 3; // Possible to change the state of the point Assert.IsTrue(point == new Point(3, 2)); // Regular Value-based equality for a structure record struct Point(int X, int Y); |
To get an immutable record struct
the keyword readonly
must be used:
Another record struct
specificity is that a default parameterless constructor is provided: it sets each field to its default value:
1 2 |
var point = new Point(); Assert.IsTrue(point == new Point(0, 0)); |
Nondestructive mutation with the ‘with’ keyword
When demonstrating string
immutability, we showed this sample code:
1 2 3 4 |
var string1 = "Bill Gates"; var string3 = string1.Replace("Gates", "Clinton"); Assert.IsFalse(ReferenceEquals(string1, string3)); // When changing a string, a new string is created Assert.IsTrue(string1 == "Bill Gates"); // string1 hasn't been changed |
An equivalent syntax based on the with
keyword is available for records:
1 2 3 4 |
var record1 = new Person("Bill", "Gates"); var record3 = record1 with { LastName = "Clinton" }; Assert.IsFalse(ReferenceEquals(record1, record3)); // When changing a record, a new record is created Assert.IsTrue(record1 == new Person("Bill", "Gates")); // record1 hasn't been changed |
This syntax is called nondestructive mutation: the original record is not modified but a new record object is created to hold the modified state.
When decompiling this C# code above with a .NET decompiler, we can see that the compiler generates a <Clone>$() method for our record. The nondestructive mutation is achieved by first cloning the record object and then assigning the property LastName. Normally this property cannot be assigned because the record is immutable. However special IL code generated by the C# compiler relying on IsExternalInit can assign it.
StringBuilder
is to mitigate this impact. De-facto non-destructive mutation of records can yield similar degraded performance outcomes so take care.Declaring mutable record class
It is possible to declare mutable record classes when not relying on the Primary Constructor syntax:
1 2 3 4 5 6 7 8 |
var record1 = new Person { FirstName = "Bill", LastName = "Gates" }; record1.LastName = "Clinton"; Assert.IsTrue(record1 == new Person { FirstName = "Bill", LastName = "Clinton" }); public record class Person { public string FirstName { get; set; } public string LastName { get; set; } } |
Record deconstruction
When using the Primary Constructor syntax, the compiler generates a Deconstruct()
method with an out
parameter for each positional parameter provided in the record declaration. Here is record deconstruction in action:
1 2 3 4 |
var person = new Person("Bill", "Gates"); var (FirstName, LastName) = person; // Deconstruct the record person Assert.IsTrue(FirstName == "Bill"); Assert.IsTrue(LastName == "Gates"); |
Notice that deconstruction makes records work seamlessly with C# Pattern Matching:
1 2 3 4 5 6 7 8 9 |
var person = new Person("Bill", "Gates"); Assert.IsTrue(WhoIsThis(person) == "Billionaire Entrepreneur"); static string WhoIsThis(Person person) => person switch { ("Bill", "Gates") => "Billionaire Entrepreneur", ("Bill", "Clinton") => "US president", (_,_) => "Who is it?" }; |
Declaring attributes
It is possible to declare attributes for any of the elements generated by the compiler based on the record definition. You can specify a target for any attribute you use on the positional properties of the record. In the following example, the System.Text.Json.Serialization.JsonPropertyNameAttribute
is assigned to each property of the Person
record. The property
target is used to specify that the attribute applies to the compiler-generated property, while other targets such as field and param
can be used to apply the attribute to the field or parameter, respectively.
1 2 3 |
public record Person( [property: JsonPropertyName("firstName")] string FirstName, [property: JsonPropertyName("lastName")] string LastName); |
Formatting
The compiler overrides the Object.ToString()
method to present a record as a string in a well-formatted manner:
1 2 |
var person = new Person("Bill", "Gates"); Assert.IsTrue(person.ToString() == "Person { FirstName = Bill, LastName = Gates }"); |
The same string representation is available at debug-time:
Record and Inheritance
record class
can deal with inheritance. A record can inherit from another record, but it cannot inherit from a class, nor can a class inherit from a record.
1 2 3 4 5 6 7 |
public record Person { public string FirstName { get; init; } public string LastName { get; init; } } public sealed record Student : Person { public int ID { get; init; } } |
While this feature is beneficial, it can introduce complexities in certain situations when dealing with the with
syntax and value-based equality.
Inheritance and the value-based equality
Viewed from the perspective of the Person
record, in the following code sample, both references hold identical values since they both have the same FirstName
and LastName
values.
1 2 3 4 5 6 7 8 9 10 |
Person person1 = new Student { FirstName = "Bill", LastName = "Gates", ID = 123 }; Person person2 = new Person { FirstName = "Bill", LastName = "Gates" }; Assert.IsFalse(person1 == person2); |
Fortunately, the compiler-generated code ensures that these two objects are treated as distinct. Upon revisiting the code produced by the compiler, you’ll notice that the implementation of the Equals()
method relies on the virtual property protected virtual Type EqualityContract => typeof(Person);
. This property is used to verify that the two compared objects share the same type.
Inheritance and the ‘with’ keyword
In the code sample below it is not immediate that person2
is a Student since it is inferred from a Person reference using the with
syntax. As we saw in the Nondestructive mutation section, under the hood the generated virtual <Clone>$()
method is called by the compiler. This virtual method is overridden by Student and its implementation calls the Student copy constructor:
1 2 3 4 5 6 7 |
Person person1 = new Student { FirstName = "Bill", LastName = "Gates", ID= 123 }; Person person2 = person1 with { LastName = "Clinton" }; Assert.IsTrue(person2 is Student); |
Generic Record
Let’s note that a record can be a generic class or structure, which offers flexibility. However, developers should keep in mind that EqualityComparer<T>.Default is employed for each property typed with T in the generated code to perform state comparisons. This can potentially result in confusing scenarios.
1 2 3 4 5 6 7 8 9 |
var pair1 = new Pair<string>("1", "2"); var pair2 = new Pair<string>("1", "2"); Assert.IsTrue(pair1 == pair2); // equals with string var pair3 = new Pair<object>(new object(), new object()); var pair4 = new Pair<object>(new object(), new object()); Assert.IsFalse(pair3 == pair4); // different with object record Pair<T>(T Left, T Right); |
Situations where records shine
C# Records find practical applications in various contexts:
- Simplifying Code: Records can help reduce boilerplate code, making your codebase more concise and easier to maintain. They are particularly beneficial when working with DTOs (Data Transfer Objects) and POCOs (Plain Old CLR Objects).
- Domain Models: Records serve as effective tools for representing domain models in your application. You can define a record with properties that align with the data you’re modeling. Records offer the advantages of working with data in a strongly-typed and immutable manner, and they provide built-in capabilities for equality checking and formatting.
- Configuration Settings: Records are well-suited for representing configuration settings within an application. By creating a record with properties corresponding to different settings, you can easily pass these settings between methods and services, maintaining their immutability throughout.
- API Response Models: Records are valuable for representing response models in RESTful APIs. They enable you to define a record with properties that mirror the data returned by the API. Records simplify the deserialization process, providing a strongly-typed object for working with the data effortlessly.
- Debugging and Logging: Records provide a clear and concise string representation of their data, making them valuable for debugging and logging. When you log or print a record, you get a well-formatted view of its contents.
Conclusion
In this comprehensive exploration of C# records, we’ve unveiled their versatile capabilities in simplifying data encapsulation, promoting immutability, and streamlining various programming tasks. Embracing records do improve code readability, reduce boilerplate, and enhance data integrity across a range of applications.