Phase 2 · Classes, Ownership & Resource SafetyModule 12~48 min read

Operator Overloading & Conversions

Make domain types feel natural without surprises by overloading operators and controlling conversions deliberately.

What you'll learn

Operators are functions with familiar syntax. Used well, they make a domain value read like built-in arithmetic or comparison; used poorly, they hide surprising work. You will implement a small numeric type, streams, comparisons, indexing, and deliberate conversions.

By the end, you'll be able to:

  • Choose member or non-member operator forms
  • Implement arithmetic, comparison, stream, and increment conventions
  • Keep indexing and call operators within their natural meaning
  • Control converting constructors and conversion operators with explicit

Operator design principles

Overloading changes how an operator works for a user-defined type; it cannot invent new syntax, change precedence, change arity, or redefine behavior for built-in operands alone. The result should preserve the operator's conventional meaning and complexity expectations.

  • Use + to produce a value and += to modify the left operand
  • Comparison should be stable, transitive, and consistent with equality
  • Stream insertion writes to the supplied stream and returns that stream
  • Do not make arithmetic trigger hidden I/O, networking, or unrelated mutation

Key idea

If a named function communicates the operation more clearly, use the name. Familiar punctuation is valuable only when the semantics are equally familiar.

Arithmetic operators

Compound assignment is naturally a member because it modifies its left object. Binary arithmetic is often a non-member implemented in terms of compound assignment; this treats both operands more symmetrically and reuses the invariant-preserving logic.

distance.cpp
#include <iostream>

class Distance {
public:
    explicit Distance(double meters) : meters_{meters} {}

    Distance& operator+=(Distance other) {
        meters_ += other.meters_;
        return *this;
    }

    double meters() const { return meters_; }

private:
    double meters_{};
};

Distance operator+(Distance left, Distance right) {
    left += right;
    return left;
}

int main() {
    Distance route{120.5};
    route += Distance{29.5};
    std::cout << (route + Distance{50.0}).meters() << '\n';
}

Equality and three-way comparison

C++20 can generate coherent comparisons with a defaulted operator== andoperator<=>. Memberwise ordering is appropriate only when member declaration order matches the domain's ordering. Floating-point values may produce partial ordering because NaN is unordered.

version.cpp
#include <compare>
#include <iostream>

struct Version {
    int major{};
    int minor{};
    int patch{};

    bool operator==(const Version&) const = default;
    auto operator<=>(const Version&) const = default;
};

int main() {
    Version current{2, 4, 0};
    Version required{2, 3, 5};
    std::cout << std::boolalpha << (current >= required) << '\n';
}

Watch out

Never derive equality from an approximate floating-point comparison unless that tolerance is explicitly the domain's equivalence relation. Approximate equality is usually a named operation.

Stream insertion and extraction

Stream operators are non-members because the stream is the left operand. Returning the stream enables chaining. Extraction should set failbit when input cannot produce a valid value and should avoid partially mutating the destination.

point_stream.cpp
#include <iostream>

struct Point { double x{}; double y{}; };

std::ostream& operator<<(std::ostream& out, const Point& point) {
    return out << '(' << point.x << ", " << point.y << ')';
}

std::istream& operator>>(std::istream& in, Point& point) {
    double x{};
    double y{};
    if (in >> x >> y) point = Point{x, y};
    return in;
}

int main() {
    Point point{3.5, 7.0};
    std::cout << "point: " << point << '\n';
}

Subscript and call operators

operator[] makes an object behave like an indexed collection and often needs const and non-const overloads. operator() makes a function object whose state can parameterize repeated calls. Both should have clear preconditions and stable reference lifetimes.

OperatorConventional role
object[index]Element access
object(args)Callable behavior
pointer->memberPointer-like access; overload sparingly
static_cast<bool>(object)Valid/present/open state
scale.cpp
#include <iostream>

class Scale {
public:
    explicit Scale(double factor) : factor_{factor} {}
    double operator()(double value) const { return value * factor_; }
private:
    double factor_;
};

int main() {
    Scale kilometers_to_miles{0.621371};
    std::cout << kilometers_to_miles(10.0) << '\n';
}

Increment and conversions

Prefix increment modifies and returns the object by reference. Postfix receives a dummyint, copies the old value, performs prefix increment, then returns the old copy. Converting constructors and conversion operators should be explicit unless implicit conversion is safe, cheap, unsurprising, and semantically exact.

ticket.cpp
class TicketNumber {
public:
    explicit TicketNumber(unsigned value) : value_{value} {}

    TicketNumber& operator++() {
        ++value_;
        return *this;
    }

    TicketNumber operator++(int) {
        TicketNumber previous{*this};
        ++*this;
        return previous;
    }

    explicit operator bool() const { return value_ != 0; }

private:
    unsigned value_{};
};

Tip

The contextual Boolean conversion used by if (object) works with an explicit operator bool, avoiding unintended arithmetic conversions.

Recap & quick check

Key takeaways

  • Overloaded operators should preserve conventional meaning and expected cost.
  • Implement binary arithmetic from compound assignment when that yields one source of truth.
  • Defaulted C++20 comparisons are useful when memberwise order matches domain order.
  • Stream operators return the supplied stream so expressions can chain.
  • Make conversion paths explicit unless implicit conversion is exact and unsurprising.

Quick check

1. What should operator+= normally return?

2. Why is stream insertion usually a non-member?

3. What distinguishes postfix increment's declaration?

4. When is an implicit conversion most defensible?

Next: Module 13 — Inheritance, Polymorphism & Interfaces, where behavior varies behind stable runtime contracts.