Dave Brock
Posted on July 6, 2020
In the previous post of this series, we discussed the init-only features of C# 9, which allowed you to make individual properties immutable. That works great on a case-by-case basis, but the real power in leveraging C# immutability is when you can do this for custom types. This is where records shine, and will be the focus of this post.
This is the second post in a five-post series on C# 9 features in-depth:
- Post 1 - Init-only features
- Post 2 (this post) - Records
- Post 3 (future post) - Pattern matching
- Post 4 (future post) - Target typing and covariant returns
- Post 5 (future post) - Putting it all together: an all-in-one application
Heads up! C# 9 is still in preview mode, so much of this content might change—this post was last updated on July 6, 2020. I will do my best to update it as I come across it, but that is not guaranteed. Have fun, but your experience may vary.
A quick primer on immutable types
Before we get started, let’s briefly talk about the concept of immutable types. For sure, immutability is a stuffy word to some but the premise here is simple: once instantiated or initialized, immutable types never change.
What do you get with immutable types? First, you get simplicity. An immutable object only has one state, the state you specified when you created the object. You’ll also see that they are secure and thread-safe with no required synchronization. Because you don’t have threads fighting to change an object, they can be shared freely in your applications.
Put another way: immutable types reduce risk, are safer, and help to prevent a lot of nasty bugs that occur when you update your objects.
Even if you aren’t familiar with this concept yet, or haven’t been forced to think this way, you’re already using it in the .NET world. For example, System.DateTime
is immutable, as are strings. And now with records in C# 9, you can create your own immutable types.
OK, so what is a record?
What is a record, exactly? A record is a construct that allows you to encapsulate property state. (I am avoiding the use of the word object, for clarity.) Or, put in less geeky terms, records allow you to perform value-like behaviors on properties. This is why I’m avoiding saying “objects” when I speak of records. We need to start thinking in terms of data, and not objects. Records aren’t meant for mutable state—if you want to represent change, create a new record. That way, you define them by working with the data, and not passing around a single object that gets changed by multiple functions.
Records appear to be super flexible. Anthony Giretti found that classes can have records as properties, and also that records can contain structs and objects.
What is the difference between structs and records?
Allow me to read your mind—you might be asking: how is this different than structs? If you haven’t used it before, we can define a value type, called a struct
, in C# today. So, why don’t we just build on the struct
functionality instead of introducing a new member (ha!) to C#? After all, you can declare an immutable value type by saying readonly struct
.
Records are offering the following advantages, from what I can see:
- An easy, simplified construct whose intent is to use as an immutable data structure with easy syntax, like
with
expressions to copy objects (keep reading for details!) - Robust equality support with
Equals(object)
,IEquatable<T>
, andGetHashCode()
- Constructor/deconstructor support with simplified positional records
Of course you can do this with structs, and even classes, but this requires tedious boilerplate. The idea here is to have a construct that is simple and straightforward to implement.
Hopefully by now, if I did my job, you know what records are and the rationale for them. Let’s see some code.
If you want to play along, the easiest way as of now is to download LinqPad 6 Beta, then select Edit > Preferences > Query > Use Roslyn Daily build for experimental C# 9 support).
Create your first record
To declare a record, you use the new record
keyword. Brilliant, right?
If you’ve read Microsoft’s Welcome to C# 9.0 post, they declare records using the data class
syntax instead of record
. Since then, it has changed to record
and their article has not been updated. The data class
was considered because the compiler team was also considering struct class
support as well but, from these meeting notes, it has been scrapped for now.
Here is a Person
record using init-only properties.
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string Address { get; init; }
public string City { get; init; }
public string FavoriteColor { get; init; }
// and so on...
}
As of this writing, the record
declaration is not enough to get complete object immutability. You will also need to use the { get; init; }
syntax for your properties, as well. Likewise, if you’ve read about auto-property improvements (for example, changing public string FirstName { get; init; }
to public string FirstName;
), this does not make the fields immutable-by-default either. This allows you the flexibility to have some properties mutable on a case-by-case basis. The compiler team is considering implementing something like public data string FirstName;
to improve on this, but from the latest discussion, appears this will stay in the preview bits and not make it to the initial C# 9 release in November 2020.
Use with
expressions with records
Before C# 9, you would do the following to represent new state using immutability.
var person = new Person
{
FirstName = "Tony",
LastName = "Stark",
Address = "10880 Malibu Point",
City = "Malibu",
FavoriteColor = "red"
};
var newPerson = person;
newPerson.FirstName = "Howard";
newPerson.City = "Pasadena";
This pattern—creating new values from existing ones to represent new state—is referred to as non-destructive mutation (now that will make you seem smart at your next dinner party!). C# 9 has a new type of expression, a with
expression, to assist you.
This functionality is only available in records, and not structs or classes.
var person = new Person
{
FirstName = "Tony",
LastName = "Stark",
Address = "10880 Malibu Point",
City = "Malibu",
FavoriteColor = "red"
};
var newPerson = person with { FirstName = "Howard", City = "Pasadena" };
You can easily use your familiar object initializer syntax to differentiate what has changed between objects, including multiple properties. Under the covers, the record has a protected
copy constructor. If you wish, you can change the default behavior of the copy constructor but the default behavior should be suitable in most of your cases (to create a copy, change properties to what you passed in, and copy the properties that you don’t).
Use inheritance with the with
expression
Remember when I said records support inheritance, while structs do not? I wasn’t lying. We can use inheritance with our with
expression. Let’s say we have a Superhero
class that inherits from Person
and has a new MaxSpeed
property.
class Program
{
static void Main(string[] args)
{
var person = new Superhero
{
FirstName = "Tony",
LastName = "Stark",
Address = "10880 Malibu Point",
City = "Malibu",
FavoriteColor = "red",
MaxSpeed = 1000
};
var newPerson = person with { FirstName = "Howard", City = "Pasadena" };
Console.WriteLine(newPerson.FirstName); // Howard
Console.WriteLine(newPerson.MaxSpeed); // 1000
}
}
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string Address { get; init; }
public string City { get; init; }
public string FavoriteColor { get; init; }
}
public record Superhero : Person
{
public int MaxSpeed { get; init; }
}
Hey, this works! How? Records actually have a hidden virtual method that clones the entire object. An inherited record overrides this method to call the copy constructor of that type, chaining to the copy constructor of the base record.
Implementing positional records
Sometimes, you’ll need to take a positional approach—meaning data is supplied from constructor arguments. You can definitely do this with records.
Here’s what you can do to enable your own constructor and deconstructor.
class Program
{
static void Main(string[] args)
{
var person = new Person("Tony", "Stark", "10880 Malibu Point", "Malibu", "red");
Console.WriteLine(person.FirstName); // Tony
Console.WriteLine(person.LastName); // Stark
}
}
public record Person
{
public string FirstName;
public string LastName;
public string Address;
public string City;
public string FavoriteColor;
public Person(string firstName, string lastName, string address, string city, string favoriteColor)
=> (FirstName, LastName, Address, City, FavoriteColor) = (firstName, lastName, address, city, favoriteColor);
public void Deconstruct(out string firstName, out string lastName, out string address,
out string city, out string favoriteColor)
=> (firstName, lastName, address, city, favoriteColor) = (FirstName, LastName, Address, City, FavoriteColor);
}
You can clean this up with new simplified syntax.
class Program
{
static void Main(string[] args)
{
var person = new Person("Tony", "Stark", "10880 Malibu Point", "Malibu", "red");
var (first, last, address, city, color) = person;
Console.WriteLine(person.FirstName); // Tony
Console.WriteLine(person.LastName); // Stark
Console.WriteLine(first); // Tony
Console.WriteLine(last); // Stark
}
}
public record Person(string FirstName, string LastName, string Address, string City, string FavoriteColor);
Evaluating record equality
If we’re being honest, technically records are a kind of class, which also means they are technically reference types. But that’s OK—like structs, records override the Equals(object)
method that every class has, to achieve the value-ness we are after. This means we can work with value-based equality as well.
For example, if we create two new objects we know that they have different references in memory, so a ReferenceEquals
call (or even a ==
call) will return false
even if they have the same values. This is different than structs—because structs are value types, this will not occur.
With records, we’ll compare values.
Watch what happens as we:
- Create a new
Person
calledperson
, Tony Stark - Create another
Person
, callednewPerson
, Howard Stark, with two different properties (FirstName
andCity
) - Create a third
Person
calledanotherPerson
, and setnewPerson
to the same values as the originalperson
class Program
{
static void Main(string[] args)
{
var person = new Person
{
FirstName = "Tony",
LastName = "Stark",
Address = "10880 Malibu Point",
City = "Malibu",
FavoriteColor = "red"
};
var newPerson = person with { FirstName = "Howard", City = "Pasadena" };
Console.WriteLine(Object.ReferenceEquals(person, newPerson)); // false
Console.WriteLine(Object.Equals(person, newPerson)); // false
var anotherPerson = newPerson with { FirstName = "Tony", City = "Malibu" };
Console.WriteLine(Object.Equals(person, anotherPerson)); // true
}
}
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string Address { get; init; }
public string City { get; init; }
public string FavoriteColor { get; init; }
}
Nice! Also, along with the Equals
override records ship with a GetHashCode()
override if you’re so inclined.
Wrapping up
In this post, we discussed a lot about records in C# 9. We discussed what a record is, how it compares to a struct, with
expressions, inheritance, positional records, and how to evaluate equality.
As I discussed, this is continually changing—let me know how this works for you, and if things have changed since this was published. These are exciting new features and I hope you are able to see their benefit.
Posted on July 6, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024