Template Metaprogramming: Computing at Compile Time

Master C++ template metaprogramming — learn compile-time computation, type traits, recursive templates, constexpr, and real-world TMP patterns with clear examples.

Template Metaprogramming: Computing at Compile Time

Template Metaprogramming (TMP) in C++ is a technique that uses the compiler’s template instantiation mechanism to perform computations at compile time rather than at runtime. By writing templates that recursively instantiate themselves with modified parameters, you can compute values, manipulate types, generate specialized code, and enforce constraints — all before your program even runs. The results are zero-runtime-overhead abstractions that would otherwise require expensive runtime computation or dynamic dispatch.

Introduction

When most programmers think about C++, they think about runtime behavior: objects being created, functions being called, data being processed as the program executes. But C++ has a second, parallel universe that operates entirely at compile time — a functional programming language embedded inside the type system, powered by templates.

This is Template Metaprogramming (TMP). The “meta” in metaprogramming means writing programs that reason about programs — in this case, writing templates that the C++ compiler evaluates during compilation to produce specialized code, computed constants, and type information that would otherwise require runtime work.

TMP was discovered almost accidentally in the early days of C++ when programmers noticed that template instantiation was itself a Turing-complete computation system. Andrei Alexandrescu’s book Modern C++ Design and the Boost libraries popularized TMP techniques, and C++11 through C++20 have progressively made compile-time programming more powerful and readable.

Understanding TMP gives you access to capabilities that are simply impossible in most other languages: types that change behavior based on the properties of their template arguments, compile-time-verified constraints on template parameters, algorithms that generate different machine code for different input types with zero branching, and library interfaces that adapt themselves to whatever types you use with them.

This article walks you through TMP from its foundations. You will understand compile-time values and types, recursive template patterns, type traits, constexpr functions, and the modern C++17/C++20 facilities that make TMP readable and practical. Every concept is illustrated with runnable code and thorough explanations.

The Foundation: Templates as Compile-Time Functions

The core insight of TMP is that template instantiation is a form of function application — it takes parameters (types or values) and produces a result (a type, a value, or more code). When you write:

C++
template<int N>
struct Square {
    static constexpr int value = N * N;
};

You have created a “compile-time function” that maps an integer to its square. Calling it looks like Square<7>::value, and the compiler evaluates it entirely during compilation — no runtime computation occurs.

C++
#include <iostream>
using namespace std;

// Compile-time square: computed at compile time
template<int N>
struct Square {
    static constexpr int value = N * N;
};

// Compile-time absolute value
template<int N>
struct Abs {
    static constexpr int value = (N < 0) ? -N : N;
};

// Compile-time max of two values
template<int A, int B>
struct Max {
    static constexpr int value = (A > B) ? A : B;
};

// Compile-time min of two values
template<int A, int B>
struct Min {
    static constexpr int value = (A < B) ? A : B;
};

int main() {
    // All values computed entirely at compile time
    constexpr int sq7   = Square<7>::value;      // 49
    constexpr int absN  = Abs<-15>::value;        // 15
    constexpr int maxAB = Max<42, 17>::value;     // 42
    constexpr int minAB = Min<42, 17>::value;     // 17

    cout << "Square<7>     = " << sq7   << endl;
    cout << "Abs<-15>      = " << absN  << endl;
    cout << "Max<42,17>    = " << maxAB << endl;
    cout << "Min<42,17>    = " << minAB << endl;

    // Use in static array size: compiler-computed dimension
    int grid[Max<3, 5>::value][Max<3, 5>::value];  // 5x5 grid
    cout << "Grid size: " << sizeof(grid) / sizeof(int) << " elements" << endl;

    return 0;
}

Output:

Plaintext
Square<7>     = 49
Abs<-15>      = 15
Max<42,17>    = 42
Min<42,17>    = 17
Grid size: 25 elements

Step-by-step explanation:

  1. Square<7>::value is not a function call — it is a member access on a class template instantiation. The compiler instantiates Square with N = 7, computes 7 * 7 = 49, and stores it as static constexpr int value. All of this happens during compilation.
  2. constexpr int sq7 = Square<7>::value stores the result in a compile-time constant. You can verify this is truly compile-time by using it where only compile-time values are allowed — like array dimensions (int grid[Max<3,5>::value][Max<3,5>::value]). If it required runtime evaluation, this line would be a compile error.
  3. Template structs acting as compile-time functions are the historical foundation of TMP. They are often called metafunctions — functions that operate on types and compile-time values rather than runtime values.
  4. The static constexpr combination means: one value per template instantiation (static), available at compile time (constexpr). Every Square<N> instantiation has its own value, computed for that specific N.

Recursive TMP: Computing Fibonacci and Factorial

The most powerful TMP patterns involve recursive template instantiation: a template that instantiates itself with modified parameters, with a base case that terminates the recursion. This mirrors recursive functions but operates entirely at compile time.

Compile-Time Factorial

C++
#include <iostream>
using namespace std;

// Recursive case: N! = N * (N-1)!
template<unsigned int N>
struct Factorial {
    static constexpr unsigned long long value = N * Factorial<N-1>::value;
};

// Base case: 0! = 1
template<>
struct Factorial<0> {
    static constexpr unsigned long long value = 1;
};

int main() {
    cout << "0!  = " << Factorial<0>::value  << endl;
    cout << "1!  = " << Factorial<1>::value  << endl;
    cout << "5!  = " << Factorial<5>::value  << endl;
    cout << "10! = " << Factorial<10>::value << endl;
    cout << "12! = " << Factorial<12>::value << endl;

    // All values are compile-time constants:
    static_assert(Factorial<5>::value == 120, "5! must be 120");
    static_assert(Factorial<10>::value == 3628800, "10! must be 3628800");

    cout << "All static_assert checks passed at compile time!" << endl;
    return 0;
}

Output:

Plaintext
0!  = 1
1!  = 1
5!  = 120
10! = 3628800
12! = 479001600
All static_assert checks passed at compile time!

Step-by-step explanation:

  1. Factorial<5>::value causes the compiler to instantiate: Factorial<5>Factorial<4>Factorial<3>Factorial<2>Factorial<1>Factorial<0>. Each instantiation references the next, building a chain.
  2. Factorial<0> is a template specialization — a specific implementation for a particular template argument value (N = 0). It provides the base case that stops the recursion. Without it, the compiler would try to instantiate Factorial<-1>, Factorial<-2>, and so on forever, eventually hitting the template instantiation depth limit and generating a compile error.
  3. static_assert(Factorial<5>::value == 120, "...") is a compile-time assertion. If the condition is false, the program fails to compile with the provided error message. This is TMP’s “unit testing” — verifying correctness at compile time, not runtime.
  4. The compiler generates zero assembly code for these computations. The value 120 for Factorial<5>::value is simply embedded as a literal constant in the compiled binary, just like writing 120 directly.

Compile-Time Fibonacci

C++
#include <iostream>
using namespace std;

// Recursive case: fib(n) = fib(n-1) + fib(n-2)
template<unsigned int N>
struct Fibonacci {
    static constexpr unsigned long long value =
        Fibonacci<N-1>::value + Fibonacci<N-2>::value;
};

// Base cases
template<> struct Fibonacci<0> { static constexpr unsigned long long value = 0; };
template<> struct Fibonacci<1> { static constexpr unsigned long long value = 1; };

int main() {
    cout << "Fibonacci sequence (compile-time):" << endl;
    cout << "F(0)  = " << Fibonacci<0>::value  << endl;
    cout << "F(1)  = " << Fibonacci<1>::value  << endl;
    cout << "F(10) = " << Fibonacci<10>::value << endl;
    cout << "F(20) = " << Fibonacci<20>::value << endl;
    cout << "F(30) = " << Fibonacci<30>::value << endl;

    // Important: compile-time Fibonacci is NOT exponential at runtime
    // The compiler memoizes each instantiation — each Fibonacci<N> is
    // computed once and reused wherever it is referenced
    static_assert(Fibonacci<10>::value == 55, "F(10) must be 55");

    return 0;
}

Output:

Plaintext
Fibonacci sequence (compile-time):
F(0)  = 0
F(1)  = 1
F(10) = 55
F(20) = 6765
F(30) = 832040

Step-by-step explanation:

  1. Fibonacci<5> needs Fibonacci<4> and Fibonacci<3>. Fibonacci<4> needs Fibonacci<3> and Fibonacci<2>. Crucially, the compiler instantiates each Fibonacci<N> at most once and caches the result — so Fibonacci<3> is computed once and reused, not recomputed exponentially as a naive runtime recursive Fibonacci would be.
  2. Two base case specializations are needed: Fibonacci<0> = 0 and Fibonacci<1> = 1. Together they stop the recursion.
  3. Notice that computing Fibonacci<30> at compile time generates zero runtime overhead — the value 832040 is simply a literal constant in the binary. At runtime, this is as fast as reading a hardcoded number.

Type-Level TMP: Manipulating Types at Compile Time

TMP is not limited to computing integer values. Its most powerful applications involve computing with types — transforming one type into another, selecting between types based on conditions, and extracting information about types.

The Conditional Type: if-else at the Type Level

C++
#include <iostream>
#include <type_traits>
using namespace std;

// Compile-time if: select TypeIfTrue if Condition is true, else TypeIfFalse
template<bool Condition, typename TypeIfTrue, typename TypeIfFalse>
struct Conditional {
    using type = TypeIfTrue;
};

// Partial specialization for Condition == false
template<typename TypeIfTrue, typename TypeIfFalse>
struct Conditional<false, TypeIfTrue, TypeIfFalse> {
    using type = TypeIfFalse;
};

// Helper alias (C++11 style)
template<bool Cond, typename T, typename F>
using Conditional_t = typename Conditional<Cond, T, F>::type;

int main() {
    // Select int or double based on a compile-time bool
    using SmallType  = Conditional_t<true,  int,    double>;
    using LargeType  = Conditional_t<false, int,    double>;
    using StringType = Conditional_t<(sizeof(int) < sizeof(long long)), string, wstring>;

    cout << "SmallType  is int?    " << is_same_v<SmallType, int>    << endl;
    cout << "LargeType  is double? " << is_same_v<LargeType, double> << endl;
    cout << "StringType is string? " << is_same_v<StringType, string> << endl;

    // Practical use: choose storage type based on size requirement
    template<size_t Size>
    struct SmallOrLarge {
        using StorageType = Conditional_t<(Size <= 4), int, long long>;
        StorageType data;
    };

    SmallOrLarge<3>  small;   // Uses int storage
    SmallOrLarge<8>  large;   // Uses long long storage

    cout << "Small storage size: " << sizeof(small.data) << " bytes" << endl;
    cout << "Large storage size: " << sizeof(large.data) << " bytes" << endl;

    return 0;
}

This pattern — Conditional<Condition, T, F>::type — is exactly what std::conditional<Condition, T, F>::type (and std::conditional_t<...>) does in the standard library. Let’s see it in action with a real use case:

C++
#include <iostream>
#include <type_traits>
using namespace std;

// A container that uses int for small counts and long long for large ones
template<size_t MaxElements>
class CompactContainer {
public:
    // Use int if max elements fits in int, otherwise use long long
    using CountType = conditional_t<(MaxElements <= 1000000), int, long long>;

    CountType count = 0;

    void add() { ++count; }

    void showInfo() {
        cout << "CountType size: " << sizeof(CountType) << " bytes, "
             << "count = " << count << endl;
    }
};

int main() {
    CompactContainer<1000> small;    // CountType = int (4 bytes)
    CompactContainer<5000000> large; // CountType = long long (8 bytes)

    small.add(); small.add(); small.add();
    large.add();

    small.showInfo();   // 4 bytes
    large.showInfo();   // 8 bytes

    return 0;
}

Output:

Plaintext
CountType size: 4 bytes, count = 3
CountType size: 8 bytes, count = 1

Step-by-step explanation:

  1. conditional_t<(MaxElements <= 1000000), int, long long> selects int when MaxElements is at most 1,000,000 and long long otherwise. This is evaluated entirely at compile time when CompactContainer is instantiated.
  2. For CompactContainer<1000>, CountType becomes int — saving 4 bytes per instance. For CompactContainer<5000000>, CountType becomes long long — providing the necessary range.
  3. There is no runtime branch. Each instantiation of CompactContainer produces a different, optimally-sized class. The compiler generates separate machine code for each instantiation.

Type Traits: Querying Type Properties at Compile Time

Type traits are compile-time predicates and transformations on types. The <type_traits> header provides dozens of them. Understanding how to build and use type traits is central to practical TMP.

C++
#include <iostream>
#include <type_traits>
using namespace std;

// Building a custom type trait: is_pointer_to_const<T>
// True if T is a pointer to a const type (e.g., const int*)
template<typename T>
struct is_pointer_to_const : false_type {};

template<typename T>
struct is_pointer_to_const<const T*> : true_type {};

// A type trait that removes all qualifiers layer by layer
template<typename T>
struct deep_remove_cv {
    using type = remove_cv_t<T>;
};

template<typename T>
struct deep_remove_cv<T*> {
    using type = typename deep_remove_cv<T>::type*;
};

template<typename T>
using deep_remove_cv_t = typename deep_remove_cv<T>::type;

// Testing standard type traits
void demonstrateTypeTraits() {
    // is_integral: is T an integer type?
    cout << "is_integral<int>:    " << is_integral_v<int>    << endl;  // 1
    cout << "is_integral<float>:  " << is_integral_v<float>  << endl;  // 0
    cout << "is_integral<bool>:   " << is_integral_v<bool>   << endl;  // 1

    // is_floating_point
    cout << "is_floating_point<double>: " << is_floating_point_v<double> << endl;

    // is_pointer
    cout << "is_pointer<int*>:    " << is_pointer_v<int*>    << endl;  // 1
    cout << "is_pointer<int>:     " << is_pointer_v<int>     << endl;  // 0

    // is_same: are two types identical?
    cout << "is_same<int,int>:    " << is_same_v<int, int>   << endl;  // 1
    cout << "is_same<int,long>:   " << is_same_v<int, long>  << endl;  // 0

    // is_base_of: inheritance check
    struct Base {};
    struct Derived : Base {};
    cout << "is_base_of<Base,Derived>: " << is_base_of_v<Base, Derived> << endl;  // 1
    cout << "is_base_of<Derived,Base>: " << is_base_of_v<Derived, Base> << endl;  // 0

    // Type transformations
    using ConstInt    = add_const_t<int>;           // const int
    using RawInt      = remove_const_t<const int>;  // int
    using PtrInt      = add_pointer_t<int>;         // int*
    using BaseInt     = remove_pointer_t<int*>;     // int
    using NoRef       = remove_reference_t<int&>;   // int
    using NoRefNoCV   = decay_t<const int&>;        // int

    cout << "add_const<int> is const int? " << is_same_v<ConstInt, const int> << endl;
    cout << "decay<const int&> is int? "     << is_same_v<NoRefNoCV, int>     << endl;
}

int main() {
    demonstrateTypeTraits();

    // Custom trait
    cout << "\nCustom trait tests:" << endl;
    cout << "is_pointer_to_const<const int*>:  "
         << is_pointer_to_const<const int*>::value  << endl;  // 1
    cout << "is_pointer_to_const<int*>:        "
         << is_pointer_to_const<int*>::value        << endl;  // 0
    cout << "is_pointer_to_const<const int**>: "
         << is_pointer_to_const<const int**>::value << endl;  // 0 (outer ptr not to const)

    return 0;
}

Output:

Plaintext
is_integral<int>:    1
is_integral<float>:  0
is_integral<bool>:   1
is_floating_point<double>: 1
is_pointer<int*>:    1
is_pointer<int>:     0
is_same<int,int>:    1
is_same<int,long>:   0
is_base_of<Base,Derived>: 1
is_base_of<Derived,Base>: 0
add_const<int> is const int? 1
decay<const int&> is int? 1

Custom trait tests:
is_pointer_to_const<const int*>:  1
is_pointer_to_const<int*>:        0
is_pointer_to_const<const int**>: 0

Step-by-step explanation:

  1. Standard type traits like is_integral_v<T>, is_pointer_v<T>, and is_same_v<T, U> are compile-time boolean predicates. The _v suffix is a C++17 variable template shorthand for ::value.
  2. Type transformation traits like add_const_t<T>, remove_reference_t<T>, and decay_t<T> compute new types from existing ones. The _t suffix is a C++14 alias template shorthand for ::type.
  3. decay_t<const int&> removes the reference and cv-qualifiers, producing int. This is what happens to function arguments when passed by value — decay models that transformation.
  4. The custom is_pointer_to_const<T> trait uses partial template specialization: the primary template returns false_type for all T, and the specialization for const T* returns true_type. This is the standard pattern for writing type traits.
  5. true_type and false_type are standard library types that inherit from integral_constant<bool, true> and integral_constant<bool, false> respectively. They provide ::value, operator bool(), and other conveniences for use in TMP.

if constexpr: Modern Compile-Time Branching

C++17 introduced if constexpr, which is the modern, readable replacement for many recursive TMP patterns. Unlike a regular if, if constexpr discards the non-taken branch at compile time — meaning the discarded branch is not instantiated, not type-checked, and generates no code.

C++
#include <iostream>
#include <type_traits>
#include <string>
#include <vector>
using namespace std;

// Universal toString: handles any type appropriately
template<typename T>
string toString(const T& value) {
    if constexpr (is_same_v<T, string>) {
        return value;  // Already a string
    } else if constexpr (is_arithmetic_v<T>) {
        return to_string(value);  // Numbers
    } else if constexpr (is_same_v<T, bool>) {
        return value ? "true" : "false";
    } else {
        // For anything with operator<<, use stringstream
        ostringstream oss;
        oss << value;
        return oss.str();
    }
}

// Process function: different behavior for arithmetic vs. other types
template<typename T>
void process(const T& value) {
    if constexpr (is_arithmetic_v<T>) {
        cout << "Arithmetic: " << value << " * 2 = " << (value * 2) << endl;
        // This branch compiles only for arithmetic T
        // value * 2 would be a compile error for string — but it's never compiled!
    } else if constexpr (is_same_v<T, string>) {
        cout << "String: '" << value << "', length = " << value.length() << endl;
        // value.length() would be a compile error for int — but discarded
    } else {
        cout << "Other type: " << value << endl;
    }
}

// Recursively sum a variadic pack
template<typename T>
T sumAll(T value) { return value; }

template<typename T, typename... Rest>
T sumAll(T first, Rest... rest) {
    if constexpr (sizeof...(rest) == 0) {
        return first;
    } else {
        return first + sumAll(rest...);
    }
}

int main() {
    process(42);
    process(3.14);
    process(string("hello"));

    cout << "\ntoString tests:" << endl;
    cout << toString(42)        << endl;
    cout << toString(3.14f)     << endl;
    cout << toString(string("world")) << endl;

    cout << "\nsumAll(1,2,3,4,5) = " << sumAll(1, 2, 3, 4, 5)         << endl;
    cout << "sumAll(1.5,2.5)   = " << sumAll(1.5, 2.5)               << endl;

    return 0;
}

Output:

Plaintext
Arithmetic: 42 * 2 = 84
Arithmetic: 3.14 * 2 = 6.28
String: 'hello', length = 5

toString tests:
42
3.14
world

sumAll(1,2,3,4,5) = 15
sumAll(1.5,2.5)   = 3

Step-by-step explanation:

  1. if constexpr (is_arithmetic_v<T>) evaluates the condition at compile time using the type trait. If T is int, the arithmetic branch is compiled and the string branch is completely discarded — it is never instantiated.
  2. This means value * 2 is only compiled when T is arithmetic. If T = string, value * 2 would be a compile error — but since the if constexpr branch is discarded for non-arithmetic types, it never causes an error.
  3. Before if constexpr, achieving this required multiple function template overloads or complex SFINAE expressions. if constexpr makes type-conditional behavior readable and maintainable.
  4. In sumAll, if constexpr (sizeof...(rest) == 0) checks at compile time whether the rest of the pack is empty. This eliminates the need for the separate base-case overload that recursive variadic templates traditionally require.

Compile-Time String Manipulation with constexpr

constexpr functions (introduced in C++11, greatly expanded in C++14 and C++17) are a modern, readable alternative to recursive template structs for computing values. They look like regular functions but are evaluated at compile time when called with constant expressions.

C++
#include <iostream>
#include <array>
using namespace std;

// Compile-time string length (counts chars until null terminator)
constexpr size_t ctstrlen(const char* s) {
    size_t len = 0;
    while (s[len] != '\0') ++len;
    return len;
}

// Compile-time FNV-1a hash of a string
constexpr uint32_t fnvHash(const char* s, size_t len) {
    uint32_t hash = 2166136261u;
    for (size_t i = 0; i < len; ++i) {
        hash ^= static_cast<uint32_t>(s[i]);
        hash *= 16777619u;
    }
    return hash;
}

// Convenient overload: compute hash from string literal directly
constexpr uint32_t operator""_hash(const char* s, size_t len) {
    return fnvHash(s, len);
}

// Compile-time power function: base^exp
constexpr long long power(long long base, unsigned int exp) {
    if (exp == 0) return 1;
    if (exp % 2 == 0) {
        long long half = power(base, exp / 2);
        return half * half;
    }
    return base * power(base, exp - 1);
}

// Compile-time check if a number is prime
constexpr bool isPrime(int n) {
    if (n < 2) return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;
    for (int i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}

int main() {
    // String operations at compile time
    constexpr size_t len = ctstrlen("Hello, World!");
    cout << "Length of \"Hello, World!\": " << len << endl;
    static_assert(len == 13, "Length must be 13");

    // Compile-time hashing — useful for switch statements on strings
    constexpr uint32_t h1 = "GET"_hash;
    constexpr uint32_t h2 = "POST"_hash;
    constexpr uint32_t h3 = "DELETE"_hash;

    cout << "Hash of \"GET\":    " << h1 << endl;
    cout << "Hash of \"POST\":   " << h2 << endl;
    cout << "Hash of \"DELETE\": " << h3 << endl;

    // Switching on string hashes (constant expressions in switch)
    const char* method = "POST";
    switch (fnvHash(method, ctstrlen(method))) {
        case "GET"_hash:    cout << "Handling GET request"    << endl; break;
        case "POST"_hash:   cout << "Handling POST request"   << endl; break;
        case "DELETE"_hash: cout << "Handling DELETE request" << endl; break;
        default:            cout << "Unknown method"          << endl;
    }

    // Compile-time power
    constexpr long long p = power(2, 10);  // 1024
    cout << "2^10 = " << p << endl;
    static_assert(p == 1024, "2^10 must be 1024");

    // Compile-time prime check — generate primes up to 30
    cout << "Primes up to 30: ";
    for (int i = 2; i <= 30; ++i) {
        if constexpr (false) {}  // placeholder
        // Using runtime loop with constexpr function — still runtime here
    }
    // Use static way: template with constexpr
    constexpr int primes[] = {
        isPrime(2)  ? 2  : 0,
        isPrime(3)  ? 3  : 0,
        isPrime(5)  ? 5  : 0,
        isPrime(7)  ? 7  : 0,
        isPrime(11) ? 11 : 0,
        isPrime(13) ? 13 : 0,
        isPrime(17) ? 17 : 0,
        isPrime(19) ? 19 : 0,
        isPrime(23) ? 23 : 0,
        isPrime(29) ? 29 : 0
    };
    for (int p : primes) if (p) cout << p << " ";
    cout << endl;

    return 0;
}

Output:

Plaintext
Length of "Hello, World!": 13
Hash of "GET":    2087226743
Hash of "POST":   2614700765
Hash of "DELETE": 2535029701
Handling POST request
2^10 = 1024
Primes up to 30: 2 3 5 7 11 13 17 19 23 29 

Step-by-step explanation:

  1. constexpr size_t len = ctstrlen("Hello, World!") computes the string length at compile time. The while loop inside ctstrlen is evaluated by the compiler — no loop runs at runtime.
  2. The user-defined literal operator""_hash allows the syntax "GET"_hash to compute a hash of a string literal at compile time. Used in switch cases, these become compile-time integer constants, enabling constant-time dispatch on strings.
  3. constexpr long long p = power(2, 10) computes 2^10 = 1024 at compile time. static_assert(p == 1024) verifies this is correct — if the function were wrong, the program would fail to compile.
  4. isPrime(n) is a constexpr function used to populate a compile-time array. Every element is computed during compilation. The resulting primes[] array contains no runtime computation — just stored constants.
  5. constexpr functions can be called both at compile time (when arguments are constant expressions) and at runtime (when called with runtime values). A single implementation serves both purposes.

TMP Pattern: Policy-Based Design

One of the most practical TMP patterns is policy-based design, where behavior is selected at compile time by passing a “policy” type as a template parameter. This gives compile-time polymorphism with zero virtual dispatch overhead.

C++
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// Sorting policies — different algorithms as type parameters
struct BubbleSort {
    static const char* name() { return "BubbleSort"; }
    template<typename It>
    static void sort(It begin, It end) {
        for (auto i = begin; i != end; ++i)
            for (auto j = begin; j < i; ++j)
                if (*i < *j) std::swap(*i, *j);
    }
};

struct StdSort {
    static const char* name() { return "StdSort"; }
    template<typename It>
    static void sort(It begin, It end) {
        std::sort(begin, end);
    }
};

struct InsertionSort {
    static const char* name() { return "InsertionSort"; }
    template<typename It>
    static void sort(It begin, It end) {
        for (auto i = begin + 1; i != end; ++i) {
            auto key = *i;
            auto j = i - 1;
            while (j >= begin && *j > key) {
                *(j + 1) = *j;
                --j;
            }
            *(j + 1) = key;
        }
    }
};

// Generic sorter: policy selected at compile time
template<typename SortPolicy>
class Sorter {
public:
    template<typename Container>
    void sort(Container& c) {
        cout << "Using " << SortPolicy::name() << endl;
        SortPolicy::sort(c.begin(), c.end());
    }
};

// Logging policy
struct ConsoleLogger {
    static void log(const string& msg) { cout << "[LOG] " << msg << endl; }
};
struct SilentLogger {
    static void log(const string& /*msg*/) {}  // No-op at compile time
};

// Data processor with pluggable sort and log policies
template<typename SortPolicy, typename LogPolicy = ConsoleLogger>
class DataProcessor {
    vector<int> data_;
public:
    void addData(initializer_list<int> values) {
        data_.insert(data_.end(), values.begin(), values.end());
        LogPolicy::log("Added " + to_string(values.size()) + " items");
    }

    void process() {
        LogPolicy::log("Sorting " + to_string(data_.size()) + " items");
        SortPolicy::sort(data_.begin(), data_.end());
        LogPolicy::log("Sort complete");
    }

    void print() const {
        for (int v : data_) cout << v << " ";
        cout << endl;
    }
};

int main() {
    cout << "=== Policy-based sorters ===" << endl;

    Sorter<BubbleSort>   bubbleSorter;
    Sorter<StdSort>      stdSorter;
    Sorter<InsertionSort> insertSorter;

    vector<int> v1 = {5, 3, 8, 1, 9, 2};
    vector<int> v2 = v1, v3 = v1;

    bubbleSorter.sort(v1); for (int x : v1) cout << x << " "; cout << endl;
    stdSorter.sort(v2);    for (int x : v2) cout << x << " "; cout << endl;
    insertSorter.sort(v3); for (int x : v3) cout << x << " "; cout << endl;

    cout << "\n=== Data processor with policies ===" << endl;

    // Verbose processor: BubbleSort + ConsoleLogger
    DataProcessor<BubbleSort, ConsoleLogger> verboseProc;
    verboseProc.addData({7, 2, 5, 1, 8});
    verboseProc.process();
    verboseProc.print();

    cout << endl;

    // Silent processor: StdSort + SilentLogger (no log output)
    DataProcessor<StdSort, SilentLogger> silentProc;
    silentProc.addData({7, 2, 5, 1, 8});
    silentProc.process();
    silentProc.print();

    return 0;
}

Output:

Plaintext
=== Policy-based sorters ===
Using BubbleSort
1 2 3 5 8 9 
Using StdSort
1 2 3 5 8 9 
Using InsertionSort
1 2 3 5 8 9 

=== Data processor with policies ===
[LOG] Added 5 items
[LOG] Sorting 5 items
Using BubbleSort
[LOG] Sort complete
1 2 5 7 8 

1 2 5 7 8 

Step-by-step explanation:

  1. SortPolicy::sort(begin, end) calls the sort algorithm specified by the template parameter. For Sorter<BubbleSort>, this is bubble sort. For Sorter<StdSort>, this is std::sort. There is no virtual dispatch — the compiler knows exactly which function to call at compile time.
  2. LogPolicy::log(msg) calls the log function. For SilentLogger, this is a no-op function that the compiler will completely optimize away — no string construction, no output call, truly zero overhead logging in release builds.
  3. DataProcessor<StdSort, SilentLogger> produces a class where the logging code is entirely absent from the generated machine code. Contrast with runtime polymorphism (virtual functions), where even a no-op virtual call has function pointer overhead.
  4. This pattern is used extensively in the C++ standard library itself: allocators as template parameters, comparators as template parameters, and hash functions as template parameters are all policy-based designs.

Compile-Time Lookup Tables

A compelling practical application of TMP is generating lookup tables entirely at compile time, embedding them directly in the binary as static data.

C++
#include <iostream>
#include <array>
using namespace std;

// Generate a compile-time sine table (approximated with integer arithmetic)
// For a real implementation you'd use constexpr math functions
constexpr double ct_pi = 3.14159265358979323846;

constexpr double ct_sin(double x) {
    // Taylor series: sin(x) = x - x^3/6 + x^5/120 - x^7/5040 + ...
    double result = x;
    double term   = x;
    double xSq    = x * x;
    int    sign   = -1;
    for (int i = 1; i <= 10; ++i) {
        term   *= xSq / ((2*i) * (2*i + 1));
        result += sign * term;
        sign   = -sign;
    }
    return result;
}

// Generate a compile-time lookup table of N sine values
template<size_t N>
constexpr array<double, N> makeSineTable() {
    array<double, N> table{};
    for (size_t i = 0; i < N; ++i) {
        double angle = (2.0 * ct_pi * i) / N;
        table[i] = ct_sin(angle);
    }
    return table;
}

// Compile-time CRC8 table for fast CRC computation
constexpr array<uint8_t, 256> makeCRC8Table() {
    array<uint8_t, 256> table{};
    for (int i = 0; i < 256; ++i) {
        uint8_t crc = static_cast<uint8_t>(i);
        for (int j = 0; j < 8; ++j) {
            crc = (crc & 0x80) ? ((crc << 1) ^ 0x07) : (crc << 1);
        }
        table[i] = crc;
    }
    return table;
}

// Use the CRC8 table for fast checksum computation
uint8_t computeCRC8(const uint8_t* data, size_t len) {
    static constexpr auto table = makeCRC8Table();  // Generated at compile time
    uint8_t crc = 0;
    for (size_t i = 0; i < len; ++i) {
        crc = table[crc ^ data[i]];
    }
    return crc;
}

int main() {
    // Sine table: 8 values, computed at compile time
    constexpr auto sineTable = makeSineTable<8>();
    cout << "Compile-time sine table (8 points):" << endl;
    for (size_t i = 0; i < 8; ++i) {
        cout << "  sin(2π * " << i << "/8) = " << sineTable[i] << endl;
    }

    // CRC8 table: 256 values, computed at compile time
    const uint8_t message[] = {0x31, 0x32, 0x33, 0x34, 0x35};
    uint8_t crc = computeCRC8(message, sizeof(message));
    cout << "\nCRC8 of message: 0x" << hex << (int)crc << endl;

    return 0;
}

Output:

Plaintext
Compile-time sine table (8 points):
  sin(2π * 0/8) = 0
  sin(2π * 1/8) = 0.707107
  sin(2π * 2/8) = 1
  sin(2π * 3/8) = 0.707107
  sin(2π * 4/8) = -2.44929e-16
  sin(2π * 5/8) = -0.707107
  sin(2π * 6/8) = -1
  sin(2π * 7/8) = -0.707107

CRC8 of message: 0x31

Step-by-step explanation:

  1. makeSineTable<8>() is a constexpr function that computes 8 sine values at compile time using a Taylor series approximation. The resulting array<double, 8> is a compile-time constant embedded directly in the binary’s read-only data segment.
  2. makeCRC8Table() generates all 256 entries of a CRC8 lookup table at compile time. This table is then used in computeCRC8 for fast runtime CRC computation — each byte processed with a single array lookup instead of a polynomial division.
  3. static constexpr auto table = makeCRC8Table() inside computeCRC8 ensures the table is computed once at compile time and stored as static data. Each call to computeCRC8 uses the pre-computed table — zero recomputation overhead.
  4. Compile-time table generation is a major pattern in embedded and performance-critical software: sin/cos tables for audio DSP, CRC tables for data integrity, hash tables for string matching, and more.

TMP and Modern C++: The Evolution

The history of TMP reflects the evolution of C++ toward making compile-time programming more readable and less arcane:

EraTechniqueExample
C++98/03Recursive template structs with specializationsFactorial<N> with Factorial<0> specialization
C++11constexpr functions, type_traits, static_assertconstexpr int f(int n) + static_assert
C++11Variadic templates, template aliases (using)template<typename... Ts>
C++14constexpr relaxed (loops, local vars), _t and _v aliasesif in constexpr, is_same_v<T,U>
C++17if constexpr, fold expressions, constexpr ifif constexpr (is_integral_v<T>)
C++20consteval, constinit, Concepts, constexpr containersconcept Sortable = ...

The trend is clear: TMP power has not decreased, but the syntax required has become dramatically more readable. What once required three template specializations and a typedef can now be written as a readable if constexpr branch.

Practical Guidelines: When to Use TMP

TMP is powerful, but it comes with real costs in compilation time, error message readability, and code maintainability. Use it purposefully.

Use TMP when:

  • You need compile-time constants for array sizes, template arguments, or static assertions.
  • You want type-level polymorphism without virtual dispatch overhead (policy-based design).
  • You are writing library code that must work correctly and efficiently with any type that meets certain requirements.
  • You are generating lookup tables, bit patterns, or other data that is fully determined at compile time.
  • You want to enforce constraints on template parameters (type traits + static_assert or Concepts).

Prefer runtime solutions when:

  • The value is not known until runtime.
  • Code readability and maintainability are more important than compile-time overhead.
  • Compilation time is already a bottleneck (heavy TMP significantly increases compile times).
  • The performance gain is not measurable or meaningful for your use case.

Modern best practices: Use constexpr functions instead of recursive template structs whenever possible — they are far more readable and debuggable. Use if constexpr instead of SFINAE for type dispatch. Use Concepts (C++20) instead of enable_if for constraining templates. Reserve complex recursive template metaprogramming for cases where constexpr genuinely cannot express the required type-level computation.

Conclusion

Template Metaprogramming transforms C++ templates from a simple code-reuse mechanism into a complete compile-time computation system. By performing computations during compilation rather than at runtime, TMP produces zero-overhead abstractions: compile-time constants, type-selected algorithms, generated lookup tables, and policy-based designs that adapt to their template arguments without any runtime cost.

The foundations are straightforward: template structs as compile-time functions, template specializations as base cases, recursive instantiation as iteration. These patterns compute factorials, Fibonacci numbers, type transformations, and arbitrary values before your program ever runs.

Modern C++ has made TMP dramatically more approachable. constexpr functions replace recursive template structs for value computation. if constexpr replaces SFINAE for conditional compilation. Type traits provide ready-made compile-time predicates. And C++20 Concepts provide a clean, readable syntax for constraining template parameters.

Mastering TMP means mastering the full power of C++ as a language — not just its runtime behavior, but its compile-time behavior as well. The ability to move computation from runtime to compile time, to select algorithms based on types, and to enforce correctness through the type system rather than through runtime checks is what makes C++ uniquely powerful among mainstream programming languages.

Share:
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments

Discover More

Understanding System Updates: Why They Matter and How They Work

Learn why operating system updates are crucial for security, performance, and features. Discover how updates…

Arduino Boards: Uno, Mega, Nano, and More

Learn about different Arduino boards, including Uno, Mega, Nano, and more. Discover their features, use…

Introduction to Robotics: A Beginner’s Guide

Learn the basics of robotics, its applications across industries, and how to get started with…

What Is a System Call and How Do Programs Talk to the Operating System?

Learn what system calls are and how programs interact with the operating system. Understand the…

Getting Started with Robotics Programming: An Introduction

Learn the basics of robotics programming, from selecting languages to integrating AI and autonomous systems…

Intel Debuts Revolutionary Core Ultra Series 3 Processors at CES 2026 with 18A Manufacturing Breakthrough

Intel launches Core Ultra Series 3 processors at CES 2026 with groundbreaking 18A technology, delivering…

Click For More
0
Would love your thoughts, please comment.x
()
x