Skip to content
C++
Language
since C++11
Basic

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:

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

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

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

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

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

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

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

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

cpp
#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 +, -.

cpp
// 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 categoryTypical formNotes
Arithmetic (+, -, *, /)Free function, takes LHS by valueImplement += first, derive + from it
Compound assignment (+=, -=)Member, returns *this by refModifies *this in place
Comparison (C++11)Free function or memberMust write each one; keep them consistent
Three-way comparison (C++20)Member <=>, = defaultGenerates 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 valueDoes not modify *this
Conversion (operator T())Member, usually explicitMark 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).

Spaceship Operator <=> and Comparison Categories (C++20)

C++20's three-way comparison operator <=> returns a value from a comparison category type (std::strong_ordering, std::weak_ordering, or std::partial_ordering) and lets the compiler synthesise all six relational operators from a single definition. The category encodes how meaningful equality and ordering are for your type.

cpp
// Requires C++20
#include <compare>
#include <iostream>

struct Point {
    int x, y;

    // Default: member-by-member strong ordering (x first, then y)
    auto operator<=>(const Point&) const = default;
    bool operator==(const Point&) const = default;
};

int main() {
    Point a{1, 2}, b{1, 3}, c{1, 2};

    std::cout << (a < b)  << '\n'; // 1 — x equal, y less
    std::cout << (a == c) << '\n'; // 1
    std::cout << (b > a)  << '\n'; // 1
    // !=, <=, >= are all synthesised automatically
}

The three ordering categories matter when the meaning of "equal" differs from "identical":

  • std::strong_ordering — equivalent elements are interchangeable (integers, most value types).
  • std::weak_ordering — equivalent elements may differ in unobserved ways (case-insensitive strings: "Hello" and "hello" compare equivalent but are not the same bytes).
  • std::partial_ordering — some pairs are unordered (floating-point: NaN is neither less than, greater than, nor equal to anything).
cpp
// Custom ordering for a version number: major.minor.patch
#include <compare>

struct Version {
    int major, minor, patch;

    std::strong_ordering operator<=>(const Version& rhs) const {
        if (auto c = major <=> rhs.major; c != 0) return c;
        if (auto c = minor <=> rhs.minor; c != 0) return c;
        return patch <=> rhs.patch;
    }
    bool operator==(const Version&) const = default;
};

Writing a custom <=> and defaulting == is the standard pattern when the default member-by-member comparison isn't suitable.

Conversion Operators

A conversion operator lets an instance of your type implicitly (or explicitly) convert to another type — without requiring a constructor on the destination type. Marking the operator explicit prevents silent conversions while still allowing explicit casts, which is almost always the right choice for non-obvious conversions.

cpp
#include <iostream>
#include <string>

class Fraction {
    int num_, den_;
public:
    Fraction(int n, int d) : num_(n), den_(d) {}

    // Implicit conversion to double — use sparingly
    operator double() const {
        return static_cast<double>(num_) / den_;
    }

    // Explicit conversion to string — caller must cast deliberately
    explicit operator std::string() const {
        return std::to_string(num_) + "/" + std::to_string(den_);
    }
};

int main() {
    Fraction f{3, 4};

    double d = f;              // OK — implicit conversion to double
    std::cout << d << '\n';   // 0.75

    // std::string s = f;     // ERROR — explicit conversion operator
    std::string s = static_cast<std::string>(f); // OK — explicit cast
    std::cout << s << '\n';   // 3/4
}

explicit conversion operators are preferred because implicit conversions are a common source of bugs — the compiler can silently apply them in unexpected contexts (function calls, comparisons, return statements). Reserve implicit conversions for types where the conversion is lossless and universally expected, such as a SafeInt<T> converting to T.

A boolean conversion operator deserves special mention: explicit operator bool() is the idiomatic way to make a type testable in if conditions while still preventing accidental arithmetic:

cpp
struct File {
    void* handle = nullptr;
    explicit operator bool() const { return handle != nullptr; }
};

File f;
if (f) { /* OK — contextual conversion */ }
// int n = f; // ERROR — explicit prevents this

Overloading << and >> for Streams

Stream operators must be free functions (or friends) because the left operand is std::ostream or std::istream — a type you do not own and cannot add member functions to. Returning the stream by reference is mandatory for operator chaining to compile.

cpp
#include <iostream>
#include <string>

struct Date {
    int year, month, day;
};

// Output operator — free function, returns ostream& for chaining
std::ostream& operator<<(std::ostream& os, const Date& d) {
    return os << d.year << '-' << d.month << '-' << d.day;
}

// Input operator — reads three integers separated by dashes
std::istream& operator>>(std::istream& is, Date& d) {
    char dash;
    is >> d.year >> dash >> d.month >> dash >> d.day;
    return is;
}

int main() {
    Date d{2026, 7, 15};
    std::cout << "Today: " << d << '\n'; // chaining works: "Today: 2026-7-15"

    Date parsed;
    // std::cin >> parsed;  // reads "2026-7-15" into parsed
}

If << needs to access private members, declare it as a friend inside the class:

cpp
class Point {
    float x_, y_;
public:
    friend std::ostream& operator<<(std::ostream& os, const Point& p) {
        return os << '(' << p.x_ << ", " << p.y_ << ')';
    }
};

This is equivalent to a free function but grants it private access, and keeps the declaration visible alongside the class definition.

Subscript Operator operator[]

operator[] must come in two overloads — a non-const version returning a mutable reference and a const version returning either a const reference or a value copy — so that both mutable and const instances of your type support indexing. Without the const overload, you cannot index into a const-qualified object.

cpp
#include <array>
#include <stdexcept>
#include <cstddef>

template <typename T, std::size_t N>
class BoundedArray {
    std::array<T, N> data_{};
public:
    // Mutable access — caller can write through the returned reference
    T& operator[](std::size_t i) {
        if (i >= N) throw std::out_of_range("BoundedArray: index out of range");
        return data_[i];
    }

    // Const access — works on const BoundedArray objects
    const T& operator[](std::size_t i) const {
        if (i >= N) throw std::out_of_range("BoundedArray: index out of range");
        return data_[i];
    }
};

int main() {
    BoundedArray<int, 4> arr;
    arr[0] = 10;  // calls non-const overload
    arr[1] = 20;

    const auto& cref = arr;
    int v = cref[0]; // calls const overload — can't assign through it
}

C++23 adds multi-dimensional subscript: operator[](i, j) with multiple parameters, used by multi-dimensional span types like std::mdspan. Before C++23, multi-dimensional indexing required operator() instead.

Call Operator operator(): Function Objects

operator() turns a class instance into a callable object — a functor. Unlike a plain function, a functor carries state across calls, which makes it suitable for callbacks that need to remember configuration, accumulate results, or access member data.

cpp
#include <algorithm>
#include <vector>
#include <iostream>

// A stateful predicate: remember how many times it matched
struct CountedPredicate {
    int threshold;
    mutable int match_count = 0; // mutable so operator() can be const

    bool operator()(int val) const {
        if (val > threshold) { ++match_count; return true; }
        return false;
    }
};

int main() {
    std::vector<int> v = {1, 7, 3, 9, 2, 8, 4};
    CountedPredicate pred{5};

    auto it = std::remove_if(v.begin(), v.end(), pred);
    v.erase(it, v.end());
    // v: {1, 3, 2, 4}

    std::cout << "removed " << pred.match_count << " elements\n"; // 3
}

Functors predate lambdas but remain useful when you need to: pass a callable with state between compilation units, specialize template parameters (e.g., a custom comparator stored in std::set), or inherit from a base callable for polymorphism.

cpp
#include <set>

struct CaseInsensitiveLess {
    bool operator()(const std::string& a, const std::string& b) const;
};

std::set<std::string, CaseInsensitiveLess> words; // comparator is part of the type

std::function can hold any callable including functors, lambdas, and free functions, at the cost of type erasure overhead. Prefer templates or direct functor storage when performance matters.

Increment and Decrement: Prefix vs Postfix

C++ distinguishes prefix (++x) and postfix (x++) forms of increment and decrement by a dummy int parameter in the postfix signature. Prefix modifies the object and returns a reference to it (O(1) in all cases); postfix must save the old value, increment, then return the saved copy — making postfix slightly more expensive and the reason you should prefer ++it over it++ in loops.

cpp
#include <iostream>

struct Counter {
    int value;

    // Prefix ++x — modifies in place, returns *this by reference
    Counter& operator++() {
        ++value;
        return *this;
    }

    // Postfix x++ — dummy int parameter distinguishes the signature
    Counter operator++(int) {
        Counter old = *this; // save current state
        ++value;             // increment
        return old;          // return the old value (by value — a copy)
    }

    // Decrement follows the same pattern
    Counter& operator--() { --value; return *this; }
    Counter  operator--(int) { Counter old = *this; --value; return old; }
};

int main() {
    Counter c{0};
    std::cout << (++c).value << '\n'; // 1 — increments, returns new value
    std::cout << (c++).value << '\n'; // 1 — returns old value, then increments
    std::cout << c.value     << '\n'; // 2
}

For iterators, always implement prefix as the primary form and build postfix from it. Many generic algorithms call ++it; writing postfix correctly ensures your type works with it++ code written by others.

Pitfalls: Operators You Should Not Overload

Overloading &&, ||, and , removes their built-in short-circuit and sequencing guarantees, turning them into ordinary function calls where both operands are always fully evaluated before the operator runs. This breaks code that relies on those guarantees for safety or correctness.

cpp
// DO NOT do this:
bool operator&&(const MyBool& a, const MyBool& b) {
    // Both a and b are ALREADY evaluated before entering this function.
    // The short-circuit "don't evaluate b if a is false" is GONE.
    return a.value && b.value;
}

// This safe pattern now silently breaks:
MyBool ptr_valid = ...;
MyBool value_ok  = ...; // even if ptr_valid is false, value_ok is evaluated
if (ptr_valid && value_ok) { ... } // potential null deref hidden here

The same problem applies to operator,: the built-in comma operator guarantees left-to-right evaluation with sequencing. An overloaded version makes both sides into unordered function arguments.

Overloading the address-of operator& is legal but almost always wrong — it breaks the ability to take a raw address of your object, which many standard library internals require. std::addressof(obj) bypasses the overload and is the safe alternative.

Operators that should almost never be overloaded:

  • &&, || — lose short-circuit evaluation
  • , — lose sequencing guarantee
  • & (unary address-of) — breaks standard library internals
  • ->* — rarely needed, confusing semantics

Hidden Friend Idiom

Defining an operator as a friend function inside the class body rather than as an external free function gives it access to private members while keeping it invisible to unqualified name lookup — it can only be found by argument-dependent lookup (ADL). This reduces namespace pollution, speeds up overload resolution, and is the recommended pattern for non-member operators and swap.

cpp
#include <iostream>
#include <utility>

class Money {
    long cents_;
public:
    explicit Money(long cents) : cents_(cents) {}

    // Hidden friend: declared and defined inside the class
    // Only findable via ADL — not via unqualified lookup from outside
    friend Money operator+(Money a, Money b) {
        return Money{a.cents_ + b.cents_};
    }

    friend bool operator==(const Money& a, const Money& b) {
        return a.cents_ == b.cents_;
    }

    friend std::ostream& operator<<(std::ostream& os, const Money& m) {
        return os << "$" << m.cents_ / 100 << "." << m.cents_ % 100;
    }

    // Hidden friend swap — preferred over std::swap specialisation
    friend void swap(Money& a, Money& b) noexcept {
        std::swap(a.cents_, b.cents_);
    }
};

int main() {
    Money a{150}, b{250}; // $1.50, $2.50
    Money c = a + b;
    std::cout << c << '\n'; // $4.00
    std::cout << (a == b)   << '\n'; // 0
    swap(a, b);             // found via ADL, not unqualified lookup
}

The hidden friend pattern is particularly valuable for symmetric binary operators: because the friend function is defined inside the class, both operands are treated equally by ADL — neither has to be this. It also prevents the function from being called as operator+(x, y) in contexts where ADL would not fire, keeping the global namespace cleaner.

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.