C++ Templates and Concepts
Templates are how C++ does generic code. Concepts (C++20) make that code readable and debuggable. Here is how both work together in practice.
What you'll learn
- ✓How templates are instantiated
- ✓What concepts add over plain templates
- ✓Writing readable constraints
- ✓SFINAE and what concepts replace
- ✓Practical concept design patterns
Prerequisites
- •Basic familiarity with the language
Templates are the reason C++ can offer zero-overhead generic code. They are also the reason error messages used to span twenty terminal screens. C++20 added concepts, which turn template error messages from cryptic to actionable and let you express interface requirements directly in the language. Together, they are how modern generic C++ is written.
How a template is instantiated
A template is a code generator. When you call max(3, 7), the compiler looks at the definition, substitutes int for T, and generates a fresh function. No runtime cost, but every distinct T produces a new copy in the binary.
template <typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}
int x = max(3, 7); // generates max<int>
double y = max(1.5, 2.5); // generates max<double>
If T does not support operator>, the error happens deep inside the template, not at the call site. That is the historical pain point.
The mental model
Templates work by substitution. Concepts add a check before substitution.
Without concepts:
call site --> substitute T --> compile body --> ERROR deep in template
With concepts:
call site --> check concept --> if OK, substitute and compile
|
v
CLEAR ERROR at the call site The compiler can refuse to instantiate a template if the type does not satisfy a concept, and it can say so with a message that points to the failed requirement.
What concepts look like
A concept is a named compile-time predicate over types.
#include <concepts>
template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template <Numeric T>
T square(T x) { return x * x; }
square(3); // OK
square(3.14); // OK
// square("hi"); // compile error: "hi" does not satisfy Numeric
You can also write concepts with requires expressions, which sketch the operations a type must support.
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>;
};
This concept is satisfied by any type where a + b is well-formed and the result is convertible to T. The compiler does not need a manual list of supported types.
Constraining functions
Three equivalent syntaxes:
template <typename T>
requires Numeric<T>
T square(T x) { return x * x; }
template <Numeric T>
T square(T x) { return x * x; }
auto square(Numeric auto x) { return x * x; }
The last form, abbreviated function templates, is the shortest. Pick the form that reads best to your team and stick with it.
Overload selection
When multiple overloads match, the most constrained wins. This replaces a lot of SFINAE machinery.
template <typename T>
void process(T x) { /* generic */ }
template <std::integral T>
void process(T x) { /* integers */ }
template <std::floating_point T>
void process(T x) { /* floats */ }
process(3); // integer overload
process(2.5); // float overload
process("hi"); // generic overload
In the old world, this required std::enable_if and partial ordering tricks. With concepts, the compiler picks the most specialized one automatically.
A hands-on example
A print function that adapts to its argument.
#include <concepts>
#include <iostream>
#include <ranges>
#include <string>
template <typename T>
concept Printable = requires(std::ostream& os, T x) {
{ os << x } -> std::same_as<std::ostream&>;
};
void print(Printable auto x) {
std::cout << x << '\n';
}
void print(std::ranges::range auto&& r) {
for (const auto& item : r) std::cout << item << ' ';
std::cout << '\n';
}
int main() {
print(42);
print(std::string{"hello"});
print(std::vector{1, 2, 3});
}
Two overloads, both constrained, both readable. The compiler picks the right one based on the argument.
SFINAE and what concepts replace
Before concepts, template overload selection relied on Substitution Failure Is Not An Error. You wrote conditional types using std::enable_if, returning unusable expressions in cases you wanted to exclude. It worked but the errors were brutal.
// old style
template <typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
T half(T x) { return x / 2; }
The concept version:
template <std::integral T>
T half(T x) { return x / 2; }
Same result, much clearer.
Common pitfalls
Writing concepts that are too narrow. Container concepts that demand specific member types can exclude valid types that happen to spell things differently. Lean on the standard library concepts (std::input_iterator, std::ranges::range, etc.) before writing your own.
Forgetting that concepts are compile-time. A concept cannot dispatch on a runtime value. If you need runtime polymorphism, use virtual functions or std::variant.
Over-constraining. If a function would work for any type with operator<, do not require Numeric. Concepts should describe what the function actually needs.
Practical tips
Compose concepts. Build small, named predicates and combine them with && and ||. The combined names document the requirements.
Constrain template parameters in public APIs even if it is more verbose. The error messages your users see will be the difference between a quick fix and a debugging session.
For library code, write concept-driven overloads instead of conditionals. The intent is clearer and the compiler does more work for you.
Wrap-up
Templates remain the foundation of generic C++; concepts are the layer that finally makes them ergonomic. With concepts, your interface tells the compiler and the reader exactly what a type must do, error messages point at the right line, and SFINAE is a footnote. Reach for standard concepts first, write small custom ones when you need them, and let constrained overloads replace the old tricks.
Related articles
- C++ C++ Templates: Generics That Compile to Specifics
Learn C++ templates from scratch: function templates, class templates, type deduction, specialization, and how the compiler stamps out concrete code.
- C++ C++ CMake Tutorial: Build Your First Project
Learn modern CMake for C++ — targets, properties, and dependencies — and configure a small project that compiles cleanly across platforms.
- C++ C++ constexpr and Compile-Time Computing
How constexpr, consteval, and constinit let you move computation from runtime to compile time, with practical patterns and the rules that govern them.
- C++ C++ Coroutines: An Introduction
Understand C++20 coroutines — co_await, co_yield, co_return — and how they enable async and generator-style code without blocking threads.