Operator Overloading
What Is Operator Overloading?
C++ lets you redefine how operators (+, -, ==, <<, [], (), etc.) behave for your custom types. This is called operator overloading. It's one of C++'s most powerful features for creating expressive, readable code, but also one of the most misused when done without discipline.
How the Compiler Sees It
When you write a + b where a and b are your custom Vector2D objects, the compiler doesn't see a mathematical addition, it sees a function call. It translates the expression into one of two forms:
a.operator+(b), ifoperator+is a member function ofa's classoperator+(a, b), ifoperator+is a free (non-member) function
The compiler performs overload resolution just like any other function call: it considers all candidate operator+ functions in scope, finds the best match based on argument types (including implicit conversions), and calls that function. If no match is found, or if multiple matches are equally good, you get a compile error.
What You Can Overload
You can overload almost every C++ operator, including:
- Arithmetic:
+,-,*,/,%, and their compound forms+=,-=, etc. - Comparison:
==,!=,<,>,<=,>=,<=> - Logical:
!(but avoid overloading&&and||) - Bitwise:
&,|,^,~,<<,>> - Access:
[],(),->,*(dereference) - Assignment:
=, and the compound assignments - Increment/Decrement:
++,--(both prefix and postfix) - Memory:
new,delete,new[],delete[] - Conversion:
operator Type()for type conversions
Why It Matters
Operator overloading makes your types feel like built-in types. Consider the difference:
// Without operator overloading:
Matrix result = add(multiply(A, B), C);
// With operator overloading:
Matrix result = A * B + C;
The second form reads like math. A BigInteger with == and < works seamlessly with std::sort. A smart pointer with * and -> behaves like a real pointer. A std::string with + lets you concatenate naturally.
This isn't just aesthetics, it enables generic programming. Templates like std::accumulate use + internally. If your type supports +, it automatically works with the algorithm. Without operator overloading, every algorithm would need customization hooks.
+ on a Vector makes sense. + on a Database does not. If you have to explain what your overloaded operator does, it's probably the wrong abstraction, use a named function instead.
Overloading Rules at a Glance
- At least one operand must be a user-defined type (you can't redefine
int + int). - You cannot create new operators (no
operator**for exponentiation). - You cannot change an operator's arity (number of operands):
+is always binary or unary, never ternary. - You cannot change precedence or associativity:
*always binds tighter than+, even for your types.
Member vs Free Function
You can define operator overloads in two fundamentally different ways, and the choice isn't just stylistic, it affects which conversions the compiler can apply and which expressions compile at all.
Member Function Form
The left operand is always *this. The compiler rewrites a + b as a.operator+(b):
class Vec2 {
double x, y;
public:
Vec2(double x, double y) : x(x), y(y) {}
// Member: this + other
Vec2 operator+(const Vec2& other) const {
return Vec2(x + other.x, y + other.y);
}
};
The key implication: the left operand must already be a Vec2. If someone writes 3.0 + myVec, the compiler won't try to convert 3.0 to a Vec2 to call the member function, it only searches for member functions on the left operand's type (which is double, not your class).
Free (Non-Member / Friend) Function Form
Both operands are parameters. The compiler rewrites a + b as operator+(a, b). Needed when the left operand isn't your class (e.g., std::ostream << yourObject):
class Vec2 {
double x, y;
public:
Vec2(double x, double y) : x(x), y(y) {}
// Free function defined as friend for private access
friend Vec2 operator+(const Vec2& a, const Vec2& b) {
return Vec2(a.x + b.x, a.y + b.y);
}
};
With a free function, implicit conversions can apply to both operands. If Vec2 has a constructor Vec2(double) that converts a scalar to a vector, then both myVec + 3.0 and 3.0 + myVec would work.
The friend Keyword
A friend declaration inside a class grants a non-member function access to private and protected members. You have three options for defining free-function operators:
// Option 1: friend defined inline (most common)
class Vec2 {
double x, y;
public:
friend Vec2 operator+(const Vec2& a, const Vec2& b) {
return Vec2(a.x + b.x, a.y + b.y); // can access x, y
}
};
// Option 2: friend declared, defined outside
class Vec2 {
double x, y;
public:
friend Vec2 operator+(const Vec2& a, const Vec2& b);
};
Vec2 operator+(const Vec2& a, const Vec2& b) {
return Vec2(a.x + b.x, a.y + b.y);
}
// Option 3: No friend — use public getters only
Vec2 operator+(const Vec2& a, const Vec2& b) {
return Vec2(a.getX() + b.getX(), a.getY() + b.getY());
}
The Implicit Conversion Trap
Here's a concrete example of why the member vs free choice matters:
class Length {
double meters_;
public:
Length(double m) : meters_(m) {} // implicit conversion from double
// Member operator+
Length operator+(const Length& rhs) const {
return Length(meters_ + rhs.meters_);
}
};
Length L(5.0);
Length a = L + 3.0; // OK: 3.0 converts to Length for the rhs
Length b = 3.0 + L; // ERROR: 3.0 is a double, no member operator+ on double
If operator+ were a free function, both lines would compile because implicit conversion can apply to either argument.
Decision Guide
| Operator | Must Be | Reason |
|---|---|---|
=, [], (), -> | Member | Language requires it, these only make sense on the object itself |
<<, >> (stream I/O) | Free function | Left operand is a stream, not your class |
Arithmetic (+, -, *, /) | Prefer free | Allows implicit conversions on both sides (symmetry) |
Comparisons (==, <, etc.) | Prefer free | Same symmetry reasoning; C++20 <=> can be a member |
Compound assignment (+=, -=) | Prefer member | Modifies the left operand in-place; member is natural |
Unary (!, -, ++) | Prefer member | Only one operand, the object itself |
Arithmetic Operators
The four arithmetic operators (+, -, *, /) and their compound forms (+=, -=, etc.) are the most commonly overloaded. There's a canonical pattern that every C++ developer should know, and understanding why it works the way it does is crucial.
The Canonical Pattern: Compound First
The fundamental insight: implement += as a member function first, then define + as a free function that delegates to +=. This avoids code duplication, ensures consistency, and produces the most efficient code.
class Vec2 {
double x, y;
public:
Vec2(double x = 0, double y = 0) : x(x), y(y) {}
// Step 1: Compound assignment (modifies in-place, returns reference)
Vec2& operator+=(const Vec2& rhs) {
x += rhs.x;
y += rhs.y;
return *this; // return reference for chaining: a += b += c;
}
// Step 2: Binary + defined in terms of +=
friend Vec2 operator+(Vec2 lhs, const Vec2& rhs) {
lhs += rhs; // lhs is a COPY (passed by value), so this is safe
return lhs;
}
};
Why This Pattern Works
Let's break down the design decisions line by line:
Vec2& operator+=(const Vec2& rhs), Returns a reference to*this. This enables chaining likea += b += c, which is how built-in types work. The parameter isconst&because we only read fromrhs.friend Vec2 operator+(Vec2 lhs, const Vec2& rhs), Noticelhsis passed by value, not by const reference. This is intentional: we need a copy anyway (since+must not modify either operand), and taking it by value lets the compiler optimize with move semantics or copy elision.- Free function, not member, Allows implicit conversions on both sides. If a
doublecan implicitly convert toVec2, then3.0 + myVecworks.
Unary Operators
Unary minus (negation) and unary plus are simple, they take no parameters and return a new object:
// Unary minus: -v
Vec2 operator-() const { return Vec2(-x, -y); }
// Unary plus (usually a no-op, but included for completeness)
Vec2 operator+() const { return *this; }
Scalar Multiplication
A common need is multiplying a vector by a scalar. You need two overloads for commutativity (v * 2.0 and 2.0 * v should both work):
// Vec2 * scalar
friend Vec2 operator*(const Vec2& v, double s) {
return Vec2(v.x * s, v.y * s);
}
// scalar * Vec2 (delegates to the above)
friend Vec2 operator*(double s, const Vec2& v) {
return v * s;
}
Implement one direction, then delegate from the other. This keeps the logic in one place and guarantees both directions produce the same result.
A Complete Example
Here's how a well-designed Vec2 class looks with the full arithmetic suite:
class Vec2 {
double x, y;
public:
Vec2(double x = 0, double y = 0) : x(x), y(y) {}
// Compound assignments (member functions, return *this)
Vec2& operator+=(const Vec2& rhs) { x += rhs.x; y += rhs.y; return *this; }
Vec2& operator-=(const Vec2& rhs) { x -= rhs.x; y -= rhs.y; return *this; }
Vec2& operator*=(double s) { x *= s; y *= s; return *this; }
Vec2& operator/=(double s) { x /= s; y /= s; return *this; }
// Binary operators (free functions, defined via compound operators)
friend Vec2 operator+(Vec2 a, const Vec2& b) { return a += b; }
friend Vec2 operator-(Vec2 a, const Vec2& b) { return a -= b; }
friend Vec2 operator*(Vec2 v, double s) { return v *= s; }
friend Vec2 operator*(double s, const Vec2& v) { return Vec2(v) *= s; }
friend Vec2 operator/(Vec2 v, double s) { return v /= s; }
// Unary operators
Vec2 operator-() const { return Vec2(-x, -y); }
Vec2 operator+() const { return *this; }
};
Notice how concise the binary operators are when they delegate to compound assignments. Each is a one-liner. All the actual logic lives in +=, -=, *=, /=.
const Vec2 from operator+. Some older guides recommend this to prevent (a + b) = c, but it defeats move semantics in modern C++. Just return by value.
▶ Vector Addition Animation
Watch operator+ add two Vec2 objects component-wise.
Comparison Operators
Comparisons are the second most commonly overloaded operator category. They're essential for using your types with STL containers (std::set, std::map) and algorithms (std::sort, std::binary_search). C++20 revolutionized this area with the spaceship operator, but understanding the old way is important for maintaining legacy code.
Pre-C++20: The Verbose Way
Before C++20, you had to write up to six separate operator functions: ==, !=, <, >, <=, >=. The canonical pattern reduces this to just two implementations:
class Fraction {
int num, den;
public:
Fraction(int n, int d) : num(n), den(d) {}
// Core: operator== and operator<
friend bool operator==(const Fraction& a, const Fraction& b) {
return (long long)a.num * b.den == (long long)b.num * a.den;
}
friend bool operator<(const Fraction& a, const Fraction& b) {
return (long long)a.num * b.den < (long long)b.num * a.den;
}
// Derived: all others defined in terms of == and <
friend bool operator!=(const Fraction& a, const Fraction& b) {
return !(a == b);
}
friend bool operator>(const Fraction& a, const Fraction& b) {
return b < a; // flip operands
}
friend bool operator<=(const Fraction& a, const Fraction& b) {
return !(b < a); // not greater
}
friend bool operator>=(const Fraction& a, const Fraction& b) {
return !(a < b); // not less
}
};
This pattern, implementing == and <, then deriving the rest, keeps the real logic in two places. But it's still six functions and a lot of boilerplate. Enter C++20.
C++20: The Spaceship Operator (<=>)
The three-way comparison operator <=> (nicknamed "spaceship" because <=> looks like a UFO) compares two values and returns an ordering result that encodes whether the left operand is less than, equal to, or greater than the right. From this single function, the compiler synthesizes all six comparison operators.
Ordering Types
The return type of <=> tells the compiler what kind of ordering your type has. This matters because not all types can be compared in the same way:
| Return Type | Meaning | Example Types |
|---|---|---|
std::strong_ordering | Exactly one of <, ==, > is true. Equal objects are indistinguishable (substitutable). | int, char, std::string |
std::weak_ordering | Same three-way split, but equivalent objects may be distinguishable. Two values can compare as "equivalent" without being truly equal. | Case-insensitive string comparison |
std::partial_ordering | Some values may be incomparable (neither less, greater, nor equal). | float/double (because of NaN) |
For most types, std::strong_ordering is what you want. Use std::partial_ordering only when your type has values that genuinely can't be compared (like floating-point NaN).
The Simplest Case: Defaulted Spaceship
#include <compare>
class Point {
int x, y;
public:
Point(int x, int y) : x(x), y(y) {}
// Compiler generates ALL comparisons: ==, !=, <, >, <=, >=
auto operator<=>(const Point&) const = default;
};
When you = default the spaceship operator, the compiler does a member-by-member comparison in declaration order. For Point, it first compares x, and if they're equal, compares y. This is lexicographic ordering, exactly what you'd typically want for a struct.
The auto return type lets the compiler deduce the strongest ordering category that all members support.
Custom Spaceship
When the default member-by-member comparison isn't right, write the body yourself:
class Fraction {
int num, den;
public:
Fraction(int n, int d) : num(n), den(d) {}
// Custom three-way comparison
std::strong_ordering operator<=>(const Fraction& rhs) const {
// Cross-multiply to compare without division
auto lhs_val = (long long)num * rhs.den;
auto rhs_val = (long long)rhs.num * den;
return lhs_val <=> rhs_val; // delegates to built-in <=> on long long
}
// With custom <=>, you must ALSO define == separately
bool operator==(const Fraction& rhs) const {
return num * rhs.den == rhs.num * den;
}
};
Important subtlety: When you write a custom (non-defaulted) <=>, the compiler only synthesizes <, >, <=, >= from it. It does not synthesize == and !=, you must define operator== separately. Why? Because equality checking is often more efficient than full three-way comparison (e.g., for strings, you can first check length before comparing characters).
How the Compiler Uses <=>
When you write a < b and there's no direct operator<, the compiler rewrites it as (a <=> b) < 0. If that doesn't work, it tries 0 < (b <=> a) (reversed). This means a single <=> definition enables all four relational operators to work in both directions.
auto operator<=>(const T&) const = default; does member-by-member comparison in declaration order. Write the body only when you need custom logic (like the Fraction cross-multiplication above).
Stream Operators (<< and >>)
Overloading << for std::ostream lets your objects work with std::cout, std::ostringstream, file streams, and any output stream. This is arguably the most practical operator to overload, you'll use it constantly during debugging and logging.
Why They Must Be Free (Friend) Functions
This is one of the most-asked questions in C++ interviews and one of the most important design constraints to understand deeply. Let's walk through the reasoning step by step.
Step 1: How the Compiler Resolves Member Operators
When the compiler sees a << b, it tries two things:
a.operator<<(b), look for a member function on the type ofaoperator<<(a, b), look for a free function with matching parameters
For member functions, the left operand is always *this. That means the class of the left operand is where the member function must live.
Step 2: Who Owns the Left Operand?
Now consider std::cout << myVec. The left operand is std::cout, which is of type std::ostream. If you tried to define operator<< as a member function, it would have to be a member of std::ostream:
// IMPOSSIBLE: You'd need to modify the standard library itself!
class std::ostream {
public:
std::ostream& operator<<(const Vec2& v) { // Can't add this!
return *this << "(" << v.x << ", " << v.y << ")";
}
};
You can't modify std::ostream, it's part of the standard library. The headers are read-only. Even if you could, every user-defined type in the world would need to add itself into std::ostream's definition, which is absurd.
Step 3: Could We Make It a Member of Our Class Instead?
What if we made it a member of Vec2? Then the left operand would be *this (a Vec2):
class Vec2 {
public:
// This compiles, but the syntax is BACKWARDS!
std::ostream& operator<<(std::ostream& os) const {
return os << "(" << x << ", " << y << ")";
}
};
// Usage would be:
myVec << std::cout; // Works but reads horribly
// std::cout << myVec; // DOESN'T COMPILE! cout has no member for Vec2
The syntax is reversed, myVec << std::cout instead of std::cout << myVec. Nobody writes it that way. It breaks chaining: std::cout << a << b won't work because the first << returns an ostream& and the second << has the stream on the left again.
Step 4: The Only Solution: Free Function
Since we can't add a member to std::ostream and making it a member of our class reverses the syntax, the only option is a free function where both operands are parameters:
// Free function: compiler sees operator<<(std::cout, myVec)
std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << "(" << v.x << ", " << v.y << ")";
}
But there's a problem: x and y are private members of Vec2. A free function can't access them. We have two choices:
- Add public getters (
getX(),getY()), works, but may not always be desirable. Adding getters for every private member defeats encapsulation. - Declare the function as a
friendinside the class, grants access to private members without exposing them to the rest of the world.
Step 5: Why friend Is the Canonical Choice
class Vec2 {
double x, y; // private!
public:
Vec2(double x, double y) : x(x), y(y) {}
// Declared as friend: it's a FREE function with private access
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << "(" << v.x << ", " << v.y << ")";
}
};
friend here does two things simultaneously:
- Declares it as a non-member function, it's not a member of
Vec2, so the left operand can be anything (here,std::ostream&). - Grants private access, the function body can directly use
v.xandv.ywithout public getters.
std::ostream / std::istream) is a standard library type you can't modify. They're declared as friend because they typically need access to private data. This isn't a design choice, it's a constraint imposed by how C++ resolves operator calls.
The Canonical Pattern
class Vec2 {
double x, y;
public:
Vec2(double x, double y) : x(x), y(y) {}
// Output: cout << myVec prints "(3.5, 2.1)"
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << "(" << v.x << ", " << v.y << ")";
}
// Input: cin >> myVec reads two doubles
friend std::istream& operator>>(std::istream& is, Vec2& v) {
is >> v.x >> v.y;
if (!is) v = Vec2(0, 0); // reset on failure
return is;
}
};
Key Design Decisions
- Return the stream by reference, This enables chaining:
cout << a << " " << b;. Each<<returns the same stream, so the next<<can take it as the left operand. const Vec2&for output,Vec2&for input, Output doesn't modify the object (const), but input writes into it (non-const).- Check for stream failure on input, If
is >> v.xfails (e.g., user types "abc"), the stream enters a fail state. Good practice is to reset the object to a known state when this happens. - Don't include
\nin the output, Let the caller decide line breaks. Your operator should format the value, nothing more.
Formatting with I/O Manipulators
For more complex formatting, be aware that your operator inherits whatever formatting state the stream currently has:
// If someone does: cout << std::fixed << std::setprecision(2) << myVec;
// Your operator will print (3.50, 2.10) because fixed/precision are sticky.
// If you need specific formatting, save and restore:
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
auto flags = os.flags(); // save
auto prec = os.precision();
os << std::fixed << std::setprecision(3)
<< "(" << v.x << ", " << v.y << ")";
os.flags(flags); // restore
os.precision(prec);
return os;
}
<<. Boost, Eigen, Qt, they all provide stream operators for their types. It's expected for any class that represents a "value."
Subscript Operator ([])
The subscript operator lets your object be indexed like an array or looked up like a dictionary. It must be a member function (language requirement). You should always provide both const and non-const overloads to support read-only and read-write access.
Basic Pattern: Const and Non-Const
class Matrix {
std::vector<std::vector<double>> data_;
size_t rows_, cols_;
public:
Matrix(size_t r, size_t c)
: data_(r, std::vector<double>(c)), rows_(r), cols_(c) {}
// Non-const: allows modification (mat[2][3] = 4.5)
std::vector<double>& operator[](size_t row) {
return data_[row];
}
// Const: read-only access (works on const Matrix objects)
const std::vector<double>& operator[](size_t row) const {
return data_[row];
}
};
Matrix mat(3, 4);
mat[2][3] = 4.5; // non-const version
const Matrix& ref = mat;
double val = ref[2][3]; // const version
Why Both Versions Matter
Without the const overload, you couldn't use your class through a const reference or in any const context (like a function that takes const Matrix&). Without the non-const overload, you couldn't assign through the subscript.
The compiler chooses the right overload based on whether the object is const:
void readOnly(const Matrix& m) {
double v = m[0][0]; // calls the const overload
// m[0][0] = 1; // ERROR: const overload returns const reference
}
void readWrite(Matrix& m) {
m[0][0] = 1; // calls the non-const overload
}
Bounds Checking
The built-in [] operator on arrays doesn't check bounds (for performance). You can choose to add bounds checking in your overload. A common pattern is to provide both unchecked [] and checked at():
class SafeArray {
std::vector<int> data_;
public:
int& operator[](size_t i) { return data_[i]; } // fast, unchecked
const int& operator[](size_t i) const { return data_[i]; } // fast, unchecked
int& at(size_t i) { // checked
if (i >= data_.size()) throw std::out_of_range("index");
return data_[i];
}
};
C++23: Multi-Dimensional Subscript
Before C++23, operator[] could only take a single argument. To emulate mat[2][3] you had to return a proxy object (like a row reference). C++23 lifts this restriction:
// C++23: operator[] can take multiple arguments
class Matrix {
std::vector<double> data_;
size_t cols_;
public:
double& operator[](size_t row, size_t col) {
return data_[row * cols_ + col];
}
};
Matrix m(3, 4);
m[2, 3] = 4.5; // Direct multi-dimensional access!
This is cleaner than the proxy pattern and has zero overhead. Until C++23, the () operator was the idiomatic alternative for multi-dimensional access (e.g., Eigen uses mat(2, 3)).
Function Call Operator (())
Overloading () makes your object callable like a function. These objects are called functors (or function objects). They're a core C++ idiom that predates lambdas and remains relevant in modern code.
Basic Functor
class Adder {
int value_;
public:
explicit Adder(int v) : value_(v) {}
int operator()(int x) const {
return x + value_;
}
};
Adder add5(5);
int result = add5(10); // result = 15 — add5 looks and acts like a function!
Why Functors Exist
The key advantage over plain function pointers: functors can carry state. A function pointer is just an address, it can't remember anything between calls. A functor is an object with member variables:
// Function pointer: no state
int addFive(int x) { return x + 5; } // "5" is hardcoded
// Functor: carries state
Class Adder {
int value_;
public:
Adder(int v) : value_(v) {} // "v" can be anything
int operator()(int x) const { return x + value_; }
};
// You can create different adders at runtime:
Adder add3(3);
Adder add100(100);
add3(7); // 10
add100(7); // 107
Functors with STL Algorithms
Functors shine when used with algorithms. The STL was designed around them:
// Custom comparator as a functor
struct CaseInsensitiveLess {
bool operator()(const std::string& a, const std::string& b) const {
return std::lexicographical_compare(
a.begin(), a.end(), b.begin(), b.end(),
[](char c1, char c2) { return tolower(c1) < tolower(c2); }
);
}
};
// Use it with std::set for case-insensitive ordering
std::set<std::string, CaseInsensitiveLess> names;
names.insert("Alice");
names.insert("alice"); // treated as duplicate!
// Use it with std::sort
std::vector<std::string> words = {"Banana", "apple", "Cherry"};
std::sort(words.begin(), words.end(), CaseInsensitiveLess());
// words = {"apple", "Banana", "Cherry"}
Functors vs Lambdas
C++11 lambdas are actually syntactic sugar for anonymous functors. The compiler generates a class with operator() for each lambda:
// This lambda:
auto add5 = [](int x) { return x + 5; };
// Is roughly equivalent to:
struct __lambda_1 {
int operator()(int x) const { return x + 5; }
};
auto add5 = __lambda_1{};
// Capturing variables creates member variables:
int n = 5;
auto addN = [n](int x) { return x + n; };
// Is roughly:
struct __lambda_2 {
int n;
int operator()(int x) const { return x + n; }
};
auto addN = __lambda_2{5};
When to still use explicit functors over lambdas:
- When you need to name the type (e.g., as a template parameter like
std::set<T, Comparator>) - When the logic is reused across many places
- When you need multiple overloads of
operator()with different parameter types - When you need to serialize or inspect the callable
Dereference & Arrow Operators (*, ->)
Overloading * (dereference) and -> (member access) is how smart pointers, iterators, and proxy objects work. They make your wrapper types behave like raw pointers, which is essential for generic code that doesn't care whether it's working with a raw pointer, a unique_ptr, or a custom handle.
The Dereference Operator (*)
This is a unary prefix operator that returns a reference to the pointed-to object:
template<typename T>
class SmartPtr {
T* ptr_;
public:
explicit SmartPtr(T* p) : ptr_(p) {}
~SmartPtr() { delete ptr_; }
// Dereference: *sp returns the object
T& operator*() const { return *ptr_; }
// Arrow: sp->member accesses the object's members
T* operator->() const { return ptr_; }
};
SmartPtr<Widget> w(new Widget());
(*w).doWork(); // operator* returns Widget&, then call doWork()
w->doWork(); // operator-> returns Widget*, compiler then accesses doWork()
How operator-> Is Special
The arrow operator has unique behavior in C++, the compiler chains calls until it gets a raw pointer. When you write w->doWork():
- The compiler calls
w.operator->(), which returnsT*(a raw pointer). - The compiler then applies
->to that raw pointer:ptr_->doWork().
If operator-> returns another object (not a raw pointer), the compiler calls operator-> on that object, and so on, until it reaches a raw pointer. This chaining enables proxy patterns:
// Proxy that logs access
struct LoggingProxy {
Widget* target_;
LoggingProxy(Widget* t) : target_(t) { std::cout << "Accessing widget\n"; }
Widget* operator->() { return target_; }
~LoggingProxy() { std::cout << "Done accessing\n"; }
};
class LoggingPtr {
Widget* ptr_;
public:
LoggingProxy operator->() { return LoggingProxy(ptr_); }
// w->doWork() calls:
// 1. LoggingPtr::operator->() → returns LoggingProxy
// 2. LoggingProxy::operator->() → returns Widget*
// 3. Widget*->doWork()
};
Const Correctness
Notice that operator* and operator-> are const member functions that return non-const references/pointers. This matches raw pointer semantics: a const pointer (Widget* const p) can't be reassigned, but you can still modify the object it points to. If you want to propagate constness (a const SmartPtr should give read-only access), you need separate const and non-const overloads:
T& operator*() { return *ptr_; } // mutable access
const T& operator*() const { return *ptr_; } // read-only access
T* operator->() { return ptr_; }
const T* operator->() const { return ptr_; }
std::unique_ptr and std::shared_ptr do not propagate constness (matching raw pointer behavior). C++20's std::experimental::propagate_const wrapper exists for when you want this.
Increment & Decrement (++, --)
Incrementing and decrementing is common for iterators, counters, date types, and any type that represents a position in a sequence. The tricky part is distinguishing prefix (++x) from postfix (x++), they have the same operator symbol but different semantics and different signatures.
The Dummy int Convention
C++ uses a clever (if inelegant) hack: postfix versions take a dummy int parameter that's never used. It's purely a syntactic marker to distinguish the two forms:
class Counter {
int value_;
public:
explicit Counter(int v = 0) : value_(v) {}
// Prefix: ++c (increment FIRST, return NEW value)
Counter& operator++() {
++value_;
return *this; // return reference to modified object
}
// Postfix: c++ (save OLD value, increment, return OLD)
Counter operator++(int) { // <-- dummy int = postfix marker
Counter old = *this; // save a copy of current state
++value_; // increment
return old; // return the OLD copy
}
int get() const { return value_; }
};
Why Prefix Is More Efficient
Look at the implementations carefully:
- Prefix
++c: Modifies the object in-place and returns a reference to itself. No copy needed. - Postfix
c++: Must create a copy of the object before incrementing, then return that copy by value. For trivial types likeint, the compiler optimizes this away. For types with expensive copies (large objects, objects that allocate memory), this matters.
This is why the C++ community convention is: prefer prefix when you don't need the old value. In a for loop, ++i is preferred over i++ (though for int, modern compilers generate identical code).
Implementing One in Terms of the Other
The canonical approach: implement prefix, then define postfix using prefix:
// Prefix: the "real" implementation
Counter& Counter::operator++() {
++value_;
return *this;
}
// Postfix: delegates to prefix
Counter Counter::operator++(int) {
Counter old = *this; // copy
++(*this); // use PREFIX to do the actual increment
return old;
}
This way, the increment logic lives in one place (prefix). If you later change what "increment" means (e.g., skip weekends for a Date class), you only update one function.
Iterator Example
Iterators are the most common real-world use of overloaded ++:
template<typename T>
class LinkedListIterator {
Node<T>* current_;
public:
// Prefix: advance and return
LinkedListIterator& operator++() {
current_ = current_->next;
return *this;
}
// Postfix: return old position, then advance
LinkedListIterator operator++(int) {
LinkedListIterator old = *this;
++(*this);
return old;
}
T& operator*() const { return current_->data; }
bool operator!=(const LinkedListIterator& other) const {
return current_ != other.current_;
}
};
// Usage with range-based for:
for (auto it = list.begin(); it != list.end(); ++it) {
std::cout << *it << "\n";
}
▶ Prefix vs Postfix Increment
Watch how prefix returns the new value while postfix returns the old value.
Conversion Operators
Conversion operators let your type automatically (or explicitly) convert to another type. They're the inverse of converting constructors: a constructor converts from another type to yours; a conversion operator converts from yours to another type.
Syntax
Conversion operators have no return type in their declaration, the return type is the type you're converting to:
class Percentage {
double value_;
public:
explicit Percentage(double v) : value_(v) {}
// Convert to double: value/100
explicit operator double() const { return value_ / 100.0; }
// Convert to bool: is non-zero?
explicit operator bool() const { return value_ != 0.0; }
};
Implicit vs Explicit
This is the most important decision for conversion operators. Always use explicit unless you have a very good reason not to:
class BadPercentage {
double value_;
public:
BadPercentage(double v) : value_(v) {}
operator double() const { return value_ / 100.0; } // IMPLICIT — dangerous!
};
BadPercentage p(75);
double x = p; // OK: implicit conversion to 0.75
double y = p + 1.0; // "Surprise!" — p converts to 0.75, not 75 + 1.0
// Was the programmer expecting 0.75 + 1.0 = 1.75? Or 76.0?
// Implicit conversions create ambiguity.
// With explicit:
class GoodPercentage {
double value_;
public:
explicit operator double() const { return value_ / 100.0; }
};
GoodPercentage p(75);
// double x = p; // ERROR: explicit conversion required
double x = static_cast<double>(p); // OK: programmer clearly intended this
// double y = p + 1.0; // ERROR: no implicit conversion
The Special Case of operator bool()
explicit operator bool() is special: even though it's explicit, it can be used in boolean contexts without a cast. This is called "contextual conversion" and applies in:
if (obj),while (obj),for (...; obj; ...)!obj,obj && other,obj || otherobj ? a : b
class Connection {
public:
explicit operator bool() const { return isOpen(); }
};
Connection conn = connect();
if (conn) { /* works — contextual conversion to bool */ }
bool b = conn; // ERROR: not a boolean context
bool b = static_cast<bool>(conn); // OK: explicit cast
This is how std::optional, std::unique_ptr, std::shared_ptr, and stream objects work in if statements. They all have explicit operator bool().
The Classic Safe Bool Problem
Before C++11, there was no explicit keyword for conversion operators. Implicit operator bool() caused infamous bugs:
// Pre-C++11: implicit operator bool
class OldStream {
public:
operator bool() const { return good(); } // implicit!
};
OldStream a, b;
int x = a + b; // Compiles! bool converts to int. a + b = 0, 1, or 2.
a << 3; // Compiles! bool converts to int, then shifts. Nonsense.
if (a == b) {} // Compiles! Compares bools, not stream states.
// None of these make sense, but they all compile silently.
The pre-C++11 workaround was the "safe bool idiom" (converting to a pointer-to-member instead of bool). C++11's explicit operator bool made all of that unnecessary.
explicit on all conversion operators. The only exception is rare cases where implicit conversion is truly natural and unambiguous (which is almost never).
Best Practices & Common Pitfalls
Do's
- Respect semantics:
+should add,==should compare equality,<<should output to a stream. Don't be clever. If someone readsa + b, they expect addition-like behavior. The entire value of operator overloading comes from leveraging existing intuition about what operators mean. - Be consistent: If you overload
==, overload!=too (or use the spaceship operator). If you overload+, overload+=. Users expect the full set.a + bshould give the same result asauto c = a; c += b;. - Implement compound first: Define
+=, then write+in terms of it. Same for-=/-,*=/*, etc. This eliminates logic duplication. - Return the right type:
+returns by value,+=returns a reference to*this,==returnsbool. Returning the wrong type causes subtle bugs or prevents chaining. - Use
friendfor symmetry: Free function operators allow implicit conversions on both operands. This makes1 + myObjandmyObj + 1both work. - Mark
constcorrectly: Operators that don't modify the object (comparisons, arithmetic, dereference) should beconst. Compound assignments (+=) should not. - Prefer
explicitfor conversions: Implicit conversions are a leading source of hard-to-find bugs. Be explicit unless the conversion is truly natural.
Don'ts
- Don't overload
&&,||, or,: Overloading disables short-circuit evaluation. Built-in&&doesn't evaluate the right side if the left is false. Overloaded&&evaluates both sides (because it's just a function call). This breaks expectations and can cause bugs (e.g.,ptr && ptr->isValid()would crash ifptris null). - Don't overload operators with non-obvious meanings:
operator+on aDatabaseclass to "merge databases" is confusing. Use a named function:db.merge(otherDb). - Don't change observable behavior:
a + bshould always equalb + aif your type is mathematically commutative.a == ashould always be true. Don't violate mathematical properties that users assume. - Don't forget exception safety: If
operator+=modifies multiple members and the second modification throws, the object is left in a partially-modified state. Use the copy-and-swap idiom for strong exception safety when needed. - Don't return references to temporaries:
operator+must return by value. Returning a reference to a local would be a dangling reference (undefined behavior).
The Copy-and-Swap Idiom
For operator= (assignment), the copy-and-swap idiom provides strong exception safety and handles self-assignment automatically:
class String {
char* data_;
size_t size_;
public:
// Copy assignment using copy-and-swap
String& operator=(String other) { // note: passed BY VALUE (makes a copy)
swap(*this, other); // swap our guts with the copy's guts
return *this;
} // old data destroyed when 'other' goes out of scope
friend void swap(String& a, String& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
};
If the copy (parameter passing) throws, the original object is untouched. If it succeeds, the swap is noexcept, so the assignment either fully succeeds or doesn't happen at all.
Operators You Cannot Overload
These operators are fixed by the language and cannot be user-defined, no matter what:
| Operator | Name | Why It Can't Be Overloaded |
|---|---|---|
:: | Scope resolution | Resolved at compile time; operates on names, not values |
. | Member access | Must reliably access the actual member; overloading would break the ability to reach any member |
.* | Pointer-to-member | Same reason as . |
?: | Ternary conditional | Relies on short-circuit evaluation (only evaluates one branch) |
sizeof | Size query | Compile-time operator; not a function call |
alignof | Alignment query | Same — compile-time intrinsic |
typeid | Type identification | Runtime type info; fundamental language mechanism |
Also note: you cannot create entirely new operators (like ** for exponentiation or <- for assignment). You can only overload existing C++ operators. The operator symbol, precedence, and associativity are all fixed.
Quick Reference
This table summarizes the canonical way to define each operator category:
| Operator | Define As | Returns | Notes |
|---|---|---|---|
+, -, *, / | Free function (friend) | By value | Define via compound assignment. Take lhs by value. |
+=, -=, etc. | Member | Reference to *this | Contains the real logic. Compound first, binary second. |
==, <=> | Member or friend | bool / ordering | C++20: one <=> generates all six. Must define == separately for custom <=>. |
<<, >> | Free function (friend) | Stream reference | Left operand is the stream. Return it for chaining. |
[] | Member | Reference | Provide const and non-const overloads. C++23: multi-arg. |
() | Member | Anything | Makes functors. Can have multiple overloads with different params. |
*, -> | Member | Reference / pointer | Arrow chains until raw pointer. Used for smart pointers & iterators. |
++ (prefix) | Member | Reference to *this | No parameter. Modifies in-place. More efficient than postfix. |
++ (postfix) | Member (dummy int) | By value (old copy) | Dummy int parameter. Returns copy of old state. |
operator T() | Member | Implicit in name | Always use explicit. explicit operator bool works in if/while. |
= | Member | Reference to *this | Consider copy-and-swap for exception safety. |