Skip to content

Module 12 — Worked Examples

Every example asks you to predict before it shows you the output, because the prediction is the exercise and the output is only the marking scheme. Where a program prints more than is useful here, an excerpt is marked with ….

The programs are in examples/module-12/, each folder with its own expected-output.txt. Build any of them with:

g++ -std=c++17 -Wall -Wextra main.cpp -o program && ./program

Two of the examples below begin with a failure on purpose — a type that cannot satisfy the template, and a template split across two files. Both are the point of the module, so do not skip them.


Example 1 — one definition, three types [L2 Apply]

The problem: the same function body has been written once per type. What does the compiler do when you write it once, in terms of a type supplied later?

Before you read on, predict: three calls to one template, with int, std::string and double. How many functions exist in the finished program? Then predict what showDeduced("hi") reports as T — std::string, or const char*?

// examples/module-12/01-function-templates/main.cpp
template <typename T>
T maximum(T left, T right) { return left > right ? left : right; }   // ONE definition

template <typename T>
void showDeduced(T value);        // what is T when the argument is a string literal?

Output (excerpt):

Part A - one definition, three types
  maximum(3, 7)                 -> 7
  maximum(std::string("b"), std::string("a")) -> b
  maximum(2.5, 1.5)             -> 2.5

Part B - what the compiler deduces, and from what
      deduced T is int, and the value is 42, sizeof(T) = 4
      deduced T is double, and the value is 1.5, sizeof(T) = 8
      deduced T is std::string, and the value is text, sizeof(T) = 32
      deduced T is const char*, and the value is literal, sizeof(T) = 8

Part C - two type parameters, when the types differ
      largerOf<int, double>(3, 4.5) -> 4.5
      `maximum(3, 4.5)` does not compile: deduction cannot choose between int and double

Why it behaves that way: three calls produce three real functions, generated at compile time, each as fast as one written by hand — that is instantiation. Deduction matches the argument type against the parameter type, so a string literal gives T = const char* and not std::string, because no conversion is applied to make the deduction fit. And when the two arguments disagree, deduction has to produce one value for T, so it produces none, and the error says so:

error: no matching function for call to 'larger(int, double)'
note:   deduced conflicting types for parameter 'T' ('int' and 'double')

What to take away: write the body once, and let the compiler write the versions. The cost is compile time and code size, not run time — and the second T in the parameter list is a promise that both arguments have the same type.


Example 2 — one class, many element types [L2 Apply]

The problem: a Stack of int, of std::string, of double, and of std::vector<int>. What is shared between those four types, and what is not?

Before you read on, predict: the class has a static counter of default constructions. Two Stack<int> objects and one Stack<std::string> are created. What does each counter print? Then predict whether Stack<std::string>::converted<int>(3) — a member that cannot possibly work for a string — stops the program compiling.

// examples/module-12/02-class-templates/main.cpp
template <typename T>
class Stack {
public:
    template <typename Other>
    T converted(const Other& value) const { return static_cast<T>(value); }   // not valid for every T

    static int& defaultConstructions() { static int count = 0; return count; }
private:
    std::vector<T> items_;
};

Output (excerpt):

Part A - one class, written once, used for four element types
  Stack<int>: top 30, 3 element(s)
  Stack<std::string>: top second, 2 element(s)
  Stack<std::vector<int>>: top row has 3 values, 1 element(s)

Part B - each instantiation is a distinct type with its own statics
  Stack<int> objects constructed         3
  Stack<std::string> objects constructed 2
  Two Stack<int> objects and one Stack<std::string> were built here, and
  the two counts are separate variables. Nothing is shared between
  instantiations, which is what "a distinct type" means in practice.

Part D - member functions are compiled only when they are used
  Stack<int> with converted<int>: 3
  Stack<std::string> exists and works: text

Why it behaves that way: each set of template arguments produces a distinct real class, so Stack<int> and Stack<std::string> have no relationship at all — not a base class, not a conversion, not a shared static. And member functions are instantiated only where they are used, which is why Stack<std::string>::converted<int> — whose body would be return static_cast<std::string>(value);, which does not compile — is harmless until somebody calls it. Both facts are load-bearing: the first explains why templates are type-safe without a hierarchy, the second explains why some errors arrive very late.

What to take away: a class template is not a class. It is a pattern the compiler turns into classes, one per set of arguments, each complete and independent.


Example 3 — the capacity as part of the type [L3 Modify]

The problem: a container with a fixed capacity, where the capacity is a design fact rather than a run-time value. What does making it a template parameter buy, and what does it cost?

Before you read on, predict: FixedArray<int, 3> and FixedArray<int, 8> — same element type, different capacity. How many bytes does sizeof report for each, and where does the storage live? Then predict what happens when the capacity comes from a configuration file.

// examples/module-12/03-non-type-parameters/main.cpp
template <typename T, std::size_t Capacity>
class FixedArray {
public:
    static_assert(Capacity > 0, "a FixedArray needs a capacity of at least 1");
    FixedArray(std::initializer_list<T> values);      // the pleasant way to create one
    static constexpr std::size_t capacity() { return Capacity; }
private:
    T data_[Capacity]{};                              // a member array: no allocation
    std::size_t size_ = 0;
};

Output (excerpt):

Part A - the capacity is part of the type, not of the object
  FixedArray<int, 3>: capacity 3, sizeof 24 bytes, and the elements are members of the object
  FixedArray<int, 8>: capacity 8, sizeof 40 bytes, and the elements are members of the object

Part B - the initializer_list constructor
  FixedArray<int, 3> values{1, 2, 3} has 3 element(s), capacity 3
  too many initial values: more initial values than the capacity allows

Part C - the capacity is a compile-time constant you can use
  constexpr capacity usable as an array bound: 3 elements

Part E - where a non-type parameter is the wrong tool
  A capacity of 18 could not be used to
    create a FixedArray: `FixedArray<int, requested>` does not compile,
    because a template argument must be a constant expression.

Why it behaves that way: Capacity is a value, not a type, so it becomes part of the type's identity: three ints and eight ints are different types, with different sizes (24 and 40 bytes here: the elements plus the size counter), and the elements live inside the object. Nothing is allocated, so the Rule of Five of Module 10 is irrelevant — and capacity() being constexpr lets it be used where C++ demands a constant, such as an array bound.

The initializer_list constructor is the usability half. Its size is known only at run time, so a list longer than the capacity is a run-time error — refused with a message rather than corrupting memory. C++ has no way to check that form at compile time, and the class says so honestly.

What to take away: a non-type parameter makes a design fact checkable at compile time, and makes a run-time fact impossible. Reach for it when the capacity genuinely is a property of the type.


Example 4 — one type gets different code [L4 Analyze]

The problem: a general implementation is right for most types and wrong for one. How do you say so, and what does the standard library do with the same tool?

Before you read on, predict: Formatter<T> streams anything into a string. What does it print for true? Then predict what Formatter<int*>::text(&value) prints, given that a partial specialisation for T* exists.

// examples/module-12/04-specialisation-and-traits/main.cpp
template <typename T>            class Formatter { /* streams the value */ };
template <>                      class Formatter<bool> { /* "true" / "false" */ };
template <typename T>            class Formatter<T*> { /* recurses on the pointee */ };

Output (excerpt):

Part A - the primary template, doing the general job
  Formatter<int>::text(42)          -> 42
  which implementation: primary template

Part B - a full specialisation, because one type needs different code
  Formatter<bool>::text(true)        -> true
  which implementation: full specialisation for bool
  Without the specialisation, the primary template would have printed 1 and
  0 - streamed as integers, which is not what a bool means.

Part C - a partial specialisation, for a whole family of types
  Formatter<int*>::text(&value)      -> -> 7
  Formatter<int*>::text(nullptr)     -> (null)
  which implementation: partial specialisation for T*

Why it behaves that way: the implementation is chosen by the compiler from the argument type, before the program runs, and it is not overridable — unlike Module 7's virtual dispatch, where the object decides at run time. The partial specialisation covers every pointer type with one rule, and recurses into Formatter<int> for the pointee, which is the primary template again; that recursion is why a partial specialisation is more than a convenience.

The same program shows the idea in the standard library, and its cost:

  std::vector<bool> is a full specialisation that packs one bit per
  element, so `v[0]` returns a PROXY object, not a `bool&`.
error: cannot bind non-const lvalue reference of type 'bool&' to an rvalue of type 'bool'
note:   after user-defined conversion: 'std::_Bit_reference::operator bool() const'

What to take away: specialise for correctness, not for convenience. Formatter<bool> fixes a genuine defect; std::vector<bool> trades addressability for memory and is still argued about decades later.


Example 5 — a requirement the compiler can enforce [L4 Analyze]

The problem: a template needs something of its type. How do you state that so a failure is one readable line, and how do you branch on a compile-time question without breaking the types that do not take the branch?

Before you read on, predict: four calls to describe with int, bool, double and a pointer. Which branch is compiled for bool, and why does the integral branch — which calls std::to_string — not break the program even though std::to_string(true) is not what anybody wants?

// examples/module-12/04-specialisation-and-traits/main.cpp
template <typename T>
std::string describe(const T& value) {
    if constexpr (std::is_same<T, bool>::value)            { return "boolean: " + Formatter<bool>::text(value); }
    else if constexpr (std::is_integral<T>::value)         { return "integral: " + std::to_string(value); }
    else if constexpr (std::is_floating_point<T>::value)   { return "floating point: " + Formatter<T>::text(value); }
    else if constexpr (std::is_pointer<T>::value)          { return "pointer " + Formatter<T>::text(value); }
    else                                                    { return "other: " + Formatter<T>::text(value); }
}

Output (excerpt):

Part D - `if constexpr`: branching at compile time inside one function
  describe(42)        -> integral: 42
  describe(true)      -> boolean: true
  describe(2.5)       -> floating point: 2.5
  describe(&local)    -> pointer -> 7
  describe(std::string("hi")) -> other: hi

Part E - a requirement stated so the compiler can enforce it
  average(3, 4)       = 3.5
  average(1.5, 2.5)   = 2
  `average<Money>` would not compile. The static_assert in the body is
  checked when the template is instantiated, and it fires with:
    error: static assertion failed: average<T> requires an arithmetic
           type: use Money::average() instead

Why it behaves that way: if constexpr discards the branches whose conditions are false at compile time, so they are not instantiated for that type — that is the difference from a plain if, and it is what lets one function handle types with mutually incompatible operations. The static_assert is the other half: a requirement is checked where the template is instantiated, so average<Money> fails with one line naming the requirement and the alternative, instead of an error about + on Money from inside the body.

What to take away: state what you require, in a form the compiler can enforce. Every message you do not write is a page of diagnostics somebody else has to read.


Example 6 — one repository, two record kinds [L6 Build]

The problem: three containers with the same logic, and a fourth record kind coming. Write it once, impose the requirements explicitly, and use it for two genuinely different types.

Before you read on, predict: two instantiations of one class, one holding Book and one holding Member. Do they have different sizeof? Then predict what happens when sortedById() is called on a Repository<std::string> — and what happens when it is never called at all.

// examples/module-12/05-generic-repository/main.cpp
template <typename T, typename = void>
struct has_int_id : std::false_type {};

template <typename T>
struct has_int_id<T, std::void_t<decltype(T{}.id)>> : std::is_same<decltype(T{}.id), int> {};

template <typename T>
class Repository {
public:
    template <typename Predicate> std::vector<T> where(Predicate matches) const;
    template <typename Projection> int totalOf(Projection value) const;
    std::vector<T> sortedById() const {            // compiled only when used
        static_assert(has_int_id<T>::value,
                      "Repository<T>::sortedById() requires T to have an int member named id");
        …
    }
};

Output (excerpt):

Part A - one class template, two record kinds
  Repository<Book> holds 3 book(s); Repository<Member> holds 2 member(s)
    sizeof(Repository<Book>)   = 24
    sizeof(Repository<Member>) = 24
  Both are the size of one std::vector - three pointers - because the
  elements live on the heap, not inside the repository.

Part B - the same generic queries over both, with lambdas as arguments
  books.where(copies >= 2)    -> Compilers, OOP in C++
  books.totalOf(copies)       = 7
  members.forEach(...)        -> Ayesha(2), Bilal(0)

Part C - a requirement, checked where it is declared
  books.sortedById()          -> OOP in C++, Algorithms, Compilers
  Repository<std::string> holds 2 label(s) and never calls sortedById()
  Calling labels.sortedById() would fail the static_assert and print:
    error: static assertion failed: Repository<T>::sortedById() requires
           T to have an int member named id

Why it behaves that way: the repository is a pattern, and the compiler produced two real classes from it — same interface, different element type, no dispatch anywhere. The lambdas supply the type-specific knowledge (book.copies, member.loans) so that the container never needs to know it, which is what makes one implementation serve both. sizeof is the same for both because a std::vector is three pointers whatever it holds; the difference between the instantiations is in the elements, not in the object.

The sortedById() case is the subtle one. Repository<std::string> is perfectly legal because that member function is never instantiated; calling it produces one line naming the requirement. The trait it uses — has_int_id, built with std::void_t — exists because writing static_assert(decltype(T{}.id) …) directly would report "no member named id" at the assertion, since the expression must be well formed before the condition can be evaluated. The SFINAE-based trait turns "ill formed" into false, and then the assertion has something to say.

What to take away: one component, several types, requirements documented and enforced. That is objective 10 of this module, and the shape Lab 12 asks for.


Next: Misconceptions and mistakes — eight beliefs that feel obviously true, and the diagnostic that contradicts each one.