Ch17.1: Operators Are Functions
1. The Substitution, Stated Precisely
An operator expression is a function call with a different spelling. The compiler rewrites it, then does ordinary overload resolution:
| You write | The compiler calls |
|---|---|
a + b | operator+(a, b) |
a += b | a.operator+=(b) |
-a | operator-(a) |
a[i] | a.operator[](i) |
a(b, c) | a.operator()(b, c) |
a == b | operator==(a, b), and in C++20 also
a.operator==(b) with the operands reversed if that is the only
candidate |
delete p | p->~T() then
operator delete(p) (Ch18.1) |
Two consequences follow immediately. First, an operator is looked up exactly like a function: if it is a member, the left operand must be of that class; if it is not a member, argument-dependent lookup finds it in the namespace of its arguments. Second, an operator can be called explicitly by name, which is how you prove to yourself that nothing magical happens.
2. A Complete Example: the Canonical Shape
The shape every arithmetic value type should follow: a compound assignment as the primitive that mutates and returns a reference, and a binary operator defined on top of it that takes its operands by value and returns a new value.
#include <fast_io.h>
#include <cstdint>
namespace money_ns
{
struct money
{
::std::int_least64_t cents{};
constexpr money &operator+=(money other) noexcept
{
cents += other.cents;
return *this;
}
};
[[nodiscard]] inline constexpr money operator+(money a, money b) noexcept
{
return a += b;
}
}
int main()
{
try
{
using money_ns::money;
money const a{1250};
money const b{375};
::fast_io::io::println("a + b = ", (a + b).cents);
::fast_io::io::println("operator+(a,b)= ", operator+(a, b).cents);
money c{a};
c += b;
::fast_io::io::println("c += b = ", c.cents);
}
catch throws (::std::error e)
{
::fast_io::io::print("error: ");
::fast_io::io::println(e);
}
}
a + b = 1625
operator+(a,b)= 1625
c += b = 1625
Everything worth noticing is in those twenty-five lines:
operator+=is a member because its left operand is modified and is the object. It returnsmoney&, notvoid, so thata = b += ckeeps the meaning it has forint.operator+is a non-member in the same namespace. It takes both operands by value, adds into the copy of the left one, and returns it — the copy is elided or moved, so this is the cheapest correct spelling. Because it is a non-member,money{1} + money{2}and any implicit conversion on either side work symmetrically (see Ch17.3).- Both are
constexprandnoexcept: addition of two integers cannot fail, so the type must not pretend it can (Ch6.16). [[nodiscard]]onoperator+catchesa + b;used as a statement — a discarded result that was almost certainly a bug.operator+(a, b)inmainis the same function. There is no separate mechanism.
3. What You Cannot Do
The feature is narrower than people expect. You cannot overload an operator unless at least one operand is a class or enumeration type:
int operator+(int a, int b) { return a * b; }
error: ‘int operator+(int, int)’ must have an argument of class or enumerated type
You cannot invent an operator that does not exist, so no **, no
<>, no &&&:
s operator**(s a, s b) { return a; }
error: expected initializer before ‘*’ token
You cannot change an operator's arity, its associativity, or its precedence:
a + b * c groups as a + (b * c) for your type too, and
a.b.c cannot be redefined as multiply. And ten operator spellings are not
overloadable at all — the list is short, closed, and worth knowing by heart, because
it is exactly the set of expressions a reader is entitled to understand without knowing
your type:
| Not overloadable | What it answers | Why the language keeps it out of reach |
|---|---|---|
:: |
which entity a name denotes | name lookup happens before any object exists, so there is nothing to dispatch on |
. |
which member of an object | member access is the one operation the compiler must fully know: layout,
offsetof, and the meaning of every other member expression depend on
it. (operator-> is the overloadable one-step cousin.) |
.* |
which member through a pointer-to-member | same reason; operator->* is overloadable and lives in the
library |
?: |
which of two expressions is evaluated | it is one of the three forms of lazy evaluation in the language; a function call would evaluate both arms |
sizeof, sizeof..., alignof |
how big the type is, and how it is aligned | these are answers about layout, not about values; they must not run code (Ch2.7) |
typeid |
what the dynamic type is | a property of the object, not of your interface; a type must not be able to report itself as another type |
static_cast, dynamic_cast,
const_cast, reinterpret_cast |
what the value means as another type | casts are the escape hatches from the type system; a type that could
define reinterpret_cast could make it lie |
Two footnotes. # and ## are not C++ operators at all —
they belong to the preprocessor, which runs before there are types to overload
(Ch3.11). And a C-style cast
(T)x has no operator name: it is a request to try
const_cast, static_cast, static_cast plus
const_cast, and reinterpret_cast in that order, so it cannot be
overloaded either — it can only be redirected through a conversion operator.
Everything else is overloadable, including the four that should never be: the
address-of &, the short-circuiting && and
||, and the sequencing ,. That asymmetry is the whole subject of
Ch17.8: the language protects
the operators it considers structural, and trusts you with the ones it considers
disposable — and one of the disposable ones, &, is the single most
load-bearing operation in systems code.
The list above is what makes operator overloading safe to reason about at all: an
expression such as a + b * c means the same shape for every type in
the program, so a reader's parser never has to be told about your class. Everything this
chapter warns about is inside that shape: the punctuation is fixed, but its
meaning is not, and that is where the trouble lives.
4. Member or Non-Member?
| Operator | Must be a member | Must be a non-member | Choose freely, prefer |
|---|---|---|---|
=, [], (), -> |
yes | ||
<<, >> (as stream I/O) |
member, unless you are doing iostreams — and you are not (Ch17.8) | ||
+= -= *= /= and friends |
member: they modify the object | ||
+ - * / == <=> |
non-member: symmetry, and implicit conversions on both sides | ||
operator T() |
yes | do not write it at all (Ch17.8) |
The symmetry argument is the only one that matters in practice: 2 * v calls
operator*(int, vec2), which can only exist as a non-member, while
v * 2 would work either way. If v * 2 compiles and
2 * v does not, you have leaked an implementation choice into the
type's interface.
5. Rules of Thumb
- Write the compound assignment first, and define the binary operator in terms of it. That guarantees the two agree.
- Binary operators take their operands by value and return by value. That is not a pessimisation; it is what enables copy elision.
- Every operator you write is a function, so it needs a contract:
noexceptorthrows, no exceptions. - Mark value-returning operators
[[nodiscard]]. - If the operator's result is not the obvious mathematical result — if it allocates, if it can fail, if it only sometimes applies — stop here and read Ch17.2: you want a named function instead.