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 this object, so operator+(const Complex& rhs) is called as lhs.operator+(rhs), i.e. lhs + rhs. Member overloads are natural for operators that modify the object, like operator=, operator+=, and operator++.
  • 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, allowing 2 + complexNumber) and is the conventional choice for operator<<, since the left operand is a std::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 + b and notes the operator symbol and the types of both operands.
  • Lookup: it searches for a matching operator+ — first as a member of a‘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, or operator+(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<< and operator>> as non-member functions, since the stream is always the left operand and you don’t control std::ostream/std::istream.
  • Mark member operators const whenever they don’t modify the object, so they can be called on const instances and used inside other const methods.
  • 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 *this by reference; implement operator+ in terms of operator+= to avoid duplicating logic.
  • When overloading operator=, always guard against self-assignment and return *this by 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 Vector2D class with x and y members. Overload operator- (member function) to subtract two vectors, and operator* (non-member function) to multiply a Vector2D by a double scalar so that both v * 2.0 and 2.0 * v compile.
  • Exercise 2: Add operator< to the Complex class from Example 2 that compares complex numbers by magnitude (re*re + im*im). Use it to sort a std::vector<Complex> with std::sort.
  • Exercise 3: Extend the Counter class from Example 3 with a compound-assignment operator operator+=(int n) that adds n to the internal value and returns *this by reference. Then implement a free operator+(Counter c, int n) in terms of it. Expected: for Counter 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/friend functions (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 dummy int parameter on the postfix version.
  • Always guard operator= against self-assignment, return *this by reference from assignment operators, and keep overloaded operators behaving the way readers expect.