C++ Module 7 – Classes, objects and RAII
C++ operator overloading and the spaceship operator
Give C++ types natural operators: += as a member and + as a non-member, all six comparisons from <=>, subscripts, calls and bool tests that stay safe.
What you will learn
- Implement arithmetic, comparison and subscript operators for your own types
- Write += as a member and + as a non-member so that either operand can convert
- Generate comparisons with a defaulted operator<=>, and write one when member order is wrong
- Recognise the operators that should not be overloaded
Before you start
What you’ll learn · Before you start
What you will learn
- Implement arithmetic, comparison and subscript operators for your own types
- Write += as a member and + as a non-member so that either operand can convert
- Generate comparisons with a defaulted operator<=>, and write one when member order is wrong
- Recognise the operators that should not be overloaded
Before you start
On this page
Operator overloading lets your own types use C++’s operators. When an operand of a + b has a class type, the
compiler turns the expression into a call of a function named operator+, which you write like any other function.
A money type can then be added with +, a 2D vector scaled with * and a grid indexed with [], so the code reads
like the arithmetic it does. The spaceship operator, <=>, added in C++20, is a three-way comparison: one call
says whether the first value is less than, equal to or greater than the second. Declare it with = default and the
compiler writes all six comparisons, ==, !=, <, <=, > and >=, comparing the members one by one.
This lesson writes the arithmetic of a money type and of a 2D vector, shows why += is usually a member and + a
non-member, compares fractions correctly with a hand-written <=>, adds subscript, call and bool operators, and
ends with the operators that are better left alone. Every example runs with Apple clang 21 and GCC 16.
An operator is a function with a special name
Money below keeps an amount as a whole number of paise, the hundredth part of a rupee, so that adding amounts never
rounds. It defines += and -= as members, because they change the object on their left: each updates the amount
and returns *this by reference, as += on an int returns the variable it changed. The binary + and - make a
new value from two existing ones, so they are ordinary functions outside the class. Each takes its left operand by
value, applies the compound operator to that copy and returns it, which keeps the arithmetic in one place:
// Money as a whole number of paise (100 paise make a rupee), so adding amounts never rounds.
#include <compare>
#include <cstdint>
#include <format>
#include <print>
#include <string>
class Money {
public:
explicit Money(std::int64_t paise) : paise_(paise) {}
std::int64_t paise() const { return paise_; }
// Compound assignment changes the object itself: a member that returns *this.
Money& operator+=(Money other) {
paise_ += other.paise_;
return *this;
}
Money& operator-=(Money other) {
paise_ -= other.paise_;
return *this;
}
// Unary minus makes a new value and leaves the object alone.
Money operator-() const { return Money(-paise_); }
// One line gives ==, !=, <, <=, > and >=, comparing the only member.
auto operator<=>(const Money&) const = default;
private:
std::int64_t paise_;
};
// The binary operators are non-members, written with the compound ones.
Money operator+(Money a, Money b) { return a += b; }
Money operator-(Money a, Money b) { return a -= b; }
std::string text(Money m) {
std::int64_t p = m.paise() < 0 ? -m.paise() : m.paise();
return std::format("{}{}.{:02}", m.paise() < 0 ? "-" : "", p / 100, p % 100);
}
int main() {
Money fare(4550); // 45.50
Money snack(1275); // 12.75
Money budget(5000); // 50.00
Money spent = fare + snack;
std::println("spent {}", text(spent));
std::println("left {}", text(budget - spent));
std::println("over budget? {}", spent > budget);
spent += -snack; // a refund
std::println("after the refund {}, the same as the fare? {}", text(spent), spent == fare);
// a + b calls the function operator+, which you may also call by name.
std::println("by name {}", text(operator+(fare, snack)));
} from session import run
# money.cpp built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -Wall -Wextra money.cpp -o money && ./money
g++-16 -std=c++23 -Wall -Wextra money.cpp -o money-gcc && ./money-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra money.cpp -o money && ./money spent 58.25 left -8.25 over budget? true after the refund 45.50, the same as the fare? true by name 58.25 $ g++-16 -std=c++23 -Wall -Wextra money.cpp -o money-gcc && ./money-gcc spent 58.25 left -8.25 over budget? true after the refund 45.50, the same as the fare? true by name 58.25
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 money_session.py
The last line calls operator+(fare, snack) by its name and gets the same 58.25 as fare + snack: the operator is
only a shorter way to write that call. Unary minus, -snack, is a separate operator with one operand, written here as
a const member that returns a new Money and leaves the original alone.
Overloading follows a few fixed rules of the standard:
- At least one operand must have a class or enumeration type. You cannot change what
+does for twoints. - You cannot invent operators, and an overload keeps the built-in operator’s precedence, grouping and number of
operands, so
a + b * cstill multiplies first. .,.*,::and?:cannot be overloaded at all, and=,(),[]and->must be members.
The C++ Core Guidelines add the rule that matters most (C.167): give an operator its conventional meaning. + adds,
== tests equality, [] reaches an element. An operator that does something surprising is worse than a function
with a clear name.
Member or non-member: why it matters
For a member operator, the left operand is the object the function is called on, and the compiler never converts it
to make that call work. A non-member operator has two ordinary parameters, so either operand may convert. The
difference shows up as soon as a type can be built from another one. Fraction has a constructor that accepts a
single whole number, deliberately not explicit, because every integer is a fraction:
// A member operator+ lets only its right operand convert.
#include <print>
struct Fraction {
long long num;
long long den;
Fraction(long long n, long long d = 1) : num(n), den(d) {} // not explicit: 3 is the fraction 3/1
Fraction operator+(const Fraction& b) const { return Fraction(num * b.den + b.num * den, den * b.den); }
};
int main() {
Fraction half(1, 2);
Fraction a = half + 1; // half.operator+(Fraction(1))
Fraction b = 1 + half; // 1 is an int, and an int has no member functions
std::println("{}/{} {}/{}", a.num, a.den, b.num, b.den);
} // The same operator as a non-member: either operand may convert.
#include <print>
struct Fraction {
long long num;
long long den;
Fraction(long long n, long long d = 1) : num(n), den(d) {}
Fraction& operator+=(const Fraction& b) {
num = num * b.den + b.num * den;
den = den * b.den;
return *this;
}
};
Fraction operator+(Fraction a, const Fraction& b) { return a += b; }
int main() {
Fraction half(1, 2);
Fraction a = half + 1; // operator+(half, Fraction(1))
Fraction b = 1 + half; // operator+(Fraction(1), half)
std::println("{}/{} {}/{}", a.num, a.den, b.num, b.den);
} from session import run
# The member operator+ first, which both compilers reject, then the non-member version.
run("""
clang++ -std=c++23 -Wall -Wextra fraction_member.cpp -o fraction_member
g++-16 -std=c++23 -Wall -Wextra fraction_member.cpp -o fraction_member-gcc
clang++ -std=c++23 -Wall -Wextra fraction_free.cpp -o fraction_free && ./fraction_free
g++-16 -std=c++23 -Wall -Wextra fraction_free.cpp -o fraction_free-gcc && ./fraction_free-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra fraction_member.cpp -o fraction_member
fraction_member.cpp:15:20: error: invalid operands to binary expression ('int' and 'Fraction')
15 | Fraction b = 1 + half; // 1 is an int, and an int has no member functions
| ~ ^ ~~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra fraction_member.cpp -o fraction_member-gcc
fraction_member.cpp: In function 'int main()':
fraction_member.cpp:15:20: error: no match for 'operator+' (operand types are 'int' and 'Fraction')
15 | Fraction b = 1 + half; // 1 is an int, and an int has no member functions
| ~ ^ ~~~~
| | |
| int Fraction
[exit status 1]
$ clang++ -std=c++23 -Wall -Wextra fraction_free.cpp -o fraction_free && ./fraction_free
3/2 3/2
$ g++-16 -std=c++23 -Wall -Wextra fraction_free.cpp -o fraction_free-gcc && ./fraction_free-gcc
3/2 3/2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 member_session.py
half + 1 works with the member, because 1 is the argument and converts to Fraction(1). 1 + half fails with
both compilers: 1 is an int, which has no member functions, and nothing makes the compiler try Fraction(1) as
the object. The non-member operator+(Fraction a, const Fraction& b) treats both sides alike, so both lines give 3/2.
That is the reason behind guideline C.161: write symmetric binary operators such as +, -, * and / as
non-members, and keep the compound assignments, which change their left operand, as members.
Where the compiler looks for the function behind an operator
Text description of the diagram
The diagram shows how C++ finds the function to call for an expression such as x + y or x < y when x or y has a class type.
- All candidates go into one set. There are four kinds.
- Member candidates come from the class of the left operand: x.operator+(y). The left operand x is the object the member is called on, so it is never converted; only y may be.
- Non-member candidates are functions named operator+ found by ordinary lookup and by argument-dependent lookup (ADL) in the namespaces and classes of the operands. Both operands may be converted to the parameter types.
- Built-in candidates are the operators of the fundamental types, such as int + int.
- For comparisons only, C++20 adds rewritten candidates: x < y may also be evaluated as (x <=> y) < 0, or with the operands reversed as 0 < (y <=> x). In the same way, x != y may become !(x == y), and x == y may be tried as y == x.
- Overload resolution then picks the best match from the whole set. If no candidate is viable, or two are equally good, the code does not compile.
Money makes the opposite choice on purpose. Its constructor is explicit, so fare + 100 does not compile: a bare
number might mean rupees or paise, and the compiler should not guess. Allow an implicit conversion only when it can
never surprise anyone, as from an integer to a fraction (guideline C.164). A non-member operator that needs the
private members of its class can be declared as a friend inside it, which a later lesson of this module explains.
Arithmetic for a 2D vector
A vector type is the classic case for overloading: physics and graphics code reads much better as
position + velocity * dt than as nested function calls. Vec2 defines addition, subtraction, negation and
multiplication by a number, with a non-member * for each order of the operands, since 2 * v and v * 2 should
both work:
// A 2D vector: arithmetic that reads like the maths, and == but no ordering.
#include <format>
#include <print>
#include <string>
struct Vec2 {
double x = 0;
double y = 0;
Vec2& operator+=(Vec2 o) { x += o.x; y += o.y; return *this; }
Vec2& operator-=(Vec2 o) { x -= o.x; y -= o.y; return *this; }
Vec2& operator*=(double k) { x *= k; y *= k; return *this; }
Vec2 operator-() const { return {-x, -y}; }
bool operator==(const Vec2&) const = default; // equal when both coordinates are equal
};
Vec2 operator+(Vec2 a, Vec2 b) { return a += b; }
Vec2 operator-(Vec2 a, Vec2 b) { return a -= b; }
Vec2 operator*(Vec2 v, double k) { return v *= k; }
Vec2 operator*(double k, Vec2 v) { return v *= k; } // so that 2 * v works as well as v * 2
std::string text(Vec2 v) { return std::format("({}, {})", v.x, v.y); }
int main() {
Vec2 position{1, 2};
Vec2 velocity{0.5, -1};
double dt = 2;
Vec2 next = position + velocity * dt; // * before +, as with numbers
std::println("next {}", text(next));
std::println("back {}", text(next - dt * velocity));
std::println("-velocity {}", text(-velocity));
std::println("back where it started? {}", next - dt * velocity == position);
} from session import run
# vec2.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra vec2.cpp -o vec2 && ./vec2
g++-16 -std=c++23 -Wall -Wextra vec2.cpp -o vec2-gcc && ./vec2-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra vec2.cpp -o vec2 && ./vec2 next (2, 0) back (1, 2) -velocity (-0.5, 1) back where it started? true $ g++-16 -std=c++23 -Wall -Wextra vec2.cpp -o vec2-gcc && ./vec2-gcc next (2, 0) back (1, 2) -velocity (-0.5, 1) back where it started? true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vec2_session.py
position + velocity * dt multiplies before it adds, exactly as with numbers, because an overload keeps the
precedence of the built-in operator. Vec2 defaults operator==, which compares x with x and y with y. It
declares no <, though, because one vector is not “smaller” than another in any useful sense. Give a type only the
operators that mean something for it. If you later need to sort vectors, say by length, pass that comparison to the
sort instead of inventing an order for the type.
Comparing doubles with == is exact, which suits this example only because 0.5 and 2 are exact in binary. Values
produced by longer calculations usually differ in the last bits, so physics code compares them with a tolerance
instead.
Three-way comparison with <=>
a <=> b returns an object that you compare with zero: (a <=> b) < 0 means that a is less than b, == 0 that
they are equal and > 0 that a is greater. Its type, from the header <compare>, says what kind of order the
values have:
std::strong_ordering: values that compare equal are the same value and can replace each other anywhere, and every pair is ordered.int,std::stringandMoneycompare this way, and most types should.std::weak_ordering: equal means equivalent, so two different values may compare equal, such as “Asha” and “ASHA” when names are compared without regard to case. Every pair is still ordered.std::partial_ordering: some pairs are not ordered at all, and the result isunordered.doublecompares this way, because NaN, the “not a number” value, is neither less than, equal to nor greater than any number.
A defaulted <=> compares the base classes first and then the members, in the order they are declared, and stops at
the first difference. Declared with auto, its return type is the weakest category among the members, so a single
double member turns it into std::partial_ordering. For Money, with its one integer member, member order is
exactly right. For a fraction it is wrong, because 1/2 and 2/4 are the same number with different members:
// Two fraction types: one compares its members, the other compares the values they stand for.
#include <algorithm>
#include <compare>
#include <print>
#include <vector>
struct ByMembers {
long long num;
long long den;
auto operator<=>(const ByMembers&) const = default; // num first, then den
};
struct Fraction {
long long num;
long long den; // kept positive, so cross-multiplying keeps the order
Fraction(long long n, long long d = 1) : num(d < 0 ? -n : n), den(d < 0 ? -d : d) {}
// a/b < c/d exactly when a*d < c*b, because b and d are positive.
std::strong_ordering operator<=>(const Fraction& o) const { return num * o.den <=> o.num * den; }
// A hand-written <=> brings no ==, so equality is written too.
bool operator==(const Fraction& o) const { return num * o.den == o.num * den; }
};
int main() {
ByMembers x{1, 2}, y{2, 4};
std::println("by members: 1/2 == 2/4 is {}, 1/2 < 2/4 is {}", x == y, x < y);
Fraction half(1, 2), two_quarters(2, 4);
std::println("by value: 1/2 == 2/4 is {}, 1/2 < 2/4 is {}", half == two_quarters, half < two_quarters);
// The member <=> also serves an int on the left: 1 > half is rewritten as 0 > (half <=> 1).
std::println("half < 1 is {}, 1 > half is {}, 2 == Fraction(4, 2) is {}", half < 1, 1 > half, 2 == Fraction(4, 2));
std::vector<Fraction> v{{3, 4}, {1, 2}, {5, -3}, {2, 4}, {1, 3}};
std::ranges::stable_sort(v); // stable: 1/2 and 2/4 are equal, so they keep their order
for (const Fraction& f : v) std::print("{}/{} ", f.num, f.den);
std::println("");
} // Only <=> is written by hand, so == does not exist.
#include <compare>
struct Fraction {
long long num;
long long den;
std::strong_ordering operator<=>(const Fraction& o) const { return num * o.den <=> o.num * den; }
};
int main() {
Fraction a{1, 2}, b{2, 4};
bool less = a < b; // fine: rewritten as (a <=> b) < 0
bool same = a == b; // == is never rewritten into a call of <=>
return less || same;
} from session import run
# The two fraction types compared, then a Fraction with <=> but no ==.
run("""
clang++ -std=c++23 -Wall -Wextra fraction_compare.cpp -o fraction_compare && ./fraction_compare
g++-16 -std=c++23 -Wall -Wextra fraction_compare.cpp -o fraction_compare-gcc && ./fraction_compare-gcc
clang++ -std=c++23 -fsyntax-only fraction_no_eq.cpp
g++-16 -std=c++23 -fsyntax-only fraction_no_eq.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra fraction_compare.cpp -o fraction_compare && ./fraction_compare
by members: 1/2 == 2/4 is false, 1/2 < 2/4 is true
by value: 1/2 == 2/4 is true, 1/2 < 2/4 is false
half < 1 is true, 1 > half is true, 2 == Fraction(4, 2) is true
-5/3 1/3 1/2 2/4 3/4
$ g++-16 -std=c++23 -Wall -Wextra fraction_compare.cpp -o fraction_compare-gcc && ./fraction_compare-gcc
by members: 1/2 == 2/4 is false, 1/2 < 2/4 is true
by value: 1/2 == 2/4 is true, 1/2 < 2/4 is false
half < 1 is true, 1 > half is true, 2 == Fraction(4, 2) is true
-5/3 1/3 1/2 2/4 3/4
$ clang++ -std=c++23 -fsyntax-only fraction_no_eq.cpp
fraction_no_eq.cpp:13:19: error: invalid operands to binary expression ('Fraction' and 'Fraction')
13 | bool same = a == b; // == is never rewritten into a call of <=>
| ~ ^ ~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -fsyntax-only fraction_no_eq.cpp
fraction_no_eq.cpp: In function 'int main()':
fraction_no_eq.cpp:13:19: error: no match for 'operator==' (operand types are 'Fraction' and 'Fraction')
13 | bool same = a == b; // == is never rewritten into a call of <=>
| ~ ^~ ~
| | |
| | Fraction
| Fraction
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare_session.py
ByMembers compares numerators first, so it claims that 1/2 is less than 2/4 and that they differ. Fraction
compares values: with both denominators kept positive, a/b is less than c/d exactly when a * d is less than
c * b, and long long <=> long long gives a std::strong_ordering. The sorted list puts -5/3 first, and 1/2 and 2/4
side by side in their original order, because stable_sort keeps equal elements as it found them.
Two rules of C++20 explain the rest of the run:
- A hand-written
<=>brings no==. Only a defaulted<=>declares a defaulted==as well. When you write the comparison yourself, the compiler cannot know whether member-by-member equality is right, soa == bdoes not compile until you writeoperator==too, asfraction_no_eq.cppshows. The compiler also never computes==through<=>, because equality is often cheaper to decide: two strings of different lengths can never be equal, whatever their characters (paper P1185R2). - Comparisons are rewritten, also with the operands reversed. For
x < ythe compiler tries(x <=> y) < 0and also0 < (y <=> x). That is how1 > halfworks with a member<=>, as0 > (half <=> 1), and how2 == Fraction(4, 2)findsFraction(4, 2) == 2.x != ybecomes!(x == y). Unlike+, comparison members therefore work with a converted operand on either side.
Cross-multiplying has one limit: a * d overflows a long long once the numerators and denominators pass about three
billion. Keeping fractions in lowest terms delays that, and the exercise at the end of this lesson builds a number
type that never overflows.
Subscript, call and bool operators
Three more operators make a type feel built in. operator[] reaches an element, operator() makes an object callable
like a function, and explicit operator bool lets an object answer “did this work?” in an if:
// Subscript, call and bool-conversion operators.
#include <algorithm>
#include <optional>
#include <print>
#include <string>
#include <vector>
class Grid {
public:
Grid(int rows, int cols) : cols_(cols), cells_(rows * cols, '.') {}
// C++23: a subscript operator may take two indices.
char& operator[](int r, int c) { return cells_[r * cols_ + c]; }
char operator[](int r, int c) const { return cells_[r * cols_ + c]; }
int rows() const { return static_cast<int>(cells_.size()) / cols_; }
int cols() const { return cols_; }
private:
int cols_;
std::vector<char> cells_;
};
// A function object: it keeps its numbers and is called like a function.
struct Linear {
double a, b;
double operator()(double x) const { return a * x + b; }
};
// explicit operator bool: usable in if and while, never as a number.
class Parsed {
public:
explicit Parsed(std::optional<int> v) : v_(v) {}
explicit operator bool() const { return v_.has_value(); }
int value() const { return *v_; }
private:
std::optional<int> v_;
};
Parsed parse_marks(const std::string& s) {
bool digits = !s.empty() && s.size() <= 3 && std::ranges::all_of(s, [](char ch) { return ch >= '0' && ch <= '9'; });
if (!digits || std::stoi(s) > 100) return Parsed(std::nullopt);
return Parsed(std::stoi(s));
}
int main() {
Grid g(3, 4);
g[1, 2] = '#';
g[0, 0] = '@';
for (int r = 0; r < g.rows(); ++r) {
for (int c = 0; c < g.cols(); ++c) std::print("{}", g[r, c]);
std::println("");
}
Linear to_fahrenheit{1.8, 32};
std::vector<double> temps{-40, 0, 25, 100};
std::ranges::transform(temps, temps.begin(), to_fahrenheit);
std::println("{} {} {} {}", temps[0], temps[1], temps[2], temps[3]);
for (std::string s : {"91", "abc", "120"}) {
if (Parsed p = parse_marks(s)) std::println("{} -> {}", s, p.value());
else std::println("{} -> not a mark", s);
}
} // explicit operator bool allows tests, but not arithmetic or a plain bool initialisation.
#include <optional>
class Parsed {
public:
explicit Parsed(std::optional<int> v) : v_(v) {}
explicit operator bool() const { return v_.has_value(); }
private:
std::optional<int> v_;
};
int main() {
Parsed p(std::optional<int>(91));
if (p) {} // fine: a condition converts explicitly
bool b = !p; // fine: so does the operand of !
int total = p + 1; // error: no implicit conversion to a number
return b + total;
} from session import run
# grid_call_bool.cpp with both compilers, then the first error each one reports for bool_misuse.cpp.
run("""
clang++ -std=c++23 -Wall -Wextra grid_call_bool.cpp -o grid && ./grid
g++-16 -std=c++23 -Wall -Wextra grid_call_bool.cpp -o grid-gcc && ./grid-gcc
clang++ -std=c++23 -fsyntax-only bool_misuse.cpp 2>&1 | grep -m1 error:
g++-16 -std=c++23 -fsyntax-only bool_misuse.cpp 2>&1 | grep -m1 error:
""") Output
$ clang++ -std=c++23 -Wall -Wextra grid_call_bool.cpp -o grid && ./grid
@...
..#.
....
-40 32 77 212
91 -> 91
abc -> not a mark
120 -> not a mark
$ g++-16 -std=c++23 -Wall -Wextra grid_call_bool.cpp -o grid-gcc && ./grid-gcc
@...
..#.
....
-40 32 77 212
91 -> 91
abc -> not a mark
120 -> not a mark
$ clang++ -std=c++23 -fsyntax-only bool_misuse.cpp 2>&1 | grep -m1 error:
bool_misuse.cpp:17:19: error: invalid operands to binary expression ('Parsed' and 'int')
$ g++-16 -std=c++23 -fsyntax-only bool_misuse.cpp 2>&1 | grep -m1 error:
bool_misuse.cpp:17:19: error: no match for 'operator+' (operand types are 'Parsed' and 'int')
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 grid_session.py
operator[]comes in two versions: the non-constone returnschar&, sog[1, 2] = '#'stores a character in the grid, and theconstone returns a copy for reading from aconst Grid. Since C++23 it may take several indices (paper P2128R6, in GCC 12 and Clang 15 onwards). Before that, two-dimensional types wroteg(r, c)or returned a helper object fromg[r].operator()turnsLinearinto a function object: it carries its own numbers, 1.8 and 32, andstd::ranges::transformcalls it for each temperature as if it were a function, turning -40, 0, 25 and 100 degrees Celsius into Fahrenheit.explicit operator boolconverts only where C++ expects a condition: inif,while,for, after!, and as an operand of&&,||and?:. That is whyif (Parsed p = parse_marks(s))works whilep + 1does not compile. Withoutexplicit,p + 1would quietly add 1 totrueand give 2.std::optional,std::unique_ptrandstd::functionall use this pattern.
Operators to leave alone
The built-in && and || short-circuit: && evaluates its right operand only when the left one is true, which
is what makes p != nullptr && p->ready() safe. An overloaded && is an ordinary function call, and C++ evaluates
every argument before it calls a function, so both operands always run:
// The built-in && skips its right operand when the left one is false; an overloaded && cannot.
#include <print>
struct Check {
bool ok;
};
Check report(const char* name, bool ok) {
std::println(" evaluated {}", name);
return Check{ok};
}
bool operator&&(Check a, Check b) { return a.ok && b.ok; } // overloaded only to show what changes
int main() {
std::println("built-in && on bool:");
bool r1 = report("left", false).ok && report("right", true).ok;
std::println(" result {}", r1);
std::println("overloaded && on Check:");
bool r2 = report("left", false) && report("right", true);
std::println(" result {}", r2);
} from session import run
# and_or.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra and_or.cpp -o and_or && ./and_or
g++-16 -std=c++23 -Wall -Wextra and_or.cpp -o and_or-gcc && ./and_or-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra and_or.cpp -o and_or && ./and_or built-in && on bool: evaluated left result false overloaded && on Check: evaluated left evaluated right result false $ g++-16 -std=c++23 -Wall -Wextra and_or.cpp -o and_or-gcc && ./and_or-gcc built-in && on bool: evaluated left result false overloaded && on Check: evaluated left evaluated right result false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 and_or_session.py
With bools, report("right", true) never runs. With Check, it does, although the result is already known to be
false. Since C++17 the operands of an overloaded operator are at least evaluated in the order of the built-in one,
left before right, but nothing restores the skipping. A reader who sees a && b will assume it skips, so an overload
quietly breaks every guard written in that style. The same goes for ||.
Two more operators deserve the same caution. An overloaded comma operator changes the meaning of expressions such as
++i, ++j in a loop header, which nobody reads as a function call, and a unary & overload stops &x from giving
the address of x, which is why the library has std::addressof and why guideline C.166 allows it only inside smart
pointer designs. If a type needs a logical “and” or “then”, give it a function with a name, such as all_of or
both.
Your turn: numbers bigger than long long
50! has 65 digits, far beyond the 19 digits of a long long. BigUint keeps a non-negative whole number of any size
as decimal digits, least significant first, so 1234 is stored as {4, 3, 2, 1}. The constructor, str() and the
non-member + and * are written for you; complete the three operators marked TODO in biguint.h:
operator+=adds anotherBigUintdigit by digit, carrying into the next digit, and adds a digit at the end when the last addition carries.operator*=multiplies by astd::uint32_t. Hold each product in astd::uint64_t, because a digit times 4,294,967,295 plus the carry does not fit in 32 bits.operator<=>decides by the number of digits first, and only between numbers of equal length compares digits, starting from the most significant one. Compared as text, “99” would come after “100”.
C header · biguint.h
// Starter code for "Your turn": complete operator+=, operator*= and operator<=>.
#pragma once
#include <compare>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
// A non-negative integer of any size, kept as decimal digits, least significant first:
// 1234 is stored as {4, 3, 2, 1}. Zero is {0}, and no other number has a leading zero.
class BigUint {
public:
BigUint() : digits_{0} {}
// From a string of decimal digits such as "1234"; leading zeros are dropped.
explicit BigUint(std::string_view text) {
for (auto it = text.rbegin(); it != text.rend(); ++it) digits_.push_back(static_cast<std::uint8_t>(*it - '0'));
trim();
}
std::string str() const {
std::string s;
for (auto it = digits_.rbegin(); it != digits_.rend(); ++it) s.push_back(static_cast<char>('0' + *it));
return s;
}
BigUint& operator+=(const BigUint& other) {
// TODO: add other digit by digit, carrying into the next digit.
(void)other;
return *this;
}
BigUint& operator*=(std::uint32_t k) {
// TODO: multiply by k (at most 4,294,967,295), carrying into the next digit.
(void)k;
return *this;
}
friend BigUint operator+(BigUint a, const BigUint& b) { return a += b; }
friend BigUint operator*(BigUint a, std::uint32_t k) { return a *= k; }
friend bool operator==(const BigUint&, const BigUint&) = default;
friend std::strong_ordering operator<=>(const BigUint& a, const BigUint& b) {
// TODO: a number with more digits is larger; with as many digits, the most significant difference decides.
(void)a;
(void)b;
return std::strong_ordering::equal;
}
private:
std::vector<std::uint8_t> digits_;
// Removes leading zeros, keeping a single 0 for zero.
void trim() {
while (digits_.size() > 1 && digits_.back() == 0) digits_.pop_back();
if (digits_.empty()) digits_.push_back(0);
}
}; C++ · biguint_tests.cpp
// Sample tests for biguint.h. Build: clang++ -std=c++23 -Wall -Wextra biguint_tests.cpp -o biguint_tests
#include <algorithm>
#include <print>
#include <vector>
#include "biguint.h"
int checks = 0;
int failures = 0;
void check(bool ok, const char* what) {
++checks;
if (!ok) {
++failures;
std::println("FAIL {}", what);
}
}
int main() {
check((BigUint("999") + BigUint("1")).str() == "1000", "999 + 1 carries into a new digit");
check((BigUint("0") + BigUint("0")).str() == "0", "0 + 0 is 0");
check((BigUint("123456789") * 1000).str() == "123456789000", "multiplying by 1,000 adds three zeros");
check((BigUint("98765") * 0).str() == "0", "multiplying by 0 leaves a single 0");
BigUint factorial("1");
for (std::uint32_t k = 2; k <= 50; ++k) factorial *= k;
check(factorial.str() == "30414093201713378043612608166064768844377641568960512000000000000", "50! has 65 digits");
BigUint a("0"), b("1");
for (int i = 0; i < 100; ++i) {
BigUint next = a + b;
a = b;
b = next;
}
check(a.str() == "354224848179261915075", "the 100th Fibonacci number");
check(BigUint("99") < BigUint("100"), "99 < 100: more digits means larger");
check(BigUint("54321") > BigUint("12345"), "54321 > 12345");
check(BigUint("007") == BigUint("7"), "leading zeros do not count");
std::vector<BigUint> v{BigUint("1000"), BigUint("99"), BigUint("100000000000000000000"), BigUint("7")};
std::ranges::sort(v);
check(v.front() == BigUint("7") && v.back() == BigUint("100000000000000000000"), "sorting uses <=>");
std::println("{} of {} checks passed", checks - failures, checks);
return failures == 0 ? 0 : 1;
} The tests build 50! with *=, the 100th Fibonacci number with + and sort four numbers with the comparison
operators. Before you change anything, the starter code passes only the two checks that need no arithmetic:
from session import run
# The sample tests run against the starter code, before any TODO is done.
run("""
clang++ -std=c++23 -Wall -Wextra biguint_tests.cpp -o biguint_tests && ./biguint_tests
g++-16 -std=c++23 -Wall -Wextra biguint_tests.cpp -o biguint_tests-gcc && ./biguint_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra biguint_tests.cpp -o biguint_tests && ./biguint_tests FAIL 999 + 1 carries into a new digit FAIL multiplying by 1,000 adds three zeros FAIL multiplying by 0 leaves a single 0 FAIL 50! has 65 digits FAIL the 100th Fibonacci number FAIL 99 < 100: more digits means larger FAIL 54321 > 12345 FAIL sorting uses <=> 2 of 10 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra biguint_tests.cpp -o biguint_tests-gcc && ./biguint_tests-gcc FAIL 999 + 1 carries into a new digit FAIL multiplying by 1,000 adds three zeros FAIL multiplying by 0 leaves a single 0 FAIL 50! has 65 digits FAIL the 100th Fibonacci number FAIL 99 < 100: more digits means larger FAIL 54321 > 12345 FAIL sorting uses <=> 2 of 10 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 biguint_session.py
Edit biguint.h until both compilers print 10 of 10 checks passed, then build the tests once more with
-fsanitize=address,undefined added, so that reading or writing outside the vector’s memory stops the program with
a report instead of passing unnoticed. Notice why the
defaulted operator== is right for this class while it was wrong for fractions: trim() keeps every number free of
leading zeros, so one value has exactly one list of digits, and comparing the lists compares the values.
Interview questions
Fresher · Why is operator+= usually a member function while operator+ is a non-member?
+= changes its left operand, the object itself, so it belongs to the class: a member function that updates the
data and returns *this by reference, as the built-in += returns the variable it changed. + creates a new value
from two operands that play the same role, so a non-member treats them alike. That matters with conversions: for a
member, the left operand is the object the function is called on and is never converted, so with a Fraction that
can be built from an int, half + 1 would compile and 1 + half would not. A non-member allows a conversion on
either side. The usual way to write it is in terms of the member: take the left operand by value, apply += and
return the copy, so the arithmetic lives in one place.
Key takeaways
- An overloaded operator is a function named
operator@; at least one operand must have a class or enumeration type, and precedence and the number of operands never change. - Write compound assignments such as
+=as members that return*this, and symmetric operators such as+as non-members built on them, so that a conversion works on either side. auto operator<=>(const T&) const = default;compares members in declaration order and also declares==. When member order is not value order, as for fractions, write<=>and==yourself.operator[]returns a reference for writing and may take several indices in C++23;operator()makes function objects;explicit operator boolallows tests but not arithmetic.- Do not overload
&&,||or the comma operator: the overloads cannot skip an operand, and readers expect them to.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
- P0515R3: Consistent comparison (ISO/IEC JTC1/SC22/WG21)
- P1185R2: <=> != == (ISO/IEC JTC1/SC22/WG21)
- P2128R6: Multidimensional subscript operator (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: C.161, use non-member functions for symmetric operators (Standard C++ Foundation)
- C++ Core Guidelines: C.164, avoid implicit conversion operators (Standard C++ Foundation)
- C++ Core Guidelines: C.167, use an operator for an operation with its conventional meaning (Standard C++ Foundation)
- C++ Standards Support in GCC (GNU Project)
- C++ Support in Clang (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress