Operator overloading defines how an existing operator handles class or enumeration objects, giving value types syntax that matches their domain meaning.
The compiler checks candidates, conversions, and return types, but it can’t guarantee that + leaves its left operand alone, comparisons agree, or an overloaded && short-circuits.
Write the semantic contract first, then choose a member, non-member, or hidden friend. Test symmetric calls, boundary values, and algebraic properties.
What it is and why it exists
Operator overloading declares an operator function for an existing C++ operator. When at least one expression operand has class or enumeration type, the compiler may choose that function instead of a built-in operation. Expressions such as total + tax, version < candidate, and scores[index] can then state domain operations directly.
This is an interface feature. If a distance, amount, or complex number has value semantics , spelling addition as add(left, right) throws away the familiar structure of arithmetic. Iterators, smart handles, and function objects similarly use *, ->, or () to take part in generic interfaces.
Symbols don’t rescue a weak design. If the relationship between two operands has no accepted meaning, merge_with_policy() is usually clearer than a strained overload of |. Readers also expect + to produce a new value, += to change the left side, and == to agree with ordering; breaking those conventions needs an unusually good reason.
An overload can’t create a new symbol or alter precedence, grouping, or operand count. ., .*, ::, and ?: can’t be overloaded, while sizeof, typeid, and alignof aren’t operator-function names either. The language still controls syntax; the type author controls only the behavior after a function is selected.
You’ll meet overloaded operators in numeric classes, containers, iterators, smart pointers, stream output, comparisons, and callable objects. Assignment and resource ownership belong to special-member design, while the call operator belongs to function-object design. This topic explains how they enter the same operator machinery and leaves their full designs to the related topics.
How it works
You can roughly read left + right as left.operator+(right) or operator+(left, right), but the compiler doesn’t try those spellings in a fixed order. It builds candidate sets and runs overload resolution , comparing viability and conversion sequences before selecting one unique best function.
The sets can include member candidates, non-member candidates found through ordinary and argument-dependent lookup, built-in candidates, and the rewritten candidates used by C++20 comparisons. If no operand has class or enumeration type, only built-in operations are considered. Declaring operator+(int, int) can’t replace integer addition.
A member function treats the left operand as its implicit object. A non-member writes both operands as parameters, which usually fits a symmetric binary operation better. operator=, operator[], operator(), and operator-> must be members. To support std::cout << value, however, operator<< must be a non-member because you can’t add a member to the left-hand std::ostream type.
friend isn’t required for a non-member operator; it merely grants access to private members. A friend operator defined inside a class definition is still a non-member, usually found only through argument-dependent lookup (ADL) . This hidden friend keeps the operation beside its associated type without exposing another broadly visible name to ordinary unqualified lookup.
The member form affects conversion symmetry. For value + 2, a member value.operator+(2) can convert the right argument. But the left side of 2 + value isn’t an object of the class, so that same member doesn’t become callable. If a mixed operation should work both ways, provide the appropriate non-member overloads and decide whether converting constructors should be explicit; don’t rely on accidental conversions.
Common return types come from semantics, not a syntactic requirement:
| Operation | Usual result | Contract |
|---|---|---|
Binary arithmetic +, - | T | Return a new value without changing the operands |
Compound assignment +=, -= | T& | Change the left operand and return *this |
Prefix ++value | T& | Return the current object after changing it |
Postfix value++ | T, with a dummy int parameter | Return the value from before the change |
Subscript [] | T& and const T& | Support writable and read-only objects respectively |
Stream output << | std::ostream& | Return the original stream for chaining |
Member operators that don’t change observable state should normally be const. Compound assignment can also be the single implementation source for binary arithmetic: copy the left value, then apply += to that copy. This keeps two addition implementations from drifting apart.
Examples
These four examples cover value operations, mixed operands, subscript references, and explicit conversion in that order. Every output below came from GCC 13.3 with -std=c++23, with address and undefined-behavior sanitizers enabled.
Building addition from compound assignment
Distance::operator+= changes the current object and returns a reference. The hidden operator+ takes its left-side copy by value and reuses +=, so + leaves the original route unchanged.
#include <iostream>
class Distance {
int metres_;
public:
explicit constexpr Distance(int metres) : metres_(metres) {}
constexpr Distance& operator+=(const Distance& other) {
metres_ += other.metres_;
return *this;
}
friend constexpr Distance operator+(Distance left, const Distance& right) {
left += right;
return left;
}
friend std::ostream& operator<<(std::ostream& out, const Distance& distance) {
return out << distance.metres_ << " m";
}
};
int main() {
Distance route{120};
const Distance detour{35};
std::cout << "route + detour: " << route + detour << '\n';
route += detour;
std::cout << "route after +=: " << route << '\n';
}route + detour: 155 m
route after +=: 155 mThe constructor is explicit, so a bare integer doesn’t quietly become a Distance. Callers must name the unit type instead of accidentally treating seconds or another integer as metres. constexpr also lets the same arithmetic participate in constant evaluation when its inputs permit it.
The hidden friend could access metres_, but it still composes behavior through the public +=. If addition later needs an overflow check or invariant maintenance, there is one mutation path to update.
Supporting both operand orders deliberately
Scalar multiplication usually has a mathematical meaning in either order. Two non-member overloads put both amount * 2 and 2 * amount in the interface, with the second implementation delegating to the first.
#include <iostream>
class Money {
long cents_;
public:
explicit constexpr Money(long cents) : cents_(cents) {}
friend constexpr Money operator*(Money amount, int multiplier) {
return Money{amount.cents_ * multiplier};
}
friend constexpr Money operator*(int multiplier, Money amount) {
return amount * multiplier;
}
friend std::ostream& operator<<(std::ostream& out, const Money& amount) {
return out << amount.cents_ << " cents";
}
};
int main() {
const Money price{1299};
std::cout << "price * 2: " << price * 2 << '\n';
std::cout << "3 * price: " << 3 * price << '\n';
}price * 2: 2598 cents
3 * price: 3897 centsThis interface doesn’t accept double and pretend rounding is solved. A production money type also needs policies for overflow, currency, and decimal scale. Adding more overloads before those rules exist only makes an ambiguous interface larger.
Both functions take Money by value, which is cheap for this one-integer type. For a larger type, a common design takes the left object to be modified by value, accepts the other operand by const&, and reuses *=.
Providing writable and read-only subscripts
The non-const subscript returns int&, so scores[0] = 10 changes an element. Its const overload returns const int&, letting read-only callers use the same syntax without gaining a path to mutate the object.
#include <array>
#include <cstddef>
#include <iostream>
class Scores {
std::array<int, 3> values_;
public:
explicit Scores(std::array<int, 3> values) : values_(values) {}
int& operator[](std::size_t index) {
return values_[index];
}
const int& operator[](std::size_t index) const {
return values_[index];
}
};
void print_first(const Scores& scores) {
std::cout << "first score: " << scores[0] << '\n';
}
int main() {
Scores scores{{7, 8, 9}};
print_first(scores);
scores[0] = 10;
print_first(scores);
}first score: 7
first score: 10This operator[] follows the unchecked contract of std::array::operator[]; an out-of-range index has undefined behavior. If a type promises bounds checking, check and throw in the operator or provide a separate at() like standard containers do. The name and documentation must tell callers which contract they get.
A value-returning subscript can make reads appear correct while preventing assignment. Supplying only the non-const overload instead makes an ordinary reader taking const Scores& fail to compile. Both omissions are common when generated code is exercised through only one call site.
Keeping boolean conversion explicit
explicit operator bool() permits the object in if and other boolean contexts while keeping it out of accidental integer arithmetic. The stream operator returns the stream it received, so later output can remain in the same chain.
#include <iostream>
#include <string_view>
class Subscription {
std::string_view name_;
bool active_;
public:
Subscription(std::string_view name, bool active) : name_(name), active_(active) {}
explicit operator bool() const noexcept {
return active_;
}
friend std::ostream& operator<<(std::ostream& out, const Subscription& plan) {
return out << plan.name_;
}
};
int main() {
const Subscription pro{"pro", true};
const Subscription trial{"trial", false};
if (pro) {
std::cout << pro << " can sync\n";
}
std::cout << trial << " can sync: " << std::boolalpha
<< static_cast<bool>(trial) << '\n';
}pro can sync
trial can sync: falseA contextual conversion considers this explicit function, so if (pro) is valid. To store the result in a bool, the caller writes static_cast<bool>(trial). This shape works for an “engaged/empty” or “valid/invalid” state; it shouldn’t replace a named query when the object has several states.
The sample’s std::string_view doesn’t own its characters. Its names come from string literals here, so their lifetimes are long enough. If the constructor receives a temporary string, correct operator functions still can’t repair the dangling view; the type needs a separate ownership contract.
Pitfalls
Letting + mutate the left operand
Fix: let += perform mutation and return T&; let + work on a left-side copy and return it by value. After result = a + b, test that a and b kept their values. Also check that the address returned by a += b is &a.
Returning a dangling reference or the wrong category
Fix: return new values by value, current-object mutations by T&, and stream operations by std::ostream&. Don’t make a by-value result const; that adds no ownership guarantee and can interfere with later moves or overload selection.
Making a symmetric operation member-only
Fix: use non-members or hidden friends for genuinely symmetric binary operations, then compile both operand orders. Keep constructors explicit when a conversion could lose a unit, precision, or range, and declare only the mixed combinations the domain actually permits.
Defaulting comparisons over the wrong fields
Fix: list the fields that define equality and order before deciding to write = default. Test equal objects, objects differing only in cache state, boundary values, and NaN. If the type is used in ordered containers, verify that its comparison satisfies their strict-weak-order contract.
Expecting overloaded && or || to short-circuit
Fix: don’t use these overloads as control-flow guards. Prefer a named function, an ordinary if, or built-in boolean operators over explicit boolean queries. Test with a side effect on the right when evaluation behavior matters.
Filling the candidate set with implicit conversions
Fix: make converting constructors and conversion functions explicit by default, relaxing that only for conversions that are genuinely lossless and natural in the domain. Ask the compiler for the concrete candidates and keep compile-time tests for mixed-type expressions that should remain invalid.
Omitting constness or a bounds contract from subscripts
Fix: when container semantics call for it, provide matching reference-returning const and non-const overloads and choose checked or unchecked access explicitly. Compile writable, read-only, and out-of-range tests. If there is also an at(), verify that the two entry points really have different contracts.
Candidate lookup and hidden friends
When an operator expression enters overload resolution, the compiler builds several candidate groups from the operand types. Member candidates come from the left operand’s class scope; non-member candidates come from ordinary unqualified lookup and ADL; built-in candidates represent language operations; relational and equality operators may also gain rewritten candidates from C++20 onward. Ordinary overload resolution still chooses the one best viable function.
ADL inspects namespaces and classes associated with argument types. Although ordinary unqualified lookup can’t see a hidden friend everywhere, ADL adds it when an object of its class participates in the call. This is a useful fit for symmetric operations: the function stays next to the class definition, can access its private representation, and gives both explicit parameters the same conversion treatment.
Hidden doesn’t mean private. A call with the associated type can find the function, and the function remains subject to normal language rules beyond access. Stuffing unrelated friends into a class doesn’t improve encapsulation. The useful property is that operations meaningful only for the type stay on its associated lookup path.
Conversion sequences decide which candidates are viable and which is better. Standard conversions generally rank above user-defined conversions, and one argument’s implicit conversion sequence contains at most one user-defined conversion. For mixed arithmetic, write down the source-to-parameter path for each side instead of deciding that something “looks convertible.”
The implicit object parameter of a member operator doesn’t use a user-defined conversion to turn an arbitrary left value into the class. That rule explains much of the left/right asymmetry; it isn’t a compiler quirk. Once a symmetric operation becomes a non-member, both sides are ordinary explicit parameters and receive genuinely symmetric conversion consideration.
An ordinary namespace-scope function can do the same job, so hidden friends aren’t mandatory style. If the function should also be called by name, or the operation belongs jointly to several types, a namespace-scope declaration can be easier to discover. Choose based on operation ownership and lookup, not because friend sounds modern.
The C++23 comparison model
The three-way comparison operator <=> describes less, equal, greater, and potentially unordered outcomes in one operation. C++20 rewritten candidates let <, >, <=, and >= use <=>, while != can be rewritten from ==. This removes much of the duplication that makes hand-written relations drift apart.
With auto operator<=>(const T&) const = default; inside a class, the compiler compares base subobjects and non-static data members in declaration order. If the class doesn’t explicitly declare any member or friend named operator==, that defaulted three-way comparison also implicitly declares a defaulted operator==. There is no need to copy the field list mechanically for equality.
Defaulting doesn’t guarantee a usable function. If a subobject lacks the required comparison, the generated function can be defined as deleted; reference members and variant members carry further restrictions. An auto result combines the subobjects’ comparison categories, so adding a floating member can weaken a strong order to a partial order.
| Comparison category | Typical semantics | Review focus |
|---|---|---|
std::strong_ordering | Equal values are substitutable, as with integers | == must agree with equivalence |
std::weak_ordering | Sortable equivalence classes, such as case-insensitive text | Equivalent objects needn’t be identical |
std::partial_ordering | Values may be unordered, as with floating-point NaN | Callers must handle unordered |
A hand-written <=> should return the category its domain relation supports, not claim strong_ordering mechanically. Case-insensitive text may place distinct spellings in one ordering-equivalence class, while floating-point NaN can be unordered with every value. Overstating the category promises generic code properties that don’t exist.
Design equality identity and ordering equivalence together. Ordered associative containers derive key equivalence from their comparison and needn’t call operator==. If the two relations use different fields, lookup, deduplication, and direct comparison can disagree over the same objects. Tests should change each candidate field one at a time.
Syntax, evaluation, and limits
Overloading happens after parsing, so a + b * c is always grouped according to * precedence first. You can’t turn binary + into a ternary operation or declare a symbol of your own. Use a named function or domain-specific builder when an expression needs a different structure.
Don’t casually interchange operator notation with an explicit function call when evaluation details matter. The operands of a @ b follow the sequencing prescribed for the corresponding built-in operator, while explicit operator@(a, b) is an ordinary function call. An interface that relies on subtle sequencing is hard to review; extract side-effecting subexpressions into named statements first.
Overloaded && and || don’t provide built-in logical short-circuiting. Both operands must become call arguments, so the right side is evaluated. Template code accepting a type that may overload these symbols can’t treat them as control-flow guards either.
Overloading comma or unary address-of similarly overturns assumptions in low-level code. Generic libraries use dedicated techniques to bypass an overloaded address-of, and an overloaded comma is rarely clearer than a named operation. Being allowed to declare one doesn’t make its interface cost worthwhile.
A conversion function uses the operator Type() form and has no ordinary return type. Outside specified contexts such as boolean conditions, explicit prevents implicit use. For ownership handles, units, and range-limited values, explicit conversion usually keeps information visible at the call site where it can be reviewed.
C++23 lets operator[] accept several subscript arguments and allows operator[] and operator() to be static members, but both remain member-only operators. This doesn’t add multidimensional bounds checking to an old interface. Each dimension’s range and the returned reference lifetime still need an explicit contract.
Verifying the semantic contract
Successful compilation proves only that a candidate is usable. Save both operands, run a binary operation, and check whether the originals stayed unchanged as promised; then test compound assignment and prefix and postfix forms separately. For an operation returning a reference, compare addresses so a copy or unrelated object can’t slip through.
Compile both a @ b and b @ a for symmetric operations, using distinct types to cover allowed and forbidden conversions. Invalid expressions deserve tests too: a requires expression or type trait can confirm they remain invalid. That stops a later constructor from widening the interface silently.
Comparison tests need more than a handful of expected outcomes. Check reflexivity, antisymmetric relationships, transitivity, and agreement between == and ordering equivalence. Types with floating members need NaN, positive and negative zero, and infinities. Concept checks can verify the syntactic shape, but they can’t prove these runtime laws.
Test subscript and dereference operations on const and non-const objects, at both ends of the valid range, and after reference invalidation. If an operator returns a proxy, exercise assignment, reading, and lifetime instead of assuming it behaves exactly like a real T&. A proxy is a separate interface decision and belongs in the type’s documentation.
A stream test should place the object between preceding and following text, proving that the function returns the original stream and permits chaining. If it changes precision, numeric base, or fill characters, check that it restores caller formatting. Simple output is safest when it honors the stream state it receives instead of imposing global formatting.
Turn these cases into a test matrix: operand types, left/right order, object constness, expected candidate, mutation, result category, and failure mode. Empty cells in that matrix matter more than repeated happy-path examples because operator bugs tend to hide in call shapes left untested.
Further reading
5 questions · 2 predict-the-output · 1 spot-the-bug