Skip to main content
C++ intermediate Lesson 5 of 23

Operators and Operator Overloading in C++

Learn operator overloading, the C++20 spaceship operator, and comparison categories for expressive, correct code.

Built-in Operators

C++ provides the full set of operators you’d expect from a systems language. Understanding their behavior precisely matters because C++ does not have bounds checking or overflow protection by default — signed integer overflow is undefined behavior, and integer division silently truncates. Knowing exactly what each operator does helps you avoid subtle bugs that only appear in production.

#include <iostream>

int main() {
    // Arithmetic
    int a = 17, b = 5;
    std::cout << a + b  << "\n";   // 22
    std::cout << a - b  << "\n";   // 12
    std::cout << a * b  << "\n";   // 85
    std::cout << a / b  << "\n";   // 3  (integer division — truncates toward zero)
    std::cout << a % b  << "\n";   // 2  (remainder — same sign as dividend)

    double x = 17.0, y = 5.0;
    std::cout << x / y  << "\n";   // 3.4 (floating-point division — no truncation)

    // Compound assignment — equivalent to a = a op b, but only evaluate a once
    a += 3;   // a = a + 3  => 20
    a -= 1;   // 19
    a *= 2;   // 38
    a /= 4;   // 9
    a %= 4;   // 1

    // Increment / decrement — prefer prefix (++n) in loops: no temporary copy
    int n = 5;
    std::cout << n++ << "\n";   // 5 (post-increment: returns old value, then increments)
    std::cout << n   << "\n";   // 6
    std::cout << ++n << "\n";   // 7 (pre-increment: increments then returns new value)

    // Logical — short-circuits: right side not evaluated if left determines result
    bool p = true, q = false;
    std::cout << (p && q) << "\n";   // 0 (false)
    std::cout << (p || q) << "\n";   // 1 (true)
    std::cout << (!p)     << "\n";   // 0 (false)

    // Bitwise — operate on individual bits; essential for flags, masks, hardware
    unsigned int flags = 0b1010;
    std::cout << (flags & 0b1100) << "\n";   // 0b1000 = 8  (AND — keep shared bits)
    std::cout << (flags | 0b0101) << "\n";   // 0b1111 = 15 (OR  — set bits)
    std::cout << (flags ^ 0b1111) << "\n";   // 0b0101 = 5  (XOR — toggle bits)
    std::cout << (~flags)         << "\n";   // bitwise NOT — flips all bits
    std::cout << (flags << 1)     << "\n";   // 0b10100 = 20 (left shift = multiply by 2)
    std::cout << (flags >> 1)     << "\n";   // 0b0101  = 5  (right shift = divide by 2)
}

Operator Overloading Syntax

Operator overloading lets user-defined types use the same operator syntax as built-in types. An overloaded operator is just a function with a special name — operator+, operator==, etc. This matters because it lets your types participate naturally in expressions and algorithms, making client code readable and intuitive.

Two forms:

  • Member function: T operator+(const T& rhs) const; — left operand is *this
  • Non-member function: T operator+(const T& lhs, const T& rhs); — both operands are parameters

The rule of thumb: use member functions for operators that mutate (+=, -=, [], ()), and non-member functions for symmetric binary operators (+, -, ==) so implicit conversions apply equally to both sides.


A Complete Vector2D Example

This example shows the full pattern for a numeric type: compound assignment operators as members, binary operators as non-members defined in terms of the compound operators, and stream insertion as a non-member friend. Defining binary + in terms of member += means the logic lives in one place.

#include <iostream>
#include <cmath>
#include <sstream>

class Vector2D {
public:
    double x, y;

    Vector2D(double x = 0.0, double y = 0.0) : x(x), y(y) {}

    // Member: compound assignment operators (mutate *this — must be members)
    Vector2D& operator+=(const Vector2D& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }

    Vector2D& operator-=(const Vector2D& rhs) {
        x -= rhs.x;
        y -= rhs.y;
        return *this;
    }

    Vector2D& operator*=(double scalar) {
        x *= scalar;
        y *= scalar;
        return *this;
    }

    // Unary negation — returns a new vector, doesn't modify *this
    Vector2D operator-() const {
        return {-x, -y};
    }

    // Subscript operator: v[0] == x, v[1] == y
    double& operator[](int index) {
        return (index == 0) ? x : y;
    }

    const double& operator[](int index) const {
        return (index == 0) ? x : y;
    }

    double magnitude() const {
        return std::sqrt(x * x + y * y);
    }
};

// Non-member: binary arithmetic (symmetric — defined in terms of +=)
// Taking lhs by value lets the compiler use it as the accumulator
Vector2D operator+(Vector2D lhs, const Vector2D& rhs) {
    lhs += rhs;   // reuse member operator — logic in one place
    return lhs;
}

Vector2D operator-(Vector2D lhs, const Vector2D& rhs) {
    lhs -= rhs;
    return lhs;
}

Vector2D operator*(Vector2D v, double scalar) {
    v *= scalar;
    return v;
}

// Allow scalar * vector (commutative) — impossible as a member function
Vector2D operator*(double scalar, Vector2D v) {
    return v * scalar;
}

// Non-member: equality
bool operator==(const Vector2D& lhs, const Vector2D& rhs) {
    return lhs.x == rhs.x && lhs.y == rhs.y;
}

bool operator!=(const Vector2D& lhs, const Vector2D& rhs) {
    return !(lhs == rhs);
}

// Stream insertion — returns os to allow chaining: cout << v1 << v2
std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
    os << "(" << v.x << ", " << v.y << ")";
    return os;
}

int main() {
    Vector2D a{3.0, 4.0};
    Vector2D b{1.0, 2.0};

    std::cout << a + b  << "\n";   // (4, 6)
    std::cout << a - b  << "\n";   // (2, 2)
    std::cout << a * 2  << "\n";   // (6, 8)
    std::cout << 3 * b  << "\n";   // (3, 6) — commutative scalar multiply
    std::cout << -a     << "\n";   // (-3, -4)

    std::cout << "Magnitude of a: " << a.magnitude() << "\n";  // 5

    a += b;
    std::cout << a      << "\n";   // (4, 6)

    std::cout << (a == b) << "\n"; // 0 (false)
    std::cout << a[0]     << "\n"; // 4 (x component)
}

Overloading operator[] and operator()

operator[] lets your type use subscript syntax. operator() makes your type callable like a function (a “functor”). Both must be member functions. Always provide both const and non-const overloads so the type works in both mutable and read-only contexts.

#include <vector>
#include <stdexcept>

// A simple 2D matrix with bounds-checked access
class Matrix {
public:
    Matrix(int rows, int cols)
        : rows_(rows), cols_(cols), data_(rows * cols, 0.0) {}

    // operator[] returns a raw pointer to the row, enabling matrix[r][c]
    double* operator[](int row) {
        return &data_[row * cols_];
    }

    const double* operator[](int row) const {
        return &data_[row * cols_];
    }

    // operator() with bounds checking — preferred when safety matters
    double& operator()(int row, int col) {
        if (row < 0 || row >= rows_ || col < 0 || col >= cols_)
            throw std::out_of_range("Matrix index out of bounds");
        return data_[row * cols_ + col];
    }

private:
    int rows_, cols_;
    std::vector<double> data_;
};

// Usage:
Matrix m(3, 3);
m[0][0] = 1.0;    // via operator[] — fast, no bounds check
m(1, 1) = 5.0;    // via operator() — bounds checked, throws on out-of-range

The Spaceship Operator <=> (C++20)

Before C++20, supporting full ordering for a class meant writing six comparison operators (==, !=, <, <=, >, >=) and keeping them consistent with each other — a common source of bugs. The spaceship operator collapses all six into one definition, and when you use = default, the compiler generates all of them automatically by comparing members in declaration order.

#include <compare>
#include <iostream>

struct Version {
    int major, minor, patch;

    // One line replaces six comparison operators — compiler generates them all
    auto operator<=>(const Version& other) const = default;
};

int main() {
    Version v1{1, 2, 3};
    Version v2{1, 3, 0};
    Version v3{1, 2, 3};

    std::cout << (v1 < v2)  << "\n";   // 1 (true)
    std::cout << (v1 > v2)  << "\n";   // 0 (false)
    std::cout << (v1 == v3) << "\n";   // 1 (true)
    std::cout << (v1 <= v2) << "\n";   // 1 (true)
}

Comparison Categories

The return type of <=> determines what comparisons mean for your type. Choosing the right category lets the compiler enforce the semantics correctly.

#include <compare>

// std::strong_ordering: total order where equivalent means identical
// Use for: integers, version numbers, anything with a unique ordering
struct IntWrapper {
    int value;
    std::strong_ordering operator<=>(const IntWrapper& o) const {
        return value <=> o.value;
    }
};

// std::weak_ordering: total order where equivalent does NOT mean identical
// Use for: case-insensitive strings ("ABC" and "abc" are equivalent but not equal)
struct CaseInsensitiveStr {
    std::string s;
    std::weak_ordering operator<=>(const CaseInsensitiveStr& o) const {
        std::string a = s, b = o.s;
        std::transform(a.begin(), a.end(), a.begin(), ::tolower);
        std::transform(b.begin(), b.end(), b.begin(), ::tolower);
        return a <=> b;
    }
};

// std::partial_ordering: some values are not comparable (e.g., NaN in IEEE floats)
// Use for: floating-point types, sets ordered by subset relation
struct FloatWrapper {
    float value;
    std::partial_ordering operator<=>(const FloatWrapper& o) const {
        return value <=> o.value;   // NaN comparisons correctly return unordered
    }
};

Custom <=> with Non-Default Logic

When the default member-by-member comparison isn’t what you want, define the operator with custom logic. This example orders employees by salary descending, then by ID ascending:

#include <compare>
#include <string>

struct Employee {
    int id;
    std::string name;
    double salary;

    // Custom ordering: highest salary first, then by id for ties
    std::strong_ordering operator<=>(const Employee& o) const {
        if (salary != o.salary)
            return o.salary <=> salary;   // reversed for descending salary
        return id <=> o.id;
    }

    bool operator==(const Employee& o) const {
        return id == o.id;  // equality based on id alone
    }
};

The spaceship operator eliminates an entire class of bugs where operator< and operator== were inconsistent with each other — a common mistake in pre-C++20 code.

Frequently Asked Questions

Should operators be member or non-member functions?
Prefer non-member for symmetric operators (==, +) and member for operators that modify the object (+=, []).
What does the spaceship operator <=> do?
It defines three-way comparison, and the compiler can auto-generate all six comparison operators from it.