2 本书有关语法解析的一切
语法解析(parsing)是将输入字符流转换成结构化内部表示的过程。常见的内部表示是树 ,程序可以递归地处理树这种数据结构。例如,给定输入流:
23 + 5 - 6
我们可以将其转换成根节点为加法,左边节点表示数23
,右边节点是用树表示5-6
的树。语法解析器(parser)是用于实现这种转换的程序。
语法解析本身是个比较复杂,且由于歧义的存在,还远没有被解决的问题。例如上面的例子,你还可以将其转换成根节点为减法,子树为加法的树。我们还需要考虑加法操作符的是否符合交换性(左右参数是否能互换)等问题。要解析功能完整的语言(暂且不提自然语言),要考虑的问题只会更多更复杂。
2.1 轻量级的,内建的语法解析器的前半部分
这些问题使得语法解析本身适合当作单独的主题来讲,也确实有很多书本、课程和工具专注于该方面。从我们的角度来说,语法解析是种令人分心的东西,因为我们想学习的是编程语言的除去语法解析的各个部分。因此,我们使用Racket一个有用的功能来将输入流转换成树:read
。read
和该语言的括号语法形式紧密关联,它将括号形式的字符流转换成内部树形式。例如,运行(read)
然后输入——
(+ 23 (- 5 6))
——会产出一链表,其第一个元素是符号'+
,第二个元素是数23
,第三个元素是链表;该链表其第一个元素是符号'-
,第二个元素是数5
,第三个元素是数6
。
2.2 快捷方式
你应该知道,程序都会需要详尽的测试,而每次测试都需要手工输入会很麻烦。幸运的是,你可能猜得到,括号表达式可以在Racket中用引号来表达,也就是你刚才看到的'<expr>
形式——其效果和运行(read)
然后输入<expr>
一样。
2.3 语法解析得到的类型
事实上,我刚才的描述并不准确。之前说(read)
会返回链表等类型。在Racket中确实如此,但在Typed PLAI中,事情稍有不同,(read)
返回值类型为s-expression
(符号表达式的简写)。
> (read)
- s-expression
[type in (+ 23 (- 5 6))]
'(+ 23 (- 5 6))
Racket包含了强大的s-expression系统,其语法还甚至可以表达带循环的结构。不过我们只会用到其中的一部分。
在静态类型的语言中,s-expression被认为是和其他类型(例如数、链表)都不同的数据。在计算机内部,s-expression是一种递归数据类型,其基本构造是原子值——例如数、字符串、符号,组合形式可以是表、向量等。因此,原子值(数、字符串、符号等)即是其自由类型,也是一种s-expression。这就造成了输入的歧义,我们后文讨论。
Typed PLAI采取一种简单的方式来处理这种歧义:当直接输入时,原子结构就是它本身的类型;当输入为大结构的一部分时——包括read或者引用——它们就是s-expression类型。你可以通过类型转换将其转换为基本类型。例如:
> '+
- symbol
'+
> (define l '(+ 1 2))
> l
- s-expression
'(+ 1 2)
> (first l)
. typecheck failed: (listof '_a) vs s-expression in:
first
(quote (+ 1 2))
l
first
> (define f (first (s-exp->list l)))
> f
- s-expression
'+
这方面和Java程序的类型转换类似。我们后文再学习类型转换。
请注意,表结构的第一个元素的类型并不是符号:表形式的s-expression是由s-expressions组成的表。因此,
> (symbol->string f)
. typecheck failed: symbol vs s-expression in:
symbol->string
f
symbol->string
f
first
(first (s-exp->list l))
s-exp->list
类型转换:
> (symbol->string (s-exp->symbol f))
- string
"+"
必须对s-expressions进行类型转换确实是个麻烦事,但是某种程度的麻烦是不可避免的:因为我们的目的是把没有类型的输入,通过严谨的类型分析,转化为有类型的。所以有些关于输入的假设必须明文给出。
好在我们只在语法解析中使用s-expressions,而我们的目的是尽快处理完语法解析!所以,这一点只会帮助我们尽快摆脱语法解析。
2.4 完整的语法解析器
原则上read
就是完整的语法解析器。不过其输出过于一般化:结构体中并不包含其意向的注释信息。所以我们倾向于使用更具体的表达方式,类似于前文中“表达加法”和“表达数”的那种。
首先,我们必须引入一种数据类型来表示这类关系。后文(3.1节)会详细讨论为啥采用这种数据类型,还有我们如何得出该数据类型。现在请先假设它是给定的:
(define-type ArithC
[numC (n : number)]
[plusC (l : ArithC) (r : ArithC)]
[multC (l : ArithC) (r : ArithC)])
现在我们需要能将s-expression解析成该数据类型的函数。这就是语法解析器的另一半:
(define (parse [s : s-expression])
(cond
[(s-exp-number? s) (numC (s-exp->number s))]
[(s-exp-list? s)
(let ([sl (s-exp->list s)])
(case (s-exp->symbol (first sl))
[(+) (plusC (parse (second sl)) (parse (third sl)))]
[(*) (multC (parse (second sl)) (parse (third sl)))]
[else (error 'parse "invalid list input")]))] ;无效的表输入
[else (error 'parse "invalid input")])) ;无效的输入
简单运行如下:
> (parse '(+ (* 1 2) (+ 2 3)))
- ArithC
(plusC
(multC (numC 1) (numC 2))
(plusC (numC 2) (numC 3)))
恭喜!你完成了首个程序的表示。从今往后我们就只需要处理用递归的树结构表示的程序了,再也不用担心各种不同的语法,还有如何把语法转换为树形结构了。我们终于可以开始学习编程语言了!
练习
如果传给语法解析器的参数忘了加引号,后果是啥?为什么?
2.5 尾声
Racket的语法继承自Scheme和Lisp,不乏争议。不过请观察它给我们带来的深层次好处:对传统语法进行解析会很复杂,而解析这种语法则简单明了,不管是从字符流到s-expressions的解析,还是从s-expressions进一步到语法树的解析。
这种语法的好处就是其多用途性。需要的代码少,而且可以方便的插入各种应用场景。所以很多基于Lisp的语言其语义各不相同,但都保留了历史继承而来的这种语法。
当然,我们也可以采用XML,它更好用;或者JSON,它和s-expression有着本质的不同!