如果你曾经打开过 Yacc 的语法文件,并疑惑为什么构建一门语言需要学习第二门语言,那么你并不孤单。解析器生成器很强大,但对于在 bounded context 中自然涌现的小型 DSL 来说,它们几乎总是大材小用。

你可以用与应用其余部分相同的语言来编写解析器。没有构建步骤,没有语法文件,没有你无法调试的生成代码。

解析器生成器解决了一个你可能没有的问题

ANTLR、Bison 或 PEG.js 等解析器生成器迫使你用领域特定的元语言表达语法。你编写 .g4.y 文件,运行工具,获取生成的源码,将其导入,然后在出问题时祈祷错误消息能说得通。

对于一门完整的编程语言,这种权衡是值得的。生成的 LR 解析器速度很快,而且语法与实现的分离很清晰。

但我们大多数人并不是在构建编程语言。我们构建的是查询过滤器、配置格式、规则引擎,或者是活在单个 bounded context 里的微型表达式语言。解析器生成器的开销、单独的构建步骤、以及需要学习的第二套语法,都是纯粹的摩擦。

解析器组合子把解析变成普通代码

解析器组合子是返回解析器的函数,而解析器是消费输入并返回解析值或错误的函数。你用高阶函数把小解析器组合成大解析器。

解析字符串 "hello" 的解析器是一个函数。解析 "hello" OR "world" 的解析器是一个先尝试第一个、失败再尝试第二个的函数。解析 "hello" THEN "world" 的解析器按顺序运行它们并组合结果。

这意味着你的语法就是代码。你用断点调试它,而不是用语法可视化工具。

40 行 TypeScript 的完整算术解析器

下面是一个微型表达式语言的完整解析器。它处理整数和带括号的加法。没有依赖,没有生成文件。

type Result<T> = 
  | { ok: true; value: T; pos: number }
  | { ok: false; error: string; pos: number };

type Parser<T> = (input: string, pos: number) => Result<T>;

// Primitives: match a literal string or a regex
const str = (expected: string): Parser<string> => (input, pos) => {
  const end = pos + expected.length;
  return input.slice(pos, end) === expected
    ? { ok: true, value: expected, pos: end }
    : { ok: false, error: `expected "${expected}"`, pos };
};

const regex = (re: RegExp): Parser<string> => (input, pos) => {
  const m = input.slice(pos).match(new RegExp(`^(?:${re.source})`));
  return m
    ? { ok: true, value: m[0], pos: pos + m[0].length }
    : { ok: false, error: `expected /${re.source}/`, pos };
};

// Combinators: sequence, choice, map, lazy
const map = <A, B>(p: Parser<A>, f: (a: A) => B): Parser<B> => (input, pos) => {
  const r = p(input, pos);
  return r.ok ? { ...r, value: f(r.value) } : r;
};

const seq = <T extends Parser<unknown>[]>(...ps: T): Parser<any[]> => (input, pos) => {
  const values: unknown[] = [];
  let curr = pos;
  for (const p of ps) {
    const r = p(input, curr);
    if (!r.ok) return r;
    values.push(r.value);
    curr = r.pos;
  }
  return { ok: true, value: values, pos: curr };
};

const or = <A, B>(a: Parser<A>, b: Parser<B>): Parser<A | B> => (input, pos) => {
  const r = a(input, pos);
  return r.ok ? r : b(input, pos);
};

const lazy = <T>(fn: () => Parser<T>): Parser<T> => (input, pos) => fn()(input, pos);

// Grammar: expr := number | "(" expr "+" expr ")"
const ws = regex(/\s*/);
const number = map(regex(/\d+/), n => parseInt(n, 10));

let expr: Parser<{ val: number }>;

expr = or(
  map(
    seq(str("("), ws, lazy(() => expr), ws, str("+"), ws, lazy(() => expr), str(")")),
    ([, , left, , , , right]) => ({ val: left.val + right.val })
  ),
  map(number, val => ({ val }))
);

// Run it
const result = expr("(1 + (2 + 3))", 0);
console.log(result.ok ? result.value : result.error);
// Output: { val: 6 }

让我们拆解一下实际发生了什么。strregexnumber 是原始解析器。seq 按顺序运行解析器。or 尝试备选。map 转换结果。lazy 是唯一微妙的环节。它将求值推迟,以免递归语法在定义时就爆炸。

类型系统追踪每个解析器的产出。解析失败时,你能得到精确的位置和期望的 token。这已经比大多数生成解析器开箱即用的错误报告更好了。

为什么它特别适合 bounded context

领域驱动设计中的 bounded context 被故意设计得很小。活在其中的 DSL 也应该很小。它只需要该 context 关心的那些构造,别的什么都不需要。

解析器组合子向下缩放得非常漂亮。你写的是域名所需的那一个查询过滤器的解析器,而不是一门通用查询语言。你添加新构造只需添加一个新函数,而不是重新生成上千行 C 代码。

解析器与 bounded context 的其余部分活在同一个仓库、同一门语言、同一种心智模型里。领域模型变了,解析器跟着变。没有语法文件在另一个目录里渐渐失步。

组合子的短板在哪里

它们不是免费的。组合子在底层构建的是递归下降解析器,而递归下降解析器对左递归规则束手无策。如果你写 expr := expr + number | number,解析器会永远调用自己。

你需要把左递归规则改写成循环,或者使用替你处理左递归的库。对于小型 DSL,这很少成为实际问题。

性能是另一个注意事项。手工调优的递归下降解析器或生成的 LR 解析器在原始吞吐上能击败组合子。对于启动时只读一次的配置文件,或者每个请求评估一次的规则,差距在微秒级别。在假设它重要之前,先测量。

如何开始构建你自己的

从最小的语法开始。解析一个构造,测试它,然后组合。

如果你写 TypeScript,parsimmonarcsecondchevrotain 等库能提供比上面从零实现的版本更好的错误消息和左递归处理能力的生产级组合子。Rust 有 nom,Haskell 有 Parsec,Python 有 parsy

模式到处都一样:primitives、sequence、choice、repetition、transformation。学一次,用在任何语言里。

FAQ

什么是解析器组合子?

解析器组合子是一个接受一个或多个解析器并返回新解析器的高阶函数。它们让你通过组合简单解析器来构建复杂解析器,使用普通代码而不是单独的语法语言。

什么时候我仍然应该使用解析器生成器?

当你解析一门完整编程语言、需要最大解析吞吐量、或者你的团队对某个特定生成器生态系统已有深厚专长时,再使用生成器。对于小型 DSL,通常不值得承担这个开销。

解析器组合子慢吗?

它们比手写或生成的 LR 解析器慢,但对于大多数 DSL 用例来说,差距无关紧要。一个典型的组合子解析器每毫秒能处理数千个 token。这对于配置文件、查询字符串和规则引擎来说已经足够快了。

我能在 TypeScript 以外的语言中使用解析器组合子吗?

当然。这个模式与语言无关。Rust 的 nom、Haskell 的 Parsec、Python 的 parsy 和 Haskell 的 attoparsec 都是成熟且广泛使用的库。

下次你在 bounded context 里需要一门微型语言时,问问自己是否真的需要语法文件和代码生成步骤。对于大多数 DSL,答案是否定的。一百行组合子代码就能给你一个可以在调试器里单步执行、无需重新生成即可扩展、而且半年后还能真正理解的解析器。