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.
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"))); // Falsepublic 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); // 2Console.WriteLine(set.Contains(new PersonneClass("Alice"))); // FalseDeux 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"))); // Truepublic 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); // 1Console.WriteLine(set.Contains(new PersonneRecord("Alice"))); // TrueLe 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); // 1public 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();
}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 identiquespublic 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 identiquesPour 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 setvar alice = new PersonneClass { Nom = "Alice" }; // avec Equals/GetHashCode redéfinisvar set = new HashSet<PersonneClass> { alice }; alice.Nom = "Alicia"; // le hash a changé, mais l'élément n'a pas bougé de bucketConsole.WriteLine(set.Contains(alice)); // False : perdu dans le setLa 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
- Dictionary et le hachage - la même mécanique
GetHashCode/Equals, côté clés - Les records - l'égalité par valeur, générée pour toi
- Les collections en pratique - le tour des structures et leurs opérations
Playground mémoire
Visualise « Deux objets distincts » pas à pas
Exécute ce code et observe la pile et le tas évoluer, ligne par ligne.