1. Pourquoi définir operator== pour votre type
Si vous pensez pour l'instant que operator== est une « fantaisie esthétique », vous n'êtes pas seul : presque tout le monde le pense jusqu'à ce qu'on écrive du code un peu plus vivant. Mais dès que vous avez un std::vector d'objets, vous voulez soudainement faire des opérations « normales » : trouver un élément, vérifier l'unicité, supprimer une entité précise, comparer deux résultats.
Les types intégrés (int, std::string) savent se comparer « out of the box », alors que votre class Task — non (par défaut). Et C++ est franc à ce sujet : il n'essaiera pas de deviner ce que vous considérez comme égal. L'égalité fait partie de l'interface publique du type, et c'est à vous d'en décrire le sens.
Imaginez une application pédagogique « mini‑task‑tracker » : une liste de tâches, chacune avec un id, un titre et un indicateur « terminé ». Pour l'utilisateur, il est évident que « la tâche n°17 » reste la même tâche même si on la renomme ou qu'on la marque comme terminée. Pour le compilateur, sans operator==, ce sont simplement deux objets distincts en mémoire.
2. operator== comme fonction
La première fois qu'on voit operator==, on a l'impression que le compilateur fait de la magie. En réalité la magie est minimale : c'est une fonction, simplement nommée operator==, afin que vous puissiez écrire a == b au lieu de a.equals(b) ou equals(a, b).
Gardez en tête une idée simple : un opérateur surchargé est votre contrat public. Si vous écrivez une égalité étrange, le programme peut « fonctionner », mais les gens (y compris vous dans une semaine) liront le code et tiqueront. Et en programmation, le fait de tiquer finit souvent en bug.
Commençons par un petit extrait de notre application. Supposons que nous ayons le type Task :
#include <string>
#include <utility>
class Task {
public:
Task(int id, std::string title)
: id_(id), title_(std::move(title)) {}
private:
int id_{};
std::string title_;
};
Pour l'instant on ne peut pas comparer correctement Task a == Task b, car C++ ne sait pas ce que signifie « égal » pour Task. Et c'est souhaitable : sinon il pourrait comparer par adresse mémoire, par octets, par humeur du compilateur ou par phase de la lune (blague… presque).
3. Le contrat d'égalité
L'opération == n'est pas juste « renvoyer true/false ». Une égalité raisonnable possède des propriétés attendues que les gens tiennent pour acquises. Si vous les violez, votre code se comportera comme s'il avait un petit gremlin chaotique dans la tête.
On peut décrire les propriétés ainsi :
| Propriété | Ce que cela signifie | Exemple simple |
|---|---|---|
| Réflexivité | un objet est égal à lui‑même | a == a est toujours true |
| Symétrie | si a == b, alors b == a | égalité « dans les deux sens » |
| Transitivité | si a == b et b == c, alors a == c | l'égalité ne « casse » pas en chaîne |
Il y a aussi une propriété pratique, rarement formulée dans les manuels, mais cruciale pour le code : la remplaçabilité. Si a == b, alors dans la plupart des endroits du programme vous vous attendez à pouvoir substituer a à b sans changer le sens. Pas toujours à 100% mais c'est un bon guide.
Pour notre Task cela donne : si deux tâches sont « égales », on attend qu'il s'agisse de la même tâche du point de vue de l'application. Il faut maintenant décider clairement ce qui rend une tâche « identique ».
4. Identité et égalité par valeur
L'erreur la plus courante chez les débutants est de croire que l'égalité signifie toujours « tous les champs concordent ». Parfois oui, mais souvent non.
Il existe deux interprétations populaires de l'égalité :
Premier sens — identité : c'est le même objet du domaine métier. Par exemple, la tâche avec id=42 est la tâche n°42, même si son titre a changé. Ici l'égalité est généralement définie par un identifiant stable.
Second sens — égalité par valeur : ce sont des « données identiques ». Par exemple, deux points du plan sont égaux si leurs coordonnées x et y sont égales. Là, « tous les champs » ont souvent du sens.
Dans notre mini‑task‑tracker, on privilégie généralement l'identité par id, car une tâche est une entité qui évolue : elle peut être renommée, marquée comme faite, recevoir une autre priorité. Mais l'entité reste la même.
Une bonne analogie (un peu bureaucratique) : le passeport. Vous pouvez changer de coupe de cheveux, porter d'autres lunettes, mais votre numéro de passeport vous identifie toujours comme « la même personne » dans le système. De la même façon, TaskId identifie la tâche.
5. Exemple : Task et comparaison par id
Nous arrivons au code. L'idée est simple : operator== ne doit généralement pas modifier l'objet, donc il est presque toujours const. Et il doit comparer ce que vous avez choisi comme sens de l'égalité.
Implémentons une interface publique minimale pour Task et comparons par id :
#include <string>
#include <utility>
class Task {
public:
Task(int id, std::string title)
: id_(id), title_(std::move(title)) {}
int id() const { return id_; }
const std::string& title() const { return title_; }
bool operator==(const Task& other) const {
return id_ == other.id_;
}
private:
int id_{};
std::string title_;
};
Remarquez les détails importants. Nous prenons other en const Task& pour éviter une copie (et pour pouvoir comparer avec un const Task). La méthode est marquée const parce qu'une comparaison ne devrait pas modifier this. Et nous comparons seulement id_ puisqu'il définit « même tâche ou pas ».
Si vous pensez maintenant : « Mais alors Task{2, "Acheter du pain"} sera égal à Task{2, "Supprimer internet"} — c'est bizarre ! » — vous avez raison. Ce n'est pas un bug de operator==, c'est le signal que vous avez choisi l'égalité par identité et non par tous les champs. Pour les entités, c'est souvent ce qu'il faut.
6. Variantes d'implémentation et conséquences pratiques
Méthode ou fonction libre
Parfois il est plus pratique d'implémenter l'opérateur en tant que fonction libre plutôt que méthode membre. C'est utile quand vous souhaitez comparer votre type à autre chose, ou si vous préférez ne pas donner à l'opérateur un accès direct aux membres private et opter pour une comparaison via l'API publique.
Voici une variante où Task ne contient pas operator== en interne, et la comparaison est définie à l'extérieur via id() :
#include <string>
#include <utility>
class Task {
public:
Task(int id, std::string title)
: id_(id), title_(std::move(title)) {}
int id() const { return id_; }
private:
int id_{};
std::string title_;
};
bool operator==(const Task& a, const Task& b) {
return a.id() == b.id();
}
Cela fonctionne parfaitement. Mais notez que vous définissez toujours un contrat d'égalité, simplement implémenté en dehors de la classe. Cette approche est bonne si vous voulez garder l'API minimale et ajouter des « facilités » depuis l'extérieur, selon des règles claires.
operator!= et point unique de vérité
Quand on définit operator==, on a souvent envie de définir aussi operator!=. Historiquement, en ancien C++, il fallait écrire les deux, sinon a != b ne compilait pas. En C++ moderne (C++20/23), le compilateur peut souvent générer automatiquement != à partir de ==.
Mais même sans approfondir les règles de génération, la pratique est simple : ne dupliquez pas la logique. Deux logiques identiques à deux endroits finissent par diverger quand on modifie l'une et qu'on oublie l'autre.
Si vous devez écrire manuellement !=, faites‑le comme la négation logique de == :
bool operator!=(const Task& a, const Task& b) {
return !(a == b);
}
Simple et sûr : en changeant ==, != change automatiquement.
operator== = default;
Parfois votre type est vraiment un « value type » : le sens de l'égalité est « tous les champs égaux ». Dans ce cas C++ permet de demander au compilateur de générer operator== automatiquement.
Cela ressemble à :
#include <string>
struct Point {
int x{};
int y{};
bool operator==(const Point&) const = default;
};
Le compilateur comparera x avec x et y avec y dans l'ordre de déclaration des champs. C'est pratique, lisible et réduit la quantité de code manuel susceptible d'être corrompu.
Mais règle de bon sens : = default fonctionne bien quand les champs reflètent réellement le sens de l'égalité. Si vous avez des champs « utilitaires » (cache, compteur d'accès, drapeau technique), la comparaison par défaut peut soudainement juger « différents » des objets qui sont en réalité équivalents.
Pour notre Task, un == par défaut sur tous les champs peut être incorrect si nous considérons l'identité par id, et que le titre ou le statut sont des caractéristiques modifiables. Donc = default est un excellent outil, mais pas universel.
Comment operator== influence std::find
Le plus agréable avec un bon operator== est que la moitié du code devient « comme dans les manuels ». Au sens positif. Vous arrêtez d'écrire des boucles manuelles « trouver l'objet avec tel id » et vous pouvez utiliser les algorithmes standard de façon naturelle.
Regardons un exemple avec std::find. Il cherche un élément dans une plage en comparant avec == :
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks = {
Task{1, "Read docs"},
Task{2, "Write code"}
};
auto it = std::find(tasks.begin(), tasks.end(), Task{2, "???"});
std::cout << (it != tasks.end()) << '\n'; // 1
}
Cet exemple est un peu provocateur : on cherche la tâche Task{2, "???"}, mais on trouve Task{2, "Write code"}. Pourquoi ? Parce que nous avons convenu : l'égalité pour Task est basée sur id. Pour std::find cela signifie : « trouvé une tâche avec le même id ».
C'est un effet puissant : une fois le contrat défini, il s'applique partout. Donc définir == « au hasard » est dangereux : vous aurez des surprises non pas à un endroit, mais dans tous les endroits qui utilisent la comparaison.
Pour illustrer, ajoutons une petite fonction style « thin main » : vérifions si une tâche est présente dans la liste.
#include <algorithm>
#include <vector>
bool containsTask(const std::vector<Task>& tasks, const Task& t) {
return std::find(tasks.begin(), tasks.end(), t) != tasks.end();
}
Le code est court et lisible. Mais la lisibilité repose sur le fait que operator== exprime honnêtement ce que signifie « même tâche ».
Égalité stricte et « similarité » : chaînes et double
Dans la pratique on veut comparer non seulement des int, mais aussi des chaînes et des nombres flottants. Et c'est là que operator== peut devenir source de décisions surprenantes si vous essayez de le rendre « trop malin ».
Avec les chaînes, un piège fréquent : l'utilisateur peut considérer que "Ann" et "ann" sont le même nom, alors que std::string les considère différents. Il y a deux solutions raisonnables. La première : normaliser les données (par exemple tout stocker en minuscules) et alors un == standard sera correct. La seconde : ne pas toucher à operator==, mais fournir une fonction séparée « comparer les noms sans tenir compte de la casse », afin de ne pas briser les attentes autour de ==.
Pour double la situation est encore plus sournoise. On a déjà évoqué que les flottants se comparent mieux avec un epsilon, car 0.1 + 0.2 n'est pas toujours exactement 0.3 à cause de la représentation en mémoire. Donc implémenter operator== pour un type qui contient des double et comparer « tel quel » est souvent une mauvaise idée. Mais faire la comparaison avec epsilon directement dans operator== est aussi discutable, car epsilon est une configuration : dans un cas il faut 1e-6, dans un autre 1e-12.
Ainsi, pour la « similarité » il est utile d'avoir une fonction séparée :
#include <cmath>
bool almostEqual(double a, double b) {
const double eps = 1e-9;
return std::abs(a - b) < eps;
}
Idée simple : il vaut mieux garder operator== comme un contrat strict, et implémenter la « similarité » via une action nommée séparément. Alors le code est clair : if (almostEqual(a, b)), au lieu d'un mystérieux if (a == b) contenant de la « magie ».
7. Erreurs typiques lors de la conception et de l'utilisation de operator==
Erreur n°1 : l'égalité dépend de détails « utilitaires » modifiables.
Parfois on inclut dans operator== des champs qui ne devraient pas influencer le sens d'« égal » : caches, compteurs, flags de debug. Résultat : l'objet « cesse d'être égal à lui‑même » après quelques opérations, ou deux objets logiquement identiques sont « différents ». On corrige cela en formulant à l'avance le contrat : « qu'est‑ce qui fait que l'objet est le même ? », et en ne comparant que cela.
Erreur n°2 : confondre identité et correspondance des données.
Pour les entités (tâche, utilisateur, commande) l'égalité est souvent par identifiant. Pour les value types (point, taille, intervalle) c'est par champs. Si on inverse, on obtient des incohérences : par exemple, une recherche via std::find commencera à trouver « le mauvais objet » ou à ne pas trouver « le bon », car on compare ce qui n'a pas d'importance pour le programme.
Erreur n°3 : oublier le const sur operator==.
La comparaison ne devrait presque jamais modifier l'objet. Si vous oubliez le const, vous ne pourrez pas comparer des const Task ou des éléments de conteneur dans certains scénarios, et vous aurez des erreurs de compilation étranges. La réparation est en général simple : rendre la méthode const et accepter l'argument comme const T&.
Erreur n°4 : écrire == et != à la main et les désynchroniser.
Aujourd'hui a == b peut signifier « mêmes id », et vous pouvez par inadvertance laisser a != b défini comme « titres différents ». Le compilateur n'est pas obligé de vous sauver. Il est plus sûr de garder un point de vérité : définir == et implémenter != via !(a == b) (ou de laisser le compilateur le déduire si approprié).
Erreur n°5 : rendre operator== « trop malin ».
La tentation est grande : comparer des chaînes sans casse, ignorer les espaces autour, considérer des double égaux selon un epsilon, « ne pas tenir compte » de certains champs. Parfois nécessaire, mais le plus souvent cela transforme == en générateur de surprises. Il est plus pratique de garder == strict et prévisible, et d'implémenter la « similarité » via une fonction séparée au nom explicite.
GO TO FULL VERSION