05 · Operator Overloading¶
std::string supports +. std::vector supports []. std::cout supports
<<. None of these are built-in language features for those types — they are
ordinary functions with unusual names, written in the Standard Library using a
mechanism you can use too.
Operator overloading lets your own types read like built-in ones. Used well, a
Vector2 c = a + b; is dramatically clearer than Vector2 c = a.plus(b);.
Used badly — overloading + to mean "send over the network" — it makes code
unreadable. The guiding rule is simple: only overload an operator when its
conventional meaning is obvious for your type.
A worked example: a 2D vector¶
#include <iostream>
#include <cmath>
class Vector2 {
public:
Vector2(double x = 0.0, double y = 0.0) : x(x), y(y) {}
// Member operator: the LEFT operand is 'this'
Vector2 operator+(const Vector2& rhs) const {
return Vector2(x + rhs.x, y + rhs.y);
}
Vector2 operator-(const Vector2& rhs) const {
return Vector2(x - rhs.x, y - rhs.y);
}
// Scalar multiply: v * 3
Vector2 operator*(double scalar) const {
return Vector2(x * scalar, y * scalar);
}
// Unary minus -- one operand, no parameters
Vector2 operator-() const {
return Vector2(-x, -y);
}
// Compound assignment: modifies *this and returns a reference to it
Vector2& operator+=(const Vector2& rhs) {
x += rhs.x;
y += rhs.y;
return *this;
}
double length() const { return std::sqrt(x * x + y * y); }
double getX() const { return x; }
double getY() const { return y; }
private:
double x, y;
};
int main() {
Vector2 a(1.0, 2.0);
Vector2 b(3.0, 4.0);
Vector2 sum = a + b; // Vector2(4, 6)
Vector2 diff = b - a; // Vector2(2, 2)
Vector2 scaled = a * 3.0; // Vector2(3, 6)
Vector2 neg = -a; // Vector2(-1, -2)
a += b; // a is now Vector2(4, 6)
std::cout << sum.getX() << ", " << sum.getY() << std::endl; // 4, 6
std::cout << b.length() << std::endl; // 5
}
Three conventions in that code are worth calling out:
- Binary arithmetic operators are
constand return a new object by value —a + bmust not modifya. - Compound assignment (
+=) returnsVector2&, a reference to the modified object, so that(a += b) += cworks like it does for built-in types. - Take the right-hand operand as
const&to avoid a copy.
The idiomatic way to keep them consistent is to implement += first and define
+ in terms of it:
// Free function, outside the class
Vector2 operator+(Vector2 lhs, const Vector2& rhs) { // lhs BY VALUE -- it's our copy
lhs += rhs; // reuse the member operator
return lhs;
}
Member vs. free function¶
// Member: 'v * 3.0' works, but '3.0 * v' does NOT.
// The left operand must be a Vector2 for a member operator to apply.
// Free function fixes the reversed case:
Vector2 operator*(double scalar, const Vector2& v) {
return v * scalar; // delegate to the member version
}
int main() {
Vector2 v(1.0, 2.0);
Vector2 a = v * 3.0; // member operator
Vector2 b = 3.0 * v; // free operator -- would not compile without it
}
This asymmetry is the main reason to prefer free functions for symmetric binary operators. A free function treats both operands equally and allows implicit conversion on either side.
| Operator | Implement as |
|---|---|
=, [], (), -> |
must be a member |
+=, -=, *=, ++, -- |
member (they modify the left operand) |
+, -, *, /, ==, < |
free function (symmetric operands) |
<<, >> (streams) |
free function (left operand is the stream) |
Stream operators¶
#include <iostream>
#include <sstream>
#include <string>
class Vector2 {
public:
Vector2(double x = 0, double y = 0) : x(x), y(y) {}
double x, y;
};
// Must be a free function: the left operand is std::ostream, not Vector2.
// Return the stream by reference so calls chain: cout << a << b << '\n';
std::ostream& operator<<(std::ostream& os, const Vector2& v) {
os << "(" << v.x << ", " << v.y << ")";
return os;
}
// Input operator: takes a NON-const reference to fill in
std::istream& operator>>(std::istream& is, Vector2& v) {
is >> v.x >> v.y;
return is;
}
int main() {
Vector2 a(1.5, 2.5);
std::cout << "a = " << a << std::endl; // a = (1.5, 2.5)
std::istringstream input("7 8");
Vector2 b;
input >> b;
std::cout << "b = " << b << std::endl; // b = (7, 8)
}
// Output:
// a = (1.5, 2.5)
// b = (7, 8)
Two non-negotiables: return std::ostream& (not void, or chaining breaks)
and never write std::endl inside operator<< — leave line breaks to the
caller.
If your class has private members, the stream operator needs access. Either
add public getters, or declare it a friend:
class Vector2 {
double x, y;
friend std::ostream& operator<<(std::ostream&, const Vector2&); // grants access
};
Comparison operators¶
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
class Version {
public:
Version(int major, int minor) : major(major), minor(minor) {}
friend bool operator==(const Version& a, const Version& b) {
return a.major == b.major && a.minor == b.minor;
}
friend bool operator!=(const Version& a, const Version& b) {
return !(a == b); // define in terms of == -- never duplicate logic
}
friend bool operator<(const Version& a, const Version& b) {
if (a.major != b.major) return a.major < b.major;
return a.minor < b.minor;
}
friend bool operator>(const Version& a, const Version& b) { return b < a; }
friend bool operator<=(const Version& a, const Version& b) { return !(b < a); }
friend bool operator>=(const Version& a, const Version& b) { return !(a < b); }
friend std::ostream& operator<<(std::ostream& os, const Version& v) {
return os << v.major << "." << v.minor;
}
private:
int major, minor;
};
int main() {
std::vector<Version> versions{{2, 1}, {1, 9}, {2, 0}};
std::sort(versions.begin(), versions.end()); // uses operator<
for (const auto& v : versions) std::cout << v << ' ';
std::cout << std::endl; // 1.9 2.0 2.1
}
Defining operator< is what makes your type usable as a std::map key, a
std::set element, or a std::sort target with no extra comparator. Write
< and == honestly, then derive the other four from them — that guarantees
they stay mutually consistent.
C++20 collapses all six into one three-way comparison ("spaceship"):
#include <compare>
class Version {
int major, minor;
public:
// Generates ==, !=, <, <=, >, >= automatically, comparing members in order
auto operator<=>(const Version&) const = default;
bool operator==(const Version&) const = default;
};
Subscript and function-call operators¶
#include <iostream>
#include <vector>
#include <stdexcept>
class Grid {
public:
Grid(int rows, int cols) : rows(rows), cols(cols), data(rows * cols, 0) {}
// Non-const version returns a MUTABLE reference: grid(1, 2) = 5;
int& operator()(int r, int c) {
if (r < 0 || r >= rows || c < 0 || c >= cols) {
throw std::out_of_range("Grid index out of range");
}
return data[r * cols + c];
}
// const version returns a read-only reference -- needed for const Grid objects
const int& operator()(int r, int c) const {
return data[r * cols + c];
}
private:
int rows, cols;
std::vector<int> data;
};
// A "functor" -- an object that behaves like a function
class MultiplyBy {
public:
MultiplyBy(int factor) : factor(factor) {}
int operator()(int x) const { return x * factor; } // callable state
private:
int factor;
};
int main() {
Grid g(3, 3);
g(1, 1) = 7; // operator() returning int& makes this assignable
std::cout << g(1, 1) << std::endl; // 7
MultiplyBy triple(3);
std::cout << triple(5) << std::endl; // 15
}
operator() can take any number of arguments, which is why it's the natural
choice for a 2D index — operator[] accepts only one argument before C++23.
An object with operator() is a functor, and it's exactly what a lambda
compiles into.
Note the const/non-const pair. Without the const overload, you couldn't read
from a const Grid& at all. Providing both is standard practice for any
accessor that returns a reference.
Operators you should not overload¶
&&,||— overloading them destroys short-circuit evaluation. Both operands get evaluated, always. Almost never worth it.,(comma) — same problem, plus nobody expects it.&(address-of) — breaks generic code that legitimately takes addresses.->— legitimate for smart pointers and iterators, confusing anywhere else.
And you simply cannot overload ., .*, ::, ?:, or sizeof.
Cheat sheet¶
| Operator | Typical signature | Returns |
|---|---|---|
a + b |
T operator+(const T&, const T&) (free) |
new value |
a += b |
T& operator+=(const T&) (member) |
*this |
-a |
T operator-() const (member) |
new value |
a == b |
bool operator==(const T&, const T&) (free) |
bool |
a < b |
bool operator<(const T&, const T&) (free) |
bool |
os << a |
std::ostream& operator<<(std::ostream&, const T&) (free) |
the stream |
a[i] |
V& operator[](std::size_t) + const version (member) |
reference |
a(x, y) |
R operator()(X, Y) (member) |
anything |
++a |
T& operator++() (member) |
*this |
a++ |
T operator++(int) (member, dummy int param) |
copy of the old value |
How It Actually Works¶
operator+ is syntactic sugar the compiler applies before overload
resolution even begins: seeing a + b, it rewrites the expression as
operator+(a, b) (free function form) or a.operator+(b) (member form) and
then runs ordinary overload resolution exactly as it would for any other
function call — there is no separate "operator dispatch" mechanism at
runtime. This is why operator overloads compile to plain function calls
(inlined away entirely at higher optimization levels for something like
Vec2::operator+), and why you can define operator+ to do anything at
all — the compiler enforces the syntax, not the semantics.
For operator<< with std::cout, the reason it must be a free function
(not a member of your class) is argument order: std::cout << obj needs the
left operand to be std::ostream&, but you can't add a member function to
std::ostream itself, so the overload has to be a free function taking both
operands, one of which the compiler finds via argument-dependent lookup
(ADL) — it searches the namespace your class lives in for a matching
operator<<, which is how std::cout << myVec2 finds your overload without
an explicit using or qualification.
operator= (copy/move assignment) has a subtlety the others don't: it must
correctly handle self-assignment (a = a;) and must release the
target's existing resources before acquiring the source's — get this wrong
(e.g. delete the target's heap buffer before checking whether source and
target are the same object) and self-assignment frees memory the right-hand
side still needs, then reads it anyway, corrupting the object. This is why
correctly-written assignment operators either check this != &other
explicitly or use the copy-and-swap idiom, which sidesteps the issue by
building the new state in a temporary before touching this at all.
Exercise¶
Write a Money class storing an amount in integer cents (never use double
for currency — rounding error accumulates). Give it:
- A constructor taking dollars and cents
operator+,operator-, andoperator+=as appropriate free/member functionsoperator*(int quantity)and the reversedoperator*(int, const Money&)- All six comparison operators, derived from
==and< operator<<printing$12.50format (watch the zero-padding on cents)
Then put several Money values in a std::vector, std::sort them, and use
std::accumulate with Money{0,0} as the initial value to total them —
verifying that your operators integrate cleanly with
the STL algorithms from Module 4.