解析与 DSL:Instaparse 与解析器组合子

深入 Clojure 的解析与领域特定语言(DSL):用 Instaparse 的 EBNF 语法构建解析器、CFG/PEG 两种模式与歧义处理、解析器组合子手写递归下降、错误定位与语法可视化调试,以及把语法树编译为可执行表达式的完整链路。

写一个领域特定语言(Domain-Specific Language, DSL),本质上是做三件事:定义语法、解析成抽象语法树(AST)、把 AST 解释或编译成可执行逻辑。Clojure 因为「代码即数据」(Homoiconicity),天然适合承载 DSL——很多场景甚至不需要字符串解析,直接用宏在数据结构层面扩展语言。

但当你需要解析外部文本(配置文件、查询语言、日志格式、表达式)时,就需要真正的解析器。Instaparse 让你用 EBNF 描述语法、几行代码得到解析器;而解析器组合子(Parser Combinator)则在需要精细控制时提供另一种选择。本文从语法定义讲到 AST 求值,串起一条完整的 DSL 实现链路。

1. 三种 DSL 实现路径

路径输入工具典型场景
宏(Macro)Clojure 形式defmacro内部 DSL、语言扩展
数据驱动EDN/数据结构解释器规则引擎、配置
文本解析字符串Instaparse、组合子外部语法、查询语言

宏路线的原理见 Clojure 宏编程 ;本文聚焦第三条——把字符串解析成 AST。

2. Instaparse 基础

2.1 引入与最小示例

(ns myapp.expr
  (:require [instaparse.core :as insta]))

(def arithmetic
  "expr   = add
   add    = add <'+'> mult | mult
   mult   = mult <'*'> num | num
   num    = #'[0-9]+'")

(def parse (insta/parser arithmetic))

(parse "1+2*3")
;; => [:add [:num "1"] [:mult [:num "2"] [:num "3"]]]

insta/parser 返回一个函数,输入字符串、输出解析树(解析树就是嵌套的 Clojure vector)。<'+'> 里的尖括号表示隐藏该终结符,让它不出现在结果里——这正是把「1+2」变成 [:add [:num "1"] [:num "2"]] 而非 [:add [:num "1"] [:op "+"] ...] 的关键。

2.2 EBNF 语法要素

语法含义示例
=规则定义expr = add
|或a = b | c
*零或多次list = item*
+一或多次digits = #'[0-9]'+
?零或一次sign = '-'?
<...>隐藏<'+'>
#'...'正则终结符#'[0-9]+'
(...)分组(a b)+
!负向前瞻(PEG)!(x) y

2.3 大小写与规则命名

Instaparse 默认大小写敏感。终结符(字符串字面量、正则)保留原样,非终结符的规则名会成为解析树的标签。规则名建议用 kebab-case:

(def config-grammar
  "config   = section+
   section  = <'['> name <']'> entry*
   entry    = key <'='> value
   key      = #'[a-zA-Z][a-zA-Z0-9_.]*'
   value    = #'[^\\n]*'
   name     = #'[a-zA-Z0-9_]+'")

3. CFG 与 PEG:两种解析模式

3.1 上下文无关文法(CFG)

默认模式是 CFG,允许歧义(Ambiguity)。当输入有多个合法解析时,Instaparse 返回一个 :instaparse.gll/failure 之外的特殊结构——用 insta/parses 可以看到全部解析:

(def ambiguous
  "expr = expr expr | #'[a-z]+'")

(insta/parses ambiguous "abc def ghi")
;; => 返回多个等价的解析树(结合方式不同)

CFG 模式(GLL 算法)能处理左递归,代价是速度较慢、歧义需消解。

3.2 PEG 模式

加上 :parser :peg 切换到解析表达式文法(Parsing Expression Grammar),它用有序选择消除歧义——第一个匹配成功的分支胜出:

(def parse-peg
  (insta/parser
    "stmt = assign | expr
     assign = ident <'='> expr
     expr   = ident
     ident  = #'[a-z]+'"
    :parser :peg))

(parse-peg "x = y")
;; => [:assign [:ident \"x\"] [:ident \"y\"]]

PEG 更快、无歧义,但不支持左递归,且规则顺序敏感(assign 必须排在 expr 前,否则 x = y 会被 expr 匹配掉 x 就停)。

3.3 如何选

需求推荐
自然语言/易歧义语法CFG(GLL)
编程语言、性能敏感PEG
需要左递归的表达式CFG
想要确定性、可预测PEG

4. 把解析树变成 AST

4.1 转换(transform)

Instaparse 的 transform 用 map 描述「标签 → 处理函数」:

(require '[instaparse.core :as insta])

(def to-ast
  (insta/transform
    {:add  (fn [a b] (list '+ a b))
     :mult (fn [a b] (list '* a b))
     :num  (fn [s] (Long/parseLong s))}))

(to-ast (parse "1+2*3"))
;; => (+ 1 (* 2 3))

注意结果 (+ 1 (* 2 3)) 恰好是合法的 Clojure 代码——因为它就是一棵 list 组成的树。

4.2 直接求值

既然 AST 是 Clojure 数据,求值器只需几行:

(defn eval-ast [ast env]
  (cond
    (number? ast) ast
    (symbol? ast) (get env ast)
    (seq? ast)
    (let [[op & args] ast
          vs (map #(eval-ast % env) args)]
      (case op
        + (apply + vs)
        * (apply * vs)
        (throw (ex-info "未知运算符" {:op op}))))
    :else (throw (ex-info "非法节点" {:node ast}))))

(eval-ast (to-ast (parse "1+2*3")) {})
;; => 7

生产环境里更安全的做法是用 clojure.core/eval 配合白名单符号,或直接复用 clojure.spec / malli 校验 AST 形状后再解释——模式校验的思路见 spec 与 malli 数据校验 。

4.3 处理变量与函数调用

扩展语法支持标识符与函数调用:

(def grammar
  "expr   = add
   add    = add <'+'> mult | mult
   mult   = mult <'*'> atom | atom
   atom   = call | num | ident | <'('> expr <')'>
   call   = ident <'('> (expr <','>)* expr <')'>
   num    = #'[0-9]+'
   ident  = #'[a-zA-Z_][a-zA-Z0-9_]*'")

转换时把 :call 映射成 (f a b) 形式,求值时从 env 里取函数即可。

5. 错误定位与诊断

5.1 失败对象

解析失败时返回 insta/failure? 为真的对象,包含行列位置与期望集合:

(def result (parse "1+*2"))

(insta/failure? result)
;; => true

(insta/get-failure result)
;; {:index 2
;;  :line 1
;;  :column 3
;;  :reason "Expected one of: ..."
;;  :text "1+*2"}

5.2 友好报错

把失败信息包装成面向用户的提示:

(defn parse-or-throw [s]
  (let [r (parse s)]
    (if (insta/failure? r)
      (let [{:keys [line column reason]} (insta/get-failure r)]
        (throw (ex-info (format "第 %d 行第 %d 列解析失败:%s" line column reason)
                        {:line line :column column :input s})))
      r)))

5.3 可视化

Instaparse 支持输出多种格式便于调试:

(insta/visualize (parse "1+2*3"))         ;; 打开浏览器显示树
(println (insta/transform {:num identity} (parse "1+2*3")))
;; 或转成 hiccup / graphviz

6. 解析器组合子:不用语法的选择

当语法较小、或者需要与现有代码交织时,手写组合子(Combinator)更直接。核心思想是「解析器即函数」:输入字符串,返回 [结果 剩余输入] 或失败。

6.1 最小组合子

(defn run-parser [p s]
  (p s))

(defn result [v] (fn [s] [v s]))

(defn bind [p f]
  (fn [s]
    (let [[v rest] (p s)]
      ((f v) rest))))

(defn satisfy [pred]
  (fn [s]
    (if (and (seq s) (pred (first s)))
      [(first s) (subs s 1)]
      (throw (ex-info "解析失败" {:at s})))))

6.2 组合出具体解析器

(def digit (satisfy #(Character/isDigit %)))
(def digits (fn [s] (let [ds (take-while #(Character/isDigit %) s)
                          n  (count ds)]
                      [(apply str ds) (subs s n)])))

(defn char-parser [c]
  (satisfy #(= c %)))

;; 解析 "12+34"
((bind digits (fn [a] (bind (char-parser \+) (fn [_] (bind digits (fn [b] [(Long/parseLong a) (Long/parseLong b)])))))) "12+34")
;; => [[12 34] ""]

6.3 何时用组合子

场景Instaparse组合子
语法大、要文档化优差
需要回溯/前瞻内置手写
与既有流式处理结合弱强
性能极致中高(可特化)
依赖引入库零依赖

组合子适合「小语法 + 想完全掌控」;Instaparse 适合「语法是核心资产、需要可读的 EBNF」。

7. 完整案例:迷你查询语言

7.1 语法

(def query-grammar
  "query  = select from where?
   select = <'select '> field (<','> field)*
   from   = <' from '> table
   where  = <' where '> cond
   cond   = cond <' and '> cmp | cmp
   cmp    = field op value
   op     = '=' | '>' | '<'
   field  = #'[a-zA-Z][a-zA-Z0-9_.]*'
   table  = #'[a-zA-Z][a-zA-Z0-9_]*'
   value  = #'\\d+' | #'\\'[^\\']*\\''")

7.2 转换到 SQL

(defn to-sql [tree]
  (insta/transform
    {:query  (fn [sel from where]
               (str "SELECT " sel " FROM " from (when where (str " WHERE " where))))
     :select (fn [& fs] (clojure.string/join ", " fs))
     :from   identity
     :where  (fn [c] c)
     :cond   (fn [a b] (str a " AND " b))
     :cmp    (fn [f op v] (str f " " op " " v))
     :op     identity
     :value  identity}
    tree))

(to-sql (insta/parser query-grammar))

真实项目里,SQL 一定用参数化而非字符串拼接防注入;这里只是演示「语法 → AST → 目标代码」的编译链路。

7.3 与数据操作的衔接

解析出的 AST 最终往往落到数据处理上:过滤、映射、聚合。这些操作与 Clojure 数据操作 中的 filter/map/reduce 组合天然对应,AST 解释器可以直接转成 transducer 流水线,做到边解析边执行、避免中间集合。

8. 小结

Clojure 做解析与 DSL 的优势在于「解析树就是数据」——AST 是嵌套 vector,转换是普通函数,求值是模式匹配。

  • 语法可读、需要文档化:用 Instaparse,EBNF 描述、transform 转 AST;
  • 语法小、要极致控制:手写组合子,解析器就是 String -> [a String];
  • 歧义:CFG 模式允许、需消解;PEG 模式有序选择、更确定但禁左递归;
  • 诊断:永远把 get-failure 的行列信息转成用户能看懂的提示。

最后一条经验:先用 EBNF 把语法写清楚,再动手写代码。语法是整个 DSL 的契约,它稳定了,解析、校验、求值都只是围绕它的机械工作。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 桌面 UI:cljfx 与 JavaFX 实战
  2. JSON/EDN 序列化与数据格式互操作
  3. JVM 调优与容器化部署:GC、JFR 与 Docker