前序文章请看:
C++模板元编程详细教程(之一)
C++模板元编程详细教程(之二)
C++模板元编程详细教程(之三)
C++模板元编程详细教程(之四)
C++模板元编程详细教程(之五)
C++模板元编程详细教程(之六)
C++模板元编程详细教程(之七)
如果读者对元组工具,也就是std::tuple
很熟悉的话,那么对std::get
也一定不陌生。我们可以通过std::get
的方式把原组的第N
个元素取出来。
但是,不知道大家有没有思考过这样一个问题,std::get
是静态模板工具,所以参数N
必须要编译期确定。那为什么STL不提供一个动态序号获取元组元素的能力呢?也就是说类似tu.get(n)
,其中n
是函数参数(运行期数据)。最主要的原因就在于类型不确定,假如说我们真的要给tuple
提供一个get
方法,那么方法的返回值是不确定的,于是这就成了一个RTTI(Run-Time Type Identification)方法。而STL是模板库,并不提供任何RTTI行为,自然也就不会提供类似的方法。
但假如,我们在实际开发中,针对某个元组,可以确定(或可以正确处理)它的类型,在这种情况下希望可以提供一个动态的get
工具应当如何来做?举例来说:
class Base {
public:
virtual void f() const = 0;
};
class Ch1 : public Base {
public:
void f() const override {}
};
class Ch2 : public Base {
public:
void f() const override {}
};
// 利用多态调用f方法
template <typename... Args>
std::enable_if_t<
std::conjunction_v<
std::is_base_of<Base, Args>...
>, void>
InvokeF(const std::tuple<Args...> &tup, size_t index) {
// 考虑这里应该怎么写
}
void Demo() {
// 一个元组,里面都是Base的子类对象
std::tuple tu{Ch1{}, Ch2{}, Ch1{}};
InvokeF(tu, 1); // 调用Ch2的f方法,注意这个参数1是运行期数据
}
解释一下上面例程,Ch1
和Ch2
都是Base
类的子类,InvokeF
函数是传入一个元组tup
和一个序号index
,我们要把tup
的第index
个元素拿出来,去调用它的f
方法,这里要求tup
的元素都必须是Base
的子类。
现在问题就在于,index
是一个运行期数据,我们用std::get
肯定是不可以的,那怎么办?只能采用编译期展开的方式。
具体来说就是,由于我们这里的元组类型是编译期确定的,那么对应的变参数量也就是确定的(比如上面例程中就是3个),那么,我们就在编译期生成分别应对假如index
就是这种情况,应当怎么去做。
以上面例程为例,由于编译期不知道index
可能是几,但针对std::tuple
这种类型的元组来说,index
只能是0
、1
、2
这3种情况时才可以有合法处理。所以,我们就分别写出当index
为0
、1
、2
时,应当做的处理。这个就叫做「编译期展开」,也就是在编译期枚举出运行时可能传入的所有数据,分别生成对应的代码指令,然后当运行期数据确定的时候去执行对应的指令。
如果手动来写,就应该写成:
template <typename... Args>
void InvokeF_0(const std::tuple<Args...> &tu) {
std::get<0>(tu).f();
}
template <typename... Args>
void InvokeF_1(const std::tuple<Args...> &tu) {
std::get<1>(tu).f();
}
template <typename... Args>
void InvokeF_2(const std::tuple<Args...> &tu) {
std::get<2>(tu).f();
}
template <typename... Args>
void InvokeF(const std::tuple<Args...> &tu, size_t index) {
if (index == 0) {
InvokeF_0(tu);
} else if (index == 1) {
InvokeF_1(tu);
} else if (index == 2) {
InvokeF_2(tu);
}
}
但这是我们假定元组是3个元素的情况。可实际场景下,元组的元素个数是不确定的,如何利用模板来生成呢?思路就是,逐个尝试,递归生成函数。请看代码:
// 辅助工具,一个用于尝试匹配的递归模板
template <int N, typename... Args>
void TryInvokeF(const std::tuple<Args...> &tup, size_t index) {
if constexpr (N < sizeof...(Args)) { // 递归终止条件(静态的)
// 尝试index是不是本次递归的
if (index == N) {
std::get<N>(tup).f(); // 如果符合,就取出元素,并调用f
return;
}
// 如果不符合,就生成下一个模板实例,再去判断是否符合
TryInvokeF<N + 1, Args...>(tup, index); // !!这一行是精华所在!!
}
}
// 利用多态调用f方法
template <typename... Args>
std::enable_if_t<
std::conjunction_v<
std::is_base_of<Base, Args>...
>, void>
InvokeF(const std::tuple<Args...> &tup, size_t index) {
// 如果index不在合法范围中,直接按照异常处理
if (index >= sizeof...(Args)) { // sizeof...是静态工具,用于获取变参个数
return;
}
TryInvokeF<0, Args...>(tup, index); // 从0开始尝试
}
void Demo() {
// 一个元组,里面都是Base的子类对象
std::tuple tu{Ch1{}, Ch2{}, Ch1{}};
InvokeF(tu, 1); // 调用Ch2的f方法,注意这个参数1是运行期数据
}
这里提供了一个辅助工具TryInvokeF
,其中的if constexpr
语句用于做编译期静态数据的判断,不符合条件的将不会生成代码。所以,这里我们要求N
不可以大于等于变参个数。对于上面例子来说,N
只能小于3
,而在InvokeF
中调用了TryInvokeF<0, Args...>
函数,因此模板实例化时会生成N
为0
时的情况,而TryInvokeF<0, Args...>
中调用了TryInvokeF<1, Args...>
,所以还会继续实例化。里面又调用了TryInvokeF<2, Args...>
,所以还会实例化。直至TryInvokeF<2, Args...>
中调用了TryInvokeF<3, Args...>
,而TryInvokeF<3, Args...>
里由于不满足if constexpr
的条件,所以到此为止。
也就是说,对于std::tuple
这个元组来说,编译器生成了TryInvokeF<0, Ch1, Ch2, Ch1>
,TryInvokeF<1, Ch1, Ch2, Ch1>
,TryInvokeF<2, Ch1, Ch2, Ch1>
和TryInvokeF<3, Ch1, Ch2, Ch1>
这4个函数实例。而最后一个TryInvokeF<3, Ch1, Ch2, Ch1>
是空的,前面的3个内部调用了后面的那个。
到了运行期,根据index
值的不同,会调用不同深度的函数,当发现N
和index
值相等时,递归结束。
请读者仔细体会模板元编程当中的「递归」,这里的递归并不是运行时的函数栈递归,而是模板实例化时的递归实例化,根据一个静态数值,递归实例化出了若干个函数。而进入运行期后,其实就不能算真正意义上的递归调用了(因为并不是在一个函数里自己调自己,而是分别链式的去调用实例化后的若干函数。)请读者仔细体会二者的区别。
如果我们想实现一个「调用」对象的方法,这个对象可能是函数、函数指针、仿函数实例、函数对象、lambda等,总之是一个可调用的对象。要怎么办?其实非常简单,直接透传参数即可:
template <typename T, typename... Args>
decltype(auto) invoke(T &&obj, Args&&... args) {
return obj(std::forward<Args>(args)...);
}
// 以下是验证Demo
void f(int a) {
std::cout << a << std::endl;
}
void Demo() {
invoke(f, 4); // 函数
invoke(&f, 5); // 函数指针
int s = invoke([](int a, int b){return a + b;}, 5, 6); // lambda
int r = invoke(std::greater<int>{}, 6, 7); // 仿函数
std::function<void(int)> t{f};
invoke(t, 2); // 函数对象
}
上面展示的也是std::invoke
的一个简化版实现(暂时没有考虑到非静态成员函数的问题),但假如,需要传入函数的参数并不是直接填入的,而是保存在一个元组中,那怎么做呢?
void f(int a, double b) {}
void Demo() {
std::tuple arg{1, 3.5}; // 参数在元组中
apply(f, arg); // 用元组调用函数,这个功能如何实现?
}
那我们就需要把元组展开,依次填写到invoke
的参数中。可是,元组只能通过std::get
的方式取出其中的数据,也就是说,我们需要依次使用std::get<0>
、std::get<1>
、std::get<2>
……去操作元组,拿出所有的数据,然后依次填写到invoke
的参数中。对于上例来说应该是
template <typename F, typename Arg0, typename Arg1>
decltype(auto) apply(F &&func, std::tuple<Arg0, Arg1> &&tup) {
return invoke(func, std::get<0>(tup), std::get<1>(tup));
}
由此可以观察到,如果我们能得到一个静态的序列{0, 1, 2, 3, ...}
,那么这里就可以按照这个序列来展开,也就是说:
template <typename F, typename Tup, size_t... Index>
decltype(auto) apply(F &&func, Tup &&tup) {
return invoke(func, std::get<Index>(tup)...); // 通过Index来展开
}
现在只要这个Index
能够正确地按照{0, 1, 2, 3, ...}
的方式填写进去,我们的问题就解决了。可是,我们总不能要求使用者手动去填写把?
void test(int a, double b, char c) {}
void Demo() {
std::tuple arg{1, 3.5, 'A'}; // 参数在元组中
apply<void(*)(int, double, char), 0, 1, 2>(test, arg); // 这不太扯了?
}
所以,现在就需要一个工具,能够根据元组的元素个数,自动生成一个序列,从0
开始,一直到N - 1
。因此,我们需要构造一个辅助工具,用于 把N
变成0, 1, 2, ..., N - 2, N - 1
这样的序列。代码如下:
// 序列类,类型本身无运行意义,仅用于静态处理,只会用到它的模板参数
template <size_t... Index>
struct sequence {};
// 用于生成序列(递归大法好)
template <size_t N, size_t... Index>
struct make_sequence : make_sequence<N - 1, N - 1, Index...> {}; // 注意这里的写法,是这个功能的重点
// 递归终点(注意不能无限递归下去,到0就要截止了)
template <size_t... Index>
struct make_sequence<0, Index...> {
// 这个时候序列已经生成了,所以把这个序列交给sequence
using result = sequence<Index...>;
};
现在来解释一下上面是如何生成序列的。在make_sequence
中,第一个参数N
表示「还剩几个数没有进入序列」,而后面的Index
就是现在已经产生的序列。举个例子来说,make_sequence<3>
,现在N
是3
,表示还有3个数要处理,而后面的Index
是空的,所以这是初始状态。根据代码继承的写法(递归),make_sequence<3>
应当继承自make_sequence<2, 2>
。
对于make_sequence<2, 2>
来说,还有2个数字要处理,已经处理完的序列是2
,再根据继承写法进行递归,它继承自make_sequence<1, 1, 2>
。同理,make_sequence<1, 1, 2>
继承自make_sequence<0, 0, 1, 2>
。这时,由于N
变为0
了,所以命中了下面的偏特化(递归结束),在make_sequence<0, 0, 1, 2>
中,成员result
被定义为 sequence<0, 1, 2>
,这就得到了我们想要的序列。
那么这仍然是一个模板元编程中很独特的用法,虽然我们用到了继承的写法,但这里其实跟OOP中的继承完全没有关系,这些工具类也不会实例化出现在运行期,我们仅仅是通过这种方式来生成一个序列而已。请读者一定要体会这种模板元编程的「feel」。
当已经拥有这个序列以后,就好办了,在apply
函数中,用它进行std::get
展开即可,下面是完整代码:
// 序列类,类型本身无运行意义,仅用于静态处理,只会用到它的模板参数
template <size_t... Index>
struct sequence {};
// 用于生成序列
template <size_t N, size_t... Index>
struct make_sequence : make_sequence<N - 1, N - 1, Index...> {};
// 递归终点
template <size_t... Index>
struct make_sequence<0, Index...> {
// 这个时候序列已经生成了,所以把这个序列交给sequence
using result = sequence<Index...>;
};
template <typename F, typename Tup, size_t... Index>
decltype(auto) apply_detail(F &&func, Tup &&tup, sequence<Index...> &&) {
return std::invoke(func, std::get<Index>(tup)...); // 通过Index来展开
}
template <typename F, typename Tup>
decltype(auto) apply(F &&func, Tup &&tup) {
return apply_detail(func, tup, typename make_sequence<std::tuple_size_v<std::decay_t<Tup>>>::result{});
}
// 测试Demo
void f(int, double) {}
void Demo() {
std::tuple tu{1, 3.4};
apply(f, tu);
}
这里的另一个要点在于,为了让apply_detail
函数能自动生成Index
,我们利用了模板的实例化自动推导,根据传入的第三个参数(序列)来自动推导模板参数。所以在apply
函数把生成的序列通过参数传递了进去。但这里由于辅助类都没有任何数据,也没有自定义的构造、析构函数,因此编译器并不会生成额外的汇编指令,大家可以放心去使用。
本章介绍了模板元编程中一个非常重要的技巧——序列的生成。希望读者可以通过这个例子,对模板元编程的方式有一个更深入的体会。
后续还会介绍模板元编程的其他实例和技巧。
C++模板元编程详细教程(之九)