Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 35

Compile-Time Branching with `if constexpr`

Recommended resources

Hello! Welcome back to our exploration of C++'s powerful features.

In our last lesson, we learned how to use template specialization to provide entirely separate, optimized implementations for specific types or categories of types. This is a powerful way to override the default generic behavior, but it can sometimes lead to maintaining multiple, disconnected template definitions.

Today, we'll explore a more modern and often cleaner alternative. The goal of this lesson is to learn how to use if constexpr to perform compile-time branching within a single template. This C++17 feature allows the compiler to discard irrelevant code branches before they are even fully compiled, giving us the benefits of specialization without the boilerplate. This is a key tool for writing zero-overhead abstractions, a concept central to systems programming.

The Problem: Why a Regular if Fails in Templates

Imagine you want to write a single generic function, printValue, that behaves differently for integers and strings. For an int, it should print the value doubled. For a std::string, it should print the value in uppercase.

Using your knowledge of templates and type traits from previous lessons, you might first try something like this:

#include <iostream>
#include <string>
#include <algorithm>
#include <type_traits>

template<typename T>
void printValue(T value) {
    if (std::is_same_v<T, int>) {
        std::cout << "Integer: " << value * 2 << std::endl;
    } else if (std::is_same_v<T, std::string>) {
        std::string upper = value;
        // This line is problematic for non-string types
        std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper); 
        std::cout << "String: " << upper << std::endl;
    }
}

// int main() { printValue(42); } // This will cause a compile error!

This code seems logical, but it won't compile. When the compiler instantiates printValue<int>, it must ensure that all code paths within the function are syntactically valid for T = int. Even though the else if branch will never be executed at runtime, the compiler still tries to compile the std::transform line with an int, which is a type error.

This is a fundamental aspect of template instantiation that can be frustrating. Before C++17, the common solutions were complex techniques like SFINAE or tag dispatching.


The Solution: if constexpr

C++17 introduced if constexpr to solve this exact problem. It looks like a regular if, but it's a signal to the compiler that the condition is a compile-time constant. The compiler evaluates the condition and then discards the branch that isn't taken.

What does "discarded" mean? It means that code is never instantiated and doesn't even go through full semantic analysis. It's as if you had surrounded it with #if 0.

Let's look at the elegant solution using if constexpr.

if constexpr: Compile-Time Branching in C++17

This article provides a fantastic, detailed explanation of if constexpr. It starts by clearly illustrating the problem with regular if in templates and then demonstrates the clean solution with if constexpr.

Read through 'Approach 2: Templates with Regular if (Fails!)' to solidify your understanding of the problem. Then, read 'Approach 4: The Elegant Solution — if constexpr'. Pay close attention to the explanation of why the if constexpr version compiles: the discarded branch is 'never checked for validity'.

As you saw, the code becomes clean and intuitive:

template<typename T>
void printValue(T value) {
    if constexpr (std::is_same_v<T, int>) {
        std::cout << "Integer: " << value * 2 << std::endl;
    } else if constexpr (std::is_same_v<T, std::string>) {
        std::string upper = value;
        std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper);
        std::cout << "String: " << upper << std::endl;
    } else {
        std::cout << "Unsupported type" << std::endl;
    }
}

When you call printValue(42), the compiler generates a version of the function where only the int branch exists. All other branches are completely removed from the compiled code.


Under the Hood: The Impact on Generated Code

Your interest lies in how things work internally, so let's examine the output. With if constexpr, the branching logic is resolved entirely at compile time. The final machine code contains no if/else checks at all.

Phases of a Compiler Flowchart
A high-level view of the compilation process. With `if constexpr`, the un-chosen branch is discarded before later stages like Semantic Analysis and Code Generation, which is why invalid code in that branch doesn't cause an error.

Let's see this in action.

C++ Weekly - Ep 18 C++17's constexpr if

This short video by Jason Turner demonstrates the core concept and shows that only the chosen branch is compiled into the final executable.

Watch the segments from 0:21-2:17 and 5:57-6:37. In the first part, observe the basic syntax. In the second part, focus on his explanation of what the compiler effectively generates: a function containing only the code from the selected branch. This is the 'zero-overhead' principle in action.

The key takeaway is that if constexpr isn't just a runtime if with a constant condition. It fundamentally changes what code is generated. This is why it's a tool for compile-time polymorphism—you are creating different versions of a function for different types before the program even runs.


The Most Important Detail: Templates and Dependent Names

You might now be wondering: if if constexpr just needs a constant expression, can I use it anywhere? Let's look at a common point of confusion.

Consider this code from a Stack Overflow question:

// In a template, this works:
template <typename T>
void print(T value) {
    if constexpr (std::is_pointer_v<T>)
        std::cout << *value << std::endl; // OK, *value not compiled for non-pointers
    else
        std::cout << value << std::endl;
}

// In a non-template function, this fails:
void does_not_work() {
    auto value = 100;
    // The type of 'value' is known here, it's 'int'.
    // The condition `std::is_pointer_v<decltype(value)>` is `false`.
    // So why does this fail to compile?
    if constexpr (std::is_pointer_v<decltype(value)>)
        std::cout << *value << std::endl; // COMPILE ERROR: cannot dereference an 'int'
    else
        std::cout << value << std::endl;
}

This behavior seems inconsistent, but it reveals the deep connection between if constexpr and the C++ compilation model, specifically two-phase name lookup for templates. This is exactly the kind of "under the hood" detail you appreciate.

"If constexpr" in C++17 does not work in a non-templated function

This Stack Overflow thread provides one of the best explanations available for this behavior. It's a deep dive into compiler mechanics.

Read the answer by Guillaume Racicot. This is dense but gets to the heart of the matter. Focus on these key ideas: Two-Phase Lookup: The compiler checks templates in two passes. Phase 1 (before instantiation): It checks 'non-dependent' names (code that doesn't depend on a template parameter T). An error here fails compilation immediately. Phase 2 (during instantiation): It checks 'dependent' names (code that does depend on T, like *value where value's type is T). The Punchline: if constexpr controls instantiation. A discarded branch is not instantiated, so Phase 2 lookup never happens for it. But in the non-template example, there's only one phase; *value (on an int) is checked and fails immediately.

To summarize that excellent explanation:

  • In the non-template does_not_work function, *value is an error because the compiler parses the whole function at once. It sees you trying to dereference an int and immediately flags it as an error, even though it's in a constexpr branch that would be false.
  • In the print<T> template, *value is a dependent expression—its validity depends on T. The compiler defers checking it until Phase 2 (instantiation). Because if constexpr prevents instantiation of the discarded branch, this check never happens for non-pointer types, and thus no error occurs.

This is why if constexpr is most powerful when used inside templates with conditions that depend on the template parameters.

Test your understanding!

You want to write a generic function that processes a container.

  • If the container is a std::vector, you want to call vec.capacity().
  • If the container is a std::list, this call would be a compile error because std::list has no capacity() member.

How does if constexpr allow you to write a single function that handles both cases correctly? Relate your answer to the concept of dependent expressions and instantiation.

Show answer

By using if constexpr inside a template function template<typename T> void process(T& container), the expression container.capacity() becomes a dependent expression.

When you call process(my_list), the compiler evaluates if constexpr (std::is_same_v<T, std::vector<...>>), which is false. This causes the entire branch containing container.capacity() to be discarded.

Because the branch is never instantiated, the compiler never performs the second phase of name lookup on the dependent expression container.capacity(). It never tries to find a .capacity() member on a std::list, thus avoiding a compile error. The code behaves as if that branch never existed in the source code for that specific instantiation.


Conclusion

You've now added a critical C++17 tool to your arsenal. if constexpr allows for clean, readable, compile-time branching that generates highly efficient code.

Key Takeaways:

  • if constexpr evaluates a constant expression at compile time and discards the branch that is not taken.
  • A discarded branch is not fully compiled, which prevents errors from code that would be invalid for a given type instantiation. This makes it a clean alternative to SFINAE and tag dispatch.
  • The generated machine code has zero runtime overhead; the branching logic is fully resolved by the compiler.
  • Its true power is unlocked inside templates, where it interacts with C++'s two-phase name lookup. It prevents the instantiation of discarded branches, thereby skipping the analysis of dependent expressions that would otherwise cause compilation to fail.

What's Next?

We have now explored two major techniques for compile-time polymorphism: template specialization (creating separate definitions) and if constexpr (branching within one definition).

In the next lesson, we will step back and compare the fundamental differences, use cases, and trade-offs between compile-time polymorphism (templates) and the other major type of polymorphism in C++: runtime polymorphism (virtual functions). Understanding when to use each is a hallmark of a proficient C++ developer.