Initialisation des systèmes...

Baptiste.Dev
Retour aux notes
LangagesAvancéSérie : C# / .NET

C# : HashSet et l'égalité (classe vs record)

Pourquoi un HashSet dédoublonne un record mais pas une classe, comment rendre une classe utilisable dans un set, et le piège de la mutation.

Par Baptiste Vidal
4 min de lecture
Mis à jour hier
c#csharphashsetrecordclassequalsgethashcodeegalite

C# : HashSet et l'égalité (classe vs record)

Un HashSet<T> garantit qu'aucun élément n'apparaît deux fois. Mais deux fois selon quelle définition de « le même » ? C'est toute la subtilité, et elle change du tout au tout selon que T est une classe ou un record.


# « Unique » selon quoi ?

Comme le Dictionary, un HashSet<T> s'appuie sur deux méthodes de ses éléments :

  • GetHashCode() pour ranger l'élément dans le bon bucket,
  • Equals() pour décider si un nouvel élément est déjà présent.

Ajouter un élément déjà considéré comme égal est simplement ignoré (Add renvoie false). Toute la question est donc : qu'est-ce que Equals renvoie pour tes éléments ? Et là, class et record ne se comportent pas pareil.


# T : class - égalité par référence

Par défaut, une classe compare par référence : deux instances sont égales seulement si ce sont le même objet en mémoire. Leur contenu n'entre pas en jeu.

public class PersonneClass
{
    public string Nom { get; init; }
    public PersonneClass(string nom) => Nom = nom;
}
 
var set = new HashSet<PersonneClass>();
set.Add(new PersonneClass("Alice"));
set.Add(new PersonneClass("Alice"));   // AUTRE référence
 
Console.WriteLine(set.Count);                              // 2
Console.WriteLine(set.Contains(new PersonneClass("Alice"))); // False

Deux PersonneClass("Alice") sont des objets distincts sur le tas, avec des références différentes, donc des hash différents. Le set les voit comme deux personnes différentes. Pire : un Contains avec une troisième instance de même contenu renvoie false, parce que ce n'est encore une fois pas la même référence.

C'est le piège numéro un du HashSet : croire qu'il dédoublonne « par contenu » alors qu'avec une classe brute, il dédoublonne « par identité d'objet ».


# T : record - égalité par valeur

Un record génère automatiquement Equals et GetHashCode par valeur : deux records sont égaux si tous leurs champs le sont. Le set retrouve son comportement intuitif.

public record PersonneRecord(string Nom);
 
var set = new HashSet<PersonneRecord>();
set.Add(new PersonneRecord("Alice"));
set.Add(new PersonneRecord("Alice"));   // même contenu
 
Console.WriteLine(set.Count);                               // 1
Console.WriteLine(set.Contains(new PersonneRecord("Alice"))); // True

Le deuxième Add est ignoré (false), et un Contains par contenu fonctionne. C'est exactement pour ce genre de cas (dédoublonner des objets métier par valeur) que les records brillent.

Le hash d'un record combine celui de tous ses champs. Deux records de mêmes valeurs ont donc le même hash et sont Equals : le contrat exigé par le set est respecté sans effort.


# T : struct - par valeur aussi, mais...

Un struct compare aussi par valeur par défaut, mais via une implémentation par réflexion (ValueType.Equals) lente et parfois surprenante. Si tu veux un type valeur dans un set, préfère un record struct, qui génère une égalité correcte et performante.

public readonly record struct Point(int X, int Y);
 
var set = new HashSet<Point> { new Point(1, 2), new Point(1, 2) };
Console.WriteLine(set.Count);   // 1

# Rendre une classe utilisable dans un set

Si tu dois garder une class (héritage, entité mutable...), deux options pour la dédoublonner par contenu.

Option 1 : redéfinir Equals et GetHashCode sur la classe.

public class PersonneClass
{
    public string Nom { get; init; } = "";
 
    public override bool Equals(object? obj) =>
        obj is PersonneClass p && p.Nom == Nom;
 
    public override int GetHashCode() => Nom.GetHashCode();
}

Option 2 : un IEqualityComparer<T> passé au constructeur, sans toucher à la classe. Utile quand tu ne possèdes pas le type, ou que la règle d'égalité dépend du contexte.

public class ParNom : IEqualityComparer<PersonneClass>
{
    public bool Equals(PersonneClass? a, PersonneClass? b) => a?.Nom == b?.Nom;
    public int GetHashCode(PersonneClass p) => p.Nom.GetHashCode();
}
 
var set = new HashSet<PersonneClass>(new ParNom());
// désormais les personnes de même Nom sont considérées identiques

Pour des chaînes, StringComparer.OrdinalIgnoreCase donne directement un set insensible à la casse : new HashSet<string>(StringComparer.OrdinalIgnoreCase).


# Ne mute jamais un élément après l'avoir ajouté

Le hash d'un élément est calculé à l'insertion pour choisir son bucket. Si tu modifies ensuite un champ qui participe à ce hash, l'élément se retrouve dans le mauvais bucket : le set ne le retrouve plus.

var alice = new PersonneClass { Nom = "Alice" };  // avec Equals/GetHashCode redéfinis
var set = new HashSet<PersonneClass> { alice };
 
alice.Nom = "Alicia";              // le hash a changé, mais l'élément n'a pas bougé de bucket
Console.WriteLine(set.Contains(alice));  // False : perdu dans le set

La parade est la même que pour les clés de dictionnaire : utilise des éléments immuables. Un record (ou une classe à propriétés init / readonly) rend ce bug impossible par construction.


# La suite

Playground mémoire

Visualise « Deux objets distincts » pas à pas

Exécute ce code et observe la pile et le tas évoluer, ligne par ligne.