解析器组合子:招牌手艺
每个工程师都逃不开「把字符串变成结构」:配置、日志、协议、DSL。你的武器库大概有两件:正则(写的时候是天才,三个月后是天书)和 ANTLR 这类重炮(为一个日期格式架一门炮?)。上游有第三件,而且是 Haskell 的招牌:解析器组合子。核心只有一句话——解析器是普通的值,所以小解析器能像积木一样拼成大解析器。这一章你会亲手用五个积木拼出一个带优先级的算术解析器,然后把这门手艺的下游版本带回家。
解析器的类型:失败乃兵家常事,剩饭也要打包
从零推导。「解析器」是什么?第一版:吃字符串,吐结构——String -> a。立刻发现两个不够:输入可能非法(要能失败),而且一个小解析器通常只消化输入的开头一段,剩下的要留给下一个解析器。两个修正打包进类型:
newtype Parser a = Parser (String -> Maybe (a, String)) -- ↑输入 ↑失败 ↑结果 ↑没吃完的剩饭 -- 最小积木:满足条件就吃掉一个字符 satisfy :: (Char -> Bool) -> Parser Char satisfy p = Parser $ \s -> case s of (c:rest) | p c -> Just (c, rest) _ -> Nothing digit = satisfy isDigit char c = satisfy (== c)
就这点东西——一个函数类型加两个小积木。接下来是本章的魔法时刻:因为 Parser 是值,峡谷卷的全部机器直接对它生效。「先解析 A 再解析 B」?那是 Monad 的 do。「解析出来顺手加工一下」?那是 fmap。「A 不行就试 B」?加一个新算子 <|>(左边失败就用右边,来自 Alternative 类)。「零个或多个」?many,many1 要求至少一个:
number :: Parser Int number = read <$> many1 digit -- 「至少一个数字,读成 Int」——代码即语法描述 -- 整数:可选负号 + 数字串 integer = do sign <- (char '-' >> pure negate) <|> pure id sign <$> number
盯着 number 的定义读一遍:「number 是至少一个 digit,fmap read」。代码和你嘴里说出来的语法规则几乎逐词对应——这是组合子对正则的第一记重拳:语法长在有名字、有类型、可单测的定义里,而不是一串 (?:-?\d+) 的密文里。
组合的机器从哪来:亲手给 Parser 装引擎
「峡谷卷机器直接生效」不是黑魔法,是三个 instance,每个都短得可以背下来。装一遍,你对「解析器为什么能组合」的理解就落到实处了:
instance Functor Parser where
fmap g (Parser p) = Parser $ \s ->
case p s of
Nothing -> Nothing
Just (a, rest) -> Just (g a, rest) -- 结果加工一下,剩饭原样传
instance Monad Parser where
(Parser p) >>= f = Parser $ \s ->
case p s of
Nothing -> Nothing -- 前一个失败,整体失败
Just (a, rest) -> runParser (f a) rest -- 剩饭喂给下一个解析器!
(<|>) :: Parser a -> Parser a -> Parser a
(Parser p) <|> (Parser q) = Parser $ \s ->
case p s of
Nothing -> q s -- 左边不行,原始输入喂右边
ok -> ok
看 >>= 的最后一行:「剩饭喂给下一个」——输入流的推进被藏进了 bind,这正是第 12 章 State 的手法(解析器本质是 State String 加上失败,你可以验证这个说法)。而 <|> 的「失败就用原始输入重试」就是回溯。三十行,一个解析框架的地基打完;正则引擎你写不出来,这个你现在就写得出来——这是两种工具在「可理解性」上的差距。
优先级:chainl1 一招收平
表达式语言的经典难题——「乘除优先于加减、同级左结合」——在组合子世界是一个高阶函数的事。chainl1 p op 读作:「一个或多个 p,用 op 左结合地连起来」:
expr = term `chainl1` addop -- 表达式 = 项 用加减连接
term = factor `chainl1` mulop -- 项 = 因子 用乘除连接
factor = between (symbol "(") (symbol ")") expr
<|> number -- 因子 = 括号表达式 | 数
addop = (node "+" <$ symbol "+") <|> (node "-" <$ symbol "-")
mulop = (node "*" <$ symbol "*") <|> (node "/" <$ symbol "/")
六行,一门带优先级、带括号、带负号、可扩展的算术语言。优先级藏在哪?藏在调用层级里:expr 由 term 组成、term 由 factor 组成,所以乘除天然比加减「绑得紧」——语法书上的 BNF 分层,一比一翻译成函数分层。下面的工作台跑的就是这套代码的 JS 直译(每个组合子逐个对应),看语法树怎么长、错误怎么报:
先跑好输入看树的形状(乘法节点为什么在加法节点下面?),再试预置的两个坏输入。注意报错的列号:组合子库的标准策略是报告走得最远的那次失败——`2 + * 3` 里,加号后面试过了「括号表达式」和「数字」都不成,失败位置和期望集合(「期望 ( 或数字」)自动汇总。这份报错质量是正则给不了的(正则只会说 no match),也是手写递归下降要额外费力维护的。
TS/JS:parsimmon、arcsecond,以及 TypeScript 编译器团队都在用的手写组合子风格;Kotlin:better-parse(语法几乎照抄本章);Python:parsy;Rust:nom(工业级,零拷贝解析网络协议);Scala:fastparse。更值得说的是「无库胜有库」:一旦领会「解析器 = 吃剩饭的函数 + 三五个组合子」,你完全可以在任何语言里手搓一套 50 行的迷你组合子库——面试写 JSON 解析器、给团队写配置 DSL,这招一出手就是降维打击。本章工作台底下那套 JS 实现,全文不到一百行,就是给你抄的。
为什么说它是「招牌」:pandoc 们的证词
这门手艺在 Haskell 生态里不是玩具,是承重墙。pandoc——你可能用它转过 Markdown——要解析二十几种标记格式,全部构建在组合子上;ShellCheck 解析 Bash 这种「语法灾难现场」级别的语言,靠的是组合子的灵活(Bash 的语法上下文相关,parser generator 反而难伺候);Cardano 的链上数据、Facebook Sigma 的规则语言,同款。生产级的库是 megaparsec(错误信息达到编译器级)和 attoparsec(网络协议级性能),本章的 Maybe 版是它们的教学骨架——真实的库在同一骨架上加了错误累积、状态、回溯控制。
组合子还有一个 parser generator 给不了的能力:解析中途可以跑任意逻辑。「先读一个长度字段 n,再精确读 n 个字节」——这是二进制协议的日常,写出来就是一行 do(n <- length字段; count n byte);而它本质上是上下文相关文法,yacc/ANTLR 的静态文法表达不了,得靠语义动作打补丁。Monad 的「下一步依赖上一步的结果」(第 10 章),在这里变成了真金白银的表达力。
加餐:JSON 解析器,一屏写完
算术只是热身。用同一批积木拼 JSON——语法规则和代码继续逐词对应,整个骨架一屏放得下:
json :: Parser JValue
json = jNull <|> jBool <|> jNumber <|> jString <|> jArray <|> jObject
jNull = JNull <$ symbol "null"
jBool = (JBool True <$ symbol "true") <|> (JBool False <$ symbol "false")
jString = JString <$> stringLit -- 引号串,处理转义
jArray = JArray <$> between (symbol "[") (symbol "]")
(json `sepBy` symbol ",") -- 递归:数组元素又是 json!
jObject = JObject <$> between (symbol "{") (symbol "}")
(pair `sepBy` symbol ",")
where pair = (,) <$> stringLit <* symbol ":" <*> json
两处点睛:jArray 里的递归——json 出现在自己的定义里,毫无特殊处理,因为解析器只是值,值当然可以递归引用(第 7 章惰性的又一次免费午餐);sepBy(逗号分隔列表)这种「语法惯用块」一旦写成组合子,以后所有语言的所有「逗号分隔」全部复用,连同 <*(「两边都解析,只留左边的值」——丢弃冒号的那记)一起进入你的永久工具箱。真实世界的 aeson(Haskell 的 JSON 标准库)骨架与此相同,再加性能工程。面试写 JSON 解析器时,这一屏就是你的地图。
其一,左递归死循环:把 expr 定义成「expr = expr + term」会无限递归(先解析自己?)——这正是 chainl1 存在的理由,遇到左递归文法一律用它改写。其二,回溯的代价:<|> 失败重试意味着输入可能被扫多遍,分支前缀相同的文法(比如「for」和「foreach」)要把公共前缀提出来,或用真实库里的 try 精确控制回溯点。其三,正则没有死:提取一个日期、校验一个邮箱,正则仍然是对的工具——组合子的甜区是「有嵌套结构、有优先级、要好报错」的输入;用工具的层级匹配问题的层级。
上一章的往返句式在这里有个天作之合:pretty-printer 和 parser 互为往返。性质:parse (pretty ast) == Right ast,配上随机 AST 生成器,等于让机器自动构造千奇百怪的程序来轰炸你的解析器——真实编译器团队(GHC 自己、rustc)都在用这招回归测试语法层。你如果动手写了任何 DSL,这条性质请务必安排上:它抓「打印和解析对不齐」的 bug 又快又狠。
1. 在工作台输入 1 - 2 - 3:结果是 -4 还是 2?这验证了 chainl1 的哪个性质?如果想要右结合(比如幂运算),组合子该叫什么名字?
2. 纸上扩展:给 factor 加一条规则支持变量名(字母开头的标识符)。写出修改后的 factor 定义——需要几个新积木?
3. 用你熟悉的语言手搓最小组合子库:实现 satisfy、char、many1、seq2、alt 五个函数(参考本章工作台的实现思路),然后拼一个「解析 CSV 一行」的解析器。目标:全程不写一个正则。
4. 思考:JSON.parse 的手写递归下降解析器和组合子风格,骨架上是什么关系?(提示:每个 parseXxx 函数吃字符串返回「值+游标」——把游标显式化,你就重新发明了本章的 Parser 类型。)