Module 5 — Concepts and Explanations¶
Read this with a compiler open. Every claim in it is one you can test in under a
minute, and the two that surprise people — a comparator that makes std::sort
behave unpredictably, and Book b = a; not being assignment — are ones you should
test rather than believe.
1. "Why can I not sort a list of books?"¶
std::vector<Book> books{Book(2, "UML Distilled"), Book(1, "C++ Primer")};
std::sort(books.begin(), books.end());
The message is long, full of template expansion, and worth reading to the end:
the last line names the missing operation, not the missing include. std::sort
is not a function that sorts books; it is a function that sorts anything you can
compare with <. Your type does not have that operation, so the algorithm cannot
exist for it.
Everything in this module's first half follows from that. Operators are how your type joins the rest of C++ instead of living beside it:
| You want | Without the operator | With it |
|---|---|---|
| to print a book | printBook(book) — every caller must know the name |
std::cout << book |
| to compare two | booksEqual(a, b) |
a == b |
| to sort a vector | write a sort, or a comparator every time | std::sort(v.begin(), v.end()) |
| to search | a hand-written loop | std::find(v.begin(), v.end(), target) |
to use a std::map<Book, Loan> |
impossible | works, because the map knows how to order keys |
The term. An operator function is a function whose name is operator@.
Overloading one is ordinary function definition with unusual syntax, and the same
rules apply: the parameter types decide which overload is called.
2. "So how do I give it one?"¶
class Book {
public:
// Identity is the identifier. Say so once, here, and every algorithm inherits
// the decision.
bool operator<(const Book& other) const {
return id_ < other.id_;
}
bool operator==(const Book& other) const {
return id_ == other.id_;
}
private:
int id_;
std::string title_;
};
Four things about that code, each of which is a rule:
- It returns
bool. The standard library requires it for comparisons. - It takes
const Book&. A copy would compile, and would copy the title on every comparison —std::sortperforms O(n log n) of them. - It is
const. Every standard algorithm holds its elements byconstreference at some point; a non-constcomparator cannot be called. - It is a member, so the left operand is
*thisand the right is the argument.a < bcallsa.operator<(b).
Now the sort works, and so does everything else that needs an order:
std::sort(books.begin(), books.end()); // by identifier
std::find(books.begin(), books.end(), target); // needs operator==
What identity means is a design decision, not a technical one. If two books
with the same title but different identifiers are equal to you, say that in
operator== — and then operator< must agree, or the container's promises
become your bugs. §6 and §7 are about the two ways that goes wrong.
3. "Member, or free function?"¶
Some operators must be members, and the compiler says so:
| Must be a member | Why |
|---|---|
= |
it changes *this, and the language requires the assignment syntax to mean that |
[] |
same reason |
() |
makes the object callable; obj(args) is defined in terms of the member |
-> |
pointer-like behaviour is defined in terms of the member |
conversion operators (operator bool) |
they convert *this |
| Should be a free function | Why |
|---|---|
<< and >> |
the left operand is a stream, not your type (§4) |
arithmetic (+, -, *) |
symmetry: a + b and b + a must both work |
comparisons between two types (Book and a title) |
the left operand may be the other type |
== and < between values of your own type |
either is defensible — a member is the usual choice, and a free function is better when you want implicit conversion on both sides |
The test. Ask: could a caller reasonably write the other operand first? If
yes, the operator should be free, because a member only exists for a user-defined
type on the left — and for int + Money, the left operand is an int.
Two real errors from trying it the other way:
error: 'std::ostream& Book::operator<<(std::ostream&, const Book&) const'
must have exactly one argument
A member operator<< already has *this as its left operand, so declaring two
parameters means three operands — which is why it cannot be a member at all.
4. "Why does std::cout << book work, if << is not mine?"¶
Because the operator is free, and the stream is the left operand:
std::ostream& operator<<(std::ostream& out, const Money& money) {
const bool negative = money.paisa() < 0;
const long long magnitude = negative ? -money.paisa() : money.paisa();
out << (negative ? "-Rs " : "Rs ") << (magnitude / 100) << '.';
if (magnitude % 100 < 10) {
out << '0';
}
return out << (magnitude % 100);
}
Three details, all required:
- The first parameter is
std::ostream&. That is why it cannot be a member ofMoney: a member's left operand is always*this. - It returns the same stream.
std::cout << a << bmeansoperator<<(operator<<(std::cout, a), b), so the result of the first call must itself be something you can<<into. Returningvoidcompiles for one object and fails for two. - It formats at the last moment.
Moneystores paisa, an integer, and turns it into "Rs 12.50" only here. A type that stores the formatted string cannot do arithmetic on it.
Does it need friend? Only if it must reach private members. Money exposes
paisa(), which answers the only question the formatter has, so no friend is
needed — Module 4's discipline paying off. operator>> is the case that usually
does need one, and §4 of that module's concepts page states what a friend costs.
5. "Which operators does the standard library actually need?"¶
Fewer than students expect. The two that unlock the most:
| Operator | What it unlocks |
|---|---|
operator< |
std::sort, std::set, std::map, std::lower_bound, std::min_element |
operator== |
std::find, std::count, std::remove, std::unique |
operator<< |
any output, including your own diagnostics and test failures |
operator+= and friends |
compound assignment, and it is the natural base for + |
Write == and < by hand, and derive everything else. All six comparisons
have to agree; if you write all six, they eventually will not:
bool operator==(const Money& other) const { return paisa_ == other.paisa_; }
bool operator<(const Money& other) const { return paisa_ < other.paisa_; }
bool operator!=(const Money& other) const { return !(*this == other); }
bool operator>(const Money& other) const { return other < *this; }
bool operator<=(const Money& other) const { return !(other < *this); }
bool operator>=(const Money& other) const { return !(*this < other); }
Two definitions, four derivations, one meaning. Note that != is not free —
you get it from == only if you write it, and forgetting is a compile error
rather than a silent surprise:
6. "Why did my sort produce the wrong order?"¶
Because a comparator is not just a function that returns a bool the compiler
accepts. std::sort requires a strict weak ordering, and the part of that
phrase beginners get wrong is strict:
For every a, b, c:
irreflexive: a < a must be false
asymmetric: a < b implies b < a is false
transitive: a < b and b < c implies a < c
(and an equivalent transitivity rule for "neither is less than the other")
A comparator that returns true for equal elements is not a <. It compiles, it
runs, and the sort produces an order that is wrong — or crashes, or works today
and not tomorrow. That is undefined behaviour, and a wrong answer is the lucky
outcome. No compiler diagnoses it, because the property is about your intent,
not your syntax.
The one-line fix is to compare the field that actually defines the order, and to
return false when it is equal:
bool operator<(const Book& other) const {
return title_ < other.title_; // no `<=`, and no `==` mixed in
}
How to test a comparator without running a sort. Take five objects, including a duplicate, and check the three properties by asking directly:
for (const Book& a : sample) {
assert(!(a < a)); // irreflexive
for (const Book& b : sample) {
assert((a < b) == !(b < a) || a == b); // asymmetric
}
}
Three lines, written once, and they catch the comparator bug that a sort hides.
7. "Is a == b the same as a = b?"¶
No, and the syntax is the only thing they have in common.
Book a(1, "C++ Primer");
Book b = a; // INITIALISATION: constructs b by copying a
Book c(2, "UML Distilled");
c = a; // ASSIGNMENT: overwrites the c that already existed
The first line calls the copy constructor, Book(const Book&). The third
calls copy assignment, Book& operator=(const Book&). They are two different
functions, and Module 10 is entirely about their differences — but the fact that
they exist at all, and that = means "construct" in one place and "assign" in the
other, is this module's business.
Predict this before running it:
struct Track {
int id;
Track(int i) : id(i) {}
Track(const Track& o) : id(o.id) { std::cout << "copy\n"; }
Track& operator=(const Track& o) { id = o.id; std::cout << "assign\n"; return *this; }
};
int main() {
Track a(1);
Track b = a; // ?
Track c(2);
c = a; // ?
}
The output is copy then assign. The = in Track b = a; is initialisation
syntax, not the assignment operator, and the only way to get this right
reliably is to ask "did the object already exist?" — if not, it is construction.
This matters because a type that prints a different message from each function will one day print a message you did not expect, and you will spend an hour looking at the wrong function.
8. "How can an object know about the whole class?"¶
Some facts are not about any one object. How many tickets have been issued, what
the venue is, what the next identifier should be — those belong to the class.
A static data member is one storage location for the whole program, declared in
the class:
class Ticket {
public:
static int liveCount() { return live_; } // reads class-level state
explicit Ticket(std::string event)
: event_(std::move(event)), serial_(nextSerial_++) { ++live_; }
~Ticket() { --live_; }
private:
static int live_; // declared here
static int nextSerial_; // ...and defined below
inline static std::string venue_ = "Main Hall"; // C++17: defined here
};
int Ticket::live_ = 0; // exactly one definition, outside the class
int Ticket::nextSerial_ = 1000;
Three consequences, in order of how often they bite:
- The declaration is not the definition. A
staticdata member declared in the class still needs one definition, and the failure mode is a link error naming the member — the compiler was satisfied, and only the linker noticed. Under C++17,inline staticin the class is the definition, which is whyvenue_above needs nothing more. constexpr staticmembers of integral or enumeration type can be initialised in the class and (until C++17, whereinlineapplies) may still need a definition if something takes their address. Writestatic constexpr int kFirstSerial = 1000;and prefer it for constants.- One copy means shared mutable state. Two objects can never disagree about
live_, which is the benefit — and it is also the danger, because a class-level cache is a global variable with a namespace around it. §14 of Module 4 asked whether a caller can observe a change; forstaticmembers the question is can any code anywhere change this, and who else is reading it?
9. "Can a function belong to a class and have no object?"¶
Yes — a static member function, called on the class rather than on an object:
It has no this, which is not a technicality but the whole explanation:
- It cannot read or write a non-static member, because there is no object to read from. That is the error above.
- It cannot be
const.constqualifies*this, and there is no*this. - It is how class-level facts are asked for, and how named constructors are written:
class Book {
public:
static Book fromIsbn(const std::string& isbn); // a second way to construct
private:
Book(int id, std::string title);
};
That last pattern is the smallest useful design pattern in the course: the
constructor is private, so the only ways to create a Book are the ones the class
approves of. Module 15 grows it into the factory pattern.
10. "Can a local variable outlive the function that made it?"¶
A static local can, and it is a different meaning of the keyword:
Scope and lifetime are different things. counter is visible only inside
nextId, and it lives until the program ends, constructed on first execution of
the declaration and destroyed at exit. The initialisation happens exactly once,
even if ten threads call the function at the same moment (C++11 guarantees the
initialisation is thread-safe).
Use it for a counter or a cache inside a function with no object to attach the
state to. The cost is genuine global state with a narrow name, which is why the
Ticket counter above is a static member: it belongs to a type, so the type's
documentation can describe it.
11. "When is a free function better than a member?"¶
Symmetry, and the left operand. Consider comparing a Book with a title:
bool operator==(const Book& book, const std::string& title); // free
bool operator==(const std::string& title, const Book& book); // free, and now
// both orders work
As a member this is impossible: "C++ Primer" == book would need a member of
std::string, which you cannot add. The same argument covers Money + Money,
Book * int, and every stream operator.
The three-question test, to apply to any operator you are about to write:
- Must it be a member? (
=,[],(),->, conversions.) If yes, done. - Is one operand a stream, or can the caller reasonably write the other operand first? If yes, make it free.
- Otherwise make it a member — it keeps the signature near the data it compares, and the compiler is happy either way.
12. The operator design checklist¶
Five rules that resolve most of the arguments, and this module is roughly their content:
- Does the operator mean what a reader already expects?
+adds,<orders,<<writes. An operator whose meaning must be explained is a bug with friendly syntax. - Is it consistent with the rest of the type's interface? If
==compares identifiers and printing shows titles, a caller will eventually be surprised. - Is the ordering a strict weak ordering? If not, the standard algorithms are undefined behaviour (§6).
- Would a named function be clearer?
book.mergeWith(other)beatsbook + otherwhen the operation is not addition. - Leave
&&,||,,, and&alone. Overloading any of them destroys a language guarantee — short-circuiting, sequencing, or "take the address" — and the loss is invisible.examples/module-05/04-operators-not-to-overload/main.cppmeasures the short-circuit loss with a counter, so it is not a claim you have to take on trust.
Checkpoint¶
Answer these before the worked examples. If an answer is not immediate, re-read the section beside it.
std::sortreportedno match for 'operator<'. Was anything missing from your code, or was something wrong with it? What else does the error tell you? (§1.)- Predict: a type defines
operator==but notoperator!=. Which of these compile:a == b,a != b,std::find(v.begin(), v.end(), a)? (§5.) - Why can
operator<<not be a member of your class? What are the two roles of the returnedstd::ostream&? (§4.) - A comparator returns
a.title_ <= b.title_. Name the violated property, state what the standard calls the resulting behaviour, and give the one-line fix. (§6.) - Predict: what does §7's
Trackprogram print, and which function runs on the line with the=? (§7.) - What exactly does a
staticdata member belong to, and what must be written outside the class where the compiler does not define it? (§8.) - Why can a
staticmember function not be markedconst? Give the one-word answer and the error it produces when it touches instance data. (§9.) - A colleague writes
titles() constreturningstd::vector<std::string>andoperator<<as a member. Which of the two choices does this module argue against, and why? (§3, §4.)
Next: Worked examples — four programs, each with a
prediction prompt, from a failing sort to a measurement of what overloading &&
would cost. · All modules