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.
Three-way comparison operator (C++20)
The spaceship operator unifies all six comparisons into one definition. operator<=> returns a comparison category object rather than bool, letting the compiler synthesise <, <=, >, >=, ==, and != from a single expression.
The return type encodes the strongest guarantee your type can provide:
// C++20
#include <compare>
#include <string>
struct Employee {
int id;
double salary;
std::string name;
// Default: lexicographic over id, then salary, then name.
// salary is double β partial_ordering is the weakest member β whole struct gets partial_ordering.
auto operator<=>(const Employee&) const = default;
};
// Custom ordering: by id only, with strong guarantees.
struct Task {
int priority;
int id;
std::strong_ordering operator<=>(const Task& rhs) const noexcept {
if (auto cmp = priority <=> rhs.priority; cmp != 0) return cmp;
return id <=> rhs.id;
}
// Custom <=> does NOT synthesise ==; add it explicitly.
bool operator==(const Task& rhs) const noexcept {
return priority == rhs.priority && id == rhs.id;
}
};Heterogeneous comparison is supported by templating operator<=>:
// C++20
struct Meters {
double value;
// Compare directly against raw double without a conversion constructor.
std::partial_ordering operator<=>(double rhs) const noexcept { return value <=> rhs; }
bool operator==(double rhs) const noexcept { return value == rhs; }
};Ordering categories and when to pick each:
| Category | When to use | NaN-safe? |
|---|---|---|
std::strong_ordering | All equivalent objects are interchangeable | Yes |
std::weak_ordering | Equivalent but not identical (e.g., case-insensitive) | Yes |
std::partial_ordering | Some pairs incomparable (floating-point) | No |
Subscript operator overloading (C++23 multidimensional)
Before C++23, operator[] accepted exactly one subscript; the workaround was operator(). C++23 lifts the restriction, letting [] accept any number of arguments, which finally gives multi-dimensional containers a natural indexing syntax.
// C++23
#include <vector>
#include <cstddef>
#include <stdexcept>
template<typename T>
class Tensor3D {
std::vector<T> data_;
std::size_t d0_, d1_, d2_;
public:
Tensor3D(std::size_t d0, std::size_t d1, std::size_t d2)
: data_(d0 * d1 * d2), d0_{d0}, d1_{d1}, d2_{d2} {}
T& operator[](std::size_t i, std::size_t j, std::size_t k) { // C++23
return data_[i * d1_ * d2_ + j * d2_ + k];
}
const T& operator[](std::size_t i, std::size_t j, std::size_t k) const { // C++23
return data_[i * d1_ * d2_ + j * d2_ + k];
}
};
Tensor3D<float> t{4, 4, 4};
t[1, 2, 3] = 3.14f; // C++23 β no proxy object neededFor C++11βC++20 code, use operator() instead (see the Matrix example above). The C++23 form is strictly limited to operator[]; operator() is unaffected and still accepts any signature.
Caveat: the old t[i][j] chaining relied on returning a proxy/row type from a single-argument []. With multidimensional [], that chain would be parsed as t[i] (returns row), row[j] β two separate calls, not one two-argument call. Choose one style per type.
Function call operator and callable objects
operator() transforms a class instance into a callable, enabling stateful function objects that lambdas cannot always replace. A class with operator() satisfies the std::invocable concept and can be stored in std::function, passed to algorithms, or composed with std::bind_front.
// C++11 and later
#include <algorithm>
#include <vector>
#include <functional>
class Clamp {
double lo_, hi_;
public:
constexpr Clamp(double lo, double hi) noexcept : lo_{lo}, hi_{hi} {}
constexpr double operator()(double x) const noexcept {
return x < lo_ ? lo_ : (x > hi_ ? hi_ : x);
}
};
std::vector<double> data{-5.0, 0.5, 2.0, 10.0};
std::ranges::transform(data, data.begin(), Clamp{0.0, 1.0}); // C++20
// data == {0.0, 0.5, 1.0, 1.0}Multiple overloads of operator() coexist on one type, enabling dispatch on argument types:
// C++17
struct Visitor {
void operator()(int x) const { /* handle int */ }
void operator()(double x) const { /* handle double */ }
void operator()(const std::string& s) const { /* handle string */ }
};
std::variant<int, double, std::string> v = 42;
std::visit(Visitor{}, v); // C++17: dispatches to operator()(int)operator() must be a non-static member through C++22. C++23 allows static operator(), which lets the compiler avoid passing this when the callable has no state:
// C++23
struct Square {
static double operator()(double x) noexcept { return x * x; } // C++23
};User-defined literals as operator""
User-defined literals (C++11) extend literal syntax to user types, making unit-safe APIs read like plain code. A literal operator is a free function named operator""_suffix; it is invoked when the compiler sees a literal with that suffix.
// C++11
#include <chrono>
#include <cstddef>
struct Meters { double value; };
struct Kilograms { double value; };
constexpr Meters operator""_m (long double v) noexcept { return {static_cast<double>(v)}; }
constexpr Kilograms operator""_kg (long double v) noexcept { return {static_cast<double>(v)}; }
auto dist = 3.5_m; // Meters{3.5}
auto mass = 70.0_kg; // Kilograms{70.0}
// dist + mass β compile error: no operator+(Meters, Kilograms) β type safety enforcedThe four literal operator signatures accepted by the standard:
| Signature | Matches |
|---|---|
(unsigned long long) | Integer literals |
(long double) | Floating-point literals |
(const char*, std::size_t) | String literals |
(char) | Character literals |
(const char*) | Raw template (integer/float, unprocessed) |
Raw and template forms allow compile-time validation:
// C++11
constexpr int operator""_bin(const char* s, std::size_t len) {
int result = 0;
for (std::size_t i = 0; i < len; ++i) {
result = result * 2 + (s[i] == '1' ? 1 : 0);
}
return result;
}
static_assert(0b1010 == "1010"_bin); // both equal 10Convention: user-defined suffixes must start with _; suffixes without a leading underscore are reserved for the standard library.
Overloading new and delete
Class-specific operator new and operator delete let types control their own memory allocation without affecting the global heap. They are implicitly static (no this pointer at allocation time) and must match in pairs to avoid UB.
// C++11 and later
#include <cstdlib>
#include <cstddef>
#include <new>
class PoolAllocated {
static constexpr std::size_t kPoolSize = 4096;
static char pool_[kPoolSize];
static std::size_t offset_;
public:
static void* operator new(std::size_t size) {
if (offset_ + size > kPoolSize) throw std::bad_alloc{};
void* p = pool_ + offset_;
offset_ += size;
return p;
}
static void operator delete(void* /*ptr*/) noexcept {
// Pool allocator: no per-object free β reset offset_ elsewhere.
}
// Placement-new passthrough β always provide this when overloading new.
static void* operator new(std::size_t size, void* place) noexcept {
return ::operator new(size, place);
}
static void operator delete(void* ptr, void* place) noexcept {
::operator delete(ptr, place);
}
};Array forms (operator new[] / operator delete[]) are separate and must also be overloaded if the class is ever array-allocated. Overloading global ::operator new is legal but affects all allocations in the translation unit β prefer class-specific forms.
C++17 adds a sized-delete overload (operator delete(void*, std::size_t)) that receives the original allocation size, enabling more efficient deallocation in custom allocators:
// C++17
static void operator delete(void* ptr, std::size_t size) noexcept {
// size is the exact byte count passed to operator new
myPool.deallocate(ptr, size);
}Hidden friend pattern for binary operators
Defining binary operators as friend functions inside the class body restricts ADL lookup in a way that avoids accidental implicit conversions and ambiguities. These so-called "hidden friends" are found only through argument-dependent lookup β they are not visible to ordinary unqualified lookup.
// C++11 and later
class Vec2 {
double x_, y_;
public:
constexpr Vec2(double x, double y) noexcept : x_{x}, y_{y} {}
// Hidden friend: defined inline, found only by ADL on Vec2 arguments.
friend constexpr Vec2 operator+(Vec2 lhs, const Vec2& rhs) noexcept {
return {lhs.x_ + rhs.x_, lhs.y_ + rhs.y_};
}
friend constexpr bool operator==(const Vec2& a, const Vec2& b) noexcept {
return a.x_ == b.x_ && a.y_ == b.y_;
}
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << '(' << v.x_ << ", " << v.y_ << ')';
}
};Compared to a free function defined at namespace scope, the hidden-friend version:
- Is not found by unqualified lookup, so it does not participate in overload sets that don't involve
Vec2. - Has direct access to private members without needing
friendto be declared separately. - Compiles faster on large projects because it does not pollute the enclosing namespace.
The pattern is recommended by the C++ Core Guidelines for symmetric binary operators, comparison operators, and swap.
Pitfall: overloading &&, ||, comma operator
Overloading &&, ||, or , (comma) breaks the sequencing guarantees the built-in operators provide. This is one of the most dangerous things you can do with operator overloading.
Built-in && and || guarantee short-circuit evaluation: the right operand is never evaluated if the result is determined by the left. Built-in , guarantees that the left operand is fully sequenced before the right. Overloaded versions are ordinary function calls β both operands are evaluated before the function is invoked, and the order of evaluation between them is unspecified prior to C++17.
// DON'T DO THIS β illustrates the pitfall only
struct Lazy {
bool value;
explicit operator bool() const { return value; }
// Looks like logical AND, but both sides are always evaluated.
// If rhs involves a side-effect or expensive computation, it runs unconditionally.
Lazy operator&&(const Lazy& rhs) const { return {value && rhs.value}; } // AVOID
};
bool flag = false;
Lazy a{false};
Lazy b{riskyComputation()}; // called even if a is false β surprise!
if (a && b) { /* ... */ } // overloaded &&: riskyComputation() always runsSimilarly, overloading the comma operator causes f(), g() to call both with unspecified evaluation order (pre-C++17) and loses the "discard left result" intent that makes comma readable.
The rule: never overload &&, ||, or ,. If you need a lazy-evaluation or chaining DSL, use named functions (andThen, orElse) or expression templates instead.
// C++23 β correct alternative: named chaining
auto result = a.andThen([&] { return b; }); // explicit laziness, no surprisesSee 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