Operator Overloading
Reviewed & published by Brayan K
By the end of this lesson you'll be able to make your own classes behave like built-in types — adding them with +, comparing them with == and <, indexing them with [], and printing them with cout << obj — while getting return types and const-correctness right.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Overload arithmetic operators (+, -, *) on your own class
- Overload comparison operators (== and <) const-correctly
- Write compound assignment (+=) that returns *this for chaining
- Give your class subscript access with operator[]
- Print objects with a friend operator<< stream insertion
- Choose member vs non-member, and know the rule of three/five
💡 Real-World Analogy
Think of an operator like + as a universal plug socket. The built-in types (int, double) already fit it. Operator overloading is wiring an adapter so your own type fits the same socket — once Money + Money is wired up, the rest of the language (printing, sorting, totalling) "just works" with your type the way it does with numbers. You're not inventing new syntax; you're teaching a familiar symbol what it means for your class. The skill is wiring the adapter correctly: returning the right thing, and not changing what shouldn't change.
📊 Operators You'll Overload
| Operator | Does | Returns | Member? |
|---|---|---|---|
| + - * | Make a new value | by value (new object) | prefer non-member |
| == < | Compare two values | bool | prefer non-member |
| += | Change this object | *this by reference | member |
| [ ] | Index into the object | element by reference | must be member |
| << | Print to a stream | the stream by reference | must be non-member |
The hardest part isn't the syntax — it's the return type. Operators that build a new value return by value; operators that change the object return *this by reference. Keep that distinction and most bugs disappear.
1. A Fully-Wired Class: Money
Here's a complete, correct example that overloads every operator this lesson covers on a Money type (stored as whole pence so there's no rounding drift). Read every comment, run it, and check the output. Notice the four shapes you'll reuse forever: arithmetic returns a new object by value, compound assignment returns *this by reference, comparison returns bool and is const, and operator<< is a non-member friend that returns the stream.
#include <iostream>
using namespace std;
// A Money type that stores a whole number of pence (no rounding drift).
class Money {
public:
long pence; // 1234 means £12.34
Money(long p = 0) : pence(p) {}
// (1) ARITHMETIC as a MEMBER. 'const' = "I don't change this object".
// We take the right-hand side by const reference (no copy).
// We RETURN BY VALUE because the sum is a brand-new Money.
Money operator+(const Money& rhs) const {
return Money(pence + rhs.pence); // a fresh object
}
Money operator-(const Money& rhs) const {
return Money(pence - rhs.pence);
}
Money operator*(int qty) const { // price * quantity
return Money(pence * qty);
}
// (2) COMPOUND assignment changes *this, so it returns *this BY REFERENCE
// (so you can chain: a += b += c). Note: NOT const here.
Money& operator+=(const Money& rhs) {
pence += rhs.pence;
return *this; // return the SAME object
}
// (3) COMPARISON returns bool. Keep == and < const-correct.
bool operator==(const Money& rhs) const { return pence == rhs.pence; }
bool operator<(const Money& rhs) const { return pence < rhs.pence; }
};
// (4) STREAM INSERTION must be a NON-MEMBER (the left operand is 'cout',
// not your class). We make it a 'friend' so it can read private data,
// and we return the stream BY REFERENCE so '<<' can chain.
ostream& operator<<(ostream& os, const Money& m) {
long pounds = m.pence / 100;
long pennies = m.pence % 100;
os << "£" << pounds << "." << (pennies < 10 ? "0" : "") << pennies;
return os; // chaining works because we return os
}
int main() {
Money coffee(350); // £3.50
Money cake(425); // £4.25
Money total = coffee + cake; // operator+ -> £7.75
cout << "Total: " << total << endl; // operator<< -> Total: £7.75
Money two = coffee * 2; // operator* -> £7.00
cout << "Two cof: " << two << endl;
total += Money(50); // operator+= -> £8.25
cout << "After +50p: " << total << endl;
cout << "coffee < cake? " << (coffee < cake) << endl; // operator< -> 1 (true)
cout << "coffee == coffee? " << (coffee == coffee) << endl; // operator== -> 1
return 0;
}
// ✅ Expected output:
// Total: £7.75
// Two cof: £7.00
// After +50p: £8.25
// coffee < cake? 1
// coffee == coffee? 12. Member vs Non-Member — How to Choose
When you write an operator as a member, the left operand is always the object itself (*this), so it takes one fewer parameter. That's perfect for operators that belong to the object: =, [], (), ->, and the compound assignments like +=. A non-member function is symmetric: both operands are ordinary parameters, so it works even when the left side isn't your class. That's why operator<< must be a non-member — its left operand is cout, not your type — and why + and == are usually non-members too, so 2.0 * vec works as well as vec * 2.0.
🔎 Deep Dive: why friend for operator<<
A member operator's left operand is fixed as your object. But cout << m has cout on the left, so operator<< can't be a member — it's a free function taking (ostream&, const Money&). You mark it friend inside the class only so it can read private fields; it returns ostream& so chained << works.
// inside the class, granting access to privates:
friend std::ostream& operator<<(std::ostream& os, const Money& m);
// defined outside as a normal non-member:
std::ostream& operator<<(std::ostream& os, const Money& m) {
return os << "£" << (m.pence / 100.0); // returns the stream -> chains
}3. Your Turn: Arithmetic & <<
Time to wire up your own type. The Vector2 below is almost finished — fill in the blanks marked ___ using the hints, then run it. Remember: operator+ and operator* build a new vector, so they return one by value.
#include <iostream>
using namespace std;
class Vector2 {
public:
double x, y;
Vector2(double x = 0, double y = 0) : x(x), y(y) {}
// 🎯 YOUR TURN — fill each ___ then press "Try it Yourself".
// 1) operator+ adds two vectors and returns a NEW Vector2 (by value).
Vector2 operator+(const Vector2& o) const {
return Vector2(___, ___); // 👉 x + o.x , y + o.y
}
// 2) operator* scales the vector by a number (member: vec * 3).
Vector2 operator*(double s) const {
return Vector2(___, ___); // 👉 x * s , y * s
}
};
// 3) operator<< prints it. Return the stream so '<<' can chain.
ostream& operator<<(ostream& os, const Vector2& v) {
return os << "(" << v.x << ", " << v.y << ")";
}
int main() {
Vector2 a(1, 2), b(3, 4);
cout << "a + b = " << (a + b) << endl;
cout << "a * 3 = " << (a * 3) << endl;
// ✅ Expected output:
// a + b = (4, 6)
// a * 3 = (3, 6)
return 0;
}4. Subscript [], Compound +=, and ==
Three more shapes to lock in. operator[] returns the element by reference so callers can assign into it (s[2] = 30). operator+= changes the object, so it returns *this by reference — that's what lets you chain (s += 10) += 20. And operator== compares field-by-field and returns bool. Fill in the blanks below.
#include <iostream>
using namespace std;
class Stack3 {
public:
int items[3] = {0, 0, 0};
int count = 0;
void push(int v) { if (count < 3) items[count++] = v; }
// 🎯 YOUR TURN — fill each ___.
// 1) operator== is true when both have the SAME count and same items.
bool operator==(const Stack3& o) const {
if (count != o.count) return false;
for (int i = 0; i < count; i++)
if (items[i] != o.items[i]) return ___; // 👉 false
return ___; // 👉 true
}
// 2) operator+= pushes one value, then returns *this so it can chain.
Stack3& operator+=(int v) {
push(v);
return ___; // 👉 *this
}
// 3) operator[] reads/writes slot i. Return BY REFERENCE so x[0] = 9 works.
int& operator[](int i) {
return ___; // 👉 items[i]
}
};
int main() {
Stack3 s;
(s += 10) += 20; // chaining works because += returns *this
s[2] = 30; // operator[] returns a reference, so we can assign
for (int i = 0; i < 3; i++) cout << s[i] << " ";
cout << endl;
Stack3 t;
t += 10; t += 20; t[2] = 30;
cout << "s == t? " << (s == t) << endl;
// ✅ Expected output:
// 10 20 30
// s == t? 1
return 0;
}🔎 Deep Dive: operator= and the Rule of Three / Five
operator= (copy assignment) is the one operator with serious hidden danger. If your class manages a raw resource — heap memory, a file handle — and you write a custom destructor, you almost certainly also need a custom copy constructor and copy assignment operator. That's the Rule of Three: those three go together. The compiler's default versions copy pointers shallowly, so two objects end up owning the same memory and both try to free it — a double-free crash.
C++11 adds the move constructor and move assignment, extending it to the Rule of Five. The best advice for beginners is the Rule of Zero: store your data in types that already manage themselves — std::string, std::vector, std::unique_ptr — and the compiler-generated operator= is automatically correct, so you write none of the five.
// Rule of Five signatures (only if you manage a raw resource):
class Buffer {
~Buffer(); // destructor
Buffer(const Buffer&); // copy constructor
Buffer& operator=(const Buffer&); // copy assignment
Buffer(Buffer&&) noexcept; // move constructor
Buffer& operator=(Buffer&&) noexcept; // move assignment
};
// Rule of Zero: use std::vector/std::string instead -> write none of these.Pro Tips
- 💡 Implement + in terms of +=: write operator+= first, then a + b as { T r = a; r += b; return r; }. Less code, no drift.
- 💡 Define != as !(a == b) and keep < consistent with == — never write them independently.
- 💡 Mark read-only operators const and take big parameters by const&. It enables use on const objects and avoids copies.
- 💡 C++20 spaceship: auto operator<=>(const T&) const = default; generates all six comparisons from one line.
Common Errors (and the fix)
- Returning by reference instead of value (or vice-versa): operator+ builds a new object, so return it by value — returning a reference to a local is a dangling reference and undefined behaviour. operator+= changes the existing object, so return *this by reference.
- Forgetting const-correctness: if operator+ or operator== isn't marked const, you can't use it on a const object or a const& parameter — "passing 'const T' as 'this' argument discards qualifiers".
- Asymmetric ==: defining == but not keeping != consistent (or comparing only some fields) breaks std::find, std::set, and sorting. Define != as !(a == b).
- Making operator<< a member: "operator<< must take exactly two arguments" — its left operand is the stream, so it has to be a non-member (a friend if it reads privates).
- Forgetting to return the stream from <<: if operator<< returns void, then cout << a << b won't compile — return ostream& so it can chain.
📋 Quick Reference
| Operator | Typical signature | Returns |
|---|---|---|
| + | T operator+(const T&) const | new T by value |
| += | T& operator+=(const T&) | *this by reference |
| == | bool operator==(const T&) const | bool |
| < | bool operator<(const T&) const | bool |
| [ ] | E& operator[](int) | element by reference |
| << | ostream& operator<<(ostream&, const T&) | the stream (non-member) |
Mini-Challenge: a Temperature class
No blanks this time — just a brief and an outline. Build a Temp class that supports +, <, and printing with <<. Run it and check your output against the example in the comments. This is exactly the shape of a real value type.
#include <iostream>
using namespace std;
// 🎯 MINI-CHALLENGE: a Temperature class (stored in Celsius)
// 1. A class 'Temp' with a double 'celsius' and a constructor.
// 2. operator+ : add two Temps, return a NEW Temp (by value, const).
// 3. operator< : true if this is colder than the other (const).
// 4. operator<< : a NON-MEMBER (friend if it needs privates) that
// prints like "21.5°C" and RETURNS the stream.
// 5. In main: make Temp(18) and Temp(4), add them, and compare them.
//
// ✅ Expected output (example):
// 18°C + 4°C = 22°C
// 4°C < 18°C? 1
class Temp {
// your code here
};
int main() {
// your code here
return 0;
}🎉 Lesson Complete
- ✅ Arithmetic (+ - *) returns a new object by value
- ✅ Compound assignment (+=) returns *this by reference so it chains
- ✅ Comparison (==, <) returns bool and is const; keep != consistent with ==
- ✅ operator[] returns the element by reference so you can assign into it
- ✅ operator<< is a non-member friend that returns the stream
- ✅ Member for =/[]/()/+=; non-member for symmetric +/==/<<
- ✅ Mind the Rule of Three/Five for operator= — or follow the Rule of Zero
Practice quiz
How should operator+ return its result?
- By reference to *this
- By value — it builds a brand-new object
- By pointer
- By const reference to a local
Answer: By value — it builds a brand-new object. Arithmetic operators like + create a new value (a fresh object), so they return by value.
What should operator+= return, and how?
- A new object by value
- void
- *this by reference, so calls can chain
- A copy of the right-hand side
Answer: *this by reference, so calls can chain. Compound assignment modifies the existing object and returns *this by reference, enabling chains like (a += b) += c.
Why must operator<< be a non-member function?
- Members can't return references
- Its left operand is the stream (cout), not your class
- It is too large for a class
- Streams forbid member operators
Answer: Its left operand is the stream (cout), not your class. cout << m has cout on the left, so operator<< can't be a member (a member's left operand is always *this); it's a free function.
Why is operator<< often marked friend inside the class?
- To make it run faster
- So it can read the class's private members
- Because friends are required for all operators
- To allow it to modify the stream
Answer: So it can read the class's private members. friend grants the free operator<< access to private fields; if it only prints public data it doesn't even need to be a friend.
What must operator<< return so that cout << a << b chains?
- void
- bool
- ostream& (the stream by reference)
- a copy of the object
Answer: ostream& (the stream by reference). Returning ostream& lets the next << operate on the same stream, so chaining works.
Why does operator[] return the element by reference (E&)?
- To avoid copying the whole container
- So callers can assign into the slot, e.g. s[2] = 30
- Because [] must return void
- To make the index bounds-checked
Answer: So callers can assign into the slot, e.g. s[2] = 30. Returning a reference lets s[2] = 30 write into the element; returning by value would only give a temporary copy.
For a read-only comparison like operator==, what qualifier should it carry?
- static
- const
- virtual
- friend
Answer: const. Comparison operators don't modify the object, so mark them const so they work on const objects and const& parameters.
Given a Money type storing whole pence, what does Money(350) + Money(425) represent?
- £3.50
- £4.25
- £7.75
- £8.25
Answer: £7.75. 350p + 425p = 775p = £7.75; operator+ adds the pence and returns a new Money.
What is the Rule of Zero?
- Never write any operators
- Store data in self-managing types (string, vector, smart pointers) so the compiler-generated special members are correct
- Always write all five special members by hand
- Use only static member functions
Answer: Store data in self-managing types (string, vector, smart pointers) so the compiler-generated special members are correct. The Rule of Zero: rely on members that manage themselves so you write none of the five special members and operator= is automatically correct.
In C++20, what single declaration can generate the comparison operators automatically?
- auto operator<=>(const T&) const = default;
- bool operator==(const T&) = 0;
- operator compare() default;
- using std::compare;
Answer: auto operator<=>(const T&) const = default;. The C++20 spaceship operator <=> defaulted generates the comparisons consistently from one line.
Continue this course
- Previous: C++17 & C++20 Modern Features (Structured Bindings, Concepts, Ranges)
- Next: Concurrency in C++: Threads, Futures, Promises, async() — Write concurrent C++ with std::thread, std::async, and std::future
- Quick reference: C++ cheat sheet
Frequently asked questions
Should an operator be a member function or a non-member?
Rule of thumb: =, [], (), and -> MUST be members. Compound assignment (+=, -=) should be members because they change the left operand. Symmetric binary operators like +, -, *, and == are best as non-members (often using a friend) so that mixed types work on either side, e.g. 2.0 * vec as well as vec * 2.0. The stream operators << and >> MUST be non-members, because their left operand is the stream, not your class.
Why is operator<< written as a friend?
operator<< takes the output stream (ostream&) as its LEFT operand, so it cannot be a member of your class — a member's left operand is always the object itself. It is written as a free function. You mark it 'friend' inside the class only so it can read the class's private members; if everything it prints is public, it doesn't even need to be a friend.
When do I return by value, by reference, or a const reference?
Return BY VALUE when you create a brand-new object: operator+ and operator* produce a new sum/product. Return *this BY REFERENCE from operators that modify the object (operator+=, operator=) so calls can chain. Return a reference from operator[] so callers can assign into the slot (arr[0] = 9). Take parameters by const reference to avoid copies, and mark read-only operators const.
What is the rule of three / five and what does it have to do with operator=?
If your class manages a resource (raw memory, a file handle) and you need a custom destructor, copy constructor, OR copy assignment operator (operator=), you almost certainly need all three — that's the Rule of Three. C++11 adds the move constructor and move assignment, making it the Rule of Five. The safest path is the Rule of Zero: store members in types that already manage themselves (std::string, std::vector, smart pointers) so the compiler-generated operator= is correct and you write none of them.
Why must operator== be symmetric and consistent?
If a == b is true, then b == a must also be true, and == should agree with !=. Asymmetric or inconsistent comparisons break sorting, std::set/std::map, and std::find in ways that are very hard to debug. Implement == once, define != as !(a == b), and (pre-C++20) keep < consistent with them. In C++20 you can let the compiler do this with operator<=> (the 'spaceship' operator).