Old C++ Templates vs Modern C++ Templates
"Simplicity boils down to two steps: identify the essential, eliminate the rest." – Leo Babauta https://leobabauta.com/

Introduction
Early C++ templates behaved like a loosely defined macro‑based code generator. Modern C++20 and beyond templates are a constrained, semantic, compile‑time language with predictable behavior, readable diagnostics, and real compile‑time execution. The mechanism is the same, but the semantics and safety model have evolved.
Old-School (C++98/03): Blueprints
A historical C++ template was a simple pattern for generating type‑specific code, often brittle and hard to debug. They formed foundation for the Standard Template Library or STL https://en.wikipedia.org/wiki/Standard_Template_Library.
Early examples included:
- vectors https://en.wikipedia.org/wiki/Sequence_container_(C%2B%2B)#Vector,
- lists https://en.wikipedia.org/wiki/Doubly_linked_list,
- queues https://en.wikipedia.org/wiki/Queue_(abstract_data_type),
- maps https://en.wikipedia.org/wiki/Associative_containers_(C%2B%2B).
Compile‑time computation was possible but painful. They were used to generate type-specific code with minimal semantic understanding https://en.wikipedia.org/wiki/Semantic_technology.
Generic code generators
The compiler cloned functions/classes for each type. In C++98, templates were literally blueprints. The compiler generated a new function or class for each distinct type used.
//Define a square template that generates the square of floats, doubles, and ints
template <typename T>
T square(T x) {
return x * x;
}
int main() {
square(3); // generates square<int>
square(3.14); // generates square<double>
}So the compiler produced something like:
square<int>(int)
square<double>(double)In other words, two separate instantiations complete with two separate pieces of code. Clunky, but effective. Templates were used to avoid duplicating function. Before templates you had to do something like specify the data type with the function:
square_intsquare_doublesquare_float
C++98 templates were originally designed as a type‑generic code generation mechanism. They lacked constraints and had delayed, unpredictable type checking, which often led to template errors that were hard to diagnose.
Macro Explosions!
Errors appeared deep in template instantiation, often unreadable. Because templates were unconstrained and substitution‑based, errors often looked like macro explosions. For example:
//Attempt to define a Wrapper struct with a typedef
template <typename T>
struct Wrapper {
typedef typename T::value_type type; // requires T::value_type
};
struct NoValueType {};
int main() {
Wrapper<NoValueType>::type x; // boom
}What you wanted:
A simple error:
"NoValueTypehas novalue_type."
What you got in C++98/03:
error: no type named ‘value_type’ in ‘struct NoValueType’
typedef typename T::value_type type;
^~~~~~~~~~~~~
note: in instantiation of template class ‘Wrapper<NoValueType>’ requested here
Wrapper<NoValueType>::type x;
note: in instantiation of ...
note: in instantiation of …….This could easily be 20–200 lines depending on how many templates were involved. And that my friends is a macro‑explosion.
“Less old” C++ Templates (C++11-17)
C++11 The “Big Bang” of Modern Templates
C++11 is where templates became usable for real metaprogramming. https://en.wikipedia.org/wiki/Template_metaprogramming.
C++17 Templates Become a Compile‑Time Language
There is insufficient time cover the complete evolution of templates, but some highlights are:
Traits‑Based Constraints
C++11–C++17 introduced traits‑based constraints, the entire ecosystem of techniques we used before concepts existed. They were used to express “this type must support X.”
This is the era where templates were powerful but primitive, and we had to build a constraint system out of:
- Type traits
- SFINAE
- enable_if
- void_t
- Metafunctions
This is the machinery that powered the STL, Boost (https://www.boost.org/, https://en.wikipedia.org/wiki/Boost_(C%2B%2B_libraries)), and generic libraries prior to C++ 20.
Type Traits (C++11)
Type traits are compile‑time predicates that answer questions about types.
Examples:
std::is_integral<T>::valueThey return true_type or false_type, which are just:
struct true_type { static constexpr bool value = true; };Traits let you ask about types, but they don’t enforce constraints by themselves.
Substitution Failure Is Not An Error (SFINAE)
SFINAE is the rule that makes traits‑based constraints possible. When substituting template arguments:
If substitution produces an invalid type or expression, then the compiler does not error. It simply removes that overload from consideration
This is how we “disabled” templates for types that don’t meet requirements.
Example:
//If size method is defined the set true_type
template <typename T>
auto test(int) -> decltype(std::declval<T>().size(), std::true_type{});
//Else false_type means disabled
template <typename>
std::false_type test(...);If T has .size(), the first overload is viable. If not, substitution fails and the fallback overload wins (if it exists, otherwise we get a compiler error).
This was the core mechanism behind all pre‑C++20 constraints.
enable_if (C++11)
std::enable_if was the workhorse for enabling/disabling templates.
template <typename T>
std::enable_if_t<std::is_integral_v<T>, int>
f(T x) { return x; }If the condition is false, the function is removed from overload resolution.
This is how we expressed:
- “Only enable this for integral types”
- “Only enable this if T has operator<”
- “Only enable this if T is constructible”
It’s powerful but super verbose.
void_t (C++17)
void_t was the C++17 simplification of SFINAE detection idioms.
template <typename, typename = void>
struct has_size : std::false_type {};
template <typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};void_t<...> becomes void if all expressions inside are valid. If not, substitution fails.
Metafunctions (C++17)
Metafunctions are templates that compute types or values at compile time.
Example: boolean metafunctions
template <typename T, typename = void>>
struct is_iterable : std::false_type {};
template <typename T>
struct is_iterable<T, std::void_t<decltype(std::declval<T&>().begin())>>
: std::true_type {};Among other things these were the building blocks for detection idioms. A detection idiom is the modern C++17‑era pattern for writing robust, SFINAE‑friendly type traits that check whether a type supports some operation, without causing hard errors.
It’s the reason behind std::void_t, std::is_detected, etc.
Compile-time reflection tricks
These were built from:
- detection idioms
- void_t
- SFINAE
- metafunctions
- decltype tricks
- std::declval
Together, these formed a proto‑reflection system.
Why detection idioms are reflection
Reflection means:
“Ask the compiler a question about a type or expression.”
C++17 detection idioms let you ask:
- Does
Thave a nested type? - Does
Thave a member function? - Is
T + Uvalid? - Is
f(t)callable? - Is
Titerable? - Does
Tsatisfy a structural interface?
All of these are reflection questions, answered at compile time.
Why This Was Hard (Why Concepts Were Needed)
Traits‑based constraints had major drawbacks:
- Verbose and Hard to read: lots of boilerplate code, intent buried in metafunctions.
- Indirect and Hard to debug: constraints expressed through overload tricks, SFINAE failures produce cryptic errors.
- Not semantic: traits check syntax, not behavior.
- Not composable: combining constraints required nested enable_if.
- Not part of overload resolution: only removed overloads, didn’t rank them.
Together, these formed the proto‑concepts system that C++20 replaced with real constraints.
Modern C++ Templates (C++20 and onward)
A modern C++ template is a compile‑time system including concepts, constexpr, and metaprogramming utilities.
From C++20 onward, templates have these defining characteristics:
- Constrained — templates can express requirements directly
- Composable constraints — constraints can be combined, layered, and reused
- Semantic over syntactic — constraints describe behavior, not just syntax
- Integrated with overload resolution — constraints participate in overload ranking
- Way less SFINAE gymnastics — concepts replace detection idioms
- A real compile‑time language — templates + constexpr + concepts form a coherent system
- Better partial specialization behavior — constraints guide specialization selection
- Foundation for ranges, views, and modern STL — the STL itself now uses concepts everywhere
C++20+ constexpr — Full Compile‑Time Execution
Modern C++ turns constexpr into a complete compile‑time execution model, not just a restricted subset.
Key properties (C++20 and beyond)
constexprvirtual functions are allowedconstexprdynamic allocation is allowedconstexprcontainers (e.g.,std::vector) are usable at compile timeconstexpralgorithms (C++20 ranges algorithms are constexpr)constevalforces compile‑time evaluationconstinitguarantees static initialization without requiring constexpr
Example: constexpr vector (C++20)
constexpr std::vector<int> v = {1,2,3,4};
static_assert(v.size() == 4);Not all containers are constexpr‑friendly yet, but std::vector, std::string, and many others are.
C++20+ Metaprogramming Utilities — Replaced by Concepts
The old toolbox:
enable_ifvoid_t- detection idioms
- SFINAE tricks
- metafunctions returning
true_type/false_type
…is now replaced by direct language support.
Modern equivalents
| Old Utility | Modern Replacement |
|---|---|
|
|
|
|
SFINAE overload tricks |
|
boolean metafunctions |
concepts +
|
tag dispatching |
constrained overloads |
Concepts Fixed Most of This
C++20 version
template <typename T>
concept HasValueType = requires { typename T::value_type; };
template <HasValueType T>
using Wrapped = typename T::value_type;Error message
error: type 'NoValueType' does not satisfy 'HasValueType'One line. Readable. Human‑friendly.
Compile-time polymorphism
Concepts give C++ a form of compile‑time polymorphism that mirrors what virtual functions do at runtime.
In classic C++ runtime polymorphism, each object of a class with virtual functions contains a hidden pointer called the vptr (virtual pointer). This vptr points to a vtable (virtual method table), which is a compiler‑generated table of function pointers representing the class’s dynamic interface. When you call a virtual function, the program performs a runtime lookup through the vtable to find the correct override based on the object’s dynamic type. This enables flexible polymorphism but introduces runtime overhead: an extra indirection, a non‑inlineable call, and a dependency on dynamic object layout.
Concepts avoid all of this. With concepts, the compiler resolves the correct overload at compile time by evaluating constraints and proving which function is the most specific match. There is no vptr, no vtable, no runtime dispatch, and no dynamic allocation requirement. The result is polymorphism without runtime cost: the compile‑time analog of virtual dispatch, but fully optimized and inlined.
See https://en.wikipedia.org/wiki/Virtual_method_table and https://en.wikipedia.org/wiki/Dynamic_dispatch and https://en.wikipedia.org/wiki/Compile-time_function_execution
Conclusion
This has been a faithful narrative of the evolution of C++ templates over the years!
Modern concepts are not without their disadvantages. Read the advanced section for details.
Advanced:
Concepts don’t eliminate SFINAE, they coexist with it
You still need SFINAE for:
- partial specialization
- certain detection idioms
- template metaprogramming tricks
- backward compatibility
Concepts replace most SFINAE, but not all.
Concepts add a second overload‑resolution system
Concepts improve clarity, but they also introduce constraint‑based overload ranking, which is:
- non‑trivial
- sometimes surprising
- occasionally ambiguous
Example:
void f(std::integral auto);
void f(std::signed_integral auto);f(-1) picks the more constrained overload — good. But add one more overload and you can get:
- ambiguous constraints
- constraint subsumption issues
- unexpected overload selection
This is a new mental model developers must learn.
Error messages are clearer… but still huge
Concepts improve top‑level diagnostics, but:
- compilers still dump long constraint traces
- nested concepts produce multi‑page messages
- template instantiation depth still leaks through
You get:
note: because 'T' does not satisfy 'Sortable'
note: because 'T' does not satisfy 'StrictWeakOrder<Less<T>>'
note: because ...Better than C++98, but still verbose.
Concepts can accidentally over‑constrain templates
A concept that is too specific breaks generic code.
Example:
template <typename T>
concept HasSize = requires(T t) {
{ t.size() } -> std::same_as<std::size_t>;
};This rejects:
std::string(size returnssize_type)std::vector<T>(size returnssize_type)- any container with a different return type
This is a common pitfall: concepts make it easy to write constraints that are too strict.
Compile‑time cost increases
Modern templates + concepts + constexpr = more work for the compiler.
This leads to:
- longer build times
- higher memory usage
- slower incremental builds
- more template instantiations
C++20 ranges are notorious for this.
Why std::ranges Compile Slowly
The slowdown comes from five interacting mechanisms, each of which is expensive on its own — but ranges combine all five.
1. Concept-heavy overload resolution
Every ranges algorithm has dozens of overloads, each guarded by multiple concepts.
Example (simplified):
template <std::input_iterator I, std::sentinel_for<I> S>
requires std::indirectly_unary_invocable<I>
void for_each(I first, S last, F f);
Each concept expands into:
- multiple requires-expressions
- nested concept checks
- type trait evaluations
- validity checks on expressions
Overload resolution must evaluate all constraints, even for overloads that will not be selected.
2. Constraint subsumption is expensive
What constraint subsumption is
Constraint subsumption answers this question:
Given two overloads with different constraints, does one logically imply the other?
If yes, the more specific one subsumes the more general one.
Example:
template <std::integral T>
void f(T);
template <std::signed_integral T>
void f(T);
std::signed_integral<T> implies std::integral<T>
so the second overload subsumes the first.
This is why calling f(-1) picks the signed version.
Why subsumption is more than type checking
Subsumption requires the compiler to:
- expand both concepts
- normalize their constraint expressions
- compare them as logical formulas
- prove whether one implies the other
This is logical implication, not simple matching.
It’s the same kind of reasoning used in formal verification.
How the compiler decides subsumption
Given two constrained overloads:
- Normalize both constraint expressions
- Break them into atomic predicates
- Check whether every predicate in the “stronger” set appears in the “weaker” set
- If yes → the stronger one subsumes the weaker
- If neither implies the other → ambiguous
This is why concepts can produce beautiful diagnostics but slow compilation.
Why subsumption matters
Without subsumption, concepts would behave like SFINAE:
- multiple viable overloads
- ambiguous calls
- cryptic errors
With subsumption:
- overloads form a semantic hierarchy
- the most specific overload wins
- constraints behave like real interfaces
It’s one of the most important parts of the concepts system.
There is a compile-time theorem prover. It’s powerful, but slow. See https://en.cppreference.com/cpp/language/constraints and https://stackoverflow.com/questions/72827759/is-using-ranges-in-c-advisable-at-all
3. Ranges are built from deeply layered adapters
A pipeline like:
auto r = vec | std::views::filter(pred) | std::views::transform(f);
expands into many nested types, each a template instantiation:
transform_view<
filter_view<
ref_view<vector<int>>,
Pred
>,
F
>
Each layer:
- introduces new template parameters
- triggers new concept checks
- instantiates new iterator/sentinel types
- instantiates new adapter closure types
A simple pipeline can easily generate 50–200 template instantiations.
4. Iterator and sentinel types are template-heavy
Ranges replace the old iterator model with:
- iterator types
- sentinel types
- borrowed ranges
- view types
- owning vs non-owning wrappers
- projections
- adapter closures
Each of these is a template with its own constraints.
Even a trivial algorithm like ranges::find may instantiate:
- iterator concept checks
- sentinel concept checks
- range concept checks
- projection concept checks
- indirect callable concept checks
This is a lot of compile-time machinery.
5. Everything is constexpr-enabled
Ranges algorithms are constexpr by default.
That means:
- more constant evaluation
- more Abstract Syntax Tree (AST) https://en.wikipedia.org/wiki/Abstract_syntax_tree nodes retained
- more template instantiations kept alive
- more memory pressure
- more work in the evaluator
Even if you don’t use them at compile time, the compiler must prepare them for compile-time execution.
Putting it all together
A single ranges call like:
auto r = v | std::views::filter(pred) | std::views::transform(f);
causes:
- dozens of concept checks
- dozens of overload candidates
- constraint subsumption comparisons
- nested template instantiations
- constexpr evaluation paths
- iterator/sentinel type generation
- adaptor closure type generation
This is why ranges are notorious for slow compile times.
Concepts are not introspective
You cannot ask:
- Which concept failed?
- Which requirement failed?
- What expression was invalid?
You only get a high‑level diagnostic.
This is a limitation compared to languages with structural typing https://en.wikipedia.org/wiki/Structural_type_system or reflection https://en.wikipedia.org/wiki/Structural_type_system.
Credits
C++98 standard https://www.iso.org/standard/25845.html
C++11 standard https://en.cppreference.com/cpp/11
C++17 standard https://en.cppreference.com/cpp/17
C++23 standard https://en.cppreference.com/cpp/23
C++ Templates: The Complete Guide 1st Edition
The C++ Programming Language: Special Edition (3rd Edition)