第十章 编译期if

通过语法if constexpr(...),编译器使用编译时表达式在编译时决定是否使用then部分或者else部分。如果任一部分被抛弃,那部分代码就不会生成。但是,这不意味着被抛弃的部分完全被忽略了。它将像未使用模板的代码一样进行检查。 比如:

#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
    }
}

这里我们用到了编译器if的特性。它在编译时决定我们是只返回一个字符串,还是说要调std::to_string()把数组转称字符串,又或者把传进来的参数转成std::string。因为无效的调用会被抛弃,下面的代码都可以编译:

#include "ifcomptime.hpp"
#include <iostream>

int main()
{
    std::cout << asString(42) << '\n';
    std::cout << asString(std::string("hello")) << '\n';
    std::cout << asString("hello") << '\n';
}

10.1 存在编译期if的动机

如果刚刚的例子中,我们用运行时if:

 #include <string>
template <typename T>
std::string asString(T x)
{
    if (std::is_same_v<T, std::string>) {
        return x; // ERROR, if no conversion to string
    }
    else if (std::is_numeric_v<T>) {
        return std::to_string(x); // ERROR, if x is not numeric
    }
    else {
        return std::string(x); // ERROR, if no conversion to string
    }
}

对应的调用代码肯定不能编译。这是因为函数模板有个规则,要么全编,要么完全不编。if的条件检查是一个运行时行为。即使编译期就知道条件一定是个false,then也必须编。所以,当传递一个std::string或者字符串字面值的时候,编译就出错了,因为对于传入的参数调用std::string是无效的。当传递数值时,编译也出错,因为第一个和第三个返回语句是无效的。

注意,仅当使用编译期if的时候,then或者else没有被用到的才会被丢弃:

  • 当传std::string时,第一个if的else部分被丢弃
  • 当传一个数值时,第一个if的then部分和最后的else被丢弃
  • 当传递一个字符串字面值时(即类型const char*),then和第二个if被丢弃

注意,被丢弃的语句不是说被忽略了。只是说当依赖模板参数时,它不会被实例化。语法必须正确,不依赖模板参数的调用也必须正确。事实上,在第一个翻译阶段(definition time)执行时,编译器会检查语法正确与否,使用的所有名字是否都不依赖模板参数。所有static_assert也必须正确,即使该分支不会被编译。比如

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)
    }
}

只要是符合标准的编译器,这个例子都不会被编译,原因有两个:

  • 即使T是整数类型,这个
undeclared(); // error if not declared (even if discarded)

没有声明的调用也是错的,因为它依赖模板参数

  • 这个静态断言
static_assert(false, "no integral");

总是失败,即使它所在的分支会被丢弃,原因还是因为它依赖一个模板参数。重复编译期条件的静态断言是可以的:

static_assert(!std::is_integral_v<T>, "no integral");

注意,一些编译器(比如Visual C++2013和1025)没有正确的实现两阶段模板翻译,它们把绝大多第一阶段(definition time)该做的事情推迟到第二阶段(instantiation time),所以无效的函数调用,甚至一些语法错误也可能通过编译。

10.2 使用编译期if

原则上,你可以使用编译器if做一些事情,看起来就像是运行时的if,只是条件是编译期的表达式。你也可以混用编译期间if和运行时if:

if constexpr (std::is_integral_v<std::remove_reference_t<T>>) {
    if (val > 10) {
        if constexpr (std::numeric_limits<char>::is_signed) {
            ...
        }
        else {
            ...
        }
    }
    else {
        ...
    }
}
else {
    ...
}

注意你不能在函数体外面使用if constexpr。因此,你不能在用它代替条件预处理器。

10.2.1 编译期if注意事项

即使有时看起来可以使用编译期if,有一些不明显的结果会出现,本小节会一一称述。

编译期if影响返回类型

编译期if可能影响函数的返回类型。比如,下面的代码总是可以编译,但是返回类型可能是不同的:

auto foo()
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
    else {
        return 42u;
    }
}

因为我们用了auto,返回类型取决于返回语句,返回语句又取决于int的大小:

  • 如果size大于4,只有一个有效的返回语句返回42,所以返回类型是_int_
  • 否则,只有一个有效的返回语句返回42u,所以返回类型是_unsigned int_

事情还可能更魔幻。比如下面的例子,如果我们跳过else部分,返回类型可能是int或者void:

auto foo() // return type might be int or void
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
}

如果这里的if运行时if那代码就不能编译,因为两个返回语句都会被编译器考虑,最后得出的结论是返回类型存在二义性。

即便then返回了,else也可能造成问题

对于运行时if语句,有一\种模式不适用于编译期if:如果then和else都有返回语句,而且能通过编译,你总是可以跳过运行时if的else部分。换句话说,下面这种代码:

if (...) {
    return a;
}
else {
    return b;
}

你总是可以改写为:

if (...) {
    return a;
}
return b;

这种模式不适用于编译期if,因为第二种形式的返回值取决于两个返回语句,而不是一个,可能会造成歧义。比如,改一下上面的例子,代码可能能编,也可能不能编:

auto foo()
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
    return 42u;
}

如果条件是true,那么编译器推导出两个不同的返回类型,编不了。否则,只有一个返回语句,不会造成问题,所以可以编译。

编译期if的短路运算

考虑下面的代码:

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;
}

我们有两个编译期条件,来决定是否直接返回val,或者翻倍再返回。 下面代码都可以编译:

constexpr auto x1 = foo(42);   //产生84
constexpr auto x2 = foo("hi"); //可以的,产生"hi"

在运行时if中的条件可以进行短路运算。你可能期望编译其if也有这种能力:

template<typename T>
constexpr auto bar(const T& val)
{
    if constexpr (std::is_integral<T>::value && T{} < 10) {
        return val * 2;
    }
    return val;
}

然而,编译期if的条件总是被实例化,需要作为整体来确定是否有效,所以传入一个不支持<10判断的类型编不了:

constexpr auto x2 = bar("hi"); // compile-time ERROR

所以,编译期if是不会短路实例化的。如果编译期条件的有效性取决于更早期的编译期条件,你不得不嵌套一下,也就是说,你不得不这样写:

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

而不是:

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

10.2.2 其他编译期if的例子

返回完美转发

一个编译期if的应用是返回值的完美转发。因为decltype(auto)不能被推导为void(因为void是不完全类型(incomplete type)),你必须这样写:

#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;
    }
}

编译期的tag派发

编译期if的一个传统应用是tag派发。在C++17之前,你必为你希望处理的类型提供完整的函数重载集合。现在有了编译期if,你可以把所有逻辑放到一个函数里面。举个例子,你可以不用像下面这样写一堆重载函数来实现std::advance()算法:

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;
    }
}

而是将所有行为在一个函数里面实现:

template<typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
    using cat = std::iterator_traits<Iterator>::iterator_category;
    
    if constexpr (std::is_same_v<cat,                       std::random_access_iterator_tag>) {
        pos += n;
    }
    else if constexpr (std::is_same_v<cat,
                       std::bidirectional_access_iterator_tag>) {
        if (n >= 0) {
            while (n--) {
                ++pos;
            }
        }
        else {
            while (n++) {
                --pos;
            }
        }
    }
    else { // input_iterator_tag
        while (n--) {
            ++pos;
        }
    }
}

在某种程度上,我们现在有一个编译期switch,虽然不同case是通过if constexpr来表达。注意,这里有一个区别:

  • 重载函数集合给你最佳匹配(best match)语意
  • 编译期if给你第一匹配(fisrt match)语意

另一个tag派发的例子是第一章里面使用编译期if的get<>()重载。 第三个例子是第十六章里面处理不同类型的std::variant<>()访问器。

10.3 编译期if初始化

注意编译期if也可以用于新的带初始化的if语法中。比如,如果有个constexpr函数foo(),你可以使用:

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";
        ...
    }
}

你可以像上面一样让bar根据foo产生结果的值的类型是否与T相同,来产生不同的行为。 要根据foo(x)返回的值本身来决定不同行为,你可以这样:

constexpr auto c = ...;
if constexpr (constexpr auto obj = foo(c); obj == 0) {
    std::cout << "foo() == 0\n";
    ...
}

obj必须声明为constexpr,因为要在条件中使用它的值。

10.4 在模板外面使用编译期if

if constexpr可以被用于任何函数,不仅仅局限于模板。我们只需要编译期表达式产生的结果可以转换为bool值。然而,在那种情况下then和else的中的所有语句都必须是有效地,即便它们可能被抛弃。

比如,下面的代码总是不能编译,因为即便char是signed、else被抛弃,undeclared()这个调用也必须有效才行:

#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)
    }
}

下面的代码也不能编译,因为其中一个静态断言总是会失败:

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);
}

在模板代码外面使用编译期if唯一的好处是那些被抛弃的语句(必须有效)不需要编到最后的二进制代码中,减小了可执行程序的体积。比如下面的程序:

#include <limits>
#include <string>
#include <array>
int main()
{
    if (!std::numeric_limits<char>::is_signed) {
        static std::array<std::string,1000> arr1;
        ...
    }
    else {
        static std::array<std::string,1000> arr2;
        ...
    }
}

arr1或者arr2是最终可执行程序的一部分,但不会都是。

10.5 后记

编译期if最初由Walter Bright,Herb Sutter和Andrei Alexandrescu在https://wg21.link/n3329中提出。Ville Voutilainen在https://wg21.link/n4461提出了static if语言特性。在https://wg21.link/p0128r0中Ville Voutilainen第一次提出了constexpr_if(这个feature名字的起源)。最后的公认措辞是由Jens Maurer在https://wg21.link/p0292r2中给出。