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;
}
One source-level function, three different generated functions. The compiler stamps out a fresh `maximum` for each `T` it sees.
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):
- Deduce
T: both arguments areint, soT = int. - Substitute the template, producing a hypothetical
int maximum(int, int). - 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. - 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;
}
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.
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:
Box<int>andBox<double>are unrelated types. No implicit conversion. No common base. The relationship lives at the template level.- A class template can have member function templates, like
greater_thanabove. These get their own template parameter list. - Templates live in headers, not in
.cppfiles, because the compiler needs to see the full definition to instantiate. (There’s explicit instantiation for the rare case it matters; ignore until it bites.)
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:
- Mixed types:
maximum(3, 7.5)won’t compile —Tcan’t be bothintanddouble. Either cast, or writemaximum<double>(3, 7.5)explicitly. - Implicit conversions are disabled in deduction: deduction matches
exactly. If a function takes
T, calling it with something that would convert toTdoesn’t help deduction figure out whatTis. 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;
}
`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.
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;
}
`Rest...` is a *parameter pack*. `rest...` (in an expression) is a pack expansion. The pattern: peel one, recurse on the rest.
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:
- Deduces
Ttwo ways (intfrom the first arg,std::stringfrom the second), can’t reconcile — fails. Error: “no matching function for call tomaximum.” 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:
- 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.”
- Compile with
-fconcepts-diagnostics-depth=1(gcc) or-ftemplate-backtrace-limit=2(clang) to cap the noise. - 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:
- Compile-time cost. Each instantiation re-compiles the body. Heavy templates can dominate a build’s wall time.
- Binary size. Every distinct instantiation is its own code in the
output.
std::vector<int>andstd::vector<long>produce different machine code, even ifint == longon your platform. - Inscrutable errors until you adopt concepts.
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
- A template is a blueprint. The compiler instantiates it once per distinct set of template arguments, producing fresh code each time.
- Function templates deduce their type arguments from the call. Class templates usually need them spelled out (or use CTAD in C++17+).
TandUare conventional names;class Tandtypename Tmean the same thing as template parameter prefixes.- Non-type template parameters let you parameterize over compile-time values (sizes, flags). They produce distinct types per value.
- Variadic templates with
T...packs accept any number of arguments. Use fold expressions to avoid recursion. - Templates live in headers — the compiler needs the full definition to instantiate.
- Errors are verbose because instantiation fails in a deep stack. Read the first line; reach for concepts to make errors human-shaped.
- Don’t write a template until a second concrete use case forces it.
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.