17.3. 无环访问者模式

访问者模式完成了我们的期望,将算法的实现与作为算法数据的对象分离开来,并允许根据两个运行时因素选择正确的实现 —— 具体的对象类型和想要执行的具体操作,这两者都从其相应的类型层次结构中选择。然而,这里有一个问题 —— 需要降低复杂性并简化代码维护,也确实做到了,但现在必须维护两个并行的类型层次结构:可访问的对象和访问者,而且两者之间的依赖关系并不简单。

这些依赖关系中最糟糕的部分是它们形成了一个循环 —— 访问者对象依赖于可访问对象的类型(每个可访问类型都有一个 visit() 方法的重载),而基可访问类型又依赖于基访问者类型。这种依赖关系的前半部分是最糟糕的。每次向层次结构中添加一个新对象时,都必须更新每个访问者。后半部分对开发者来说不需要太多工作,可以随时添加新的访问者而无需其他更改 —— 这正是访问者模式的全部意义所在。但仍然存在基可访问类对基访问者类的编译时依赖,所以所有派生类也必须依赖它。如果访问者类发生变化,每个使用可访问类的文件都需要重新编译。

访问者在接口和实现方面大多是稳定的,但有一种情况除外 —— 添加一个新的可访问类。因此,这个循环的作用如下 —— 向可访问对象的层次结构中添加一个新类。访问者类需要用新类型进行更新。由于基访问者类已更改,基可访问类和依赖于它的每一行代码都必须重新编译,包括那些只使用旧可访问类,而不使用新可访问类的代码。即使尽可能使用前向声明也无济于事 —— 如果添加了一个新的可访问类,所有旧的可访问类都必须重新编译。

传统访问者模式的另一个问题是,必须处理对象类型和访问者类型之间的每一种可能组合。通常情况下,有些组合是没有意义的,某些对象永远不会被某些类型的访问者访问。但我们无法利用,因为每种组合都必须有定义的操作(操作可能非常简单,但每个访问者类仍必须定义完整的 visit() 成员函数集)。

无环访问者模式是访问者模式的一种变体,专门设计用于打破依赖循环并允许部分访问。无环访问者模式的基可访问类与常规访问者模式相同:

// Example 10
class Pet {
public:
  virtual ~Pet() {}
  virtual void accept(PetVisitor& v) = 0;
  ...
};

然而,相似之处到此为止。基访问者类不再为每个可访问对象提供 visit() 重载。实际上,它根本没有 visit() 成员函数:

// Example 10
class PetVisitor {
public:
  virtual ~PetVisitor() {}
};

那到底由谁来执行访问呢?对于原始层次结构中的每个派生类,我们也会声明相应的访问者类,visit() 函数就定义在这里:

// Example 10
class Cat;

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

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

每个访问者只能访问为其设计的类 —— CatVisitor 只访问 Cat 对象,DogVisitor 只访问 Dog 对象,依此类推。

魔法在于新的 accept() 函数 —— 当一个类被要求接受访问者时,首先使用 dynamic_cast 检查这是否是正确类型的访问者。如果是,则一切正常,接受访问者。如果不是,就遇到了问题了,必须处理错误(确切的错误处理机制取决于应用程序;例如,可以抛出异常)。

// Example 10
class FeedingVisitor : public PetVisitor,
                       public CatVisitor,
                       public DogVisitor {
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;
  }
};

每个具体的访问者类,都可以从通用访问者基类和每一个必须由该访问者处理的类型特定访问者基类(如 CatVisitor、DogVisitor 等)派生。

另一方面,如果该访问者不打算访问层次结构中的某些类,可以简单地省略相应的访问者基类,也就不需要实现对应的虚函数重写了:

// Example 10
class BathingVisitor : public PetVisitor,
                       public DogVisitor
                       { // But no CatVisitor
public:
  void visit(Dog* d) override {
    std::cout << "Wash the " << d->color()
              << " dog" << std::endl;
  }
  // No visit(Cat*) here!
};

无环访问者模式的调用方式与常规访问者模式完全相同:

// Example 10
std::unique_ptr<Pet> c(new Cat("orange"));
std::unique_ptr<Pet> d(new Dog("brown"));

FeedingVisitor fv;
c->accept(fv);
d->accept(fv);

BathingVisitor bv;
//c->accept(bv); // Error
d->accept(bv);

如果尝试访问一个特定访问者不支持的对象,这个错误会被检测到。因此,解决了部分访问的问题。那么依赖循环呢?这个问题也一并解决了 —— 通用的 PetVisitor 基类不再需要列出所有可访问对象的完整层次结构,而具体的可访问类仅依赖于其对应的类特定访问者(如 CatVisitor),而不依赖于其他类型的访问者,当向层次结构中添加另一个可访问对象时,现有的可访问类无需重新编译。

无环访问者模式看起来如此出色,有读者不禁要问:为什么不总是使用它,而要用传统的访问者模式呢?原因有几点:

无环访问者模式的另一个问题 —— 产生了大量的样板代码。每个可访问类都需要复制几行类似的代码。事实上,传统的访问者模式也存在同样的问题,实现一种访问者都需要大量重复的编码。但 C++ 有一套特殊的工具来替代代码重复,实现代码复用 —— 泛型编程。

接下来,将看到访问者模式如何被适配到现代 C++,利用模板、std::variant、std::visit 等特性,以更简洁、类型安全且高效的方式实现类似功能,从而减少样板代码,提升性能,并更好地融入现代 C++ 的编程范式。