访问者模式因其复杂性而与其他经典面向对象模式有所不同;一方面,访问者模式的基本结构相当复杂,涉及许多需要协同工作的类才能构成该模式。另一方面,对访问者模式的描述本身也很复杂 —— 有几种截然不同的方式来描述同一个模式。许多模式可以应用于多种类型的问题,但访问者模式更进一步 —— 描述其功能的方式有多种,这些方式使用完全不同的语言,讨论看似无关的问题,总体上毫无共同之处,但都描述的都是同一个模式。让我们先探讨访问者模式的多种表现形式,然后再转向其实现。
访问者模式是一种将算法与对象结构(即该算法的数据)分离的模式。使用访问者模式,可以在不修改类本身的情况下向类型层次结构添加新操作。访问者模式的使用遵循软件设计的开闭原则 —— 一个类(或另一个代码单元,如模块)应对修改关闭;当类向其使用端提供了接口,使用端就会依赖此接口及其提供的功能。这个接口应保持稳定;不应为了维护软件和继续开发而必须修改类。一个类应对扩展开放 —— 可以添加新功能以满足新的需求。与所有非常通用的原则一样,可以找到反例,严格遵守规则比打破规则更糟糕。同样,与所有通用原则一样,其价值不在于成为每个案例的绝对规则,而在于作为默认规则或指导方针,在没有充分理由不遵循时应当遵守;现实是,大多数日常工作并不特殊,遵循此原则的结果会更好。
从这个角度看,访问者模式允许(不必修改类的情况下)向类或整个类型层次结构添加功能。当处理公共API时,这一特性可能特别有用 —— API的用户可以使用其他操作来扩展,而无需修改源代码。
另一种截然不同且更技术性描述访问者模式的方式是,说它实现了双分派。这需要一些解释。让我们从常规的虚函数调用开始:
class Base {
virtual void f() = 0;
};
class Derived1 : public Base {
void f() override;
};
class Derived2 : public Base {
void f() override;
};
如果通过指向基类 b 的指针调用虚函数 b→f(),该调用将分派到 Derived1::f() 或 Derived2::f(),具体取决于对象的实际类型。这就是单一分派 —— 实际调用的函数由单一因素决定,即对象的类型。
现在,让假设函数 f() 还接受一个指向基类的指针作为参数:
class Base {
virtual void f(Base* p) = 0;
};
class Derived1 : public Base {
void f(Base* p) override;
};
class Derived2 : public Base {
void f(Base* p) override;
};
*p 对象的实际类型也是派生类之一。现在,b→f(p) 调用可能有四种不同的版本;*b 和 *p 对象都可以是两种派生类型中的任意一种,希望在每种情况下实现不同的行为是合理的。这就是双分派 —— 最终运行的代码由两个独立的因素决定。虚函数不能直接提供实现双分派的方法,但访问者模式可以。
以这种方式呈现时,双分派访问者模式与操作添加访问者模式之间有关联并不明显。但它们实际上是完全相同的模式,这两种需求本质上是同一回事。以下是一种可能有助于理解的观点 —— 如果我们想向层次结构中的所有类添加一个操作,这相当于添加一个虚函数,因此有一个因素控制每次调用的最终处理方式,即对象类型。但如果能有效地添加虚函数,就可以添加多个 —— 为需要支持的每个操作添加一个。操作的类型是控制分派的第二个因素,类似于前面示例中函数的参数,所以操作添加访问者能够提供双分派。或如果有一种实现双分派的方法,就可以做访问者模式所做的事情 —— 实际上为每个想要支持的操作添加一个虚函数。
现在,了解了访问者模式的作用,那问题来了,我们为什么要这样做?双分派有什么用?当可以直接添加真正的虚函数时,为什么还要寻找另一种向类添加虚函数替代方法的方式?撇开源代码不可用的公共API情况不谈,为什么要在外部添加操作,而不是在每个类中实现它?
考虑序列化/反序列化的例子。序列化是一种将对象转换为可存储或传输格式(例如,写入文件)的操作。反序列化是逆向操作 —— 从其序列化和存储的图像中构造一个新对象。为了以直接的面向对象方式支持序列化和反序列化,层次结构中的每个类都需要两个方法,每个操作一个。但如果有一种以上的方法来存储对象呢?例如,可能需要将对象写入内存缓冲区,以便通过网络传输并在另一台机器上反序列化。
或者,可能需要将对象保存到磁盘,或者需要将容器中的所有对象转换为 JSON 等标记格式。直接的方法要求,为每个序列化机制向每个对象添加一个序列化和一个反序列化方法。如果需要一种新的、不同的序列化方法,必须遍历整个类型层次结构,并为其添加支持。
对于大型层次结构,这两种实现都难以维护。访问者模式提供了一种解决方案 —— 允许在类外部实现新操作,而不修改它们,同时避免了循环中巨大决策树的缺点(访问者模式并不是解决序列化问题的唯一方案;C++ 还提供了其他方法,但本章我们专注于访问者模式)。
正如开头所述,访问者模式是一种具有复杂描述的复杂模式。可以通过研究具体的例子来最好地处理这种困难的模式,从下一节的非常简单的例子开始。
真正理解访问者模式如何运作的唯一方法是通过一个例子。先从一个非常简单的例子开始。首先,需要一个类型层次结构:
// Example 01
class Pet {
public:
virtual ~Pet() {}
Pet(std::string_view color) : color_(color) {}
const std::string& color() const { return color_; }
private:
const std::string color_;
};
class Cat : public Pet {
public:
Cat(std::string_view color) : Pet(color) {}
};
class Dog : public Pet {
public:
Dog(std::string_view color) : Pet(color) {}
};
这个层次结构中,有 Pet 基类以及几种不同宠物动物的派生类。现在,想向类添加一些操作,例如“喂宠物”或“与宠物玩耍”。实现取决于宠物的类型,如果直接添加到每个类中,这些必须是虚函数。对于如此简单的类型层次结构来说,这并不是问题,但预计将来需要维护一个更大的系统,在其中修改层次结构中的每个类将变得昂贵且耗时。我们需要一种更好的方法,于是创建一个新类 PetVisitor,将应用于每个 Pet 对象(访问它)并执行需要的操作。首先,需要声明这个类:
// Example 01
class Cat;
class Dog;
class PetVisitor {
public:
virtual void visit(Cat* c) = 0;
virtual void visit(Dog* d) = 0;
};
必须提前声明 Pet 层次结构的类,PetVisitor 必须在具体的 Pet 类之前声明。现在需要使 Pet 层次结构可访问,所以需要修改它,但无论我们以后想添加多少操作,都只需要修改一次。我们需要向每个可访问类,添加一个接受访问者模式的虚函数:
// Example 01
class Pet {
public:
virtual void accept(PetVisitor& v) = 0;
...
};
class Cat : public Pet {
public:
void accept(PetVisitor& v) override { v.visit(this); }
...
};
class Dog : public Pet {
public:
void accept(PetVisitor& v) override { v.visit(this); }
...
};
现在我们的 Pet 层次结构是可访问的,并且有了一个抽象的 PetVisitor 类。一切都已准备就绪,可以为类实现新操作了(所做的事情都取决于将要添加的操作;已经创建了访问基础设施,只需要实现一次)。通过实现从 PetVisitor 派生的具体访问者类来添加操作:
// Example 01
class FeedingVisitor : public PetVisitor {
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;
}
};
class PlayingVisitor : public PetVisitor {
public:
void visit(Cat* c) override {
std::cout << "Play with a feather with the "
<< c->color() << " cat" << std::endl;
}
void visit(Dog* d) override {
std::cout << "Play fetch with the " << d->color()
<< " dog" << std::endl;
}
};
假设访问基础设施已经构建到类型层次结构中,可以通过实现一个派生的访问者类及其所有 visit() 的虚函数重写来实现一个新操作。要在类型层次结构中的对象上调用其中一个新操作,需要创建一个访问者并访问该对象:
// Example 01
Cat c("orange");
FeedingVisitor fv;
c.accept(fv); // 将金枪鱼喂给橙色的猫
在调用的示例中,有一个方面过于简单了 —— 在调用访问者时,知道所访问对象的确切类型。为了使示例更加真实,必须以多态方式访问一个对象:
// Example 02
std::unique_ptr<Pet> p(new Cat("orange"));
...
FeedingVisitor fv;
p->accept(fv);
在编译时不知道 p 指向的对象的实际类型;在接收访问者时,p 可能来自不同的来源。尽管不太常见,但访问者也可以以多态方式使用:
// Example 03
std::unique_ptr<Pet> p(new Cat("orange"));
std::unique_ptr<PetVisitor> v(new FeedingVisitor);
...
p->accept(*v);
以这种方式编写时,代码突出了访问者模式的双分派特性 —— 对 accept() 的调用最终会根据两个因素分派到特定的 visit() 函数 —— 可访问对象 *p 的类型和 *v 访问者的类型。如果希望强调访问者模式的这一方面,可以使用辅助函数来调用访问者:
// Example 03
void dispatch(Pet& p, PetVisitor& v) { p.accept(v); }
std::unique_ptr<Pet> p = ...;
std::unique_ptr<PetVisitor> v = ...;
dispatch(*p, *v); // 双分派
我们现在有了C++中经典面向对象访问者最基础的示例。尽管很简单,但包含了所有必要的组件;一个大型实际类型层次结构,和多个访问者操作的实现,会有更多的代码,但不会有新的代码类型。这个示例展示了访问者模式的两个方面;一方面,如果关注软件的功能,现在访问基础设施已经到位,可以添加新操作而无需对类本身进行更改。另一方面,如果只看操作的调用方式,accept() 调用,这里已经实现了双分派。
我们可以立即看到访问者模式的魅力,可以在无需修改层次结构中,每个类的情况下,添加任意数量的新操作。如果向 Pet 层次结构添加一个新类,就不可能忘记处理它 —— 如果对访问者什么都不做,新类上的 accept() 调用将无法编译,没有相应的 visit() 函数可调用。当在 PetVisitor 基类中添加了新的 visit() 重载,也必须将其添加到所有派生类中;否则,编译器会告诉我们有一个纯虚函数没有重写。后者也是访问者模式的主要缺点 —— 如果向层次结构添加一个新类,所有访问者都必须更新,无论这些新类是否真的需要支持这些操作。有时建议仅在相对稳定的层次结构上使用访问者,这些层次结构不经常添加新类。还有一种替代的访问者实现,可以在一定程度上缓解这个问题。
本节中的示例非常简单 —— 新操作不接受参数也不返回结果。现在将考虑这些限制是否显著,以及如何消除。
上一节中的第一个访问者,允许向层次结构中的每个类添加一个虚函数。该虚函数没有参数也没有返回值。前者很容易扩展;我们的 visit() 函数完全没有理由不能有参数。通过允许宠物拥有小猫和小狗来扩展类型层次结构。仅使用访问者模式无法完成此扩展 —— 不仅需要添加新的操作,还需要添加新的数据成员。访问者模式可以用于前者,但后者需要修改代码。如果有先见之明提供了适当的策略,基于策略的设计本可以让此更改分解为现有策略的新实现。本书第15章专门讨论了基于策略的设计,因此在这里将避免混合多种模式,而只是添加新的数据成员:
// Example 04
class Pet {
public:
..
void add_child(Pet* p) { children_.push_back(p); }
virtual void accept(PetVisitor& v, Pet* p = nullptr) = 0;
private:
std::vector<Pet*> children_;
};
每个父级 Pet 对象都跟踪其子对象(容器是指针的vector,而不是 unique 指针的vector,因此该对象并不拥有其子对象,而仅仅是访问它们),还添加了新的 add_child() 成员函数来将对象添加到vector中。我们本可以使用访问者来实现,但这个函数是非虚函数,因此只需在基类中添加一次,而无需在每个派生类中都添加 —— 在这里使用访问者是不必要的。accept() 函数已被修改,增加了一个其他参数,该参数也必须添加到所有派生类中,只是简单地转发给 visit() 函数:
// Example 04
class Cat : public Pet {
public:
Cat(std::string_view color) : Pet(color) {}
void accept(PetVisitor& v, Pet* p = nullptr) override {
v.visit(this, p);
}
};
class Dog : public Pet {
public:
Dog(std::string_view color) : Pet(color) {}
void accept(PetVisitor& v, Pet* p = nullptr) override {
v.visit(this, p);
}
};
visit() 函数也必须修改以接受其他参数,即使对于那些不需要它的访问者也是如此。因此,修改 accept() 函数的参数是一项代价高昂的全局操作,应尽可能避免。层次结构中同一虚函数的所有重写本来就必须具有相同的参数。访问者模式将此限制扩展到使用,同一基访问者对象添加的所有操作。解决此问题的常见方法是使用聚合体(将多个参数组合在一起的类或结构体)来传递参数。visit() 函数声明为接受指向基聚合类的指针,而每个访问者接收指向可能具有附加字段的派生类的指针,并根据需要进行使用。
现在,其他参数通过虚函数调用链传递给访问者,可以在其中使用它。创建一个访问者,用于记录宠物的出生情况,并将新的宠物对象作为子对象添加到其父对象中:
// Example 04
class BirthVisitor : public PetVisitor {
public:
void visit(Cat* c, Pet* p) override {
assert(dynamic_cast<Cat*>(p));
c->add_child(p);
}
void visit(Dog* d, Pet* p) override {
assert(dynamic_cast<Dog*>(p));
d->add_child(p);
}
};
如果想确保家谱中没有生物学上的不可能情况,验证必须在运行时进行 —— 在编译时,不知道多态对象的实际类型。新的访问者与上一节中的访问者一样易于使用:
Pet* parent; // 一只猫
BirthVisitor bv;
Pet* child(new Cat("calico"));
parent->accept(bv, child);
当建立了亲子关系,可能想要检查宠物家庭。这是要添加的另一个操作,需要另一个访问者:
// Example 04
class FamilyTreeVisitor : public PetVisitor {
public:
void visit(Cat* c, Pet*) override {
std::cout << "Kittens: ";
for (auto k : c->children_) {
std::cout << k->color() << " ";
}
std::cout << std::endl;
}
void visit(Dog* d, Pet*) override {
std::cout << "Puppies: ";
for (auto p : d->children_) {
std::cout << p->color() << " ";
}
std::cout << std::endl;
}
};
不过,我们遇到了一个小问题,这样编写的话,代码将无法编译。原因是 FamilyTreeVisitor 类试图访问 Pet::children_ 数据成员,而该成员是私有的。这是访问者模式的另一个弱点 —— 访问者向类添加了新操作,就像虚函数一样,但从编译器的角度来看,它们是完全独立的类,根本不像 Pet 类的成员函数,也没有特殊访问权限。应用访问者模式通常需要以两种方式之一放宽封装 —— 可以允许对数据进行公开访问(直接或通过访问器成员函数),或者将访问者类声明为友元(这确实需要修改源代码)。在示例中,将采用第二种方式:
class Pet {
...
friend class FamilyTreeVisitor;
};
现在家庭树访问者按预期工作:
Pet* parent; // 一只猫
...
amilyTreeVisitor tv;
parent->accept(tv); // 打印小猫的颜色
与 BirthVisitor 不同,FamilyTreeVisitor 不需要其他参数。
现在有了实现带参数操作的访问者。那么返回值呢?visit() 和 accept() 函数并没有必须返回 void 的要求,可以返回其他类型。然而,它们都必须返回相同类型的限制通常使这种能力变得无用。虚函数可以有协变返回类型,即基类的虚函数返回某个类的对象,而派生类的重写返回从该类派生的对象,但通常也过于受限。还有另一种更简单的解决方案 —— 每个访问者对象的 visit() 函数都可以完全访问该对象的数据成员。我们没有理由不能将返回值存储在访问者类本身中,然后稍后访问。这很适合最常见的情况,即每个访问者添加不同的操作,可能具有唯一的返回类型,但操作本身通常对层次结构中的所有类都有相同的返回类型。例如,可以让 FamilyTreeVisitor 计算子对象的总数,并通过访问者对象返回该值:
// Example 05
class FamilyTreeVisitor : public PetVisitor {
public:
FamilyTreeVisitor() : child_count_(0) {}
void reset() { child_count_ = 0; }
size_t child_count() const { return child_count_; }
void visit(Cat* c, Pet*) override {
visit_impl(c, "Kittens: ");
}
void visit(Dog* d, Pet*) override {
visit_impl(d, "Puppies: ");
}
private:
template <typename T>
void visit_impl(T* t, const char* s) {
std::cout << s;
for (auto p : t->children_) {
std::cout << p->color() << " ";
++child_count_;
}
std::cout << std::endl;
}
size_t child_count_;
};
FamilyTreeVisitor tv;
parent->accept(tv);
std::cout << tv.child_count() << " kittens total"
<< std::endl;
这种方法在多线程程序中施加了一些限制 —— 访问者现在不是线程安全的,因为多个线程不能使用同一个访问者对象来访问不同的宠物对象。最常见的解决方案是每个线程使用一个访问者对象,通常是在调用访问者的函数堆栈上创建的局部变量。还有更复杂的选项可以为访问者提供每线程(线程局部)状态,但此类选项的分析超出了本书的范围。另一方面,有时希望在多次访问中累积结果,将结果存储在访问者对象中的先前技术非常有效。还相同的解决方案可用于将参数传递给访问者操作,而不是添加到 visit() 函数中;可以将参数存储在访问者对象本身内部,然后就不需要特殊的东西从访问者访问。当参数在每次调用访问者时不会改变,但在不同的访问者对象之间可能变化时,这种技术特别有效。
让我们暂时回到并再次检查 FamilyTreeVisitor 的实现。它遍历父对象的子对象,并依次对每个子对象调用相同的操作。然而,并没有处理子对象的子对象 —— 家谱树只有一代。访问包含其他对象的对象的问题非常普遍,而且相当频繁地出现。
从本章开头的动机示例,即序列化问题,完美地展示了这种需求 —— 每个复杂对象都是通过逐个序列化其组件来序列化的,而这些组件又以相同的方式序列化,直到深入到像 int 和 double 这样的内置类型,了解了如何读写它们。下一节将更全面地处理访问复杂对象的问题。