Operator Overloading
Canonical forms, spaceship operator ordering categories, member vs free functions, conversion operators, and pitfalls that trip up experienced engineers.
Operator Overloadingsince C++98Operator overloading lets user-defined types define the semantics of built-in operators via ordinary function calls, preserving operator precedence, associativity, and arity unchanged.
Overview
An overloaded operator is a function named operatorX. The call a + b is exactly operator+(a, b) (free function form) or a.operator+(b) (member form). At least one operand must be a user-defined type; you cannot overload operators for built-in types alone, invent new operators, or alter precedence.
Member vs free function:
| Operator | Form required |
|---|---|
= () [] -> ->* | Must be member |
+= -= *= /= etc., ++ -- | Member (mutates lhs) |
+ - * / (binary) | Free (symmetric — both sides can convert) |
<< >> (stream) | Free (lhs is ostream/istream) |
== != < > <= >= <=> | Free preferred (symmetric) |
| Type conversion | Member |
Non-overloadable: :: . .* ?: sizeof typeid alignof and all cast keywords.
The free-function preference for symmetric binary operators matters: a free operator+(Fraction, int) and operator+(int, Fraction) can both exist; a member version only handles Fraction + int.
Arithmetic — Canonical Pattern
Define compound assignment (+=) as a member, then derive the binary operator as a free function receiving the left operand by value. This is the idiomatic form from C++11 onward and enables move semantics.
class Fraction {
int num_, den_;
void reduce() noexcept; // divide by GCD, ensure den_ > 0
public:
explicit Fraction(int n, int d = 1) noexcept : num_{n}, den_{d} { reduce(); }
Fraction& operator+=(const Fraction& rhs) noexcept {
num_ = num_ * rhs.den_ + rhs.num_ * den_;
den_ *= rhs.den_;
reduce();
return *this; // always return *this by reference
}
Fraction& operator-=(const Fraction& rhs) noexcept { return *this += Fraction{-rhs.num_, rhs.den_}; }
Fraction& operator*=(const Fraction& rhs) noexcept { num_ *= rhs.num_; den_ *= rhs.den_; reduce(); return *this; }
Fraction& operator/=(const Fraction& rhs) noexcept { return *this *= Fraction{rhs.den_, rhs.num_}; }
Fraction operator-() const noexcept { return Fraction{-num_, den_}; }
int num() const noexcept { return num_; }
int den() const noexcept { return den_; }
};
// lhs by value — the copy is the working buffer
Fraction operator+(Fraction lhs, const Fraction& rhs) noexcept { return lhs += rhs; }
Fraction operator-(Fraction lhs, const Fraction& rhs) noexcept { return lhs -= rhs; }
Fraction operator*(Fraction lhs, const Fraction& rhs) noexcept { return lhs *= rhs; }
Fraction operator/(Fraction lhs, const Fraction& rhs) noexcept { return lhs /= rhs; }Comparison
C++20 Spaceship Operator
operator<=> (C++20) returns one of three ordering types from <compare>:
| Return type | Semantics | Typical use |
|---|---|---|
std::strong_ordering | Equivalent means identical | integers, pointers |
std::weak_ordering | Equivalent but not identical | case-insensitive strings |
std::partial_ordering | Some pairs are unordered | floating-point (NaN) |
Defaulted <=> generates all six comparison operators and ==/!= automatically, with lexicographic member-by-member semantics:
#include <compare>
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default; // C++20: all six comparisons generated
};
Version v1{1, 2, 0}, v2{1, 3, 0};
static_assert(v1 < v2);
static_assert(v1 != v2);When the default ordering is wrong, write a custom <=>. Critical rule: a custom (non-defaulted) operator<=> does NOT synthesise operator==. You must define it separately, because equality and equivalence may differ:
struct PointByRadius {
double x, y;
// Order by distance from origin — two points at distance 5 are "equivalent" here
std::partial_ordering operator<=>(const PointByRadius& rhs) const noexcept {
double d1 = x*x + y*y, d2 = rhs.x*rhs.x + rhs.y*rhs.y;
return d1 <=> d2; // partial_ordering because NaN is possible
}
// Without this, == falls back to rewriting as (a <=> b) == 0,
// making (3,4) == (4,3) true — probably not what you want.
bool operator==(const PointByRadius& rhs) const noexcept {
return x == rhs.x && y == rhs.y;
}
};Pre-C++20 Comparison
Define == and <; derive the rest to avoid divergence:
struct Key {
int id;
std::string name;
bool operator==(const Key& rhs) const { return id == rhs.id && name == rhs.name; }
bool operator!=(const Key& rhs) const { return !(*this == rhs); }
bool operator< (const Key& rhs) const { return id != rhs.id ? id < rhs.id : name < rhs.name; }
bool operator> (const Key& rhs) const { return rhs < *this; }
bool operator<=(const Key& rhs) const { return !(rhs < *this); }
bool operator>=(const Key& rhs) const { return !(*this < rhs); }
};Increment and Decrement
Prefix returns *this by reference after mutation. Post-increment accepts a dummy int parameter and returns the pre-mutation copy by value — it cannot return a reference to a local.
class Iterator {
int* ptr_;
public:
explicit Iterator(int* p) noexcept : ptr_{p} {}
Iterator& operator++() noexcept { ++ptr_; return *this; } // prefix: mutate, return self
Iterator operator++(int) noexcept { // postfix: save, mutate, return saved
Iterator prev{*this};
++*this; // delegate to prefix to keep logic in one place
return prev;
}
Iterator& operator--() noexcept { --ptr_; return *this; }
Iterator operator--(int) noexcept { Iterator prev{*this}; --*this; return prev; }
int& operator*() const noexcept { return *ptr_; }
int* operator->() const noexcept { return ptr_; }
};Prefer prefix increment in generic code; the postfix form always copies.
Subscript and Call
operator[] took exactly one argument through C++20. C++23 lifted this restriction, enabling true multi-dimensional indexing:
class Matrix {
std::vector<double> data_;
size_t rows_, cols_;
public:
Matrix(size_t r, size_t c) : data_(r * c, 0.0), rows_{r}, cols_{c} {}
double& operator[](size_t i, size_t j) { return data_[i * cols_ + j]; } // C++23
double operator[](size_t i, size_t j) const { return data_[i * cols_ + j]; } // C++23
// For C++11–C++20 code, use operator() instead:
double& operator()(size_t i, size_t j) { return data_[i * cols_ + j]; }
double operator()(size_t i, size_t j) const { return data_[i * cols_ + j]; }
};operator() doubles as a function call operator for stateful callables:
class Scale {
double factor_;
public:
explicit Scale(double f) : factor_{f} {}
double operator()(double x) const noexcept { return x * factor_; }
};
std::vector<double> v{1.0, 2.0, 3.0};
std::ranges::transform(v, v.begin(), Scale{2.5}); // C++20 rangesStream Operators
Always free functions — you cannot add members to std::ostream:
#include <ostream>
#include <iomanip>
struct Color { uint8_t r, g, b; };
std::ostream& operator<<(std::ostream& os, const Color& c) {
auto flags = os.flags(); // save, restore to avoid polluting caller's stream state
os << '#' << std::hex << std::uppercase << std::setfill('0')
<< std::setw(2) << static_cast<int>(c.r)
<< std::setw(2) << static_cast<int>(c.g)
<< std::setw(2) << static_cast<int>(c.b);
os.flags(flags);
return os;
}Conversion Operators
C++11 added explicit to conversion operators, which prevents silent implicit conversion while still permitting contextual conversion (e.g., in if/while/static_cast):
class Celsius {
double t_;
public:
explicit Celsius(double t) : t_{t} {}
operator double() const noexcept { return t_; } // implicit — fine for a thin wrapper
explicit operator int() const noexcept { return static_cast<int>(t_); } // C++11
explicit operator bool() const noexcept { return t_ > 0.0; } // C++11 safe-bool
};
Celsius c{36.6};
double d = c; // implicit OK
int i = static_cast<int>(c);
if (c) { /* ... */ } // contextual bool conversion — explicit still works hereBefore C++11, the "safe-bool idiom" used a pointer-to-member trick to prevent c + 1 from compiling; explicit operator bool() (C++11) replaces it cleanly.
Best Practices
- Canonical arithmetic: define
+=as member, free+receives lhs by value — one copy, zero temporaries. - C++20+: always start with
= defaultspaceship. Write a custom<=>only when the default ordering is semantically wrong. - Always
return *thisfrom compound assignment. Returning by value silently breaks chaining (a += b += c). - Mark arithmetic and comparison operators
noexceptwhen possible. Standard containers rely on this for optimized move paths. - Make conversion operators
explicitby default. Implicit conversions surprise overload resolution and make error messages cryptic. - Never overload
&&,||, or,. The built-in forms guarantee short-circuit evaluation and sequencing; overloads lose these guarantees and will surprise callers.
Common Pitfalls
Custom spaceship without explicit ==: When operator<=> is user-defined (not defaulted), == is not generated as a separate overload. It falls back to rewriting a == b as (a <=> b) == 0, which uses the ordering semantics — correct when ordering implies equality, wrong when it does not (e.g., PointByRadius above). Always pair a custom <=> with an explicit operator==.
Returning wrong type from compound assignment:
// Wrong — caller gets a copy, not a reference; a += b += c breaks
Fraction Fraction::operator+=(const Fraction& rhs) { ...; return *this; }
// Correct
Fraction& Fraction::operator+=(const Fraction& rhs) { ...; return *this; }Symmetric binary operator as member: Fraction::operator+(int) handles frac + 1 but not 1 + frac. A free function with an implicit-constructor path handles both.
Forgetting const on observers: operator[], operator*, and operator-> all need const and non-const overloads; a missing const version makes the operator inaccessible on const objects.
Post-increment returning a reference: The saved copy is a local; returning it by reference is UB. Always return post-increment by value.
See Also
- Spaceship Operator (
<=>) — ordering categories and heterogeneous comparison - Lambda Expressions — anonymous callable objects vs
operator() - RAII —
operator booland resource handle patterns - Type Erasure —
operator()as the callable abstraction boundary