Cpp Notes

ch10.compile_time_if

C++17 - The complete guide, Ch 10: Compile-time If

  • With the syntax if constexpr(...), the compiler uses a compile-time expression to decide at compile time whether to use the then part or the else part (if any) of an if statement.
  • The other part (if any) is discarded, meaning that no code is generated.
  • This does not mean that the discarded part is completely ignored though. It will be checked just like the code of unused templates.
#include <string>
template <typename T>
std::string asString(T x) {
  if constexpr (std::is_same_v<T, std::string>) {
    return x;  // statement invalid if no conversion to string
  } else if constexpr (std::is_arithmetic_v<T>) {
    return std::to_string(x);  // statement invalid if x is not numeric
  } else {
    return std::string(x);  // statement invalid if no conversion to string
  }
}
  • By using if constexpr we decide at compile time whether we just return a passed string, call to_string() for a passed integral or floating-point value, or use a constructor to convert the passed argument to type std::string.
  • The invalid calls are discarded, therefore the following code compiles (which would not be the case when using a regular runtime if):
#include <iostream>

#include "ifcomptime.hpp"
int main() {
  std::cout << asString(42) << '\n';
  std::cout << asString(std::string("hello")) << '\n';
  std::cout << asString("hello") << '\n';
}

10.1 Motivation for Compile-Time if

#include <string>
template <typename T>
std::string asString(T x) {
if (std::is_same_v<T, std::string>) {
  return x; // compile ERROR if no conversion to string
} else if (std::is_numeric_v<T>) {
  return std::to_string(x); // compile ERROR if x is not numeric
} else {
  return std::string(x); // compile ERROR if no conversion to string
}
}
  • This is a consequence of the rule that function templates are compiled as a whole when being instantiated. The check of the if condition is a runtime feature.

  • Even if at compile-time it becomes clear that a condition must be false, the then part must be able to compile.

  • Therefore, when passing a std::string or string literal, the compilation fails because the call of std::to_string() for the passed argument is not valid.

  • Furthermore, when passing a numeric value, the compilation fails because the first and third return statements would be invalid.

  • Using the compile-time if, the then and else parts that cannot be used become discarded statements:

    • When passing a std::string value, the else part of the first if is discarded.
    • When passing a numeric value, the then part of the first if and the final else part are discarded.
    • When passing a string literal (i.e., type const char*), the then parts of the first and second if are discarded.
  • Therefore, on each instantiation, each invalid combination is discarded at compile-time and the code compiles successfully.

  • Note that a discarded statement is not ignored. Even for discarded statements, the syntax must be correct and calls that do not depend on template parameters must be valid.

  • In fact, the first translation phase (the definition time) is always performed, which checks for correct syntax and the validity of all names that do not depend on template parameters.

  • All static_asserts must also be valid, even in branches that are not compiled. For example:

template <typename T>
void foo(T t) {
  if constexpr (std::is_integral_v<T>) {
    if (t > 0) {
      foo(t - 1);  // OK
    }
  } else {
    undeclared(t);  // error if not declared and not discarded (i.e., T is not
                    // integral)
    undeclared();   // error if not declared (even if discarded)
    static_assert(false, "no integral");  // always asserts (even if discarded)
  }
}
  • With a conforming compiler, this example never compiles for two reasons:

    • Even if T is an integral type, the call of undeclared(); // error if not declared (even if discarded) in the discarded else part is an error if no such function is declared, because this call does not depend on a template parameter.
  • The assertion static_assert(false, "no integral"); // always asserts (even if discarded) always fails even if it is part of the discarded else part, because again it does not depend on a template parameter.

  • A static assertion repeating the compile-time condition would be fine: static_assert(!std::is_integral_v<T>, "no integral");

  • Note that some compilers (e.g., Visual C++ 2013 and 2015) do not implement or perform the two-phase translation of templates correctly. They defer most of the first phase (the definition time) to the second phase (the instantiation time), which means that invalid function calls and even some syntax errors might compile.

  • Note that you cannot use if constexpr outside function bodies. Thus, you cannot use it to replace conditional preprocessor directives.

10.2.1 Caveats for Compile-Time if

  • Even when it is possible to use compile-time if, there might be some consequences that are not obvious. These are discussed in the following subsections.

Compile-Time if Impacts the Return Type

  • Compile-time if might impact the return type of a function. For example, the following code always compiles but the return type might differ:
auto foo() {
  if constexpr (sizeof(int) > 4) {
    return 42;
  } else {
    return 42u;
  }
}
  • Here, because we use auto, the return type of the function depends on the return statements, which depend on the size of int:
  • If the size is greater than 4, there is only one valid return statement returning 42, meaning that the return type is int. Otherwise, there is only one return statement returning 42u, meaning that the return type becomes unsigned int. This way, the return type of a function with if constexpr might differ even more dramatically.
  • For example, if we skip the else part the return type might be int or void:
auto foo()  // return type might be int or void
{
  if constexpr (sizeof(int) > 4) {
    return 42;
  }
}
  • Note that this code never compiles if the runtime if is used here, because then both return statements are taken into account meaning that the deduction of the return type is ambiguous.

else Matters Even if then Returns

  • For runtime if statements there is a pattern that does not apply to compile-time if statements: if code with return statements in both the then and the else part compiles, you can always skip the else in the runtime if statements. That is, instead of
if (...) {
  return a;
} else {
  return b;
}
  • you can always write:
if (...) {
  return a;
}
return b;
  • This pattern does not apply to compile-time if because in the second form, the return type depends on two return statements instead of one, which can make a difference. For example, modifying the example above results in code that might or might not compile:
auto foo() {
  if constexpr (sizeof(int) > 4) {
    return 42;
  }
  return 42u;
}
  • If the condition is true (the size of int is greater than 4), the compiler deduces two different return types, which is not valid. Otherwise, we have only one return statement that matters, meaning that the code compiles.

Short-Circuit Compile-Time Conditions

Consider the following code:

template <typename T>
constexpr auto foo(const T& val) {
  if constexpr (std::is_integral<T>::value) {
    if constexpr (T{} < 10) {
      return val * 2;
    }
  }
  return val;
}
  • Here, we have two compile-time conditions to decide whether to return the passed value as it is or doubled. This compiles for both:
constexpr auto x1 = foo(42); // yields 84
constexpr auto x2 = foo("hi"); // OK, yields ”hi”
  • Conditions in runtime ifs short-circuit (evaluating conditions with && only until the first false and conditions with || only until the first true). This might result in the expectation that this is also the case for compile-time if:
template <typename T>
constexpr auto bar(const T& val) {
  if constexpr (std::is_integral<T>::value && T{} < 10) {
    return val * 2;
  }
  return val;
}
  • However, the condition for the compile-time if is always instantiated and must be valid as a whole, which means that passing a type that does not support <10 no longer compiles:
constexpr auto x2 = bar("hi"); // compile-time ERROR
  • Thus, compile-time if does not short-circuit the instantiations. If the validity of compile-time conditions 0depends on earlier compile-time conditions, you have to nest them as done in foo(). As another example, you have to write:
if constexpr (std::is_same_v<MyType, T>) {
  if constexpr (T::i == 42) {
    ...
  }
}

instead of just:

if constexpr (std::is_same_v<MyType, T> && T::i == 42) {
  ...
}

10.2.2 Other Compile-Time if Examples

Perfect Return of a Generic Value

One application of compile-time if is the perfect forwarding of return values, where they have to be processed before they can be returned. Because decltype(auto) cannot be deduced for void when declaring a variable (as void is an incomplete type), you have to write something like the following:

#include <functional>   // for std::forward()
#include <type_traits>  // for std::is_same<> and std::invoke_result<>
template <typename Callable, typename... Args>
decltype(auto) call(Callable op, Args&&... args) {
  if constexpr (std::is_void_v<std::invoke_result_t<Callable, Args...>>) {
    // return type is void:
    op(std::forward<Args>(args)...);
    ...  // do something before we return
    return;
  } else {
    // return type is not void:
    decltype(auto) ret{op(std::forward<Args>(args)...)};
    ...  // do something (with ret) before we return
    return ret;
  }
}
  • The return type declaration works for void but the declaration of ret does not, so we have to skip the use of ret in that case.

Compile-Time if for Tag Dispatching

  • A typical application of compile-time if is tag dispatching. Before C+17, you had to provide an overload set with a separate function for each type you wanted to handle. Now, with compile-time if, you can put all the logic together in one function.

  • For example, instead of overloading the `std::advance()`` algorithm:

template <typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
  using cat = std::iterator_traits<Iterator>::iterator_category;
  advanceImpl(pos, n, cat{});  // tag dispatch over iterator category
}

template <typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n, std::random_access_iterator_tag) {
  pos += n;
}

template <typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n, std::bidirectional_iterator_tag) {
  if (n >= 0) {
    while (n--) {
      ++pos;
    }
  } else {
    while (n++) {
      --pos;
    }
  }
}
template <typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n, std::input_iterator_tag) {
  while (n--) {
    ++pos;
  }
}
  • we can now implement all behavior in one function:
template <typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
  using cat = std::iterator_traits<Iterator>::iterator_category;
  if constexpr (std::is_convertible_v<cat, std::random_access_iterator_tag>) {
    pos += n;
  } else if constexpr (std::is_convertible_v<
                           cat, std::bidirectional_access_iterator_tag>) {
    if (n >= 0) {
      while (n--) {
        ++pos;
      }
    } else {
      while (n++) {
        --pos;
      }
    }
  } else {  // input_iterator_tag
    while (n--) {
      ++pos;
    }
  }
}
  • Here, to some extent, we have a compile-time switch, where the different cases have to be formulated as if constexpr clauses. However, note one difference that might matter
    • The set of overloaded functions gives you best match semantics.
    • The implementation with compile-time if gives you first match semantics.
  • Another example of tag dispatching is the use of compile-time if for get<>() overloads to implement a structured bindings interface.
    • A third example is the handling of different types in a generic lambda as in std::variant<> visitors.

10.3 Compile-Time if with Initialization

  • Note that the compile-time if can also use the new form of if with initialization. For example, if there is a constexpr function foo(), you can use:
template <typename T>
void bar(const T x) {
  if constexpr (auto obj = foo(x); std::is_same_v<decltype(obj), T>) {
    std::cout << "foo(x) yields same type\n";
  } else {
    std::cout << "foo(x) yields different type\n";
    ...
  }
}
  • If there is a constexpr function foo() for a passed type, you can use this code to provide different behavior depending on whether foo(x) yields the same type as x.
  • To decide on the value returned by foo(x), you can write:
constexpr auto c = ...;
if constexpr (constexpr auto obj = foo(c); obj == 0) {
  std::cout << "foo() == 0\n";
  ...
}
  • Note that obj has to be declared as constexpr for you to use its value in the condition.

10.4 Using Compile-Time if Outside Templates

  • if constexpr can be used in any function, not just in templates. All we need is a compile-time expression that yields something convertible to bool.
  • However, in that case, in both the then and the else parts, all statements always have to be valid even if they are discarded.
  • For example, the following code will always fail to compile because the call of undeclared() must be valid even if chars are signed and the else part is discarded:
#include <limits>
template <typename T>
void foo(T t);
int main() {
  if constexpr (std::numeric_limits<char>::is_signed) {
    foo(42);  // OK
  } else {
    undeclared(42);  // ALWAYS ERROR if not declared (even if discarded)
  }
}
  • The following code can never successfully compile because one of the static assertions will always fail:
if constexpr (std::numeric_limits<char>::is_signed) {
  static_assert(std::numeric_limits<char>::is_signed);
} else {
  static_assert(!std::numeric_limits<char>::is_signed);
}
  • The (only) benefit of the compile-time if outside generic code is that code in the discarded statement, although it must be valid, does not become part of the resulting program, which reduces the size of the resulting executable.
  • For example, in this program:
#include <array>
#include <limits>
#include <string>
int main() {
  if (!std::numeric_limits<char>::is_signed) {
    static std::array<std::string, 1000> arr1;
    ...
  } else {
    static std::array<std::string, 1000> arr2;
    ...
  }
}
  • either arr1 or arr2 is part of the final executable but not both.