Monday, November 25, 2024
Understanding Value Equality in .NET Records

.NET records have emerged as a powerful abstraction for representing immutable data in a concise, expressive manner, largely due to their intrinsic support for value equality. However, achieving truly comprehensive value equality across all aspects of a record, especially when collections are involved, requires nuanced understanding and careful handling. In this article, we delve into the intricacies of value equality in .NET records, examine the challenges posed by collections, and present a robust solution to ensure that collections can be seamlessly integrated while preserving value equality semantics.
Check the complete source code on GitHub.
▸ Defining value equality
In .NET, value equality refers to the principle that two objects are considered equal if their values, rather than their references, are identical. This concept is particularly valuable when dealing with data transfer objects (DTOs) and other forms of data representation, where different instances with equivalent data must be treated as identical.
Records in C# provide a convenient mechanism for automatic value-based equality. By default, the equality of two records is determined by comparing the values of all properties within the record. This significantly reduces the boilerplate code otherwise required for implementing equality and hash functions.
However, achieving consistent value equality within records becomes more challenging when properties include collections, as conventional collection types in .NET employ reference-based equality by default.
▸ Challenges of collections in records
Consider the
BadOrder record below, which includes a List<OrderItem> as one of its properties:public record OrderItem(
string ProductName,
int Quantity,
decimal Price);
public record BadOrder(
string OrderId,
string CustomerName,
List<OrderItem> Items);
The
BadOrder record contains a List<OrderItem> property named Items. To illustrate the issue, let us create two instances of BadOrder with identical data and evaluate their equality.Here is the test code:
[Test]
public void TestBadOrderEquality()
{
OrderItem item1 = new("ProductA", 2, 10.0m);
OrderItem item2 = new("ProductB", 1, 20.0m);
BadOrder order1 = new("Order1", "John Doe", [item1, item2]);
BadOrder order2 = new("Order1", "John Doe", [item1, item2]);
Assert.Multiple(() =>
{
Assert.That(order1.OrderId, Is.EqualTo(order2.OrderId));
Assert.That(order1.CustomerName, Is.EqualTo(order2.CustomerName));
Assert.That(order1.Items[0], Is.EqualTo(order2.Items[0]));
Assert.That(order1.Items[1], Is.EqualTo(order2.Items[1]));
// :-( THIS LINE WILL FAIL BECAUSE THE COLLECTIONS ARE NOT VALUE EQUAL
Assert.That(order1, Is.EqualTo(order2));
});
}
Although
order1 and order2 contain identical data, the equality check will fail. This outcome is due to the List type's default use of reference-based equality in C#. As a result, even if two lists contain identical elements, they are treated as different objects in memory, thereby causing the equality check to fail.▸ Introducing ValueCollection for value-based collection equality
To resolve this issue, we need a collection type that implements value-based equality rather than relying on reference equality. This can be accomplished by creating a custom collection class,
ValueCollection<T>, which overrides the equality methods to ensure that the contents of the collection are compared based on their values.Consider the revised
GoodOrder record, which employs ValueCollection<OrderItem>:public record GoodOrder(
string OrderId,
string CustomerName,
ValueCollection<OrderItem> Items);
The implementation of
ValueCollection<T> is as follows:using System.Collections.ObjectModel;
using System.Text;
/// <summary>
/// Represents a collection of values.
/// </summary>
/// <param name="values">The values to initialize the collection with.</param>
/// <typeparam name="T">The type of the values in the collection.</typeparam>
public class ValueCollection<T>(params IList<T> values)
: ReadOnlyCollection<T>(new List<T>(values))
{
/// <summary>
/// Adds an item to the collection.
/// </summary>
/// <param name="item">The object to add to the value collection.</param>
/// <exception cref="ArgumentNullException">Thrown when item is null.</exception>
public void Add(T item)
{
ArgumentNullException.ThrowIfNull(item);
Items.Add(item);
}
/// <summary>
/// Determines whether the specified object is equal to the current object.
/// The comparison is done by comparing the items in the collection.
/// </summary>
/// <param name="obj">The object to compare with the current object.</param>
/// <returns>true if the specified object is equal to the current object; otherwise, false.</returns>
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
{
return false;
}
if (ReferenceEquals(this, obj))
{
return true;
}
return obj is ValueCollection<T> other
&& other.Items.SequenceEqual(Items);
}
/// <inheritdoc />
public override int GetHashCode()
{
HashCode hashCode = new();
foreach (T item in Items)
{
hashCode.Add(item);
}
return hashCode.ToHashCode();
}
/// <summary>
/// Returns a string that represents the current object.
/// If the collection has more than 3 items, only the first 3 items are shown.
/// </summary>
/// <returns>A string that represents the current object.</returns>
public override string ToString()
{
StringBuilder sb = new();
sb.Append("[ ");
// if we have more than 3 items, we only show the first 3 then "..."
if (Items.Count > 3)
{
sb.AppendJoin(", ", Items.Take(3));
sb.Append(", ...");
}
else
{
sb.AppendJoin(", ", Items);
}
sb.Append(" ]");
return sb.ToString();
}
}
The
ValueCollection<T> class extends ReadOnlyCollection<T> and overrides the Equals, GetHashCode, and ToString methods to enforce value-based equality for the collection. Consequently, two instances of ValueCollection<T> are considered equal if their items are equivalent, regardless of whether they are different objects in memory.▸ Testing value equality with GoodOrder
Let us revisit our previous test, this time using
GoodOrder:[Test]
public void TestGoodOrderEquality()
{
OrderItem item1 = new("ProductA", 2, 10.0m);
OrderItem item2 = new("ProductB", 1, 20.0m);
GoodOrder order1 = new("Order1", "John Doe", [item1, item2]);
GoodOrder order2 = new("Order1", "John Doe", [item1, item2]);
Assert.Multiple(() =>
{
Assert.That(order1.OrderId, Is.EqualTo(order2.OrderId));
Assert.That(order1.CustomerName, Is.EqualTo(order2.CustomerName));
Assert.That(order1.Items[0], Is.EqualTo(order2.Items[0]));
Assert.That(order1.Items[1], Is.EqualTo(order2.Items[1]));
// :-) THIS LINE WILL PASS BECAUSE THE COLLECTIONS ARE VALUE EQUAL
Assert.That(order1, Is.EqualTo(order2));
});
}
In this scenario, the equality check succeeds because
ValueCollection<OrderItem> guarantees that the Items property is compared based on the values of its elements, rather than comparing the references of the collections.▸ Advantages of using ValueCollection
The use of
ValueCollection<T> in records yields several significant benefits:Consistent equality semantics: by overriding the
Equals and GetHashCode methods, ValueCollection<T> ensures consistent value-based equality, making it easier to work with records that contain collections.Simplified testing: since equality behaviour matches the expected value-based semantics, writing and maintaining unit tests becomes more straightforward without the need to address reference equality issues.
Enhanced readability and maintainability: the use of
ValueCollection<T> makes the intent explicit, that the collection should be treated as a set of values and not merely a reference, which enhances code readability and maintainability.▸ Key considerations
While
ValueCollection<T> offers a robust solution for achieving value equality, certain considerations must be kept in mind:Immutability:
ValueCollection<T> is based on ReadOnlyCollection<T>, meaning that modifications are restricted after the collection is created, except by using the Add method provided. This characteristic is crucial for preserving immutability, which is fundamental for records.Performance implications: depending on the size of the collection, computing hash codes or performing equality checks may introduce performance overhead, as each element in the collection must be evaluated individually.
▸ Conclusion
Value equality in .NET records is a powerful feature that enables developers to treat objects as equal based on their contents rather than their references. However, when dealing with collections, it is essential to ensure that equality comparisons are conducted correctly. The default collection types in C# rely on reference equality, which can lead to unintended behaviour when used within records.
By employing a custom collection type such as
ValueCollection<T>, you can ensure that collections in your records are compared based on their values, thereby providing consistent and intuitive value equality. This approach streamlines testing, enhances code clarity, and ensures that your records are both robust and reliable.If you are working with .NET records and require value equality involving collections, consider implementing
ValueCollection<T> to circumvent the limitations of reference-based equality, ensuring that your data is compared in a manner consistent with your expectations.Does this resonate with your team?
Let's talk about how Atypical Consulting can help you move forward.
Contact me