17.5. 编译期访问者模式

在本节中,将分析在编译时使用访问者模式的可能性,类似于导致基于策略设计的策略模式的应用。

首先,当在模板上下文中使用时,访问者模式的多重分派特性变得微不足道:

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)... };
}

这种方法可行,但在清晰度和可维护性方面恐怕有些困难。

总体而言,编译时访问者模式更容易实现,无需费力实现多重分派,模板机制本身就提供了这一功能。我们真正需要做的,是发掘该模式的有趣应用场景,例如刚刚探讨的序列化/反序列化问题。