0. Introduction & Environment Setup

Motivation: why care about metaprogramming?

Metaprogramming allows to reuse and execute code at compile time by the use of templates and libraries such as boost. The goal is to make the code faster and cleaner.

Also keep in mind its use in industry, with serialization libraries like protobuf, optimized numerical libs like Eigen.

Development environment: C++ compiler (e.g., g++/clang++), standard version (C++17/20), optional libraries (Boost.MPL, Boost.Hana)

We suppose using a nixOs distro to manage cache libraries:

1
nix-shell -p libgcc g++ boost

and we compile with:

1
g++ -std=c++20 -Wall -O2 <files.h> main.c -L lboost

Context and goals of the article

Epita gives us a first idea of what c++ is all about, and we can see a bit more with the c++ elective but a lot is to experiment with it. The goal of this article is to make you consider using c++ more often with thoses programming strategies, and show all you need to know to masteries Template logic.

1. Getting Started with C++ Templates

Let’s start with some old good syntax

template <parameter-list > requires-clause (optional) declaration

we got the ’template’ keyword, followed by a non-empty comma-separated list, the params can be a constant paramater, a type-parameter or a template parameter.

The requires-clause is optional and enable us to put constraints on the template arguments and follow by a declaration.

We clearly understand templates are useful for non-constant types in objects as classes.

But the point here is to try to abuse the fact that a template is computed at compile time, not runtime.

Introduction to templates: function and class templates

For a introduction, let’s start with a demo:

1
2
3
4
5
6
7
8
9
\\demo.hh

template<class T>
class edge {
private:
    int start_node_;
    int end_node_;
    T weight_;
};

The main use of templates is variadic parameters, in this code we see a class edge with a variadic weight, it can be a int, string or whatever.

So let’s go with a basic example. Here, instead of writing a function add_int, add_double, and so on, we can do:

1
2
3
4
5
6
\\add.hh

template <typename T>
T add(T a, T b) {
    return a + b;
}

And now we can use it with int, float, even std::string:

1
2
3
4
5
6
7
8
#include "add.hh"
#include <iostream>

int main() {
    std::cout << add(2, 3) << std::endl;           // int
    std::cout << add(2.1, 3.4) << std::endl;       // float
    std::cout << add(std::string("a"), "b") << std::endl; // string
}

Here is a quick summary:

1
2
3
template <typename T> // type parameter
template <int N>      // non-type parameter
template <template<typename> class C> // template template param

Note putting multiple types at once is also possible:

1
2
3
4
template <typename... Args>
void print_all(Args... args) {
    (std::cout << ... << args); // fold expression (C++17)
}

All these methods push us to think more deeply about the problem and get creative when solving any kind of issue in C++.

How template instantiation works at compile-time

Let’s dive down a bit about how the instantiation works. First quick reminder: a template is just a blueprint, the compiler doesn’t generate code for it until it’s used.

When you write something like:

1
edge<int> myEdge;

The compiler reads your template edge<T> and creates a version of the class specifically for int. This is the instantiation. If you later write:

1
edge<std::string> anotherEdge;

Now it generates a second version of the class for std::string.

You can think of it as macro expansion, but type-aware and checked by the compiler. Also, if you write some code inside the template that only works for integers, but then try to instantiate with std::string, the compiler will fail only when you instantiate, not before.

1
2
3
4
template<typename T>
T my_abs(T value) {
    return value < 0 ? -value : value; // fails if T doesn’t support <
}

This will only produce an error if T is used with a type that doesn’t have <.

So template instantiation is lazy, and contextual:

  • It’s only compiled when you use it.
  • It’s compiled differently for each type.
  • If unused, the code isn’t checked (good and bad at once!).

Simple examples: type traits, compile-time constant functions

Now that we get how templates work, let’s check some small examples we can actually use.

Type traits are like tools to check or modify types at compile time. It’s all in <type_traits>, and really useful.

Quick example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <type_traits>

template<typename T>
void print_type_info() {
    if constexpr (std::is_integral<T>::value) {
        std::cout << "Type is integral\n";
    } else {
        std::cout << "Type is NOT integral\n";
    }
}

This gets evaluated at compile time, so the wrong branch is just removed by the compiler. Super clean and fast.

We also have compile-time functions with constexpr. These are just normal functions but evaluated at compile time (if possible).

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int arr[factorial(5)]; // creates an array of size 120

So, here we compute the factorial at compile time and use the result directly. Saves runtime cost and shows the power of templates mixed with constexpr.

2. From Templates to Metaprogramming

What is metaprogramming in C++?

First i want to quote David A. who wrote :

[…] a metaprogram is a program that manipulates code.[…].Your C++ compiler is one example: it manipulates your C++ code to produce assembly langague or machine code.

Note that in C++, metaprograming was discover almost by accident. At first, C++ templates were just meant for making generic containers like std::vector. But developers, being developers, started abusing the system—using recursion and type tricks—until the compiler accidentally became a Turing machine. And that’s how metaprogramming was born… more by hack than by design!

We can say that the metaprograming in C++ is the abuse of template and more to manipulate code in compile-time.

We do this for:

  • speed
  • abstraction
  • because we can ;p

Static polymorphism and the role of templates

In C++ you have dynamic polymorphism with virtuals:

1
2
3
struct Animal {
    virtual void speak() = 0;
};

But templates allow us to do something like:

1
2
3
4
template <typename T>
void speak(T animal) {
    animal.speak(); // no vtable, resolved at compile time
}

Now speak() is just a function that works on any object that has a .speak() method. This is sometimes called duck typing at compile time.

One point is, no vtable is generated for the method .speak(), so no virtual keyword is needed, the function is considered inline and no runtime dispatch happens.

But there’s a catch:

With static polymorphism, the type must be known at compile time, so you lose runtime flexibility. Also, code size can grow if you instantiate the function for many different types.

Tools: constexpr, if constexpr, type traits like std::enable_if, std::conditional

  • constexpr: Marks functions or variables as evaluable at compile time.
1
2
3
constexpr int square(int x) {
    return x * x;
}
  • if constexpr: Compile-time branching.
1
2
3
4
5
6
7
8
9
template<typename T>
void info() {
    if constexpr (std::is_integral<T>::value)
        std::cout << "Integral
";
    else
        std::cout << "Not integral
";
}
  • std::enable_if: Enable/disable functions based on compile-time conditions.
1
2
3
4
5
6
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value>::type
only_for_floats(T value) {
    std::cout << "Float value: " << value << "
";
}
  • std::conditional: Compile-time type selection.
1
2
template <bool B, typename T, typename F>
using conditional_t = typename std::conditional<B, T, F>::type;

Examples: compile-time factorial, filtering types

Let’s take a simple example: computing a factorial at compile time.

1
2
3
4
5
6
7
constexpr int factorial(int n) {
    return (n <= 1) ? 1 : n * factorial(n - 1);
}

int main() {
    int x = factorial(5); // computed at compile time
}

Now with if constexpr (C++17), we can branch at compile time:

1
2
3
4
5
6
7
8
template <typename T>
void print_type_info(T t) {
    if constexpr (std::is_integral<T>::value) {
        std::cout << "integral\n";
    } else {
        std::cout << "something else\n";
    }
}

No instantiation of the unused branch,the compiler skips it completely.

Let’s say we want to enable a function only for floating points:

1
2
3
4
5
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value>::type
only_for_floats(T value) {
    std::cout << "Float value: " << value << "\n";
}

Or in C++20 with concepts (cleaner):

1
2
3
4
template <std::floating_point T>
void only_for_floats(T value) {
    std::cout << "Float value: " << value << "\n";
}

You can do a lot with these techniques, build lists of types, switch implementations, pick algorithms… All before your program even runs.

3. Crossing the Compile-Time / Runtime Boundary

Why mix compile-time and runtime logic?

We can ensure that not everything is known at compile time, we need flexibility. However we still want to push as much logic as possible at compiletime to optimize runtime. In thoses cases, combine both of these two worlds to have flexibility and speed at the same time.

C++ metaprogramming allows us to encode logic that is normally runtime-checked… At compile time. That gives us extra safety and performance. Here we use types to encode units and dimensions (like mass, length, time) directly into the type system.

That means: you can’t add a mass to a length, the compiler will stop you.

Examples of compile-time decision-making affecting runtime

To demonstrate this point here a exemple of the dimensional analysis applied in c++ using templates and boost.

Let’s take Boost.MPL’s vector_c to describe the physical units. It’s a compile-time container of integers.

1
2
3
4
5
6
7
8
#include <boost/mpl/vector_c.hpp>
namespace mpl = boost::mpl;

typedef mpl::vector_c<int,1,0,0,0,0,0,0> mass;
typedef mpl::vector_c<int,0,1,0,0,0,0,0> length;
typedef mpl::vector_c<int,0,0,1,0,0,0,0> time;
typedef mpl::vector_c<int,0,1,-1,0,0,0,0> velocity;
typedef mpl::vector_c<int,0,0,0,0,0,0,0> scalar;

Here, each vector_c is a type that represents powers of each base unit. This is 100% compile-time info, there’s no data here.

Now we use it with a runtime value, by attaching it to a quantity<T, Dimensions> wrapper:

1
2
3
4
5
6
7
template <class T, class Dimensions>
struct quantity {
    explicit quantity(T x) : m_value(x) {}
    T value() const { return m_value; }
private:
    T m_value;
};

You can do:

1
2
quantity<float, length> l(1.0f);
quantity<float, mass> m(2.0f);

But you can’t assign one to the other:

1
m = l; // compile time error!

That’s what we call static type checking of domain rules, and it’s done before the program even runs.

Using SFINAE or concepts to enable/disable code paths

SFINAE (Substitution Failure Is Not An Error) allows us to enable or disable functions or templates based on type information, at compile time.

You could extend this to define arithmetic only between same-dimension types:

1
2
3
4
template <class T, class D>
quantity<T, D> operator+(quantity<T, D> x, quantity<T, D> y) {
    return quantity<T, D>(x.value() + y.value());
}

No std::enable_if or concepts needed here, the type system itself does the check.

But you can imagine more complex behaviors (e.g. multiplying dimensions), where you’d use SFINAE to restrict combinations, or Boost.MPL operations to compute the new dimensions at compile time.

You could also write:

1
2
3
4
5
6
7
template <class Q1, class Q2>
auto multiply(Q1 a, Q2 b) -> quantity<
    decltype(a.value() * b.value()),
    typename mpl::transform<typename Q1::dim, typename Q2::dim, mpl::plus<_,_>>::type
> {
    return quantity<..., ...>(a.value() * b.value());
}

That’s what SFINAE is: if substitution of template parameters fails, it’s not an error, that overload is just skipped.

Basic use of Boost.MPL or Boost.Hana to manipulate types

As in the example seen before Boost.MPL is considered more old-school but still powerful. We can build type lists, do logic at compile-time.

On the other hand, Boost.Hana operates on both values and types, and is considered a more modern approach to metaprogramming, designed with C++14/17 features in mind.

Thoses tools are mainly used to:

  • filter types
  • transform types
  • dispatch based on type lists.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <boost/mpl/int.hpp>
#include <boost/mpl/if.hpp>
#include <iostream>

namespace mpl = boost::mpl;

// A type-level max function
template <typename A, typename B>
struct max_type : mpl::if_c<(A::value > B::value), A, B> {};

int main() {
    typedef mpl::int_<5> five;
    typedef mpl::int_<9> nine;

    typedef max_type<five, nine>::type result;

    std::cout << "Max is: " << result::value << '\n'; // prints 9
}

Here:

mpl::int_<N> wraps an integer into a type with a static value field.

mpl::if_c<cond, A, B> selects one of two types based on a compile-time condition.

max_type computes the greater of two mpl::int_values, all at compile time. result::value is accessible at runtime, but was resolved long before.

This pattern is used heavily in template metaprogramming to manipulate types like numbers, type lists, and traits.

4. Performance and Optimization

Advantages: removing runtime branching, improving specialization

With this kind of methods, The code is lighter at runtime, there is no need for if, switch or else. The logic is specialized per type with no runtime code added.

Drawbacks: code bloat, compile-time overhead, complexity

Of course there is a catch, generated code and templates make larger binaries, and the compile time increases a lot. Also note that you are never really alone on projects and metaprograming can be harder to read thus debug especially for newbies.

Example: comparing compile-time and runtime dispatch

Let’s compare two cases:

Runtime polymorphism with virtual functions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Animal {
    virtual void speak() = 0;
};

struct Dog : Animal {
    void speak() override { std::cout << "Woof
"; }
};

void make_animal_speak(Animal* a) {
    a->speak();
}

Compile-time dispatch using templates and constexpr:

1
2
3
4
template<typename AnimalType>
void make_animal_speak(AnimalType a) {
    a.speak();
}

Performance differences:

  • Virtual functions: Extra runtime cost due to vtable lookup.
  • Templates with constexpr: Fully inlined, no runtime overhead.

Conclusion:

If your use case allows compile-time dispatch, prefer it for performance-critical paths. But for flexibility and when dealing with unknown types at runtime, virtual functions still have their place.

Let’s compare two ways to select behavior based on type:

  • Runtime dispatch via virtual functions (classic OOP).
  • Compile-time dispatch via templates and constexpr (metaprogramming).

Virtual Call (Runtime Dispatch)

1
2
3
4
5
6
7
8
9
struct Shape {
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

struct Circle : Shape {
    double radius;
    double area() const override { return 3.14 * radius * radius; }
};

Dispatch happens at runtime. Introduces indirection thus implying slower execution (vtable lookup). Allows polymorphism with different types at runtime.

Template Dispatch (Compile-Time)

1
2
3
4
5
6
template<typename Shape>
double area(const Shape& s) {
    if constexpr (std::is_same_v<Shape, Circle>)
        return 3.14 * s.radius * s.radius;
    // Other shapes...
}

Resolved at compile time means the compiler selects the code path before execution, removing any runtime decision. No vtable implies no indirection, the call gets fully inlined and optimized. Downside: the type must be fully known during compilation, no late binding possible.

Benchmark Overview

Using a simple benchmark,


Dispatch Type Time (ms) Notes
Virtual Functions 130 Cache misses, vtable overhead
Templates 80 Inlined, branchless

5. Practical Applications

Real-world use cases in libraries (e.g., Boost, Eigen)

Boost:

  • Uses templates everywhere: type_traits, mpl::if_, hana, etc…
  • Heavy use of SFINAE and compile-time computation.
  • Example: boost::variant dispatch logic is pure metaprogramming.

Eigen:

  • Expression templates to fuse matrix operations -> no temporary objects.
  • Operations like C = A + B * D are optimized at compile-time.

STL (Standard Template Library):

  • std::enable_if, std::is_same, iterator_traits are the template metaprogramming core tools.
  • Used internally to make containers and algorithms generic and safe.

When to use or avoid metaprogramming in production code

When to use metaprogramming

  • When performance is critical (e.g., embedded systems, game engines, math libraries)
  • When logic needs to be generic and type-safe
  • When you want strong compile-time guarantees
  • When optimizing runtime efficiency (e.g., no virtual dispatch, inlining)

When to avoid metaprogramming

  • When it makes simple code unreadable (template hell)
  • When the team lacks experience with advanced C++ templates
  • When compile-time complexity outweighs runtime benefits
  • When rapid development and readability are more important than raw performance

Rule of thumb: If it’s not faster or safer, don’t make it a template.

Tips for keeping template code maintainable

Prefer constexpr, consteval, and concepts (C++20) over old-school SFINAE.

Split logic into small, composable metafunctions.

Use static_assert,with meaningful messages ; static_assert let’s you debug templates and print clear compile-time error messages when constraints aren’t met. It’s a classic assert but design for template.

Always document what your template expects (T must be a type with .size() etc).

Avoid deeply nested typename hell.

6. Conclusion

Recap of key takeaways

Metaprogramming moves logic to compile time, improving performance and safety. Templates enable abstraction, generic programming, and static checks. Use modern tools: constexpr, concepts, type_traits, Boost.

When metaprogramming is most useful

When is metaprogramming most useful?

  • In performance-critical code paths, such as rendering engines or digital signal processing, where every cycle counts.
  • Within libraries designed for heavy reuse, including math utilities, container implementations, and serialization frameworks.
  • In scenarios where eliminating runtime overhead is essential to meet strict performance or resource constraints.

Final thoughts on clarity, performance, and abstraction

Metaprogramming is like magic: powerful but dangerous. Clarity > cleverness. Abstractions must help both the compiler and the human reader. Good metaprogramming should feel invisible — just fast, safe code.

Bibliography

  • C++ Template Metaprogramming David Abrahams & Aleksey Gurtovoy PDF

  • The C++ Programming Language (4th Edition) Bjarne Stroustrup PDF

  • LSST DM Developer Guide – C++ API Design Rubin Observatory Data Management Online Guide