访问者模式

访问者模式是一种行为设计模式, 它能将算法与其所作用的对象隔离开来。

../../_images/visitor.png

问题

假如你的团队开发了一款能够使用巨型图像中地理信息的应用程序。 图像中的每个节点既能代表复杂实体 (例如一座城市), 也能代表更精细的对象 (例如工业区和旅游景点等)。 如果节点代表的真实对象之间存在公路, 那么这些节点就会相互连接。 在程序内部, 每个节点的类型都由其所属的类来表示, 每个特定的节点则是一个对象。

../../_images/map_problem1.png

一段时间后, 你接到了实现将图像导出到 XML 文件中的任务。 这些工作最初看上去非常简单。 你计划为每个节点类添加导出函数, 然后递归执行图像中每个节点的导出函数。 解决方案简单且优雅: 使用多态机制可以让导出方法的调用代码不会和具体的节点类相耦合。

但你不太走运, 系统架构师拒绝批准对已有节点类进行修改。 他认为这些代码已经是产品了, 不想冒险对其进行修改, 因为修改可能会引入潜在的缺陷。

../../_images/map_problem2-zh.png

此外, 他还质疑在节点类中包含导出 XML 文件的代码是否有意义。 这些类的主要工作是处理地理数据。 导出 XML 文件的代码放在这里并不合适。

还有另一个原因, 那就是在此项任务完成后, 营销部门很有可能会要求程序提供导出其他类型文件的功能, 或者提出其他奇怪的要求。 这样你很可能会被迫再次修改这些重要但脆弱的类。

解决方案

访问者模式建议将新行为放入一个名为访问者的独立类中, 而不是试图将其整合到已有类中。现在, 需要执行操作的原始对象将作为参数被传递给访问者中的方法, 让方法能访问对象所包含的一切必要数据。

如果现在该操作能在不同类的对象上执行会怎么样呢? 比如在我们的示例中, 各节点类导出 XML 文件的实际实现很可能会稍有不同。 因此, 访问者类可以定义一组 (而不是一个) 方法, 且每个方法可接收不同类型的参数, 如下所示:

class ExportVisitor implements Visitor is
    method doForCity(City c) { ... }
    method doForIndustry(Industry f) { ... }
    method doForSightSeeing(SightSeeing ss) { ... }
    // ...

但我们究竟应该如何调用这些方法 (尤其是在处理整个图像方面) 呢? 这些方法的签名各不相同, 因此我们不能使用多态机制。 为了可以挑选出能够处理特定对象的访问者方法, 我们需要对它的类进行检查。 这是不是听上去像个噩梦呢?

foreach (Node node in graph)
    if (node instanceof City)
        exportVisitor.doForCity((City) node)
    if (node instanceof Industry)
        exportVisitor.doForIndustry((Industry) node)
    // ...
}

你可能会问, 我们为什么不使用方法重载呢? 就是使用相同的方法名称, 但它们的参数不同。 不幸的是, 即使我们的编程语言 (例如 Java 和 C#) 支持重载也不行。 由于我们无法提前知晓节点对象所属的类, 所以重载机制无法执行正确的方法。 方法会将 节点基类作为输入参数的默认类型。

但是, 访问者模式可以解决这个问题。 它使用了一种名为双分派的技巧, 不使用累赘的条件语句也可下执行正确的方法。 与其让客户端来选择调用正确版本的方法, 不如将选择权委派给作为参数传递给访问者的对象。 由于该对象知晓其自身的类, 因此能更自然地在访问者中选出正确的方法。 它们会 “接收” 一个访问者并告诉其应执行的访问者方法。

// 客户端代码
foreach (Node node in graph)
    node.accept(exportVisitor)

// 城市
class City is
    method accept(Visitor v) is
        v.doForCity(this)
    // ...

// 工业区
class Industry is
    method accept(Visitor v) is
        v.doForIndustry(this)
    // ...

我承认最终还是修改了节点类, 但毕竟改动很小, 且使得我们能够在后续进一步添加行为时无需再次修改代码。

现在, 如果我们抽取出所有访问者的通用接口, 所有已有的节点都能与我们在程序中引入的任何访问者交互。 如果需要引入与节点相关的某个行为, 你只需要实现一个新的访问者类即可。

访问者模式结构

../../_images/visitor_structure.png

  • 访问者 (Visitor) 接口声明了一系列以对象结构的具体元素为参数的访问者方法。 如果编程语言支持重载, 这些方法的名称可以是相同的, 但是其参数一定是不同的。
  • 具体访问者 (Concrete Visitor) 会为不同的具体元素类实现相同行为的几个不同版本。
  • 元素 (Element) 接口声明了一个方法来“接收” 访问者。 该方法必须有一个参数被声明为访问者接口类型。
  • 具体元素 (Concrete Element) 必须实现接收方法。 该方法的目的是根据当前元素类将其调用重定向到相应访问者的方法。请注意, 即使元素基类实现了该方法, 所有子类都必须对其进行重写并调用访问者对象中的合适方法。
  • 客户端 (Client) 通常会作为集合或其他复杂对象 (例如一个组合树) 的代表。客户端通常不知晓所有的具体元素类,因为它们会通过抽象接口与集合中的对象进行交互。

真实世界类比

../../_images/visitor_real_world.png

假如有这样一位非常希望赢得新客户的资深保险代理人。 他可以拜访街区中的每栋楼, 尝试向每个路人推销保险。 所以, 根据大楼内组织类型的不同, 他可以提供专门的保单:

  • 如果建筑是居民楼, 他会推销医疗保险。
  • 如果建筑是银行, 他会推销失窃保险。
  • 如果建筑是咖啡厅, 他会推销火灾和洪水保险。

代码示例

class BellTower;
class TerracottaWarriors;

//@ 访问者
class IVisitor
{
public:
	virtual ~IVisitor() {}
	virtual void visit(const std::shared_ptr<BellTower>) = 0;
	virtual void visit(const std::shared_ptr<TerracottaWarriors>) = 0;
};

//@ 游客
class Tourist : public IVisitor
{
public:
	virtual void visit(const std::shared_ptr<BellTower>) override {
		std::cout << "I'm visiting the Bell Tower!" << std::endl;
	}

	virtual void visit(const std::shared_ptr<TerracottaWarriors>) override {
		std::cout << "I'm visiting the Terracotta Warriors!" << std::endl;
	}
};

//@ 清洁工
class Cleaner : public IVisitor
{
public:
	virtual void visit(const std::shared_ptr<BellTower>) override {
		std::cout << "I'm cleaning up the garbage of Bell Tower!" << std::endl;
	}

	virtual void visit(const std::shared_ptr<TerracottaWarriors>) override {
		std::cout << "I'm cleaning up the garbage of Terracotta Warriors!" << std::endl;
	}
};

//@ 景点
class IPlace
{
public:
	virtual ~IPlace() {}
	virtual void accept(std::shared_ptr<IVisitor> visitor) = 0;
};

//@ 钟楼
class BellTower : public IPlace
{
public:
	virtual void accept(std::shared_ptr<IVisitor> visitor) override {
		std::cout << "Bell Tower is accepting visitor." << std::endl;
		visitor->visit(std::shared_ptr<BellTower>(this));
	}
};

//@ 兵马俑
class TerracottaWarriors : public IPlace
{
public:
	virtual void accept(std::shared_ptr<IVisitor> visitor) override {
		std::cout << "Terracotta Warriors is accepting visitor." << std::endl;
		visitor->visit(std::shared_ptr<TerracottaWarriors>(this));
	}
};

//@ 城市(西安)
class City
{
public:
	void attach(std::shared_ptr<IPlace> place) {
		places_.push_back(place);
	}

	void detach(std::shared_ptr<IPlace> place) {
		places_.remove(place);
	}

	void accept(std::shared_ptr<IVisitor> visitor) {
		//@ 为每一个 element 设置 visitor,进行对应的操作
		for (std::list<std::shared_ptr<IPlace>>::iterator it = places_.begin(); it != places_.end(); ++it) {
			(*it)->accept(visitor);
		}
	}

private:
	std::list<std::shared_ptr<IPlace>> places_;
};

int main()
{
	std::shared_ptr<City> city(new City());

	//@ 景点 - 钟楼、兵马俑
	std::shared_ptr<IPlace> bellTower(new BellTower());
	std::shared_ptr<IPlace> warriors(new TerracottaWarriors());

	//@ 访问者 - 游客、清洁工
	std::shared_ptr<IVisitor> tourist(new Tourist());
	std::shared_ptr<IVisitor> cleaner(new Cleaner());

	//@ 添加景点
	city->attach(bellTower);
	city->attach(warriors);

	//@ 接受访问
	city->accept(tourist);
	city->accept(cleaner);

	return 0;
}

访问者模式总结

实现方式

  • 在访问者接口中声明一组 “访问” 方法, 分别对应程序中的每个具体元素类。
  • 声明元素接口。 如果程序中已有元素类层次接口, 可在层次结构基类中添加抽象的 “接收” 方法。 该方法必须接受访问者对象作为参数。
  • 在所有具体元素类中实现接收方法。 这些方法必须将调用重定向到当前元素对应的访问者对象中的访问者方法上。
  • 元素类只能通过访问者接口与访问者进行交互。 不过访问者必须知晓所有的具体元素类,因为这些类在访问者方法中都被作为参数类型引用。
  • 为每个无法在元素层次结构中实现的行为创建一个具体访问者类并实现所有的访问者方法。
    • 你可能会遇到访问者需要访问元素类的部分私有成员变量的情况。 在这种情况下, 你要么将这些变量或方法设为公有, 这将破坏元素的封装; 要么将访问者类嵌入到元素类中。后一种方式只有在支持嵌套类的编程语言中才可能实现。
  • 客户端必须创建访问者对象并通过 “接收” 方法将其传递给元素。

优点

  • 开闭原则。 你可以引入在不同类对象上执行的新行为, 且无需对这些类做出修改。
  • 单一职责原则。 可将同一行为的不同版本移到同一个类中。
  • 访问者对象可以在与各种对象交互时收集一些有用的信息。 当你想要遍历一些复杂的对象结构 (例如对象树), 在结构中的每个对象上应用访问者时, 这些信息可能会有所帮助。

缺点

  • 每次在元素层次结构中添加或移除一个类时, 你都要更新所有的访问者。
  • 在访问者同某个元素进行交互时, 它们可能没有访问元素私有成员变量和方法的必要权限。

适用场景

  • 一个对象结构包含多个类型的对象,希望对这些对象实施一些依赖其具体类型的操作。在访问者中针对每一种具体的类型都提供了一个访问操作,不同类型的对象可以有不同的访问操作。
  • 需要对一个对象结构中的对象进行很多不同的并且不相关的操作,而需要避免让这些操作“污染”这些对象的类,也不希望在增加新操作时修改这些类。访问者模式使得我们可以将相关的访问操作集中起来定义在访问者类中,对象结构可以被多个不同的访问者类所使用,将对象本身与对象的访问操作分离。
  • 对象结构中对象对应的类很少改变,但经常需要在此对象结构上定义新的操作。