C++ Part I · Module 15 · Operator Overloading
Assignment, Subscript and Function-Call Operators
Every operator so far produced a value and modified nothing. Addition made a new object, comparison reported a fact, stream insertion wrote and handed back the stream, and none of them had to answer a question about const because none of them changed anything. This chapter is the group that does. Assignment writes to its left operand. Subscript hands the caller something they can write through, which means it must behave differently depending on whether the object it was called on is modifiable. And the function-call operator turns an object into something callable, which is the mechanism behind every comparator, predicate and callback you will pass to the standard library — including, as it turns out, every lambda. All three must be members, the language will tell you so in those words, and two of them need to exist twice: one version for a modifiable object and one for a const one, with the compiler choosing silently between them at every call site. This chapter measures that choice being made, measures what a const object gets when the second version was never written, and measures what happens when subscript returns a value instead of a reference — an error message of exactly four words that is the entire explanation. It closes with the reason to prefer a callable object to a function pointer, which has nothing to do with notation and everything to do with what the optimiser can see: measured across one call to std::sort, no indirect calls against eighty-seven.
Advanced19 min readC++Part IFundamentalsOperators
Part I · Module 15 · Chapter 15.6 · Assignment, Subscript and Function-Call Operators
1. Three Operators That Modify or Expose
| Operator | What it does | Must be a member |
|---|---|---|
= | writes to the left operand | yes |
[] | exposes an element, possibly for writing | yes |
() | makes the object callable | yes |
Chapter 15.2 §6 measured the requirement — each one as a non-member gives must be a non-static member function, in those words. The common reason is that each one's left operand is the object in a way that admits no symmetric reading, so losing Chapter 15.2 §4's symmetry costs nothing.
What makes this group different from everything earlier in the module is const. = modifies, and [] may hand back something modifiable, so both have to take a position on whether the object they were called on is allowed to change — and [] has to take two positions, which is §4.
2. Assignment: What Is Left to Say
Chapter 13.3 covered operator= thoroughly and this chapter does not repeat it. Settled there: the signature (13.3 §1), why it is harder than the copy constructor (§2), the two naive shapes and their failures (§4, §5), the correct version (§7), and self-assignment (§9). Chapter 13.5 placed it in the Rule of Three and Chapter 13.11 argued for having none at all.
Three things remain, and all three are about assignment as an operator rather than as a special member function.
It must be a member — §1's table, measured in Chapter 15.2 §6.
It returns T&, and the reason is the same as Chapter 15.3 §6's for +=. Chapter 4.6 §4 established that assignment chains, grouping right to left:
struct Chain { int v = 0; Chain& operator=(int x) { v = x; return *this; } };
Chain a, b, c;
a = b = c = 7;A. chaining with the conventional form:
a=7 b=7 c=7With void instead, the chain is a hard error:
error: no viable overloaded '='Because b = 7 now produces nothing for a = to take. Chapter 15.3 §6 measured the quieter version of this mistake for +=, where chaining compiled and silently discarded work; here it does not compile at all, which is better.
And it may be overloaded for other types — §3.
3. Assignment from a Different Type
operator= is not restricted to taking its own type:
struct Reg {
unsigned bits = 0;
Reg& operator=(const Reg&) = default; // copy assignment
Reg& operator=(unsigned raw) { bits = raw; return *this; } // from a raw value
};
Reg r; r = 0xDEADu;
Reg s; s = r;B. assignment from a different type, and copy assignment, coexisting:
r.bits=0xDEAD s.bits=0xDEADBoth work. r = 0xDEADu selects the unsigned overload and s = r selects the copy assignment — ordinary overload resolution, Chapter 6.7's mechanism.
One rule worth measuring rather than assuming, because the adjacent rule is different:
declaring ONLY operator=(unsigned) -> a = b still compilesA heterogeneous operator= does not suppress the implicit copy assignment. Only a user-declared copy assignment does that — and per Chapter 13.10 §3, declaring the copy assignment is what suppresses the implicit move assignment. So adding operator=(unsigned) to a class is additive and safe; adding operator=(const Reg&) changes what else you get.
Whether you should is Chapter 15.1 §8's question. reg = 0xDEADu reads clearly for a register wrapper. For most types, a named setRaw() is better, because assignment from an unrelated type is where implicit conversions and surprising overload resolution start — the hazard Chapter 12.5 §2 measured.
4. Subscript: Why It Comes in Pairs
struct Regs {
unsigned w[4] = {10, 20, 30, 40};
unsigned& operator[](std::size_t i) { return w[i]; }
const unsigned& operator[](std::size_t i) const { return w[i]; }
};Two overloads differing only in the trailing const and the return type. This is Chapter 11.7 §5's "one name, two functions" applied to an operator, and the reason both are needed is the reason the whole chapter exists:
- on a modifiable object,
r[2]should be writable, so it returnsunsigned&; - on a
constobject,cr[2]must not be writable, so it returnsconst unsigned&.
One function cannot do both. A single non-const version cannot be called on a const object at all (§6), and a single const version cannot return something writable (§7).
5. The Choice, Measured
With each overload announcing itself:
unsigned& operator[](std::size_t i) { std::printf(" [non-const] "); return w[i]; }
const unsigned& operator[](std::size_t i) const { std::printf(" [const] "); return w[i]; }A. which overload is selected:
[non-const] read from non-const object -> 10
[const] read from const object -> 10
B. assigning THROUGH the subscript (needs a reference return):
[non-const] wrote 999
r.w[2] is now 999The object's constness picks the overload, not what you do with the result. Reading r[0] from a modifiable object called the non-const version even though the read did not need to modify anything — overload resolution looks only at the object, so const propagates from the object to the function. Chapter 11.7 §4 established that propagation.
And B is the payoff: r[2] = 999 worked, and the write landed in the real array. That requires the reference return, which is §7.
6. Forgetting the const Overload
Write only the non-const version, then use a const object:
struct Regs { unsigned w[4]; unsigned& operator[](std::size_t i) { return w[i]; } };
const Regs r;
return (int)r[0];error: no viable overloaded operator[] for type 'const Regs'
note: candidate function not viable: 'this' argument has type 'const Regs',
but method is not marked constThe note is the useful half — it names the exact cause. And the consequence is larger than one expression: a type whose operator[] is not const-qualified cannot be read through a const reference, which means it cannot be passed as a const T& parameter and used. Chapter 11.7 §3 called this "the real cost: the type becomes unusable", and subscript is where it bites hardest, because reading an element is the main thing callers want to do.
So always write both, and write the const one first if you are the sort of person who forgets.
7. Returning the Wrong Thing
Return by value instead of by reference:
struct Regs { unsigned w[4]; unsigned operator[](std::size_t i) const { return w[i]; } };
Regs r; r[2] = 999;error: expression is not assignableFour words, and they are the whole explanation. r[2] is now a unsigned value — a temporary — and you cannot assign to a temporary. Chapter 13.6 §4's classification is what the compiler is applying: the by-value call produces an rvalue, and assignment needs an lvalue on the left.
This is the counterpart to Chapter 15.3 §7, where returning a reference from + was wrong because the result was new. Here returning a value is wrong because the result already exists and the caller wants to reach it. Same question — does the result already exist as an object — opposite answer.
A note on the pattern this rules out. Some container-like types genuinely cannot return a plain reference — a packed bit array has no bool& to give, which is why std::vector<bool> returns a proxy object instead, and why it is famous for being awkward. If your elements are not individually addressable, subscript is a hard design problem rather than a two-line pair, and a named get/set pair is often the honest answer.
8. Subscript Does Not Check
operator[] conventionally performs no bounds checking, and that is a convention readers rely on in both directions: they expect it to be fast and they expect to be responsible for the index.
unsigned& operator[](std::size_t i) { return w[i]; } // no check -- conventional
unsigned& at(std::size_t i) { if (i >= 4) throw std::out_of_range("Regs"); return w[i]; }The standard library draws exactly this line — std::vector::operator[] is unchecked and std::vector::at throws — and following it means a reader of your type already knows which is which. Inverting it, so that [] checks and at does not, would be legal and cruel.
Two practical points. Providing at as well is cheap and worth it for a type used in test code or configuration paths. And an assertion in operator[] under a debug build is a reasonable middle ground that costs nothing in release. Chapter 9.2 §5 measured what an out-of-range index does to a built-in array, which is what an unchecked subscript inherits.
(Containers, at, and the exception it throws belong to Modules 19 and 22. Here only the convention matters.)
9. The Function-Call Operator
operator() makes an object callable, and it is the least restricted operator in the language: any number of parameters, any return type, and it may be overloaded on arity.
struct Scale {
double k;
double operator()(double x) const { return x * k; }
double operator()(double x, double y) const { return (x + y) * k; }
};
Scale s{2.5};C. operator() -- a callable object, two arities:
s(4.0) = 10.0
s(1.0, 3.0) = 10.0An object with operator() is called a function object or functor, and the thing that distinguishes it from a function is the member k: it carries state alongside its behaviour. Scale{2.5} and Scale{3.0} are two different callables of the same type, which a plain function cannot be.
Mark it const unless calling genuinely modifies the object — a comparator or predicate that cannot be called on a const functor will not work with much of the standard library.
(A lambda is exactly this: the compiler writes a class with an operator() and the captures become members. Module 20 covers the syntax; the mechanism is the one in this section, which is why Chapter 14.4 §8 could measure a captureless lambda deleter as costing zero bytes.)
10. Why a Callable Object Beats a Function Pointer
The reason to reach for operator() rather than a function pointer is not notation. Two calls to std::sort on the same data, one with a functor and one with a pointer to an equivalent function:
struct FunctorLess { bool operator()(const Rec& a, const Rec& b) const { return a.key < b.key; } };
static bool fnLess(const Rec& a, const Rec& b) { return a.key < b.key; }
void sortWithFunctor(std::vector<Rec>& v) { std::sort(v.begin(), v.end(), FunctorLess{}); }
void sortWithFnPtr (std::vector<Rec>& v) { std::sort(v.begin(), v.end(), &fnLess); }At -O2, summed over every instantiation the compiler generated:
FUNCTOR (FunctorLess) : 867 instructions, 0 indirect calls (blr)
FN-POINTER (bool(*)(...)) : 1220 instructions, 87 indirect calls (blr)Zero indirect calls against eighty-seven. The per-instantiation figures make the mechanism clear: the functor's __introsort body is 480 instructions with no blr, and the function pointer's is 614 with 47.
The reason is what the compiler knows at compile time. FunctorLess is a type, so std::sort's instantiation knows exactly which comparison is being made and inlines the body into the loop. bool(*)(const Rec&, const Rec&) is a runtime value — the same type could hold any function — so every comparison is an indirect call that cannot be inlined through.
This is the same finding as Chapter 14.4 §8's deleter measurement, which is worth noting because it is the same principle in a different place: a function-pointer deleter made unique_ptr 16 bytes and uninlinable, while a captureless lambda left it at 8 and inlinable. Encoding behaviour in a type costs nothing at runtime; encoding it in a value costs an indirect call every time.
So: pass functors and lambdas to algorithms, not function pointers.
11. What to Actually Do
Make =, [] and () members — the language requires it.
Return T& from operator= so assignment chains.
Write operator[] as a pair — non-const returning T&, const returning const T&.
Treat the pair as one unit. The non-const one alone makes the type unusable through a const reference.
Return a reference from subscript, because the element already exists.
Leave operator[] unchecked and add at if you want checking — the library's line, which readers already know.
Mark operator() const unless calling really modifies the object.
Pass functors and lambdas to algorithms, never function pointers — measured, 0 indirect calls against 87.
And prefer a named setter to operator= from an unrelated type unless the reading is obvious.
12. Mistakes Worth Making Once
Declaring =, [] or () as a non-member. must be a non-static member function.
Returning void from operator=. Measured: a = b = 7 gives no viable overloaded '='.
Writing only the non-const operator[]. Measured: no viable overloaded operator[] for type 'const Regs' — and the type becomes unusable through a const reference.
Returning by value from operator[]. Measured: expression is not assignable.
Expecting the use to pick the overload. Measured: the object's constness picks it, even for a read.
Assuming operator=(unsigned) suppresses the copy assignment. Measured: it does not — only a user-declared copy assignment affects what else you get.
Making operator[] check and at not. Legal and the opposite of every reader's expectation.
A non-const operator(). It will not work as a comparator on a const functor.
Passing a function pointer to std::sort. Measured: 87 indirect calls and 353 more instructions.
Assuming a bit-packed container can return T&. It cannot — that is what proxies are for, and it is a design problem.
13. Interview Relevance
"Why does operator[] come in pairs?" Because the object's constness determines whether the element may be written, and one signature cannot serve both. The strong answer adds that omitting the const version makes the type unusable through a const reference.
"What does operator[] return, and why not by value?" A reference — the element already exists and the caller may want to write to it. By value gives expression is not assignable.
"Which overload does r[0] select on a non-const object?" The non-const one, even for a read. Overload resolution looks at the object, not at what you do with the result.
"Why does operator= return T&?" So assignment chains right-to-left. void makes a = b = c a hard error.
"Should operator[] bounds-check?" No, by convention — at is the checked one. Following the library's split means readers already know which is which.
"Functor or function pointer for a comparator?" Functor. Measured in one std::sort: 0 indirect calls versus 87, because the comparison is encoded in the type and can be inlined. This is a strong discriminator.
"What is a lambda, mechanically?" A compiler-generated class with an operator(), with captures as members — which is why a captureless one costs nothing.
14. What You Should Be Able to Say Now
=,[]and()must be members — Chapter 15.2 §6's measured rule.- This is the first operator group that modifies or exposes, so
constmatters for the first time. - Chapter 13.3 settled copy assignment; what is left here is that it must be a member, returns
T&, and may be overloaded for other types. - Measured:
a = b = c = 7gavea=7 b=7 c=7with aT&return, andno viable overloaded '='withvoid. - Measured:
operator=(unsigned)and copy assignment coexist, and the heterogeneous one does not suppress the implicit copy assignment. - Subscript needs two overloads — non-const returning
T&, const returningconst T&. - Measured: the object's constness selects the overload, even when the use is only a read.
- Measured: omitting the const version gives
no viable overloaded operator[] for type 'const Regs', with a note naming thethisargument. - The real cost of that omission is that the type cannot be used through a
constreference (Chapter 11.7 §3). - Measured: returning by value gives
expression is not assignable— the result is a temporary. - Subscript returns a reference because the element already exists — the mirror of Chapter 15.3 §7.
- A bit-packed container cannot return
T&and needs a proxy or named accessors. operator[]is unchecked by convention;atis the checked one.operator()may take any parameters and be overloaded on arity, and should beconst.- A functor carries state, which a plain function cannot.
- A lambda is a class with an
operator()and captures as members. - Measured: functor 867 instructions, 0 indirect calls; function pointer 1,220 instructions, 87 indirect calls.
- Behaviour in a type is visible to the optimiser; behaviour in a value is not — the same finding as Chapter 14.4 §8's deleters.
15. Exercises
Exercise 1 — the pair. Write both operator[] overloads for a Buffer holding std::byte, with correct return types.
Exercise 2 — predict the selection. For a non-const Buffer b and a const Buffer& cb, say which overload each of b[0], cb[0], and b[0] = x selects.
Exercise 3 — the omission. Remove the const overload and say what void dump(const Buffer&) can no longer do, plus the exact diagnostic.
Exercise 4 — the return type. Say what expression is not assignable means for a by-value subscript, and relate it to Chapter 15.3 §7's opposite error.
Exercise 5 — chaining. Explain why operator= returns T&, and give the diagnostic for the void version.
Exercise 6 — heterogeneous assignment. Add operator=(unsigned) to a register type. Say what it enables, what it suppresses, and whether it is a good idea by Chapter 15.1 §8's test.
Exercise 7 — checked or not. Write operator[] and at for Buffer following the library's convention, and say what inverting them would cost.
Exercise 8 — a functor. Write a KeyLess functor that orders records by a field chosen at construction, and say what it can do that a plain function cannot.
Exercise 9 — the measurement. Give the two instruction counts and indirect-call counts, and explain the mechanism in terms of what the compiler knows.
Exercise 10 — the proxy problem. A BitSet packs eight bools per byte. Say why operator[] cannot return bool& and give two honest designs.
16. Summary
=,[]and()must be members, and this is the first operator group whereconstmatters.operator=returnsT&so assignment chains — measured,voidgivesno viable overloaded '='.- A heterogeneous
operator=coexists with copy assignment and suppresses nothing — measured. - Subscript comes in a const/non-const pair, and the object's constness picks the overload even for a read.
- Measured: omitting the const version gives
no viable overloaded operator[] for type 'const Regs'— and makes the type unusable through aconstreference. - Measured: returning by value gives
expression is not assignable, because the result is a temporary. - Return a reference: the element already exists — the mirror image of Chapter 15.3 §7's wrong reference.
operator[]is unchecked by convention andatis checked — follow the library's split.- Measured: a functor comparator gave 0 indirect calls against a function pointer's 87, because behaviour encoded in a type is visible to the optimiser.
Six chapters have been about getting overloads right, and every one of them has at some point said that the mechanism will not stop you — that the compiler has no opinion on whether an overload is a good idea, and that the characteristic failure is a reader forming a wrong belief rather than anything a tool can see. The last chapter of this module is that argument on its own, because it is the half of the subject the mechanics cannot cover. It catalogues the overloads that make code shorter and worse: the three operators that lose a guarantee the moment you touch them, measured; a conversion operator left non-explicit that let two file handles be added together and compiled without a single warning; and the general shape of the mistake, which is reaching for notation because it is available rather than because a reader would understand it. Then the alternative, which is almost always a named function, and the small number of cases where notation genuinely wins.
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.
