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.

See Also