Skip to content

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());
error: no match for 'operator<' (operand types are 'Book' and 'Book')

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::sort performs O(n log n) of them.
  • It is const. Every standard algorithm holds its elements by const reference at some point; a non-const comparator cannot be called.
  • It is a member, so the left operand is *this and the right is the argument. a < b calls a.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:

error: 'Book& operator=(Book&, const Book&)' must be a non-static member function
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:

  1. The first parameter is std::ostream&. That is why it cannot be a member of Money: a member's left operand is always *this.
  2. It returns the same stream. std::cout << a << b means operator<<(operator<<(std::cout, a), b), so the result of the first call must itself be something you can << into. Returning void compiles for one object and fails for two.
  3. It formats at the last moment. Money stores 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:

error: no match for 'operator!=' (operand types are 'Book' and 'Book')

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:

  1. The declaration is not the definition. A static data 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 static in the class is the definition, which is why venue_ above needs nothing more.
  2. constexpr static members of integral or enumeration type can be initialised in the class and (until C++17, where inline applies) may still need a definition if something takes their address. Write static constexpr int kFirstSerial = 1000; and prefer it for constants.
  3. 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; for static members 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:

std::cout << Ticket::liveCount() << '\n';     // no Ticket exists yet

It has no this, which is not a technicality but the whole explanation:

error: invalid use of member 'Book::id_' in static member function
  • 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. const qualifies *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:

int nextId() {
    static int counter = 0;      // initialised once, on first call
    return ++counter;
}

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:

  1. Must it be a member? (=, [], (), ->, conversions.) If yes, done.
  2. Is one operand a stream, or can the caller reasonably write the other operand first? If yes, make it free.
  3. 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:

  1. 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.
  2. Is it consistent with the rest of the type's interface? If == compares identifiers and printing shows titles, a caller will eventually be surprised.
  3. Is the ordering a strict weak ordering? If not, the standard algorithms are undefined behaviour (§6).
  4. Would a named function be clearer? book.mergeWith(other) beats book + other when the operation is not addition.
  5. 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.cpp measures 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.

  1. std::sort reported no match for 'operator<'. Was anything missing from your code, or was something wrong with it? What else does the error tell you? (§1.)
  2. Predict: a type defines operator== but not operator!=. Which of these compile: a == b, a != b, std::find(v.begin(), v.end(), a)? (§5.)
  3. Why can operator<< not be a member of your class? What are the two roles of the returned std::ostream&? (§4.)
  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.)
  5. Predict: what does §7's Track program print, and which function runs on the line with the =? (§7.)
  6. What exactly does a static data member belong to, and what must be written outside the class where the compiler does not define it? (§8.)
  7. Why can a static member function not be marked const? Give the one-word answer and the error it produces when it touches instance data. (§9.)
  8. A colleague writes titles() const returning std::vector<std::string> and operator<< 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