In C++, template parameters must be known at compile-time. If you try to pass a runtime variable (a variable that is not constexpr) as a template argument, the compiler will throw a frustrating error:

error: non-type template argument is not a constant expression

But what if your application determines data types dynamically at runtime—for example, when parsing a file format, handling user input, or dealing with void* buffers? How do you bridge the gap between runtime variables and compile-time templates?

The solution is a technique called Runtime Dispatching. In this article, we'll look at why this error happens and how to cleanly resolve it using modular dispatch helpers.

The Root of the Problem

C++ templates are not functions that exist at runtime; they are blueprints. The compiler must generate (instantiate) concrete machine code for every specific combination of template arguments during compilation. Because of this, the compiler must know the exact types at compile-time.

If your variables tA, tB, and tC are determined at runtime, you cannot pass them directly into a template like this:

some_template_function<DataType<tA>, DataType<tB>, DataType<tD>>(); // Error!

The Solution: Runtime Dispatching

To solve this, you need to write runtime decision-making code (like switch or if-else blocks) that maps your runtime enum values to their corresponding compile-time types, and then invokes the template function within those branches.

If you have multiple dynamic types, nesting switch statements directly can quickly lead to messy, unreadable code (the "combinatorial explosion" problem). A clean, modular way to handle this is by chaining dispatch helper functions.

Step-by-Step Implementation

Here is how you can refactor your code to support dynamic, runtime-selected types using layered dispatching:

#include <iostream>
#include <complex>

template<typename TA, typename TB, typename TC>
void some_template_function() {
    std::cout << "Successfully instantiated template!" << std::endl;
    // Your type-specific logic here...
}

typedef int MY_Datatype;
enum {
    MY_F32 = 0,
    MY_F64 = 1,
    MY_C32 = 2,
    MY_C64 = 3
};

// Layer 3: Dispatch the third template parameter (TC)
template<typename TA, typename TB>
void dispatch_TC(MY_Datatype tC) {
    switch (tC) {
        case MY_F32: some_template_function<TA, TB, float>(); break;
        case MY_F64: some_template_function<TA, TB, double>(); break;
        case MY_C32: some_template_function<TA, TB, std::complex<float>>(); break;
        case MY_C64: some_template_function<TA, TB, std::complex<double>>(); break;
        default: std::cerr << "Unknown type for tC" << std::endl;
    }
}

// Layer 2: Dispatch the second template parameter (TB)
template<typename TA>
void dispatch_TB(MY_Datatype tB, MY_Datatype tC) {
    switch (tB) {
        case MY_F32: dispatch_TC<TA, float>(tC); break;
        case MY_F64: dispatch_TC<TA, double>(tC); break;
        case MY_C32: dispatch_TC<TA, std::complex<float>>(tC); break;
        case MY_C64: dispatch_TC<TA, std::complex<double>>(tC); break;
        default: std::cerr << "Unknown type for tB" << std::endl;
    }
}

// Layer 1: Dispatch the first template parameter (TA)
void dispatch_template(MY_Datatype tA, MY_Datatype tB, MY_Datatype tC) {
    switch (tA) {
        case MY_F32: dispatch_TB<float>(tB, tC); break;
        case MY_F64: dispatch_TB<double>(tB, tC); break;
        case MY_C32: dispatch_TB<std::complex<float>>(tB, tC); break;
        case MY_C64: dispatch_TB<std::complex<double>>(tB, tC); break;
        default: std::cerr << "Unknown type for tA" << std::endl;
    }
}

int main() {
    // These are now standard runtime variables (non-constexpr)
    MY_Datatype tA = MY_F64;
    MY_Datatype tB = MY_C32;
    MY_Datatype tD = MY_F32;

    // Safely dispatch to the template function at runtime
    dispatch_template(tA, tB, tD);

    return 0;
}

Why This Approach Works

  • Type Safety: The compiler still checks and generates all possible valid combinations of some_template_function at compile-time.
  • Clean Code: By nesting the dispatch functions (dispatch_templatedispatch_TBdispatch_TC), we avoid a giant, unreadable nested block inside main.
  • Zero Runtime Overhead: The switch statements execute extremely fast at runtime, immediately executing the correct statically-compiled function path.

Alternative: Modern C++ (std::variant and std::visit)

If you are using C++17 or newer, you can also leverage std::variant and std::visit to handle runtime-to-compile-time dispatching. While it requires setting up a variant wrapper, it can significantly reduce boilerplate code for complex application architectures.