第 27 章 · 解释器(Interpreter)
一句话:给一门很小的语言定好文法,每条文法规则写成一个类,句子变成这些类组成的树,对树调用
interpret就得到结果。 解决的痛:运营每想一条新的筛选规则,开发就要写一段条件、发一次版;有人为了省事拿eval直接执行运营敲的字符串,后台输入框从此能执行任意代码。
27.1 一个场景
Section titled “27.1 一个场景”多练网周边商城的首页有好几个专区:“百元新品”“有货图书”……每个专区背后是一条商品筛选规则,运营用一行文字写,比如 price < 100 and tag = 新品、category = 图书 or stock > 0。
商品能筛的字段有四个:价格 price、标签 tag、分类 category、库存 stock。现在要做的是:给定一条规则,从商品列表里挑出符合的商品。
27.2 先按直觉写
Section titled “27.2 先按直觉写”规则不多,运营后台给一个下拉框,开发把每条规则写成一段条件,用规则文本当键:
from dataclasses import dataclassfrom typing import Callable
@dataclassclass Product: name: str price: int tag: str category: str stock: int
PRODUCTS = [ Product("机械键盘", 399, "热卖", "外设", 12), Product("设计模式手册", 69, "新品", "图书", 0), Product("鼠标垫", 29, "清仓", "外设", 40), Product("算法图解", 59, "热卖", "图书", 8), Product("帆布袋", 45, "新品", "周边", 0),]
# 运营后台下拉框里的每条规则,都在这里对应一段写死的条件RULES: dict[str, Callable[[Product], bool]] = { "price < 100 and tag = 新品": lambda p: p.price < 100 and p.tag == "新品", "category = 图书 or stock > 0": lambda p: p.category == "图书" or p.stock > 0,}
def select(rule: str, products: list[Product]) -> list[str]: check = RULES.get(rule) if check is None: raise ValueError(f"不支持的规则:{rule}") return [p.name for p in products if check(p)]
if __name__ == "__main__": for rule in RULES: print(f"{rule} -> {'、'.join(select(rule, PRODUCTS))}")
# 输出:# price < 100 and tag = 新品 -> 设计模式手册、帆布袋# category = 图书 or stock > 0 -> 机械键盘、设计模式手册、鼠标垫、算法图解TS 版一样:
type Product = { name: string; price: number; tag: string; category: string; stock: number };
const PRODUCTS: Product[] = [ { name: "机械键盘", price: 399, tag: "热卖", category: "外设", stock: 12 }, { name: "设计模式手册", price: 69, tag: "新品", category: "图书", stock: 0 }, { name: "鼠标垫", price: 29, tag: "清仓", category: "外设", stock: 40 }, { name: "算法图解", price: 59, tag: "热卖", category: "图书", stock: 8 }, { name: "帆布袋", price: 45, tag: "新品", category: "周边", stock: 0 },];
// 运营后台下拉框里的每条规则,都在这里对应一段写死的条件const RULES: Record<string, (p: Product) => boolean> = { "price < 100 and tag = 新品": (p) => p.price < 100 && p.tag === "新品", "category = 图书 or stock > 0": (p) => p.category === "图书" || p.stock > 0,};
function select(rule: string, products: Product[]): string[] { const check = RULES[rule]; if (check === undefined) throw new Error(`不支持的规则:${rule}`); return products.filter(check).map((p) => p.name);}
for (const rule of Object.keys(RULES)) { console.log(`${rule} -> ${select(rule, PRODUCTS).join("、")}`);}
// 输出:// price < 100 and tag = 新品 -> 设计模式手册、帆布袋// category = 图书 or stock > 0 -> 机械键盘、设计模式手册、鼠标垫、算法图解两条规则两段 lambda,一眼能看懂。规则一个月才加一两条时,大多数人第一版都这么写。
27.3 需求变了
Section titled “27.3 需求变了”新需求是:规则由运营在后台直接输入,保存后立刻生效,不再走发版;还要支持括号和 not,比如 price < 100 and not tag = 清仓。
要改的地方:
RULES字典:运营能敲出的规则无穷多,字典只列得出写好的几条,每加一条还是改代码、发版。not和括号:能和已有条件任意组合,a and not b、(a or b) and c……每种组合又是一个新条目。- 有人想绕过发版:把规则里的
=换成==,交给 Python 的eval去算。
运营在后台敲了第一条新规则。把 before.py 的入口换成下面这样(文件头加 import ast),看看会发生什么:
if __name__ == "__main__": typed = "price < 100 and not tag = 清仓" # 运营在后台敲的新规则 try: select(typed, PRODUCTS) except ValueError as e: print(e) for text in ["100 < 200", "price < 100"]: # 有人提议用"安全的" ast.literal_eval try: ast.literal_eval(text) except ValueError as e: print(f"literal_eval({text!r}):{type(e).__name__}")运行结果如下:
不支持的规则:price < 100 and not tag = 清仓literal_eval('100 < 200'):ValueErrorliteral_eval('price < 100'):ValueError第一行就是 ①②:规则文本对不上字典里的键,专区直接空了。
第 ③ 条更糟。eval 会把整个字符串当 Python 代码执行:有人在输入框里填 __import__('os') 开头、后面接删文件调用的一串,服务器就照着执行。把 __builtins__ 清空也挡不住,绕过的写法很多。何况它本身也不好用:tag == 新品 里的“新品”被当成变量名,报 NameError;price <= 60 换完成了 price <== 60,语法错误。
有人提议换成只认字面量的 ast.literal_eval,安全是安全,但后两行说明它连 100 < 200 都不算,更别说读商品字段。
问题的形状是:规则是一门小语言,写死只能覆盖列出来的几句,用 eval 又把整门 Python 交给了输入框。
浅红的 ①② 是字典和组合爆炸,③ 是 eval 应急;加粗的两个框是事故和风险。
27.4 用解释器模式重写
Section titled “27.4 用解释器模式重写”先把这门小语言的文法(grammar)写出来,一共四行:
or_expr = and_expr ("or" and_expr)*and_expr = unary ("and" unary)*unary = "not" unary | "(" or_expr ")" | comparecompare = field op value field 只能是 price tag category stock,op 是 < <= > >= =解释器模式的做法是:每条文法规则一个类,Compare(field, op, value)、And、Or、Not,都实现 interpret(product) -> bool。一条规则就是这些对象组成的一棵树,对树根调 interpret,它再调子节点的 interpret,层层往下。
把字符串变成树的是 Parser,一个几十行的递归下降解析器(recursive descent parser):文法前三行各对应它的一个方法,第四行写在 unary 的末尾。解析器是配角,不属于 GoF 的解释器模式。GoF 只管“树已经有了,怎么解释”,怎么建树留给客户端自己解决。
import refrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom typing import Any, Callable
@dataclassclass Product: name: str price: int tag: str category: str stock: int
PRODUCTS = [ Product("机械键盘", 399, "热卖", "外设", 12), Product("设计模式手册", 69, "新品", "图书", 0), Product("鼠标垫", 29, "清仓", "外设", 40), Product("算法图解", 59, "热卖", "图书", 8), Product("帆布袋", 45, "新品", "周边", 0),]
FIELDS = {"price", "tag", "category", "stock"} # 规则里只能出现这几个字段OPS: dict[str, Callable[[Any, Any], bool]] = { "<": lambda a, b: a < b, "<=": lambda a, b: a <= b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "=": lambda a, b: a == b,}
class Expr(ABC): # 抽象表达式:每个节点都能对一件商品给出真假 @abstractmethod def interpret(self, product: Product) -> bool: ...
class Compare(Expr): # 终结符表达式:field op value def __init__(self, field: str, op: str, value: int | str) -> None: self.field, self.op, self.value = field, op, value
def interpret(self, product: Product) -> bool: return OPS[self.op](getattr(product, self.field), self.value)
def __str__(self) -> str: return f"{self.field} {self.op} {self.value}"
class And(Expr): # 非终结符表达式:两边都为真 def __init__(self, left: Expr, right: Expr) -> None: self.left, self.right = left, right
def interpret(self, product: Product) -> bool: return self.left.interpret(product) and self.right.interpret(product)
def __str__(self) -> str: return f"({self.left} and {self.right})"
class Or(Expr): # 非终结符表达式:有一边为真 def __init__(self, left: Expr, right: Expr) -> None: self.left, self.right = left, right
def interpret(self, product: Product) -> bool: return self.left.interpret(product) or self.right.interpret(product)
def __str__(self) -> str: return f"({self.left} or {self.right})"
class Not(Expr): # 非终结符表达式:取反 def __init__(self, operand: Expr) -> None: self.operand = operand
def interpret(self, product: Product) -> bool: return not self.operand.interpret(product)
def __str__(self) -> str: return f"not {self.operand}"
class RuleError(ValueError): pass
class Parser: # 配角:递归下降,把规则字符串变成表达式树;不属于 GoF 的解释器模式 def __init__(self, text: str) -> None: self.tokens: list[str] = re.findall(r"<=|>=|[()<>=]|[^\s()<>=]+", text) self.pos = 0
def parse(self) -> Expr: expr = self.or_expr() if self.pos < len(self.tokens): raise RuleError(f"多出来的 {self.tokens[self.pos]}") return expr
def peek(self) -> str | None: return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def take(self) -> str: if self.pos >= len(self.tokens): raise RuleError("规则没写完") self.pos += 1 return self.tokens[self.pos - 1]
def or_expr(self) -> Expr: # or_expr = and_expr ("or" and_expr)* left = self.and_expr() while self.peek() == "or": self.take() left = Or(left, self.and_expr()) return left
def and_expr(self) -> Expr: # and_expr = unary ("and" unary)* left = self.unary() while self.peek() == "and": self.take() left = And(left, self.unary()) return left
def unary(self) -> Expr: # unary = "not" unary | "(" or_expr ")" | compare if self.peek() == "not": self.take() return Not(self.unary()) if self.peek() == "(": self.take() expr = self.or_expr() if self.take() != ")": raise RuleError("缺右括号") return expr field, op, value = self.take(), self.take(), self.take() if field not in FIELDS: raise RuleError(f"未知字段 {field}") if op not in OPS: raise RuleError(f"未知运算符 {op}") return Compare(field, op, int(value) if value.isdigit() else value)
if __name__ == "__main__": rules = [ "price < 100 and tag = 新品", "category = 图书 or stock > 0", "price < 100 and not tag = 清仓", "(category = 图书 or category = 周边) and price <= 60", "__import__('os')", ] for text in rules: # 规则来自运营后台,不用发版 try: tree = Parser(text).parse() except RuleError as e: print(f"{text} -> 规则有误:{e}") continue names = [p.name for p in PRODUCTS if tree.interpret(p)] print(f"{tree} -> {'、'.join(names)}")
# 输出:# (price < 100 and tag = 新品) -> 设计模式手册、帆布袋# (category = 图书 or stock > 0) -> 机械键盘、设计模式手册、鼠标垫、算法图解# (price < 100 and not tag = 清仓) -> 设计模式手册、算法图解、帆布袋# ((category = 图书 or category = 周边) and price <= 60) -> 算法图解、帆布袋# __import__('os') -> 规则有误:未知字段 __import__TS 版一样:
type Product = { name: string; price: number; tag: string; category: string; stock: number };type Field = "price" | "tag" | "category" | "stock";type Value = number | string;
const PRODUCTS: Product[] = [ { name: "机械键盘", price: 399, tag: "热卖", category: "外设", stock: 12 }, { name: "设计模式手册", price: 69, tag: "新品", category: "图书", stock: 0 }, { name: "鼠标垫", price: 29, tag: "清仓", category: "外设", stock: 40 }, { name: "算法图解", price: 59, tag: "热卖", category: "图书", stock: 8 }, { name: "帆布袋", price: 45, tag: "新品", category: "周边", stock: 0 },];
const FIELDS: readonly string[] = ["price", "tag", "category", "stock"]; // 规则里只能出现这几个字段const OPS: Record<string, (a: Value, b: Value) => boolean> = { "<": (a, b) => a < b, "<=": (a, b) => a <= b, ">": (a, b) => a > b, ">=": (a, b) => a >= b, "=": (a, b) => a === b,};
interface Expr { // 抽象表达式:每个节点都能对一件商品给出真假 interpret(product: Product): boolean;}
class Compare implements Expr { // 终结符表达式:field op value constructor(readonly field: Field, readonly op: string, readonly value: Value) {}
interpret(product: Product): boolean { return OPS[this.op](product[this.field], this.value); }
toString(): string { return `${this.field} ${this.op} ${this.value}`; }}
class And implements Expr { // 非终结符表达式:两边都为真 constructor(readonly left: Expr, readonly right: Expr) {}
interpret(product: Product): boolean { return this.left.interpret(product) && this.right.interpret(product); }
toString(): string { return `(${this.left} and ${this.right})`; }}
class Or implements Expr { // 非终结符表达式:有一边为真 constructor(readonly left: Expr, readonly right: Expr) {}
interpret(product: Product): boolean { return this.left.interpret(product) || this.right.interpret(product); }
toString(): string { return `(${this.left} or ${this.right})`; }}
class Not implements Expr { // 非终结符表达式:取反 constructor(readonly operand: Expr) {}
interpret(product: Product): boolean { return !this.operand.interpret(product); }
toString(): string { return `not ${this.operand}`; }}
class RuleError extends Error {}
class Parser { // 配角:递归下降,把规则字符串变成表达式树;不属于 GoF 的解释器模式 readonly tokens: string[]; pos = 0;
constructor(text: string) { this.tokens = text.match(/<=|>=|[()<>=]|[^\s()<>=]+/g) ?? []; }
parse(): Expr { const expr = this.orExpr(); if (this.pos < this.tokens.length) throw new RuleError(`多出来的 ${this.tokens[this.pos]}`); return expr; }
peek(): string | undefined { return this.tokens[this.pos]; }
take(): string { if (this.pos >= this.tokens.length) throw new RuleError("规则没写完"); return this.tokens[this.pos++]; }
orExpr(): Expr { // or_expr = and_expr ("or" and_expr)* let left = this.andExpr(); while (this.peek() === "or") { this.take(); left = new Or(left, this.andExpr()); } return left; }
andExpr(): Expr { // and_expr = unary ("and" unary)* let left = this.unary(); while (this.peek() === "and") { this.take(); left = new And(left, this.unary()); } return left; }
unary(): Expr { // unary = "not" unary | "(" or_expr ")" | compare if (this.peek() === "not") { this.take(); return new Not(this.unary()); } if (this.peek() === "(") { this.take(); const expr = this.orExpr(); if (this.take() !== ")") throw new RuleError("缺右括号"); return expr; } const [field, op, value] = [this.take(), this.take(), this.take()]; if (!FIELDS.includes(field)) throw new RuleError(`未知字段 ${field}`); if (!(op in OPS)) throw new RuleError(`未知运算符 ${op}`); return new Compare(field as Field, op, /^\d+$/.test(value) ? Number(value) : value); }}
const rules = [ "price < 100 and tag = 新品", "category = 图书 or stock > 0", "price < 100 and not tag = 清仓", "(category = 图书 or category = 周边) and price <= 60", "__import__('os')",];for (const text of rules) { // 规则来自运营后台,不用发版 let tree: Expr; try { tree = new Parser(text).parse(); } catch (e) { if (!(e instanceof RuleError)) throw e; console.log(`${text} -> 规则有误:${e.message}`); continue; } const names = PRODUCTS.filter((p) => tree.interpret(p)).map((p) => p.name); console.log(`${tree} -> ${names.join("、")}`);}
// 输出:// (price < 100 and tag = 新品) -> 设计模式手册、帆布袋// (category = 图书 or stock > 0) -> 机械键盘、设计模式手册、鼠标垫、算法图解// (price < 100 and not tag = 清仓) -> 设计模式手册、算法图解、帆布袋// ((category = 图书 or category = 周边) and price <= 60) -> 算法图解、帆布袋// __import__('os') -> 规则有误:未知字段 __import__输出按树打印,括号显示了分组:and 比 or 结合得紧,not 只管紧跟它的一项。
黄色的是新加的:解析器只建树,四个表达式类负责解释;未知字段、未知运算符、括号不配对这类输入走 RuleError。
蓝色的 And、Not 是非终结符,下面挂着子表达式;黄色的 Compare 是终结符,直接读商品字段。
对照 27.3:
- 第 ① 处:规则是后台存的一行字符串,保存后下次筛选就按新树走,不发版。
- 第 ② 处:
not和括号各在文法里占一行,组合多少种都是同一套类,不再一条条列。 - 第 ③ 处:没有
eval。解析器只认四个字段和五个运算符,__import__('os')在切词后就被当成“未知字段”拒绝,什么也没执行。
代价也要说清楚。第一,解析器得自己写、自己测,括号不配对、缺值的报错都要自己做,这部分比四个表达式类还长。第二,语言一长大(函数调用、日期比较),手写的递归下降就难维护了,该换现成的解析库,或干脆用已有的语言:存成 SQL 的 WHERE 子句交给数据库,或用 JSON Logic 这类规则格式。手写只适合很小的语言。
27.5 模式定义
Section titled “27.5 模式定义”GoF 对解释器的定义是:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。
落到本章:“文法的一种表示”就是 Compare、And、Or、Not 这几个类,一个句子表示成它们组成的一棵抽象语法树(abstract syntax tree,AST);“解释器”就是每个类上的 interpret。GoF 书里的示例代码恰好也是布尔表达式,用 AndExp、OrExp、NotExp 这几个类。
对照案例图:Expr 是 AbstractExpression,Compare 是 TerminalExpression,And、Or、Not 是 NonterminalExpression;被筛选的商品就是 interpret(context) 里的 context。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 抽象表达式(AbstractExpression) | Expr |
声明 interpret(context) |
| 终结符表达式(TerminalExpression) | Compare |
文法里不再往下分的部分,直接读上下文给出结果 |
| 非终结符表达式(NonterminalExpression) | And、Or、Not |
持有子表达式,先让它们 interpret,再组合结果 |
| 上下文(Context) | Product |
解释时要读的外部数据 |
| 客户端(Client) | 筛选入口(借 Parser 建树) |
拿到树,对每件商品调 interpret |
常见场景:
- 规则引擎:促销条件、风控规则。
- 过滤表达式:本章的商品筛选、日志查询语法。
- 简单模板:
你好,{name}这类占位符替换。 - 配置里的小表达式:告警配置里的
cpu > 80 and duration > 5m。
27.6 真实系统里的例子
Section titled “27.6 真实系统里的例子”例一:fnmatch 的通配符(Python)
Section titled “例一:fnmatch 的通配符(Python)”*.py、[a-c]?.txt 这种 shell 通配符(glob)是一门小语言:* 匹配任意多个字符,? 匹配一个,[a-c] 匹配范围里的一个。标准库 fnmatch 大致这样解释它:translate 把通配符翻译成正则,再交给 re 匹配,不建对象树。GoF 书里讲解释器用的例子正是正则表达式。下面先看真实的 translate,再手写一个对象版,只支持 * 和 ?:
import fnmatchimport refrom abc import ABC, abstractmethod
# 一、标准库:fnmatch 把 shell 通配符翻译成正则表达式,再交给 re 去匹配regex = fnmatch.translate("*.py")print(f"translate('*.py') = {regex}") # 不同 Python 版本写法略有不同,这是 3.12 的结果files = ["main.py", "main.pyc", "a1.txt", "ab.txt", "d1.txt"]print(f"re 匹配:{[f for f in files if re.match(regex, f)]}")print(f"[a-c]?.txt:{[f for f in files if fnmatch.fnmatchcase(f, '[a-c]?.txt')]}")
# 二、自己写:每个通配符字符是一个表达式对象(只支持 * 和 ?)class Glob(ABC): # 抽象表达式:从 text[i] 开始匹配自己,成功就把剩下的交给 rest @abstractmethod def interpret(self, text: str, i: int, rest: list["Glob"]) -> bool: ...
def run(parts: list[Glob], text: str, i: int) -> bool: # 顺序表达式:一个接一个 return i == len(text) if not parts else parts[0].interpret(text, i, parts[1:])
class Literal(Glob): # 普通字符:必须一模一样 def __init__(self, char: str) -> None: self.char = char
def interpret(self, text: str, i: int, rest: list[Glob]) -> bool: return i < len(text) and text[i] == self.char and run(rest, text, i + 1)
class AnyChar(Glob): # ?:任意一个字符 def interpret(self, text: str, i: int, rest: list[Glob]) -> bool: return i < len(text) and run(rest, text, i + 1)
class AnyRun(Glob): # *:任意多个字符,挨个试吃掉 0、1、2……个 def interpret(self, text: str, i: int, rest: list[Glob]) -> bool: return any(run(rest, text, j) for j in range(i, len(text) + 1))
def compile_glob(pattern: str) -> list[Glob]: # "解析器":一个字符一个节点 return [AnyRun() if c == "*" else AnyChar() if c == "?" else Literal(c) for c in pattern]
for pattern in ["*.py", "?1.txt"]: parts = compile_glob(pattern) mine = [f for f in files if run(parts, f, 0)] real = [f for f in files if fnmatch.fnmatchcase(f, pattern)] print(f"{pattern}:自己写 {mine},和 fnmatchcase 相同:{mine == real}")
# 输出:# translate('*.py') = (?s:.*\.py)\Z# re 匹配:['main.py']# [a-c]?.txt:['a1.txt', 'ab.txt']# *.py:自己写 ['main.py'],和 fnmatchcase 相同:True# ?1.txt:自己写 ['a1.txt', 'd1.txt'],和 fnmatchcase 相同:Truetranslate 的输出写法随 Python 版本略有不同,上面是 3.12 的。对比用 fnmatchcase,因为 fnmatch.fnmatch 在 Windows 上不分大小写。Literal、AnyChar、AnyRun 是终结符,run 把它们串成顺序表达式,compile_glob 是只有一行的“解析器”。
例二:MongoDB 风格的查询对象(TypeScript)
Section titled “例二:MongoDB 风格的查询对象(TypeScript)”MongoDB 的查询写成对象:{ price: { $lt: 100 }, tag: "新品" }。sift、mingo 这类库能在内存里对普通数组执行同样的查询,大致是把查询对象解释成一组判断。查询对象本身就是树,省掉了解析。下面手写一个只支持 $lt、$gt、$eq、$and、$or 的版本:
// 简化模型:sift、mingo 这类库在内存里解释 MongoDB 风格的查询对象(大致如此,真实的库支持的运算符多得多)type Doc = Record<string, number | string>;type Op = "$lt" | "$gt" | "$eq";type Query = { [key: string]: number | string | Record<string, number | string> | Query[] };
interface Expr { // 抽象表达式 interpret(doc: Doc): boolean;}
class FieldExpr implements Expr { // 终结符表达式:{ price: { $lt: 100 } } 里的一个条件 constructor(readonly field: string, readonly op: Op, readonly value: number | string) {}
interpret(doc: Doc): boolean { const actual = doc[this.field]; if (this.op === "$eq") return actual === this.value; return this.op === "$lt" ? actual < this.value : actual > this.value; }}
class AllOf implements Expr { // 非终结符表达式:$and,以及顶层的多个键 constructor(readonly parts: Expr[]) {} interpret(doc: Doc): boolean { return this.parts.every((p) => p.interpret(doc)); }}
class AnyOf implements Expr { // 非终结符表达式:$or constructor(readonly parts: Expr[]) {} interpret(doc: Doc): boolean { return this.parts.some((p) => p.interpret(doc)); }}
function parse(query: Query): Expr { // 查询对象本身已经是一棵树,"解析"只是把它换成表达式对象 const parts: Expr[] = []; for (const [key, value] of Object.entries(query)) { if (key === "$and" || key === "$or") { const subs = (value as Query[]).map(parse); parts.push(key === "$and" ? new AllOf(subs) : new AnyOf(subs)); } else if (key.startsWith("$")) { throw new Error(`不支持的运算符 ${key}`); } else if (typeof value === "object") { for (const [op, v] of Object.entries(value)) { if (op !== "$lt" && op !== "$gt" && op !== "$eq") throw new Error(`不支持的运算符 ${op}`); parts.push(new FieldExpr(key, op, v)); } } else { parts.push(new FieldExpr(key, "$eq", value)); // tag: "新品" 是 { $eq: "新品" } 的简写 } } return new AllOf(parts);}
const products: Doc[] = [ { name: "机械键盘", price: 399, tag: "热卖", category: "外设", stock: 12 }, { name: "设计模式手册", price: 69, tag: "新品", category: "图书", stock: 0 }, { name: "鼠标垫", price: 29, tag: "清仓", category: "外设", stock: 40 }, { name: "帆布袋", price: 45, tag: "新品", category: "周边", stock: 0 },];const queries: Query[] = [ { price: { $lt: 100 }, tag: "新品" }, { $or: [{ category: "图书" }, { stock: { $gt: 0 } }] }, { name: { $regex: "键盘" } }, // 真实的库支持 $regex,这个简化版不认识];for (const q of queries) { try { const tree = parse(q); console.log(`${JSON.stringify(q)} -> ${products.filter((d) => tree.interpret(d)).map((d) => d.name).join("、")}`); } catch (e) { console.log(`${JSON.stringify(q)} -> ${(e as Error).message}`); }}
// 输出:// {"price":{"$lt":100},"tag":"新品"} -> 设计模式手册、帆布袋// {"$or":[{"category":"图书"},{"stock":{"$gt":0}}]} -> 机械键盘、设计模式手册、鼠标垫// {"name":{"$regex":"键盘"}} -> 不支持的运算符 $regexFieldExpr 对应 Compare,AllOf、AnyOf 对应 And、Or。不认识的运算符直接拒绝,查询内容不会被当成代码执行。
27.7 什么时候别用
Section titled “27.7 什么时候别用”- 语言会长大。下个月要加函数、日期运算、嵌套字段,每加一样都要改解析器、加类。这时用解析器生成器(如 Lark、ANTLR),或直接嵌入现成的语言(SQL、JSON Logic、Lua)。
- 对性能敏感。每件商品都要从树根走到叶子,一层层方法调用。商品多、规则又不常变时,把树一次编译成函数或正则会更快,
fnmatch就是这么做的。 - 其实只需要几个固定开关。运营要的若只是“价格上限”和“是否只看有货”,做成两个配置项就够了,用不着一门语言。
27.8 容易混淆的模式
Section titled “27.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 解释器(Interpreter) | 给一门小语言定义文法,按文法解释句子 | 树的形状由文法决定,每种节点对应一条文法规则,重点在“这句话什么意思” |
| 组合(Composite) | 把对象组织成树,单个和整体一样对待 | 表达式树本身就是组合:And 是容器,Compare 是叶子,都实现 interpret。见第 13 章 |
| 访问者(Visitor) | 不改节点类,给一组节点类加新操作 | 同一棵表达式树要求值、打印、优化时,可以把这些操作写成访问者,节点只留 accept。见第 26 章 |
两组都能用一个问题当场判断:
- 解释器还是组合:问“这棵树是不是一句话”。第 13 章的礼盒树是商品的包含关系;本章的树来自一句规则,节点种类由文法定死。
- 解释器还是访问者:问“操作多不多”。只有求值,写在
interpret上最直接;还要打印、化简,每加一种都得改四个类,这时换成第 26 章练一练那样的访问者。
27.9 练一练
Section titled “27.9 练一练”给筛选语言加 in 运算符:tag in [新品, 热卖] 表示标签是两者之一,也能和 and、or、not 组合。写出文法要改的那一行,加一个 In 类,让解析器认识方括号和逗号。用 tag in [新品, 热卖] and not category = 图书 验证,再试一条少了右方括号的规则,看报错。Python 和 TS 各写一版。
提示:切词的正则里加上 [、] 和 ,;In 是终结符,和 Compare 一样直接读商品字段。
27.10 小结
Section titled “27.10 小结”- 解释器让一门小语言的每条文法规则对应一个类,句子是这些对象组成的树,对树根调
interpret得到结果。 - 规则成了数据:运营输入、保存即生效;
not、括号这些组合由文法覆盖,不用一条条写死。 - 不用
eval:解释器只认文法里有的东西,别的输入一律拒绝。 - 解析器是配角,不属于模式本身;语言一长大,就换成现成的解析库或已有的语言。