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
EqualsetGetHashCodeensemble ; - 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.
| Notion | Question | En 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
class PosteBrut
{
public PosteBrut(string num, string marque)
{
NumInventaire = num;
Marque = marque;
}
public string NumInventaire { get; }
public string Marque { get; }
}
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 :
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})";
}
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 :
Equalsest réflexive (a.Equals(a)), symétrique (sia.Equals(b)alorsb.Equals(a)) et transitive.a.Equals(null)renvoiefalse, sans exception.- Deux objets égaux ont le même
GetHashCode(). L'inverse n'est pas vrai : deux objets différents peuvent avoir le même code. - Les propriétés utilisées par
EqualsetGetHashCodene changent jamais : sinon l'objet se perd dans leHashSetoù 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.
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)
}
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.
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 par | une identité qui persiste dans le temps | ses valeurs, rien d'autre |
| Ses données changent ? | oui, c'est toujours le même objet | non : on en crée un nouveau |
| Égalité | sur l'identifiant (numéro d'inventaire, numéro de ticket) | sur toutes les valeurs |
| Exemples GestParc | Poste, Ticket, Employe, Logiciel | une adresse, un montant avec sa devise, une période, un numéro d'inventaire validé |
| En C# | une classe, avec Equals sur l'identifiant | un 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.
record LigneImport(string NumInventaire, string Marque, decimal Prix);
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
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 :
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;
}
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.
readonly record struct Periode(DateOnly Debut, DateOnly Fin)
{
public bool Contient(DateOnly jour) => jour >= Debut && jour <= Fin;
}
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
| Type | Nature | Égalité par défaut | Usage |
|---|---|---|---|
class | référence | identité (référence) | les entités et les services : Ticket, Poste, TicketService |
record (record class) | référence | valeurs | les objets-valeurs, les données transportées (lignes d'import, réponses d'API) |
struct | valeur | valeurs, champ par champ (lente si non redéfinie) | rare dans le code métier : petites valeurs techniques |
readonly record struct | valeur | valeurs | petites 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.
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
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
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.
record Adresse(string Rue, string CodePostal, string Ville);
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
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.
- Un employé.
- Un montant de 1 049,90 euros.
- Une ligne lue dans un fichier CSV d'import de matériel.
- Un ticket d'assistance.
- Les coordonnées GPS d'un site de l'entreprise.
Voir la réponseMasquer la réponse de l'exercice 4
- Classe : un employé a une identité qui persiste quand son service, son téléphone ou même son nom changent.
- 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. - Record : une donnée transportée, immuable, comparée par valeur, sans comportement.
- Classe : un ticket a un numéro, un cycle de vie (ouvert, en cours, résolu) et des interventions qui s'ajoutent.
- Record (ou
record struct) : une paire latitude, longitude n'est qu'une valeur.
Tu avais trouvé ?