C++ Programming
Lesson 22 of 24

Lesson 22 · 24 lesson course

22. Operator Overloading

Rephrasing functions to look like operators

Programming glossary 65 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

Abstract balanced operation combining two compatible objects into one intuitive result.

 

Lesson: Operator Overloading in C++ — Intuitive APIs, Correct Semantics

Operator overloading lets you re-express a function so it can be used with operator syntax. Instead of calling add(a, b) you can write a + b. The goal is to exploit a programmer’s intuitive understanding of operators without breaking expectations or invariants.

Principle: Only overload when the operator’s conventional meaning matches your type’s semantics, and when it improves clarity. Keep behaviour unsurprising and mathematically/semantically consistent.

Programming Insight (AI) — Pick the Right Signature
  • Paste your class and the operator you want. Ask AI: “member vs non-member vs friend?” with justification and exact signatures.
  • Request a rewrite that implements op in terms of the compound form (e.g., + via +=) for consistency.

1) Member vs Non-member (and friend)

  • Member operator (method): one operand is *this (the left-hand side). Good for operator[], operator(), assignment (=), +=, ++, etc.
  • Non-member operator: both operands are parameters. Best for symmetric binary operators like +, -, *, comparisons, and stream operators.
  • friend non-member: when non-member is best (symmetry/ADL) but needs access to private state. Grants surgical access without exposing members publicly.

Key subtlety: A member binary operator enables implicit conversions only on the right operand; a non-member enables conversions on both.


2) Worked Example — Time with Normalisation (ideal for +)

We model time as hours/minutes with the invariant 0 ≤ minutes < 60.

#include <stdexcept>
#include <iostream>

class Time {
public:
    Time(int h = 0, int m = 0) : hours_{h}, minutes_{m} { normalise(); }

    // Compound form first: preferred for efficiency & reuse
    Time& operator+=(const Time& rhs) {
        hours_   += rhs.hours_;
        minutes_ += rhs.minutes_;
        normalise();
        return *this;
    }

    // Non-member + in terms of += (symmetric; allows implicit conversions on both sides)
    friend Time operator+(Time lhs, const Time& rhs) {
        lhs += rhs;      // reuse compound
        return lhs;      // return by value (NRVO/move)
    }

    // Hidden friend stream (ADL-friendly)
    friend std::ostream& operator<<(std::ostream& os, const Time& t) {
        return os << t.hours_ << "h " << t.minutes_ << "m";
    }

private:
    int hours_{}, minutes_{};

    void normalise() {
        if (minutes_ >= 60) { hours_ += minutes_ / 60; minutes_ %= 60; }
        if (minutes_ < 0)    { int borrow = ( -minutes_ + 59 ) / 60; hours_ -= borrow; minutes_ += 60 * borrow; }
        // (Optional) enforce non-negative total time, etc.
    }
};

Why non-member operator+? It provides symmetry and allows conversions on both operands. We implement + using += to keep logic in one place.

Programming Insight (AI) — State Invariants & Tests
  • Ask AI to extract invariants (e.g., 0 ≤ minutes < 60) and generate unit tests for +=/+ (including carry/borrow cases).

3) Making LHS & RHS Equally Flexible (the “friend” angle)

If you implement operator+(Time) as a member, only the right operand can use implicit conversion. To support lhs + rhs where either side might require conversion (e.g., adding int minutes), use a non-member. If it needs private access, declare it as a friend inside the class: narrow, explicit permission.

class Minutes {
public:
    explicit Minutes(int m): m_{m} {}
    int count() const noexcept { return m_; }
private:
    int m_;
};

class Time2 {
public:
    Time2(int h, int m): h_{h}, m_{m} { normalise(); }

    // Allow Time2 + Minutes and Minutes + Time2 symmetrically:
    friend Time2 operator+(Time2 lhs, Minutes rhs) { lhs.m_ += rhs.count(); lhs.normalise(); return lhs; }
    friend Time2 operator+(Minutes lhs, Time2 rhs) { rhs.m_ += lhs.count(); rhs.normalise(); return rhs; }

private:
    int h_{}, m_{};
    void normalise(){ /* as before */ }
};

4) Comparison & Ordering (C++20+) — keep it consistent

Either define equality and ordering manually or use defaults when your representation matches semantics.

class Point {
public:
    Point(int x, int y): x_{x}, y_{y} {}

    // Equality (hidden friend)
    friend bool operator==(const Point& a, const Point& b) = default; // C++20: compares x_, y_

    // Three-way comparison (spaceship) gives <, >, ≤, ≥ for free if meaningful
    friend auto operator<=>(const Point&, const Point&) = default;

private:
    int x_, y_;
};

Rule: If you define ==, ensure it agrees with your ordering and with hashing (if you later provide a hash).


5) Stream Operators — idiomatic non-members (often hidden friends)

#include <iostream>

class Vec2 {
public:
    Vec2(float x, float y): x_{x}, y_{y} {}
    friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
        return os << '(' << v.x_ << ',' << v.y_ << ')';
    }
    friend std::istream& operator>>(std::istream& is, Vec2& v) {
        char ch; return (is >> ch >> v.x_ >> ch >> v.y_ >> ch);
    }
private:
    float x_, y_;
};

Define inside the class for “hidden friend” behaviour so ADL finds it with std::cout << v without polluting namespaces.


6) Arithmetic Family — implement once, reuse everywhere

Implement the compound form first, then derive the pure form.

class BigInt {
public:
    BigInt& operator+=(const BigInt& rhs) { /* ... */ return *this; }
    friend BigInt operator+(BigInt lhs, const BigInt& rhs) { lhs += rhs; return lhs; }

    BigInt& operator-=(const BigInt& rhs) { /* ... */ return *this; }
    friend BigInt operator-(BigInt lhs, const BigInt& rhs) { lhs -= rhs; return lhs; }
};

7) Prefix vs Postfix Increment (form matters)

class Counter {
public:
    Counter& operator++()    { ++n_; return *this; }     // prefix: cheap, returns reference
    Counter   operator++(int) { Counter tmp = *this; ++*this; return tmp; } // postfix: returns old value
private:
    int n_{};
};

Use prefix (++x) when you don’t need the old value; it avoids a copy.


8) Conversions & Surprises — be explicit

class Money {
public:
    explicit Money(long cents): c_{cents} {}
    // explicit operator double() const; // consider carefully; may lose precision
private: long c_;
};

9) Common Pitfalls (and how to avoid them)

  • Breaking intuition: Don’t make + mutate, or == perform fuzzy comparisons unless documented.
  • Overloading the wrong operators: Avoid &&, ||, ,, ::, and ?:. They don’t short-circuit or can’t be overloaded meaningfully.
  • Inconsistent families: If you provide +=, also provide + (via +=). If you provide ==, consider <=> or consistent ordering.
  • Asymmetry & slicing: Member operator+(T) restricts LHS conversions; prefer non-member. Don’t store polymorphic types by value.
  • Const-correctness: Read-only operators on logically-const objects should be const (e.g., operator[]() const variant).
  • Leaking invariants: Keep representation private; use hidden friends or getters. Test the invariants after each operator.
Programming Insight (AI) — Operator Lint
  • Ask AI to check for broken semantics (mutating +, missing const, asymmetric conversions, missing virtual dtor in streamed polymorphic types).

10) Mini Exercise — Triangle (friend + symmetry)

Starting point: your note suggested:

class Triangle {
public:
    // ...
    friend Triangle operator+(Triangle a, Triangle b); // combine edges/angles with proper normalisation
private:
    // private measures here
};

Task: Implement += (normalise perimeter/angles), then implement operator+ via +=. Add == and a hidden friend operator<<. Explain why non-member friends are appropriate (symmetry + private access).


Summary Checklist

  • Overload only when behaviour matches the operator’s conventional meaning and improves clarity.
  • Prefer non-member binary operators for symmetry; make them friends only if private access is required.
  • Implement compound forms (+=, -=, …) first; derive pure forms (+, -) from them.
  • Keep invariants intact (e.g., normalise minutes to [0,60)); test carry/borrow and edge cases.
  • Use hidden friends for streaming and comparators; consider C++20 = default for ==/<=> where valid.
  • Be explicit with conversions to avoid surprises; ensure const-correctness.
  • Avoid overloading operators with misleading semantics or broken short-circuit expectations.

Advanced perspective: familiar syntax borrows expectations from the language

An operator overload is successful only when its compact spelling tells the truth

Operator overloading lets a user-defined type participate in familiar C++ expressions. It does not let the type redesign the grammar. Precedence, associativity and the number of operands are fixed, new operator symbols cannot be invented, and at least one operand must involve a user-defined type. The compiler checks whether an overload is legal and selectable. It does not decide whether left + right has an honest meaning in the domain.

The compact syntax therefore carries a debt. A reader expects + to produce a value without unexpectedly modifying its operands, += to modify its left operand, == to express a coherent equality relation, and stream extraction to report failure without leaving a half-read object. If the type needs a paragraph of exceptions every time an operator appears, a named function may be the clearer interface.

Operator claimExpectation borrowed by the syntaxQuestion the type must answer
a + bProduces a result while leaving ordinary value operands unchanged.What domain value is the sum, and is it closed over the type?
a += bUpdates a and supports conventional chaining.Does every valid update preserve the invariant?
a == bReports a stable notion of value equality.Which state contributes to equality, and is the relation coherent?
stream >> valueParses one representation and communicates failure through the stream.Can invalid or incomplete input leave value unchanged?

Implement the mutating rule once, then derive the value operation

#include <iostream>

class Time {
public:
    explicit Time(int minutes) : minutes_{minutes} {}

    Time& operator+=(const Time& other) {
        minutes_ += other.minutes_;
        return *this;
    }

    int minutes() const {
        return minutes_;
    }

    friend Time operator+(Time left, const Time& right) {
        left += right;
        return left;
    }

private:
    int minutes_;
};

int main() {
    const Time first{40};
    const Time second{35};
    const Time total{first + second};
    std::cout << total.minutes() << '\n';
}

operator+= modifies its left object and returns that same object by reference. The return supports conventional expressions such as (a += b) += c without creating a replacement Time. The operation's one state rule is visible in one place.

operator+ is a non-member friend whose left operand is taken by value. That parameter is a copy of first. The function applies the established compound operation to the copy and returns the resulting value. Consequently, first remains 40, second remains 35, and total receives 75. The implementation relationship mirrors the semantic relationship: addition behaves like a non-mutating value form of compound addition.

StagefirstsecondLocal lefttotal
Before operator+4035Not yet created.Not yet initialised.
On entry4035Copy holding 40.Not yet initialised.
After left += right403575.Not yet initialised.
After return4035Its returned value has been used.75.

The pattern is useful where += and + genuinely share one rule. It is not a quota that every type must satisfy. If the compound operation has different validity or performance requirements, state those differences rather than forcing code reuse to dictate semantics.

Decide what the type represents before deciding what the symbol means

The name Time is deliberately ambiguous. If it represents a duration measured in minutes, adding two values can make sense and 75 is a straightforward result. If it represents a time of day, adding two clock readings is not an obvious operation. A time of day plus a duration may be meaningful, perhaps with wraparound or a date transition, but that is a different type relationship.

This distinction changes the interface. One undifferentiated Time + Time overload can hide whether the operands are durations, points on a timeline or clock readings. Stronger domain types can prevent nonsense before an operator body runs. Syntax should follow the model. It should not be used to avoid choosing one.

Possible modelPotentially meaningful operationQuestion that must be settled
DurationDuration plus duration.Range, overflow and unit policy.
Time of dayTime of day plus duration.Wraparound, date context and invalid values.
Time pointTime point minus time point gives duration.Clock, epoch and precision.
Time point plus time pointNo generally obvious value operation.Whether a named domain operation exists at all.

Member, non-member and friend answer separate questions

Some operators must be members, including assignment, subscript, function call and member access through ->. For a symmetric binary operation such as value addition or equality, a non-member often treats both operands more evenly and allows relevant implicit conversions to be considered on either side. A member binary operator fixes the left operand as the object on which the member is invoked.

Non-member does not automatically mean friend. If public operations can implement the operator without weakening clarity or efficiency, an ordinary non-member preserves representation independence. Friendship is justified only when the non-member relationship is right and narrow private access is genuinely necessary. These are two decisions, not one combined slogan.

Equality and ordering must describe the domain, not merely the member list

A useful equality relation is expected to be reflexive, symmetric and transitive. If a == b, code commonly expects them to be interchangeable for the observations that define value identity. Approximate floating-point comparison can be useful for a named numerical test, but embedding a context-dependent tolerance in operator== can break transitivity and make containers or algorithms behave unexpectedly.

Defaulted equality and three-way comparison can remove repetitive member-wise code when member order and comparison already express the intended value semantics. They compare the relevant subobjects in their language-defined order and may be unavailable when those subobjects cannot supply the required comparisons. They do not decide whether a cache, identifier, timestamp or spelling variation belongs to value identity.

PropertyQuestion for the typeFailure example
EqualityWhich observations make two values interchangeable?Including a cache makes equal domain values compare unequal.
OrderingIs the relation total, weak or partial for the domain?Inventing an arbitrary order merely because sorting syntax is available.
ConsistencyDoes equality agree with ordering and any hash definition?Two equal values produce different hashes or distinct ordering positions.

Input should commit only after the representation is complete

A stream insertion operator conventionally returns the stream by reference, allowing chained output and preserving the stream's accumulated state. It may be an ordinary non-member using public observations or a hidden friend when narrow representation access is justified.

Extraction is more dangerous because it mutates. Read components and delimiter characters into local temporary values, validate the entire representation, and assign to the target only after success. If the second coordinate or closing delimiter is absent, the stream should report failure and the object should retain its previous invariant-satisfying value. Directly extracting into members can leave the first member changed before a later read fails.

This is a transaction in miniature: parse, validate, then commit. The same pattern applies beyond streams whenever one logical update depends on several fallible steps.

Legal syntax can still remove behaviour readers depend upon

The operators ., .*, :: and ?: cannot be overloaded. Other operators are overloadable but lose special built-in behaviour. User-defined && and || do not provide the built-in short-circuit guarantee; both operands participate in the function-call evaluation needed for the overload. Code that expects the right operand to be skipped can therefore become invalid or expensive.

Overloading the comma operator, address-of or other low-level-looking syntax may likewise make ordinary reading unreliable. A domain can occasionally justify unusual notation, especially in a carefully constrained library. The burden of proof is high because the operator's spelling already tells experienced readers a story.

Conversions alter overload selection before the chosen function begins

A single-argument constructor or conversion operator can make additional overloads viable. That convenience can also make an unrelated expression compile by silently creating a temporary domain value. Mark conversions explicit when automatic conversion would hide cost, lose information or make operator selection surprising.

An explicit operator bool can still participate in the language's contextual Boolean uses, such as an if condition, while resisting broad arithmetic conversion. The decision is not "explicit is always safer". The decision is which implicit interpretations belong to the type's contract and which should require visible intent.

Corrections to the earlier operator examples: the preserved Vec2 extraction reads arbitrary delimiter characters and writes directly into members before the complete input has been validated. A robust form reads coordinates and separators into local temporaries, checks the full syntax, and commits to Vec2 only on success. The Triangle exercise also assumes that Triangle + Triangle has an obvious domain meaning. It does not. Combining vertices, edge lengths, areas or transformations would describe different operations, some of which may not produce a valid triangle. Treat the task as an interface challenge, not an instruction to invent arithmetic for the sake of using friend.

Programming Insight (AI): require a semantic table before requesting signatures

Ask an AI system to state what each operand represents, whether either operand changes, the result type, invalid-input policy, invariant and algebraic properties. Generate boundary cases that challenge those claims. Only then ask for member, non-member or friend signatures and for one operation to be implemented in terms of another.

Check overload resolution separately. Which conversions are considered on the left and right? Does a defaulted comparison include every intended identity field and exclude incidental state? Does an overloaded logical operator lose short-circuiting? A compiling overload family is evidence that the syntax is legal, not that the family is coherent.

Transfer task: decide whether the symbol deserves the behaviour

Design a Percentage type whose valid values lie from 0 to 100. A proposal defines a + b as a saturating sum capped at 100, a += b with the same rule, approximate equality within one percentage point, and implicit conversion to int. For each choice, list the expectation a reader brings from the operator and the place where the proposal meets or breaks it.

Produce two designs. One may retain operators, but it must state the invariant, mutation rules, equality relation and conversion policy precisely. The other should replace misleading syntax with names such as capped_add or is_within. Add a stream extraction plan that leaves the target unchanged for 101, missing digits or trailing invalid syntax. Choose between the designs by clarity of the resulting call sites, not by which one demonstrates more overloads.

Reveal answer

The proposed operators borrow expectations which are not equally defensible:

ChoiceOrdinary expectationAssessment
a + bProduces a new sum without changing either operand.It preserves non-mutation and is closed over the type, but 60 + 60 == 100 silently discards part of the mathematical sum. Saturation must be an explicit type policy, not an accidental implementation detail.
a += bChanges a consistently with a = a + b.It meets that relationship if it uses exactly the same cap. The hidden saturation remains the question.
Approximate ==Defines an equivalence relation suitable for ordinary equality reasoning.It fails. With a one-point tolerance, 0 equals 1 and 1 equals 2, while 0 does not equal 2. Equality is no longer transitive.
Implicit intPermits an unsurprising, meaning-preserving interpretation wherever an integer is accepted.The numeric value is exact, but the conversion leaks the domain into unrelated arithmetic and changes overload selection. A named query or explicit conversion is clearer.

Design one: operators are part of a stated saturating policy

#include <algorithm>
#include <stdexcept>

class Percentage {
public:
    explicit Percentage(int value) : value_{value}
    {
        if (value < 0 || value > 100) {
            throw std::out_of_range{"percentage must be in [0, 100]"};
        }
    }

    Percentage& operator+=(Percentage other) noexcept
    {
        value_ = std::min(100, value_ + other.value_);
        return *this;
    }

    [[nodiscard]] int value() const noexcept { return value_; }
    explicit operator int() const noexcept { return value_; }

    bool operator==(const Percentage&) const = default;

private:
    int value_ = 0;
};

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

The invariant is always 0 <= value && value <= 100. Addition never mutates its operands; compound addition mutates only the left operand; both saturate at 100. Equality is exact equality of the represented percentage, and integer conversion requires visible intent. This design is coherent, although a name such as SaturatingPercentage would make its unusual arithmetic policy easier to discover.

Design two: name the policy that changes the arithmetic

Keep checked construction, value(), exact operator== and no implicit conversion. Remove operator+ and operator+=. Provide capped_add(left, right) for saturation and is_within(left, right, tolerance) for a tolerance test. The calls then state the two facts which the original operators concealed:

const Percentage combined = capped_add(tax, surcharge);

if (is_within(measured, expected, 1)) {
    // close enough for this particular decision
}

is_within is a predicate for one decision, not a replacement equality relation. Its tolerance must be non-negative, and the subtraction should be performed in a type which cannot overflow for the permitted range.

Extraction: parse, validate, then commit

Suppose the accepted token is one or more decimal digits with an optional final percent sign. Read one complete token, remove only that permitted final sign, require every remaining character to form the integer, validate the range, and assign only then:

#include <charconv>
#include <ios>
#include <istream>
#include <string>
#include <system_error>

std::istream& operator>>(std::istream& in, Percentage& target)
{
    std::string token;
    if (!(in >> token)) {
        return in;
    }

    if (token.ends_with('%')) {
        token.pop_back();
    }

    int candidate = 0;
    const char* first = token.data();
    const char* last = first + token.size();
    const auto [next, error] = std::from_chars(first, last, candidate);

    if (token.empty()
        || token.find_first_not_of("0123456789") != std::string::npos
        || error != std::errc{}
        || next != last
        || candidate < 0
        || candidate > 100) {
        in.setstate(std::ios::failbit);
        return in;
    }

    target = Percentage{candidate};
    return in;
}

101, % and 40%x all set failbit and leave the previous target untouched. I would select the named-operation design. capped_add(a, b) makes loss visible, while is_within(a, b, 1) prevents a local tolerance rule from pretending to be equality for the whole type.

An operator overload succeeds when a reader can transfer ordinary C++ expectations to the user-defined type and remain correct. Compact syntax is valuable after the semantics are settled. Before that, it merely makes uncertainty shorter.