设计模式与生活⑰:空腹体检,单子领着人走

0 阅读7分钟

一年一度的体检,约在了周二。

早上打完卡,没开电脑,先去体检机构——公司指定的那家。空腹,早饭留着查完再吃。

前台登记,递身份证,领回一张指引单:姓名、条码,今天要查的项目按房间号排成一列。

抽血、心电图、视力……单子一到手,人就被单子领着走了。先查哪项看哪边排队人少,但单子上的项目,一项落不下。

去年也是这套流程。单子上的项目换了几样,走的科室还是那几个。

身体还是这副身体,单子年年换。这事儿跟代码里的一种结构,是一回事。

scene-checkup.jpg

单子一到手,人就被单子领着走了

今天聊的访问者模式(Visitor):元素稳定,操作多变;把"做什么"从元素里抽出来,做成一张张可以换的单子。系列第十七篇。

01 先把套餐写进身体里

最直接的写法,是每个检查项目自己认识所有套餐。

class BloodTest {
    void annual() {
        System.out.println("抽血:三管,血常规 + 生化");
    }

    void driverLicense() {
        // 驾照体检不抽血,空着
    }
}

抽血这个类,得认识天下所有套餐

第一眼也能跑。但套餐这东西,是体检机构最爱翻新的:今年"标准套餐",明年"升级套餐",换驾照还有个"驾照体检"。每加一种套餐,抽血、心电图、视力,每个类都得补一个方法——哪怕是空方法。

项目是十年不动的那几样,套餐是年年翻新的那一类。结果最稳定的东西,跟着最爱变的东西改。这正好拧了。

02 把单子从身体里抽出来

访问者模式干的就这件事:把"做什么检查"从身体部件里拿出来,放进一张张单子里。

身体部件只留一个动作:收下这张单子。

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,还是身体自己的,跟单子无关。心电图、视力测试,长得一模一样。

单子这边,是个接口:

interface ExamVisitor {
    void visit(BloodTest bloodTest);
    void visit(Ecg ecg);
    void visit(VisionTest visionTest);
}

单子模板:每个科室留一栏

每种科室一栏。到了那儿查不查、怎么查,单子说了算。

03 两张单子,过同一副身体

年度体检的单子,栏栏都有内容:

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();
    }
}

驾照换证那张,两栏是空的:

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();
    }
}

同一副身体,单子不同,查法就不同

体检流程就是那张科室清单,固定不变:

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 里。指标一行一行,箭头有上有下。

身体还是这副身体。

visitor.png

单子递进去是 accept,按单子查是 visit,一来一回,双分派

跟前面十六篇对一句:单例管造几个,工厂管怎么造,模板方法管事情怎么走,策略管这一步用什么做法,装饰器管外面加几层,命令管把请求做成对象,代理管谁能碰到真东西,观察者管变化后通知谁,外观管一堆零件只留一个入口,状态管当前状态下怎么做,责任链管请求由哪一环接住,原型管拿哪个现成对象复制,桥接管抽象和实现怎么独立扩展,享元管把能共享的部分只留一份,备忘录管把某个时刻的状态存下来、再原样恢复,生成器管把复杂对象的装配拆成一步一步、最后一次性成型。本篇访问者,管对一批稳定的元素做什么操作,由递进来的单子说了算。

带走一句话:访问者 = 元素稳定,操作多变;把"做什么"从元素里抽出来,做成一张张可以换的单子。

身体是同一副,查法可以很多套。

套餐年年换,身体不用跟着改。


参考资料