C++17 通过向标准库引入 std::variant,为使用访问者模式带来了重大变革。std::variant 模板本质上是一种“智能联合体”:std::variant<T1, T2, T3> 与 union { T1 v1; T2 v2; T3 v3; } 类似,两者都可以存储指定类型之一的值,并且同一时间只能存储一个值。关键区别在于,variant 对象知道它包含的是哪种类型,而使用 union 时,开发者必须完全负责以之前写入的相同类型进行读取。如果以与初始化时所用类型不同的类型访问联合体,则属于未定义行为:
union { int i; double d; std::string s; } u;
u.i = 0;
++u.i; // 没问题
std::cout << u.d; // 未定义行为
相比之下,std::variant 提供了一种安全的方式,在同一块内存中存储不同类型的值。可以轻松地在运行时检查 variant 当前存储的是哪种可选类型,如果以错误的类型访问 variant,则会抛出异常:
std::variant<int, double, std::string> v;
std::get<int>(v) = 0; // 初始化为 int 类型
std::cout << v.index(); // 0 是 int 类型的索引
++std::get<0>(v); // 正确,int 是第 0 种类型
std::get<1>(v); // 抛出 std::bad_variant_access 异常
std::variant 提供了与基于继承的运行时多态类似的功能:两者都允许使用同一个变量名,在运行时引用不同类型的对象。两者的主要区别在于:第一,std::variant 不要求其所有类型都来自同一继承层次(它们甚至不必是类);第二,variant 对象只能存储其声明中列出的类型,而基类指针可以指向派生类。换句话说,向类型层次结构中添加新类型,不需要重新编译使用基类的代码,而向 variant 中添加新类型则需要更改 variant 对象的类型,所有引用该对象的代码都必须重新编译。
在本节中,将重点讨论 std::variant 在访问操作中的应用。这一功能由一个恰如其分地命名为 std::visit 的函数提供,该函数接受一个可调用对象和一个 variant:
std::variant<int, double, std::string> v;
struct Print {
void operator()(int i) { std::cout << i; }
void operator()(double d) { std::cout << d; }
void operator()(const std::string& s) { std::cout << s; }
} print;
std::visit(print, v);
要与 std::visit 一起使用,可调用对象必须为 variant 中可以存储的每种类型都声明一个 operator()(否则调用将无法编译)。当然,如果实现方式类似,可以使用模板化的 operator(),无论是函数对象还是 Lambda 表达式都可以:
std::variant<int, double, std::string> v;
std::visit([](const auto& x) { std::cout << x;}, v);
现在,将使用 std::variant 和 std::visit 重新实现宠物访问者。首先,Pet 类型不再作为类型层次结构的基类,而是变为一个包含所有可能类型选项的 variant:
// Example 21
using Pet = std::variant<class Cat, class Dog, class Lorikeet>;
这些类型本身不再需要访问机制。仍然可以使用继承来复用公共的实现代码,但这些类型不再需要属于同一个继承层次:
// Example 21
class PetBase {
public:
PetBase(std::string_view color) : color_(color) {}
const std::string& color() const { return color_; }
private:
const std::string color_;
};
class Cat : private PetBase {
public:
using PetBase::PetBase;
using PetBase::color;
};
class Dog : private PetBase {
... 与Cat类似 ...
};
class Lorikeet {
public:
Lorikeet(std::string_view body, std::string_view head) :
body_(body), head_(head) {}
std::string color() const {
return body_ + " and " + head_;
}
private:
const std::string body_;
const std::string head_;
};
现在需要实现一些访问者。访问者只是可调用对象,能够以 variant 中可能存储的每一种可选类型的方式调用:
// Example 21
class FeedingVisitor {
public:
void operator()(const Cat& c) {
std::cout << "Feed tuna to the " << c.color()
<< " cat" << std::endl;
}
void operator()(const Dog& d) {
std::cout << "Feed steak to the " << d.color()
<< " dog" << std::endl;
}
void operator()(const Lorikeet& l) {
std::cout << "Feed grain to the " << l.color()
<< " bird" << std::endl;
}
};
要将访问者应用于一个 variant,调用 std::visit:
// Example 21
Pet p = Cat("orange");
FeedingVisitor v;
std::visit(v, p);
variant p 可以包含我们在定义 Pet 类型时列出的类型(在此示例中,它是一个 Cat)。然后调用 std::visit,其产生的操作结果既取决于访问者本身,也取决于当前存储在 variant 中的类型。这个结果看起来非常像一个虚函数调用,可以说 std::visit 允许为一组类型添加新的多态函数(将其称为“虚函数”会具有误导性,这些类型甚至不必是类)。
每当我们看到一个带有用户自定义 operator() 的可调用对象时,必须想到 Lambda 表达式。然而,将 Lambda 与 std::visit 结合使用并不直接:需要该对象能够以 variant 中可能存储的每种类型调用,而一个 Lambda 只有一个 operator()。第一种选择是让这个 operator() 成为一个模板(即多态 Lambda),并在其内部处理所有可能的类型:
// Example 22
#define SAME(v, T) \
std::is_same_v<std::decay_t<decltype(v)>, T>
auto fv = [](const auto& p) {
if constexpr (SAME(p, Cat)) {
std::cout << "Feed tuna to the " << p.color()
<< " cat" << std::endl; }
else if constexpr (SAME(p, Dog)) {
std::cout << "Feed steak to the " << p.color()
<< " dog" << std::endl; }
else if constexpr (SAME(p, Lorikeet)) {
std::cout << "Feed grain to the " << p.color()
<< " bird" << std::endl; }
else abort();
};
该 Lambda 可以使用任意类型的参数进行调用,并且在 Lambda 主体内部,使用 if constexpr 来处理 variant 中可以存储的所有类型。这种方法的缺点是,不再有编译时验证来确保访问者处理了所有可能的类型。但反过来说,即使没有处理所有类型,代码现在也能编译通过,而且只要访问者没有调用时传入一个未定义操作的类型,程序就能正常运行。通过这种方式,此版本类似于非循环访问者,而之前的实现则类似于常规访问者。
也可以使用 Lambda 和我们在第1章中见过的创建重载集的技术,来实现熟悉的重载 operator() 集合:
// Example 22
template <typename... T> struct overloaded : T... {
using T::operator()...;
};
template <typename... T>
overloaded( T...)->overloaded<T...>;
auto pv = overloaded {
[](const Cat& c) {
std::cout << "Play with feather with the " << c.color()
<< " cat" << std::endl; },
[](const Dog& d) {
std::cout << "Play fetch with the " << d.color()
<< " dog" << std::endl; },
[](const Lorikeet& l) {
std::cout << "Teach words to the " << l.color()
<< " bird" << std::endl; }
};
这个访问者是一个从所有 Lambda 继承的类,并暴露其 operator(),从而创建了一组重载。它的使用方式与显式编写每个 operator() 的访问者完全相同:
// Example 22
Pet l = Lorikeet("yellow", "green");
std::visit(pv, l);
现在,我们还没有用到 std::visit 的全部潜力:它可以接受任意数量的 variant 参数。这使得我们能够执行依赖于两个以上运行时条件的操作:
// Example 23
using Pet = std::variant<class Cat, class Dog>;
Pet c1 = Cat("orange");
Pet c2 = Cat("black");
Pet d = Dog("brown");
CareVisitor cv;
std::visit(cv, c1, c2); // 两只猫
std::visit(cv, c1, d); // 一只猫和一只狗
访问者必须编写成能够处理每个 variant 中可存储类型的所有可能组合:
class CareVisitor {
public:
void operator()(const Cat& c1, const Cat& c2) {
std::cout << "Let the " << c1.color() << " and the "
<< c2.color() << " cats play" << std::endl; }
void operator()(const Dog& d, const Cat& c) {
std::cout << "Keep the " << d.color()
<< " dog safe from the vicious " << c.color()
<< " cat" << std::endl; }
void operator()(const Cat& c, const Dog& d) {
(*this)(d, c);
}
void operator()(const Dog& d1, const Dog& d2) {
std::cout << "Take the " << d1.color() << " and the "
<< d2.color() << " dogs for a walk"
<< std::endl;
}
};
在实践中,唯一可行的方法是使用模板化的 operator() 来编写适用于所有可能类型组合的可调用对象,但这只有在访问者操作能够以通用方式编写时才有效。尽管如此,能够进行多重分派的能力是 std::visit 的一个潜在有用特性,超越了常规访问者模式的双重分派能力。