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:
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.