现代c++模板元编程:遍历tuple
tuple是c++11新增的数据结构,通过tuple我们可以方便地把各种不同类型的数据组合在一起。有了这样的数据结构我们就可以轻松模拟多值返回等技巧了。
tuple和其他的容器不同,标准库没有提供适用于tuple的迭代器,也没有提供tuple类型的迭代接口。所以当我们想要遍历tuple的时候只能自己动手了。
所以这篇文章我们会实现一个简单的接口用来遍历各种tuple,顺便一窥现代c++中的模板元编程。
本文索引
接口设计
为什么要遍历tuple呢?通常我们确实不需要逐个遍历tuple的数据,通过使用get取出特定位置的元素就满足大部分的应用需求了。
但偶尔我们也会想要把某一个泛型算法应用到tuple的每一项成员上,虽然不多见但也确实有需求的场景存在。因此如何实现对tuple的遍历就被摆上了议程。
然而遗憾的是get需要的索引只能是编译期常量,这导致我们无法依赖现有的循环语句去实现索引的递增,因此只有两条路可供选择:硬编码每一项索引和模板元编程。我是个不喜欢硬编码的人,所以我选择了后者。
把STL里的通用容器算法实现一遍工程量太大了,而且很明显一篇文章也讲不完。我决定实现标准库里的for_each
,正好也契合今天的主题——遍历tuple。
标准库的for_each
是这样的template <class Iterator, class UnaryFunction> void for_each(Iterator first, Iterator last, UnaryFunction f)
,其中UnaryFunction
的函数签名是void fun(const Type &a)
。
通过for_each
我们可以顺序遍历容器中的每一项数据,我们的for_each_tuple
也将实现类似的功能——顺序遍历tuple的每一项元素。
不过前面已经提到了,tuple是没有迭代器的,因此我们的函数得改个样子:template <class Tuple, class Functor> void for_each_tuple(const Tuple &, Functor &&)
。因为不能使用迭代器,所以我们传了tuple的引用进函数。
当然,c++17里tuple是constexpr类型,所以你还可以给我们的for_each
加上constexpr。
函数内部要做的事其实也很简单,就是对每一项元素调用f即可,在这里我们不考虑其他一些细节,我们的接口形式上应该是这样子的(伪代码):
template <class Tuple, class Functor>
constexpr void for_each_tuple(const Tuple &t, Functor &&f)
{
for element in t {
f(t);
}
}
实现接口
接口设计好了,下面我们就该实现for element in t
的部分了。
接下来我会介绍三种实现遍历tuple的方法,以及一种存在缺陷的方法,首先我们从最原始的方案开始。
初步尝试
距离c++11发布已经快整整十年了,想必大家也习惯于书写c++11的代码了。不过让我们把时间倒流回c++11前的时代,那时候既没有constexpr,也没有变长模板参数,相当的原始而蛮荒。
那么问题来了,那时候有tuple吗?当然有,boost里的tuple的历史在c++11前就已经开始了。
其中的秘诀就在于模板递归,这是一种经典的元编程手段,解铃还须系铃人,我们的foreach也需要借助这种技术。
现在我们来看一下不使用编译期计算和变长模板参数的原始方案:
template <typename Tuple, typename Functor, int Index>
void for_each_tuple_impl(Tuple &&t, Functor &&f)
{
if (Index >= std::tuple_size<std::remove_reference_t<Tuple>>::value) {
return;
} else {
f(std::get<Index>(t));
for_each_tuple_impl<Tuple, Functor, Index+1>(std::forward<Tuple>(t), std::forward<Functor>(f));
}
}
template <typename Tuple, typename Functor>
void for_each_tuple(Tuple &&t, Functor &&f)
{
for_each_tuple_impl<Tuple, Functor, 0>(std::forward<Tuple>(t), std::forward<Functor>(f));
}
我们用std::remove_reference_t
来把Tuple从引用类型转化为被引用的tuple的类型,原因是模板函数的右值引用参数会自动根据引用折叠的规则转换为左值引用或者右值引用,而我们不能从引用类型调用std::tuple_size
获取tuple的长度。
整体的思路其实很简单,我们从0开始,每遍历处理完一项就让index+1,然后递归调用impl。如果了最后一个元素+1的位置,函数就返回。这样遍历就结束了。
注意f上的std::forward
,我们用右值引用的目的是接受包括lambda在内的所有可调用对象,这些对象可以是一个lambda字面量,可以是一个具名的存储了lambda的变量,还以可以是函数指针或者任何重载了template <typename T> void operator()(const T&)
运算符的类的实例。所以我们很难假设这么广范围内的可调用对象都是可以被复制的,所以保险起见我们使用了模板的右值引参数来将不可以复制的内容用右值引用捕获。当然因为移动语义会产生副作用,这点用户得自己负担,而我们也不得不对f使用std::forward
进行完美转发。不过这样好处也不是没有,至少我们省去了很多不必要的复制。
然而当你满心欢喜地准备尝试运行自己杰作的时候,编译器给你浇了一头冷水:
...
/usr/include/c++/10.2.0/tuple:1259:12: fatal error: template instantiation depth exceeds maximum of 900 (use '-ftemplate-depth=' to increase the maximum)
1259 | struct tuple_element<__i, tuple<_Head, _Tail...> >
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated.
报了一大堆错,甚至超过了屏幕的最大滚动高度(我设置的是10000行)。发生了什么呢?
稍微翻翻报错信息,我们发现了实际上是模板递归超过了允许的最大深度。可是我们不是已经给出了退出递归的条件了吗?
让我再来看看impl的代码:
template <typename Tuple, typename Functor, int Index>
void for_each_tuple_impl(Tuple &&t, Functor &&f)
{
if (Index >= std::tuple_size<std::remove_reference_t<Tuple>>::value) {
return;
} else {
f(std::get<Index>(t));
// 注意下面这行
for_each_tuple_impl<Tuple, Functor, Index+1>(std::forward<Tuple>(t), std::forward<Functor>(f));
}
}
编译器在编译函数的时候是需要把所有条件分支都编译的,所以即使是在函数模板的实例达到退出递归的条件,else分支仍然会被编译,而在这个分支里模板会被不断递归实例化,最终超过允许的最大递归深度。
这里就引出了模板递归的一个重要规则:我们应该用模板特化或是函数重载来实现递归的终止条件
然而在这里我们既不是模板的特化也没有调用重载函数。
如果想利用函数重载的话并不现实,因为递归函数调用的形式是相同的,无法针对tuple的最后一个元素进行特殊处理。
而函数模板不支持部分特化,所以我们也很难实现一个针对tuple结尾的特化版本。
那怎么办呢?
通用的古典实现
既然函数模板不能满足要求,我们使用类模板不就行了。只要重载了operator()
,使用起来也没多少区别。
所以一个真正通用的古典实现可以写出下面这样:
template <typename Tuple, typename Functor, std::size_t Start, std::size_t End>
struct classic_for_each_tuple_helper
{
constexpr void operator()(const Tuple &t, Functor &&f) const
{
f(std::get<Start>(t));
classic_for_each_tuple_helper<Tuple, Functor, Start + 1, End>{}(t, std::forward<Functor>(f));
}
};
我们首先实现了主模板,其中Start和End是tuple开始和结束的索引。每处理一个元素,我们就让Start加上1。
你可以想一想这里递归的停止条件是什么。
我们每次给Start递增1,那么最后我们的Start一定会等于甚至超过End。没错,这就是我们的停止条件:
template <typename Tuple, typename Functor, std::size_t End>
struct classic_for_each_tuple_helper<Tuple, Functor, End, End>
{
constexpr void operator()(const Tuple &t, Functor &&f) const
{
f(std::get<End>(t));
}
};
我们没办法在模板参数列表里判断相等,那么最好的解决办法就是特化出Start和End都一样的特殊情况,这时候用一样的值End同时填入主模板的Start和End就行了。
特化的处理也很简单,我们直接把递归的语句删了就可以了。
想要使用这个帮助模板还需要一点代码,因为我可不想每次手动指定一大长串的tuple类型参数。
正好,利用函数模板我们可以自动进行类型推导:
template <typename Tuple, typename Functor>
constexpr void classic_for_each_tuple(const Tuple &t, Functor &&f)
{
classic_for_each_tuple_helper<Tuple, Functor, 0, std::tuple_size_v<Tuple> - 1>{}(t, std::forward<Functor>(f));
}
这样我们就可以书写如下的代码了:
classic_for_each_tuple(std::make_tuple(1, 2, 3, "hello", "world", 3.1415, 2.7183),
[](const auto &element) { /* work */ });
即使是make_tuple
生成的临时对象,我们也可以自动推导出它的类型,所有粗活累活编译器都帮我们代劳了。
不过凡事总是有代价的,有得必有失。表面上我们实现了简单而漂亮的接口,但代价实际上是被转移到了底层:
$ nm a.out | grep classic_for_each_tuple_helper
00000000000031d6 t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm0ELm6EEclERKS3_OS7_
00000000000034f0 t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm1ELm6EEclERKS3_OS7_
00000000000036ca t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm2ELm6EEclERKS3_OS7_
0000000000003946 t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm3ELm6EEclERKS3_OS7_
0000000000003a66 t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm4ELm6EEclERKS3_OS7_
0000000000003b94 t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm5ELm6EEclERKS3_OS7_
0000000000003c0e t _ZNK29classic_for_each_tuple_helperISt5tupleIJiiiPKcS2_ddEEZ4mainEUlRKT_E2_Lm6ELm6EEclERKS3_OS7_
我们的tuple有6个元素,所以我们生成了6个helper的实例。过多的模板实例会导致代码膨胀。
模板递归的另一个缺点是递归的最大深度有限制,在g++10.2上这个限制是900,也就是说超过900个元素的tuple我们是无法处理的,除非用编译器的命令行选项更改这一限制。不过通常也没人会写出有900多个元素的tuple。
还有一个需要考虑的情况,当我们传递了一个空的tuple进去会发生什么呢?
classic_for_each_tuple(std::tuple<>{},
[](const auto &element) { /* work */ });
我们会得到一个编译错误,而我们所期望的是foreach什么也不做。问题发生在std::tuple_size_v<Tuple> - 1
,当tuple为空时size为0,而对无符号数的0减去1会导致回环,从而导致get使用的索引的范围十分巨大,超过了模板递归深度限制;而更致命的是get一个无效的索引(tuple为空,任何索引都无效)是被static_assert
断言的编译期错误,并且往往会产生一大长串错误信息导致debug困难。
不过别担心,这是个小问题,解决起来也不麻烦,还记得我们的模板元编程技巧吗?用重载或特化表示边界条件:
template <typename Functor>
constexpr void classic_for_each_tuple(const std::tuple<> &, Functor &&)
{
// 什么也不做
}
如此一来空的tuple也不会导致问题了。
虽然有些缺点,还需要工具类模板来实现遍历,但这是旧时代的c++实现for element in t
的唯一选择。
使用编译期条件分支
好消息是现在是现代c++的天下了,我们可以简化一下代码。
比如使用c++17提供的编译期间计算的条件分支。一般形式如下:
if constexpr (编译期常量表达式) {
work 1
} else {
work 2
}
constexpr if
最大的威力在于如果条件表达式为真,那么else里的语句根本不会被编译,反之亦然。当然这得是在模板里,否则else分支的代码仍然会被编译器检查代码的语法正确性。
没错,我们在最开始遇到的问题就是if和else里的语句都会被编译,导致了模板的无限递归,现在我们可以用constexpr if
解决问题了:
template <typename Tuple, typename Functor, int Index>
constexpr void for_each_tuple_impl(Tuple &&t, Functor &&f)
{
if constexpr (Index >= std::tuple_size<std::remove_reference_t<Tuple>>::value) {
return;
} else {
f(std::get<Index>(t));
for_each_tuple_impl<Tuple, Functor, Index+1>(std::forward<Tuple>(t), std::forward<Functor>(f));
}
}
template <typename Tuple, typename Functor>
constexpr void for_each_tuple(Tuple &&t, Functor &&f)
{
for_each_tuple_impl<Tuple, Functor, 0>(std::forward<Tuple>(t), std::forward<Functor>(f));
}
这次当遍历完最后一个元素后函数会触发退出递归的条件,if constexpr会帮我们终止模板的递归。问题被干净利落地解决了。
对于空tuple这个方案是如何处理的呢?答案是tuple为空的时候直接达到了impl的退出条件,所以是安全的noop。
虽然代码被进一步简化了,但是模板递归的两大问题依旧存在。
变长模板参数——错误的解法
现代c++有许多简化模板元编程的利器。如果说前面的constexpr if
是编译期计算和模板不沾边,那下面要介绍的变长模板参数可就是如假包换的模板技巧了。
顾名思义,变长模板参数可以让我们在模板参数上指定任意数量的类型/非类型参数:
template <typename... Ts>
class tuple;
上面的就是c++11中新增的tuple的定义,通过变长模板参数使得tuple支持了任意多的类型不同的元素。
想要处理变长模板参数,在c++17之前还是得靠模板递归。所以我们是不是可以用变长模板参数获取tuple里每一个元素的类型呢?正好get也可以根据元素的类型来获取相应的数据。
于是新的实现产生了:
template <typename Tuple, typename Functor, typename First, typename... Ts>
constexpr void for_each_tuple2_impl(const Tuple& t, Functor &&f)
{
f(std::get<First>(t));
for_each_tuple2_impl<Tuple, Functor, Ts...>(t, std::forward<Functor>(f));
}
template <typename Tuple, typename Functor>
constexpr void for_each_tuple2_impl(const Tuple&, Functor &&)
{
return;
}
template <typename Functor, typename... Ts>
constexpr void for_each_tuple2(const std::tuple<Ts...> &t, Functor &&f)
{
for_each_tuple2_impl<std::tuple<Ts...>, Functor, Ts...>(t, std::forward<Functor>(f));
}
代码有些复杂,我会逐步讲解。
首先我们有两个for_each_tuple2_impl
,不过别紧张,因为模板形参不同,所以这是两个不同的模板(函数模板没有部分特化)。又因为变长参数的实参数量可以为0,为了实例化的时候不会产生歧义,只能让第二个for_each_tuple2_impl
不接受任何额外的模板参数。
接着我们看到for_each_tuple2
,它的作用很简单,通过参数上的std::tuple<Ts...>
自动推导出tuple元素的所有类型,然后存放在Ts里。习惯上我们给变长参数包的名字是以s结尾的,象征参数包里可能有不止一个类型参数。
接下来才是重头戏。当我们这样调用for_each_tuple2_impl<std::tuple<Ts...>, Functor, Ts...>
时,实际上会展开成for_each_tuple2_impl<std::tuple<Type1, Type2, ..., TypeN>, Functor, Type1, Type2, ..., TypeN.>
。
对应到我们的template <typename Tuple, typename Functor, typename First, typename... Ts>
, First就会是Type1,而其他剩下来的类型又会被收集到for_each_tuple2_impl
的Ts里,这样我们就分离出了第一次个tuple里的元素。
然后我们使用std::get<First>(t)
获取到这个元素,然后递归重复上述步骤。
Ts的第一个参数会被逐个分离,最后一直到Ts和First都为空,这是递归就该结束了,所以我们写出了第二个for_each_tuple2_impl
模板来处理这一情况。
因为tuple的类型参数列表的顺序和其中包含元素是对应的,所以我们可以实现遍历。
到目前为止我们的for_each工作得很好,然而当我们传入了std::make_tuple(1,2,3)
,编译器又一次爆炸了。
好奇的你一定又在思考为什么了。不过这回你应该很快就有了头绪,tuple<int,int,int>
,存在一样的类型参数,这时候std::get
会不会不知道该获取的是哪个元素呢?
你猜对了,get的文档里是这么说的Fails to compile unless the tuple has exactly one element of that type.
,意思是当某个类型A出现了不止一次时,使用get<A>
会导致编译出错。
因此这个方案是有重大缺陷的,我们不能保证tuple里总是不同类型的数据。因此这条路走到死胡同里了。
折叠表达式——使用变长模板参数的正确解法
别气馁,尝试失败也是模板元编程的乐趣之一。更何况现代c++里有相当多的实用工具可以加以利用,比如integer_sequence
和折叠表达式。
折叠表达式用于按照给定的模式展开变长模板参数包,而integer_sequence
则可以用来包含0-N的整数类型非类型模板参数,在我上一篇介绍模板元编程的文章里有介绍,这里不再赘述。
使用integer_sequence
可以构建一个包含所有tuple元素索引的编译期整数常量序列,配合折叠表达式可以把这些索引展开利用,这样正好可以让get用上每一个索引:
template <typename Tuple, typename Functor, std::size_t... Is>
constexpr void for_each_tuple3_impl(const Tuple &t, Functor &&f, std::index_sequence<Is...>)
{
// 展开成(f(std::get<0>(t)),f(std::get<1>(t)),...)
(f(std::get<Is>(t)), ...);
}
template <typename Tuple, typename Functor>
constexpr void for_each_tuple3(const Tuple &t, Functor &&f)
{
// std::make_index_sequence<std::tuple_size_v<Tuple>>产生一个index_sequence<0,1,2,..,N>
for_each_tuple3_impl(t, std::forward<Functor>(f), std::make_index_sequence<std::tuple_size_v<Tuple>>());
}
这次不再是模板递归了,我们生成了所有元素的索引,然后教编译器硬编码了所有的get操作,形式上不太像但确确实实完成了遍历操作。
当然老问题是少不了要问的,tuple为空的时候这个方案能正常工作吗?
答案是肯定的,标准规定了std::make_index_sequence<0>
会生成一个空的序列,而逗号运算符的一元折叠表达式对于空的参数包会安全地返回void,所以在传入一个空tuple时我们的函数是noop的。
这种方案简单粗暴,同时也是三种方法中最直观的。
而且这个方案不会产生一大堆的模板实例,生成的二进制文件也是清爽干净的。同时因为不是递归,也不会受到递归深度限制的影响。
这就是现代c++在模板元编程上的威力。
总结
我们一共实现了三种遍历tuple的方法,从原始到现代,从复杂到简单。
同时我们还踩掉了一些坑,在今后的开发中只要留意类似的问题也能及时避免了。
当然,我写的方案仍有很大的提升空间,你可以自己进行尝试改进。
不过我最想说的还是现代c++真的极大简化了模板元编程,把模板元编程从一个复杂抽象的黑魔法变成了直观易于理解的开发技巧,应该有更多的人来体验使用现代c++的乐趣,c++已经脱胎换骨了。
现代c++模板元编程:遍历tuple的更多相关文章
- C++模板元编程(C++ template metaprogramming)
实验平台:Win7,VS2013 Community,GCC 4.8.3(在线版) 所谓元编程就是编写直接生成或操纵程序的程序,C++ 模板给 C++ 语言提供了元编程的能力,模板使 C++ 编程变得 ...
- AutoSharedLibrary -- 基于模板元编程技术的跨平台C++动态链接载入库
基于模板元编程技术的跨平台C++动态链接载入库.通过模板技术,使用者仅需通过简单的宏,就可以使编译器在编译期自己主动生成载入动态链接库导出符号的代码,无不论什么额外的执行时开销. extern &qu ...
- 现代c++与模板元编程
最近在重温<c++程序设计新思维>这本经典著作,感慨颇多.由于成书较早,书中很多元编程的例子使用c++98实现的.而如今c++20即将带着concept,Ranges等新特性一同到来,不得 ...
- C++模板元编程 - 函数重载决议选择工具(不知道起什么好名)完成
这个还是基于之前实现的那个MultiState,为了实现三种类型“大类”的函数重载决议:所有整数.所有浮点数.字符串,分别将这三种“大类”的数据分配到对应的Converter上. 为此实现了一些方便的 ...
- C++模板元编程 - 挖新坑的时候探索到了模板元编程的新玩法
C++真是一门自由的语言,虽然糖没有C#那么多,但是你想要怎么写,想要实现什么,想要用某种编程范式或者语言特性,它都会提供. 开大数运算类的新坑的时候(又是坑),无意中需要解决一个需求:大数类需要分别 ...
- 读书笔记_Effective_C++_条款四十八:了解模板元编程
作为模板部分的结束节,本条款谈到了模板元编程,元编程本质上就是将运行期的代价转移到编译期,它利用template编译生成C++源码,举下面阶乘例子: template <int N> st ...
- c++ 模板元编程的一点体会
趁着国庆长假快速翻了一遍传说中的.大名鼎鼎的 modern c++ design,钛合金狗眼顿时不保,已深深被其中各种模板奇技淫巧伤了身...论语言方面的深度,我看过的 c++ 书里大概只有 insi ...
- C++模板元编程 - 3 逻辑结构,递归,一点列表的零碎,一点SFINAE
本来想把scanr,foldr什么的都写了的,一想太麻烦了,就算了,模板元编程差不多也该结束了,离开学还有10天,之前几天部门还要纳新什么的,写不了几天代码了,所以赶紧把这个结束掉,明天继续抄轮子叔的 ...
- C++模板元编程 - 2 模仿haskell的列表以及相关操作
这是昨天和今天写的东西,利用C++的可变模板参数包以及包展开,模式匹配的一些东西做的,感觉用typename...比轮子叔那个List<A,List<B, List<C, D> ...
随机推荐
- HDU 4335 What is N?(指数循环节)题解
题意: 询问有多少数\(n\)满足\(n^{n!}\equiv b\mod p \land\ n\in[1,M]\),数据范围:\(M\leq2^{64}-1,p\leq1e5\) 思路: 这题显然要 ...
- DOM型XSS
打开漏洞页面,随便输入点东西,发现没有啥东西. 但是我们发现我们输入的11,在面的herf 中 看到这儿就很简单了,我们只需要闭合一下,就可以构造出我们的payload了. '><img ...
- JDK下载安装与卸载详解
JDK下载安装 1. 下载: 推荐版本:JDK 8 (7.8目前广泛应用) 官网地址:https://www.oracle.com/cn/java/technologies/javase/javase ...
- /usr/lib/nvidia-384/libEGL.so.1 is not a symbolic link
记得要将384改为自己系统对应的a. sudo mv /usr/lib/nvidia-384/libEGL.so.1 /usr/lib/nvidia-384/libEGL.so.1.org sudo ...
- vue-cli emit webpack config
vue-cli emit webpack config 如何暴漏出 vue-cli 的 webpack 配置文件 refs xgqfrms 2012-2020 www.cnblogs.com 发布文章 ...
- how to input special symbol in macOS
how to input special symbol in macOS 如何在 macOS 中输入特殊符号 1024 ≈ 1000 2^10 == 1024 约等于 1000, 方便用来表示 Opt ...
- HTML5 download 执行条件
HTML5 download 执行条件 同一个域名下的资源 http only 绝对路径/相对路径 都可以 demo https://cdn.xgqfrms.xyz/ https://cdn.xgqf ...
- js to svg flowchart
js to svg flowchart flowchart https://flowchart.js.org/ https://github.com/adrai/flowchart.js https: ...
- 埋点 & 数据上报 & 数据异常处理
埋点 & 数据上报 & 数据异常处理 如何在用户关闭浏览器前面,发送请求 beforeunload unload https://developer.mozilla.org/en-US ...
- vue 使用stylus
λ yarn add stylus stylus-loader --dev <style scoped lang="stylus"> div color #ff4488 ...