深入理解访问者模式与多分派机制
1. 访问者模式概述
访问者模式是经典的面向对象设计模式之一。在面向对象编程的黄金时代,它广受欢迎,因为能让大型类层次结构更易于维护。不过近年来,随着大型复杂层次结构变得不那么常见,且该模式实现较为复杂,在 C++ 中的使用有所减少。但泛型编程(特别是 C++11 和 C++14 新增的语言特性)让访问者类的实现和维护变得更简单,也重新唤起了人们对它的兴趣。
以下是本文将涵盖的主题:
- 访问者模式
- C++ 中访问者模式的实现
- 利用泛型编程简化访问者类
- 访问者模式在组合对象中的应用
- 编译时访问者与反射
示例代码可在 GitHub 链接 找到。
2. 什么是访问者模式
访问者模式因复杂性在经典面向对象模式中独树一帜。一方面,其基本结构复杂,涉及多个协同工作的类;另一方面,对它的描述也复杂,有多种不同方式描述同一模式。
2.1 分离算法与对象结构
访问者模式将算法与对象结构(即算法的数据)分离。借助该模式,可在不修改类本身的情况下为类层次结构添加新操作,遵循软件设计的开闭原则。类应封闭修改,开放扩展。这意味着类向客户端提供接口后,接口应保持稳定,无需为维护和开发而修改类;同时,可添加新功能以满足新需求。
从这个角度看,访问者模式能在不修改类的情况下为类或整个类层次结构添加功能,在处理公共 API 时尤为有用,API 用户可在不修改源代码的情况下扩展其操作。
2.2 实现双重分派
另一种更技术化的描述是,访问者模式实现了双重分派。为理解这点,先看常规的虚函数调用:
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;
};
此时, b->f(p) 调用有四种不同版本,因为 *b 和 *p 对象都可能是两种派生类型之一。希望在每种情况下实现不同操作,这就是双重分派,最终运行的代码由两个独立因素决定。虚函数无法直接实现双重分派,而访问者模式可以。
操作添加型访问者模式和双重分派型访问者模式本质上是同一模式。若要为层次结构中的所有类添加操作,相当于添加虚函数,对象类型是控制调用最终处理的一个因素;若能有效添加虚函数,就能为每个需要支持的操作添加一个,操作类型是控制分派的第二个因素,类似于前面示例中函数的参数。
3. 为何使用访问者模式
了解访问者模式的作用后,会问为何要使用它,双重分派有何用,为何要以另一种方式为类添加虚函数替代,而不是直接添加真正的虚函数。以序列化/反序列化问题为例。
序列化是将对象转换为可存储或传输的格式(如写入文件),反序列化则是从序列化和存储的图像构建新对象。为以直接的面向对象方式支持序列化和反序列化,层次结构中的每个类都需要两个方法。但如果有多种存储对象的方式,如写入内存缓冲区、保存到磁盘或转换为 JSON 等标记格式,直接的方法是为每个对象的每种序列化机制添加序列化和反序列化方法。若需要新的序列化方法,就得遍历整个类层次结构并添加支持。
另一种方法是在单独的函数中实现整个序列化/反序列化操作,代码是遍历所有对象的循环,包含一个大的决策树,需询问每个对象并确定其类型。当新类添加到层次结构时,所有序列化和反序列化实现都必须更新以处理新对象。
对于大型层次结构,这两种实现都难以维护。访问者模式提供了解决方案,可在不修改类的情况下在类外部实现新操作,且避免了循环中巨大决策树的缺点。
4. C++ 中的基本访问者模式
要真正理解访问者模式的工作原理,最好通过示例。
4.1 创建类层次结构
// 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 对象并执行所需操作。
4.2 声明访问者类
// Example 01
class Cat;
class Dog;
class PetVisitor {
public:
virtual void visit(Cat* c) = 0;
virtual void visit(Dog* d) = 0;
};
由于 PetVisitor 必须在具体 Pet 类之前声明,所以要提前声明 Pet 层次结构的类。
4.3 使类层次结构可访问
// 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 类,可实现新操作。
4.4 实现具体访问者类
// 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); // Feed tuna to the orange cat
4.5 更真实的示例
上述示例在调用访问者时知道要访问对象的精确类型,为使示例更真实,需多态地访问对象:
// 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() 调用最终根据可访问对象 *p 的类型和访问者 *v 的类型分派到特定的 visit() 函数。若要强调这方面,可使用辅助函数调用访问者:
// Example 03
void dispatch(Pet& p, PetVisitor& v) { p.accept(v); }
std::unique_ptr<Pet> p = ...;
std::unique_ptr<PetVisitor> v = ...;
dispatch(*p, *v); // Double dispatch
这个 C++ 中经典面向对象访问者的最简示例虽简单,但包含所有必要组件。大型实际类层次结构和多个访问者操作的实现代码更多,但无新类型代码。此示例展示了访问者模式的两个方面:一方面,关注软件功能时,访问基础设施就位后可在不修改类的情况下添加新操作;另一方面,从操作调用方式( accept() 调用)看,实现了双重分派。
访问者模式的吸引力在于可在不修改层次结构中每个类的情况下添加任意数量的新操作。若新类添加到 Pet 层次结构,很难忘记处理它;若不对访问者做任何处理,新类上的 accept() 调用将无法编译,因为没有相应的 visit() 函数可调用。但这也是访问者模式的主要缺点之一,若新类添加到层次结构,所有访问者都必须更新,无论新类是否真的需要支持这些操作。因此,有时建议仅在相对稳定、不常添加新类的层次结构中使用访问者模式。
5. 访问者模式的泛化与限制
5.1 带参数的操作
前面的访问者模式示例中,添加的虚函数无参数和返回值。添加参数很容易,可扩展类层次结构,让宠物有小猫和小狗。
// 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 对象跟踪其子对象,添加了 add_child() 成员函数。 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; // A cat
BirthVisitor bv;
Pet* child(new Cat("calico"));
parent->accept(bv, child);
5.2 访问私有成员的问题
添加查看宠物家族的操作,创建 FamilyTreeVisitor :
// 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_ 。这是访问者模式的另一个弱点,从开发者角度看,访问者为类添加新操作,像虚函数一样,但从编译器角度看,它们是完全独立的类,无特殊访问权限。应用访问者模式通常需通过两种方式之一放宽封装:允许公共访问数据(直接或通过访问器成员函数)或声明访问者类为友元(需修改源代码)。在示例中,选择第二种方式:
class Pet {
...
friend class FamilyTreeVisitor;
};
现在家族树访问者按预期工作:
Pet* parent; // A cat
...
FamilyTreeVisitor tv;
parent->accept(tv); // Prints kitten colors
以下是访问者模式使用流程的 mermaid 流程图:
graph TD
A[创建类层次结构] --> B[声明访问者类]
B --> C[使类层次结构可访问]
C --> D[实现具体访问者类]
D --> E[调用访问者操作]
| 步骤 | 描述 |
|---|---|
| 创建类层次结构 | 定义基类和派生类 |
| 声明访问者类 | 声明抽象访问者类,包含纯虚函数 |
| 使类层次结构可访问 | 为类层次结构中的类添加 accept() 函数 |
| 实现具体访问者类 | 派生自抽象访问者类,实现 visit() 函数 |
| 调用访问者操作 | 创建访问者对象并调用 accept() 函数 |
深入理解访问者模式与多分派机制
5.3 带返回值的操作
技术上, visit() 和 accept() 函数不一定要返回 void ,可以返回其他类型。但它们必须返回相同类型的限制通常使这种能力无用。虚函数可以有协变返回类型,但通常也很受限。
更简单的解决方案是将返回值存储在访问者类本身,然后再访问它。例如,让 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 :
FamilyTreeVisitor tv;
Pet* parent; // A cat
parent->accept(tv);
size_t count = tv.child_count();
6. 泛型编程简化访问者类
C++11 和 C++14 引入的泛型编程特性使得实现和维护访问者类变得更加容易。泛型编程可以减少代码重复,提高代码的可维护性。
例如,使用模板可以创建一个通用的访问者类,它可以处理不同类型的对象:
template <typename... Types>
class GenericVisitor;
template <typename T, typename... Rest>
class GenericVisitor<T, Rest...> : public GenericVisitor<Rest...> {
public:
using GenericVisitor<Rest...>::visit;
virtual void visit(T* t) = 0;
};
template <>
class GenericVisitor<> {
public:
virtual ~GenericVisitor() = default;
};
使用这个通用访问者类,可以为不同类型的对象创建具体的访问者:
class Cat;
class Dog;
class PetVisitor : public GenericVisitor<Cat, Dog> {
public:
virtual void visit(Cat* c) override = 0;
virtual void visit(Dog* d) override = 0;
};
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;
}
};
这样,通过泛型编程,减少了手动编写访问者类的工作量,提高了代码的复用性。
7. 访问者模式在组合对象中的应用
组合对象是由多个部分组成的对象,这些部分可以是其他组合对象或叶子对象。访问者模式可以很好地应用于组合对象,以实现对组合对象及其子对象的操作。
考虑一个简单的图形组合对象,包含不同类型的图形(如圆形、矩形):
class Shape {
public:
virtual void accept(class ShapeVisitor& v) = 0;
};
class Circle : public Shape {
public:
void accept(ShapeVisitor& v) override { v.visit(this); }
};
class Rectangle : public Shape {
public:
void accept(ShapeVisitor& v) override { v.visit(this); }
};
class ShapeComposite : public Shape {
private:
std::vector<Shape*> shapes;
public:
void add(Shape* s) { shapes.push_back(s); }
void accept(ShapeVisitor& v) override {
for (auto s : shapes) {
s->accept(v);
}
v.visit(this);
}
};
class ShapeVisitor {
public:
virtual void visit(Circle* c) = 0;
virtual void visit(Rectangle* r) = 0;
virtual void visit(ShapeComposite* sc) = 0;
};
class DrawingVisitor : public ShapeVisitor {
public:
void visit(Circle* c) override { std::cout << "Draw a circle" << std::endl; }
void visit(Rectangle* r) override { std::cout << "Draw a rectangle" << std::endl; }
void visit(ShapeComposite* sc) override { std::cout << "Draw a composite shape" << std::endl; }
};
使用这个访问者模式,可以方便地对图形组合对象进行操作,如绘制所有图形:
ShapeComposite composite;
Circle circle;
Rectangle rectangle;
composite.add(&circle);
composite.add(&rectangle);
DrawingVisitor dv;
composite.accept(dv);
以下是组合对象访问者模式的 mermaid 流程图:
graph TD
A[创建组合对象] --> B[添加子对象]
B --> C[创建访问者对象]
C --> D[调用组合对象的 accept() 函数]
D --> E{子对象是否为组合对象}
E -- 是 --> F[递归调用子对象的 accept() 函数]
E -- 否 --> G[调用访问者的 visit() 函数]
F --> G
G --> H[调用访问者对组合对象的 visit() 函数]
| 步骤 | 描述 |
|---|---|
| 创建组合对象 | 定义组合对象类,包含子对象容器 |
| 添加子对象 | 向组合对象中添加子对象 |
| 创建访问者对象 | 实现具体的访问者类 |
| 调用组合对象的 accept() 函数 | 开始访问过程 |
| 判断子对象类型 | 检查子对象是否为组合对象 |
| 递归调用子对象的 accept() 函数 | 对子组合对象进行递归访问 |
| 调用访问者的 visit() 函数 | 对叶子对象进行访问 |
| 调用访问者对组合对象的 visit() 函数 | 对组合对象本身进行访问 |
8. 编译时访问者与反射
编译时访问者是一种在编译时确定访问操作的技术,它可以提高性能,避免运行时的开销。反射是指程序在运行时检查和修改自身结构和行为的能力。
虽然 C++ 本身没有内置的反射机制,但可以通过一些技巧实现编译时访问者和类似反射的功能。例如,使用模板元编程可以在编译时生成访问者代码。
template <typename... Types>
struct TypeList {};
template <typename T, typename... Types>
struct TypeList<T, Types...> {
using Head = T;
using Tail = TypeList<Types...>;
};
template <typename TypeList>
class CompileTimeVisitor;
template <typename Head, typename... Tail>
class CompileTimeVisitor<TypeList<Head, Tail...>> {
public:
void visit(Head* h) { // 处理 Head 类型
std::cout << "Visit " << typeid(Head).name() << std::endl;
}
void visitAll(Head* h) {
visit(h);
CompileTimeVisitor<TypeList<Tail...>>().visitAll(h);
}
};
template <>
class CompileTimeVisitor<TypeList<>> {
public:
void visitAll(...) {}
};
使用编译时访问者:
using MyTypeList = TypeList<Cat, Dog>;
CompileTimeVisitor<MyTypeList> visitor;
Cat cat;
visitor.visitAll(&cat);
通过编译时访问者和类似反射的技术,可以在编译时确定访问操作,提高程序的性能和可维护性。
访问者模式是一种强大的设计模式,它可以将算法与对象结构分离,实现双重分派,在不修改类的情况下为类层次结构添加新操作。虽然它有一些限制,如需要更新所有访问者、访问私有成员的问题等,但通过合理的设计和使用,可以充分发挥其优势,提高代码的可维护性和扩展性。同时,结合泛型编程、组合对象应用以及编译时访问者和反射等技术,可以进一步提升访问者模式的性能和功能。
超级会员免费看

917

被折叠的 条评论
为什么被折叠?



