Generic Programming · #9 of 13

Templates from First Principles

How `std::sort` works for every type, why the compiler generates fresh code per instantiation, and what makes template error messages so famous

Why it matters

Every container and algorithm you’ve used in the last two lessons — std::vector<int>, std::sort, std::unique_ptr<Widget> — is a template instantiation. The standard library is not a pile of overloads. It’s a small set of generic blueprints, plus the compiler machinery that stamps out a specific, optimized version per type you actually use.

Templates are the feature that makes C++ “zero-cost generic.” A std::vector<int> is exactly as fast as a hand-rolled int array. A std::sort on doubles compiles to the same instructions a careful hand-written sort would emit. Inheritance can’t do this. Generics in Java and Go can’t do this. Templates can, because the compiler generates fresh code per type, with full visibility into the type’s layout and operations.

This is also the feature that makes C++ error messages legendary. The first half of this lesson is the mental model. The second half is how to read what the compiler is shouting.

A function template, in one screen

The simplest template: a function parameterized over its argument type.

#include <iostream>
#include <string>

template <class T>
T maximum(T a, T b) {
return (a > b) ? a : b;
}

int main() {
std::cout << "ints:    " << maximum(3, 7) << "\n";
std::cout << "doubles: " << maximum(2.5, 1.5) << "\n";

std::string apple = "apple";
std::string banana = "banana";
std::cout << "strings: " << maximum(apple, banana) << "\n";

// Explicit instantiation — pin T even when deduction would do something else:
std::cout << "explicit:" << maximum<double>(3, 7.5) << "\n";
return 0;
}
idle

One source-level function, three different generated functions. The compiler stamps out a fresh `maximum` for each `T` it sees.

expected output
ints:    7
doubles: 2.5
strings: banana
explicit:7.5
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

What the compiler actually does when you write maximum(3, 7):

  1. Deduce T: both arguments are int, so T = int.
  2. Substitute the template, producing a hypothetical int maximum(int, int).
  3. Type-check the substituted body. If anything in the body wouldn’t compile for int, you get an error here, at the call site, with the stack of template instantiations attached. This is most of why template errors look terrifying.
  4. Generate code: emit machine code for int maximum(int, int) exactly once, even if you call it 1000 times. Then link.

Crucially: there is no one maximum function in your binary. There are as many as you have distinct Ts. Compile time pays for this; runtime gets it free.

What class T and typename T mean

You’ll see both forms. They mean the same thing in this context.

template <class T>        // historical; reads naturally
template <typename T>     // C++ added this for parameters that aren't classes
template <class T, class U>
template <typename Iterator>

typename is required in some other positions (lookup-dependent type disambiguation) but as a template parameter prefix the two are interchangeable. Pick one and stay consistent. Most modern code uses class for type parameters and typename only where it’s required.

Class templates: the real workhorse

Function templates are nice. Class templates are how std::vector and std::map and std::unique_ptr exist.

#include <iostream>
#include <utility>

template <class T>
class Box {
T value_;
public:
explicit Box(T v) : value_(std::move(v)) {}

const T& get() const { return value_; }

// Member functions in a class template are also templates.
template <class U>
bool greater_than(const U& other) const {
  return value_ > other;
}
};

int main() {
Box<int> a{5};
Box<double> b{3.14};

std::cout << "a.get() = " << a.get() << "\n";
std::cout << "b.get() = " << b.get() << "\n";
std::cout << "a > 3?  " << a.greater_than(3) << "\n";
std::cout << "b > 4?  " << b.greater_than(4) << "\n";
return 0;
}
idle

A class template parameterized over the stored type. Each instantiation is a distinct type — `Box<int>` and `Box<double>` are unrelated, just like `int` and `double` are.

expected output
a.get() = 5
b.get() = 3.14
a > 3?  1
b > 4?  0
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

A few rules worth knowing:

Template argument deduction

Deduction is the compiler figuring out what T should be from the call site. Most of the time it Just Works:

maximum(3, 7);          // T = int, no need to say it
maximum(2.5, 1.5);      // T = double
maximum(apple, banana); // T = std::string

std::vector<int> v;     // T = int, but here you spell it because there's no argument to deduce from
auto v2 = std::vector{1, 2, 3};   // C++17 CTAD: T deduced from {1,2,3}

Two situations where deduction needs help:

  1. Mixed types: maximum(3, 7.5) won’t compile — T can’t be both int and double. Either cast, or write maximum<double>(3, 7.5) explicitly.
  2. Implicit conversions are disabled in deduction: deduction matches exactly. If a function takes T, calling it with something that would convert to T doesn’t help deduction figure out what T is. This is intentional — it keeps deduction predictable.

C++17 added Class Template Argument Deduction (CTAD): std::vector{1, 2, 3} deduces std::vector<int> from the initializer. Most of the standard containers support CTAD.

Non-type template parameters

Templates can take values as parameters too, as long as the value’s type is acceptable:

#include <iostream>
#include <array>

template <class T, std::size_t N>
class FixedBuffer {
T data_[N];
public:
std::size_t size() const { return N; }
T&       operator[](std::size_t i)       { return data_[i]; }
const T& operator[](std::size_t i) const { return data_[i]; }
};

int main() {
FixedBuffer<int, 8> small;
for (std::size_t i = 0; i < small.size(); i++) small[i] = (int)(i * i);
std::cout << "small.size() = " << small.size() << "\n";
std::cout << "small[3] = " << small[3] << "\n";

FixedBuffer<int, 1000> big;   // a different type, with a different sizeof
std::cout << "sizeof(small) = " << sizeof(small) << "\n";
std::cout << "sizeof(big)   = " << sizeof(big)   << "\n";
return 0;
}
idle

`FixedBuffer<int, 8>` and `FixedBuffer<int, 1000>` are different types. Their sizes are baked in at compile time. This is exactly how `std::array<T, N>` works.

expected output
small.size() = 8
small[3] = 9
sizeof(small) = 32
sizeof(big)   = 4000
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

The size, type traits, function pointers, even C++20-and-later structural types can be template parameters. The constraint: they have to be known at compile time.

Variadic templates: any number of arguments

Variadic templates accept a pack of template parameters:

#include <iostream>
#include <string>

// Base case: no more args.
void print() {
std::cout << "\n";
}

// Recursive case: peel one arg, recurse on the rest.
template <class T, class... Rest>
void print(T first, Rest... rest) {
std::cout << first;
if (sizeof...(rest) > 0) std::cout << ", ";
print(rest...);                    // pass the rest along
}

int main() {
print(1, 2.5, "hello", std::string("world"), 42);
print();                            // base case alone
print("just one");
return 0;
}
idle

`Rest...` is a *parameter pack*. `rest...` (in an expression) is a pack expansion. The pattern: peel one, recurse on the rest.

expected output
1, 2.5, hello, world, 42

just one
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

C++17 added fold expressions that often replace the recursion:

template <class... Args>
void print(Args... args) {
  ((std::cout << args << ' '), ...);   // unary right fold over comma
  std::cout << '\\n';
}

That (... , (expr)) syntax expands to (((expr_1, expr_2), expr_3), …), calling the body once per pack element. Variadics are how std::tuple, std::make_unique, and std::format work.

When templates go wrong: the error message

Templates have a reputation for unreadable error messages. Here’s why:

If you write maximum(3, std::string("x")), the compiler:

  1. Deduces T two ways (int from the first arg, std::string from the second), can’t reconcile — fails. Error: “no matching function for call to maximum.” Plus a list of every overload it considered and rejected, with full template instantiation stacks for each.

The error is correct. It’s just verbose. Three things help:

  1. Read the first line. Compilers print the actual error first, then the instantiation context. The first line usually says what failed. The rest is “and here’s how I got there.”
  2. Compile with -fconcepts-diagnostics-depth=1 (gcc) or -ftemplate-backtrace-limit=2 (clang) to cap the noise.
  3. Use concepts (lesson 10). A named constraint produces an error like “argument doesn’t satisfy Comparable” instead of a 200-line substitution-failure dump.

Two-phase lookup, briefly

When the compiler sees a template definition, it does what type-checks it can with the placeholder types. When the template is instantiated with concrete types, it re-does the lookup of any dependent names — names whose meaning depends on T.

The practical consequence:

template <class T>
void f() {
  g();          // resolved at definition time (must already exist)
  T::value;     // dependent on T — resolved at instantiation time
  this->method(); // dependent on this — resolved at instantiation time
}

You’ll mostly notice two-phase lookup when an inherited method “isn’t found” in a class template — you need this-> or using to make the lookup happen in phase two.

When NOT to write a template

Templates compose beautifully but they have costs:

The rule: write a template when the function or class truly needs to work for arbitrary types. Don’t write one to look clever, or to “future- proof” — speculative generality is technical debt. Concrete code first, template-ify when a second concrete use case forces it.

Key takeaways

What’s next

Lesson 10 introduces concepts — the C++20 feature that lets you give a name to “the type T must support these operations.” The result: template parameter constraints that compile faster, fail at the right place, and produce errors a human can read.