Skip to content
C++
Language
since C++98
Intermediate

Operator Overloading

Canonical forms, spaceship operator ordering categories, member vs free functions, conversion operators, and pitfalls that trip up experienced engineers.

Operator Overloadingsince C++98

Operator 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:

OperatorForm 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 conversionMember

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.

cpp
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 typeSemanticsTypical use
std::strong_orderingEquivalent means identicalintegers, pointers
std::weak_orderingEquivalent but not identicalcase-insensitive strings
std::partial_orderingSome pairs are unorderedfloating-point (NaN)

Defaulted <=> generates all six comparison operators and ==/!= automatically, with lexicographic member-by-member semantics:

cpp
#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:

cpp
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:

cpp
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.

cpp
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:

cpp
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:

cpp
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 ranges

Stream Operators

Always free functions β€” you cannot add members to std::ostream:

cpp
#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):

cpp
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 here

Before 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 = default spaceship. Write a custom <=> only when the default ordering is semantically wrong.
  • Always return *this from compound assignment. Returning by value silently breaks chaining (a += b += c).
  • Mark arithmetic and comparison operators noexcept when possible. Standard containers rely on this for optimized move paths.
  • Make conversion operators explicit by 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:

cpp
// 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:

cpp
// 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<=>:

cpp
// 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:

CategoryWhen to useNaN-safe?
std::strong_orderingAll equivalent objects are interchangeableYes
std::weak_orderingEquivalent but not identical (e.g., case-insensitive)Yes
std::partial_orderingSome 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.

cpp
// 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 needed

For 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.

cpp
// 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:

cpp
// 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:

cpp
// 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.

cpp
// 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 enforced

The four literal operator signatures accepted by the standard:

SignatureMatches
(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:

cpp
// 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 10

Convention: 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.

cpp
// 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:

cpp
// 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.

cpp
// 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 friend to 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.

cpp
// 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 runs

Similarly, 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.

cpp
// C++23 β€” correct alternative: named chaining
auto result = a.andThen([&] { return b; });  // explicit laziness, no surprises

See Also