第15.6章 变参模板、折叠表达式、完美转发
1. 同一个东西的零个或多个
::fast_io::io::println(1, "two", 3.0, 'x') 接四个实参、四种类型,却是同一个
函数。这是一个参数包:
template <typename... Ts>
inline void println(Ts const &...args) throws;
typename... Ts声明一个类型包:零个或多个类型。Ts const &...args声明一个函数形参包:零个或多个形参, 每个Ts一个。sizeof...(Ts)就是个数,是编译期常量。args...是包展开:把省略号前面的模式按元素重复。
C++17 之前,变参模板只能靠递归消费 —— 每个元素一层实例化,外加一个终止的基例。 折叠表达式消灭了递归,也消灭了绝大部分实例化。
2. 折叠表达式
折叠把二元运算符施加到整个包上。四种形状:
| 形状 | 含义 |
|---|---|
(args + ...) |
一元右折叠:a + (b + (c + d)) |
(... + args) |
一元左折叠:((a + b) + c) + d |
(args + ... + 0) |
二元右折叠,带初值:空包给出 0 |
(0 + ... + args) |
二元左折叠,带初值 |
初值很要紧:对空包的一元折叠只有 &&、||、,
合法。写成 (args + ...) 的 sum() 编译不过;
(args + ... + 0) 返回 0,而且类型也稳定(是
int,不是编译错误):
template <typename... Ts>
[[nodiscard]] inline auto sum(Ts... args) noexcept
{
return (args + ... + 0);
}
折叠也适用于你自己写的运算符,以及概念。下面的 && 折叠就是
“这些类型是不是全为整型”的写法,不需要递归特征:
template <typename... Ts>
concept all_integral = (::std::integral<Ts> && ...);
3. 活下来的递归:用 if constexpr 写基例
有些包折叠不了,因为运算不满足结合律,或者需要提前停下。max_of
是标准例子:答案取决于每个元素与“其余部分最大值”的比较。
template <typename first, typename... rest>
[[nodiscard]] inline constexpr auto max_of(first head, rest... tail) noexcept
{
if constexpr (sizeof...(tail) == 0)
{
return head;
}
else
{
auto const rest_max{max_of(tail...)};
return head < rest_max ? rest_max : head;
}
}
让递归终止的是 if constexpr:包为空时 else 分支被丢弃,
因此永远不会实例化一个零实参的 max_of()。普通 if
根本编译不过 —— 两个分支都必须合法
(第15.8章)。
注意 noexcept:比较数字不会失败,而且这个承诺对所有实例化都成立。
若某个元素的 operator< 可能抛,整个函数就必须是 throws。
4. 完美转发:不要丢掉值类别
一个把实参继续往下传的变参函数,必须为每个实参保住两样东西:类型与值类别。
用 Ts const& 接会丢掉后者 —— 一切都变成左值并产生拷贝。
解法是 Args&& 加 ::std::forward:
template <typename T, typename... Args>
[[nodiscard]] inline T *create(Args &&...args) throws
{
auto const res{::fast_io::native_global_allocator::allocate_aligned_at_least(alignof(T), sizeof(T))};
return ::new (static_cast<void *>(res.ptr)) T(::std::forward<Args>(args)...);
}
Args&&逐元素都是转发引用 (第15.5章): 左值推出U&,右值推出U。::std::forward<Args>(args)从推出的类型恢复值类别, 于是左值仍以左值传下去,右值仍以右值传下去。std::forward<Args>(args)...里args后面的...展开整个模式,每个实参保住自己的类型。
这不是理论差别。一个统计自己拷贝与移动次数的类型各用一行就能证明 —— 传左值:一次拷贝、零次移动;传右值:零次拷贝、一次移动:
struct probe
{
::std::size_t copies{};
::std::size_t moves{};
probe() noexcept = default;
probe(probe const &other) noexcept : copies{other.copies + 1}, moves{other.moves} {}
probe(probe &&other) noexcept : copies{other.copies}, moves{other.moves + 1} {}
};
把 ::std::forward 换成朴素的 args...,右值那一格就变成第二次拷贝
—— 这就是“为什么我那个只能移动的类型移不动”这个 bug。
5. 把包展开到一次调用上
包最常见的用途不是算术,而是转交给别的东西:
template <typename... Ts>
inline void print_each(Ts const &...args) throws
{
(::fast_io::io::println(args), ...);
}
这是对逗号运算符的折叠,模式里出现了 args,于是展开成每个元素一次
println,顺序保持。逗号是能对空包合法折叠的三个运算符之一,所以
print_each() 能编译并且什么都不做 —— 这正是你要的。
这里的 println 是 throws 那个,所以
print_each 也是 throws:错误契约会沿调用链传染,
用 noexcept 假装不是,只会在管道断掉时调用
::std::terminate(第3.16章)。
6. 完整程序
#include <fast_io.h>
#include <cstddef>
#include <string_view>
#include <type_traits>
#include <utility>
using namespace ::std::string_view_literals;
template <typename... Ts>
[[nodiscard]] inline auto sum(Ts... args) noexcept
{
return (args + ... + 0);
}
template <typename... Ts>
[[nodiscard]] inline constexpr ::std::size_t count_of(Ts...) noexcept
{
return sizeof...(Ts);
}
template <typename first, typename... rest>
[[nodiscard]] inline constexpr auto max_of(first head, rest... tail) noexcept
{
if constexpr (sizeof...(tail) == 0)
{
return head;
}
else
{
auto const rest_max{max_of(tail...)};
return head < rest_max ? rest_max : head;
}
}
template <typename... Ts>
concept all_integral = (::std::integral<Ts> && ...);
template <typename... Ts>
inline void print_each(Ts const &...args) throws
{
(::fast_io::io::println(args), ...);
}
struct probe
{
::std::size_t copies{};
::std::size_t moves{};
probe() noexcept = default;
probe(probe const &other) noexcept : copies{other.copies + 1}, moves{other.moves} {}
probe(probe &&other) noexcept : copies{other.copies}, moves{other.moves + 1} {}
};
template <typename T, typename... Args>
[[nodiscard]] inline T *create(Args &&...args) throws
{
auto const res{::fast_io::native_global_allocator::allocate_aligned_at_least(alignof(T), sizeof(T))};
return ::new (static_cast<void *>(res.ptr)) T(::std::forward<Args>(args)...);
}
int main()
{
try
{
::fast_io::io::println(sum(1, 2, 3, 4));
::fast_io::io::println(count_of(1, 2.5, "three"sv));
::fast_io::io::println(max_of(1, 4, 2));
::fast_io::io::println("all_integral of int,char=", all_integral<int, char>);
probe p{};
probe *a{create<probe>(p)};
probe *b{create<probe>(probe{})};
::fast_io::io::println("lvalue: copies=", a->copies, " moves=", a->moves);
::fast_io::io::println("rvalue: copies=", b->copies, " moves=", b->moves);
print_each(1, "two"sv, 3);
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
10
3
4
all_integral of int,char=1
lvalue: copies=1 moves=0
rvalue: copies=0 moves=1
1
two
3
7. 经验法则
- 先想折叠,再想递归变参模板。每一层递归都是一次实例化。
- 空包有意义时,永远把初值写上(
(args + ... + 0))。 - 把包往下传时是转发,不是拷贝:
Args&&...配::std::forward<Args>(args)...。 - 只包装一次调用的变参函数应当
inline且只有一行;再长的内容属于非模板的辅助函数 (第15.9章)。 - 不要把
char或小整数包折叠在+、*上而不想清楚整型提升: 累积发生在int里。