在本节中,将分析在编译时使用访问者模式的可能性,类似于导致基于策略设计的策略模式的应用。
首先,当在模板上下文中使用时,访问者模式的多重分派特性变得微不足道:
template <typename T1, typename T2> auto f(T1 t1, T2 t2);
一个模板函数可以为任意的 T1 和 T2 类型组合运行不同的算法。与使用虚函数实现的运行时多态不同,基于两种或多种类型进行不同的调用分派不会带来额外的开销(当然,编写需要处理的所有组合的代码除外)。基于这一观察,可以很容易地在编译时模拟经典的访问者模式:
// Example 18
class Pet {
std::string color_;
public:
Pet(std::string_view color) : color_(color) {}
const std::string& color() const { return color_; }
template <typename Visitable, typename Visitor>
static void accept(Visitable& p, Visitor& v) {
v.visit(p);
}
};
accept() 函数现在是一个模板,并且是静态成员函数 —— 第一个参数(从 Pet 类派生的可访问对象)的实际类型将在编译时推导出来。具体的可访问类仍以通常的方式从基类派生:
// Example 18
class Cat : public Pet {
public:
using Pet::Pet;
};
class Dog : public Pet {
public:
using Pet::Pet;
};
由于现在在编译时解析类型,访问者无需再从一个共同的基类派生:
// Example 18
class FeedingVisitor {
public:
void visit(Cat& c) {
std::cout << "Feed tuna to the " << c.color()
<< " cat" << std::endl;
}
void visit(Dog& d) {
std::cout << "Feed steak to the " << d.color()
<< " dog" << std::endl;
}
};
可访问的类可以接受具有正确接口的访问者,访问者需要为类型层次结构中的所有类提供相应的 visit() 重载函数:
// Example 18
Cat c("orange");
Dog d("brown");
FeedingVisitor fv;
Pet::accept(c, fv);
Pet::accept(d, fv);
当然,接受访问者参数并需要支持多种访问者的函数,也必须定义为模板(此时仅有一个共同基类是不够的,基类只帮助在运行时确定实际对象的类型)。
编译时访问者模式解决了与经典访问者模式相同的问题,允许在不修改类定义的情况下有效地为类添加新的成员函数,但看起来远没有运行时版本那么引人注目。
当把访问者模式与组合模式结合起来时,更有趣的可能性就出现了。之前在讨论复杂对象的访问时已经这样做过,尤其是在序列化的背景下。这之所以特别有趣,是因为它与C++中为数不多的重要缺失特性 —— 反射 —— 有关联。编程中的反射是指程序能够检查和自省其自身源代码,然后基于这种自省生成新的行为的能力。一些编程语言,如Delphi或Python,具有原生的反射能力,但C++没有。反射对于解决许多问题很有用:如果能让编译器遍历对象的所有数据成员,并递归地序列化每一个成员,直到遇到内置类型,序列化问题就可以轻松解决。可以使用编译时访问者模式,来实现类似的功能。
再次,考虑几何对象的层次结构。由于现在所有操作都在编译时进行,不再关心类的多态特性(如果需要用于运行时操作,仍然可以使用虚函数;只是不会在本节中编写或讨论它们)。例如,下面是Point类:
// Example 19
class Point {
public:
Point() = default;
Point(double x, double y) : x_(x), y_(y) {}
template <typename This, typename Visitor>
static void accept(This& t, Visitor& v) {
v.visit(t.x_);
v.visit(t.y_);
}
private:
double x_ {};
double y_ {};
};
访问是通过 accept() 函数提供的,但现在特定于类。我们拥有第一个模板参数 This 的唯一原因是为了方便地支持 const 和非 const 操作:This 可以是 Point 或 const Point。针对此类的访问者都会被用来访问定义该点的两个值,即 x_ 和 y_。访问者必须具备适当的接口,具体来说,就是具有接受 double 类型参数的 visit() 成员函数。与大多数 C++ 模板库(包括标准模板库 (STL))一样,这段代码是通过约定来维系的 —— 没有虚函数需要重写,也没有基类需要继承,只有对系统中每个类接口的要求。更复杂的类由较简单的类组合而成;例如,下面是 Line 类:
// Example 19
class Line {
public:
Line() = default;
Line(Point p1, Point p2) : p1_(p1), p2_(p2) {}
template <typename This, typename Visitor>
static void accept(This& t, Visitor& v) {
v.visit(t.p1_);
v.visit(t.p2_);
}
private:
Point p1_;
Point p2_;
};
Line 类由两个点组成。在编译时,访问者会引导去访问每一个点。这就是 Line 类的全部参与;Point 类自行决定如何被访问(正如刚才所见,也会将工作委托给另一个访问者)。由于不再使用运行时多态,现在能够容纳不同类型几何图形的容器类也必须是模板:
// Example 19
template <typename G1, typename G2>
class Intersection {
public:
Intersection() = default;
Intersection(G1 g1, G2 g2) : g1_(g1), g2_(g2) {}
template <typename This, typename Visitor>
static void accept(This& t, Visitor& v) {
v.visit(t.g1_);
v.visit(t.g2_);
}
private:
G1 g1_;
G2 g2_;
};
现在有了可访问的类型。可以使用多种不同类型的访问者来配合这个接口,而不仅仅是序列化访问者。然而,现在关注的是序列化。之前,已经见过一种将对象转换为 ASCII 字符串的访问者。现在,将对象序列化为二进制数据,即连续的比特流。序列化访问者可以访问一个特定大小的缓冲区,并一次一个 double 类型地将对象写入该缓冲区:
// Example 19
class BinarySerializeVisitor {
public:
BinarySerializeVisitor(char* buffer, size_t size) :
buf_(buffer), size_(size) {}
void visit(double x) {
if (size_ < sizeof(x))
throw std::runtime_error("Buffer overflow");
memcpy(buf_, &x, sizeof(x));
buf_ += sizeof(x);
size_ -= sizeof(x);
}
template <typename T> void visit(const T& t) {
T::accept(t, *this);
}
private:
char* buf_;
size_t size_;
};
反序列化访问者从缓冲区读取内存,并将其复制到其所恢复对象的数据成员中:
// Example 19
class BinaryDeserializeVisitor {
public:
BinaryDeserializeVisitor(const char* buffer, size_t size)
: buf_(buffer), size_(size) {}
void visit(double& x) {
if (size_ < sizeof(x))
throw std::runtime_error("Buffer overflow");
memcpy(&x, buf_, sizeof(x));
buf_ += sizeof(x);
size_ -= sizeof(x);
}
template <typename T> void visit(T& t) {
T::accept(t, *this);
}
private:
const char* buf_;
size_t size_;
};
两个访问者都直接处理内置类型,将其从缓冲区复制进去或出来,同时让更复杂的类型自行决定对象的处理方式。这两种情况下,如果超出缓冲区大小,访问者则会抛出异常。现在,就可以使用这些访问者,通过套接字将对象发送到另一台机器:
// Example 19
// 在发送端机器上:
Line l = ...;
Circle c = ...;
Intersection<Circle, Circle> x = ...;
char buffer[1024];
BinarySerializeVisitor serializer(buffer, sizeof(buffer));
serializer.visit(l);
serializer.visit(c);
serializer.visit(x);
... 将buffer发送到接收端 ...
// 在接收端机器上:
Line l;
Circle c;
Intersection<Circle, Circle> x;
BinaryDeserializeVisitor deserializer(buffer, sizeof(buffer));
deserializer.visit(l);
deserializer.visit(c);
deserializer.visit(x);
尽管在没有语言支持的情况下,无法实现通用的反射,但可以让类以有限的方式反射其内容,比如这种组合访问模式。在这个主题上,还可以考虑一些变体。
首先,通常的做法是让那些只有一个重要成员函数的对象变成可调用的;换句话说,使用函数调用语法直接调用对象本身,而不是调用其成员函数。按照这一惯例,visit() 成员函数应改名为 operator():
// Example 20
class BinarySerializeVisitor {
public:
void operator()(double x);
template <typename T> void operator()(const T& t);
...
};
可访问的类现在像调用函数一样调用访问者:
// Example 20
class Point {
public:
static void accept(This& t, Visitor& v) {
v(t.x_);
v(t.y_);
}
...
};
为方便起见,也可以实现包装函数,以便在多个对象上调用访问者:
// Example 20
SomeVisitor v;
Object1 x; Object2 y; ...
visitation(v, x, y, z);
使用可变参数模板很容易实现:
// Example 20
template <typename V, typename T>
void visitation(V& v, T& t) {
v(t);
}
template <typename V, typename T, typename... U>
void visitation(V& v, T& t, U&... u) {
v(t);
visitation(v, u ...);
}
在 C++17 中,有了折叠表达式,不再需要递归模板:
// Example 20
template <typename V, typename T, typename... U>
void visitation(V& v, U&... u) {
(v(u), ...);
}
在 C++14 中,可以利用基于 std::initializer_list 的技巧来模拟折叠表达式:
template <typename V, typename T, typename... U>
void visitation(V& v, U&... u) {
using fold = int[];
(void)fold { 0, (v(u), 0)... };
}
这种方法可行,但在清晰度和可维护性方面恐怕有些困难。
总体而言,编译时访问者模式更容易实现,无需费力实现多重分派,模板机制本身就提供了这一功能。我们真正需要做的,是发掘该模式的有趣应用场景,例如刚刚探讨的序列化/反序列化问题。