Lesson 19 of 25

Templates

Writing the Same Function Once

Suppose you need a function that returns the larger of two values. You write it for int. Then somebody needs it for double, so you copy it and change the types. Then for std::string. Now three identical functions exist, and any fix to the logic has to be made three times — and will be made twice, because somebody will miss one.

C inherited a bad answer to this: a preprocessor macro. Macros are pure text substitution, so they have no type checking, they evaluate their arguments more than once (which breaks MAX(i++, j)), and their errors point at incomprehensible expanded text. C++ has a proper answer.

A template is a pattern from which the compiler writes the actual functions. You write findMax once with a placeholder type T. When you call it with two ints, the compiler works out that T is int, generates an int version and compiles it. Call it with doubles and a second version appears. This step is called instantiation, and it happens per type, on demand — a template you never call generates no code at all.

The result is genuinely the best of both worlds: one source of truth for the logic, and full type checking and full speed for every version. There is no run-time cost whatsoever, because all of the decisions were made during compilation. This is why templates are described as compile-time polymorphism, as opposed to the run-time polymorphism that virtual provides.

The compiler deduces T from the arguments in most cases, but you can state it explicitly with findMax<double>(3, 4.5), which is necessary when deduction would fail or would deduce something you did not want. typename and class mean exactly the same thing in a template parameter list; typename reads better because the type need not be a class.

Example
#include <iostream>
#include <string>

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

// Two independent type parameters, with a deduced return type (C++14)
template <typename T, typename U>
auto add(const T& a, const U& b) {
    return a + b;
}

int main() {
    std::cout << findMax(10, 20) << '\n';            // 20      T = int
    std::cout << findMax(3.14, 2.71) << '\n';        // 3.14    T = double
    std::cout << findMax(std::string("apple"),
                         std::string("banana")) << '\n';   // banana

    // findMax(3, 4.5);            // error: T cannot be both int and double
    std::cout << findMax<double>(3, 4.5) << '\n';    // 4.5 — stated explicitly

    std::cout << add(3, 4.5) << '\n';                // 7.5 — two type parameters
}
Notes
  • std::max from <algorithm> is exactly this function, already written and tested. Write your own once to understand the mechanism, then use the standard one — and note that it has the same single-type-parameter restriction, which is why std::max(3, 4.5) also fails to compile.

Class Templates

Classes can be templates too, and this is where most of the value is: every container in the standard library is a class template. std::vector<int> and std::vector<std::string> are two separate classes generated from one definition.

The syntax mirrors function templates — template <typename T> before the class — and inside, T is used wherever a concrete type would go. Unlike function templates, class templates historically could not deduce their type from the constructor arguments, so you write Stack<int> s;. Since C++17 deduction works in many cases, but writing the type is still the clearer habit while learning.

Template parameters do not have to be types. A non-type template parameter lets you pass a compile-time value, most often a size. This is how std::array<int, 5> works: the 5 is part of the type, which is why an std::array knows its own size with no run-time storage and why two arrays of different sizes are different types.

Now the practical rule that catches everyone the first time they split a project into files: template definitions must live in the header. The reason follows directly from how instantiation works. The compiler can only generate the int version of your class when it sees both the template's full definition and the fact that you asked for int. If the definitions are in a .cpp file, the compiler processing main.cpp never sees them, generates nothing, and you get undefined reference at link time — for code you can see with your own eyes.

So templates are written header-only: the class definition and all its member function bodies in the .h. This is why the standard library headers are so large, and it is also why heavy template use slows compilation down.

Example
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

template <typename T>
class Stack {
    std::vector<T> data_;

public:
    void push(const T& item) { data_.push_back(item); }

    T pop() {
        if (data_.empty()) throw std::out_of_range("pop from empty stack");
        T top = data_.back();
        data_.pop_back();
        return top;
    }

    const T& peek() const {
        if (data_.empty()) throw std::out_of_range("peek at empty stack");
        return data_.back();
    }

    bool empty() const { return data_.empty(); }
    std::size_t size() const { return data_.size(); }
};

// A non-type template parameter: the size is part of the type
template <typename T, std::size_t N>
class FixedBuffer {
    T items_[N]{};
public:
    constexpr std::size_t capacity() const { return N; }
    T& operator[](std::size_t i) { return items_[i]; }
};

int main() {
    Stack<int> numbers;
    numbers.push(1);
    numbers.push(2);
    std::cout << numbers.pop() << '\n';        // 2

    Stack<std::string> words;
    words.push("hello");
    std::cout << words.peek() << '\n';         // hello

    FixedBuffer<double, 4> buf;
    buf[0] = 1.5;
    std::cout << buf.capacity() << ' ' << buf[0] << '\n';   // 4 1.5
}
Notes
  • If a template compiles fine but the linker reports undefined reference to one of its member functions, the definitions are almost certainly sitting in a .cpp file. Move them into the header. Some projects use a .tpp or .inl file included at the bottom of the header, which keeps the header readable while still making the definitions visible.

The Hidden Contract, and Those Error Messages

A template looks as though it works with any type, but it does not. It works with any type that supports whatever the body does. findMax uses >, so T must be comparable with >. A function that prints its argument requires a type with an operator<<. This is a real contract, but it is implicit — nothing in the signature states it.

The consequence is C++'s most notorious rough edge. Instantiate a template with a type that does not meet the hidden contract and the error is not reported at your call site with a helpful message. It is reported deep inside the template's body, often several layers down inside standard library code, and a single mistake can produce several hundred lines of output mentioning types you have never heard of.

The technique for reading them is worth learning, because you will need it. Scroll to the first error, not the last. Look for the line that says required from here or in instantiation of — that is your code, and it names the type that failed. Then read the one line of the template that could not be compiled. Ninety per cent of the time it is a missing operator or a missing constructor on your type.

You can improve matters yourself with static_assert, which checks a condition at compile time and produces the message you wrote. Combined with the type traits in <type_traits>, it lets a template state its requirements and reject bad types with one clear line instead of two hundred.

C++20 added concepts, which turn those implicit contracts into named, checkable requirements written directly in the template's declaration. They are the proper fix for this problem, and if your compiler supports C++20 they are well worth learning after you are comfortable with plain templates.

Example
#include <iostream>
#include <string>
#include <type_traits>

// The hidden contract: T must support > and be copyable
template <typename T>
T findMax(const T& a, const T& b) {
    return (a > b) ? a : b;
}

// Stating the contract yourself, with a message you control
template <typename T>
T average(const T& a, const T& b) {
    static_assert(std::is_arithmetic_v<T>,
                  "average() requires a numeric type");
    return (a + b) / T{2};
}

struct Student { std::string name; };   // no operator>

int main() {
    std::cout << findMax(3, 9) << '\n';        // 9
    std::cout << average(4, 10) << '\n';       // 7
    std::cout << average(4.0, 11.0) << '\n';   // 7.5

    // findMax(Student{"A"}, Student{"B"});
    //   -> a wall of errors, ending in "no match for operator>"

    // average(std::string("a"), std::string("b"));
    //   -> one clear line: "average() requires a numeric type"
}
Notes
  • When a template error appears, the fastest first move is often to write the same call with concrete types instead of the template. If a > b does not compile for your type on its own, you have found the problem in one line instead of two hundred.

Templates or Virtual Functions?

Both templates and virtual functions let one piece of code work with many types, so it is worth being clear about when each applies. They are not competitors so much as answers to different questions.

Templates decide at compile time. The compiler generates a separate, fully optimised version for each type, so there is no dispatch cost and everything can be inlined. The requirement is that every type must be known while compiling. This is why the standard library is built from templates: std::sort on a std::vector<int> compiles down to code as tight as a hand-written integer sort.

Virtual functions decide at run time. That costs an indirection and blocks inlining, but it buys something templates cannot offer: a single container holding objects of different types, chosen while the program runs. A std::vector<std::unique_ptr<Shape>> can hold circles and squares together. A template cannot do that, because std::vector<Circle> and std::vector<Square> are unrelated types.

So the question to ask is simply: do I know the type at compile time? If yes, a template is faster and safer. If the type is chosen while the program runs — from a config file, from user input, from a plugin — you need virtual functions.

One cost of templates worth mentioning, because it surprises people who assume generic code is free: each instantiation is separate machine code. Using a heavy template with fifteen different types produces fifteen copies in your binary. This is called code bloat, and along with slower compilation it is the price of the run-time speed.

  • Type known at compile time, speed matters — template
  • Type chosen at run time, or a mixed collection — virtual functions
  • A container or algorithm that should work with any element type — template
  • A plugin or strategy the user picks at run time — virtual functions
  • Templates: no run-time cost, larger binary, slower builds, harder error messages
  • Virtual: one indirection per call, no inlining, smaller binary, clearer errors
Notes
  • The two combine well and often do. The standard library uses templates for containers and algorithms, and your application uses virtual functions for the parts whose type genuinely varies at run time — for instance a std::vector<std::unique_ptr<PaymentMethod>>, which is a template holding polymorphic objects.
Ask AI