周六,睡到自然醒。
醒的时候,窗帘缝里那条光已经挪到了床脚。没有闹钟,也不用起。躺着听了一会儿楼下的动静------有人遛狗,链子拖在地上响。
起来做饭。冰箱里剩半把青菜、两个鸡蛋。煮了碗面,卧了个蛋,青菜下锅烫了一下。吃完把碗泡在水池里,出门在小区里转了两圈。
太阳很好。健身区有个老人在压腿,动作很慢;快递柜前排着人,一个接一个输取件码。
回到家,手机里躺着一条消息:体检报告已出。
点开,二十几项,一行一行往下翻。箭头有上有下。去年只有一项带箭头,今年三项。
心里沉了一下。
体检这事,查之前想不起来,查完才想起身体这回事。刚才那碗面,忽然显得有点敷衍。
报告最下面是一段话:几项指标异常,建议......
那段话不全是医生一个字一个字敲出来的。报告上的箭头、"偏高""偏低"这些标注,很多是系统按规矩算的:这个数过了上限,记一笔;这一组里超了两项,再记一笔。小规矩套小规矩,最后拼成一句话。
今天聊的解释器模式(Interpreter):把一句判断拆成能算的小句,再一层层拼回去算。系列第二十一篇。

报告先到了手机里
01 一长串 if,把话焊死在判断里
最直接的写法,是把报告上的规矩全塞进一个方法。
java
void check(Report r) {
if (r.get("总胆固醇") > 5.2) {
System.out.println("总胆固醇偏高");
}
if (r.get("低密度脂蛋白") > 3.4) {
System.out.println("低密度脂蛋白偏高");
}
if (r.get("甘油三酯") > 1.7) {
System.out.println("甘油三酯偏高");
}
if (r.get("高密度脂蛋白") < 1.0) {
System.out.println("高密度脂蛋白偏低");
}
if ((r.get("总胆固醇") > 5.2 && r.get("低密度脂蛋白") > 3.4)
|| (r.get("甘油三酯") > 1.7 && r.get("高密度脂蛋白") < 1.0)) {
System.out.println("血脂这一组,建议复查");
}
}
第一眼能跑。
毛病有三个。
一是阈值写了两遍。5.2 在上面出现一次,下面又出现一次。哪天机构把上限调了,改了上面忘了下面,一份报告就能自己跟自己打架。
二是判断和话术焊在一起。想改一句提示语,得钻进 if 里;想加一条规矩,也得改这个方法。
三是没有层次。单个指标是"一个数跟一条线比",组合是"几句话拼起来"。这是两种东西,挤在同一层里。指标一年比一年多,这个方法一年比一年长。
一份报告,七八条 if,全堆在一个方法里
02 把判断拆成能算的小句
报告上的判断,拆到底只有两种:某个数高于上限,某个数低于下限。
先定"一条规矩"长什么样。它只干三件事:算不算命中,这句话本身怎么念,带上数值给人看又怎么念。
java
interface Rule {
boolean hit(Report r); // 算不算命中
String phrase(); // 这句话本身
String show(Report r); // 带上数值,给人看
}
高于上限:
java
class Above implements Rule {
private final String item;
private final double limit;
Above(String item, double limit) {
this.item = item;
this.limit = limit;
}
public boolean hit(Report r) {
return r.get(item) > limit;
}
public String phrase() {
return item + " 高于 " + limit;
}
public String show(Report r) {
return item + " " + r.get(item) + ",高于 " + limit;
}
}
低于下限的 Below,把 > 翻成 <,"高于"换成"低于",别的照抄。
为什么要单独留一个:报告上不是所有箭头朝上才叫麻烦。高密度脂蛋白这种,低了才要留意。同样是比大小,方向是反的。
到这儿,一条规矩还只是一个孤零零的小句。让它变成"一句话"的,是连接词。
java
abstract class Combine implements Rule {
protected final Rule[] rules;
Combine(Rule... rules) {
this.rules = rules;
}
}
class All extends Combine { // 且
All(Rule... rules) {
super(rules);
}
public boolean hit(Report r) {
for (Rule rule : rules) {
if (!rule.hit(r)) {
return false;
}
}
return true;
}
}
class Any extends Combine { // 或
Any(Rule... rules) {
super(rules);
}
public boolean hit(Report r) {
for (Rule rule : rules) {
if (rule.hit(r)) {
return true;
}
}
return false;
}
}
phrase 和 show 这两个方法,两个类都照同样的方式拼字符串,这里省了;打印树的方法和 main,也不占篇幅。
关键在一句:All 和 Any 自己也实现了 Rule。 所以 All 里可以塞 Any,Any 里可以再塞 All,套多少层都行。
连接词也是规矩,规矩可以套规矩
03 一层一层,往上算
把这四个小句和两个连接词拼起来:
java
Rule tc = new Above("总胆固醇", 5.2);
Rule ldl = new Above("低密度脂蛋白", 3.4);
Rule tg = new Above("甘油三酯", 1.7);
Rule hdl = new Below("高密度脂蛋白", 1.0);
Rule lipid = new Any(
new All(tc, ldl),
new All(tg, hdl));
文中的 5.2、3.4、1.7、1.0 只是示例,各家机构的参考范围并不一样。
人念这句话,是从左往右:
text
((总胆固醇 高于 5.2 且 低密度脂蛋白 高于 3.4) 或 (甘油三酯 高于 1.7 且 高密度脂蛋白 低于 1.0))
括号不是给人看的,是树的形状。
拿一份报告进去跑(Report 只是个装指标的 Map,这里不展开):
java
Report first = new Report()
.put("总胆固醇", 5.8)
.put("低密度脂蛋白", 3.9)
.put("甘油三酯", 1.5)
.put("高密度脂蛋白", 1.1)
.put("尿酸", 452);
实际打印出来,树的部分是这样:
text
✓ 或
✓ 且
✓ 总胆固醇 5.8,高于 5.2
✓ 低密度脂蛋白 3.9,高于 3.4
✗ 且
✗ 甘油三酯 1.5,高于 1.7
✗ 高密度脂蛋白 1.1,低于 1.0
→ 血脂这一组,建议复查
第二支"且"没成立。但"或"只要一支成立就够了,所以整句亮。
报告上其实还有一项尿酸偏高,这棵树里还没有它------正好留到下一步。
现在加一条规矩:尿酸超过 420。
java
Rule uric = new Above("尿酸", 420);
Rule whole = new Any(lipid, uric);
只挂了一个节点。原来的四个小句、两个连接词,一个字都不用动。
换一份报告,血脂四项都在范围内,只有尿酸高:
text
✓ 或
✗ 或
✗ 且
✗ 总胆固醇 4.6,高于 5.2
✗ 低密度脂蛋白 2.8,高于 3.4
✗ 且
✗ 甘油三酯 1.2,高于 1.7
✗ 高密度脂蛋白 1.3,低于 1.0
✓ 尿酸 468.0,高于 420.0
→ 建议复查
加一条规矩,是往树上挂一个节点,不是改一段方法。

左边那棵树,就是这份报告判断的算法:两个"且"各管一组,一个"或"给结论
04 为什么叫解释器
这棵树,就是一句话的语法树。
程序算的时候从上往下:先把叶子算成 true 或 false,再一层层合上去,最后合出一句结论。这个"照着语法结构一层层把结果算出来"的过程,就叫解释。
实际项目里的解释器,往往还多一步:把一句话从字符串解析成树。正则表达式、Spring 的 SpEL、MyBatis 动态 SQL 里的 OGNL,都是这么干的。我们这里是手拼的树,省了解析。
别小看那一步。字符串一进来,词法、语法、括号配对、报错位置,全是活。写一个能用的解释器,大部分工夫花在"把话读对"上,不是"把数算对"。
跟组合(Day⑱)再对一句:组合管的是"单个和一组长一个样",树是它装东西的筐;解释器管的是"这棵树怎么算出一个结果",树就是那句话本身。
所以有几条边界:
- 规矩就一两条,直接 if,别建树。煮一碗面,犯不上先搭一个调料架。
- 要让人写规则文本,先看现成的表达式引擎(SpEL、OGNL、Aviator 这类),别自己发明一门方言。
- 解释器只负责"按规矩算"。数从哪来(检验仪器、接口、手工录入),算完拿去干什么(存库、发通知),都不归它管。
再把失真点挑明。
现实里,报告上的规矩是检验机构定死的,你手里没有。代码里的树是你自己拼的;要改一条上限,得重新发版。想做到改规矩不发版,就得把树从代码里挪到配置里,那是另一件事。
还有一处,比技术更要紧:算出来的只是"提示",不是诊断。
箭头不等于病,参考范围也不是健康的分界线。这段代码能给报告加一句"建议复查",给不了"你没事"或"你有事"。真结论,得让医生看。
05 又转了一圈
看完报告,把手机扣在桌上,又出门转了一圈。
这次没带手机,走得比刚才慢。小区里有人在跑步,跑得不快,一圈一圈,从我身边过去。
报告替不了我做什么。它只是把身体早就想说的话,晚几天递到我手上。
那三项我记进日历,标了红,三个月后再查一次。不是为了盯着数字,是想知道,这时候改,还来不来得及。
跟前面二十篇对一句:单例管造几个,工厂管怎么造,模板方法管事情怎么走,策略管这一步用什么做法,装饰器管外面加几层,命令管把请求做成对象,代理管谁能碰到真东西,观察者管变化后通知谁,外观管一堆零件只留一个入口,状态管当前状态下怎么做,责任链管请求由哪一环接住,原型管拿哪个现成对象复制,桥接管抽象和实现怎么独立扩展,享元管把能共享的部分只留一份,备忘录管把某个时刻的状态存下来、再原样恢复,生成器管把复杂对象的装配拆成一步一步、最后一次性成型,访问者管对一批稳定的元素做什么操作由递进来的单子说了算,组合管单个和一组长一个样,适配器管两套对不上的接口中间怎么搭一座桥,中介者管一堆对象互相说话时怎么都先经过一个中间人。本篇解释器,管一句判断怎么拆成能算的小句、再拼回去算。
带走一句话:解释器 = 一句判断,拆成能算的小句,再一层层拼回去算。
箭头是报告给的,日子是自己过的。
那句"建议复查",几行代码就能拼出来;往后的日子,拼不出来,只能一天天过。
参考资料
- 《设计模式:可复用面向对象软件的基础》(GoF 原著)------解释器模式的原始定义:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子
- Refactoring Guru · Interpreter------把语法规则表示成类,用表达式树逐层求值
- Spring Framework · SpEL------把表达式字符串解析成语法树,再求值;完整解释器多出的那一步就在这里
- MyBatis · Dynamic SQL------
<if test="...">里的条件字符串交给 OGNL 求值