可变参数模板在C++17中的增强

基本内容

可变参数模板, 称作variadic, 在C++11中就引入了, 但是那个时候需要人工编写递归表达式. 在C++中对此进行了增强. 因此, 在绝大多数情况下, 不需要再通过人工编写递归表达式, 简化了开发.

比如, 下面这个求和的函数:

1
2
3
4
5
template<typename... T>
auto foldSum(T... args)
{
return (... + args);
}

在C++17之前, 我们需要这么实现:

1
2
3
4
5
6
7
8
9
10
11
template<typename... T>
auto foldSum(T arg)
{
return arg;
}

template<typename T1, typename... Ts>
auto foldSum(T1 arg1, Ts... args)
{
return arg1 + foldSum(args);
}

不过就我个人来看, 其实还是老的模式更容易理解一些. 新的方式下还要记住左折叠, 右折叠, 等等一堆的限制, 反倒不如老的递归模式更清晰, 写起来更可靠.

其展开后的形式是(((arg1+arg2)+arg3)+...).
注意的一点是, return的表达式的括号一定不能省略. 省略了编译器会报错

两种单目折叠形式: 左折叠和右折叠

左折叠, 例如 (... op args), 其展开形式为: ((arg1 op arg2) op arg3) op ...

右折叠, 例如 (args op ...), 其展开形式为: arg1 op (arg2 op ...(argN-1 op argN))

这里要注意的一点是, 第一个被计算的参数的类型, 一定是要能够支持op操作符的. 不然就会出错. 例如, 上面的例子来说, 下面的代码, 对于上面的foldSum()函数(我们是左折叠的实现),

std::cout << foldSum(std::string("hellow"), "world", "!") << endl;

能够编译成功, 而对于下面的代码:

std::cout << foldSum("hellow", "world", std::string("!")) << endl;

就编译失败. 道理也很简单:

对左折叠: ... + args的模板,
第一个代码被展开为: (std::string("hellow) + "world") + "!",
第二个代码被展开为: ("hellow" + "world") + std::string("!"), const char*不支持+, 所以编译失败.

对空参数包的处理

翻译有些令人迷惑, 需要有时间去实测一下

  • 如果操作符为&&, 结果为true
  • 如果操作符为||, 结果为false
  • 如果操作符为,, 结果为void
  • 所有其他情况, 都是错误

它的意思, 应该是, 比如, 对于前面的foldSum(...)函数, 下面的调用形式foldSum()会编译错误的.

所以C++17中还定义了双目折叠:

双目折叠形式:

同样, 双目折叠也分成左折叠和右折叠两种:

左折叠 (value op ... op args), 展开为 (((value op arg1) op arg2) op arg3) op ...

右折叠 (args op ... op value), 站卡为 arg1 op (arg2 op ...(argN op value)))

这样, 我们可以强制要求定义函数foldSum()必须有一个参数.

作为一个普遍的原则, 在大多数情况下, 左折叠的形式更符合我们阅读代码的习惯, 因此, 在没有忒别限制的情况下, 使用左折叠的方式具有更好的可读性.

示例: 打印被分隔符隔开的参数列表

例如, 在控制台上打印hellow 42 world

知识点: 如何在参数中间增加分隔符的打印

方法1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
const T& spaceBefore(const T& arg)
{
std::cout << ' ';
return arg;
}

template <typename T1, typename ... Ts>
void print1(const T1& arg1, const Ts&... args)
{
std::cout << arg1;
(std::cout << ... << spaceBefore(args)) << '\n';
}

方法2:

将上面的辅助函数改为lambda:

1
2
3
4
5
6
7
8
9
10
11
template <typename T1, typename... Ts>
void print2(const T1& arg1, const Ts&... args)
{
std::cout << arg1;
auto spaceBefore = [](const auto& arg){
std::cout << ' ';
return arg;
};

(std::cout << ... << spaceBefore(args)) << '\n';
}

方法3:

方法2中的lambda缺省返回了一个临时对象, 这意味着需要创建一个不必要的临时对象. 我们可以修改其实现, 让它返回参数的引用. 我们可以使用const auto&decltype(auto):

1
2
3
4
5
6
7
8
9
10
11
template <typename T1, typename... Ts>
void print3(const T1& arg1, const Ts&... args)
{
std::cout << arg1;
auto spaceBefore = [](const auto& arg) -> const auto& {
std::cout << ' ';
return arg;
};

(std::cout << ... << spaceBefore(args)) << '\n';
}

方法4:

我们也可以把lambda写到一个表达式里面:

1
2
3
4
5
6
7
8
9
template <typename T1, typename... Ts>
void print4(const T1& arg1, const Ts&... args)
{
std::cout << arg1;
(std::cout << ... << [](const auto& arg) -> decltype(auto){
std::cout << ' ';
return arg;
}(args) ) << '\n';
}

这个就是炫技了, 除了让读代码的人费劲, 没啥好处.

方法5:

下面其实是最有价值的做法. 使用一个lambda来打印空格和参数. 这种做法具有普遍的使用价值, 对于很多其他的场景也可以参照. 而前面几种方法都很特化.

1
2
3
4
5
6
7
8
9
10
template <typename T1, typename... Ts>
void print5(const T1& arg1, const Ts&... args)
{
std::cout << arg1;
auto outWithSpace = [](const auto& arg) {
std::cout << ' ' << arg;
};
(..., outWithSpace(args));
std::cout << "\n";
}

比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename... Ts, ApiMethodType=ApiMethodType::putMethod>
DbModifier(KaryoServices* service, Ts... args)
: _service{service}
{
QString result ;
if constexpr (cmd==ApiCmdType::modifyCase)
{
result = "api/case/";
}
else if constexpr (cmd==ApiCmdType::modifySlide)
{
result = "api/slide/";
}
else if constexpr (cmd==ApiCmdType::modifyMitosis)
{
result = "api/mitosis/";
}
auto h = [](const auto& x){ return QString("%1/").arg(x); };
_path = (result + ... + h(args));
}
这段代码用于拼装Restful的api字符串, 在参数之间添加分隔符'/'. 

示例: 实现回调函数

这是一个最简单的回调框架( 类似于Python中的装饰器? ). 我们可以在它的前后插入各种插装动作, 手工实现各种策略. 本质上, C++的面向策略编程, 大概率就是基于这种方式进行的.

1
2
3
4
5
inline auto callback_template = [](auto&& func, auto&&... params)
{
//
std::forward<decltype(func)>(func)(std::forward<decltype(params)>(params)...);
};

示例: 自动调用所有基类的成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template <typename... Bases>
class MultiBase : private Bases...
{
public :
void print()
{
// 依次调用每个基类的print()函数:
(..., Bases::print());
}
};

struct A {
void print() { std::cout << "A::print()\n"; }
};

struct B {
void print() { std::cout << "B::print()\n"; }
};

struct C {
void print() { std::cout << "C::print()\n"; }
};

int main()
{
//print5("Hellow", "World", 2023, "!");
std::cout << "\ncall mb1::print(): " << std::endl;
MultiBase<A, B, C> mb1;
mb1.print();
std::cout << "\ncall mb2::print(): " << std::endl;
MultiBase<C, B, A> mb2;
mb2.print();
}

可以看到, 调用基类的顺序和类声明的顺序是完全一致的, 在里面, 代码
(..., Bases::print());
被展开为:
(A::print(), B::print()), C::print();

示例: 组合哈希计算函数

下面的例子, 同样利用折叠表达式来计算多个参数的哈希值.

这个实现能够一定程度上简化, 但是并不能自动化结构成员的哈希计算. 这个要求可能太高, 即使有了反射, 我们还需要有一种描述手段来通知编译器哪个成员不需要哈希计算. 但是这是自动化的第一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
void hashCombine(std::size_t& seed, const T& val)
{
seed ^= std::hash<T>()(val) + 0x9e3779b6 + (seed << 6) + (seed >> 2);
};

template<typename... Ts>
std::size_t combinedHashValue(const Ts&... args)
{
std::size_t seed = 0;
(..., hashCombine(seed, args));
return seed;
};

这样测试:

1
2
3
std::size_t a{combinedHashValue(std::string("Hellow"), std::string("world"), 2023)};

std::cout << a;

注意, 这里, 不支持字符串指针的方式, 必须传过去std::string, 可能是MSVC的编译器的问题?

使用折叠表达式在二叉树中使用->*来遍历路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Node
{
int value;
Node* left;
Node* right;
Node(int i = 0) : value{ i }, left{ nullptr }, right{ nullptr } {}
// 其他内容...
};

auto left = &Node::left;
auto right = &Node::right;

template<typename T, typename... TP>
Node* traverse(T np, TP... paths)
{
return (np->* ...->*paths);
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void test_FoldTraverse()
{
//构造一个树:
Node* root = new Node{ 0 };
root->left = new Node{ 1 };
root->right = new Node{ 2 };
root->left->left = new Node{ 3 };
root->left->right = new Node{ 4 };
root->right->left = new Node{ 5 };

auto node = traverse(root, left, left);
std::cout << "(root->left->left) is: " << node->value << std::endl;
std::cout << "(root->right->left) is: " << traverse(root, right, left)->value << std::endl;
}

在type traits中使用折叠表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <type_traits>

template <typename T1, typename... Ts>
struct IsHomogeneous
{
static constexpr bool value = (std::is_same<T1, Ts>::value && ...);
};

template <typename T1, typename... Ts>
constexpr bool isHomogeneous(T1, Ts...)
{
return (std::is_same<T1, Ts>::value && ...);
}

对于下面的代码:

1
auto a1 = IsHomogeneous<int, std::size_t, decltype(42)>::value;

会被展开为:

1
std::is_same<int, std::size_t>::value && std::is_save<int, decltype(42)>::value

同样, 函数调用

1
isHomogeneous(43, -1, "hello");

会被展开为

1
std::is_same<int, int>::value && std::is_same<int, const char*>::value