Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1118 minIntermédiaire

Égalité, Equals, GetHashCode, ToString et records

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • distinguer identité et égalité de deux objets ;
  • redéfinir Equals et GetHashCode ensemble ;
  • redéfinir ToString ;
  • utiliser un record pour un objet-valeur ;
  • choisir entre classe, record et structure.

4 exercices corrigés

Deux objets sont identiques s'il s'agit du même objet en mémoire, et égaux s'ils représentent la même chose selon une règle que tu choisis : même numéro d'inventaire, même contenu. Par défaut, C# compare les objets d'une classe par identité ; pour comparer par contenu, on redéfinit Equals et GetHashCode, ou on utilise un record, un type dont l'égalité porte sur les valeurs. Ce chapitre explique ces mécanismes, le piège de GetHashCode avec Dictionary et HashSet, la redéfinition de ToString, et le choix entre classe, record et structure.

Identité et égalité

Deux postes créés séparément avec le même numéro d'inventaire PC-0001 : est-ce le même poste ? Pour la gestion de parc, oui, c'est la même machine, chargée deux fois depuis la base. Pour le langage, non : ce sont deux objets distincts en mémoire.

NotionQuestionEn C#
IdentitéEst-ce le même objet ?ReferenceEquals(a, b) ; == par défaut sur une classe
ÉgalitéLes deux objets représentent-ils la même chose ?a.Equals(b), une fois redéfinie

Ce que fait == par défaut

csharp
class PosteBrut
{
    public PosteBrut(string num, string marque)
    {
        NumInventaire = num;
        Marque = marque;
    }
    public string NumInventaire { get; }
    public string Marque { get; }
}
csharp
PosteBrut a = new PosteBrut("PC-0001", "Dell");
PosteBrut b = new PosteBrut("PC-0001", "Dell");
Console.WriteLine(a == b);           // False
Console.WriteLine(a.Equals(b));      // False
Console.WriteLine(a);                // PosteBrut
HashSet<PosteBrut> brut = [a, b];
Console.WriteLine(brut.Count);       // 2

Pour une classe, == et la méthode Equals héritée d'object comparent les références. Le HashSet, censé éliminer les doublons, garde les deux postes. Et ToString() affiche seulement le nom du type (avec son espace de noms dans un vrai projet, GestParc.Domaine.PosteBrut).

Redéfinir Equals et GetHashCode

Pour que deux postes de même numéro soient égaux, on redéfinit les deux méthodes héritées d'object, et on implémente IEquatable<T> pour une version typée, plus rapide :

csharp
class Poste : IEquatable<Poste>
{
    public Poste(string numInventaire, string marque)
    {
        NumInventaire = numInventaire.Trim().ToUpperInvariant();
        Marque = marque;
    }

    public string NumInventaire { get; }      // l'identité métier : jamais modifiée
    public string Marque { get; set; }

    public bool Equals(Poste? autre) => autre is not null && NumInventaire == autre.NumInventaire;

    public override bool Equals(object? obj) => Equals(obj as Poste);

    public override int GetHashCode() => NumInventaire.GetHashCode();

    public override string ToString() => $"{NumInventaire} ({Marque})";
}
csharp
Poste p1 = new Poste("PC-0001", "Dell");
Poste p2 = new Poste("pc-0001", "Dell (réétiqueté)");
Console.WriteLine(p1.Equals(p2));                 // True
Console.WriteLine(p1 == p2);                      // False : == compare toujours les références
Console.WriteLine(ReferenceEquals(p1, p2));       // False
HashSet<Poste> postes = [p1, p2];
Console.WriteLine(postes.Count);                  // 1
Dictionary<Poste, string> affectations = new Dictionary<Poste, string> { [p1] = "Hélène Garnier" };
Console.WriteLine(affectations[p2]);              // Hélène Garnier
Console.WriteLine(p1);                            // PC-0001 (Dell)

L'égalité ne porte que sur NumInventaire, l'identifiant métier : la marque peut différer, c'est le même poste. List.Contains, List.Remove, Distinct(), HashSet et Dictionary utilisent tous Equals et GetHashCode : ils se comportent désormais comme le métier l'attend.

Le contrat à respecter :

  1. Equals est réflexive (a.Equals(a)), symétrique (si a.Equals(b) alors b.Equals(a)) et transitive.
  2. a.Equals(null) renvoie false, sans exception.
  3. Deux objets égaux ont le même GetHashCode(). L'inverse n'est pas vrai : deux objets différents peuvent avoir le même code.
  4. Les propriétés utilisées par Equals et GetHashCode ne changent jamais : sinon l'objet se perd dans le HashSet où il est rangé.

Quand l'égalité porte sur plusieurs propriétés, HashCode.Combine calcule un bon code : public override int GetHashCode() => HashCode.Combine(Nom, Editeur);.

Pourquoi GetHashCode compte

Un HashSet ou un Dictionary ne compare pas chaque élément avec Equals, ce serait trop lent. Il calcule d'abord le code de hachage de l'élément, qui désigne un « tiroir », puis compare avec Equals seulement les éléments du même tiroir. Si deux objets égaux ont des codes différents, ils tombent dans des tiroirs différents et ne sont jamais comparés.

csharp
class PosteBancal
{
    public PosteBancal(string num) => NumInventaire = num;
    public string NumInventaire { get; }
    public override bool Equals(object? obj) => obj is PosteBancal autre && autre.NumInventaire == NumInventaire;
    // GetHashCode oublié : le compilateur avertit (CS0659)
}
csharp
PosteBancal c = new PosteBancal("PC-0001");
PosteBancal d = new PosteBancal("PC-0001");
Console.WriteLine(c.Equals(d));                   // True
HashSet<PosteBancal> bancal = [c];
Console.WriteLine(bancal.Contains(d));            // False

c et d sont égaux, mais le HashSet ne trouve pas d : son GetHashCode hérité d'object est différent de celui de c (à une coïncidence près). Le compilateur prévient par l'avertissement CS0659 : « 'PosteBancal' se substitue à Object.Equals(object o) mais pas à Object.GetHashCode() ».

Et l'opérateur == ?

== n'utilise pas Equals : pour une classe, il compare toujours les références, sauf si la classe surcharge l'opérateur (public static bool operator ==(Poste? a, Poste? b), avec son jumeau !=). Pour une entité comme Poste, beaucoup d'équipes laissent == comparer l'identité et utilisent Equals pour l'égalité métier : les deux questions restent distinctes. Les records, plus bas, surchargent == automatiquement.

Le cas des chaînes

string est une classe, mais elle redéfinit Equals et surcharge == : deux chaînes de même contenu sont égales, même si ce sont deux objets distincts.

csharp
string s1 = "PC-0001";
string s2 = string.Concat("PC-", "0001");
Console.WriteLine(s1 == s2);                       // True : même contenu
Console.WriteLine(ReferenceEquals(s1, s2));        // False : deux objets
Console.WriteLine(string.Equals("pc-0001", s1, StringComparison.OrdinalIgnoreCase));   // True

Pour comparer sans tenir compte de la casse, utilise StringComparison.OrdinalIgnoreCase plutôt que ToUpper() des deux côtés. En Java, c'est l'inverse : == compare les références des chaînes, il faut equals() (chapitre 16).

ToString : représenter un objet

ToString() est utilisée par Console.WriteLine, l'interpolation, le débogueur et les contrôles WinForms qui affichent une liste d'objets. Redéfinis-la sur chaque classe métier, avec ce qui identifie l'objet pour un humain : PC-0001 (Dell), #4 Serveur de fichiers inaccessible [Critique, EnCours]. Ce n'est pas un format d'export : pour un fichier CSV ou JSON, écris une méthode dédiée (chapitre 9).

Entité ou objet-valeur ?

La conception orientée objet distingue deux sortes d'objets métier.

EntitéObjet-valeur
Défini parune identité qui persiste dans le tempsses valeurs, rien d'autre
Ses données changent ?oui, c'est toujours le même objetnon : on en crée un nouveau
Égalitésur l'identifiant (numéro d'inventaire, numéro de ticket)sur toutes les valeurs
Exemples GestParcPoste, Ticket, Employe, Logicielune adresse, un montant avec sa devise, une période, un numéro d'inventaire validé
En C#une classe, avec Equals sur l'identifiantun record

Deux adresses « 12 rue des Lilas, 69003 Lyon » sont interchangeables : c'est la même valeur. Deux tickets de même titre ne le sont pas.

Les records

Un record (C# 9) est une classe pensée pour les objets-valeurs. En une ligne, le compilateur génère les propriétés init, un constructeur, l'égalité par valeur (Equals, GetHashCode, == et !=), un ToString lisible et la déconstruction.

csharp
record LigneImport(string NumInventaire, string Marque, decimal Prix);
csharp
LigneImport l1 = new LigneImport("PC-0010", "Dell", 899m);
LigneImport l2 = new LigneImport("PC-0010", "Dell", 899m);
Console.WriteLine(l1 == l2);                       // True : égalité par valeur
Console.WriteLine(l1);
LigneImport l3 = l1 with { Prix = 849m };          // copie modifiée
Console.WriteLine(l3);
Console.WriteLine(l1.Prix);                        // 899 : l1 n'a pas changé
var (num, marque, prix) = l3;                      // déconstruction
Console.WriteLine($"{num} {marque} {prix}");
// l1.Prix = 10;                                   // erreur CS8852 : propriété init uniquement
text
True
LigneImport { NumInventaire = PC-0010, Marque = Dell, Prix = 899 }
LigneImport { NumInventaire = PC-0010, Marque = Dell, Prix = 849 }
899
PC-0010 Dell 849

Un record positionnel est immuable : pour « modifier » un record, l'expression with crée une copie dont certaines propriétés changent. Cette immuabilité est ce qui rend l'égalité par valeur sûre : un record rangé dans un HashSet ne peut pas changer de valeur sous ses pieds.

Un record peut aussi avoir un corps, pour valider ses données dans un constructeur explicite. Voici un numéro d'inventaire qui ne peut pas exister sous une forme invalide :

csharp
record NumeroInventaire
{
    public NumeroInventaire(string valeur)
    {
        valeur = valeur.Trim().ToUpperInvariant();
        if (!System.Text.RegularExpressions.Regex.IsMatch(valeur, @"^(PC|IM)-\d{4}$"))
        {
            throw new ArgumentException($"Numéro d'inventaire invalide : {valeur}");
        }
        Valeur = valeur;
    }

    public string Valeur { get; }

    public override string ToString() => Valeur;
}
csharp
NumeroInventaire n1 = new NumeroInventaire("pc-0042");
Console.WriteLine(n1);                                        // PC-0042
Console.WriteLine(n1 == new NumeroInventaire("PC-0042"));     // True
new NumeroInventaire("PC42");      // ArgumentException : Numéro d'inventaire invalide : PC42

Une propriété NumeroInventaire NumInventaire dans Poste, plutôt qu'un string, garantit que tout poste a un numéro valide, sans revérifier le format partout. La vérification se fait une fois, à la création de la valeur.

struct et record struct

Les structures (struct) sont des types valeur, comme int ou DateOnly : la variable contient directement les données, et une affectation copie tout. Elles conviennent aux petites valeurs (quelques champs), immuables, très nombreuses. Un readonly record struct combine les avantages : type valeur, immuable, égalité et ToString générés.

csharp
readonly record struct Periode(DateOnly Debut, DateOnly Fin)
{
    public bool Contient(DateOnly jour) => jour >= Debut && jour <= Fin;
}
csharp
Periode garantie = new Periode(new DateOnly(2024, 9, 2), new DateOnly(2027, 9, 1));
Console.WriteLine(garantie.Contient(new DateOnly(2026, 11, 2)));   // True
Periode copie = garantie;
copie = copie with { Fin = new DateOnly(2028, 9, 1) };
Console.WriteLine($"{garantie.Fin} / {copie.Fin}");                 // 01/09/2027 / 01/09/2028

Choisir : class, record ou struct

TypeNatureÉgalité par défautUsage
classréférenceidentité (référence)les entités et les services : Ticket, Poste, TicketService
record (record class)référencevaleursles objets-valeurs, les données transportées (lignes d'import, réponses d'API)
structvaleurvaleurs, champ par champ (lente si non redéfinie)rare dans le code métier : petites valeurs techniques
readonly record structvaleurvaleurspetites valeurs immuables : une période, un montant, des coordonnées

Exercices

Exercice 1 sur 4 Égaux ou identiques ? Réussi À revoir

La classe Logiciel redéfinit Equals et GetHashCode sur le nom, sans tenir compte de la casse ni des espaces en fin de nom (enlevés par le constructeur), et ToString affiche « Nom (Editeur) ». Elle ne surcharge pas ==. Donne les cinq lignes affichées.

csharp
Logiciel a = new Logiciel("VLC", "VideoLAN");
Logiciel b = new Logiciel("vlc ", "Videolan");
Logiciel c = a;
Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(a == c);
HashSet<Logiciel> set = [a, b, c];
Console.WriteLine(set.Count);
Console.WriteLine(a);
Voir la réponseMasquer la réponse de l'exercice 1
text
False
True
True
1
VLC (VideoLAN)

== compare les références : a et b sont deux objets, a et c le même. Equals compare les noms sans la casse : égaux. Le HashSet utilise GetHashCode puis Equals : a, b et c comptent pour un seul élément. Le premier ajouté (a) est conservé.

Tu avais trouvé ?

Exercice 2 sur 4 Écrire l'égalité Réussi À revoir

Écris dans la classe Logiciel de l'exercice précédent l'implémentation de IEquatable<Logiciel>, Equals(object?) et GetHashCode(), avec une égalité sur le nom insensible à la casse.

Voir la réponseMasquer la réponse de l'exercice 2
csharp
class Logiciel : IEquatable<Logiciel>
{
    public Logiciel(string nom, string editeur)
    {
        Nom = nom.Trim();
        Editeur = editeur;
    }

    public string Nom { get; }
    public string Editeur { get; }

    public bool Equals(Logiciel? autre) =>
        autre is not null && string.Equals(Nom, autre.Nom, StringComparison.OrdinalIgnoreCase);

    public override bool Equals(object? obj) => Equals(obj as Logiciel);

    public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Nom);

    public override string ToString() => $"{Nom} ({Editeur})";
}

Le point délicat est GetHashCode : Nom.GetHashCode() donnerait des codes différents pour « VLC » et « vlc », deux noms pourtant égaux. StringComparer.OrdinalIgnoreCase.GetHashCode(Nom) calcule un code cohérent avec la comparaison choisie. Nom n'a pas de set : la valeur qui fonde l'égalité ne peut pas changer.

Tu avais trouvé ?

Exercice 3 sur 4 Records et with Réussi À revoir

Donne l'affichage.

csharp
record Adresse(string Rue, string CodePostal, string Ville);
csharp
Adresse ad1 = new Adresse("12 rue des Lilas", "69003", "Lyon");
Adresse ad2 = ad1 with { CodePostal = "69007" };
Adresse ad3 = new Adresse("12 rue des Lilas", "69003", "Lyon");
Console.WriteLine(ad1 == ad3);
Console.WriteLine(ad1 == ad2);
Console.WriteLine(ReferenceEquals(ad1, ad3));
Console.WriteLine(ad2);
Voir la réponseMasquer la réponse de l'exercice 3
text
True
False
False
Adresse { Rue = 12 rue des Lilas, CodePostal = 69007, Ville = Lyon }

Le record surcharge == pour comparer les valeurs : ad1 et ad3 sont égales, bien que distinctes en mémoire (ReferenceEquals le confirme). with a créé une copie au code postal différent, sans toucher à ad1. Le ToString généré liste les propriétés.

Tu avais trouvé ?

Exercice 4 sur 4 Classe ou record ? Réussi À revoir

Pour chaque concept de GestParc, choisis une classe (entité) ou un record (objet-valeur), et justifie.

  1. Un employé.
  2. Un montant de 1 049,90 euros.
  3. Une ligne lue dans un fichier CSV d'import de matériel.
  4. Un ticket d'assistance.
  5. Les coordonnées GPS d'un site de l'entreprise.
Voir la réponseMasquer la réponse de l'exercice 4
  1. Classe : un employé a une identité qui persiste quand son service, son téléphone ou même son nom changent.
  2. Record (idéalement un readonly record struct Montant(decimal Valeur, string Devise)) : deux montants de même valeur et même devise sont interchangeables. On peut lui ajouter un opérateur + qui refuse d'additionner deux devises différentes.
  3. Record : une donnée transportée, immuable, comparée par valeur, sans comportement.
  4. Classe : un ticket a un numéro, un cycle de vie (ouvert, en cours, résolu) et des interventions qui s'ajoutent.
  5. Record (ou record struct) : une paire latitude, longitude n'est qu'une valeur.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Les principes SOLID expliqués avec des exemplesRevoir le sommaire du cours