Comprendre la différence entre égalité et identité est essentiel dès que l’on commence à utiliser des collections comme Dictionary, HashSet ou même List en .NET. Ce document propose un parcours progressif : d’abord les notions de base (égalité logique vs identité de référence), puis le fonctionnement interne des collections (hash + Equals), avant de montrer comment implémenter correctement l’égalité pour les classes et les structs, utiliser des comparateurs externes et éviter les pièges les plus courants.
Deux grandes notions : égalité et identité
En .NET, on distingue :
- Identité (référence) : deux variables pointent‑elles vers le même objet ?
- Testée par
ReferenceEquals(a, b)ou, par défaut,==sur uneclassnon surchargée.
- Testée par
- Égalité logique (valeur) : deux objets représentent‑ils la même chose selon le métier ?
- Testée par
Equals,IEquatable<T>, comparateurs, etc.
- Testée par
Les collections basées sur des clés (surtout Dictionary et HashSet) utilisent l’égalité logique, pas (ou pas seulement) l’identité.
Mécanisme général dans Dictionary / HashSet
Pour rechercher/insérer un élément, ces collections utilisent :
GetHashCode()pour trouver un “bucket”.Equals(...)(ouIEqualityComparer<T>.Equals) pour distinguer les éléments dans ce bucket.
Donc, pour un type T utilisé comme clé de Dictionary<TKey, ...> ou élément de HashSet<T> :
EqualsetGetHashCodedoivent être cohérents.- Un mauvais
GetHashCodepeut poser des problèmes de performance. - Un mauvais
Equalspeut poser des problèmes fonctionnels (clés introuvables, doublons inattendus).
Contrat fondamental égalité / hash
Pour tout type que l’on souhaite utiliser comme clé ou élément :
- Contrat 1 – Cohérence
- Si
a.Equals(b)esttrue⇒a.GetHashCode() == b.GetHashCode()doit être vrai. - L’inverse n’est pas obligatoire : deux objets différents peuvent partager un même hash (collision).
- Contrat 2 – Stabilité
- La valeur de
GetHashCode()ne doit pas changer tant que l’objet est dans unDictionary/HashSet. - Ne pas baser le calcul du hash sur des champs susceptibles d’être modifiés après l’insertion dans la collection.
- Contrat 3 – Propriétés d’égalité
Equalsdoit être :- Réflexive :
a.Equals(a)esttrue. - Symétrique : si
a.Equals(b)alorsb.Equals(a). - Transitive : si
a.Equals(b)etb.Equals(c)alorsa.Equals(c).
- Réflexive :
Classes (types référence) : égalité par défaut vs logique métier
Par défaut, une class sans override :
class Person
{
public string Name { get; set; }
}
Equalset==comparent les références :- Deux instances avec le même contenu (
Name) mais créées avecnewseront différentes pour unDictionaryouHashSet.
- Deux instances avec le même contenu (
Pour avoir une égalité métier (par valeur), il faut :
- Implémenter
IEquatable<Person>. - Surcharger
Equals(object)pour déléguer versEquals(Person). - Surcharger
GetHashCode()de manière cohérente.
Exemple (égalité basée sur Email) :
public sealed class Person : IEquatable<Person>
{
public string Email { get; }
public string Name { get; }
public Person(string email, string name)
{
Email = email;
Name = name;
}
public bool Equals(Person? other) => other is not null && Email == other.Email;
public override bool Equals(object? obj) => ReferenceEquals(this, obj) || obj is Person other && Equals(other);
public override int GetHashCode() => Email.GetHashCode(); // ou HashCode.Combine(Email)
public static bool operator ==(Person? left, Person? right) => Equals(left, right);
public static bool operator !=(Person? left, Person? right)=> !Equals(left, right);
}
Maintenant :
var set = new HashSet<Person>();
set.Add(new Person("a@x.com", "Alice"));
set.Add(new Person("a@x.com", "Alice 2"));
set.Count == 1; // égalité logique sur Email
Structs (types valeur) : égalité naturelle et value objects
Pour les struct :
- La BCL fournit une égalité par défaut champ par champ (mais souvent via réflexion → potentiellement plus lente).
- Pour un value object (type valeur métier), il est fortement recommandé :
- de le rendre immuable (
readonly struct, propriétés en lecture seule), - d’implémenter
IEquatable<T>etGetHashCodeexplicitement.
- de le rendre immuable (
Exemple :
public readonly struct Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public bool Equals(Money other) => Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) => obj is Money other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}
- Ce
Moneyest parfait comme clé deDictionary<Money,...>ou élément deHashSet<Money>.
Comparateur externe : IEqualityComparer<T>
Il arrive que l’on ne puisse pas ou ne souhaite pas modifier le type, par exemple dans les cas suivants :
- Type provenant d’une bibliothèque externe.
- Besoin de plusieurs notions d’égalité (par adresse e‑mail, par identifiant, insensible à la casse, etc.).
Dans ces situations, vous pouvez fournir un comparateur externe :
public sealed class PersonEmailComparer : IEqualityComparer<Person>
{
public bool Equals(Person? x, Person? y) => string.Equals(x?.Email, y?.Email, StringComparison.OrdinalIgnoreCase);
public int GetHashCode(Person obj) => StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Email);
}
// Usage
var set = new HashSet<Person>(new PersonEmailComparer());
var dict = new Dictionary<Person, Order>(new PersonEmailComparer());
- Ici, l’égalité dans la collection est basée uniquement sur
Email, en insensible à la casse.
Cas particuliers : Dictionary, HashSet, List
-
Dictionary<TKey, TValue>:- Utilise
IEqualityComparer<TKey>(par défaut :EqualityComparer<TKey>.Default, qui s’appuie surIEquatable<T>/Equals/GetHashCode). - Une clé “égale” écrase l’ancienne valeur (
dict[key] = newValue).
- Utilise
-
HashSet<T>:- Ensemble d’éléments uniques.
- Un
Addd’un élément égal à un autre existant ne change pas le set (renvoiefalse).
-
List<T>:- Ne se base pas sur le hash pour stocker, mais l’égalité intervient pour :
Contains,IndexOf,Remove, etc. - Utilise aussi
EqualityComparer<T>.Default.
- Ne se base pas sur le hash pour stocker, mais l’égalité intervient pour :
Donc le même contrat d’égalité / hash est important partout, même si List<T> ne se base pas sur GetHashCode pour la structure interne.
Pièges fréquents
- Clé mutable dans un
Dictionary/HashSet
Modification d’un champ utilisé dans Equals/GetHashCode après insertion. L’objet est “perdu” dans la collection (introuvable, pas supprimable correctement).
- Override partiel (
EqualssansGetHashCode, ou inverse)
Viol du contrat, bugs difficile à diagnostiquer.
- Comparer par référence sans le vouloir
Classe sans override → Equals = référence. Deux objets “égaux” métier mais créés avec new sont considérés “différents” pour les collections.
- Hash code constant ou trop pauvre
public override int GetHashCode() => 1;
Fonctionne logiquement, mais :
- toutes les clés/éléments dans le même bucket,
- performances très mauvaises (souvent proche d’une liste chaînée parcourue à chaque accès).
Résumé pratique / Checklist
- Disposez-vous d’une notion claire d’égalité métier ?
- Qu’est-ce qui fait que deux instances représentent « la même chose » ?
- Même
Email? MêmeId? Même couple(X, Y)?
- Pour une
class:
- Implémenter
IEquatable<T>lorsque le type est maîtrisé. - Surcharger
Equals(object)pour déléguer versEquals(T). - Surcharger
GetHashCodede façon cohérente avecEquals. - Facultatif mais recommandé : aligner
==et!=avecEquals.
- Pour un
struct:
- Le rendre immuable (
readonly struct, propriétés en lecture seule).
- Si le type ne peut pas être modifié :
- Utiliser un
IEqualityComparer<T>personnalisé pour lesDictionaryetHashSet.
- Éviter :
- De baser
GetHashCodesur des champs modifiés après insertion. - De changer la sémantique de l’égalité sans mettre à jour toutes les méthodes concernées.