前序文章请看:
C++模板元编程详细教程(之一)
C++模板元编程详细教程(之二)
C++模板元编程详细教程(之三)
C++模板元编程详细教程(之四)
C++模板元编程详细教程(之五)
C++模板元编程详细教程(之六)
C++模板元编程详细教程(之七)
C++模板元编程详细教程(之八)
C++模板元编程详细教程(之九)
通过类型访问
在上一节里,我们实现的简化版variant
仅仅提供了一个通过Index
来获取数据的方法。但有时我们可能更希望通过类型来获取数据(假如该类型是唯一的)。因此,我们就希望能够提供一个,在类型唯一的前提下,通过类型来获取数据的get
方法。
既然已经有了用Index
来获取的方法,那么只需要想办法把类型转换成Index
即可,所以我们的思路就是「逐个来试,找到为止」。请看代码:
// 辅助工具,用于在列表中找到第一次出现Target类型的Index
template <typename Target, typename Head, typename... Types>
struct get_index_from_types {
// 递归查找,把上一层的递归值+1
constexpr static int value = get_index_from_types<Target, Types...>::value + 1;
};
template <typename Target, typename... Types>
struct get_index_from_types<Target, Target, Types...> { // 列表第一项是目的类型时,视为找到
constexpr static int value = 0;
};
template <typename Target>
struct get_index_from_types<Target, Target> { // 只剩一个并且匹配到了,仍视为找到
constexpr static int value = 0;
};
template <typename Target, typename Head>
struct get_index_from_types<Target, Head> { // 只剩一个还不匹配时,视为没找到
};
// 验证用Demo
void Demo() {
std::cout << get_index_from_types<int, double, char, int>::value << std::endl; // 2
std::cout << get_index_from_types<int, int, char, int>::value << std::endl; // 0
std::cout << get_index_from_types<int, int>::value << std::endl; // 0
std::cout << get_index_from_types<int, double, char, std::string>::value << std::endl; // ERR,未找到,所以报错
}
可能有读者会疑惑,为什么要针对只剩一个切匹配到的情况单独给一个特化?这是因为,假如我们不提供get_index_from_types<Target, Target>
的特化,那么根据优先级原则,get_index_from_types<int, int>
会同时命中get_index_from_types<Target, Target, Types...>
和get_index_from_types<Target, Head>
这两种特化,会报二义性错误。所以我们不得不单独给出get_index_from_types<Target, Target>
的特化。
有了这个工具就好办了,我们就可以给variant
提供一个通过类型来获取数据的接口了:
template <typename... Types>
class variant {
public:
// 省略无关代码
// 取出数据-通过Index
template <size_t Index>
auto get() const -> std::add_lvalue_reference_t<std::decay_t<typename get_type_by_index<Index, Types...>::type>>;
// 取出数据-通过类型
template <typename T>
auto get() const -> std::add_lvalue_reference_t<std::decay_t<T>>;
private:
void *data_ = std::malloc(std::max(sizeof(Types)...));
int index_; // 当前生效的数据序号
// 省略无关代码
};
template <typename... Types>
template <typename T>
auto variant<Types...>::get() const -> std::add_lvalue_reference_t<std::decay_t<T>> {
// 先拿出Index
constexpr int Index = get_index_from_types<T, Types...>::value;
// 然后再调用原本的get
return this->get<Index>();
}
这里就不再跟上节的拼一个完整代码了,主要是希望读者掌握在类型列表中找到某个类型的位置的模板元编程方法。
访问器
前面的篇幅我们介绍了如何实现一个简化版的variant
,而由于STL中已经提供了std::variant
,这是一个成熟的工具了,所以大家在实际开发应用中,可以直接使用STL的工具。后面的篇幅中遇到使用多选一结构的场景也会切换为使用std::variant
,而不再使用前面例程中的variant
。读者可以参考C++官方参考手册中对于它的介绍。
在使用std::variant
时,我们经常会有这样的一个场景:对于可能出现的多种类型,每种类型对应一个不同的处理函数,然后根据运行时实际的类型选择不同的函数。用代码示例就是:
// 针对不同类型的处理函数
void f(int data) {}
void f(double data) {}
void f(char data) {}
void Demo() {
std::variant<int, double, char> va;
if (auto int_data = std::get_if<int>(&va); int_data != nullptr) { // 如果是int类型
f(*int_data);
} else if (auto double_data = std::get_if<double>(&va); double_data != nullptr) { // 如果是double类型
f(*double_data);
} else if (auto char_data = std::get_if<char>(&va); char_data != nullptr) { // 如果是char类型
f(*char_data);
}
}
这样的用法应该很常见,但是按照上例的写法会很冗长,因此,如果能够提供一个工具,直接作用在variant
上,自动根据当前数据类型来调用对应的函数,岂不是会让代码更加简洁方便?这就是这一节要介绍的内容——访问器(visitor)。
一个简单的访问器
那么,说来说去,这个所谓的「访问器」到底是什么呢?简单来说,就是对刚才那一组f
函数进行的一个封装,比如说:
struct Vis {
void f(int data) {}
void f(double data) {}
void f(char data) {}
};
这就是一个简单的访问器,可以用于处理含有int
、double
和char
类型的variant
。我们把「用访问器来处理variant
」的这个动作就叫做「访问」。不过由于这里把用于访问的方法叫做了f
,并不是STL中标准规定的,因此为了适配「访问」工具,我们这里要按照STL中的规定,使用仿函数方法,也就是把f
改成operator()
:
struct Vis {
void operator()(int data) {}
void operator()(double data) {}
void operator()(char data) {}
};
STL中提供了std::visit
方法,表示「访问」这个动作,也就是将访问器作用于variant
上。我们先来介绍访问器的用法,然后下一节再来介绍「访问」方法是如何实现的。
先来看一下std::visit
的函数原型:
template <typename Visitor, typename... Variants>
constexpr auto visit(Visitor&& vis, Variants&&... vars);
很简单,传入一个访问器,再传入一组variant
。也就是说,这个函数是支持传入多个variant
的情况,不过暂时我们先不考虑这么复杂,先看看对于一个variant
的情况。
需要说明的是,在C++17标准下,我们无法指定返回值,而是要求访问器中所有方法的返回值必须一致(可以理解为,前面例程中的f
函数重载,都必须是同一个返回值类型)。在C++20中此方法进行了扩展,支持不同类型返回值的访问器,但要求手动指定visit
的返回值:
// C++20起
template <typename R, typename Visitor, typename... Variants>
constexpr R visit(Visitor&& vis, Variants&&... vars);
同样,我们还是简化问题,只考虑C++17标准中,访问器中的方法返回值一致的情况。前面的Vis
访问器就是符合要求的,因为里面每一个operator()
都是void
返回值,所以就可以这样来调用:
void Demo() {
std::variant<int, double, char> va;
// 创建访问器实例
Vis vis;
// 通过访问器进行访问
std::visit(vis, va);
}
std::visit
函数就会根据variant
中当前的数据,去调用访问器中对应的处理方法。不过访问器如果仅仅只能这样来写,那还是太LOW了,我们来看看它怎么飞起来的吧。
可动态构造的访问器
要想正确使用访问器,就要求访问器中的每一个用于访问的方法都要可以正常分发到。举刚才的例子来说:
// 一个支持int, double, char的访问器
struct Vis {
void f(int data) {std::cout << 1;}
void f(double data) {std::cout << 2;}
void f(char data) {std::cout << 3;}
};
void Demo() {
// 创建访问器实例
Vis vis;
// 通过访问器方法应该能够正确分发数据
vis.f(1); // 1
vis.f(1.0); // 2
vis.f('A'); // 3
}
也就是说,我们分别把int
、double
和char
传入访问器的访问方法中,是可以正确调用到对应的方法的。上面的例子中,访问方法都定义在同一个访问器类中,形成重载函数,这自然是没问题的。可如果,访问方法在不同的类型中,或者是以独立函数、函数对象、lambda等形式存在的话怎么办?
我们首先考虑,如果这3个访问函数,分属3个类,要怎么构造这个访问器呢?
struct Vis1 {
void f(int data) {std::cout << 1;}
};
struct Vis2 {
void f(int data) {std::cout << 2;}
};
struct Vis3 {
void f(int data) {std::cout << 3;}
};
那么此时,我们就构造一个「集合类」,能够同时复用3个类的f
函数:
struct Vis : Vis1, Vis2, Vis3 { // 多继承,先把函数继承下来
using Vis1::f; // 再复用父类的f函数
using Vis2::f;
using Vis3::f;
};
这样我们就间接构造了一个访问器。下面的调用也是合法的:
void Demo() {
// 创建访问器实例
Vis vis;
// 通过访问器方法应该能够正确分发数据
vis.f(1); // 1
vis.f(1.0); // 2
vis.f('A'); // 3
}
既然f
函数通过这种方法可行,那么更加规范的opeartor()
函数也应该是同理:
struct Vis1 {
void operator()(int data) {std::cout << 1;}
};
struct Vis2 {
void operator()(double data) {std::cout << 2;}
};
struct Vis3 {
void operator()(char data) {std::cout << 3;}
};
struct Vis : Vis1, Vis2, Vis3 {
using Vis1::operator();
using Vis2::operator();
using Vis3::operator();
};
void Demo() {
// 创建访问器实例
Vis vis;
// 通过访问器方法应该能够正确分发数据
vis(1); // 1
vis(1.0); // 2
vis('A'); // 3
}
利用这种方法,我们就可以进行访问器的合并,并且,可以写一个模板,来支持任意类型的访问器合并:
template <typename... Base_Visitor>
struct Visitor : Base_Visitor... { // 多继承父类展开
using Base_Visitor::operator()...; // 函数复用展开
}
void Demo() {
// 直接用模板构造访问器
Visitor<Vis1, Vis2, Vis3> vis;
vis(1); // 1
vis(1.0); // 2
vis('A'); // 3
}
这样就实现了一个动态生成的访问器(注意这里的「动态」指的是可以根据不同类型,生成不同的访问器,并不是指「运行时」。毕竟模板都是编译期动作。)
我们知道lambda表达式的本质就是一个匿名的仿函数类型,那么是不是我们也可以直接用lambda来创建访问器呢?
void Demo() {
auto vis1 = [](int data) {};
auto vis2 = [](double data) {};
auto vis3 = [](char data) {};
Visitor<decltype(vis1), decltype(vis2), decltype(vis3)> vis;
vis(1);
vis(1.0);
vis('A');
}
OK,确实没有问题,只不过这种写法有点奇怪,我们能不能想办法让访问器自动推导出lambda的类型呢?大家还记得「推导指南」吗?由于Visitor
只能提供默认的构造函数(因为本来也没有什么需要构造的成员),所以我们不能指望编译期通过构造参数来推导实例化类型,所以只能手写一个推导指南了:
template <typename... Base_Visitor>
struct Visitor : Base_Visitor... {
using Base_Visitor::operator()...;
}
// 补充一个推导指南
template <typename... Types>
Visitor(Types &&...) -> Visitor<std::decay_t<Types>...>;
void Demo() {
auto vis1 = [](int data) {};
auto vis2 = [](double data) {};
auto vis3 = [](char data) {};
// 直接用lambda构造访问器
Visitor vis{vis1, vis2, vis3};
vis(1);
vis(1.0);
vis('A');
}
由于实现了推导指南,编译期就会按照指南,把Visitor vis{vis1, vis2, vis3}
推到为Visitor<decltype(vis1), decltype(vis2), decltype(vis3)>
,而又因为Visitor
并没有一个对应3个参数的构造函数,因此这里还是会调用默认的无参构造函数,而不会报错(多啰嗦一句,请读者一定要分清这里编译期和运行期的不同。模板实例化推导是编译期事项,而构造函数则是运行期事项,二者并不冲突。当没有实现自定义的推导指南时,编译器会根据构造参数来推导,而如果实现了就会按照推导指南来推导,这与调用哪个构造函数并没有直接关系)。
甚至,我们都可以直接把lambda写到vis
里面:
void Demo() {
Visitor vis{
[](int data) {},
[](double data) {},
[](char data) {}
};
vis(1);
vis(1.0);
vis('A');
}
注意,如果你发现传变量进去是OK的,但是直接传lambda会报错的话,要检查一下推导指南中有没有去掉引用符(或者decay
),因为lambda本身是xvalue,会推导出右值引用类型,而引用类型不能做父类,所以没有去掉引用符的话会引起报错。
掌握了访问器的写法,操作variant
就更方便了,比如说:
// 通用访问器
template <typename... Base_Visitor>
struct Visitor : Base_Visitor... {
using Base_Visitor::operator()...;
}
template <typename... Types>
Visitor(Types &&...) -> Visitor<std::decay_t<Types>...>;
void Demo() {
std::variant<int, double char> va;
// 直接在visit函数里构造访问器
int res = std::visit(Visitor {
[](int data)->int {return 1;},
[](double data)->int {return 2;},
[](char data)->int {return 3}
}, va);
}
小结
这一篇我们重点介绍了访问器如何实现和使用,并且引出了std::visit
函数,相信读者一定会很好奇,std::visit
究竟如何根据variant
的数据,调用对应的访问器方法的。
下一篇将会介绍如何通过访问器来「访问」variant
。