1 if-constexpr 语法
1.1 基本语法
if-constexpr 语法是 C++ 17 引入的新语法特性,也被称为常量 if 表达式或静态 if(static if)。引入这个语言特性的目的是将 C++ 在编译期计算和求值的能力进一步扩展,更方便地实现编译期的分支选择动作。早期的 C++ 缺少类似的语言特性,C++ 开发者不得不使用 tag dispatching 这样的惯用手法或者借助于模板特化机制让编译器在模板参数推导时“曲折地”实现一些静态选择。C++ 11 标准明确了 SFINAE 机制的应用,并提供了 enable_if
,二者配合可以更方便地实现编译期的分支选择,但是与 if-constexpr 相比,可读性和易用性都差了几条街。
在介绍 if-constexpr 的“神通”之前,前来看一下 if-constexpr 的语法形式。其实我们在介绍如何让自己的设计的类型支持结构化绑定的时候(《让自定义类型支持结构化绑定》),为实现 get<N>()
成员方法,就用到了这种语法,这里再回顾一下 if-constexpr 语法的表达形式:
struct FooTest
{
template<std::size_t N>
const auto& get() const
{
if constexpr (N == 0) return name;
else if constexpr (N == 1) return age; //else if 分支中的 constexpr 说明符可以省略
else return weight;
}
};
这个函数中对模板参数 N 的判断在编译期间进行,由哪个分支返回数据也是在编译期间就决定了,false 分支中的代码甚至不会出现在实例化后的函数代码中。以get<N>()
成员函数为例,如果使用 FooTest 的代码用到 get<0>()
取 name 这个成员,则最终实例化后会得get<0>()
的特化实现:
struct FooTest
{
template<>
const std::string& get<0>() const
{
return name;
}
};
如果代码中还用了get<1>()
取 age 的值,则编译器还会实例化出 get<1>()
这个特化版本:
template<>
const int& get<1>() const
{
return age;
}
1.2 扩展说明
1.2.1 条件表达式
常量 if 表达式中的条件表达式必须是 bool 类型的常量表达式,或者是可转换成 bool 类型的常量表达式。因为这个条件表达式是在编译期进行评估的,所以 constexpr 修饰的常量或函数都可以出现在这个表达式中,但是运行期间的变量或非 constexpr 的函数不可以出现在条件表达式中。
C++ 17 中,lambda 表达式(lambda 函数)可以被显式声明为 constexpr,但是当一个 lambda 表达式和相关的上下文一起达成一个闭包时,如果这个闭包在一个 constexpr 上下文中使用,即使没有将其显式声明为 constexpr,它也会被当作 constexpr 使用,比如:
auto DoubleIt = [](int n) { return n + n; };
template<std::size_t N>
bool Func2()
{
if constexpr (DoubleIt(N) < 100)
return true;
else
return false;
}
std::cout << Func2<10>() << ", " << Func2<50>() << std::endl; //1, 0
1.2.2 false 分支处理
函数模板实例化时,评估为 false 分支的语句不会出现在最终实例化后的函数代码中,但是编译器会对其进行语法检查,当出现语法错误时也会报错(有些编译器报错)。虽然会进行语法检查,但是 false 分支中的 return 语句不会参与函数的返回值类型推定,比如这个例子:
template<typename T>
auto get_value(T t)
{
if constexpr (std::is_pointer_v<T>)
return *t;
else
return t;
}
当std::is_pointer_v<T>
为 true 时,else 分支中的 return t 语句不参与返回值类型推定,函数返回值类型推定为 *t
的类型。当std::is_pointer_v<T>
为 false 时正好相反,else 分支中的 return t 语句将决定返回值类型,函数返回值类型推定为 t 的类型。这里需要注意,如果取消配合 if-constexpr 的 else 分支,改用函数设计常用的默认返回方式,编译器就会报错,比如:
template<typename T>
auto get_value(T t)
{
if constexpr (std::is_pointer_v<T>)
return *t;
return t;
}
这个 get_value2() 函数的代码语义与前面的 get_value() 函数一样,但是编译器会报错,因为最后的 return 语句也参与返回值类型的推定,并且当 T 是指针类型的时候,两个 return 语句的返回值类型推定会互相矛盾。
尽管编译器会对 false 分支的代码进行语法检查,但是 false 分支的代码会被丢弃,所以不参与代码链接。比如这个例子:
extern int x; //
int f()
{
if constexpr (true)
return 0;
else if (x)
return x;
else
return -x;
}
f(); //调用 f
尽管全局变量 x 只有一个 extern 声明,并没有定义,但是这段代码编译正常,没有错误。因为 else if 和 else 分支的代码都被丢弃,编译器没有为链接代码而定位 x 的需要。
编译器之所以要对准备丢弃的 false 分支代码进行语法检查,可能原因是它要对整个 if 语句进行分析,了解每个分支逻辑的起始位置和结束位置,以便能够正确地保留 true 分支的代码,丢弃 false 分支的代码。
1.2.3 初始化语句
C++ 17 开始支持在 if 语句中使用初始化语句,当然,if-constexpr 语法上也支持初始化语句,只是要求初始化语句也只能使用常量和常量表达式。比如这个例子中的 k 的初始化:
template<std::size_t N>
bool Func()
{
if constexpr (constexpr std::size_t k = 3; (N % k) == 0)
return true;
else
return false;
}
k 必须是个常量,初始化 k 的表达式必须是常量表达式。
2 if-constexpr 的作用
可在编译期执行的 if-constexpr,用于模板元编程中的条件判断,不仅扩展了模板元编程的分支处理能力,也简化了很多以前用非常复杂方式实现的分支判断代码,使得模板元编程对条件分支的处理代码更直观,更容易理解。这一部分我们用三个例子,分别介绍一下 if-constexpr 的作用,包括对传统的 tag dispatching 和模板特化习惯用法的比较。
2.1 简化可变参数的处理方式
使用 if-constexpr 可以提高泛型代码的可读性,上一节介绍的 get<N>()
的函数,如果不用 if-constexpr,就需要借助于模板的递归推导来解决 N 的个数不确定问题。具体做法就是定义一个泛化版本加上一个特化版本,这样实现起来不仅麻烦,代码可读性也大打折扣。这一节,我们用一个之前介绍过的用于求和的函数模板为例,介绍一下 if-constexpr 对可变参数的处理以及提高代码可读性能带来的好处。
在 C++ 17 之前,没有折叠表达式和 if-constexpr,对参数包的处理需要用到模板的递归推导,需要定义一个结束递归推导的特化实例,非常不直观:
template<typename T>
auto Sum(T arg)
{
return arg;
}
template<typename T, typename... Args>
auto Sum(T arg, Args... args_left)
{
return arg + Sum(args_left...);
}
std::cout << Sum(3, 5, 8) << std::endl; //输出16
std::cout << Sum(std::string("Emma "), std::string(" love cats!")) << std::endl; //输出 Emma love cats!
C++ 17 引入了折叠表达式,用了折叠表达式就简单多了,看看折叠表达式的版本:
template<typename... Args>
auto Sum(Args&&... args)
{
return (0 + ... + args);
}
但是折叠表达式的语法让很多初学者“毛骨悚然”,如果改成用 if-constexpr 实现,则代码更符合直觉,可读性也上了一个台阶:
template <typename T, typename... Args>
auto Sum(T arg, Args... args)
{
if constexpr (0 == sizeof...(Args))
return arg;
else
return arg + Sum(args...);
}
2.2 比std::enable_if 更灵活
SFINAE (Substitution Failure Is Not An Error)的意思就是模板推导的过程中,如果模板参数替换后得到一个无意义的错误结果,编译器并不立即报错,而是暂时忽略这个模板函数声明,继续参数推导和替换。C++ 11 引入的 std::enable_if 就是实现 SFINAE 的最直接方式,下面用 ToString() 函数为例(注意,这不是一个严谨的实现,只是作为一个例子),看看如何用 std::enable_if 实现编译期的条件分支。
//也可以用 enable_if_t
template<typename T>
std::enable_if<std::is_arithmetic<T>::value, std::string>::type
ToString(T t)
{
return std::to_string(t);
}
template<typename T>
std::enable_if<!std::is_arithmetic<T>::value, std::string>::type
ToString(T t)
{
return t;
}
std::to_string() 支持将一个整数型数据或浮点数型数据转成字符串,如果 T 本身就是 std::string 类型,则不需要转换。std::enable_if 的作用就是通过对返回值类型的控制,使得当类型 T 与函数代码不匹配(比如 to_string() 函数不支持 std::string 类型)的时候产生一个错误的函数声明。举个例子,当 T 是 std::string 类型时(不是数字类型),编译器对两个模板函数进行参数替换后得到两个函数声明:
template<>
ToString<std::string>(std::string t);
template<>
std::string ToString<std::string>(std::string t);
第一个替换结果没有函数返回值,是个语法上错误的函数声明,编译器会丢弃这个替换结果,选择第二个语法上正确的作为最终的 ToString() 函数重载裁决结果。如此一来,就通过 std::enable_if 与 SFINAE 机制配合,实现了编译期分支选择的目的。
但是使用 std::enable_if 控制需要注意一点,std::enable_if 只能将条件分割成两种情况,就是两个条件必须互斥,即一个是 true 条件,另一个必须是 false 条件,否则一旦出现两个判断条件都是 true 的情况,就会出现两个正确的结果,导致编译器报告“模棱两可的函数调用” 的编译错误。通过上面的例子可以看出,尽管 std::enable_if 也能实现编译期的条件分支选择,但是代码并不直观,约束条件比较多,且只能实现两个分支的选择。现在看看使用 if-constexpr 的实现方案:
template<typename T>
auto ToString(T t)
{
if constexpr (std::is_arithmetic<T>::value)
return std::to_string(t);
else
return t;
}
这样的代码要比写两个重载函数让编译器按照 SFINAE 原则匹配调用的方式更直观,也更容易理解和维护。
2.3 比 tag dispatching 更直观
tag 就是一些没有数据,没有操作的空类型,它们可以作为函数参数来影响编译器对重载函数的选择。用 tag dispatching 技术首先要定义 tag,根据本文的例子,我们定义两个 tag:
struct NumTag {};
struct StrTag {};
接着要定义重载函数,唯一不同的参数就是 tag 类型,tag 类型作为函数的哑形参只影响编译器对重载函数的选择,最终这个没有的参数都会被编译器优化掉:
template <typename T>
auto ToString_impl(T t, NumTag)
{
return std::to_string(t);
}
template <typename T>
auto ToString_impl(T t, StrTag)
{
return t;
}
最后就是实现 ToString(),根据 T 的类型确定是调用 ToString_impl(t, NumTag()); 还是调用 ToString_impl(t, StrTag());,具体做起来就八仙过海,各显神通,比如这个使用自定义 type_traits 的方式:
template <typename T> //一个并不严谨的泛化版本
struct traits
{
typedef NumTag tag;
};
template <> //针对 std::string 的特化版本
struct traits<std::string>
{
typedef StrTag tag;
};
template <typename T>
auto ToString(T t)
{
return ToString_impl(t, typename traits<T>::tag()); //根据 traits<T>::tag 选择 ToString_impl()
}
对比上一节用 if constexpr 实现的版本,可以看出来使用 tag dispatching 的代码比较晦涩,需要研究一下 tag 的定义才能了解分支选择的具体条件,代码实现不如 if constexpr 直观。
3 if-constexpr 与 if 的区别
3.1 if 为什么不行
上一节的 ToString() 函数的例子如果不用 if-constexpr,像这样直接用 if 实现:
template<typename T>
auto ToString(T t)
{
if (std::is_arithmetic<T>::value)
return std::to_string(t);
else
return t;
}
是否也可以呢?答案是不可以,因为 std::is_arithmetic<T>
是在编译期求值的,当代码中需要将整数 42 转成字符串,调用 ToString(42) 的时候,传入参数是 int 或 double,评估结果是 true,此时函数模板被实例化成:
auto ToString(int t)
{
if (true)
return std::to_string(t);
else
return t;
}
这个实例化结果是无法编译的,因为返回值到底是整数还是 std::string 呢?两个 return 语句的返回值类型不一致。再看看到传入参数是 std::string 的情况,此时 if 的评估结果是 false,函数模板被实例化成:
auto ToString(std::string t)
{
if (false)
return std::to_string(t);
else
return t;
}
尽管走 else 分支,直接返回 t 没有问题,但是 if 分支的编译会有问题,因为 std::to_string() 不支持 std::string 类型。所以,直接使用 if 语句是不可以的。
3.2 if-constexpr 为什么可以
现在对比使用 if-constexpr 的情况。前面提到过,对于 false 分支编译只进行语法分析,不生成代码。所以当代码中出现 ToString(42) 的调用的时候,传入参数是 int,评估结果是 true,此时函数模板被实例化成:
std::string ToString(int t)
{
return std::to_string(t);
}
当传入参数是字符串类型的时候,else 分支就成为 true 分支被保留,函数模板实例化的结果就是:
std::string ToString(std::string t)
{
return t;
}
最终实例化的结果和使用 std::enable_if 的结果是一样的,但是语法比 std::enable_if 简单,直观。
4 if-constexpr 与 #if 的区别
编译期 if 表达式很容易让人想到 C++ 的条件编译指令 #if,但是它们的区别还是很明显的,主要有三点:
- 处理阶段不同:#if 条件编译指令是在代码预处理阶段解析的,预处理器处理完成后提交给编译器时,编译器只能看到 true 分支的内容,而 if-constexpr 的代码都是在编译阶段进行处理的;
- 条件表达式要求不同:首先是代码处理的阶段不一样,#if 只能使用用于定义的宏、编译器预定义的宏和环境变量,不能使用代码中的函数或变量,而 if-constexpr 的条件表达式可以是代码中的常量,或者常量函数;
- false 分支的处理方式不同:条件编译中的 false 分支编译器不进行语法检查,实际上它们在预编译阶段就被过滤掉了,而 if-constexpr 中的 false 分支也进行语法检查。
关注作者的算法专栏
https://blog.csdn.net/orbit/category_10400723.html
关注作者的出版物《算法的乐趣(第二版)》
https://www.ituring.com.cn/book/3180