【设计模式精讲】28.访问者模式(Visitor)
【摘要】:第 12 篇组合模式的结尾埋过一句「需要按类型分派时升级到访问者」,第 20 篇解释器又留下「操作在节点内 vs 操作外置」的悬念------本篇收线。表达式树已有求值,现在要打印、要求导、要统计变量,每种新操作都要改所有节点类;dynamic_cast 链虽能外置操作,却顺序敏感、新增类型静默漏掉。本文给出访问者的 GoF 意图:在不改元素类的前提下为结构加新操作,核心机关是双重分派------accept 调 visit,visit 重载选具体行为;C++98 版用 const 重载族贯穿只读访问,现代版给出 variant + std::visit 的无类形态与 overloaded 惯用法。文末对照 std::visit、Boost.Variant 的 static_visitor 与 POCO 的 SAX 处理器。这是 23 种模式的最后一难。
【关键词】:访问者、双重分派、accept/visit、类型分派、variant、稳定结构
【代码基准】:C++17
1. 加一个操作,改所有节点
第 20 篇的表达式树已经服役:Num/Var/Add/Mul 四种节点,自带 eval。需求接踵而至------先要打印(带最小括号的中缀式),再要求导,再要统计变量引用......每加一个操作,四类节点全要改:
cpp
// 说明性片段
// ❌ 路线一的代价:操作在节点里
class Num : public Expression {
public:
double eval(...) const;
void print(...) const; // 新操作
Ptr derivative(...) const; // 又一个
void collectVars(...)
const; // 再一个
// 每个新操作 × 所有节点类
};
有人把操作外移,写成 dynamic_cast 链:
cpp
// ❌ 外移了,但更脆
void print(const Node* n) {
if (auto* num =
dynamic_cast<const Num*>(n))
...
else if (auto* add =
dynamic_cast<const Add*>(n))
...
// 新增节点类型时,
// 这条链静默漏掉它
}
链式 dynamic_cast 的病:顺序敏感 (继承层次里基类放前面就全被截胡)、新增类型静默漏掉 (没有编译错误,走到链尾静默返回)、类型清单散落(每个函数一条链,加节点的改动点不可枚举)。
困境的形状很清楚:结构稳定(四种节点几年不变)、操作疯长(求值/打印/求导/类型检查/编译......每天在加) 。把每个操作做成一个「知道全部节点类型」的访问者,一次分派不够就分派两次------这就是本篇,23 种模式的最后一难,也是第 12 篇 walk 与第 20 篇「两条路线」共同指向的终点。
2. 模式意图与定义
- 一句话定义 :表示一个作用于某对象结构中各元素的操作。访问者使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
解决的问题:结构稳定、操作持续增长时,「加操作」不应该意味着「改所有元素类」。 - GoF 原文意图 :Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates. (封装一些作用于某种数据结构中各元素的操作。)后半句是模式的赌注:用「元素类不变」换「操作可加」------代价藏在没说的地方(第 6 节展开:元素类型反过来不能加)。
- Refactoring Guru 的表述:访问者是一种行为模式,能将算法与其所作用的对象分离开来;RG 的类比是保险推销员挨家挨户------房子(元素)各不相同且不会为推销员改造结构,推销员(访问者)带着对不同人家的不同话术(重载)上门。
核心机关是双重分派(double dispatch) 。C++ 的虚函数只按一个对象的动态类型分派(单分派):
cpp
node->accept(v); // 第一次分派:按 node 的
// 动态类型进入 Num::accept
// Num::accept 里:
v.visit(*this); // 第二次分派:*this 的
// 静态类型是 Num,
// 选中 visit(const Num&)
两步合起来,「节点 × 操作」的二维选择只用语言的单分派就完成了------这正是访问者被称为「最难模式」的全部机关,其余都是纪律。
3. UML 图 + 结构说明
#mermaid-svg-OrbiIH4DvNQCEdol{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OrbiIH4DvNQCEdol .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OrbiIH4DvNQCEdol .error-icon{fill:#552222;}#mermaid-svg-OrbiIH4DvNQCEdol .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OrbiIH4DvNQCEdol .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OrbiIH4DvNQCEdol .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OrbiIH4DvNQCEdol .marker.cross{stroke:#333333;}#mermaid-svg-OrbiIH4DvNQCEdol svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OrbiIH4DvNQCEdol p{margin:0;}#mermaid-svg-OrbiIH4DvNQCEdol g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-OrbiIH4DvNQCEdol g.classGroup text .title{font-weight:bolder;}#mermaid-svg-OrbiIH4DvNQCEdol .cluster-label text{fill:#333;}#mermaid-svg-OrbiIH4DvNQCEdol .cluster-label span{color:#333;}#mermaid-svg-OrbiIH4DvNQCEdol .cluster-label span p{background-color:transparent;}#mermaid-svg-OrbiIH4DvNQCEdol .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OrbiIH4DvNQCEdol .cluster text{fill:#333;}#mermaid-svg-OrbiIH4DvNQCEdol .cluster span{color:#333;}#mermaid-svg-OrbiIH4DvNQCEdol .nodeLabel,#mermaid-svg-OrbiIH4DvNQCEdol .edgeLabel{color:#131300;}#mermaid-svg-OrbiIH4DvNQCEdol .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-OrbiIH4DvNQCEdol .label text{fill:#131300;}#mermaid-svg-OrbiIH4DvNQCEdol .labelBkg{background:#ECECFF;}#mermaid-svg-OrbiIH4DvNQCEdol .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-OrbiIH4DvNQCEdol .classTitle{font-weight:bolder;}#mermaid-svg-OrbiIH4DvNQCEdol .node rect,#mermaid-svg-OrbiIH4DvNQCEdol .node circle,#mermaid-svg-OrbiIH4DvNQCEdol .node ellipse,#mermaid-svg-OrbiIH4DvNQCEdol .node polygon,#mermaid-svg-OrbiIH4DvNQCEdol .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OrbiIH4DvNQCEdol .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol g.clickable{cursor:pointer;}#mermaid-svg-OrbiIH4DvNQCEdol g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-OrbiIH4DvNQCEdol g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-OrbiIH4DvNQCEdol .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-OrbiIH4DvNQCEdol .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-OrbiIH4DvNQCEdol .dashed-line{stroke-dasharray:3;}#mermaid-svg-OrbiIH4DvNQCEdol .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-OrbiIH4DvNQCEdol #compositionStart,#mermaid-svg-OrbiIH4DvNQCEdol .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #compositionEnd,#mermaid-svg-OrbiIH4DvNQCEdol .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #dependencyStart,#mermaid-svg-OrbiIH4DvNQCEdol .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #dependencyStart,#mermaid-svg-OrbiIH4DvNQCEdol .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #extensionStart,#mermaid-svg-OrbiIH4DvNQCEdol .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #extensionEnd,#mermaid-svg-OrbiIH4DvNQCEdol .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #aggregationStart,#mermaid-svg-OrbiIH4DvNQCEdol .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #aggregationEnd,#mermaid-svg-OrbiIH4DvNQCEdol .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #lollipopStart,#mermaid-svg-OrbiIH4DvNQCEdol .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol #lollipopEnd,#mermaid-svg-OrbiIH4DvNQCEdol .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-OrbiIH4DvNQCEdol .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-OrbiIH4DvNQCEdol .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OrbiIH4DvNQCEdol .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OrbiIH4DvNQCEdol .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OrbiIH4DvNQCEdol :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} accept 调 visit
visit 重载按型选择
<<interface>>
Node
+accept(v) : void
Num
+accept(v) : void
Add
+lhs() : Node
+rhs() : Node
+accept(v) : void
<<interface>>
Visitor
+visit(n Num) : void
+visit(v Var) : void
+visit(a Add) : void
+visit(m Mul) : void
PrintVisitor
+visit(n Num) : void
+visit(a Add) : void
四个参与者:
- 元素(Element)接口 :
Node------只有一个方法accept(visitor); - 具体元素 :
Num/Var/Add/Mul------各自的accept恰好是一行:v.visit(*this),靠自己的静态类型触发重载选择; - 访问者(Visitor)接口 :为每种具体元素声明一个
visit重载------这张重载清单就是结构类型清单的镜像; - 具体访问者 :
PrintVisitor/EvalVisitor------一个访问者等于一个完整的操作。
注意那对双向虚线:accept 与 visit 互相认识 (循环依赖是模式的胎记)。它的价值在编译期兑现:新增节点类型时,Visitor 接口必须加重载,于是所有既有访问者编译报错 ------对照第 1 节 dynamic_cast 链的「静默漏掉」,访问者把「漏」变成「编译期点名」,这个看似的缺点其实是它最实在的优点。另一点要在图上看清:遍历通常由访问者负责 (visit(const Add&) 里自己递归 a.lhs()->accept(*this))------每个访问者可以定制自己的遍历顺序,打印走中序、求导走后序,各取所需。
4. 传统 C++ 写法(C++11 之前)
C++98 完整形态:元素族与访问者接口互相前置声明,const 引用贯穿(只读访问的标准做法):
cpp
// C++98/03 写法
#include <cstdio>
#include <string>
class Visitor;
// ---- 元素族 ----
class Node {
public:
virtual ~Node() {}
virtual void accept(
Visitor& v) const = 0;
protected:
Node() {}
private:
Node(const Node&);
Node& operator=(const Node&);
};
class Num;
class Var;
class Add : public Node {
public:
Add(const Node* l, const Node* r)
: l_(l), r_(r) {}
~Add() {
delete l_;
delete r_;
}
const Node& lhs() const {
return *l_;
}
const Node& rhs() const {
return *r_;
}
void accept(Visitor& v) const;
private:
const Node* l_;
const Node* r_;
};
// ---- 访问者接口:元素清单的镜像 ----
class Visitor {
public:
virtual ~Visitor() {}
virtual void visit(
const Num& n) = 0;
virtual void visit(
const Var& v) = 0;
virtual void visit(
const Add& a) = 0;
protected:
Visitor() {}
private:
Visitor(const Visitor&);
Visitor& operator=(
const Visitor&);
};
// ---- accept 的定义推迟到此处:
// 此时 Visitor 已完整 ----
void Add::accept(
Visitor& v) const {
v.visit(*this); // 双分派第二步
}
class Num : public Node {
public:
explicit Num(double v) : v_(v) {}
double value() const {
return v_;
}
void accept(Visitor& v) const {
v.visit(*this);
}
private:
double v_;
};
class Var : public Node {
public:
explicit Var(std::string name)
: name_(name) {}
const std::string& name() const {
return name_;
}
void accept(Visitor& v) const {
v.visit(*this);
}
private:
std::string name_;
};
// ---- 具体访问者一:打印(带括号)----
class PrintVisitor : public Visitor {
public:
void visit(const Num& n) {
printf("%g", n.value());
}
void visit(const Var& v) {
printf("%s", v.name().c_str());
}
void visit(const Add& a) {
printf("(");
a.lhs().accept(*this); // 遍历
printf(" + "); // 归访问者
a.rhs().accept(*this);
printf(")");
}
};
// ---- 具体访问者二:统计变量 ----
class CountVars : public Visitor {
public:
void visit(const Num&) {}
void visit(const Var& v) {
if (v.name() == target_)
++count_;
}
void visit(const Add& a) {
a.lhs().accept(*this);
a.rhs().accept(*this);
}
int count_;
explicit CountVars(std::string target)
: target_(target), count_(0) {}
private:
std::string target_;
};
int main() {
// (x + 3) 的树
Add tree(new Var("x"), new Num(3));
PrintVisitor print;
printf("表达式:");
tree.accept(print);
printf("\n");
CountVars count("x");
tree.accept(count);
printf("x 出现 %d 次\n", count.count_);
return 0;
}
对照第 1 节:加「求导」「类型检查」= 各写一个 Visitor 子类,节点族一个不动 ;节点将来若真要加 Mul,Visitor 接口加一个纯虚重载,全部既有访问者编译报错------改动点被编译器点名,而不是静默漏网。
三条传统写法的铁律:accept 永远一行 ------v.visit(*this),谁在 accept 里加逻辑谁就破坏了双分派的对称;const 贯穿 ------元素对访问者只读(accept 与全部 visit 都收 const 引用),可变访问者(改节点的 pass)另立非 const 接口,别混在一族里;遍历归访问者------顺序(前序/中序/短路)是操作的一部分,放在各访问者里才能各取所需;结构公共的遍历需求再上移(或交给迭代器,第 21 篇)。
5. 现代 C++ 进阶写法
升级零:= default/override 与「默认行为」基类 。样板减少之外,更实用的是给大访问者一个带默认实现的中间基类 ------visit(其他) 默认空/报错,具体访问者只覆盖关心的类型:
cpp
// 节选:默认行为基类,缓解重载爆炸
class DefaultVisitor
: public Visitor {
protected:
void visit(const Num&) override {}
void visit(const Var&) override {}
void visit(
const Add& a) override {
a.lhs().accept(*this); // 默认:递归
a.rhs().accept(*this); // 不动手
}
};
class CountVars : public DefaultVisitor {
// 只覆盖关心的一个类型
void visit(const Var& v) override {
...
}
};
改进一:variant + std::visit------无类的访问者 。第 20 篇路线二的正式名分:std::variant 是「封闭的类型清单」,std::visit 按当前备选分派------访问者的全部机关(类型清单镜像、外置操作、编译期点名)一步到位:
cpp
// 节选:C++17 无类访问者
#include <string>
#include <variant>
#include <vector>
struct NumV { double value; };
struct VarV { std::string name; };
struct AddV; // 递归 variant 需要包一层
struct Expr;
struct AddV {
Expr* lhs;
Expr* rhs;
};
struct Expr {
std::variant<NumV, VarV, AddV>
node;
};
// overloaded:让一组 lambda
// 拼成一个访问者
template <class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
void print(const Expr& e) {
std::visit(
overloaded{
[](const NumV& n) {
/* 打印数字 */
},
[](const VarV& v) {
/* 打印变量 */
},
[](const AddV& a) {
print(*a.lhs);
/* " + " */
print(*a.rhs);
}},
e.node);
}
overloaded 三行是 C++17 最著名的惯用法之一------一组 lambda 各自持有 operator(),继承拼盘后 std::visit 按类型匹配。加操作 = 新写一个 overloaded;加类型 = variant 模板参数表改一处、全部访问点编译报错------与类形态同构,但元素成了纯数据。
改进二:带状态的访问者与返回值 。访问者是操作的对象形态,天然携带累积状态(CountVars::count_);要返回值时,让全部 visit/lambda 声明同一返回类型(double、std::string),std::visit 直接聚合返回------需要「不同节点不同类型」时退回 std::variant 返回(求导返回表达式、求值返回数字,用 variant<Expr, double> 分流)。深层树上的递归 std::visit 注意栈深------超大结构改显式工作栈(第 21 篇的教训通用)。
展望 :C++26 静态反射与模式匹配提案(inspect/match)落地后,「类型清单镜像」可由编译器生成,访问者与 variant 的样板将同时消失------但「结构稳定换操作可加」的赌注不会变,语言只是把下注的手续费降下来。
6. 优缺点与适用场景
- ✅ 优点(GoF 后果清单):加操作不动元素类 ------结构库一次编译、操作无限生长(编译器几十个 pass 的组织方式);相关操作聚内聚 ------打印的全部逻辑住在一个访问者里,对照「操作分散在各节点类」的路线一;访问者自带状态 ------累积、缓冲、符号表全在访问者成员里,多个访问过程互不干扰;新增元素类型 = 编译期点名,静默漏接被根除。
- ❌ 缺点:加元素类型 = 全部访问者返工 ------赌注的另一半,结构不稳定就血本无归;双分派间接、accept/visit 循环依赖 ------元素与操作两个库互相 include,架构上要划清方向;破坏封装 ------访问者要读节点内部(本例暴露了
value()/name()/lhs()),元素类被迫开口;重载清单随类型数线性膨胀。 - 🎯 适用场景:结构稳定、操作疯长------AST 与 IR 上的多 pass 编译、文档模型的多格式导出、场景图的多种遍历渲染、配置树的多方言序列化;反面清单同样重要:类型会加的、操作只有一两个的、用不上它。
〔辨析〕访问者 vs 组合(第 12 篇,正式收线):组合提供结构(树),访问者提供「结构上按型分派的操作」------第 12 篇的 walk 只能「对每个节点做同一件事」,本篇让每个类型做不同 的事;GoF 点名的黄金搭档。访问者 vs 解释器(第 20 篇):20 篇的两条路线在此正式会师------「操作在节点内」是 GoF 式解释器,「操作外置」就是访问者;新语言选 variant AST,多 pass 加工选访问者,同一条谱系的两端。访问者 vs 命令(第 19 篇):命令物化对单个对象 的一次请求,访问者物化对整个结构 的一类操作。访问者 vs 迭代器(第 21 篇):迭代器按位置 交付元素(不问类型),访问者按类型分派行为(不问位置)------两者常串成「迭代 + 分派」的流水线。
7. 开源项目中的身影
标准库:std::visit 把访问者做成了关键字级设施 。variant 收编「封闭类型清单」、std::visit 收编「按型分派」、overloaded 惯用法收编「操作成族」------本篇 §5 的主角本身就是标准答案:
cpp
#include <variant>
#include <cstdio>
std::variant<int, double,
std::string> v = 3.14;
std::visit(overloaded{
[](int i) {
std::printf(
"int %d\n", i);
},
[](double d) {
std::printf(
"double %g\n", d);
},
[](const std::
string& s) {
std::printf(
"str %s\n",
s.c_str());
}},
v); // double 3.14
点评:注意 std::visit 的一条硬规矩------lambda 组必须穷尽 variant 的全部备选,缺一个编译不过。「编译期点名」从访问者的纪律变成了语言检查,这正是模式的完成态。
Boost.Variant:static_visitor,std 的直系祖先 。std::variant 之前,Boost.Variant 已用 static_visitor<T> 基类与 apply_visitor 提供同构设施,返回类型显式声明在基类模板参数上:
cpp
// 说明性片段(需包含 Boost/variant)
#include <boost/variant.hpp>
#include <string>
struct LenOf
: public boost::static_visitor<
std::size_t> {
std::size_t operator()(
int) const {
return sizeof(int);
}
std::size_t operator()(
const std::string& s) const {
return s.size();
}
};
boost::variant<int, std::string> v =
std::string("visitor");
// boost::apply_visitor(
// LenOf{}, v) == 7
点评:static_visitor 的返回值走模板参数而不是 decltype------十几年前的设计已把「访问者 = 带返回类型的操作族」定型;std::visit 只是把它匿名化、lambda 化。
POCO:SAX 解析,跨系统边界的访问者 。Poco::XML 的 SAX 接口里,解析器是遍历结构的一方,ContentHandler 是访问者------startElement/endElement/characters 等「事件重载」按类型分派,文档结构(DTD + 输入流)稳定、处理方式(校验、抽取、转换)千变万化:
cpp
// 说明性片段(需链接 PocoXML)
#include <Poco/SAX/ContentHandler.h>
#include <Poco/SAX/SAXParser.h>
class TitleGrabber
: public Poco::XML::ContentHandler {
public:
void startElement(
const Poco::XML::XMLString&
name,
...) override {
if (name == "title")
inTitle_ = true; // 只关心一种元素
}
void characters(...) override {
if (inTitle_) { /* 收集文本 */ }
}
private:
bool inTitle_ = false;
};
// SAXParser 解析时回调------
// 结构在 XML 里,操作在你手里
点评:SAX 的取舍是访问者代价的放大版------它只给一遍(流式、低内存),想要第二遍请重读文件;换来的是能处理远超内存的文档。访问者「一遍结构、一族操作」的本性,在系统边界上变成了吞吐量的筹码。
三份代码合看:std::visit 把双分派做进语言、Boost 是它的史前地层、SAX 把它推到流式边界------「结构稳定、操作疯长」这笔赌注无论押在哪里,三十年来从未亏过。
本篇小结
结构稳定、操作疯长时,把每个操作做成访问者:accept 与 visit 两步握手完成单分派语言做不到的按型分派,重载清单镜像类型清单,新增操作零改动元素、新增类型编译期点名。纪律三条:accept 一行、const 贯穿、遍历归访问者;C++17 之后,variant + std::visit + overloaded 给出无类的同构形态,元素退回纯数据。它与组合、解释器、迭代者的三笔账至此清算完毕------第 12、20、21 篇埋的伏笔全部收线。行为型十一种模式到齐,下一篇把它们放上同一张桌:横向对比、决策树、通信模型三兄弟辨析,以及每篇末尾那些「模式如何协作」的总账。
本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「访问者」一章,意图译文、双重分派与「遍历归谁」的讨论参考了 GoF《Design Patterns》第 5 章 Visitor 一节。