17.4. 现代C++中的访问者模式

正如刚才所看到的,访问者模式促进了关注点的分离;例如,序列化的顺序和序列化的机制解耦,各自由独立的类负责。该模式还通过将执行特定任务的所有代码,集中在一个地方来简化代码维护,但访问者模式并未促进无代码重复的代码复用。

这只是现代 C++ 出现之前的面向对象式访问者模式。来看看利用 C++ 的泛型能力能做些什么,从传统的访问者模式开始。

17.4.1 泛型访问者

尝试减少访问者模式实现中的样板代码。首先从 accept() 成员函数入手,这个函数必须复制到每一个可访问的类中,看起来总相同:

class Cat : public Pet {
  void accept(PetVisitor& v) override { v.visit(this); }
};

这个函数不能移动到基类中,需要使用实际类型(而非基类型)来调用访问者 —— visit() 接受的是 Cat*、Dog* 等参数,而不是 Pet*。如果引入一个中间的模板基类,就可以让模板生成这个函数:

// Example 11
class Pet { // 和之前相同
public:
  virtual ~Pet() {}
  Pet(std::string_view color) : color_(color) {}
  const std::string& color() const { return color_; }
  virtual void accept(PetVisitor& v) = 0;
private:
  std::string color_;
};

template <typename Derived>
class Visitable : public Pet {
public:
  using Pet::Pet;
  void accept(PetVisitor& v) override {
    v.visit(static_cast<Derived*>(this));
  }
};

该模板以派生类作为参数,类似于奇异递归模板模式(CRTP),但在这里并不从模板参数继承 —— 使用它将 this 指针转换为正确的派生类指针。现在,只需让每个宠物类从该模板的正确实例化版本派生,就能自动获得 accept() 函数:

// Example 11
class Cat : public Visitable<Cat> {
  using Visitable<Cat>::Visitable;
};

class Dog : public Visitable<Dog> {
  using Visitable<Dog>::Visitable;
};

这解决了样板代码的一半问题 —— 即可访问的派生对象内部的代码。现在,只剩下另一半问题:访问者类内部的代码,需要为每个可访问的类一遍又一遍地输入相同的声明。对于具体的访问者,我们无能为力;毕竟,真正的逻辑工作是在那里完成的,而且可能需要为不同的可访问类做不同的事情(否则为何要使用双重分派?)。

然而,如果引入这个通用的 Visitor 模板,就可以简化基 Visitor 类的声明:

// Example 12
template <typename ... Types> class Visitor;

template <typename T> class Visitor<T> {
public:
  virtual void visit(T* t) = 0;
};

template <typename T, typename ... Types>
class Visitor<T, Types ...> : public Visitor<Types ...> {
public:
  using Visitor<Types ...>::visit;
  virtual void visit(T* t) = 0;
};

我们只需要实现一次这个模板:不是为每个类型层次结构实现一次,而是永久地只实现一次(或者至少在需要更改 visit() 函数的签名之前,例如添加参数)。这是一个优秀的泛型库类。当拥有它,并为特定类型层次结构声明一个访问者基类,就会很简单,甚至感觉有点无趣:

// Example 12
template <typename ... Types> class Visitor;

这里使用 class 关键字的语法有些不寻常 —— 将模板参数列表与前向声明结合在一起,等价于以下写法:

class Cat;
class Dog;
using PetVisitor = Visitor<Cat, Dog>;

泛型访问者基类是如何工作的?使用可变参数模板来捕获任意数量的类型参数,但主模板仅声明而未定义,其余的则是特化版本。首先,处理只有一个类型参数的特殊情况:为该类型声明纯虚的 visit() 成员函数。然后,对多于一个类型参数的情况进行特化,其中第一个参数是显式指定的,其余参数位于参数包中。为显式指定的类型生成 visit() 函数,并从参数数量少一个的同一可变参数模板的实例化中继承其余部分。这种实例化是递归进行的,直到只剩下一个类型参数时,便使用第一个特化版本。

这段通用且可重用的代码有一个限制:无法处理深层次的继承层次结构。回想一下,每个可访问的类都派生自一个公共基类:

template <typename Derived>
class Visitable : public Pet {...};
class Cat : public Visitable<Cat> {...};

如果要从 Cat 派生另一个类,也必须从 Visitable 派生:

class SiameseCat : public Cat,
                   public Visitable<SiameseCat> {...};

我们不能简单地让 SiameseCat 直接从 Cat 派生,正是 Visitable 模板为每个派生类提供了 accept() 方法。但也无法像刚才尝试的那样使用多重继承,现在 SiameseCat 类会通过 Cat 基类和 Visitable 基类两次继承 Pet。如果仍想使用模板来生成 accept() 方法,唯一的解决办法就是将层次结构分离,使每个可访问的类(如 Cat)同时继承自 Visitable 和一个对应的基类 CatBase,而 CatBase 包含除访问支持之外的所有“猫特定”功能。但这会使层次结构中的类数量翻倍,是一个主要的缺点。

现在,我们已经通过模板生成了访问者模式的样板代码。同样,也可以让具体访问者的定义变得更简单。

17.4.2 Lambda访问者

在定义一个具体访问者时,大部分工作是编写针对每个可访问对象所需执行的实际操作代码。一个具体的访问者类本身并没有太多样板代码,但有时可能连类的声明都不想写。想想 Lambda 表达式 —— 能用 Lambda 表达式完成的事,也都可以通过显式声明的可调用类来实现, Lambda 本质上就是(匿名的)可调用类。尽管如此,我们发现 Lambda 表达式在编写一次性可调用对象时非常有用。同样地,也可能希望编写一个无需显式命名的访问者 —— 即一个“Lambda 访问者”。

我们希望它的使用方式为:

auto v(lambda_visitor<PetVisitor>(
  [](Cat* c) { std::cout << "Let the " << c->color()
                         << " cat out" << std::endl;
  },
  [](Dog* d) { std::cout << "Take the " << d->color()
                         << " dog for a walk" << std::endl;
  }
));
pet->accept(v);

有两个问题需要解决 —— 如何创建一个类来处理一个类型列表,及其对应的对象(在我们的情况中,即可访问的类型和对应的 Lambda 表达式),以及如何使用 Lambda 表达式生成一组重载函数。

第一个问题需要我们对参数包进行递归模板实例化,一次剥离一个参数。第二个问题的解决方案类似于第X章中类模板一章所描述的 Lambda 表达式的重载集。可以直接使用该章节中的重载集,但也可以利用无论如何都需要的递归模板实例化,直接构建出函数的重载集合。

这次实现会带来一个新挑战 —— 必须同时处理两个类型列表。第一个列表包含所有可访问的类型;在我们的例子中,即 Cat 和 Dog。第二个列表包含 Lambda 表达式的类型,每个可访问类型对应一个 Lambda。我们之前还没有见过带有两个参数包的可变参数模板 —— 因为无法简单地声明 template<typename... A, typename... B>,编译器无法确定第一个列表在何处结束、第二个列表从何处开始。解决方法是将一个或两个类型列表隐藏在其他模板内部。

在我们的案例中,已经有了一个基于可访问类型列表实例化的 Visitor 模板:

using PetVisitor = Visitor<class Cat, class Dog>;

可以从 Visitor 模板中提取出这个类型列表,并将每种类型与其对应的 Lambda 表达式进行匹配。用于同步处理两个参数包的偏特化语法较为复杂,所以需要将分步骤进行说明。首先,需要为 LambdaVisitor 类声明一个通用模板:

// Example 13
template <typename Base, typename...>
class LambdaVisitor;

这里只有一个通用的参数包,以及访问者的基类(在我们的例子中,将是 PetVisitor)。这个模板必须进行声明,但实际上永远不会使用 —— 为所有需要处理的情况提供特化版本。第一个特化版本用于仅有一种可访问类型和一个对应 Lambda 表达式的情况:

// Example 13
template <typename Base, typename T1, typename F1>
class LambdaVisitor<Base, Visitor<T1>, F1> : private F1, public Base
{
public:
  LambdaVisitor(F1&& f1) : F1(std::move(f1)) {}
  LambdaVisitor(const F1& f1) : F1(f1) {}
  using Base::visit;
  void visit(T1* t) override { return F1::operator()(t); }
};

这个特化版本除了处理仅有一种可访问类型的情况外,还在每一次递归模板实例化的链条中作为最终的实例化使用。由于它始终是 LambdaVisitor 实例化递归层次结构中的第一个基类,也是唯一一个直接继承自 PetVisitor 等基础 Visitor 类的类。即使只有一种可访问类型 T1,仍使用 Visitor 模板将其封装。这是为了应对一般情况所做的准备,即面对的是一组长度未知的类型列表。两个构造函数会将 f1 Lambda 表达式存储在 LambdaVisitor 类内部,会使用移动而非复制的方式。最后,visit(T1*) 虚函数的重写只是简单地将调用转发给 Lambda 表达式。乍一看,似乎更简单的做法是公开继承自 F1,并约定使用函数调用语法(将所有对 visit() 的调用替换为对 operator() 的调用)。但这种方法行不通;我们需要这种间接层, Lambda 表达式自身的 operator() 实例无法成为虚函数的重写。这里的 override 关键字非常重要,能帮助我们发现模板未从正确基类继承或虚函数声明不完全匹配等代码中的错误。

可访问类型和 Lambda 表达式任意数量的一般情况,由以下这个偏特化版本处理,显式处理两个列表中的第一个类型,然后递归地实例化自身以处理列表中剩余的部分:

// Example 13
template <typename Base,
          typename T1, typename... T,
          typename F1, typename... F>
class LambdaVisitor<Base, Visitor<T1, T...>, F1, F...> :
  private F1,
  public LambdaVisitor<Base, Visitor<T ...>, F ...>
{
public:
  LambdaVisitor(F1&& f1, F&& ... f) :
    F1(std::move(f1)),
    LambdaVisitor<Base, Visitor<T...>, F...>(
      std::forward<F>(f)...)
  {}

  LambdaVisitor(const F1& f1, F&& ... f) :
    F1(f1),
    LambdaVisitor<Base, Visitor<T...>, F...>(
      std::forward<F>(f) ...)
  {}

  using LambdaVisitor<Base, Visitor<T ...>, F ...>::visit;
  void visit(T1* t) override { return F1::operator()(t); }
};

同样,我们提供了两个构造函数,用于在当前类中存储第一个 Lambda 表达式,并将剩余的参数转发给下一层递归实例化。在递归的每一步中都会生成一个虚函数的重写,该重写始终针对可访问类列表中剩余的第一个类型。该类型随后从列表中移除,处理过程以相同方式继续,直到到达最后一个实例化,即处理单一可访问类型的那个实例。

由于无法显式命名 Lambda 表达式的类型,也无法显式声明 Lambda 访问者(Lambda visitor)的类型。因此,Lambda 表达式的类型必须通过模板参数推导来确定,所以需要一个 Lambda_visitor() 模板函数,该函数接受多个 Lambda 表达式参数,并用它们来构造出 LambdaVisitor 对象:

// Example 13
template <typename Base, typename ... F>
auto lambda_visitor(F&& ... f) {
  return LambdaVisitor<Base, Base, F...>(
    std::forward<F>(f) ...);
}

在 C++17 中,同样的功能也可以通过使用类模板参数推导来实现。现在,我们拥有了一个能够存储任意数量 Lambda 表达式,并将每个表达式绑定到相应 visit() 重写函数的类,因此可以像编写 Lambda 表达式一样轻松地编写 Lambda 访问者:

// Example 13
void walk(Pet& p) {
  auto v(lambda_visitor<PetVisitor>(
  [](Cat* c){std::cout << "Let the " << c->color()
                       << " cat out" << std::endl;},
  [](Dog* d){std::cout << "Take the " << d->color()
                       << " dog for a walk" << std::endl;}
  ));
  p.accept(v);
}

由于在继承自对应 Lambda 表达式的同一个类中声明了 visit() 函数,在 Lambda_visitor() 函数的参数列表中,Lambda 表达式的顺序必须与 PetVisitor 定义中类型列表的类顺序相匹配,并且可以通过增加实现的复杂性来消除这一限制。

C++ 中处理类型列表的另一种常见方式是将其存储在 std::tuple 中:例如,可以使用 std::tuple<Cat, Dog> 来表示由这两种类型组成的列表。同样,整个参数包也可以存储在一个 tuple 中:

// Example 14
template <typename Base, typename F1, typename... F>
class LambdaVisitor<Base, std::tuple<F1, F...>> :
  public F1, public LambdaVisitor<Base, std::tuple<F...>>;

可以对比示例13和示例14,了解如何使用 std::tuple 来存储类型列表。

现在,已经了解了如何将访问者模式代码中,公共部分转化为可重用的模板,以及这如何进一步使我们能够创建 Lambda 访问者。但我们并没有忘记本章中介绍的另一种访问者实现方式 —— 无环访问者模式。接下来,让我们看看它如何受益于现代 C++ 语言的特性的。

17.4.3 泛型无环访问者

无环访问者模式不需要一个包含所有可访问类型的基类,但其自身也存在不少样板代码。首先,每种可访问类型都需要一个 accept() 成员函数,而且其代码比原始访问者模式中的类似函数要多:

// Example 10
class Cat : public Pet {
public:
  void accept(PetVisitor& v) override {
    if (CatVisitor* cv = dynamic_cast<CatVisitor*>(&v)) {
      cv->visit(this);
    } else { // 处理错误
      assert(false);
    }
  }
};

假设错误处理方式是统一的,这个函数会为不同类型的访问者重复编写多次,每个访问者对应其可访问的类型(这里的 CatVisitor)。然后是每个类型的访问者类本身:

class CatVisitor {
public:
  virtual void visit(Cat* c) = 0;
};

同样,这段代码也会在程序中多处复制,仅有细微修改。我们可以将这种容易出错的代码,重复转换为易于维护的可复用代码。

我们首先需要创建一些基础设施。无环访问者模式的继承体系基于所有访问者共用的一个基类:

class PetVisitor {
public:
  virtual ~PetVisitor() {}
};

这里的实现与 Pet 继承体系没有特定关联。只要换一个更通用的名字,这个类就可以作为访问者体系的基类:

// Example 15
class VisitorBase {
public:
  virtual ~VisitorBase() {}
};

还需要一个模板,用于生成针对各种可访问类型的特定访问者基类,以替代那些几乎完全相同的 CatVisitor、DogVisitor 等类。由于这些类所需的功能仅仅是声明一个纯虚的 visit() 方法,可以使用可访问类型作为模板参数来参数化这个模板:

// Example 15
template <typename Visitable> class Visitor {
public:
  virtual void visit(Visitable* p) = 0;
};

现在,类型层次结构的可访问基类都可以使用,通用的 VisitorBase 基类来接受访问者:

// Example 15
class Pet {
  ...
  virtual void accept(VisitorBase& v) = 0;
};

为了避免让每个可访问类都直接从 Pet(或 Animal)派生并复制粘贴 accept() 方法,引入一个中间的模板基类,可以自动生成具有正确类型的 accept() 方法:

// Example 15
template <typename Visitable>
class PetVisitable : public Pet {
public:
  using Pet::Pet;
  void accept(VisitorBase& v) override {
    if (Visitor<Visitable>* pv =
      dynamic_cast<Visitor<Visitable>*>(&v)) {
      pv->visit(static_cast<Visitable*>(this));
    } else { // Handle error
      assert(false);
    }
  }
};

这是唯一需要编写的 accept() 函数副本,其中包含了应用程序首选的错误处理实现,用于处理访问者不被基类接受的情况(无环访问者模式允许部分访问,即某些访问者与可访问类型的组合是不被支持的)。就像常规的访问者模式一样,这种中间的 CRTP 基类使得在此方法中使用深层次的继承结构变得困难。

具体的可访问类通过中间的 PetVisitable 基类间接地继承自共同的 Pet 基类,而 PetVisitable 也为其提供了可访问的接口。PetVisitable 模板的参数是派生类自身(再次,我们看到了 CRTP 的应用):

// Example 15
class Cat : public PetVisitable<Cat> {
  using PetVisitable<Cat>::PetVisitable;
};

class Dog : public PetVisitable<Dog> {
  using PetVisitable<Dog>::PetVisitable;
};

当然,没有必要强制所有派生类都使用相同的基类构造函数,可以根据需要在每个类中定义自定义的构造函数。

最后只剩下实现访问者类。在无环访问者模式中,具体的访问者类继承自通用的访问者基类,以及代表所支持的可访问类型的每一个访问者类。不会改变,但现在有了按需生成这些访问者类的方法:

// Example 15
class FeedingVisitor : public VisitorBase,
                       public Visitor<Cat>,
                       public Visitor<Dog>
{
public:
  void visit(Cat* c) override {
  std::cout << "Feed tuna to the " << c->color()
            << " cat" << std::endl;
  }

  void visit(Dog* d) override {
    std::cout << "Feed steak to the " << d->color()
              << " dog" << std::endl;
  }
};

回顾一下已完成的工作 —— 访问者类的平行层次结构,不再需要显式地逐个编写;相反,它们可以根据需要自动生成。重复的 accept() 函数简化为单一的 PetVisitable 类模板。然而,仍需为每一个新的可访问类型层次结构,编写这样的模板。也可以对此进行泛化,创建一个可适用于所有层次结构的通用可重用模板,该模板以可访问基类作为参数:

// Example 16
template <typename Base, typename Visitable>
class VisitableBase : public Base {
public:
  using Base::Base;
  void accept(VisitorBase& vb) override {
    if (Visitor<Visitable>* v =
      dynamic_cast<Visitor<Visitable>*>(&vb)) {
      v->visit(static_cast<Visitable*>(this));
    } else { // 处理错误
      assert(false);
    }
  }
};

现在,对于每一个可访问类的层次结构,只需创建一个模板别名即可:

// Example 16
template <typename Visitable>
using PetVisitable = VisitableBase<Pet, Visitable>;

还可以进一步简化,允许开发者以类型列表的形式指定可访问的类,而不是像之前那样继承自 Visitor<Cat>、Visitor<Dog> 等。这需要一个可变参数模板来存储类型列表。其实现方式类似于之前看到的 LambdaVisitor 实例:

// Example 17
template <typename ... V> struct Visitors;

template <typename V1>
struct Visitors<V1> : public Visitor<V1> {};

template <typename V1, typename ... V>
struct Visitors<V1, V ...> : public Visitor<V1>,
                             public Visitors<V ...> {};

可以使用这个包装模板来缩短具体访问者类的声明:

// Example 17
class FeedingVisitor : public VisitorBase, public Visitors<Cat, Dog>
{
  ...
};

如有需要,甚至可以在单个类型参数的 Visitors 模板定义中将 VisitorBase 隐藏起来。

现在,已经了解了经典的面向对象的访问者模式,以及如何利用 C++ 的泛型编程工具实现可重用的版本。前面的章节中,已经看到一些设计模式可以完全在编译时应用。现在,思考一下访问者模式是否也能以同样的方式在编译时完成。