Master Operator Overloading to Build Expressive C++ Types
Learn to overload operators so your custom types support +, ==, <<, and more — and understand when overloading helps versus hurts readability.
By the end of this page, you will be able to overload arithmetic, comparison, and stream operators for your own types; choose between member and non-member implementations; avoid the classic pitfalls that produce surprising behavior; and use C++20's spaceship operator to generate a full family of comparisons from a single declaration.
What and Why
When you write 3 + 4, the + sign is just syntax sugar for a built-in operation the compiler already knows about. Your own Vec2 or Fraction type doesn't get that for free — but C++ lets you teach the compiler what +, ==, <<, and most other operators mean for your type.
That's operator overloading: defining a function whose name starts with operator followed by the symbol you want to give meaning to.
The payoff is expressive code:
// Without overloading
Vec2 result = add(scale(a, 2.0f), b);
// With overloading
Vec2 result = a * 2.0f + b;Both do the same work, but the second reads like the math it represents. The rule to keep in mind: overload an operator only when the meaning is obvious and unsurprising to any reader. Matrix * Matrix is fine. Thread * Thread is not.
Step by Step
A minimal value type
Start with a simple two-dimensional vector. It holds x and y and needs to support addition.
// Requires C++11
#include <iostream>
struct Vec2 {
float x, y;
};
int main() {
Vec2 a{1.0f, 2.0f};
Vec2 b{3.0f, 4.0f};
// Vec2 c = a + b; // won't compile yet
}Adding operator+ as a member function
The simplest form: define the operator inside the class. The left-hand operand is *this; the right-hand operand arrives as a parameter.
#include <iostream>
struct Vec2 {
float x, y;
Vec2 operator+(const Vec2& rhs) const {
return Vec2{x + rhs.x, y + rhs.y};
}
};
int main() {
Vec2 a{1.0f, 2.0f};
Vec2 b{3.0f, 4.0f};
Vec2 c = a + b;
std::cout << c.x << ' ' << c.y << '\n'; // 4 6
}The const at the end of the member signature is important: it promises that operator+ won't modify the left-hand operand — which is correct, since a + b should never change a.
Adding operator+= first, then deriving operator+
A common and clean pattern: implement the compound assignment form first (it modifies *this), then build the binary form on top of it.
#include <iostream>
struct Vec2 {
float x, y;
Vec2& operator+=(const Vec2& rhs) {
x += rhs.x;
y += rhs.y;
return *this;
}
};
// Free function: lives outside the class
inline Vec2 operator+(Vec2 lhs, const Vec2& rhs) {
lhs += rhs; // reuse the member
return lhs; // lhs is a copy, so this is safe
}
int main() {
Vec2 a{1.0f, 2.0f};
Vec2 b{3.0f, 4.0f};
a += b;
std::cout << a.x << ' ' << a.y << '\n'; // 4 6
}Notice lhs is taken by value (a copy), so mutating it inside the free function doesn't touch the caller's a.
Comparison with == and the C++20 spaceship operator
Before C++20, you had to write every comparison operator by hand. With C++20 you declare one three-way comparison and the compiler generates ==, !=, <, <=, >, and >= for you.
// Requires C++20
#include <compare>
#include <iostream>
struct Vec2 {
float x, y;
auto operator<=>(const Vec2&) const = default;
bool operator==(const Vec2&) const = default;
};
int main() {
Vec2 a{1.0f, 2.0f};
Vec2 b{1.0f, 2.0f};
Vec2 c{3.0f, 4.0f};
std::cout << (a == b) << '\n'; // 1
std::cout << (a == c) << '\n'; // 0
}= default tells the compiler to perform a member-by-member comparison in declaration order. For types where that is the right semantics, this is the preferred approach in C++20.
Stream output with operator<<
std::cout << myVec requires operator<< to be a free function (or a friend), because the left-hand side is std::ostream, which you don't own.
#include <iostream>
struct Vec2 {
float x, y;
};
std::ostream& operator<<(std::ostream& os, const Vec2& v) {
os << '(' << v.x << ", " << v.y << ')';
return os; // must return the stream for chaining
}
int main() {
Vec2 a{1.0f, 2.0f};
std::cout << a << '\n'; // (1, 2)
}The return type std::ostream& is what makes std::cout << a << '\n' work — without it, the second << would have nothing to chain to.
Common Patterns
Symmetric arithmetic via non-member functions
When an operator is commutative (order doesn't matter), a free function makes both orderings work equally:
#include <iostream>
struct Vec2 {
float x, y;
Vec2& operator*=(float s) { x *= s; y *= s; return *this; }
};
inline Vec2 operator*(Vec2 v, float s) { v *= s; return v; }
inline Vec2 operator*(float s, Vec2 v) { return v * s; } // reuse above
int main() {
Vec2 a{1.0f, 2.0f};
Vec2 b = a * 3.0f; // works
Vec2 c = 3.0f * a; // also works
std::cout << b.x << ' ' << c.x << '\n'; // 3 3
}A member operator* can only handle vec * scalar. The extra free function flips the arguments.
Index operator for container-like types
operator[] gives your type array-style access. Return a reference so callers can both read and write:
#include <array>
#include <stdexcept>
struct Vec4 {
std::array<float, 4> data{};
float& operator[](std::size_t i) {
if (i >= 4) throw std::out_of_range("Vec4 index");
return data[i];
}
float operator[](std::size_t i) const {
if (i >= 4) throw std::out_of_range("Vec4 index");
return data[i];
}
};
int main() {
Vec4 v;
v[0] = 1.0f;
v[1] = 2.0f;
}Two overloads: one for mutable access, one for const objects (returns by value so nobody can modify a const Vec4 through an index).
Unary negation
#include <iostream>
struct Vec2 {
float x, y;
Vec2 operator-() const { return Vec2{-x, -y}; }
};
int main() {
Vec2 a{1.0f, -2.0f};
Vec2 b = -a;
std::cout << b.x << ' ' << b.y << '\n'; // -1 2
}What Can Go Wrong
Returning *this from binary operators.
a + b should produce a new value, not modify a. Returning *this by reference is correct for +=, -=, etc., but wrong for +, -.
// WRONG
Vec2& operator+(const Vec2& rhs) { x += rhs.x; y += rhs.y; return *this; }
// CORRECT
Vec2 operator+(const Vec2& rhs) const { return Vec2{x + rhs.x, y + rhs.y}; }Forgetting const correctness.
If operator+ is not const, you can't add two const Vec2 values. Mark read-only operators const.
Overloading && or || and breaking short-circuit evaluation.
The built-in && and || don't evaluate the right operand if the left already determines the result. An overloaded version is a normal function call — both sides always evaluate. Avoid overloading these unless you have a very specific reason.
Missing the return from operator<<.
Forgetting return os; makes chaining (cout << a << b) fail to compile. Always return the stream reference.
Quick Reference
| Operator category | Typical form | Notes |
|---|---|---|
Arithmetic (+, -, *, /) | Free function, takes LHS by value | Implement += first, derive + from it |
Compound assignment (+=, -=) | Member, returns *this by ref | Modifies *this in place |
| Comparison (C++11) | Free function or member | Must write each one; keep them consistent |
| Three-way comparison (C++20) | Member <=>, = default | Generates all six comparisons automatically |
Stream output (<<) | Free function or friend, returns ostream& | Must return stream for chaining |
Index ([]) | Member (two overloads: mutable + const) | Return reference for mutable, value for const |
Unary (-, !, ~) | Member, returns by value | Does not modify *this |
Conversion (operator T()) | Member, usually explicit | Mark explicit to prevent surprise conversions |
Member vs. free function rule of thumb:
- Use a member when the left-hand operand must be your type (
[],(),->,=, compound assignments). - Use a free function when you want symmetry or the left-hand operand could be a different type (
<<,>>, commutative arithmetic).
What's Next
- Operator Precedence — your overloaded operators inherit built-in precedence rules; knowing them prevents subtle bugs.
- The Spaceship Operator — deep dive into
operator<=>, comparison categories, and how the compiler synthesises the six comparisons. - Function Overloading — operator overloading is a special case of function overloading; understanding the general rules helps resolve ambiguities.
- Operator Overloading Reference — exhaustive list of every overloadable operator, its signature requirements, and which can be defaulted.