第15.8章 if constexpr 与条件错误契约
1. 不被编译的那个分支
第7.3章把
if constexpr 介绍成编译期 if。在模板里它多做一件事:没被选中的
分支会被丢弃,而不是编译,所以它对当前模板实参来说可以是非法代码。
这就是它与普通 if 的全部差别。运行期 if 的两个分支都必须对每个
类型都编译通过;if constexpr 只要被选中的那个能编译。泛型函数就是靠它说
“如果类型是整型就做这个,否则做那个”,而不用给每个类别写一个函数:
template <typename T>
[[nodiscard]] inline constexpr ::std::string_view describe() noexcept
{
if constexpr (::std::integral<T>)
{
return "integral";
}
else if constexpr (::std::floating_point<T>)
{
return "floating point";
}
else
{
return "something else";
}
}
describe<no_size>() 返回 "something else",
前两个分支在那个实例化里根本不存在 —— 不编译、不生成、也到不了。
2. if constexpr 里的 requires
条件可以是临时 requires 表达式
(第15.3章),
它问的是“这个表达式对这个类型合法吗”。泛型代码就是这样发现一个可选成员函数的,
而不必给调用者加约束:
template <typename T>
[[nodiscard]] inline constexpr ::std::size_t element_count() noexcept
{
if constexpr (requires(T const &t) { t.size(); })
{
return 8u;
}
else
{
return 1u;
}
}
::std::string_view 有 size(),走第一个分支;
no_size 没有,走第二个。没有一处写了两次,而且某个类型明天多了
size(),这个函数不用改就会开始走第一个分支。
3. 取决于实例化的契约
第6.16章说每个函数要么
noexcept 要么 throws。对模板来说这是麻烦事:对
int 不会失败的操作,对用户类型可能失败。答案是条件异常说明:
template <typename T>
inline void check_nonzero(T value) throws(::std::is_integral_v<T>)
{
if constexpr (::std::is_integral_v<T>)
{
if (value == 0) [[unlikely]]
{
throw throws ::std::errc::invalid_argument;
}
}
else
{
static_cast<void>(value);
}
}
throws(condition) 的含义是:条件为 true 时函数可以抛赫布异常;
为 false 时函数是 noexcept,并由编译器强制执行。
两个实例化是真正不同类型的函数:
noexcept(check_nonzero<::std::int_least32_t>(7)) // false
noexcept(check_nonzero<::std::float64_t>(1.0)) // true
为什么值得?因为 noexcept 才让一个操作能用在另一个 noexcept
操作里,也因为
::std::is_nothrow_move_constructible_v 之类
(第15.1章)正是容器在移动与
拷贝之间做选择的依据。一个无条件 throws 的模板,会强迫装它的容器在扩容时拷贝。
4. 继承被包装调用的契约
最常见的条件异常说明与类型无关,与被转发的调用有关。noexcept(表达式)
是一个运算符:该表达式不会抛时得到 true。于是完美转发的包装函数可以承诺
与它调用的那次调用完全一样的强度:
template <typename output, typename T>
inline void print_once(output &&out, T const &value) throws(!noexcept(::fast_io::io::print(out, value)))
{
::fast_io::io::print(out, value);
}
throws(!noexcept(...)) 读起来别扭,但意思就是它说的:
“除非被包装的调用不会抛,否则本函数可以抛”。如果你更喜欢,也可以写成
noexcept(noexcept(...)) —— 在赫布异常工具链上两种写法一致。
fast_io 自己的头文件里到处是这个模式,只是藏在宏后面,以便库能同时面向两种异常模型构建:
// fast_io_dsal/impl/misc/push_macros.h, herbceptions branch
#define FAST_IO_HERBCEPTIONS_THROWS_IF(...) throws(__VA_ARGS__)
#define FAST_IO_HERBCEPTIONS_THROWS_IF_NOT_NOEXCEPT(...) throws(!noexcept(__VA_ARGS__))
// legacy branch
#define FAST_IO_HERBCEPTIONS_THROWS_IF(...) noexcept(!(__VA_ARGS__))
#define FAST_IO_HERBCEPTIONS_THROWS_IF_NOT_NOEXCEPT(...) noexcept(noexcept(__VA_ARGS__))
::fast_io::native_global_allocator 给每个分配函数都加上
FAST_IO_HERBCEPTIONS_THROWS_IF(allocator::throws_on_allocation_failure),
这就是为什么 ::fast_io::vector<T> 是 throws,
而建立在“永不失败”分配器上的 vector 是 noexcept
(第15.9章给出那段代码)。
5. 分支里放什么
| 条件 | 用来做什么 |
|---|---|
::std::integral<T>、::std::floating_point<T> |
按类型类别分开算法 |
::std::is_pointer_v<T>、sizeof(T) == N |
布局决策、挑选内建指令 |
requires { t.size(); } |
可选成员、能力探测 |
::std::is_constant_evaluated() |
编译期用另一套算法 (第7.1章) |
!noexcept(expr) |
镜像某次调用的错误契约 |
两条规矩能让 if constexpr 不至于变成新的 #ifdef 面条。
第一,走不到的分支是死代码:它不编译,所以里面的笔误要等到有人用匹配的类型实例化时才会被发现。
第二,超过三个分支的链是设计味道 —— 要么这些类别该变成概念加重载(重载会按包含关系自动排序),
要么这个类型本该自己提供那个操作。
6. 完整程序
#include <fast_io.h>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <stdfloat>
#include <type_traits>
struct no_size
{
int value{};
};
template <typename T>
[[nodiscard]] inline constexpr ::std::string_view describe() noexcept
{
if constexpr (::std::integral<T>)
{
return "integral";
}
else if constexpr (::std::floating_point<T>)
{
return "floating point";
}
else
{
return "something else";
}
}
template <typename T>
[[nodiscard]] inline constexpr ::std::size_t element_count() noexcept
{
if constexpr (requires(T const &t) { t.size(); })
{
return 8u;
}
else
{
return 1u;
}
}
// the error contract depends on the instantiation
template <typename T>
inline void check_nonzero(T value) throws(::std::is_integral_v<T>)
{
if constexpr (::std::is_integral_v<T>)
{
if (value == 0) [[unlikely]]
{
throw throws ::std::errc::invalid_argument;
}
}
else
{
static_cast<void>(value);
}
}
int main()
{
try
{
::fast_io::io::println(describe<int>());
::fast_io::io::println(describe<::std::float64_t>());
::fast_io::io::println(describe<no_size>());
::fast_io::io::println("element_count<no_size>=", element_count<no_size>());
::fast_io::io::println("element_count<string_view>=", element_count<::std::string_view>());
::fast_io::io::println("noexcept(check_nonzero<int>)=", noexcept(check_nonzero<::std::int_least32_t>(1)));
::fast_io::io::println("noexcept(check_nonzero<float64_t>)=", noexcept(check_nonzero<::std::float64_t>(1.0)));
check_nonzero<::std::int_least32_t>(7);
::fast_io::io::println("check_nonzero<int>(7) returned");
try
{
check_nonzero<::std::int_least32_t>(0);
}
catch throws (::std::error)
{
::fast_io::io::println("zero rejected");
}
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
integral
floating point
something else
element_count<no_size>=1
element_count<string_view>=8
noexcept(check_nonzero<int>)=0
noexcept(check_nonzero<float64_t>)=1
check_nonzero<int>(7) returned
zero rejected
处理器收到的是 [posix](22)Invalid argument,即标准为
::std::errc::invalid_argument 给出的文本;程序只打印自己的消息,
好让上面的运行记录只有一条流。
7. 经验法则
- 分支取决于类型、且未被选中的分支无法编译时用
if constexpr; 取决于值时用普通if。 - 绝不把
if constexpr (true)当成#ifdef的替代品, 那会让死代码看起来是活的。 - 用
throws(condition)表达随实例化变化的错误契约,而不是把函数拆成两个。throws形式写明了条件,也允许noexcept(...)去回答它。 - 转发包装函数用
throws(!noexcept(...))镜像被包装调用的契约; 不要声称自己证明不了的noexcept。 - 长长的
if constexpr链应当换成概念加重载:重载按包含关系选择, 诊断信息也更好(第15.3章)。