Comprendre l'égalité de valeur dans les records .NET

Blog post cover image
Les records .NET se sont imposés comme une abstraction puissante pour représenter des données immuables de façon concise et expressive, en grande partie grâce à leur support natif de l'égalité de valeur. Mais obtenir une égalité de valeur réellement complète sur tous les aspects d'un record, en particulier quand des collections sont impliquées, demande de comprendre quelques subtilités et de les traiter avec soin. Dans cet article, nous plongeons dans les rouages de l'égalité de valeur des records .NET, nous examinons le problème que posent les collections, et nous présentons une solution robuste qui permet d'intégrer une collection sans perdre la sémantique d'égalité de valeur.
Le code source complet est sur GitHub.

▸ Définir l'égalité de valeur

En .NET, l'égalité de valeur désigne le principe selon lequel deux objets sont considérés comme égaux si leurs valeurs, et non leurs références, sont identiques. Ce concept est particulièrement utile quand on manipule des objets de transfert de données (DTO) et d'autres formes de représentation de données, où deux instances différentes portant les mêmes données doivent être traitées comme identiques.
Les records en C# fournissent un mécanisme pratique d'égalité de valeur automatique. Par défaut, l'égalité de deux records est déterminée en comparant les valeurs de toutes les propriétés du record. Cela réduit considérablement le code boilerplate qu'il faudrait sinon écrire pour implémenter l'égalité et les fonctions de hachage.
En revanche, obtenir une égalité de valeur cohérente devient nettement plus délicat lorsque les propriétés incluent des collections, puisque les types de collection classiques de .NET utilisent par défaut une égalité par référence.

▸ Le problème des collections dans les records

Prenons le record BadOrder ci-dessous, qui contient une List<OrderItem> parmi ses propriétés :
public record OrderItem(
    string ProductName,
    int Quantity,
    decimal Price);

public record BadOrder(
    string OrderId,
    string CustomerName,
    List<OrderItem> Items);
Le record BadOrder contient une propriété List<OrderItem> nommée Items. Pour illustrer le problème, créons deux instances de BadOrder avec des données identiques et évaluons leur égalité.
Voici le code du test :
[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]));

        // :-( CETTE LIGNE ÉCHOUE PARCE QUE LES COLLECTIONS NE SONT PAS ÉGALES PAR VALEUR
        Assert.That(order1, Is.EqualTo(order2));
    });
}
Bien que order1 et order2 contiennent des données identiques, le test d'égalité échoue. La raison tient à l'égalité par référence utilisée par défaut par le type List en C#. Résultat : même si deux listes contiennent exactement les mêmes éléments, elles restent deux objets différents en mémoire, ce qui fait échouer la comparaison.

▸ ValueCollection : une collection qui compare par valeur

Pour résoudre le problème, il nous faut un type de collection qui implémente une égalité par valeur plutôt que par référence. Cela passe par une classe de collection dédiée, ValueCollection<T>, qui surcharge les méthodes d'égalité pour que le contenu de la collection soit comparé sur ses valeurs.
Voici le record GoodOrder révisé, qui utilise ValueCollection<OrderItem> :
public record GoodOrder(
    string OrderId,
    string CustomerName,
    ValueCollection<OrderItem> Items);
Et l'implémentation de ValueCollection<T> :
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();
    }
}
La classe ValueCollection<T> étend ReadOnlyCollection<T> et surcharge Equals, GetHashCode et ToString pour imposer une égalité par valeur sur la collection. Par conséquent, deux instances de ValueCollection<T> sont considérées comme égales si leurs éléments sont équivalents, qu'il s'agisse ou non de deux objets distincts en mémoire.

▸ Tester l'égalité de valeur avec GoodOrder

Reprenons le test précédent, cette fois avec 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]));

        // :-) CETTE LIGNE PASSE PARCE QUE LES COLLECTIONS SONT ÉGALES PAR VALEUR
        Assert.That(order1, Is.EqualTo(order2));
    });
}
Cette fois, le test d'égalité réussit, parce que ValueCollection<OrderItem> garantit que la propriété Items est comparée sur les valeurs de ses éléments plutôt que sur les références des collections.

▸ Les avantages de ValueCollection

Utiliser ValueCollection<T> dans un record apporte plusieurs bénéfices notables :

▸ Points de vigilance

ValueCollection<T> est une solution robuste, mais deux points méritent d'être gardés en tête :

▸ Conclusion

L'égalité de valeur dans les records .NET est une fonctionnalité puissante, qui permet de traiter deux objets comme égaux sur la base de leur contenu plutôt que de leurs références. Mais dès qu'on manipule des collections, il devient essentiel de s'assurer que les comparaisons se font correctement. Les types de collection par défaut en C# reposent sur l'égalité par référence, ce qui produit un comportement inattendu à l'intérieur d'un record.
En utilisant un type de collection dédié comme ValueCollection<T>, vous garantissez que les collections de vos records sont comparées sur leurs valeurs, et donc une égalité de valeur cohérente et intuitive. Cette approche simplifie les tests, clarifie le code et rend vos records à la fois robustes et fiables.
Si vous travaillez avec des records .NET et que vous avez besoin d'une égalité de valeur incluant des collections, envisagez d'implémenter ValueCollection<T> pour contourner les limites de l'égalité par référence, et comparer vos données conformément à vos attentes.

Does this resonate with your team?

Let's talk about how Atypical Consulting can help you move forward.

Contact me