C++ Part I · Module 15 · Operator Overloading
Arithmetic and Compound Assignment Operators
Every arithmetic idea in C++ comes with two operators rather than one. There is + and there is +=, and a reader who has used any built-in type believes something about how they relate: that a += b leaves a holding what a = a + b would have given it. Nothing in the language enforces that belief for your type. You write two functions, the compiler checks each on its own, and if they drift apart the result is a type on which both expressions compile, neither warns, and they produce different answers — measured here as twelve thousand two hundred and seventy-three disagreements out of nineteen thousand four hundred and twenty-five pairs of values, from one plausible pair of operators where one of them applied a rule the other forgot. The convention that makes the drift impossible is to write only one of the two and derive the other, and the direction is the one people get backwards: the compound form carries the logic, and the binary form is built from it rather than the other way round. Then the return types, which is where this chapter stops being about convention and starts being about memory. Three choices look interchangeable. One gives you a compound assignment that cannot be chained and silently discards work. One gives you a binary operator returning a reference to storage that has already gone, which this compiler warns about and then runs correctly anyway, and which AddressSanitizer will not report unless you turn on an option that is off by default. Only the third is right, and it costs nothing — measured byte-identical to addition written by hand.
Intermediate18 min readC++Part IFundamentalsOperators
Part I · Module 15 · Chapter 15.3 · Arithmetic and Compound Assignment Operators
1. Two Operators, One Idea
For every arithmetic operation a type supports, a reader expects two spellings:
c = a + b; // produce a new value, leave a and b alone
a += b; // modify a in placeChapter 4.6 §5 established what compound assignment means for built-in types, and the relationship a reader relies on:
a += bmust leaveaholding exactly whata = a + bwould have left it holding.
For int that is guaranteed by the language. For your type it is guaranteed by nothing at all. Two functions, checked separately, with no rule connecting them — so the agreement is a property you have to create rather than one you can assume.
2. The Drift, Measured
Two types. The first derives + from +=. The second writes them independently, and its += applies a saturation rule its + does not:
struct Bad {
long c = 0;
Bad& operator+=(const Bad& r) { c += r.c; if (c > 1000) c = 1000; return *this; } // saturates
};
Bad operator+(const Bad& a, const Bad& b) { return Bad{a.c + b.c}; } // does notChecking a += b against a = a + b for every pair over a wide range of values:
Good (+ derived from +=) : 0 disagreement(s) out of 19425 pairs
Bad (+ written separately): 12273 disagreement(s) out of 19425 pairs12,273 of 19,425. Both operators compile. Neither produces a warning at any level. Every individual function is internally correct — Bad's += saturates exactly as written, and its + adds exactly as written. The defect exists only in the relationship, which is why nothing local detects it.
And the fix is not "be careful". It is structural, because the derived version's 0 disagreements are not a test result — they are a consequence. Good's + calls +=, so there is one implementation of the rule and no second place for it to differ.
3. Which One Carries the Logic
The derivation direction is the part people reverse. The instinct is to write + first — it feels more fundamental, like the mathematical operation — and then define += as a = a + b. That is backwards, for two reasons.
+= is the one that can be written without the other. It modifies an object in place using the object's own members. + needs to produce a new object, which means constructing one, which is extra work that has nothing to do with the arithmetic.
And defining += in terms of + costs a copy. *this = *this + rhs constructs a temporary and then assigns it over the object. Writing it the other way round, as §5 shows, costs nothing measurable.
So:
Write
+=as the only implementation. Derive+from it.
The same applies to every pair: -=/-, *=/*, /=//, %=/%, &=/&, |=/|, ^=/^, <<=/<<, >>=/>>.
4. The Canonical Pair
struct Money {
long cents = 0;
// the COMPOUND form carries the logic -- a MEMBER, since it modifies the object
Money& operator+=(const Money& rhs) { cents += rhs.cents; return *this; }
Money& operator-=(const Money& rhs) { cents -= rhs.cents; return *this; }
};
// the BINARY form is derived -- a NON-MEMBER, taking the left operand BY VALUE
Money operator+(Money lhs, const Money& rhs) { lhs += rhs; return lhs; }
Money operator-(Money lhs, const Money& rhs) { lhs -= rhs; return lhs; }Four decisions in those six lines, three of them already established:
+= is a member — Chapter 15.2 §8: it modifies its left operand, which is always the object, so symmetry buys nothing.
+ is a non-member — Chapter 15.2 §4: measured, a member version rejects a literal on the left.
+= returns Money& and + returns Money — §6 and §7 measure what the alternatives do.
And + takes its left operand by value, which is the one idiom that looks like a mistake and is not. The parameter lhs is already a copy, so lhs += rhs modifies that copy and returns it — no second temporary, and the copy you needed in order to produce a new value is the parameter itself. §9 measures its cost.
5. The Trace
A. a + b leaves the operands alone:
a=250 b=175 c=425
B. a += b modifies a and returns it:
a=425 d=425 (a += b returned a reference to a)
&d == &a ? 0 (d is a COPY, built from the returned reference)
C. chaining, which needs += to return a reference:
e = 775A is the essential difference: + produced a third value and left both operands untouched.
B shows += doing the opposite — a changed. And the &d == &a ? 0 line is worth pausing on: operator+= returned a reference to a, but Money d = (a += b) copy-initialises d from that reference, so d is a separate object. The reference is what makes the expression usable, not what d becomes.
C is why the reference return matters. ((e += a) += b) += b starting from 0 with a=425 and b=175 gives 775, because each += returned something further += could be applied to — and applied to the same object each time.
6. Return Type One: By Value from +=
The first of the three wrong-looking-right choices. Return Money from += instead of Money&:
struct ByValue {
long c = 0;
ByValue operator+=(const ByValue& r) { c += r.c; return *this; } // NOT a reference
};Chained three times, adding one each time:
A. += returning BY VALUE, chained:
a.c = 1 (three += applied; expected 3 if chaining accumulated)
B. += returning a REFERENCE, chained:
b.c = 3One, not three. The first += modified a and returned a copy. The second += modified that copy. The third modified a copy of the copy. Two of the three operations were applied to temporaries and discarded.
Zero warnings, at any level. Chapter 4.6 §3 established that assignment produces a value; what it produces for built-ins is a reference to the assigned-to object, and a by-value return quietly breaks that.
This is the chapter's only silent wrong answer — the other two failures are a diagnostic and a memory bug.
7. Return Type Two: A Reference from +
The symmetric error, and the one with teeth. Return const Money& from +:
const Money& operator+(Money lhs, const Money& rhs) { lhs += rhs; return lhs; }lhs is a by-value parameter — it is destroyed when the function returns, so the reference refers to storage that is gone. Three results, in the order you would encounter them.
The compiler warns, and the warning is excellent:
warning: reference to stack memory associated with parameter 'lhs' returned [-Wreturn-stack-address]The program then prints the right answer:
read through the returned reference: 425 (expected 425)
exit: 0And AddressSanitizer says nothing — until you enable an option that is off by default:
ASan, default : silent, exit 0
ASAN_OPTIONS=detect_stack_use_after_return=1 :
ERROR: AddressSanitizer: stack-use-after-return
SUMMARY: ... badreturn.cpp:13 in main
exit 134So the diagnostic chain is exactly inverted from what you would hope: the compile-time warning is the reliable detector, the run is misleading, and the sanitizer needs configuring. Chapter 14.4 §10's callout made this argument about alloc_dealloc_mismatch and Chapter 14.6 §11 generalised it — when a tool is silent, confirm it could have spoken. This is the third instance in two modules.
The practical rule is simpler than the analysis: a binary arithmetic operator returns by value, always, because the value it produces is new and nothing else owns it. Chapter 8.8 §3 measured the general form of this dangle.
8. Return Type Three: Correct
| Operator | Returns | Why |
|---|---|---|
a += b | T& | the object already exists and the result is the object |
a + b | T | the result is a new value nothing else owns |
++a | T& | the object is modified and the result is the object |
a++ | T | the result is the old value, which no longer exists in the object |
unary -a | T | a new value |
The pattern: return a reference when the result already exists as an object; return a value when you just made it.
And const on the return of + is a historical habit worth dropping — const T prevents moving from the result, which Chapter 13.8's move semantics make undesirable. Return a plain T.
9. The By-Value Left Operand Costs Nothing
§4's Money operator+(Money lhs, const Money& rhs) takes its left operand by value, which looks like a gratuitous copy. At -O2:
long viaCanonical(Money a, Money b) { return (a + b).cents; }
long byHand (Money a, Money b) { return a.cents + b.cents; }__Zpl5MoneyRKS_ (3): ldr x8,[x1] | add x0,x8,x0 | ret
__Z12viaCanonical5MoneyS_ (2): add x0,x1,x0 | ret
__Z6byHand5MoneyS_ (2): add x0,x1,x0 | retByte-identical to addition written by hand — two instructions. The copy the idiom appears to make is the copy the operation genuinely needs, and the optimiser removes the rest. Chapter 15.1 §4 measured the same thing for the operator mechanism itself.
(There is a refinement for types where copying is expensive: a second overload taking the left operand as T&& can reuse a temporary's storage. That depends on Chapter 13.8's move semantics and is a deliberate omission here — the by-value form is correct and free for the ordinary case, which is the one this chapter is about.)
10. Increment and Decrement
Two operators with two forms each, and the forms must be distinguished somehow. The language's answer is a dummy parameter:
Money& operator++() { ++cents; return *this; } // PREFIX
Money operator++(int) { Money old = *this; ++cents; return old; } // POSTFIXThe int is a tag, not a value. It is never passed and never named; its only job is to give the postfix form a different signature. This is the one place in C++ where a parameter exists purely to disambiguate.
D. prefix vs postfix ++ :
after pre = ++p : p=11 pre=11
after post = q++ : q=11 post=10Both modified the object to 11. The prefix form yielded 11 — the new value, by reference. The postfix form yielded 10 — the old value, by value, which is why it must copy. Chapter 4.7 §2 established exactly this for built-ins.
And that copy is the reason for Chapter 4.7 §5's advice: prefer prefix unless you need the old value, because postfix constructs an extra object. For an int the compiler erases the difference; for a type with real state it may not.
11. What to Actually Do
Write += and derive + from it — one implementation, so they cannot disagree.
Never write += as *this = *this + rhs — the derivation runs the other way and costs a copy.
+= is a member returning T&; + is a non-member returning T.
Take +'s left operand by value — measured byte-identical to hand-written addition.
Do not return const T from + — it blocks moving from the result.
Give postfix ++ an unnamed int tag, return the old value by value, and prefer prefix.
And write the agreement check: for a range of values, assert a += b leaves a equal to a = a + b. It is the only automatic detector of the one failure nothing else catches.
12. Mistakes Worth Making Once
Writing + and += independently. Measured: 12,273 disagreements out of 19,425 with zero diagnostics.
Deriving in the wrong direction. *this = *this + rhs costs a temporary and an assignment.
Returning T from +=. Measured: chained three times, gave 1 instead of 3, silently.
Returning T& or const T& from +. A dangling reference; -Wreturn-stack-address catches it, the run prints 425, and ASan is silent by default.
Trusting the clean run over the warning. Here the compile-time diagnostic is the reliable one.
Returning const T from +. Prevents moving from the result.
Making + a member. Chapter 15.2 §2 — a literal on the left is rejected.
Making += a non-member. Legal, but it always modifies the object.
Naming the postfix int parameter and using it. It is a tag; nothing is passed.
Using postfix ++ out of habit in a loop that discards the result — Chapter 4.7 §5.
13. Interview Relevance
"How do you implement + and += for a type?" += as a member returning T&, carrying the logic; + as a non-member returning T, taking the left operand by value and calling +=. Naming the derivation direction is the discriminator.
"Why that direction?" += can be written without +, and defining += via + costs a temporary and an assignment.
"What does operator+ return, and why not a reference?" By value — the result is new. A reference would refer to the by-value parameter, which is destroyed at return.
"What happens if += returns by value?" Chaining silently stops accumulating — measured 1 instead of 3, with no warning. Few candidates have seen this.
"How are prefix and postfix ++ distinguished?" An unnamed int parameter on the postfix form. Prefix returns T&, postfix returns T holding the old value.
"Why prefer prefix?" Postfix must copy the old value; prefix does not.
"How would you check that + and += agree?" Compare a += b against a = a + b over many values — the answer that shows you know nothing else will catch a disagreement.
14. What You Should Be Able to Say Now
- Every arithmetic idea needs two operators, and a reader assumes
a += bequalsa = a + b. - Nothing in the language enforces that for your type.
- Measured: independently written operators disagreed 12,273 times out of 19,425 value pairs — with zero diagnostics.
- Measured: the derived version disagreed 0 times, and that is a consequence of having one implementation, not a test result.
+=carries the logic;+is derived — because+=needs nothing else, and the reverse derivation costs a temporary.+=is a member returningT&;+is a non-member returningT.+takes its left operand by value, and the copy it appears to add is the one the operation needs.- Measured: that idiom is byte-identical to hand-written addition — 2 instructions.
- Measured:
+=returning by value made a chained expression yield 1 instead of 3, silently. - Measured:
+returning a reference gave-Wreturn-stack-address, then printed 425 correctly, and ASan was silent by default. - Only
detect_stack_use_after_return=1reported it —stack-use-after-return, exit 134. - Return a reference when the result already exists; a value when you just made it.
- Do not return
const Tfrom+. - Postfix
++is distinguished by an unnamedinttag, returns the old value by value, and therefore copies. - Measured:
++pgavep=11 pre=11;q++gaveq=11 post=10. - The agreement check is the only automatic detector of the one failure no tool catches.
15. Exercises
Exercise 1 — the canonical pair. Write += and + for a Duration holding nanoseconds, with the correct placements and return types.
Exercise 2 — the direction. Explain why += is the implementation and + the derivation, giving both reasons.
Exercise 3 — predict the drift. A Level whose += clamps to 100 and whose + does not. Give the result of a += b and a = a + b for a=80, b=50, and say what detects the discrepancy.
Exercise 4 — write the check. Write the agreement check for Duration over a range of values, and say what it would catch that the compiler cannot.
Exercise 5 — the three return types. For += returning T, + returning const T&, and the correct pair, say what each does: compile result, run result, and diagnostic.
Exercise 6 — chaining. Explain why ((e += a) += b) += b gave 775 with a reference return and what it would give with a by-value return.
Exercise 7 — the tag. Write prefix and postfix -- for Duration and say what the int is for and why postfix must copy.
Exercise 8 — cost. A colleague objects that Money operator+(Money lhs, const Money& rhs) makes an unnecessary copy. Give the measured answer.
Exercise 9 — which tool. For each of the chapter's three failures, name the stage it is caught at and the tool, or say that nothing catches it.
16. Summary
- Two operators per idea, and a reader assumes they agree. Nothing enforces it.
- Measured: 12,273 disagreements out of 19,425 pairs from independently written operators, with zero diagnostics; 0 from the derived version.
- Write
+=as the only implementation and derive+— the reverse direction costs a temporary. +=is a member returningT&;+is a non-member returningTwith the left operand by value.- Measured: that idiom compiles to 2 instructions, byte-identical to hand-written addition.
- Measured: by-value
+=made a chained expression give 1 instead of 3, silently. - Measured: a reference-returning
+gave-Wreturn-stack-address, then printed the right answer, with ASan silent untildetect_stack_use_after_return=1. - Reference out when the result exists; value out when you made it. Postfix
++takes an unnamedinttag and copies. - The agreement check is the only automatic detector of the drift.
Arithmetic is the easy half of the consistency problem, because + and += are two operators and there are only two ways for two things to disagree. Comparison is six operators — ==, !=, <, <=, >, >= — which a reader expects to form a single coherent story: that exactly one of less-than, equal, greater-than holds for any pair, that != is the negation of ==, and that <= is < or ==. Six hand-written functions give that story many ways to go wrong, and the next chapter measures one of them doing something none of this chapter's failures did: a comparison operator that compiles, reads correctly, passes casual testing, and drives a standard-library algorithm into reading past the end of its array. Then the C++20 feature that replaces all six with one line, along with the precise measurement of what that line does and does not generate — because the obvious assumption about it is wrong.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
