C# 9 introduced us to records
, a new way to define data structures with increased readability and usability in specific scenarios. While they may look like structs or classes, they offer an opportunity to reduce boilerplate code and potentially allow us to code differently. One of the significant differences between records and other types is that records operate as value types.
In this post, we’ll explore the difference between a reference type and a value type and how we can use source generators to change the value comparison of one record against another.
Value Types vs. Reference Types
When comparing classes to records, the significant difference is comparison operators. In .NET, users will likely define types using the class
type. While you may create two classes with all the same values, you will find they are not the same in the comparison. Let’s look at a code example.
using static System.Console;
// will be false
WriteLine(new Friend("Khalid") == new Friend("Khalid"));
public class Friend
{
public Friend(string name)
{
Name = name;
}
public string Name { get; }
}
When we talk about “reference types,” we are talking about the space an entity occupies in memory. In this case, the instances of Friend
are two separate entities in memory, even though they share the same values. But what about value types? In our case, what about record
types?
using static System.Console;
// will be true
WriteLine(new Friend("Khalid") == new Friend("Khalid"));
public record Friend(string Name);
Well, record comparisons compare all the property values in the record. If they match, then the two entities are the “same”. So value comparison has advantages, especially when you’re more concerned about logical sameness than technical sameness.
A good use case is removing elements from a collection to determine logical uniqueness. In the following example, I have a group of friends, and I want to know which entries are Distinct
.
using static System.Console;
Friend[] friends =
{
new("Khalid"),
new("Maarten"),
new("Rachel"),
new("Khalid")
};
// Is 3: Khalid, Maarten, & Rachel
WriteLine(friends.Distinct().Count());
public record Friend(string Name);
If the Friend
type were of class
, we’d see a result of 4
instead of 3
since we would be comparing each instance by reference.
The Business Problem with Records
While the value comparison of records is a great feature, it does break down in most business apps I’ve written, where entities typically have an identifier field of Id
. Let’s take a look at an example.
using static System.Console;
var items = new List<CartItem>
{
new(1, "The Witcher 3", 60m),
new(2, "Elden Ring", 60m),
new(3, "King of Fighters XV", 50m),
new(4, "Street Fighter V", 30m)
};
WriteLine($"Items in Cart (before): {items.Count}");
// remove record with value equality
// not with reference equality
var item = new CartItem(1);
items.Remove(item);
WriteLine($"Items in Cart (after): {items.Count}");
public record CartItem(
int Id,
string Name = "",
decimal Price = 0m);
We have a shopping cart with items, and each item has an Id
property. Logically, we know prices and names of products can change, but identifiers never do. Therefore, removing an entry with only the Id
will not produce the outcome we intended in the previous code, as records compare all the properties.
To fix this issue, we need to overwrite the comparison operator of our CartItem
class.
public record CartItem(
int Id,
string Name = "",
decimal Price = 0m)
{
public virtual bool Equals(CartItem? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id == other.Id;
}
public override int GetHashCode()
{
return Id;
}
}
Changing the Equals
and GetHashCode
will make our code work as expected, but with the drawback of being noisey.
Using Source Generators For Record Comparisons
Rather than overriding every records’ comparison operators, what if we could declare which properties should be part of the value comparison?
public partial record CartItem(
[Equality] int Id,
string Name = "",
decimal Price = 0m);
Well, I did just that! You can add this generator to your projects, and it will generate the record comparison operators to change the value comparison of records to each other. Note, I had to embed a gist because source generator code and liquid (blog engine templating language) don’t mix.
If you want to see a working sample, you can go to the GitHub Repository and try it for yourself. When you include the source generator in your projects, you can reduce the boilerplate needed to express your intent. That’s pretty cool in my book.
using RecordEquality;
using static System.Console;
var items = new List<CartItem>
{
new(1, "The Witcher 3", 60m),
new(2, "Elden Ring", 60m),
new(3, "King of Fighters XV", 50m),
new(4, "Street Fighter V", 30m)
};
WriteLine($"Items in Cart (before): {items.Count}");
// remove record with value equality
// not with reference equality
var item = new CartItem(1);
items.Remove(item);
// before records, you'd have to do this
// items.RemoveAll(i => i.Name == item.Name);
WriteLine($"Items in Cart (after): {items.Count}");
public partial record CartItem(
[Equality] int Id,
string Name = "",
decimal Price = 0m);
Now our example works with adding a partial
on our record and an EqualityAttribute
.
Do you like what you see? I hope so, but you can also just override the original comparisons if you
think Source Generators are too much trouble.
It’s your code!
Conclusion
Records are a pretty cool type, and by using source generators, we can express our value comparison intent with minimal changes. Try out the sample project and tell me what you think on Twitter at @buhakmeh. Thank you so much for reading and sharing my post with colleagues.