CodeGym /Cours /C# SELF /Comparaison d'objets en C# :

Comparaison d'objets en C# : Equals et GetHashCode()

C# SELF
Niveau 34 , Leçon 3
Disponible

1. Introduction

En prog, on compare tout le temps des trucs : des nombres, des chaînes, des objets. Mais c'est quoi vraiment "l'égalité" des objets en C# ? C'est pas toujours aussi évident qu'on le pense. Aujourd'hui, on va voir deux méthodes clés qui définissent comment les objets sont comparés : Equals() et GetHashCode(). Comprendre ces méthodes, c'est crucial pour que tes programmes tournent bien, surtout quand tu utilises des collections comme Dictionary ou HashSet.

En C#, il y a deux grands types d'égalité :

Égalité de référence (Reference Equality) :

Ça veut dire que deux variables de type référence pointent vers le même objet en mémoire. Ça se vérifie avec l'opérateur == pour les types référence.

MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;

Console.WriteLine(obj1 == obj2); // false (objets différents en mémoire)
Console.WriteLine(obj1 == obj3); // true (les deux références pointent sur le même objet)

Égalité de valeur (Value Equality) :

Ça veut dire que deux objets différents (ou deux types valeur) ont le même contenu (valeurs de leurs champs/propriétés). C'est ce qu'on veut généralement quand on compare des objets. Pour ça, on utilise la méthode Equals().

// Imaginons qu'on a une classe Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);

// p1 et p2 sont des objets différents, mais on veut qu'ils soient "égaux" par la valeur
Console.WriteLine(p1.Equals(p2)); // ? Dépend de l'implémentation de Equals()
Console.WriteLine(p1.Equals(p3)); // ?

2. La méthode Equals()

La méthode Equals() est définie dans la classe de base System.Object, dont héritent tous les types en C#. Son but principal : déterminer si deux objets sont égaux par la valeur.

Comportement par défaut de Equals()

Pour les types valeur (struct, int, bool, etc.) : L'implémentation par défaut de Equals() (héritée de System.ValueType) fait une comparaison bit à bit de tous les champs. Si tous les champs sont égaux, les objets sont considérés comme égaux. En général, ça marche comme on s'y attend.

Pour les types référence (class, string, array, etc.) : L'implémentation par défaut de Equals() (héritée de System.Object) vérifie l'égalité de référence. Donc obj1.Equals(obj2) par défaut retourne true seulement si obj1 et obj2 pointent sur le même objet en mémoire.
class Person // Type référence
{
    public string Name { get; set; }
    public int Age { get; set; }
}

Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // Autre objet, mais même contenu

Console.WriteLine(person1.Equals(person2)); // false (par défaut compare les références)

Comme tu vois, le comportement par défaut pour les types référence, c'est souvent pas ce qu'on veut ! On aimerait que deux "Alice" de 30 ans soient considérées comme égales, même si c'est pas le même objet en mémoire.

Override de Equals() pour tes propres classes

Pour avoir l'égalité par la valeur dans tes propres classes, tu dois obligatoirement overrider la méthode Equals().

Règles pour overrider Equals() (contrat) :

  • Réflexivité : x.Equals(x) doit toujours être true.
  • Symétrie : Si x.Equals(y) vaut true, alors y.Equals(x) doit aussi être true.
  • Transitivité : Si x.Equals(y) et y.Equals(z) sont tous les deux true, alors x.Equals(z) doit aussi être true.
  • Consistance : Plusieurs appels à x.Equals(y) doivent donner le même résultat tant que les objets n'ont pas changé.
  • Compatibilité avec null : x.Equals(null) doit toujours être false.

Exemple : Override de Equals() pour la classe Person

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // Override de Equals()
    public override bool Equals(object? obj)
    {
        // 1. Vérif null
        if (obj == null) return false;

        // 2. Vérif du même type
        if (obj.GetType() != this.GetType()) return false; 

        // 3. Cast
        Person other = (Person)obj;

        // 4. Comparaison des champs par la valeur
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }
}

// Utilisation :
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);

Console.WriteLine(person1.Equals(person2)); // true (maintenant compare par la valeur !)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null));    // false

3. La méthode GetHashCode()

La méthode GetHashCode() est aussi définie dans System.Object. Elle retourne une valeur entière (hash code) qui identifie rapidement et de façon (plus ou moins) unique un objet.

À quoi sert GetHashCode() ?

Les hash codes servent à optimiser le taf avec les collections basées sur des hash tables. Genre :

  • Dictionary<TKey, TValue> (le hash code sert à retrouver la clé vite fait)
  • HashSet<T> (le hash code sert à vérifier l'unicité des éléments)
  • Hashtable

Quand tu ajoutes un objet dans un HashSet ou que tu l'utilises comme clé dans un Dictionary, la collection calcule d'abord le hash code de l'objet. Ça lui permet d'aller direct dans le bon "panier" ou groupe d'éléments qui ont le même hash code, au lieu de tout parcourir. Ensuite, dans ce "panier", elle utilise Equals() pour comparer précisément.

Règles pour overrider GetHashCode() (contrat) :

Si tu overrides Equals(), tu DOIS aussi overrider GetHashCode() ! C'est une des règles les plus importantes en C#.

  • Consistance : Si Equals() retourne true pour deux objets, alors GetHashCode() pour ces objets doit retourner la même valeur. (L'inverse n'est pas vrai : des objets différents peuvent avoir le même hash code — c'est une "collision".)
  • Stabilité : GetHashCode() doit retourner la même valeur pour le même objet tant que les champs utilisés pour la comparaison n'ont pas changé.
  • Rapidité : GetHashCode() doit être rapide et pas trop gourmand en calculs.

Pourquoi c'est si important ? Si tu overrides Equals() mais pas GetHashCode(), tes collections (surtout les hash collections) vont déconner :

  • Dictionary ne pourra pas retrouver ta clé.
  • HashSet va ajouter des doublons parce qu'il pensera qu'ils sont uniques.

Ça arrive parce que par défaut, GetHashCode() retourne un hash basé sur la référence de l'objet (pour les types référence). Si Equals() compare maintenant par la valeur, alors des objets avec la même valeur mais des références différentes auront des hash codes différents, et la collection ne les verra pas comme identiques.

Override de GetHashCode() pour la classe Person

Bonne pratique : génère le hash code à partir des mêmes champs que ceux utilisés dans Equals(). .NET propose la méthode statique HashCode.Combine(), super pratique pour ça.

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public override bool Equals(object? obj)
    {
        if (obj == null || obj.GetType() != this.GetType()) return false;
        Person other = (Person)obj;
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }

    // Override de GetHashCode()
    public override int GetHashCode()
    {
        // On utilise HashCode.Combine pour combiner les hash des champs.
        return HashCode.Combine(Name.ToLowerInvariant(), Age); 
    }
}

// Utilisation dans une collection :
public class Program
{
    public static void Main(string[] args)
    {
        Person p1 = new Person("Alice", 30);
        Person p2 = new Person("Alice", 30);
        Person p3 = new Person("Bob", 25);

        HashSet
  
    uniquePeople = new HashSet
   
    (); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 est considéré égal à p1 par la valeur, donc pas ajouté Console.WriteLine($"Nombre de personnes uniques : {uniquePeople.Count}"); // Affiche : 1 uniquePeople.Add(p3); Console.WriteLine($"Nombre de personnes uniques : {uniquePeople.Count}"); // Affiche : 2 } } 
   
  

Point important : Dans GetHashCode() pour les champs string qui sont comparés sans tenir compte de la casse (StringComparison.OrdinalIgnoreCase dans Equals), tu dois aussi générer le hash code sans tenir compte de la casse (genre, mets en minuscules avant de calculer le hash, comme Name.ToLowerInvariant()). Sinon Equals() retournera true (Alice == alice), mais GetHashCode() donnera des valeurs différentes, ce qui casse le contrat.

4. Surcharge de l'opérateur == et !=

Pour les classes (types référence), l'opérateur == vérifie par défaut l'égalité de référence. Tu peux le surcharger pour qu'il vérifie l'égalité de valeur, comme Equals().

Règles pour surcharger == :

  • Si tu surcharges ==, tu dois aussi surcharger !=.
  • Il est conseillé que le == surchargé ait le même comportement que Equals().
  • Il faut aussi overrider GetHashCode() et Equals() quand tu surcharges ==.
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    // Constructeur, Equals, GetHashCode comme avant

    // Surcharge de l'opérateur ==
    public static bool operator ==(Person? left, Person? right)
    {
        if (ReferenceEquals(left, null)) // Vérifie si left est null
        {
            return ReferenceEquals(right, null); // Si les deux sont null, ils sont égaux
        }
        return left.Equals(right); // Sinon, utilise notre Equals overridé
    }

    // Surcharge de l'opérateur != (obligatoire si == est surchargé)
    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

// Utilisation :
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;

Console.WriteLine(p1 == p2); // true (utilise maintenant le == surchargé)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true

5. record — égalité automatique

Depuis C# 9, il existe le type record. C'est un type référence, mais il implémente automatiquement l'égalité par la valeur (et override Equals(), GetHashCode(), ToString() et les opérateurs ==/!=) sur tous ses champs/propriétés. C'est parfait pour les objets data immuables.

public record PersonRecord(string Name, int Age);

// Utilisation :
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);

Console.WriteLine(r1 == r2); // true (comparaison automatique par la valeur !)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false

record te simplifie grave la vie quand tu veux un comportement de type valeur pour un type référence.

6. Conseils

Si tu overrides Equals(), override toujours aussi GetHashCode() ! Ne pas respecter cette règle, c'est la galère assurée dans les hash collections.

Equals() et GetHashCode() doivent utiliser les mêmes champs. Les champs qui rendent les objets "égaux" par la valeur doivent servir à calculer le hash.

Fais gaffe aux types mutables. Si les champs utilisés dans Equals() et GetHashCode() peuvent changer après la création de l'objet, le hash code de l'objet peut changer. C'est super dangereux pour les hash collections, car l'objet peut "disparaître" après modif (son hash code change, et la collection ne le retrouve plus dans son "panier"). Pour les hash collections, préfère utiliser des types immuables comme clés ou éléments.

Pour les objets data immuables, pense à utiliser record. Ça simplifie grave l'implémentation de l'égalité par la valeur.

Surcharge == et != seulement pour les types référence, quand ça a du sens. Pour les types valeur, == compare déjà par la valeur. Si tu surcharges, assure-toi que le comportement colle à Equals().

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION