Four versions went into a std::set. It kept two. The operator< used to order them compared only the major version number, so the set treated 2.1.0, 2.4.3 and 2.0.9 as the same element and silently discarded two of them. Nothing warned. That is the real subject of operator overloading: not the syntax, which is small, but the promises an operator makes to the code that uses it.
This guide covers the rules, the canonical signature for each kind of operator, when to write a member and when a non-member, why compound assignment returns a non-const reference, and C++20’s defaulted operator<=> and operator==. It also covers the copy assignment operator’s self-assignment trap and the operators you should not overload at all. Every program was compiled and run for this article on Ubuntu 24.04 with g++ 13.3 and clang++ 18.1.3 under -std=c++20 and -std=c++23, with -Wall -Wextra -pedantic. The working examples produce zero warnings and run clean under AddressSanitizer and UndefinedBehaviorSanitizer; the deliberately broken ones are shown with the diagnostics they actually produced. All output is captured verbatim.
What Is Operator Overloading in C++?
Operator overloading in C++ lets a class define what built-in operators such as +, ==, << and [] mean for its objects, by writing a function named operator+, operator== and so on. At least one operand must be a user-defined type, and overloading cannot create new operators or change an operator’s precedence, associativity or number of operands.
When the compiler sees a + b and a or b has class type (see classes in C++ for the basics), it looks for a function called operator+ and calls it, either as a.operator+(b) or as operator+(a, b). The expression reads like arithmetic, and the call is an ordinary function call that the optimizer can inline like any other. The full rules, including which operators take which forms, are on cppreference’s operator overloading page.
Almost every operator can be overloaded. The exceptions are ::, ., .*, ?:, sizeof, alignof, typeid and noexcept. For some of these there are firm reasons. Bjarne Stroustrup’s C++ FAQ explains, for example, that pointer arithmetic depends on sizeof, so letting a class redefine it would break the language’s own rules. For ?: he writes that there is “no fundamental reason to disallow overloading”, adding that an overload could not guarantee only one branch is evaluated.
Canonical Operator Signatures
Each kind of operator has a conventional signature, and following it makes an overloaded operator behave like the built-in one:
| Operator | Canonical signature | Member or non-member |
|---|---|---|
| Copy assignment | T& T::operator=(const T& other) | Member (required) |
| Compound assignment | T& T::operator+=(const T& rhs) | Member |
| Binary arithmetic | T operator+(T lhs, const T& rhs) | Non-member (usually a friend) |
| Prefix increment | T& T::operator++() | Member |
| Postfix increment | T T::operator++(int) | Member |
| Equality | bool operator==(const T&, const T&) | Non-member, or member; often defaulted |
| Three-way comparison (C++20) | auto operator<=>(const T&, const T&) | Non-member, or member; often defaulted |
| Stream output | std::ostream& operator<<(std::ostream&, const T&) | Non-member (required in practice) |
| Subscript | R& T::operator[](std::size_t), plus a const overload | Member (required); C++23 also allows a[i, j] |
| Function call | R T::operator()(args...) | Member (required); may be static since C++23 |
| Conversion | explicit T::operator bool() const | Member (required) |
Every signature in the table was compiled together in one class with both compilers. One side effect showed up in the process: declaring operator= in a class that did not also declare a copy constructor made clang++ warn that the implicit copy constructor is deprecated (-Wdeprecated-copy, enabled by -Wextra). If a class needs a custom copy assignment, it almost always needs the matching copy constructor too, and usually neither.
Here are those conventions applied to a small value type:
// money.cpp - canonical operator signatures on a small value type (C++20)
#include <compare>
#include <cstdint>
#include <iomanip>
#include <iostream>
class Money {
public:
constexpr Money() = default;
constexpr explicit Money(std::int64_t cents) : cents_(cents) {}
// Compound assignment: members, modify *this, return *this by reference.
Money& operator+=(const Money& rhs) { cents_ += rhs.cents_; return *this; }
Money& operator-=(const Money& rhs) { cents_ -= rhs.cents_; return *this; }
Money& operator*=(std::int64_t k) { cents_ *= k; return *this; }
// Unary minus: returns a new value.
Money operator-() const { return Money(-cents_); }
// Binary arithmetic: non-members, built on the compound forms, return by value.
friend Money operator+(Money lhs, const Money& rhs) { return lhs += rhs; }
friend Money operator-(Money lhs, const Money& rhs) { return lhs -= rhs; }
friend Money operator*(Money lhs, std::int64_t k) { return lhs *= k; }
friend Money operator*(std::int64_t k, Money rhs) { return rhs *= k; }
// Comparisons: one defaulted line gives ==, !=, <, >, <= and >=.
friend auto operator<=>(const Money&, const Money&) = default;
// Stream output: must be a non-member, because the left operand is the stream.
friend std::ostream& operator<<(std::ostream& os, const Money& m) {
std::int64_t c = m.cents_ < 0 ? -m.cents_ : m.cents_;
return os << (m.cents_ < 0 ? "-$" : "$") << c / 100 << '.'
<< std::setw(2) << std::setfill('0') << c % 100 << std::setfill(' ');
}
private:
std::int64_t cents_ = 0;
};
int main()
{
Money price(1999), tax(160);
Money total = price + tax;
std::cout << "price + tax = " << total << '\n';
std::cout << "3 * price = " << 3 * price << '\n';
std::cout << "price * 3 = " << price * 3 << '\n';
std::cout << "-tax = " << -tax << '\n';
total -= Money(500);
std::cout << "after -= $5 = " << total << '\n';
std::cout << std::boolalpha
<< "price < total: " << (price < total) << '\n'
<< "price != total: " << (price != total) << '\n'
<< "price >= price: " << (price >= price) << '\n';
}
Output:
price + tax = $21.59
3 * price = $59.97
price * 3 = $59.97
-tax = -$1.60
after -= $5 = $16.59
price < total: false
price != total: true
price >= price: true
Three conventions do most of the work:
- Write
+=first, then build+on it. The binary operator takes its left operand by value, which is a copy it can modify, then applies+=and returns the result. The arithmetic lives in one place. - Return by value from operators that create a new value, and by reference from operators that modify
*this.a + bproduces a newMoney;a += bchangesaand returns it. - The constructor is
explicit.Money m = 5;does not compile, so anintcannot quietly become a number of cents. For money, that is the right design.
A product of two Money values has no sensible meaning, so there is no operator*(Money, Money). The class overloads * only for a whole-number factor, and it offers no division at all. Leaving an operator out is part of the design, not a gap in it.
One thing Money leaves out to stay short is overflow checking. cents_ += rhs.cents_ can exceed the range of std::int64_t, and negating the smallest value, in operator- and in operator<<, cannot be represented. Both are undefined behavior, and UndefinedBehaviorSanitizer reported both when tested with those values. A production money type would check its arithmetic before performing it.
Member or Non-Member?
The choice follows from four questions:
The third and fourth questions come down to one rule: a member operator’s left operand must be an object of the class. price * 3 calls price.operator*(3). For 3 * price there is no int::operator* to call. With operator* written as a member, the second form fails:
// member_star.cpp - operator* as a member works in only one direction
#include <cstdint>
class Money {
public:
explicit Money(std::int64_t cents) : cents_(cents) {}
Money operator*(std::int64_t k) const { return Money(cents_ * k); } // member
private:
std::int64_t cents_;
};
int main()
{
Money price(1999);
Money a = price * 3; // price.operator*(3): fine
Money b = 3 * price; // no int::operator*(Money) exists
(void)a; (void)b;
}
error: no match for 'operator*' (operand types are 'int' and 'Money') (g++)
error: invalid operands to binary expression ('int' and 'Money') (clang++)
Stream output has the same problem in a stronger form. In std::cout << m, the left operand is a std::ostream, a class you cannot add members to, so operator<< must be a non-member.
Hidden friends. Money declares its non-member operators as friend functions defined inside the class. A friend defined this way is a non-member that can see private data, and it has a second advantage: ordinary name lookup does not find it. The compiler finds it only through argument-dependent lookup, when one of the operands is a Money. That keeps the operators out of unrelated overload resolution, which shortens error messages and compile times in large code bases. A friend declared like this is part of the class’s interface, not a hole in it; the guide to encapsulation in C++ discusses where friends fit.
Why operator+= Returns T&, Not const T&
Older advice has compound assignment return const T&, so that (a += b) = c will not compile, on the grounds that this matches the built-in operators. It does not match them. For built-in types, += returns an lvalue, and the expression is legal:
#include <cstdio>
struct BigInt {
int v;
const BigInt& operator+=(const BigInt& rhs) { v += rhs.v; return *this; }
};
int main()
{
int a = 1, b = 2;
(a += b) = 10; // legal for int
std::printf("int: a = %d\n", a);
BigInt x{1}, y{2};
(x += y) = y; // with a const T& return: rejected
}
The int line compiled and printed int: a = 10. The BigInt line did not compile:
error: passing 'const BigInt' as 'this' argument discards qualifiers [-fpermissive] (g++)
error: no viable overloaded '=' (clang++)
Nobody should write (a += b) = 10 on purpose. The point is that the const return makes the class behave differently from int, which contradicts the reason given for it. It also blocks ordinary chaining such as f(a += b) when f takes a non-const reference. Return T&, as the standard library’s own types do.
Comparisons in C++20: operator<=> and operator==
Before C++20, a fully comparable class needed six functions: ==, !=, <, >, <= and >=. It was usual to write two and build the other four on them, which is a lot of boilerplate for one decision. C++20 replaces all of it with the three-way comparison operator, <=>, often called the spaceship operator. The expression a <=> b returns an ordering object that says whether a is less than, equal to or greater than b, and the compiler rewrites a < b as (a <=> b) < 0.
Defaulting it compares the members in declaration order, which is exactly the lexicographic comparison a version number needs:
// goodless.cpp - the same set with a defaulted <=>
#include <compare>
#include <iostream>
#include <set>
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default; // major, then minor, then patch
};
int main()
{
std::set<Version> installed{{2, 1, 0}, {2, 4, 3}, {3, 0, 0}, {2, 0, 9}};
std::cout << "inserted 4 versions, set holds " << installed.size() << ":\n";
for (const Version& v : installed)
std::cout << " " << v.major << '.' << v.minor << '.' << v.patch << '\n';
Version a{2, 4, 3}, b{2, 10, 0};
std::cout << std::boolalpha << "2.4.3 < 2.10.0: " << (a < b)
<< ", 2.4.3 == 2.4.3: " << (a == Version{2, 4, 3}) << '\n';
}
Output:
inserted 4 versions, set holds 4:
2.0.9
2.1.0
2.4.3
3.0.0
2.4.3 < 2.10.0: true, 2.4.3 == 2.4.3: true
One line produced all six comparisons. Defaulting <=> also implicitly declares a defaulted ==, and != is rewritten as !(a == b), so equality came for free as well. Note 2.4.3 < 2.10.0: comparing the numbers, not the text, gets that right, where a string comparison would not. The rules for what gets generated are on cppreference’s default comparisons page.
An Ordering That Ignores a Field Loses Data
Here is the example from the introduction. The same four versions, with an operator< that compares only major:
// badless.cpp - an operator< that ignores a field loses data in std::set
#include <iostream>
#include <set>
struct Version {
int major, minor, patch;
};
bool operator<(const Version& a, const Version& b) // compares major only
{
return a.major < b.major;
}
int main()
{
std::set<Version> installed{{2, 1, 0}, {2, 4, 3}, {3, 0, 0}, {2, 0, 9}};
std::cout << "inserted 4 versions, set holds " << installed.size() << ":\n";
for (const Version& v : installed)
std::cout << " " << v.major << '.' << v.minor << '.' << v.patch << '\n';
}
Output (g++ and clang++, identical):
inserted 4 versions, set holds 2:
2.1.0
3.0.0
std::set treats two elements as the same when neither is less than the other. Under this operator<, 2.1.0, 2.4.3 and 2.0.9 are all equivalent, so only the first to arrive was kept. The code compiles without a warning, and nothing looks wrong until data goes missing. A type’s own operator< should agree with its ==: when neither a < b nor b < a holds, a == b should hold too. A defaulted <=> guarantees that by construction. (A separate comparator handed to a std::set may deliberately treat more values as equivalent, as a case-insensitive string set does; the bug here is that nobody intended it.)
Floating-Point Members Give a Partial Order
A defaulted <=> over a double member cannot promise a total order, because NaN is not less than, equal to or greater than anything:
// nan.cpp - a defaulted <=> over a double is only a partial order
#include <cmath>
#include <compare>
#include <iostream>
#include <type_traits>
struct Reading {
double value;
auto operator<=>(const Reading&) const = default;
};
int main()
{
Reading a{1.0}, bad{std::nan("")};
std::cout << std::boolalpha
<< "result type is partial_ordering: "
<< std::is_same_v<decltype(a <=> bad), std::partial_ordering> << '\n'
<< "a < bad: " << (a < bad) << ", a > bad: " << (a > bad)
<< ", a == bad: " << (a == bad) << '\n'
<< "a <=> bad is unordered: "
<< ((a <=> bad) == std::partial_ordering::unordered) << '\n';
}
Output:
result type is partial_ordering: true
a < bad: false, a > bad: false, a == bad: false
a <=> bad is unordered: true
With auto, the compiler chose std::partial_ordering, and <, > and == with the NaN reading were all false (so were <= and >=; only != was true). That is a warning sign for sorting: a sort needs a strict weak ordering, and a sequence that contains NaN does not provide one. Asking for a stronger guarantee than the members can give is a compile error. Declaring the return type std::strong_ordering for the same struct fails with:
error: three-way comparison of 'Reading::value' has type 'std::partial_ordering', which does not convert to 'std::strong_ordering' (g++)
Use auto unless the class needs to promise a particular ordering category, and filter or reject NaN before sorting floating-point keys.
Copy Assignment and Self-Assignment
operator= is the one operator most classes should not write at all. A class whose members already manage themselves (std::string, std::vector, std::unique_ptr) gets correct assignment from the compiler: a copy assignment when every member can be copied, and, with a std::unique_ptr member, a deleted copy assignment and a working move assignment, which is exactly right for an object with one owner. This is the rule of zero. Classes that manage a raw resource have to write their own, and the obvious version has a bug that shows up only when an object is assigned to itself:
// selfassign.cpp - a copy assignment operator that frees before it copies
#include <cstddef>
#include <cstring>
#include <iostream>
class Text {
public:
explicit Text(const char* s)
: len_(std::strlen(s)), data_(new char[len_ + 1]) { std::memcpy(data_, s, len_ + 1); }
Text(const Text& other)
: len_(other.len_), data_(new char[len_ + 1]) { std::memcpy(data_, other.data_, len_ + 1); }
~Text() { delete[] data_; }
Text& operator=(const Text& other) { // naive version
delete[] data_; // frees other.data_ too if &other == this
len_ = other.len_;
data_ = new char[len_ + 1];
std::memcpy(data_, other.data_, len_ + 1);
return *this;
}
const char* c_str() const { return data_; }
private:
std::size_t len_;
char* data_;
};
int main()
{
Text a("hello"), b("world");
a = b;
std::cout << "a = b: " << a.c_str() << '\n';
Text& alias = a; // in real code: v[i] = v[j] with i == j
a = alias; // self-assignment
std::cout << "a = a: " << a.c_str() << '\n';
}
a = b printed world. a = a printed a few bytes of garbage in both the g++ and clang++ builds, and AddressSanitizer stopped the program:
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000076 at pc 0x7f10fd47d96f bp 0x7ffc0d9784f0 sp 0x7ffc0d977c98
READ of size 7 at 0x502000000076 thread T0
When other is *this, delete[] data_ frees the text before it is read. The next line then points data_, and therefore other.data_, at a fresh, uninitialized buffer, and memcpy copies that buffer onto itself. The string has lost its contents and its terminating '\0', and printing it reads past the end of the 6-byte buffer. That out-of-bounds read is what ASan reported.
Self-assignment rarely looks like a = a. It arrives as v[i] = v[j] when i == j, or through two references to the same object. The standard fix is copy-and-swap: take the parameter by value, so the copy is made before anything is destroyed, then swap:
Text& operator=(Text other) { // copy-and-swap: other is a copy
std::swap(len_, other.len_);
std::swap(data_, other.data_);
return *this; // other's destructor frees the old buffer
}
With only this function changed (and <utility> included for std::swap), the program printed:
a = b: world
a = a: world
and ran clean under AddressSanitizer. Copy-and-swap is safe for self-assignment and for exceptions: if the copy throws, *this is untouched. The simplest version of all, though, is a std::string member and no operator=.
When Not to Overload
The rules allow overloading almost every operator. Good design uses far fewer. Four guidelines cover most cases:
Do not overload &&, || or the comma operator. The built-in && and || short-circuit: the right operand is not evaluated if the left one already decides the result. An overloaded version is a function call, and a function call evaluates both arguments:
// shortcircuit.cpp - an overloaded && evaluates both operands
#include <iostream>
struct Check {
bool ok;
explicit operator bool() const { return ok; }
};
Check operator&&(const Check& a, const Check& b) { return Check{a.ok && b.ok}; }
Check expensive(const char* who)
{
std::cout << " expensive() called by " << who << '\n';
return Check{true};
}
int main()
{
bool fast = false;
std::cout << "built-in &&:\n";
if (fast && static_cast<bool>(expensive("built-in")))
std::cout << " both true\n";
Check cheap{false};
std::cout << "overloaded &&:\n";
if (cheap && expensive("overloaded"))
std::cout << " both true\n";
}
Output:
built-in &&:
overloaded &&:
expensive() called by overloaded
The built-in && never called expensive(). The overloaded one did, even though the left side was already false. Code such as p && p->valid() depends on short-circuiting to avoid a null dereference, and an overloaded && that looks identical silently removes that protection. Since C++17 the two operands are at least evaluated left to right, but both are still evaluated.
Do not overload unary &. Code that takes an object’s address expects &x to do exactly that; std::addressof exists because some classes broke this.
Give an operator the meaning a reader would guess. + should add or concatenate, and == should compare values. The standard library’s use of << for output is a long-established convention, not permission to invent new ones. If you have to explain what % means for your type, name a function instead.
Leave out operators that make no sense for the type. Money has no Money * Money and no division. A missing operator gives a compile error; a meaningless one gives wrong answers.
Key Takeaways
- An overloaded operator is a function named
operator@. You cannot create new operators or change precedence, and at least one operand must be a user-defined type. - Follow the canonical signatures. Compound assignment is a member returning
T&. Binary arithmetic is a non-member returningTby value, built on the compound form. - Symmetric operators should be non-members. A member
operator*compiled forprice * 3and failed for3 * pricewith both compilers. Stream output must be a non-member. - Return
T&, notconst T&, from compound assignment. Forint,(a += b) = 10is legal; theconstreturn made the class behave differently from the built-in type it claimed to imitate. - In C++20, default
<=>. One line gave all six comparisons in member order, and sorted 2.4.3 before 2.10.0 correctly. - A type’s own operator< must agree with its ==. One that ignored two fields made a
std::setkeep 2 of 4 versions, with no warning. - Copy assignment must survive self-assignment. The naive version corrupted the string and triggered an AddressSanitizer report; copy-and-swap fixed it, and the rule of zero avoids writing it at all.
- Don’t overload
&&,||or,. The overloaded&&evaluated both operands.
Frequently Asked Questions
Conclusion
Operator overloading gives a class the same syntax as the built-in types, and with it the same expectations. Readers assume + does not modify its operands, that a < b and b < a cannot both be true, that a = a is harmless, and that && skips its right side when it can. Every bug in this article came from an operator that looked right and broke one of those assumptions. C++20 removed much of the risk for comparisons, since a defaulted <=> cannot forget a field; the rest comes down to the canonical signatures and restraint.
Overloaded operators are one form of compile-time polymorphism; the guide to polymorphism covers the runtime kind, and the C++ programming tutorials cover classes, templates and the standard library that these operators plug into.


