Skip to content

6. Operator Overloading

What & why

Operator overloading lets your types use natural syntax: a + b for vectors, p1 == p2 for points, std::cout << money. Done well, your types feel built in and work with the standard library (sorting, maps, printing). Done badly, + does something surprising. The rule: only overload an operator when its meaning is obvious.

A running example: Vec2

#include <cmath>
#include <compare>
#include <iostream>

struct Vec2 {
    double x{};
    double y{};

    // Compound assignment: member, modifies *this, returns *this by reference
    Vec2& operator+=(const Vec2& o) { x += o.x; y += o.y; return *this; }
    Vec2& operator-=(const Vec2& o) { x -= o.x; y -= o.y; return *this; }
    Vec2& operator*=(double s)      { x *= s;   y *= s;   return *this; }

    // Unary minus
    Vec2 operator-() const { return {-x, -y}; }

    // Equality (C++20: defaulted, compares members in order)
    bool operator==(const Vec2&) const = default;

    double length() const { return std::hypot(x, y); }
};

// Binary arithmetic: free functions, implemented in terms of the compound versions
inline Vec2 operator+(Vec2 a, const Vec2& b) { return a += b; }
inline Vec2 operator-(Vec2 a, const Vec2& b) { return a -= b; }
inline Vec2 operator*(Vec2 v, double s) { return v *= s; }
inline Vec2 operator*(double s, Vec2 v) { return v *= s; }   // so 2.0 * v works

// Stream output
inline std::ostream& operator<<(std::ostream& os, const Vec2& v) {
    return os << '(' << v.x << ", " << v.y << ')';
}
Vec2 a{1, 2}, b{3, 4};
std::cout << a + b << '\n';       // (4, 6)
std::cout << 2.0 * a << '\n';     // (2, 4)
std::cout << (a == b) << '\n';    // 0
a += b;
std::cout << -a << '\n';          // (-4, -6)

Why free functions for binary operators?

A member operator+ only allows implicit conversions on the right operand. A free function treats both sides symmetrically, so 2.0 * v and v * 2.0 both work.

Taking the left operand by value (Vec2 a) gives you a copy to modify and return — a neat idiom.

Comparisons in C++20: == and <=>

Before C++20 you wrote six comparison operators. Now:

struct Version {
    int major{}, minor{}, patch{};
    auto operator<=>(const Version&) const = default;   // generates <, <=, >, >=
    bool operator==(const Version&) const = default;    // generates ==, !=
};

Version{1, 2, 3} < Version{1, 10, 0};   // true — compares members in declaration order

<=> ("spaceship") returns an ordering: std::strong_ordering, std::weak_ordering, or std::partial_ordering (for floating point, where NaN is unordered).

(Actually, defaulting <=> also implicitly defaults ==, but writing both is clearer.)

A custom ordering — case-insensitive names:

#include <algorithm>
#include <cctype>

struct Name {
    std::string value;

    std::weak_ordering operator<=>(const Name& o) const {
        auto lower = [](unsigned char c) { return std::tolower(c); };
        return std::lexicographical_compare_three_way(
            value.begin(), value.end(), o.value.begin(), o.value.end(),
            [&](char a, char b) { return lower(a) <=> lower(b); });
    }
    bool operator==(const Name& o) const { return (*this <=> o) == 0; }
};

Once a type has <, it works with std::sort, std::map, and std::set with no extra code.

Stream operators

std::ostream& operator<<(std::ostream& os, const T& value);   // output
std::istream& operator>>(std::istream& is, T& value);         // input

Always return the stream so calls chain. If they need private members, declare them as friend inside the class:

class Money {
public:
    explicit Money(long long cents) : cents_{cents} {}
    friend std::ostream& operator<<(std::ostream& os, const Money& m) {
        return os << '$' << m.cents_ / 100 << '.'
                  << (m.cents_ % 100 < 10 ? "0" : "") << m.cents_ % 100;
    }
private:
    long long cents_;
};

std::format support

To make std::format("{}", money) work, specialize std::formatter:

#include <format>

template <>
struct std::formatter<Vec2> : std::formatter<std::string> {
    auto format(const Vec2& v, std::format_context& ctx) const {
        return std::formatter<std::string>::format(std::format("({}, {})", v.x, v.y), ctx);
    }
};

std::cout << std::format("pos = {}\n", Vec2{1, 2});

Subscript []

class Grid {
public:
    Grid(std::size_t w, std::size_t h) : w_{w}, cells_(w * h) {}

    int& operator[](std::size_t row, std::size_t col) {             // C++23 multi-arg
        return cells_[row * w_ + col];
    }
    // C++20-compatible alternative: int& at(std::size_t row, std::size_t col);

private:
    std::size_t w_;
    std::vector<int> cells_;
};

Provide both a const and a non-const overload when appropriate:

int& operator[](std::size_t i) { return data_[i]; }
const int& operator[](std::size_t i) const { return data_[i]; }

Function call (): function objects

A class with operator() can be called like a function, and can carry state:

struct Multiplier {
    int factor;
    int operator()(int x) const { return x * factor; }
};

Multiplier triple{3};
triple(5);   // 15

std::ranges::transform(v, v.begin(), Multiplier{2});   // double every element

Lambdas (lesson 9) are just a short way to write these.

Conversion operators

class Percentage {
public:
    explicit operator double() const { return value_ / 100.0; }
    explicit operator bool() const { return value_ != 0; }   // for `if (p)`
private:
    double value_{};
};

Make them explicit to avoid surprising implicit conversions. An explicit operator bool still works in if, while, &&, !.

Hashing for unordered_map

To use your type as an unordered_map key, provide == and a std::hash specialization:

template <>
struct std::hash<Vec2> {
    std::size_t operator()(const Vec2& v) const noexcept {
        std::size_t h1 = std::hash<double>{}(v.x);
        std::size_t h2 = std::hash<double>{}(v.y);
        return h1 ^ (h2 + 0x9e3779b97f4a7c15ULL + (h1 << 6) + (h1 >> 2));
    }
};

std::unordered_map<Vec2, std::string> labels;

Rules of thumb

Operator Member or free? Returns
=, [], (), ->, compound += etc. member T& (for =/+=), element ref for []
binary + - * / free (often via +=) T by value
==, <=> member, often = default bool / ordering
<<, >> for streams free (maybe friend) the stream by reference
  • Keep semantics unsurprising: + shouldn't modify its operands.
  • Keep related operators consistent: if a + b exists, a += b should too.
  • Don't overload &&, ||, or , — you lose short-circuiting and evaluation order.

Common mistakes

  • Returning by value from += or by reference from +.
  • Member operator+ that breaks 2 * v.
  • Forgetting const on comparison operators — then they don't work on const objects or in std::sort.
  • Inconsistent == and <=>.
  • An operator< that isn't a strict weak ordering — std::sort becomes undefined behavior (e.g. <= instead of <).
  • Implicit conversion operators that cause ambiguous overloads.

Exercises

  1. Complete Vec2 with /, /=, dot, normalized(), operator>>, and a std::formatter.
  2. Write a Fraction class (from lesson 4) with + - * /, <=>, ==, and <<. Sort a vector of fractions.
  3. Write a Matrix2x2 with * for matrix multiplication and operator[](row, col) (or at(row, col) if your compiler lacks C++23).
  4. Make a Date {int y, m, d;} usable as a key in both std::map (via <=>) and std::unordered_map (via std::hash).
  5. Write a function object CountingLess that counts how many comparisons std::sort makes on 1,000 random ints. (Careful: std::sort copies the comparator — use a reference or pointer to the counter.)

Recap

  • Overload operators only when the meaning is obvious.
  • Implement += as a member returning *this; implement + as a free function using it.
  • C++20: = default == and <=> give you all six comparisons.
  • operator<< returns the stream; specialize std::formatter for std::format.
  • operator() makes function objects; explicit conversion operators avoid surprises.
  • Provide std::hash + == for unordered containers.

Next: Inheritance & polymorphism →