Skip to content

解析器的实现

语法解析在制作编程语言过程中处于关键的起始步骤,不过相较于其他部分,其入门难度确实偏高。

简介

在多数情形下,解析器可划分为两个主要部分,也就是分词器与语法解析部分。分词器的作用是对代码进行分割,以便后续能更便利地解析代码,而像像 Python 这种将缩进作为语法组成部分的语言,分词器需要承担更多额外的工作任务。对于分词器的实现,既可以借助现成的工具来生成,比如 Flex,也可以通过手写一个状态机的方式来达成。语法分析环节会复杂得多,在学术领域,虽然普遍认为语法分析问题已得到解决,并且确实存在众多优秀的解析器生成器,但要想很好地运用这些工具,仍需对它们的底层原理有比较深入的理解才行。

快速入门

编程语言的语法能够通过一个非终结符的集合来描述。例如,最基础的加法运算可以相对不那么严谨地描述为规则 E -> E + T | T 以及 T -> 0 | ... | 9。更为学术性的解释可以参考上下文无关文法(CFG),是业界学界都常用的用于语法描述的方式。此处就不过多深入阐述了。在具体的算法实现方面,存在多种选择,例如自上而下的 LL 算法以及自下而上的 LR 算法,还有它们的各类变体。LL 算法实现起来相对简单,但有着较大的局限性;LR 算法则与之相反,功能更为强大但实现难度更高。在实际应用落地层面,有像 YACC/Bison 以及较为现代的 ANTLR4 这类工具,后者支持的编程语言种类较多,有兴趣的话可以自行深入学习,它们都属于非常成熟的技术了。

即便不采用这些成熟的技术,出于对高度定制化以及性能优化方面的考量,很多语言还是会选择手写一个递归下降解析器,例如 Rust 的解析器就是纯手写完成的,Felys 在早期也是如此。只不过手写这种解析器对于项目的结构设计要求颇高,很容易出现结构设计不合理的情况。

另外,还有一种相对较新的解析器思路,即解析表达式语法(PEG)及其变体 Packrat 解析器。这种解析器更像是一种功能强大的正则解析器,与传统解析器有所不同的是,Packrat 解析器支持无限回溯,并且它通过缓存机制,采用以空间换时间的策略来避免重复解析,最终能够达到线性时间复杂度,本质上就是动态规划的思路。其实际落地的代码与手写的递归下降解析器比较相似,但代码结构会更加清晰美观。不过需要注意的是,PEG 的语法注解方式和 CFG 是不一样的,主要差异在于 PEG 中规则是有顺序要求的,而在 CFG 规则顺序是无关紧要的。比如在 PEG 语法中,A -> B \ CA -> C \ B,是不等价的,但在 CFG 里这两个表达式则是等价的。而追其根本的差异在于 PEG 描述的解析过程,但 CFG 描述的是语言。Packrat 解析器在实际应用中不算广泛,主要是由于其性能表现欠佳,尤其是在内存占用方面,据了解,目前只有 Python 有使用到它。更多的细节内容可以参考原论文

INFO

PEG 语法中使用 \ 而不是 | 来强调顺序的重要性,以此与传统语法描述方式形成区别。

技术选型

最终决定选用 Packrat 解析器,主要原因如下:Python 在早几年已经将原本的 LL 解析器替换成了 PEG 解析器,而且从实际情况来看,性能方面并没有出现明显的差异,同时可维护性却得到了极大的提升。再者,考虑到 Packrat 解析器实现起来较为直观、简单,并且 Python 是现成的案例可供参考,还有 Guido 的相关教学博客能够辅助学习。而对于其他传统的解析器生成器,我只了解皮毛,它们的学习曲线同样比较陡峭,并且各自都存在一定程度的局限性。综合多方面因素考虑,最终决定自己手写一个 Packrat 解析器。

语法设计

这个语法文件单纯是用来表达语法的,并不能丢给哪个解析器生成器来生成。有些非终结符我这里没写出来,因为代码实现的时候直接调用 Rust 的内置函数。此外,有些细节和实际的实现略有不同或者简化,有需求直接参考源代码。

入口语法

program -> stmt* eof

语句语法

stmt -> \
    \ expr ';'
    \ expr
    \ ';'

block -> '{' stmt* '}'

模版语法

pat -> \
    \ '_'
    \ ident
    \ '(' ','.pat+ ')'
    \ lit

ident -> (alphabetic \ "_") (alphanumeric \ "_")*

表达式语法

expr -> \
    \ assign
    \ tuple
    \ block
    \ "break" expr?
    \ "continue"
    \ "for" pat "in" expr block
    \ "match" expr '{' ','.(pat '=>' expr)+ '}'
    \ "if" block ("else" expr)?
    \ "loop" block
    \ "return" expr?
    \ "while" expr block

assign -> \
    \ pat '=' expr
    \ pat '+=' expr
    \ pat '-=' expr
    \ pat '*=' expr
    \ pat '/=' expr
    \ pat '%=' expr

tuple -> \
    \ '(' ','.expr+ ')'
    \ disjunction

disjunction -> \
    \ disjunction "or" conjunction
    \ conjunction

conjunction -> \
    \ conjunction "and" inversion
    \ inversion

inversion -> \
    \ "not" inversion
    \ equality

equality -> \
    \ equality '==' comparison
    \ equality '!=' comparison
    \ comparison

comparison -> \
    \ comparison '>' term
    \ comparison '>=' term
    \ comparison '<' term
    \ comparison '<=' term
    \ term

term -> \
    \ term '+' factor
    \ term '-' factor
    \ factor

factor -> \
    \ factor '*' unary
    \ factor '/' unary
    \ factor '%' unary
    \ unary

unary -> \
    \ '+' unary
    \ '-' unary
    \ primary

primary -> \
    \ lit
    \ ident
    \ '(' expr ')'
    \ '|' ','.ident* '|' expr

字面量语法

lit -> \
    \ float
    \ int
    \ str
    \ bool

float -> \
    \ '0' '.' digit+
    \ digit+ '.' digit+

int -> \
    \ '0x' hex+
    \ '0o' oct+
    \ '0b' bin+
    \ '0' !digit
    \ digit+

bool -> \
    \ "true"
    \ "false"

str -> '"' (!'"' char)* '"'

经验分享

编程语言本身实则也是一种产品,用户体验至关重要。正因如此,绝大多数流行的编程语言都会引入大量的语法糖并允许副作用,借此简化重复性工作,提升代码可读性,但这也会破坏语言在学术方面的严谨性。像 Python 就是极致易用的代表,而 Haskell 则有着绝对的学术严谨性,所以 Haskell 在学术研究中被大量运用,但在业界的使用量并不算多。总之,在两者之中找到平衡才是关键。

在设计编程语言时,开发者很容易不自觉地朝着函数范式靠拢,因为从代码实现角度来看,这样做就是很合理,但必然会牺牲诸多便利性。例如,比如最简单的变量赋值,由于需要在后端存储变量内容,所以同样会产生副作用,而函数式语言中因不允许重复赋值,才能实现无需后端存储的效果;再或者说绝大多数语言都具备的打印函数,会自动和系统交互输出,但在函数范式里,和系统交互的接口必须手动传入才行。

就个人观点而言,Go 和 Rust 等语言在权衡方面做得比较好,整体风格偏向函数式,同时也提供了不少语法来实现更高层次的抽象,无需像 C 语言那样把各类信息全都塞在函数名当中,进而避免了代码可读性欠佳的问题。不过就单单日常实用性而言,我应该还是会毫不犹豫的选择 Python,毕竟平时写个脚本之类的逻辑严谨性并不这么重要。