C++ Operator Overloading
Operator overloading lets you redefine what operators like +, ==, and << mean when applied to objects of your own classes. Instead of writing add(a, b) for two Complex numbers, you can write a + b and have the compiler call your own function behind the scenes. This makes user-defined types feel like built-in types and is one of the features that gives C++ its expressive, “as if built-in” library style (think std::string using + to concatenate, or std::cout using << to print).
Overview: How Operator Overloading Works
In C++, an operator is really just a function with a special name and a special calling syntax. When the compiler sees a + b, it looks for a function named operator+ that accepts the types of a and b. If it finds one — either as a member of the class or as a free (non-member) function — it rewrites the expression as a call to that function: a.operator+(b) or operator+(a, b). Everything else about the language (overload resolution, implicit conversions, const-correctness) applies exactly as it does to ordinary functions.
You can overload operators in two ways:
- As a member function. The left-hand operand becomes the implicit
thisobject, sooperator+(const Complex& rhs)is called aslhs.operator+(rhs), i.e.lhs + rhs. Member overloads are natural for operators that modify the object, likeoperator=,operator+=, andoperator++. - As a non-member (often
friend) function. Both operands are passed explicitly as parameters. This is required when the left-hand operand isn’t your class type (for example, allowing2 + complexNumber) and is the conventional choice foroperator<<, since the left operand is astd::ostream, not your class.
Under the hood, nothing magical happens: the compiler performs normal function overload resolution based on the operand types, then emits a call instruction to whichever operator function matches. There is no runtime dispatch table involved (unless the function happens to be virtual) — it is resolved entirely at compile time, just like any other overloaded function call. Almost every operator in C++ can be overloaded (+, -, *, /, ==, <, [], (), ->, ++, --, and more), with a few exceptions: ::, ., .*, and ?: cannot be overloaded, and you cannot invent brand-new operator symbols or change an operator’s arity (unary stays unary, binary stays binary) or its precedence.
Syntax
// Member function form
ReturnType operator@(const ClassName& rhs) [const];
// Non-member function form
ReturnType operator@(const ClassName& lhs, const ClassName& rhs);
| Part | Meaning |
|---|---|
operator@ |
The special function name, where @ is the actual operator symbol, e.g. operator+, operator==, operator<<. |
ReturnType |
What the expression evaluates to. Arithmetic operators usually return a new object by value; comparisons return bool; operator<< returns ostream& so output can be chained. |
const ClassName& rhs |
The right-hand operand, almost always taken by const reference to avoid unnecessary copies. |
trailing const |
On a member function, marks it as not modifying the object — required for operators like + and == that only read the operand’s state. |
Examples
Example 1: Overloading + as a member function
#include <iostream>
using namespace std;
class Point {
public:
double x, y;
Point(double x = 0, double y = 0) : x(x), y(y) {}
// Overload + as a member function
Point operator+(const Point& other) const {
return Point(x + other.x, y + other.y);
}
};
int main() {
Point a(1.5, 2.0);
Point b(3.0, 4.5);
Point c = a + b;
cout << "c.x = " << c.x << ", c.y = " << c.y << endl;
return 0;
}
Output:
c.x = 4.5, c.y = 6.5
The expression a + b is rewritten by the compiler as a.operator+(b). Inside the function, x and y refer to a‘s members (since it’s called on a), while other.x and other.y refer to b‘s. A brand-new Point is constructed and returned — neither a nor b is modified.
Example 2: Multiple operators together (member and friend)
#include <iostream>
using namespace std;
class Complex {
private:
double re, im;
public:
Complex(double re = 0, double im = 0) : re(re), im(im) {}
Complex operator+(const Complex& rhs) const {
return Complex(re + rhs.re, im + rhs.im);
}
bool operator==(const Complex& rhs) const {
return re == rhs.re && im == rhs.im;
}
friend ostream& operator<<(ostream& os, const Complex& c);
};
ostream& operator<<(ostream& os, const Complex& c) {
os << c.re;
if (c.im >= 0) os << "+" << c.im << "i";
else os << c.im << "i";
return os;
}
int main() {
Complex a(2, 3);
Complex b(1, -4);
Complex sum = a + b;
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "a + b = " << sum << endl;
if (a == Complex(2, 3))
cout << "a equals 2+3i" << endl;
return 0;
}
Output:
a = 2+3i
b = 1-4i
a + b = 3-1i
a equals 2+3i
operator<< is declared friend so the free function can reach re and im, which are private. It must be non-member because the left operand of cout << a is a ostream, not a Complex — you cannot add a member function to a class you don’t own. It returns ostream& so calls like cout << a << endl can chain. operator== compares the two private members directly and returns a plain bool.
Example 3: Prefix vs. postfix increment
#include <iostream>
using namespace std;
class Counter {
private:
int value;
public:
Counter(int value = 0) : value(value) {}
// Prefix ++counter
Counter& operator++() {
++value;
return *this;
}
// Postfix counter++
Counter operator++(int) {
Counter temp = *this;
++value;
return temp;
}
int get() const { return value; }
};
int main() {
Counter c(5);
Counter afterPrefix = ++c;
cout << "After ++c: c = " << c.get() << ", afterPrefix = " << afterPrefix.get() << endl;
Counter afterPostfix = c++;
cout << "After c++: c = " << c.get() << ", afterPostfix = " << afterPostfix.get() << endl;
return 0;
}
Output:
After ++c: c = 6, afterPrefix = 6
After c++: c = 7, afterPostfix = 6
C++ distinguishes prefix from postfix purely by a dummy int parameter that is never used — it exists only so the compiler can pick a different overload. Prefix operator++() increments and returns a reference to the now-updated object, matching how ++x behaves for built-in types. Postfix operator++(int) must save a copy of the old state, increment the real object, and return the saved copy by value, which is why c++ is slightly more expensive than ++c for non-trivial types.
How It Works Step by Step
- Parsing: the compiler sees an expression like
a + band notes the operator symbol and the types of both operands. - Lookup: it searches for a matching
operator+— first as a member ofa‘s type, then among non-member candidates found via argument-dependent lookup. - Overload resolution: if several candidates match (including ones reachable through implicit conversions or constructors), the compiler picks the best match using the same rules as any function call; ambiguity is a compile error.
- Rewrite: the expression is rewritten as an ordinary function call —
a.operator+(b)for a member, oroperator+(a, b)for a non-member — and compiled exactly like any other call, with the same argument-passing and return-value mechanics. - Execution: at run time there is nothing special going on; the CPU just executes the chosen function’s instructions. The “overload” only affects which code gets selected, decided entirely at compile time.
Common Mistakes
Mistake 1: Making + a member-only function blocks left-hand conversions
If operator+ is only a member function, the left operand must already be your class type, so mixed expressions with the class on the right fail to compile:
class Complex {
public:
double re, im;
Complex(double re = 0, double im = 0) : re(re), im(im) {}
Complex operator+(const Complex& rhs) const {
return Complex(re + rhs.re, im + rhs.im);
}
};
Complex a(2, 3);
Complex result = 2 + a; // error: no match for 'operator+' (2 is int, not Complex)
a + 2 works, because the compiler can implicitly convert 2 to a Complex and call a.operator+(Complex(2)). But 2 + a fails: the compiler looks for int::operator+, which doesn’t exist. The fix is to make operator+ a non-member (often friend) function so both sides can be converted symmetrically:
class Complex {
public:
double re, im;
Complex(double re = 0, double im = 0) : re(re), im(im) {}
};
Complex operator+(const Complex& lhs, const Complex& rhs) {
return Complex(lhs.re + rhs.re, lhs.im + rhs.im);
}
Complex a(2, 3);
Complex result = 2 + a; // works: 2 converts to Complex, then non-member operator+ is used
Mistake 2: Forgetting the self-assignment check in operator=
When a class manages its own dynamic memory, a naive operator= that frees the old buffer before checking whether the source and target are the same object corrupts the data during self-assignment (buf = buf):
class Buffer {
private:
int* data;
int size;
public:
Buffer(int size) : size(size), data(new int[size]) {}
Buffer& operator=(const Buffer& other) {
delete[] data; // BUG: deletes data before checking self-assignment
data = new int[other.size];
size = other.size;
for (int i = 0; i < size; ++i) data[i] = other.data[i];
return *this;
}
~Buffer() { delete[] data; }
};
If other is the same object as *this, delete[] data frees the memory, and the following loop then reads from other.data, which now points to freed memory — undefined behavior. Guard against this with a self-assignment check, and always return *this by reference so assignments can be chained:
class Buffer {
private:
int* data;
int size;
public:
Buffer(int size) : size(size), data(new int[size]) {}
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this; // guard against self-assignment
delete[] data;
data = new int[other.size];
size = other.size;
for (int i = 0; i < size; ++i) data[i] = other.data[i];
return *this;
}
~Buffer() { delete[] data; }
};
Best Practices
- Keep overloaded operators behaving the way programmers expect from built-in types:
+shouldn’t have side effects, and==should be a true equivalence check, not something with hidden mutation. - Prefer non-member (often
friend) functions for symmetric binary operators like+,-, and==so both operands support implicit conversions equally. - Implement
operator<<andoperator>>as non-member functions, since the stream is always the left operand and you don’t controlstd::ostream/std::istream. - Mark member operators
constwhenever they don’t modify the object, so they can be called onconstinstances and used inside otherconstmethods. - Take operands by
const&to avoid unnecessary copies, and return new objects by value (never a reference to a local variable). - For compound-assignment operators such as
operator+=, return*thisby reference; implementoperator+in terms ofoperator+=to avoid duplicating logic. - When overloading
operator=, always guard against self-assignment and return*thisby reference. - Don’t overload an operator with a meaning unrelated to its conventional use (e.g. using
+to mean “send a network request”) — surprising semantics make code harder to read, not easier.
Practice Exercises
- Exercise 1: Write a
Vector2Dclass withxandymembers. Overloadoperator-(member function) to subtract two vectors, andoperator*(non-member function) to multiply aVector2Dby adoublescalar so that bothv * 2.0and2.0 * vcompile. - Exercise 2: Add
operator<to theComplexclass from Example 2 that compares complex numbers by magnitude (re*re + im*im). Use it to sort astd::vector<Complex>withstd::sort. - Exercise 3: Extend the
Counterclass from Example 3 with a compound-assignment operatoroperator+=(int n)that addsnto the internal value and returns*thisby reference. Then implement a freeoperator+(Counter c, int n)in terms of it. Expected: forCounter c(10); c += 5;the value should be 15.
Summary
- Operator overloading lets user-defined types use the same operator syntax as built-in types by defining special functions named
operator@. - Overloads can be member functions (implicit left operand, natural for mutating operators like
=and++) or non-member/friendfunctions (explicit operands, required when the left side isn’t your class, as with<<or mixed-type+). - Resolution happens entirely at compile time via normal function overload resolution — there is no runtime cost beyond the function call itself.
- Prefix and postfix
++/--are distinguished by a dummyintparameter on the postfix version. - Always guard
operator=against self-assignment, return*thisby reference from assignment operators, and keep overloaded operators behaving the way readers expect.
