Operators and Operator Overloading in C++
Learn operator overloading, the C++20 spaceship operator, and comparison categories for expressive, correct code.
Built-in Operators
C++ provides the full set of operators you’d expect from a systems language. Understanding their behavior precisely matters because C++ does not have bounds checking or overflow protection by default — signed integer overflow is undefined behavior, and integer division silently truncates. Knowing exactly what each operator does helps you avoid subtle bugs that only appear in production.
#include <iostream>
int main() {
// Arithmetic
int a = 17, b = 5;
std::cout << a + b << "\n"; // 22
std::cout << a - b << "\n"; // 12
std::cout << a * b << "\n"; // 85
std::cout << a / b << "\n"; // 3 (integer division — truncates toward zero)
std::cout << a % b << "\n"; // 2 (remainder — same sign as dividend)
double x = 17.0, y = 5.0;
std::cout << x / y << "\n"; // 3.4 (floating-point division — no truncation)
// Compound assignment — equivalent to a = a op b, but only evaluate a once
a += 3; // a = a + 3 => 20
a -= 1; // 19
a *= 2; // 38
a /= 4; // 9
a %= 4; // 1
// Increment / decrement — prefer prefix (++n) in loops: no temporary copy
int n = 5;
std::cout << n++ << "\n"; // 5 (post-increment: returns old value, then increments)
std::cout << n << "\n"; // 6
std::cout << ++n << "\n"; // 7 (pre-increment: increments then returns new value)
// Logical — short-circuits: right side not evaluated if left determines result
bool p = true, q = false;
std::cout << (p && q) << "\n"; // 0 (false)
std::cout << (p || q) << "\n"; // 1 (true)
std::cout << (!p) << "\n"; // 0 (false)
// Bitwise — operate on individual bits; essential for flags, masks, hardware
unsigned int flags = 0b1010;
std::cout << (flags & 0b1100) << "\n"; // 0b1000 = 8 (AND — keep shared bits)
std::cout << (flags | 0b0101) << "\n"; // 0b1111 = 15 (OR — set bits)
std::cout << (flags ^ 0b1111) << "\n"; // 0b0101 = 5 (XOR — toggle bits)
std::cout << (~flags) << "\n"; // bitwise NOT — flips all bits
std::cout << (flags << 1) << "\n"; // 0b10100 = 20 (left shift = multiply by 2)
std::cout << (flags >> 1) << "\n"; // 0b0101 = 5 (right shift = divide by 2)
}
Operator Overloading Syntax
Operator overloading lets user-defined types use the same operator syntax as built-in types. An overloaded operator is just a function with a special name — operator+, operator==, etc. This matters because it lets your types participate naturally in expressions and algorithms, making client code readable and intuitive.
Two forms:
- Member function:
T operator+(const T& rhs) const;— left operand is*this - Non-member function:
T operator+(const T& lhs, const T& rhs);— both operands are parameters
The rule of thumb: use member functions for operators that mutate (+=, -=, [], ()), and non-member functions for symmetric binary operators (+, -, ==) so implicit conversions apply equally to both sides.
A Complete Vector2D Example
This example shows the full pattern for a numeric type: compound assignment operators as members, binary operators as non-members defined in terms of the compound operators, and stream insertion as a non-member friend. Defining binary + in terms of member += means the logic lives in one place.
#include <iostream>
#include <cmath>
#include <sstream>
class Vector2D {
public:
double x, y;
Vector2D(double x = 0.0, double y = 0.0) : x(x), y(y) {}
// Member: compound assignment operators (mutate *this — must be members)
Vector2D& operator+=(const Vector2D& rhs) {
x += rhs.x;
y += rhs.y;
return *this;
}
Vector2D& operator-=(const Vector2D& rhs) {
x -= rhs.x;
y -= rhs.y;
return *this;
}
Vector2D& operator*=(double scalar) {
x *= scalar;
y *= scalar;
return *this;
}
// Unary negation — returns a new vector, doesn't modify *this
Vector2D operator-() const {
return {-x, -y};
}
// Subscript operator: v[0] == x, v[1] == y
double& operator[](int index) {
return (index == 0) ? x : y;
}
const double& operator[](int index) const {
return (index == 0) ? x : y;
}
double magnitude() const {
return std::sqrt(x * x + y * y);
}
};
// Non-member: binary arithmetic (symmetric — defined in terms of +=)
// Taking lhs by value lets the compiler use it as the accumulator
Vector2D operator+(Vector2D lhs, const Vector2D& rhs) {
lhs += rhs; // reuse member operator — logic in one place
return lhs;
}
Vector2D operator-(Vector2D lhs, const Vector2D& rhs) {
lhs -= rhs;
return lhs;
}
Vector2D operator*(Vector2D v, double scalar) {
v *= scalar;
return v;
}
// Allow scalar * vector (commutative) — impossible as a member function
Vector2D operator*(double scalar, Vector2D v) {
return v * scalar;
}
// Non-member: equality
bool operator==(const Vector2D& lhs, const Vector2D& rhs) {
return lhs.x == rhs.x && lhs.y == rhs.y;
}
bool operator!=(const Vector2D& lhs, const Vector2D& rhs) {
return !(lhs == rhs);
}
// Stream insertion — returns os to allow chaining: cout << v1 << v2
std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
os << "(" << v.x << ", " << v.y << ")";
return os;
}
int main() {
Vector2D a{3.0, 4.0};
Vector2D b{1.0, 2.0};
std::cout << a + b << "\n"; // (4, 6)
std::cout << a - b << "\n"; // (2, 2)
std::cout << a * 2 << "\n"; // (6, 8)
std::cout << 3 * b << "\n"; // (3, 6) — commutative scalar multiply
std::cout << -a << "\n"; // (-3, -4)
std::cout << "Magnitude of a: " << a.magnitude() << "\n"; // 5
a += b;
std::cout << a << "\n"; // (4, 6)
std::cout << (a == b) << "\n"; // 0 (false)
std::cout << a[0] << "\n"; // 4 (x component)
}
Overloading operator[] and operator()
operator[] lets your type use subscript syntax. operator() makes your type callable like a function (a “functor”). Both must be member functions. Always provide both const and non-const overloads so the type works in both mutable and read-only contexts.
#include <vector>
#include <stdexcept>
// A simple 2D matrix with bounds-checked access
class Matrix {
public:
Matrix(int rows, int cols)
: rows_(rows), cols_(cols), data_(rows * cols, 0.0) {}
// operator[] returns a raw pointer to the row, enabling matrix[r][c]
double* operator[](int row) {
return &data_[row * cols_];
}
const double* operator[](int row) const {
return &data_[row * cols_];
}
// operator() with bounds checking — preferred when safety matters
double& operator()(int row, int col) {
if (row < 0 || row >= rows_ || col < 0 || col >= cols_)
throw std::out_of_range("Matrix index out of bounds");
return data_[row * cols_ + col];
}
private:
int rows_, cols_;
std::vector<double> data_;
};
// Usage:
Matrix m(3, 3);
m[0][0] = 1.0; // via operator[] — fast, no bounds check
m(1, 1) = 5.0; // via operator() — bounds checked, throws on out-of-range
The Spaceship Operator <=> (C++20)
Before C++20, supporting full ordering for a class meant writing six comparison operators (==, !=, <, <=, >, >=) and keeping them consistent with each other — a common source of bugs. The spaceship operator collapses all six into one definition, and when you use = default, the compiler generates all of them automatically by comparing members in declaration order.
#include <compare>
#include <iostream>
struct Version {
int major, minor, patch;
// One line replaces six comparison operators — compiler generates them all
auto operator<=>(const Version& other) const = default;
};
int main() {
Version v1{1, 2, 3};
Version v2{1, 3, 0};
Version v3{1, 2, 3};
std::cout << (v1 < v2) << "\n"; // 1 (true)
std::cout << (v1 > v2) << "\n"; // 0 (false)
std::cout << (v1 == v3) << "\n"; // 1 (true)
std::cout << (v1 <= v2) << "\n"; // 1 (true)
}
Comparison Categories
The return type of <=> determines what comparisons mean for your type. Choosing the right category lets the compiler enforce the semantics correctly.
#include <compare>
// std::strong_ordering: total order where equivalent means identical
// Use for: integers, version numbers, anything with a unique ordering
struct IntWrapper {
int value;
std::strong_ordering operator<=>(const IntWrapper& o) const {
return value <=> o.value;
}
};
// std::weak_ordering: total order where equivalent does NOT mean identical
// Use for: case-insensitive strings ("ABC" and "abc" are equivalent but not equal)
struct CaseInsensitiveStr {
std::string s;
std::weak_ordering operator<=>(const CaseInsensitiveStr& o) const {
std::string a = s, b = o.s;
std::transform(a.begin(), a.end(), a.begin(), ::tolower);
std::transform(b.begin(), b.end(), b.begin(), ::tolower);
return a <=> b;
}
};
// std::partial_ordering: some values are not comparable (e.g., NaN in IEEE floats)
// Use for: floating-point types, sets ordered by subset relation
struct FloatWrapper {
float value;
std::partial_ordering operator<=>(const FloatWrapper& o) const {
return value <=> o.value; // NaN comparisons correctly return unordered
}
};
Custom <=> with Non-Default Logic
When the default member-by-member comparison isn’t what you want, define the operator with custom logic. This example orders employees by salary descending, then by ID ascending:
#include <compare>
#include <string>
struct Employee {
int id;
std::string name;
double salary;
// Custom ordering: highest salary first, then by id for ties
std::strong_ordering operator<=>(const Employee& o) const {
if (salary != o.salary)
return o.salary <=> salary; // reversed for descending salary
return id <=> o.id;
}
bool operator==(const Employee& o) const {
return id == o.id; // equality based on id alone
}
};
The spaceship operator eliminates an entire class of bugs where operator< and operator== were inconsistent with each other — a common mistake in pre-C++20 code.