前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >基于解析器组合子的语法解析器(上)

基于解析器组合子的语法解析器(上)

原创
作者头像
黄啊码
发布于 2022-06-20 01:40:02
发布于 2022-06-20 01:40:02
2.7K00
代码可运行
举报
运行总次数:0
代码可运行

基于解析器组合子的语法解析器(上)

1.语法的来源

语法,在语言学中是指任意自然语言中句子、短语以及词汇等语法单位的语法结构与语法意义的规律,本质上即音义结合体之间的结合规律。在程序语言的范畴上,描述的则是基于文本的源码以特定规则放置,来表达其特有的语义内涵。

在语法的描述上,可以以任意方式来表达,但为保证准确无异议,通常都会采用 BNF 范式及其衍生版本等方式来进行形式化的定义,避免自然语言带来的歧义性。

Expr::=<Var>∣<Number>∣func(<Var>){<Expr>}Expr ::= <Var> | <Number> | func ( <Var> ) \{ <Expr> \}Expr::=<Var>∣<Number>∣func(<Var>){<Expr>}

上述栗子中描述了一个表达式的简单定义,ExprExprExpr是表达式的符号名称,其可以由VarVarVar(变量)、NumberNumberNumber(数字)或funcfuncfunc(函数)中的任意一个替换,其中funcfuncfunc的参数是一个VarVarVar,函数体是另一个ExprExprExpr。

2.如何解析语法

2.1 解析语法的运作

语法解析的运作,是将输入的原始文本按照给定的语法规则,在一定的上下文环境中,通过扫描和匹配,将原始文本转换为具有特定语义的结构化数据。例如,在解析1 + 2这样的原始文本时,如果依据Expr::=Expr+ExprExpr ::= Expr + ExprExpr::=Expr+Expr的语法规则,则可以将其转换为根节点为+,两个子节点各为12的树状结构。

2.2 解析语法的方案

市面上的语法解析方案已经非常成熟,从手写的递归下降分析到自动生成解析代码的 Yacc、ANTLR 生成器等等。另外可使用的算法也非常丰富,包括 LL、LR 以及其各种衍生变体。在实际使用中,由于 Yacc、ANTLR 等生成器使用自己特有的语法来描述目标语言的语法规则,在调试与维护中难免有诸多不便。因此,现在有许多语言重新选择了手写解析器,以开发语言自身来描述目标语言的语法规则,从而可以更好的优化与扩展。今天要介绍的解析器组合子,便是手写递归下降分析器中的一种。

2.3 Racket语言

本文所采用的开发语言,是一门很容易上手的 Lisp 方言 —— Racket,其使用的S表达式与模式匹配等特性,可以有效的控制解析过程整体的复杂度,避免由于语言自身的细节干扰所带来的诸多麻烦。

由于Racket等Lisp方言通常使用S表达式作为语法,其与市面上常见的编程语言语法有较大差异,因此在这里简要介绍一下本文所使用到的部分。

2.3.1 S表达式

S表达式可以由单个元素构成(如数字、变量等), 也可以由括号框选的复合元素嵌套组合构成。例如(+ (* 1 2) 3),其中最外层的S表达式由+(* 1 2)3构成,而(* 1 2)又是由*12构成。

S表达式既可以描述程序,也可以描述数据(列表)。在描述程序时,括号括起的整个表达式被理解为函数(宏)调用,其括号中的左起第一个元素,用来描述整个表达式的功能,后续的元素,则作为该功能所依赖的参数。例如(* 1 2),其中*表示该表达式是一个乘法运算,而12,则作为该乘法运算的两个参数。在描述数据时,如果与描述程序的S表达式同时存在时,便需要对其进行区分标记。在Racket中,不做任何标记的S表达式,会作为程序表达,而作为数据的S表达式,则需要使用(quote (x y z))的方式进行标记,通常简写为'(x y z)

2.3.2 define与lambda

当需要定义一个符号时,可以使用define来实现,例如定义x等于5,则可以表达成(define x 5),后续使用x时则等价于使用5

对于匿名函数的表达,则需要使用lambda来实现,例如声明一个乘法函数,可以表示为(lambda (x y) (* x y))。其中(x y)表示该函数的参数列表,此处有xy两个参数,(* x y)则作为该函数的函数体。在该函数被调用时,xy会被替换为实际参数后,执行对应的操作。如果需要操作函数的整个参数列表,则可以将参数列表的括号去掉,以list的方式进行使用,例如(lambda nums (apply + nums))

由于Racket是一门函数式语言,函数可以被作为参数和返回值进行传递。因此,如果需要返回一个函数,则可以直接在函数体内声明另一个函数即可,例如(lambda (x) (lambda (y) (+ x y))),其被调用时,会返回一个(lambda (y) (+ x y))函数作为返回值,其中x的值是外部函数调用时传递的实际参数。

3.解析器组合子(Parser Combinator)

解析器组合子本质上是一种高阶对象,其接收多个其他解析器作为参数,构造出一个新的解析器。通过组合的方式由简到繁、由小到大的描绘出目标语言的语法规则。解析器组合子描述的分析器易于构造、结构良好、具有可读性且易于维护,很适用于规模不大且需要快速开发的场景。解析器组合子一般采用自顶向下的递归下降分析法,并在分析的过程中配合 GLL 等算法的思想,可以较好的处理左递归文法及二义文法。

3.1 如何实现解析器组合子

解析器组合子是由小到大、由简到繁构成的解析器。因此首先要实现的,便是其中最基础的单元构件。

3.1.1 解析器的接口定义

在实现单元构建之前,需要先来梳理一下解析器的功能。对于每一个解析器,其目标是将输入的内容,按照一定的规则进行匹配,之后将匹配的结果作为输出向后传递,作为下一个解析器的输入,以此往复,直到最后得出想要的结果为止。

基于上述描述,可以得出,解析器需要有一个输入源,以及一个在当前环节执行成功或失败后的下一步操作。于是可以将其定义为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(lambda (src k-succ k-fail) ...)
复制代码

其中,src是输入源,k-succ是当次解析成功后的下一步操作,k-fail是当次解析失败后的下一步操作,参数名中的k,描述的是continuation的含义,代表着接下来要做的事情。

3.1.2 单位元解析器

在定义完解析器的接口后,便可以开始构造最基础的元解析器。

首先要引入的,是二个是最简单的解析器,其不对输入进行任何解析,只是单纯的认为当次解析的结果为成功或失败,在概念上与加法中的0和乘法中的1相似,作为单位元来使用:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;不解析, 直接返回成功
(define @:succ
  (lambda ()
    (lambda (src k-succ k-fail)
      (k-succ src))))

;不解析, 直接返回错误
(define @:fail
  (lambda ()
    (lambda (src k-succ k-fail)
      (k-fail src))))
复制代码

由于 Racket 对标识符命名的约束极少,只有特定几个字符不能用来命名,为了便于区分,对于元解析器的命名,均已@:开头(元->圆->@),来表达其所属的范畴。

3.2.3 序列、选择解析器

有了单位元解析器,便可以组合出接下来的两个最重要的复合解析器——序列解析与选择解析:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;序列匹配: 全部成功才成功
(define @:seq
  (lambda *ps
    ;当前解析器匹配成功后, 执行下一个解析器, 只要有一个匹配失败, 则整体就失败
    ;p1.succ -> p2.succ -> ... -> pn.succ -> @:succ
    (foldr
      (lambda (p p-succ)
        ;这里返回一个新的解析器
        (lambda (src k-succ k-fail)
          (p src (lambda (cur) (p-succ cur k-succ k-fail)) k-fail)))
      (@:succ)
      *ps)))

;选择匹配: 任意成功则成功
(define @:opt
  (lambda *ps
    ;当前解析器匹配失败后, 执行下一个解析器, 只要有一个匹配成功, 则整体就成功
    (foldr
      (lambda (p p-fail)
        ;这里返回一个新的解析器
        (lambda (src k-succ k-fail)
          (p src k-succ (lambda (cur) (p-fail src k-succ k-fail)))))
      (@:fail)
      *ps)))
复制代码

其中,*ps表示接收多个解析器参数,并将其作为列表使用。foldr和其他语言中的reduce函数相同,不过是从列表的末尾开始递归。

序列解析器通过接收多个子解析器,以从头到尾的顺序依次连接,只有输入源通过了全部的解析后,才认为当次的解析成功,在逻辑上与and相同。选择解析器的功能与序列解析器相似,但表达的是or的概念,只要有一个子解析器匹配成功,则认为当次的解析成功。

例如要从HelloWorld中匹配到Hello序列,首先需要构造一个匹配字符的解析器,之后按照Hello的顺序依次将对应字符的解析器传递给序列解析器,便可生成一个可以匹配Hello序列的解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;匹配字符的解析器
(define &:char
  (lambda (ch)
    (lambda (src k-succ k-fail)
      ;将输入源解构为`str``res`, `str`为输入的字符串, `res`为命中匹配后的结果
      (match-let ([`(,str ,res) src])
        (if (eq? (string-ref str 0) ch)
          ;如果字符串的首字母和`ch`相同, 则认为匹配成功
          ;将`str`中命中的字符剔除, 并将其保存在`res`中
          (k-succ `(,(substring str 1) ,(cons ch res)))
          ;否则认为匹配失败
          (k-fail src))))))

;生成匹配`Hello`的解析器
(define &:hello
  (lambda ()
    ;`Hello`按顺序传入
    (@:seq (&:char #\H) (&:char #\e) (&:char #\l) (&:char #\l) (&:char #\o))))

((&:hello)
 ;构造输入源, '() 提供一个容纳匹配结果的地方
 (list "HelloWorld" '())
 ;匹配成功时的回调
 (lambda (cur)
   (match-let ([`(,str ,res) cur])
     (printf "res: ~a~nstr: ~a~n" (reverse res) str)))
 ;匹配失败时的回调
 (lambda (cur)
   (error '&:hello "match failure")))

;输出结果:
;res: (H e l l o)
;str: World
复制代码
3.2.4 ?*+解析器

有了序列匹配与选择匹配,接下来便可以构造出更加实用的三个解析器:正则表达式中的?(零个或一个)、*(零个或多个)和+(一个或多个)。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;匹配0个或1个
(define @:?
  (lambda (p)
    (@:opt p (@:succ))))

;匹配0个或多个
(define @:*
  (lambda (p)
    ;这里会依赖`@:+`, 为了避免在调用时出现死循环, 通过包裹一层`lambda`来实现延迟求值
    (lambda *as (apply (@:opt (@:+ p) (@:succ)) *as))))

;匹配1个或多个
(define @:+
  (lambda (p)
    ;这里会依赖`@:*`, 为了避免在调用时出现死循环, 通过包裹一层`lambda`来实现延迟求值
    (lambda *as (apply (@:seq p (@:* p)) *as))))
复制代码

由于@:*@:*互相递归,为了在调用时避免形成死循环,因此通过外包一层函数来达到延迟求值的目的。

例如要从aaabcc中匹配尽可能多的a,则可以向@:*传递一个匹配a的解析器,来生成可以匹配 0 个或多个的解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
((@:* (&:char #\a))
 (list "aaabcc" '())
 (lambda (cur)
   (match-let ([`(,str ,res) cur])
     (printf "res: ~a~nstr: ~a~n" (reverse res) str)))
 (lambda (cur)
   (error '&:hello "match failure")))

;输出结果
;res: (a a a)
;str: bcc
复制代码

至此,几个最核心的元解析器就介绍完了,下面,通过使用上述的元解析器,来实现一个具体的词法解析器。

4.词法解析器与语法解析器

4.1 目标语言的定义

在实现词法解析器之前,首先来定义一下需要解析的目标语言——MiniLambda(随便起了一个名字),其是一个由表达式构成,包含数字、函数和条件判断的简单语言。表达式内部可以嵌套子表达式,以#开头的一行作为注释,函数可以作为参数和返回值进行传递。其定义如下:

例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# comment
func (x) {
  cond {
    eq?(x, 0) -> 1
    else -> mul(x, x)
  }
}(5)
复制代码
4.2 词法解析器的定义与实现

词法解析器的目的,是将程序文本按照词法规则,解析为一组由特定字符序列组合而成的 token 列表,作为后续的语法解析器的输入。

4.2.1 token 的结构

在解析的时,为了保留更多的源文件上下文信息,可以将每一个 token 的起始行列号记录下来,便于与源文件中的位置进行关联。因此,其结构可以简单定义如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
'(token symbol (row col))
复制代码

其中,token是对象的类型标记;如果symbol是数字时,则转换为数字存储,否则依旧以字符串存储。

4.2.2 词法解析器的上下文环境

在解析的过程中,由于字符序列的匹配是通过更小的解析器来完成的,因此需要一个缓存空间来容纳每一步的中间产品,因此对于输入源,其结构可以简单定义如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
'(lex char-list stash-list token-list (cur-row cur-col) (row col))
复制代码

其中,char-list是待匹配的字符流,stash-list是记录匹配中间态的列表,token-list是记录已经匹配到的 token 流,(cur-row cur-col)(row col)分别记录了当前步骤的行列号与当前匹配 token 的起始行列号。

4.2.3 子解析器的实现

有了上述对相关结构的定义,则可以定义出匹配单个字符的解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;通用的匹配解析器
(define %:match
  (lambda (func) ;接收一个检测函数
    (lambda (src k-succ k-fail)
      (match src
        ;如果当前字符流已空, 则认为匹配失败
        [`(lex ,(? empty?) . ,_)
          (k-fail src)]
        [`(lex (,cur . ,rst) ,stash-ls ,token-ls (,cur-row ,cur-col) ,idx)
          (cond
            ;如果检测失败, 则认为匹配失败
            [(not (func cur))
             (k-fail src)]
            ;对于非换行符, 则递增列号
            [(not (eq? cur #\newline))
             (k-succ `(lex ,rst ,(cons cur stash-ls) ,token-ls (,cur-row ,(add1 cur-col)) ,idx))]
            ;对于换行符, 则将列号置为1, 同时递增行号
            [(k-succ `(lex ,rst ,(cons cur stash-ls) ,token-ls (,(add1 cur-row) 1) ,idx))])]))))

;匹配字母
(define %:alpha
  (lambda ()
    (%:match char-alphabetic?)))

;匹配数字
(define %:digit
  (lambda ()
    (%:match char-numeric?)))

;匹配指定字符
(define %:char
  (lambda (char)
    (%:match (curry eq? char))))
复制代码

之后,再定义一个可以将准备好的字符列表转换为 token 的解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;将stash-ls中保存的已经匹配到的字符转换为token对象
(define %:token
  (lambda (p func) ;由外部指定stash-ls的合成规则
    (lambda (src k-succ k-fail)
      (p src
        (lambda (cur)
          (match cur
            [`(lex ,char-ls ,stash-ls ,token-ls ,cur-idx ,idx)
              ;stash-ls的更新是通过压栈的方式操作的, 因此里需要对其做一个反转, 恢复正确的顺序
              (let ([token `(token ,(func (reverse stash-ls)) ,idx)])
                (k-succ `(lex ,char-ls () ,(cons token token-ls) ,cur-idx ,cur-idx)))]
              ;如果解构失败, 则认为匹配失败
            [_(begin (pretty-printf "%:token ~a~n" cur) (k-fail cur))]))
        k-fail))))
复制代码

有了可以将字符列表转换为 token 的解析器后,接下来便可以定义匹配具体 token 的解析器了。

首先可以定义的,是标识符的解析器,其对应的词法规则可以通过如下正则描述:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
identifier = \w[\w\d-?!]*
复制代码

标识符的首字符必须为字母,之后可以跟任意多个字母、数字或-?!等符号,其对应的解析器为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(define %:identifier
  (lambda ()
    (%:token
     (@:seq
      (%:alpha)
      (@:* (@:opt (%:alpha) (%:digit) (%:char #\-) (%:char #\?) (%:char #\!))))
      list->string)))
复制代码

可以看到,解析器组合子在描述构成规则时,与定义几乎一致,直观明了。list->string描述了需要将stash-ls中的字符列表转换为字符串存储。

接下来描述数字的定义规则:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
number = (+|-)?\d+(\.\d+)?
复制代码

数字支持最简单的整数和浮点数表达,暂不支持科学计数法及非十进制数字。其对应的解析器为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(define %:number
  (lambda ()
    (%:token
     (@:seq
      (@:? (@:opt (%:char #\+) (%:char #\-)))
      (@:+ (%:digit))
      (@:? (@:seq (%:char #\.) (@:+ (%:digit)))))
      list->number)))
复制代码

接下来描述注释的定义规则:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
comment = #[^\n]*
复制代码

注释的起始为#,之后直至换行符之前的所有符号,都算作注释的内容。其对应的解析器为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(define %:comment
  (lambda ()
    (%:token
     (@:seq
      (%:char #\#)
      (@:* (%:match (lambda (ch) (not (eq? ch #\newline))))))
      list->string)))
复制代码

有了上述的标识符、数字及注释解析器后,还有部分符号和空白符需要解析,其对应的解析器为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;symbol = !\alpha
(define %:symbol
  (lambda ()
    (%:token
      (%:match (lambda (ch) (not (or (char-alphabetic? ch) (char-numeric? ch)))))
      list->string)))

;whitespace => \S+
(define %:whitespace
  (lambda ()
    (%:token
      (@:+ (%:match char-whitespace?))
      list->string)))

(define %:string
  (lambda (str)
    (%:token
     ;这里将字符串映射为解析器列表转入`@:seq`
     (apply @:seq (map (lambda (ch) (%:match (curry eq? ch)))
                       (string->list str)))
     list->string)))
复制代码

对于空白和注释,在本文的语境下,可以将其简单的进行丢弃处理,因此,还需要新增一个处理丢弃的解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(define %:skip
  (lambda (p)
    (lambda (src k-succ k-fail)
      (p src
        (lambda (cur)
          (match cur
            [`(lex ,char-ls ,stash-ls ,token-ls ,cur-idx ,idx)
              (k-succ `(lex ,char-ls ,stash-ls ,(if (empty? token-ls) token-ls (cdr token-ls)) ,cur-idx ,cur-idx))]
            [_(begin (pretty-printf "%:skip ~a~n" cur) (k-fail cur))]))
        (~ k-fail)))))
复制代码

最后,组合上述的几个解析器,便可以构造出最终的词法解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;(identifier|number|skip(whitespace+)|skip(comment)|->|symbol)+
(define %:scan
  (lambda ()
    (lambda (src k-succ k-fail)
      ((@:+
        (@:opt
         (%:identifier)
         (%:number)
         (%:skip (%:whitespace))
         (%:skip (%:comment))
         (%:string "->")
         (%:symbol)))
       src
       (lambda (cur)
         (match cur
           [`(lex ,char-ls ,stash-ls ,token-ls ,cur-idx ,idx)
             (k-succ (reverse token-ls))]
           [_(begin (pretty-printf "%:scan ~a~n" cur) (k-fail cur))]))
       k-fail))))
复制代码

以介绍 MiniLambda 时的源码为例,在通过词法解析器后,生成的 token 流为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
((token func (3 1))
 (token ( (3 6))
 (token x (3 7))
 (token ) (3 8))
 (token { (3 10))
 (token cond (4 3))
 (token { (4 8))
 (token eq? (5 5))
 (token ( (5 8))
 (token x (5 9))
 (token , (5 10))
 (token 0 (5 12))
 (token ) (5 13))
 (token -> (5 15))
 (token 1 (5 18))
 (token else (6 5))
 (token -> (6 10))
 (token mul (6 13))
 (token ( (6 16))
 (token x (6 17))
 (token , (6 18))
 (token x (6 20))
 (token ) (6 21))
 (token } (7 3))
 (token } (8 1))
 (token ( (8 2))
 (token 5 (8 3))
 (token ) (8 4)))
复制代码
4.3 语法解析器的定义与实现

有了词法解析器,下一步便是基于 token 流进行语法解析了。

4.3.1 AST 的结构

语法解析器的构造与词法解析器类似,首先给出的,是各个 AST 节点的定义。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(ast-var symbol idx)
(ast-num number idx)
(ast-func args exprs idx)
(ast-cond (pred expr)* expr idx)
(ast-call func args* idx)
复制代码

每种节点的定义,基本与 EBNF 中的描述一致,只是额外附带了idx字段,便于解析出错时报告其在源码中的具体位置。有关错误的处理,会在之后详细介绍。

4.3.2 语法解析器的上下文环境

与词法解析器一样,语法解析器的定义也是由子解析器组合而成,因此同样存在中间态,所以在上下文的结构中,也需要暂存中间态的空间,其描述如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
'(stx token-ls ast-space)
复制代码

其中,ast-space是一个栈结构,由多层列表组成,每当暂存的 token 列表满足一个 AST 节点时,便将其整体出栈,组合成一个 AST 节点后,再重新入栈。

例如,当栈顶结构如下所示时,其栈顶已经符合func的 AST 定义,因此可以通过将其替换成ast-func结构,重新入栈:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
;替换前
(((ast-num 123 (1 15)))
 ((ast-var b (1 10)) (ast-var a (1 7)))
 ((ast-text func (1 1)))
 ())

;替换后
(((ast-func
   ((ast-var a (1 7)) (ast-var b (1 10)))
   ((ast-num 123 (1 15)))
   (1 1))))

复制代码
4.3.3 子解析器的实现

语法解析器的定义,不同于词法解析器的地方在于,其内部会递归的调用,为了保证在互相调用时,不会造成死循环,因此,需要给每一个解析器都额外包一层函数,延迟内部的求值时机。对于延迟包装的实现,可以通过宏来实现:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(define-syntax-rule
  ($:: (name params ...) parser)
  (define name
    (lambda (params ...)
      (lambda *as
        (apply parser *as)))))
复制代码

简述了语法解析的上下文后,便可以给出同词法解析器相似的通用匹配解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
($:: ($:match func)
     (lambda (src k-succ k-fail)
       (match src
         ;token流为空, 则认为匹配失败
         [`(stx ,(? empty?) . ,_)
           (k-fail src)]
         [`(stx ((token ,tkn ,idx) . ,rst) (,ret-stk . ,ast-stk))
           (let ([ret (func tkn idx)])
             (match ret
               ;外部返回了error, 则认为匹配失败
               [`(ast-error) (k-fail src)]
               ;外部要求跳过
               [`(ast-skip) (k-succ `(stx ,rst (,ret-stk . ,ast-stk)))]
               ;其他情况, 则认为匹配成功
               [_(k-succ `(stx ,rst (,(cons ret ret-stk) . ,ast-stk)))]))])))
复制代码

有了通用的匹配解析器后,便可以依次构造出标识符解析器、数字解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(define *$:keyword* '("func" "cond" "else" "call"))

($:: ($:var)
     ($:match
      (lambda (tkn idx)
        (if
          (and
           (string? tkn)
           (not (member tkn *$:keyword*))
           (positive? (string-length tkn))
           (char-alphabetic? (string-ref tkn 0)))
         `(ast-var ,(string->symbol tkn) ,idx)
         `(ast-error)))))

($:: ($:num)
     ($:match
      (lambda (tkn idx)
        (if (number? tkn)
         `(ast-num ,tkn ,idx)
         `(ast-error)))))
复制代码

其中,标识符解析的判断逻辑是,符号不能为关键字且必须为字母开头。

对于复杂表达式(funccond等),在解析的过程中,需要对暂存的栈空间增加新的层级,来区分每一部分的 token 序列。例如,在func的匹配中,首先需要区分的是func关键字,之后需要区分的是参数列表,最后是函数具体的表达式列表。因此,在定义复杂表达式的解析器前,首先增加一个对暂存区压栈的解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
($:: ($:new-space [mark #f])
     (@:map
       (@:succ)
       (match-lambda
         [`(stx ,token-ls (,ret-stk . ,ast-stk))
          `(stx ,token-ls (,(if mark `(,mark) `()) ,ret-stk . ,ast-stk))])))
复制代码

外部可以通过mark参数指定该层的名称,默认为空。

有了new-space解析器,便可以继续定义funccondcall解析器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
($:: ($:skip text)
     ($:match
      (lambda (tkn idx)
        (if (equal? tkn text)
         `(ast-skip)
         `(ast-error)))))

($:: ($:text text)
     ($:match
      (lambda (tkn idx)
        (if (equal? tkn text)
         `(ast-text ,tkn ,idx)
         `(ast-error)))))

($:: ($:func)
     (@:map
       (@:seq
         ($:new-space)
         ($:text "func")
         ($:skip "(")
         ($:new-space)
         (@:? (@:seq (@:* (@:seq ($:var) ($:skip ","))) ($:var)))
         ($:skip ")")
         ($:skip "{")
         ($:new-space)
         (@:+ ($:expr))
         ($:skip "}"))
       (match-lambda
         [`(stx ,token-ls (,exprs ,args ((ast-text "func" ,idx)) ,ret-stk . ,ast-stk))
           (let ([ast (cons `(ast-func ,(reverse args) ,exprs ,idx) ret-stk)])
            `(stx ,token-ls (,ast . ,ast-stk)))])))

($:: ($:cond)
     (@:map
      (@:seq
       ($:new-space)
       ($:text "cond")
       ($:skip "{")
       ($:new-space)
       (@:* (@:seq ($:expr) ($:skip "->") ($:expr)))
       ($:new-space)
       (@:seq ($:skip "else") ($:skip "->") ($:expr))
       ($:skip "}"))
      (match-lambda
        [`(stx ,token-ls ((,else-expr) ,pair-exprs ((ast-text "cond" ,idx)) ,ret-stk . ,ast-stk))
          (let loop ([pair-exprs pair-exprs] [cond-exprs '()])
            (match pair-exprs
              [`()
                (let ([ast (cons `(ast-cond ,cond-exprs ,else-expr ,idx) ret-stk)])
                 `(stx ,token-ls (,ast . ,ast-stk)))]
              [`(,cond-expr ,pred-expr . ,rst)
                (loop rst (cons `(,pred-expr ,cond-expr) cond-exprs))]))])))

($:: ($:call)
     (@:map
      (@:seq
       ($:new-space)
       ($:sample-expr)
       (@:+
        (@:seq
         ($:skip "(")
         ($:new-space "args")
         (@:? (@:seq (@:* (@:seq ($:expr) ($:skip ","))) ($:expr)))
         ($:skip ")"))))
      (match-lambda
        [`(stx ,token-ls ((,argss ... "args") ... (,func) ,ret-stk . ,ast-stk))
          (let ([ast-call (foldr (lambda (args func) `(ast-call ,func ,(reverse args) (0 0))) func argss)])
           `(stx ,token-ls (,(cons ast-call ret-stk) . ,ast-stk)))])))
复制代码

其中,funccond解析器基本同 EBNF 范式,在解析的过程中,额外增加了处理暂存空间的子解析器,但并没有改变语法本身的描述,唯一的不同在于call的定义。由于call的 EBNF 定义中,其右侧的产生式第一项便是Expr,属于左递归语法,对于这样的式子,普通的递归下降解析器无法简单的处理,因此需要将其转换为非左递归的描述,将递归的部分剥离出来,放在产生式的右侧,避免死循环。另外,@:map的作用是,将解析成功后的结果在传递给下一个解析器前,做一次变换操作。

函数的连续调用,可以将其转换为以下方式来描述:

在之后的文章中,会通过引入 GLL 的思想来支持左递归文法及二义文法。

最后,定义上述转换后的exprsimple-expr

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
($:: ($:expr)
     (@:opt
      ($:call)
      ($:sample-expr)))

($:: ($:sample-expr)
     (@:opt
      ($:var)
      ($:num)
      ($:func)
      ($:cond)))
复制代码

至此,语法解析器的定义已经完成。对其输入上述词法解析器的结果后,可以得到如下结构的语法树,为了便于阅读,此处略去具体的行列号:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
(ast-call
 (ast-func
  ((ast-var x))
  ((ast-cond
    (((ast-call
       (ast-var eq?)
       ((ast-var x) (ast-num 0)))
      (ast-num 1)))
    (ast-call
     (ast-var mul)
     ((ast-var x) (ast-var x))))))
 ((ast-num 5)))
复制代码

对于更复杂且嵌套多层调用的源码,也可以得到正确的语法树:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
带有多层嵌套且多次调用的源码:
func (Y) {
  Y(func (fact) {
      func (n) {
        cond {
          eq?(n, 1) -> 1
          else -> mul(n, fact(sub(n, 1)))
        }
      }
    })
}(func (wrap-func) {
    func (r-func) {
      r-func(r-func)
    }(func (real-func) {
        wrap-func(func (arg) {
          real-func(real-func)(arg)
        })
      })
  })(5)

语法树:
(ast-call
 (ast-call
  (ast-func
   ((ast-var Y))
   ((ast-call
     (ast-var Y)
     ((ast-func
       ((ast-var fact))
       ((ast-func
         ((ast-var n))
         ((ast-cond
           (((ast-call
              (ast-var eq?)
              ((ast-var n) (ast-num 1)))
             (ast-num 1)))
           (ast-call
            (ast-var mul)
            ((ast-var n)
             (ast-call
              (ast-var fact)
              ((ast-call
                (ast-var sub)
                ((ast-var n) (ast-num 1))))))))))))))))
  ((ast-func
    ((ast-var wrap-func))
    ((ast-call
      (ast-func
       ((ast-var r-func))
       ((ast-call (ast-var r-func) ((ast-var r-func)))))
      ((ast-func
        ((ast-var real-func))
        ((ast-call
          (ast-var wrap-func)
          ((ast-func
            ((ast-var arg))
            ((ast-call
              (ast-call
               (ast-var real-func)
               ((ast-var real-func)))
              ((ast-var arg)))))))))))))))
 ((ast-num 5)))
复制代码

5.总结

本文通过描述解析器组合子的定义与实现,实现了源码文本到语法树的整体流程。在接下来的文章中,会引入 GLL 的思想来处理左递归文法和二义文法,以及增加对匹配出错的定位报告,更加完善解析器的功能。

作者:快手电商无线团队 链接:https://juejin.cn/post/7104465293161791519 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
ChatGPT|AI自制编程语言-语法解析1
而本篇主要集中语义解析和AST树生成部分,还未实现求值(下一篇《语法解析1》实现求值功能)。
用户1904552
2025/02/27
670
ChatGPT|AI自制编程语言-语法解析1
Antlr4 语法解析器(下)
Antlr4 的两种AST遍历方式:Visitor方式 和 Listener方式。
awwewwbbb
2021/07/16
3.6K0
Antlr4  语法解析器(下)
如何实现一个SQL解析器
随着技术的不断的发展,在大数据领域出现了越来越多的技术框架。而为了降低大数据的学习成本和难度,越来越多的大数据技术和应用开始支持SQL进行数据查询。SQL作为一个学习成本很低的语言,支持SQL进行数据查询可以降低用户使用大数据的门槛,让更多的用户能够使用大数据。
2020labs小助手
2022/10/24
2.6K0
Calcite系列(六):执行流程-语法解析
目前广泛使用的语法解析框架主要包括ANTLR、JavaCC和Yacc等。在大数据领域中,很多计算引擎都是基于ANTLR进行语法解析,例如 Hive、Spark和Presto等都基于ANTLR进行处理。然而,Calcite使用JavaCC编译器进行语法解析。
Yiwenwu
2024/04/19
8100
Calcite系列(六):执行流程-语法解析
使用 LLVM 实现一个简单编译器
作者:tomoyazhang,腾讯 PCG 后台开发工程师 1. 目标 这个系列来自 LLVM 的Kaleidoscope 教程,增加了我对代码的注释以及一些理解,修改了部分代码。现在开始我们要使用 LLVM 实现一个编译器,完成对如下代码的编译运行。 # 斐波那契数列函数定义 def fib(x)     if x < 3 then         1     else         fib(x - 1) + fib(x - 2) fib(40) # 函数声明 extern sin(arg)
腾讯技术工程官方号
2021/09/18
3.1K0
golang源码分析(18)添加一个新语句到Golang编译器内部
在Go中添加while语句是简单的,因为只需要简单的将while翻译为for。所以我们选择了一个更具有挑战性的任务:添加until。until与while相似,只是将判定条件改为了否定,意为“直到……”。例如:
golangLeetcode
2022/08/02
3530
golang源码分析(18)添加一个新语句到Golang编译器内部
Postgresql源码(44)server端语法解析流程分析
1、raw_parser作为高层入口 2、raw_parser初始化后,通过base_yyparse进入yacc框架 3、yacc框架中调用base_yylex进入lex拿一个token(正常用框架是每次拿一个,PG通过对lex函数的封装可以拿后面多个,有些语法需要看到后面多个一块解析) 4、拿回来token后,进入语法树开始递归(有点像后续遍历,从底层开始向上构造语法节点,实际是用两个堆栈解析每一层语法规则,原理也比较简单,见第二节)。 5、从语法树底层节点向上reduce,识别收集文本中的目标信息,创建对应的stmt结构体,填入数据,返回上层。
mingjie
2022/07/14
6030
Postgresql源码(44)server端语法解析流程分析
内核级python:编译器的词法和语法解析基本原理
python在收到代码内容后,首先要启动两个流程,分别为词法解析和语法解析。看过我编译原理课程的同学对这两个流程应该不陌生。词法解析其实就是把代码里面不同的元素分别归类,例如234,1.35,1e3等这类字符串统一用一个标志或数字来表示,通常它们的标志为NUMBER,对应字符串pi, age等这类变量名统一用标志来表示,例如使用NAME,于是整篇代码会一下子浓缩成一系列标志的排列,例如表达式 a = 100 + 10 就变成了 NAME = NUMBER + NUMBER。
望月从良
2021/11/15
5910
内核级python:编译器的词法和语法解析基本原理
两百行内 JavaScript 打造lambda 演算解释器
我当然听说过 lambda 演算,但直到我读了这本书 《类型和编程语言》(Types and Programming Languages) 我才体会到其中美妙。
疯狂的技术宅
2019/03/27
1.9K0
从编译器角度出发探索如何在go中实现AOP
起源:在当下微服务盛行,服务的依赖越来越复杂,服务的颗粒越来越细,业务迭代越来越频繁,软件的系统性测试的维护成本越来越高,对于特别复杂的业务场景的单测编写或者接口测试的数据构造是越发困难。所以我们开发了UGO智能单测辅助工具来解决这些问题。
Gospel
2022/07/27
1.5K0
从编译器角度出发探索如何在go中实现AOP
(1)PHP内核 - 玩转php的编译与执行
曾几何时php一不小心闯入了我生活,php语法竟然和C语言那么莫名的相似,这是最初php给我的感受,当接触的php时间越来越多的时候,php也没有那般生涩难懂,但是偶尔一些的新的php 设计思想,也会思考许久,不知是从什么时候开始了php另一个世界。我想应该是从那次的类型转换开始的,"1e12"字符串类型在转化为数字类型变量时,不同的php版本下转换结果截然不同,有的就变成了数字1,有的却可以正常的识别为科学计数法10^12,在这个地方就已经悄悄的埋下了一枚种子。
猿哥
2019/07/10
1.9K0
用 Antlr 重构脚本解释器
在上一个版本实现的脚本解释器 GScript 中实现了基本的四则运算以及 AST 的生成。
crossoverJie
2022/10/27
7930
用 Antlr 重构脚本解释器
Python Ast介绍及应用
Abstract Syntax Trees即抽象语法树。Ast是python源码到字节码的一种中间产物,借助ast模块可以从语法树的角度分析源码结构。此外,我们不仅可以修改和执行语法树,还可以将Source生成的语法树unparse成python源码。因此ast给python源码检查、语法分析、修改代码以及代码调试等留下了足够的发挥空间。
py3study
2020/02/10
2.3K1
[2] 使用 LLVM 实现一门简单的语言
IR 指中间表达方式,介于高级语言和汇编语言之间。与高级语言相比,丢弃了语法和语义特征,比如作用域、面向对象等;与汇编语言相比,不会有硬件相关的细节,比如目标机器架构、操作系统等。
谛听
2022/03/06
2.6K0
TiDB SQL Parser 的实现
其中,SQL Parser的功能是把SQL语句按照SQL语法规则进行解析,将文本转换成抽象语法树(AST),这部分功能需要些背景知识才能比较容易理解,我尝试做下相关知识的介绍,希望能对读懂这部分代码有点帮助。
mazhen
2023/11/24
6260
TiDB SQL Parser 的实现
TiDB 源码阅读系列文章(五)TiDB SQL Parser 的实现
PingCAP 发布了 TiDB 的源码阅读系列文章,让我们可以比较系统的去学习了解TiDB的内部实现。最近的一篇《SQL 的一生》,从整体上讲解了一条 SQL 语句的处理流程,从网络上接收数据,MySQL 协议解析和转换,SQL 语法解析,查询计划的制定和优化,查询计划执行,到最后返回结果。
PingCAP
2018/03/22
4.6K3
TiDB 源码阅读系列文章(五)TiDB SQL Parser 的实现
基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术
随着得物离线业务的快速增长,为了脱离全托管服务的一些限制和享受技术发展带来的成本优化,公司提出了大数据Galaxy开源演进项目,将离线业务从全托管且封闭的环境迁移到一个开源且自主可控的生态系统中,而离线开发治理套件是Galaxy自研体系中一个核心的项目,在数据开发IDE中最核心的就是SQL编辑器,我们需要一个SQL解析引擎在SQL编辑提供适配得物自研Spark引擎的语法定义,实时语法解析,语法补全,语法校验等能力,结合业内dataworks和dataphin的实践,我们最终选用ANTLR作为SQL解析引擎底座。
得物技术
2025/03/06
2250
基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术
Python 之父新发文,将替换现有解析器
花下猫语:Guido van Rossum 是 Python 的创造者,虽然他现在放弃了“终身仁慈独裁者”的职位,但却成为了指导委员会的五位成员之一,其一举一动依然备受瞩目。近日,他开通了 Medium 账号,并发表了第一篇文章,透露出要替换 Python 的核心部件(解析器)的想法。这篇文章分析了当前的 pgen 解析器的诸多缺陷,并介绍了 PEG 解析器的优点,令人振奋。这项改造工作仍在进行中,Guido 说他还会写更多相关的文章,我们就拭目以待吧。
Python猫
2019/07/30
1.1K0
Python 之父新发文,将替换现有解析器
llvm入门教程-Kaleidoscope前端-2-解析器和AST
llvm是当前编译器领域非常火热的项目,其设计优雅,官方文档也很全面,可惜目前官方中文翻译。笔者在学习过程中也尝试进行一些翻译记录,希望能对自己或者他人的学习有所帮助。
hunterzju
2021/12/09
1.8K0
深入浅出:Go语言编译原理与过程解析
Go语言是一种静态强类型、编译型开发语言,编译器扮演着核心角色,它不仅负责将Go源代码转换成机器代码,还涉及代码的优化、错误检查和性能分析等关键环节。
windealli
2024/03/28
1.3K0
深入浅出:Go语言编译原理与过程解析
推荐阅读
相关推荐
ChatGPT|AI自制编程语言-语法解析1
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
查看详情【社区公告】 技术创作特训营有奖征文
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验