C++ Templates Tutorial — Function Templates, Class Templates, and Concepts

One definition, three functions in the binary. A measured guide to templates, from deduction through concepts and the errors they produce.

One cookie cutter above three biscuits of different colours sharing its exact outline, illustrating how a single C++ template definition generates a separate concrete function for each type used

A template is not a function. It is instructions for writing one, and the compiler follows them once per distinct type you use. Write one larger(T, T) and call it with an int, a double and a std::string, and the binary ends up containing three separate functions — which nm will show you by name.

That single fact explains most of what people find confusing about templates: why the error messages are enormous, why their definitions usually have to be visible wherever they are used, and why “template bloat” is a real phenomenon that the optimiser sometimes erases completely. This guide covers function templates, class templates, specialisation, variadic templates and C++20 concepts, with a section on reading the compiler errors they produce. Every program below was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 under -std=c++20 with -Wall -Wextra. Diagnostic line counts, symbol listings and binary sizes are measured from those runs, and every output block is captured verbatim.

Table of Contents

The Short Answer

What is a template in C++? A parameterised definition. template <typename T> introduces T as a placeholder for a type, and the compiler generates a concrete function or class for each distinct set of template arguments that actually requires one.

When should I write one? When you have two or more functions or classes that differ only in the types they operate on.

What changed recently? C++17 added class template argument deduction, so you can write Pair p{3, 4} instead of Pair<int> p{3, 4}. C++20 added concepts, which let you state a template’s requirements up front instead of letting the compiler discover them deep inside an instantiation.

What Is a Template in C++?

A template is a definition parameterised by types or values, written with template <typename T> before a function or class. It is not itself compiled into the program; the compiler instantiates it, generating a concrete function or class for each distinct set of template arguments that requires instantiation — and for class templates, only the members actually used. This gives one source definition that works across many types, with the type checking still performed at compile time.

The normative rules are in cppreference’s templates page. The practical model is simpler: a template is a recipe, and the compiler is the cook.

Function Templates

The smallest useful example. One definition, any type that supports <:

#include <iostream>
#include <string>

template <typename T>
T larger(T a, T b) { return (a < b) ? b : a; }

int main() {
    std::cout << larger(3, 7)                 << '\n';   // T deduced as int
    std::cout << larger(2.5, 1.5)             << '\n';   // T deduced as double
    std::cout << larger(std::string{"apple"},
                        std::string{"pear"})  << '\n';   // T deduced as std::string
    std::cout << larger<double>(3, 7.5)       << '\n';   // T stated explicitly
    return 0;
}

Output:

7
2.5
pear
7.5

You never wrote <int> or <std::string> for the first three calls. The compiler deduced T from the argument types — template argument deduction, and it is what makes templates pleasant to call.

One definition, three functions in the binary A template is a recipe. The compiler writes a separate function for every type it sees. template <typename T> T larger(T, T) no code of its own larger(3, 7) deduces T = int larger(2.5, 1.5) deduces T = double larger(s1, s2) deduces T = std::string int larger<int> (int, int) double larger<double> (double, double) string larger<string> (string, string) These three symbols are what nm reports in the compiled binary — listed in full below.

The three functions are really there

This is the part worth seeing rather than being told. Compiling that program and listing its symbols:

$ nm -C basics | grep larger

W std::__cxx11::basic_string<char, ...> larger<std::__cxx11::basic_string<char, ...> >(...)
W double larger<double>(double, double)
W int larger<int>(int, int)

Three distinct functions from one definition. The template definition is not itself one of the emitted functions — the code in the binary belongs to its instantiations. That is the mental model to keep; almost every template question resolves back to it.

When deduction fails

Deduction is not conversion. Both parameters are declared T, so both arguments must deduce the same T:

template <typename T> T larger(T a, T b) { return (a < b) ? b : a; }
int main() { return larger(3, 7.5) > 0; }   // int and double: which T?
error: no matching function for call to 'larger(int, double)'
    2 | int main() { return larger(3, 7.5) > 0; }
      |                     ~~~~~~^~~~~~~~
note: candidate: 'template<class T> T larger(T, T)'

Which fix is right depends on the interface you intend. Give the call an explicit argument (larger<double>(3, 7.5)) when converting to one common type is the point; use two parameters (template <typename A, typename B>) when mixed argument types are genuinely valid; or return auto when you want the common type deduced for you.

typename or class?

In a template parameter list, template <typename T> and template <class T> mean exactly the same thing. typename is the clearer spelling because T need not be a class — int is a perfectly good T. class is older and still common in existing code.

There is one place typename is not interchangeable: before a dependent qualified name, where the compiler cannot otherwise tell whether the name is a type.

template <typename Container>
void f(const Container& c) {
    typename Container::value_type first = *c.begin();   // typename required
}

Without typename, the compiler assumes Container::value_type names a value, not a type, and rejects the declaration. C++20 relaxed the rule in some contexts where only a type is possible. Compiling the same dependent name four ways under GCC 13.3, a return type and an alias declaration stopped needing typename at -std=c++20, while a base specifier never needed it. The block-scope variable declaration above — the context where most people first hit this — still requires it in C++20 exactly as it did in C++17. The rule remains worth knowing, because it explains an error message that otherwise makes no sense.

Class Templates and CTAD

A class template parameterises a whole type. Template parameters can also be values, not just types:

template <typename T>
class Stack {
    std::vector<T> items;
public:
    void push(T value)  { items.push_back(std::move(value)); }
    T    pop()          { T top = std::move(items.back()); items.pop_back(); return top; }
    bool empty() const  { return items.empty(); }
    std::size_t size() const { return items.size(); }
};

// A non-type template parameter: N is a value, fixed at compile time.
template <typename T, std::size_t N>
struct FixedBuffer {
    T data[N];
    static constexpr std::size_t capacity() { return N; }
};

template <typename T>
struct Pair {
    T first, second;
    Pair(T a, T b) : first(a), second(b) {}
};

int main() {
    Stack<std::string> s;
    s.push("first"); s.push("second");
    std::cout << "size=" << s.size() << "  pop=" << s.pop() << '\n';

    FixedBuffer<int, 8> buf{};
    std::cout << "FixedBuffer capacity = " << buf.capacity() << '\n';

    Pair p{3, 4};                       // CTAD: Pair<int>, no <int> written
    std::vector v{1, 2, 3};             // CTAD: std::vector<int>
    std::cout << "CTAD Pair<int>: " << p.first << ',' << p.second << '\n';
    std::cout << "CTAD vector size = " << v.size() << '\n';
    return 0;
}

Output:

size=2  pop=second
FixedBuffer capacity = 8
CTAD Pair<int>: 3,4
CTAD vector size = 3

Those last two declarations use class template argument deduction (CTAD), added in C++17: the type argument is deduced from the constructor arguments. Before C++17 you had to write it out. Compiling the identical file as C++14 shows what changed:

$ g++ -std=c++14 classtmpl.cpp
error: missing template arguments before 'p'
error: missing template arguments before 'v'

CTAD is why std::vector v{1, 2, 3}; works in modern code and why older tutorials always write std::vector<int>. It is not universal: deduction only succeeds when the constructors — or an explicit deduction guide — give the compiler enough to work with, so some class templates still need their arguments spelled out.

Both std::vector and std::string are class templates you already use — std::string is an alias for std::basic_string<char>. In the GCC 13.3 / libstdc++ build tested here its small-string buffer holds up to 15 characters; that threshold is a property of the implementation, not of the language.

Class templates and function templates differ in one practical way: a class template’s member functions are only instantiated if you call them. A member with an error in it can sit unnoticed until some instantiation actually uses it.

Template Specialisation

Specialisation supplies a different definition for particular template arguments.

// Primary template
template <typename T>
struct Describe { static std::string name() { return "some other type"; } };

// Full specialisation: an exact type
template <>
struct Describe<bool> { static std::string name() { return "bool"; } };

// Partial specialisation: any pointer
template <typename T>
struct Describe<T*> { static std::string name() { return "pointer to " + Describe<T>::name(); } };

// Partial specialisation: any array of known bound
template <typename T, std::size_t N>
struct Describe<T[N]> { static std::string name() { return "array of " + Describe<T>::name(); } };

Output:

some other type
bool
pointer to bool
pointer to pointer to some other type
array of some other type

Note Describe<int**> printing “pointer to pointer to some other type” — the partial specialisation matched, then recursed into itself. That recursion over types is the basis of most compile-time type manipulation.

One rule that trips people up: function templates cannot be partially specialised. Overloading is the usual alternative, and since C++20 constraints give another way to select among templates by requirement. Class templates support both full and partial specialisation.

Variadic Templates and Fold Expressions

A template can take an arbitrary number of arguments. C++17’s fold expressions make consuming them readable:

template <typename... Args>
auto sum_all(Args... args) { return (args + ... + 0); }   // binary fold, 0 seeds the empty case

template <typename... Args>
void print_all(const Args&... args) {
    ((std::cout << args << ' '), ...);                    // comma fold
    std::cout << '\n';
}

template <typename... Args>
constexpr std::size_t count_args(const Args&...) { return sizeof...(Args); }

Output:

15
0
42 hello 3.5 x 
3

sum_all() with no arguments returns 0 because the fold was written with a seed value. Without one, a unary fold over an empty pack is only valid for a few operators — supplying the identity explicitly is the habit worth forming.

sizeof...(Args) gives the number of arguments in the pack, at compile time.

Concepts (C++20)

Before C++20, a template’s requirements were implicit: whatever the body happened to do. Concepts let you name those requirements and state them in the signature. Nothing is checked at run time — a concept constrains which template arguments are valid during compilation.

#include <concepts>

// Define a requirement once, reuse it by name.
template <typename T>
concept Addable = requires (T a, T b) { { a + b } -> std::convertible_to<T>; };

// Three equivalent ways to apply it:
template <Addable T>                                   // 1. constrained parameter
T add1(T a, T b) { return a + b; }

template <typename T> requires Addable<T>              // 2. requires clause
T add2(T a, T b) { return a + b; }

Addable auto add3(Addable auto a, decltype(a) b)       // 3. abbreviated
{ return a + b; }

// Overloads selected by constraint rather than by exact type.
template <std::integral T>       std::string kind(T) { return "integral"; }
template <std::floating_point T> std::string kind(T) { return "floating point"; }

Output:

5 3.5 ab
integral / floating point
Addable<int>          = true
Addable<std::vector>  = false

A concept is also a compile-time boolean you can test directly, which is what the last two lines show. The standard library ships a useful set in <concepts>std::integral, std::floating_point, std::same_as, std::convertible_to, std::sortable — documented on cppreference’s constraints page.

if constexpr: branching at compile time

Related, and often confused with concepts. if constexpr discards the untaken branch entirely, so it may contain code that would not compile for that type:

template <typename T>
std::string describe(const T& value) {
    if constexpr (std::is_integral_v<T>) {
        return "integer " + std::to_string(value);
    } else if constexpr (std::is_floating_point_v<T>) {
        return "real " + std::to_string(value);
    } else {
        return "something else";
    }
}
integer 42 | real 2.500000 | integer 99

describe('c') reports “integer 99” because char is an integral type, and 99 is the code for 'c' — a reminder that std::is_integral_v<char> is true.

Concepts choose which template to use. if constexpr chooses what happens inside one. Reach for concepts at the interface and if constexpr for implementation details.

Decoding Template Error Messages

Template errors have a reputation, and the standard advice is that concepts fix them. That is worth measuring rather than repeating, so here are the same two mistakes compiled both ways, with the diagnostics counted.

Case 1: a shallow failure

A single-level template whose body needs +=, called with a type that lacks it. Unconstrained, versus the same template with a concept:

VersionGCC 13.3 diagnostic linesCharacters
Unconstrained5366
Constrained with a concept171,189

The concept made this error more than three times longer. That is the opposite of the usual claim, and it is a real result on a real case — a shallow template where the compiler could already point straight at the offending line.

Case 2: a deep failure

Now the case the reputation actually comes from: std::sort on a vector whose element type has no operator<, so the failure happens several levels inside <algorithm>.

VersionGCC 13.3 diagnostic linesCharacters“required from” frames
Unconstrained std::sort7810,83020
Constrained wrapper263,5881

Three times shorter, and the instantiation trace collapsed from twenty frames to one.

But the length is not the important part. Look at where each diagnostic begins. The unconstrained version opens inside the standard library:

In file included from /usr/include/c++/13/bits/stl_algobase.h:71,
                 from /usr/include/c++/13/algorithm:60,
                 from deep_unconstrained.cpp:1:

The constrained version opens at the line the programmer wrote:

deep_constrained.cpp:14:15: error: no matching function for call to 'sort_range(std::vector<Point>&)'
   14 |     sort_range(pts);
      |     ~~~~~~~~~~^~~~~
note: candidate: 'template<class R> requires (random_access_range<R>) && (sortable<...>)'

Concepts do not universally shorten template errors — they move the failure to the call site. For a shallow template that costs you verbosity. For a deep instantiation stack it saves both length and, more usefully, points at your code instead of the library’s. That is the honest version of the claim.

Reading a template error

Three habits that work regardless of concepts:

  1. Read from the bottom. With GCC’s diagnostic format, the last “required from” frame is usually your code and everything above it is the library’s descent into the failure. Clang formats its notes differently, but the principle — find the frame that names your file — carries across.
  2. Find the first error: line, not the first line of output. The opening lines are often just include paths.
  3. Look for the operation that failed, not the type. “no match for operator<” tells you what to add.

Does Template Code Really Bloat Your Binary?

The claim that templates cause code bloat is old and repeated everywhere, including in the version of this article that stood here before, which asserted it without evidence. It is measurable.

The test: one class template with two non-inlinable member functions, instantiated over N distinct types, compiled at -O2.

Distinct types.text sizeBox<> symbolsvs. N=1
1359 B21.0×
10903 B202.5×
503,479 B1009.7×
1006,951 B20019.4×

Two symbols per instantiation, and in this test roughly 68 bytes of .text per additional type, growing approximately linearly across the range measured. The bloat is real and it is measurable.

Now the other half. Running the same experiment without noinline, so the compiler is free to inline and fold:

types=  1  .text=281 B
types= 10  .text=281 B
types= 50  .text=281 B
types=100  .text=281 B

Identical at every size. In this build GCC inlined every member and computed the results at compile time, leaving the same .text size at every instantiation count tested.

So both statements are true, and neither is complete. Each instantiation genuinely produces its own code, and when that code is small and inlinable the optimiser removes it entirely. Bloat becomes a real cost when instantiations are large, numerous, and not inlinable — a template-heavy library instantiated over dozens of types, not a Stack<int> and a Stack<double>.

If you do hit it, the standard remedies are to move type-independent logic into a non-template base class, to use explicit instantiation to control where code is emitted, and to reduce the number of distinct instantiations. Measure first: a small reproducible experiment settles this faster than reasoning about it, and the numbers above came from four generated files and a size command.

Key Takeaways

  • A template emits no code. One definition of larger produced three separate functions in the binary, confirmed by name with nm.
  • Deduction is not conversion. larger(3, 7.5) failed to compile because T cannot be both int and double.
  • CTAD (C++17) removed most explicit type arguments. The same file compiled as C++14 rejected Pair p{3, 4}.
  • Function templates cannot be partially specialised — use overloading, or constraints.
  • Concepts move errors to the call site rather than always shortening them. In a deep failure they cut the diagnostic from 78 lines to 26 and the instantiation trace from 20 frames to 1; in a shallow one they made it longer.
  • Template bloat is real and measurable.text grew 19.4× from 1 to 100 instantiations. With inlining allowed, the same test produced an identical 281-byte .text at every size.
  • Reach for concepts at interfaces, if constexpr inside implementations.

Frequently Asked Questions

Conclusion

Templates are easier to reason about once you stop reading them as code and start reading them as instructions the compiler follows. Deduction, instantiation, specialisation and constraints are all answers to the same question: which concrete function or class should the compiler write, given what it can see at the call site?

That framing also makes the error messages tractable. A wall of diagnostics is the compiler narrating its descent through instantiations, and the useful line is almost always the last one that names your file. Concepts help by making the compiler check the requirement before it starts descending — which, as measured above, is a change in where the error appears more than how long it is. The containers and algorithms that templates were largely invented to support show the same machinery from the user’s side, and the rest of the C++ section covers the neighbouring ground.

Scroll to Top