一年一度的体检,约在了周二。
早上打完卡,没开电脑,先去体检机构------公司指定的那家。空腹,早饭留着查完再吃。
前台登记,递身份证,领回一张指引单:姓名、条码,今天要查的项目按房间号排成一列。
抽血、心电图、视力......单子一到手,人就被单子领着走了。先查哪项看哪边排队人少,但单子上的项目,一项落不下。
去年也是这套流程。单子上的项目换了几样,走的科室还是那几个。
身体还是这副身体,单子年年换。这事儿跟代码里的一种结构,是一回事。

单子一到手,人就被单子领着走了
今天聊的访问者模式(Visitor):元素稳定,操作多变;把"做什么"从元素里抽出来,做成一张张可以换的单子。系列第十七篇。
01 先把套餐写进身体里
最直接的写法,是每个检查项目自己认识所有套餐。
java
class BloodTest {
void annual() {
System.out.println("抽血:三管,血常规 + 生化");
}
void driverLicense() {
// 驾照体检不抽血,空着
}
}
抽血这个类,得认识天下所有套餐
第一眼也能跑。但套餐这东西,是体检机构最爱翻新的:今年"标准套餐",明年"升级套餐",换驾照还有个"驾照体检"。每加一种套餐,抽血、心电图、视力,每个类都得补一个方法------哪怕是空方法。
项目是十年不动的那几样,套餐是年年翻新的那一类。结果最稳定的东西,跟着最爱变的东西改。这正好拧了。
02 把单子从身体里抽出来
访问者模式干的就这件事:把"做什么检查"从身体部件里拿出来,放进一张张单子里。
身体部件只留一个动作:收下这张单子。
java
interface BodyPart {
void accept(ExamVisitor visitor);
}
class BloodTest implements BodyPart {
public void accept(ExamVisitor visitor) {
visitor.visit(this);
}
void drawBlood() {
System.out.println("抽血:三管,血常规 + 生化");
}
}
accept 是收下单子,visit 是按单子查这一项
accept 里就一句 visitor.visit(this):单子递进来,我把自己递给单子。至于查血的真本事 drawBlood,还是身体自己的,跟单子无关。心电图、视力测试,长得一模一样。
单子这边,是个接口:
java
interface ExamVisitor {
void visit(BloodTest bloodTest);
void visit(Ecg ecg);
void visit(VisionTest visionTest);
}
单子模板:每个科室留一栏
每种科室一栏。到了那儿查不查、怎么查,单子说了算。
03 两张单子,过同一副身体
年度体检的单子,栏栏都有内容:
java
class AnnualCheckup implements ExamVisitor {
public void visit(BloodTest bloodTest) {
bloodTest.drawBlood();
}
public void visit(Ecg ecg) {
ecg.run();
}
public void visit(VisionTest visionTest) {
visionTest.check();
}
}
驾照换证那张,两栏是空的:
java
class DriverLicenseCheck implements ExamVisitor {
public void visit(BloodTest bloodTest) {
System.out.println("抽血:单子上没这项,下一个");
}
public void visit(Ecg ecg) {
System.out.println("心电图:单子上没这项,下一个");
}
public void visit(VisionTest visionTest) {
visionTest.check();
}
}
同一副身体,单子不同,查法就不同
体检流程就是那张科室清单,固定不变:
java
class CheckupFlow {
private final List<BodyPart> parts =
List.of(new BloodTest(), new Ecg(), new VisionTest());
void run(ExamVisitor visitor) {
for (BodyPart part : parts) {
part.accept(visitor);
}
}
}
public class Main {
public static void main(String[] args) {
CheckupFlow flow = new CheckupFlow();
System.out.println("== 年度体检 ==");
flow.run(new AnnualCheckup());
System.out.println("== 驾照换证体检 ==");
flow.run(new DriverLicenseCheck());
}
}
流程固定,单子往里递
跑一遍:
== 年度体检 ==
抽血:三管,血常规 + 生化
心电图:躺下,夹上导联,三十秒
视力:指 E 字表,再认辨色卡
== 驾照换证体检 ==
抽血:单子上没这项,下一个
心电图:单子上没这项,下一个
视力:指 E 字表,再认辨色卡
代码实际运行输出
注意一件事:从头到尾没有一个 if。没有"如果是年度套餐就抽血,如果是驾照套餐就跳过"。分支不存在------到了抽血窗口,单子上那一栏自己说了算。
04 这一来一回,叫双分派
刚才那趟流程里,"查什么"是被定了两次的。
第一次,part.accept(visitor)。part 是接口类型,运行时才看真身是抽血还是心电图------这是多态,定的是"到了哪个科室"。
第二次,visitor.visit(this)。this 写在 BloodTest 类里,编译期类型就是 BloodTest,编译器直接锁定 visit(BloodTest) 这个重载------这是重载,把"到了哪个科室"翻译成"单子上的哪一栏"。至于那一栏里写的什么,看手里这张单子是年度还是驾照------单子的真身,还是运行时才定,还是多态。
科室靠多态,栏目靠重载,内容再靠一次多态,两个维度都参与了,才叫双分派。Java 的方法调用本身只认接收者这一维,访问者用"你调我、我再回调你"这一来一回,把第二个维度也拉了进来。
用体检的话说:查什么,由两件事决定------你走到了哪个科室,单子上那一栏写的什么。两个都定了,动作才定。
05 什么时候别用
先把硬伤挑明:访问者适合"元素稳定、操作多变"。
科室项目多少年没大变,套餐倒是常换常新,这才合用。反过来,如果元素三天两头加------代码里老要新增一种检查项目------那每加一个,ExamVisitor 接口和手里所有单子,全得补一个 visit 方法,编译器逼着你一处不落地改。新增元素的成本,摊到了每一张单子头上。
失真点也得挑明。现实里体检机构加个新项目,旧单子照样用,顶多新项目没人管;代码里不行,接口不加方法,编译就过不去。还有,现实里是医生主动查你,代码里是部位收下单子、再回调单子------主动权在元素手里。
JDK 里的 Files.walkFileTree 就是这个思路:文件树这个结构不变,遍历到每个节点要做什么------统计、复制还是删除------由你递进去的那个 FileVisitor 定。
06 报告出来了
几天后,报告推到 App 里。指标一行一行,箭头有上有下。
身体还是这副身体。

单子递进去是 accept,按单子查是 visit,一来一回,双分派
跟前面十六篇对一句:单例管造几个,工厂管怎么造,模板方法管事情怎么走,策略管这一步用什么做法,装饰器管外面加几层,命令管把请求做成对象,代理管谁能碰到真东西,观察者管变化后通知谁,外观管一堆零件只留一个入口,状态管当前状态下怎么做,责任链管请求由哪一环接住,原型管拿哪个现成对象复制,桥接管抽象和实现怎么独立扩展,享元管把能共享的部分只留一份,备忘录管把某个时刻的状态存下来、再原样恢复,生成器管把复杂对象的装配拆成一步一步、最后一次性成型。本篇访问者,管对一批稳定的元素做什么操作,由递进来的单子说了算。
带走一句话:访问者 = 元素稳定,操作多变;把"做什么"从元素里抽出来,做成一张张可以换的单子。
身体是同一副,查法可以很多套。
套餐年年换,身体不用跟着改。
参考资料
- 《设计模式:可复用面向对象软件的基础》(GoF 原著)------访问者模式的原始定义:表示一个作用于某对象结构中的各元素的操作,使你可以在不改变各元素的类的前提下,定义作用于这些元素的新操作
- Refactoring Guru · Visitor------访问者模式的结构与双分派:Element、Visitor、ObjectStructure 的分工
- JDK · Files.walkFileTree------遍历文件树:树结构不变,对每个节点做什么由传入的 FileVisitor 决定