1. Why define operator== for your type
If you currently think that operator== is just “cosmetic”, you are not alone: almost everyone thinks that until they start writing slightly more realistic code. But as soon as you have a std::vector of objects, you suddenly want to do “regular” operations: find an element, check uniqueness, remove a specific entity, compare two results.
Built-in types (int, std::string) can be compared “out of the box”, but your class Task does not (by default). And C++ is honest here: it will not guess what you consider equal. Equality is part of a type's public interface, and you must describe its meaning.
Imagine we have a small educational application — a mini-task tracker: a list of tasks, each with an id, a title and a “done” flag. For the user it is obvious that “task №17” remains the same task even if we rename it or mark it done. But to the compiler without operator== this is just two different objects in memory.
2. operator== as a function
When you first see operator==, it may seem like the compiler performs magic. In fact, the magic is minimal: it's just a function with a special name operator==, so you can write a == b instead of a.equals(b) or equals(a, b).
Keep in mind a simple thought: an overloaded operator is your public contract. If you write a strange equality, the program may “work”, but people (including you in a week) will read the code and blink nervously. In programming, a nervous blink usually ends in a bug.
Let's start with a small fragment of our application. Suppose we have a 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_;
};
At the moment such a type cannot be compared properly as Task a == Task b, because C++ does not know what “equal” means for Task. And that's good: otherwise it might compare by address in memory, by bytes, by the compiler's mood or by the phase of the moon (joke… almost).
3. The equality contract
Comparison with == is not just “returns true/false”. A proper equality has expected properties that people take for granted. If you violate them, your code will start behaving as if a little chaos gremlin lives in its head.
The properties can be described like this:
| Property | What it means in simple terms | An easy example |
|---|---|---|
| Reflexivity | an object is equal to itself | a == a is always true |
| Symmetry | if a == b, then b == a | equality “works both ways” |
| Transitivity | if a == b and b == c, then a == c | equality does not “break the chain” |
There is another practical “property” that is rarely spelled out in textbooks but is extremely important for code: substitutability. If a == b, then in most places in the program you expect to be able to substitute a for b without changing the meaning. Not always 100%, but as a guideline this works very well.
For our Task this sounds like: if two tasks are “equal”, we expect that they are the same task in the sense of our application. Now we must decide clearly — what exactly makes a task “the same”.
4. Identity vs. value equality
A common beginner mistake is to assume equality always means “all fields matched”. Sometimes yes, but often no.
Objects have two popular meanings of equality:
First meaning — identity: it's the same domain entity. For example, a task with id=42 is task №42, even if the title changed. Here equality is usually defined by a stable identifier.
Second meaning — value equality: it's “identical data”. For example, two points on a plane are equal if their x and y coordinates are equal. Here “all fields” often makes sense.
In our mini-task tracker identity by id usually wins, because a task is an entity that evolves over time: it can be renamed, marked as done, or get a different priority. But the entity remains the same.
A good analogy (and yes, it's a bit bureaucratic): a passport. You can change your haircut, your glasses and even stop liking the Times New Roman font, but your passport number still identifies you as “the same person” within the system. Similarly TaskId identifies the task.
5. Example: Task and comparing by id
Now we get to the code. The idea is simple: operator== should not modify the object, so it is almost always const. And it should compare what you chose as the meaning of equality.
Let's add a minimal public interface to Task and implement comparison by 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_;
};
Note the important details. We take other as a const Task& to avoid copying (and to allow comparing with a const Task). The method is marked const because comparison should not modify this. And we compare only id_ because it defines “the same task or not”.
If you are thinking now: “But then Task{2, "Buy bread"} will be equal to Task{2, "Delete internet"} — that's weird!” — you're right. This is not a bug of operator==; it's a signal: you chose identity equality, not full-data equality. For entities this is often exactly what you need.
6. Implementation options and practical consequences
Member method or free function
Sometimes it's more convenient to make the operator a free function rather than a member. This is primarily useful when you want to compare your type with something else, or when you don't want the operator to access private members and prefer to compare via public methods.
Here's a variant where Task does not contain operator== inside, and comparison is defined outside using 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();
}
This works perfectly fine. But understand: you still define the equality contract, it's just implemented outside the class. This approach is good when you want the type to have a minimal built-in API and add conveniences from the outside under clear rules.
operator!= and a single source of truth
When you define operator==, you almost immediately want to define operator!= as well. Historically, in old C++ you had to write both, otherwise a != b would not compile. In modern C++ (C++20/23) the compiler can often generate != automatically from ==.
Even without diving into generation rules, the practical approach is simple: do not duplicate logic. Two identical logics in two places will eventually diverge, because you change one and forget the other.
If you need to write != by hand, make it the logical negation of ==:
bool operator!=(const Task& a, const Task& b) {
return !(a == b);
}
This is concise and reliable: change the meaning of == and != automatically follows.
operator== = default;
Sometimes your type is truly a value type: equality means “all fields equal”. In such cases C++ allows you to ask the compiler to generate operator== automatically.
This looks like:
#include <string>
struct Point {
int x{};
int y{};
bool operator==(const Point&) const = default;
};
Here the compiler will compare x with x and y with y in declaration order. This is convenient, readable and reduces the amount of manual code you might mess up.
But an important rule of common sense: = default works well when fields truly reflect the meaning of equality. If you have “auxiliary” fields (for example, a cache, a usage counter, a technical flag), default comparison may suddenly deem objects “different” that are logically the same.
For our Task default field-wise == may be wrong if we consider identity by id and title and status are mutable characteristics. So = default is a great tool but not universal.
How operator== affects std::find
The nicest thing about a good operator== is that half your code starts to look “by the book”. In a good way. You stop writing manual loops like “find object with a certain id” and can use standard algorithms more naturally.
Take an example with std::find. It searches a range by comparing with ==:
#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
}
This example is slightly provocative: we search for Task{2, "???"}, but we find Task{2, "Write code"}. Why? Because we agreed that equality for Task is equality by id. For std::find this means: “found a task with the same id”.
This is a very powerful effect: define the contract once — and it works everywhere. That's why defining == “at random” is dangerous: you'll get surprises not in one place, but in every place where comparison is used.
To reinforce this, let's add a small helper in a “thin main” style: check if a task is in the list.
#include <algorithm>
#include <vector>
bool containsTask(const std::vector<Task>& tasks, const Task& t) {
return std::find(tasks.begin(), tasks.end(), t) != tasks.end();
}
The code is short and readable. But readability relies on operator== honestly expressing the meaning of “the same task”.
Strict equality and “similarity”: strings and double
In real problems you want to compare not only simple int, but strings and floating point numbers. Here operator== can become a source of surprising behavior if you try to make it “too smart”.
With strings a common trap is this: a user thinks "Ann" and "ann" are the same name, while std::string considers them different. There are two reasonable approaches. First — store data in a normalized form (for example, always lowercase) and then ordinary == will be correct. Second — don't touch operator==, and provide a separate function “compare names case-insensitively” so you don't break expectations for ==.
With double the situation is even trickier. We already discussed that floating point numbers are better compared with an epsilon, because 0.1 + 0.2 is not always exactly 0.3 due to representation. So making operator== for a type that holds double and comparing “as is” is often a bad idea. But comparing with an epsilon inside operator== is also debatable, because epsilon is a setting: in one task you may need 1e-6, in another 1e-12.
Therefore for “similarity” it's useful to have a separate function:
#include <cmath>
bool almostEqual(double a, double b) {
const double eps = 1e-9;
return std::abs(a - b) < eps;
}
The idea is simple: keep operator== as a strict contract, and express “similar” as a separately named operation. Then the code reads honestly: if (almostEqual(a, b)), not the mysterious if (a == b) with hidden “magic”.
7. Common mistakes when designing and using operator==
Mistake #1: equality depends on mutable “auxiliary” details.
Sometimes operator== includes fields that should not affect the meaning of “equal”: cached values, counters, debug flags. As a result an object “stops being equal to itself” after a couple of operations, or two logically identical objects become “different”. Fix this by formulating the contract in words beforehand: “what exactly makes the object the same?”, and compare only that.
Mistake #2: confusing identity and data match.
For entities (task, user, order) equality is often by identifier. For value types (point, size, range) it's by fields. If you mix them up you get oddities: for example, searching with std::find may find “the wrong object” or not find “the very one” because you're comparing what doesn't actually matter to the program.
Mistake #3: forgetting const on operator==.
Comparison almost never should modify the object. If you forget const, you suddenly won't be able to compare const Task or container elements in some scenarios, and strange compile errors will appear. The fix is usually simple: make the method const and accept the argument as a const T&.
Mistake #4: implement == and != manually and let them diverge.
Today a == b may mean “same id”, while a != b you accidentally left as “different titles”. The compiler is not obliged to save you. It's safer to keep a single source of truth: define == and implement != as !(a == b) (or let the compiler derive it automatically where appropriate).
Mistake #5: making operator== “too smart”.
The temptation is strong: compare strings case-insensitively, ignore surrounding whitespace, consider double equal by epsilon, “ignore” some fields. Sometimes this is necessary, but more often it turns == into a surprise generator. More practical: keep == strict and predictable, and model “similarity” as a separate function with a descriptive name.
GO TO FULL VERSION