Operator overloading
Making your type read like a built-in one, without making it lie.
By the end of this chapter you can
- Overload arithmetic and comparison operators idiomatically
- Use the spaceship operator to generate comparisons
- Explain when an operator should be a member and when a free function
You have been using overloaded operators since chapter 1. std::cout << x is a
function call; so is a + b on two std::strings, and v[i] on a vector. C++
lets your types join in.
The power is real and so is the temptation. The guiding rule is one sentence: an overloaded operator should mean what the reader already thinks it means.
A type worth the effort
#include <iostream>
class Money {
public:
constexpr explicit Money(long long cents) : cents_(cents) {}
constexpr long long cents() const { return cents_; }
// Compound assignment first: the others are written in terms of it.
constexpr Money& operator+=(Money other) {
cents_ += other.cents_;
return *this;
}
constexpr Money& operator-=(Money other) {
cents_ -= other.cents_;
return *this;
}
constexpr Money& operator*=(int factor) {
cents_ *= factor;
return *this;
}
constexpr Money operator-() const { return Money{-cents_}; }
private:
long long cents_;
};
// Free functions, built on the members. Taking the left operand by value gives
// us the copy we need to modify.
constexpr Money operator+(Money a, Money b) { return a += b; }
constexpr Money operator-(Money a, Money b) { return a -= b; }
constexpr Money operator*(Money a, int f) { return a *= f; }
constexpr Money operator*(int f, Money a) { return a *= f; }
std::ostream& operator<<(std::ostream& out, Money m) {
const long long cents = m.cents();
return out << (cents < 0 ? "-" : "") << std::abs(cents) / 100
<< '.' << (std::abs(cents) % 100 < 10 ? "0" : "")
<< std::abs(cents) % 100;
}
int main() {
const Money price{1999};
const Money shipping{450};
std::cout << "price: " << price << '\n';
std::cout << "total: " << price + shipping << '\n';
std::cout << "three: " << 3 * price << '\n';
std::cout << "refund: " << -price << '\n';
}Two structural decisions in there are worth naming.
Compound assignment is the primitive. operator+= does the work as a
member, and operator+ is a free function that copies its left operand and
calls it. One implementation, no duplication, and the copy is the one you needed
anyway.
Symmetric operators are free functions. 3 * price only works because
operator*(int, Money) exists as a free function. A member operator requires
the left operand to be your type, so 3 * price could never find it — and an
operator that works one way round but not the other is worse than none.
Member or free?
| Operator | Where |
|---|---|
=, [], (), -> |
must be members |
+=, -=, and the other compound assignments |
members, by convention |
+, -, *, /, % |
free functions |
==, <=> |
members (C++20 handles the symmetry for you) |
<<, >> for streams |
must be free — the left operand is the stream |
The rule behind the table: if the left operand must be able to convert, or is not your type, it has to be free.
Comparison, before and after C++20
Writing comparisons used to mean six functions that had to agree with each
other. operator<=> — the three-way comparison or “spaceship” operator —
generates them:
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
struct Version {
int major;
int minor;
int patch;
// Memberwise comparison in declaration order, and == with it.
auto operator<=>(const Version&) const = default;
bool operator==(const Version&) const = default;
};
int main() {
Version a{1, 4, 0};
Version b{1, 10, 0};
std::cout << std::boolalpha;
std::cout << "a < b: " << (a < b) << '\n';
std::cout << "a >= b: " << (a >= b) << '\n';
std::cout << "a == b: " << (a == b) << '\n';
std::cout << "a != b: " << (a != b) << '\n';
std::vector<Version> versions{{2, 0, 0}, {1, 4, 0}, {1, 10, 0}};
std::ranges::sort(versions);
for (const auto& v : versions) {
std::cout << v.major << '.' << v.minor << '.' << v.patch << ' ';
}
std::cout << '\n';
}= default compares members in declaration order, which is exactly right
for a version number and exactly wrong if your members are not in priority
order. When the ordering is not memberwise, write it:
#include <compare>
#include <iostream>
#include <string>
struct Task {
std::string name;
int priority;
// Higher priority sorts first; ties broken by name.
std::strong_ordering operator<=>(const Task& other) const {
if (auto c = other.priority <=> priority; c != 0) return c;
return name <=> other.name;
}
bool operator==(const Task& other) const = default;
};
int main() {
Task a{"write", 2};
Task b{"review", 5};
std::cout << std::boolalpha;
std::cout << "review before write: " << (b < a) << '\n';
std::cout << "equal to itself: " << (a == a) << '\n';
}Note other.priority <=> priority — reversed, to sort higher first. And note
that operator== still has to be declared: <=> generates the four relational
operators, but equality is separate, because for many types equality is cheaper
than ordering and the compiler will not assume they agree.
The operators worth overloading
Stream output. Almost always worth it, and always a free function:
#include <iostream>
#include <string>
struct Point {
double x;
double y;
};
std::ostream& operator<<(std::ostream& out, const Point& p) {
return out << '(' << p.x << ", " << p.y << ')';
}
int main() {
std::cout << "the origin is " << Point{0, 0} << '\n';
std::cout << "and a point: " << Point{1.5, -2.25} << '\n';
}Return the stream by reference so calls chain, and take it by non-const reference because writing modifies it.
Subscript and call. operator[] for anything indexable, operator() to
make a type callable — which is what a lambda is underneath:
#include <algorithm>
#include <iostream>
#include <vector>
struct DivisibleBy {
int divisor;
bool operator()(int value) const { return value % divisor == 0; }
};
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9};
const auto count = std::ranges::count_if(v, DivisibleBy{3});
std::cout << "divisible by three: " << count << '\n';
// A lambda is exactly this, generated by the compiler.
const auto count2 = std::ranges::count_if(v, [](int x) { return x % 3 == 0; });
std::cout << "same with a lambda: " << count2 << '\n';
}Where it goes wrong
The failure mode is not technical — it is a reader who has to look up what your operator means.
#include <iostream>
#include <string>
#include <vector>
struct Path {
std::string value;
};
// Defensible: joining paths reads naturally as addition... or does it?
Path operator+(const Path& a, const Path& b) {
return Path{a.value + "/" + b.value};
}
struct Matrix {
std::vector<double> data;
};
// Indefensible: does * mean matrix product, or elementwise?
// The reader cannot tell, and both are plausible.
Matrix operator*(const Matrix& a, const Matrix&) { return a; }
int main() {
Path root{"usr"};
Path sub{"local"};
std::cout << (root + sub).value << '\n';
std::cout << "clearer as: root.join(sub)\n";
}std::filesystem::path chose operator/ for joining, which is better than +
because it looks like the separator it inserts. For a matrix, multiply and
elementwise_multiply are named functions for a reason.