C++17 详解 13

本文为 《C++17 in detail》 一书的中文渣中渣译文,不足之处还望指正。

4.3 if constexpr

这是一个重点!

C++ 的编译时 if

此特性可以让你在编译时舍弃基于常量表达式条件的 if 语句的分支。

1
2
3
4
if constexpr (cond)
statement1; // cond 为 false 时舍弃
else
statement2; // cond 为 true 时舍弃

比如:

1
2
3
4
5
6
7
8
template <typename T>
auto get_value(T t)
{
if constexpr (std::is_pointer_v<T>)
return *t;
else
return t;
}

if constexpr 有能力简化许多模版代码——特别是用到标签调度(译注:tag dispatching)、SFINAE 或预处理器技巧时。

译注:

标签调度的示例

SFINAE 的详细解释

通俗地讲,都是实现模版特化/重载的技术手段。if constexpr 更轻量,允许在同一函数内使用 if 分支实现编译期条件选择。本文后边有一个这三种技术应用比较的例子。

为什么需要编译时 if ?

一开始你可能会问,为什么我们需要 if constexpr,还有那些复杂的模版化的表达式?普通的 if 不行吗?

这里有一段示例代码:

1
2
3
4
5
6
7
8
template <typename Concrete, typename... Ts>
unique_ptr<Concrete> constructArgs(Ts&&... params)
{
if (is_constructible_v<Concrete, Ts...>) // 普通 if
return make_unique<Concrete>(forward<Ts>(params)...);
else
return nullptr;
}

上述例程是 make_unique 的“更新”版本:当参数允许它构造包装的对象时返回 unique_ptr,否则返回 nullptr

下边是一份测试 constructArgs 的简单代码:

1
2
3
4
5
6
7
8
9
10
class Test
{
public:
Test(int, int) { }
};

int main()
{
auto p = constructArgs<Test>(10, 10, 10); // 3 个参数!
}

代码试图用 3 个参数构造 Test,但是请注意 Test 只有一个接受两个 int 实参的构造函数。

编译时会得到类似如下的编译错误:

1
2
3
4
5
6
In instantiation of 'typename std::_MakeUniq<_Tp>::__single_object std::make_unique(\
_Args&& ...) [with _Tp = Test; _Args = {int, int, int}; typename std::_MakeUniq<_Tp>\
::__single_object = std::unique_ptr<Test, std::default_delete<Test> >]':

main.cpp:8:40: required from 'std::unique_ptr<_Tp> constructArgs(Ts&& ...) [with C\
oncrete = Test; Ts = {int, int, int}]'

让我们试着理解这条错误消息。在模板推断后,编译器编译出如下代码:

1
2
3
4
if (std::is_constructible_v<Concrete, 10, 10, 10>)
return std::make_unique<Concrete>(10, 10, 10);
else
return nullptr;

在运行时 if 分支永远不会被执行——因为 is_constructible_v 返回 false,但是此分支内的代码必须能编译通过。

这就是为什么我们需要 if constexpr,可以“舍弃”代码,只编译匹配的语句。

为了修复上边代码你必须添加 constexpr

1
2
3
4
5
6
7
8
template <typename Concrete, typename... Ts>
unique_ptr<Concrete> constructArgs(Ts&&... params)
{
if constexpr (is_constructible_v<Concrete, Ts...>) // 好了!
return make_unique<Concrete>(forward<Ts>(params)...);
else
return nullptr;
}

现在,编译器在编译时计算出 if constexpr 的值,对 auto p = constructArgs<Test>(10, 10, 10); 这句来说,整个的 if 分支都在编译处理的第二个步骤里被“删除”了。

准确地说,被舍弃分支里的代码不是在编译阶段被完全删除了的。只有那些依赖于条件里用到的模板参数的表达式不会被实例化。但是要求语法上必须是有效的。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
void Calculate(T t)
{
if constexpr (is_integral_v<T>)
{
// ...
static_assert(sizeof(int) == 100);
}
else
{
execute(t);
strange syntax
}
}

上边的伪代码里,如果 T 的类型是 int,else 分支会被舍弃,意味着 execute(t) 不会被实例化。但是 strange syntax 这行仍然会被编译(因为它不依赖 T),这也是为什么你将会得到一个编译错误的原因。

然后,另一个编译错误从 static_assert 抛出,这个表达式也不依赖 T,所以它也会被(编译器)计算。

模版代码简化

C++17 之前,如果你有一个算法的多个版本——它们是依赖于类型需求的,你可能会通过 SFINAE 或标签调度生成候补重载决议集。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Chapter Templates/sfinae_example.cpp
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> simpleTypeInfo(T t)
{
std::cout << "foo<integral T> " << t << '\n';
return t;
}

template <typename T>
std::enable_if_t<!std::is_integral_v<T>, T> simpleTypeInfo(T t)
{
std::cout << "not integral \n";
return t;
}

上边的例子里有两个函数实现,但是只可能有一个会最终进入重载决议集。如果 std::is_integral_v<T> 为真就选择上边的函数,第二个因为 SFINAE 的缘故被拒绝。

同样的事发生在标签调度时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Chapter Templates/tag_dispatching_example.cpp
template <typename T>
T simpleTypeInfoTagImpl(T t, std::true_type)
{
std::cout << "foo<integral T> " << t << '\n';
return t;
}

template <typename T>
T simpleTypeInfoTagImpl(T t, std::false_type)
{
std::cout << "not integral \n";
return t;
}

template <typename T>
T simpleTypeInfoTag(T t)
{
return simpleTypeInfoTagImpl(t, std::is_integral<T>{});
}

不同于 SFINAE,我们为每一种条件生成一个唯一的标签类型:true_typefalse_type。基于结果,只有一个实现会被选择。

现在我们可以通过 if constexpr 简化这种模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
T simpleTypeInfo(T t)
{
if constexpr (std::is_integral_v<T>)
{
std::cout << "foo<integral T> " << t << '\n';
}
else
{
std::cout << "not integral \n";
}
return t;
}

模版代码的书写变得更“自然”了,而且也不需要那么多“伎俩”(译注:原文为 tricks)了。

示例

让我们看另外两个示例:

  1. 行打印

你可能已经在本书开头的快速开始小节看到过下边的示例了。让我们深入细节看看代码是怎么工作的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T> void linePrinter(const T& x)
{
if constexpr (std::is_integral_v<T>)
{
std::cout << "num: " << x << '\n';
}
else if constexpr (std::is_floating_point_v<T>)
{
const auto frac = x - static_cast<long>(x);
std::cout << "flt: " << x << ", frac " << frac << '\n';
}
else if constexpr(std::is_pointer_v<T>)
{
std::cout << "ptr, ";
linePrinter(*x);
}
else
{
std::cout << x << '\n';
}
}

linePrinter 利用 if constexpr 检查输入类型。基于此我们可以输出额外信息。有意思的是指针类型——当指针被检测到,代码对其解引用然后递归调用 linePrinter

  1. 声明自定义 get<N> 函数

结构化绑定对只有公有成员的简单结构体有效,比如:

1
2
3
4
5
6
7
8
9
struct S
{
int n;
std::string s;
float d;
};

S s;
auto [a, b, c] = s;

但是,如果你有一个带私有成员的自定义类型,那你可能要重载 get<N> 函数以使结构化绑定能生效。看下边演示代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyClass
{
public:
int GetA() const { return a; }
float GetB() const { return b; }
private:
int a;
float b;
};

template <std::size_t I> auto get(MyClass& c)
{
if constexpr (I == 0) return c.GetA();
else if constexpr (I == 1) return c.GetB();
}

// specialisations to support tuple-like interface
namespace std
{
template <> struct tuple_size<MyClass> : std::integral_constant<size_t, 2> { };
template <> struct tuple_element<0,MyClass> { using type = int; };
template <> struct tuple_element<1,MyClass> { using type = float; };
}

上边代码有一个优点:用一个函数完成所有工作。你也可以用模版特化实现:

1
2
template <> auto& get<0>(MyClass &c) { return c.GetA(); }
template <> auto& get<1>(MyClass &c) { return c.GetB(); }

在后边“用 if constexpr 替换 std::enable_if” 一章中有更多相关示例,“结构化绑定”一章中也有,在关于自定义 get<N> 特化小节中。

你也可以参考这篇文章:C++17 中使用 if constexpr 简化代码

扩展:本修改提案:P0292R2

评论