`
Lich_Ray
  • 浏览: 163423 次
  • 性别: Icon_minigender_1
  • 来自: 南京
文章分类
社区版块
存档分类
最新评论

递归下降语法分析详解

阅读更多
引用
本文以 lichray 设计的 S-dict(t) 配置文件解析器为例,简单介绍了词法分析器的原理,详细讲述了递归下降语法分析器手工构造方法。因为该项目本身已经完成,故此本文拥有一个实际可用的例子,是不可多得的入门教程。

引用
T1 大人说过,技术的迅速贬值是十分残酷的,比如大部分的手工优化代码,早已被编译器们代劳。这篇文章中要说的递归下降语法分析方法也是严重贬值了的技术之一。不过我认为,在享受着别人构造的自动化工具同时,知道其原理还是很重要的。一个典型的例子就是正则表达式——大家都会用,能保证写对的人也很多,但看了专家们的解答后都自愧不如——原因很简单:你会写正则表达式的编译器吗?
不过这篇文章并不是在教你怎样写 yayacc,只是希望你能从中体会到工具的思想并能更好地组织头脑中的 BNF 产生式。当然了,用这种方法手工构造一个代码语法高亮程序也是个不错的想法。


很多人都是从《C 编程语言》这本书上听说递归下降这种优美的语法分析器手工构造方法的,但这“很多人”中的很多人事实上没有看懂或者是看懂了就是构造不出自己的语法分析器。我三个月前完成了自己设计的 S-dict(t) 解析器,找到一个来理清这里面思路的好机会。

S-dict(t) 简介
这是作者设计的一直配置文件和数据交换格式,语法类似 Scheme,支持多种数据类型甚至包括迭代器。具体的示例文件太长,在附件中可获得。它的解析器相当于只把编程语言的解析过程做到语法分析树构造这一步,天然的例子。
main ::= {tree}
tree ::= '(' id leaf ')'
leaf ::= exps | main
exps ::= [{exp}]
exp  ::= id | bool | num | str | arr | low
id   ::  [^#\.\d\(\)\[\]\{\}'"`,;+-][^\s\(\)\[\]\{\}'"`,;]*
bool ::  #t|#f|#T|#F
num  ::  [+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?
str  ::  '\'' [{chr}] '\'' | '"' [{chr}] '"'
low  ::  #[^\s\(\)\[\]\{\}'"`,;]*
chr  ::= any-Unicode-character-except-"-or-\-or-control-character | 
		\" | \\ | \0 | \b | \7 | \f | \n | \r | \t | \v | 
		\x two-hex-digits | \u four-hex-digits
number
arr  ::= '[' arrc ']'
arrc ::= [{exp}]
comt ::  ^\s*#.*$

以上它的完整的 E-BNF 产生式,是实现解析器的根据,在下面两章会被用到。

词法分析:到底是什么
《现代编译原理》这本书上对词法分析的介绍尤为生动,生动的结果就是给人一种错觉:词法分析需要完整地解析正则表达式,连接有穷自动机,所以手工实现,难度不亚于给自动机作数学注明。但那只是语言学的理论分析结果。在实际应用中,如果有什么语言的词法分析需要严格地完全连接非确定有穷自动机,那只能说明这个语言设计地很“令人困惑”。
词法分析器只不过是这样的一个程序:你给它要分析的程序源代码,它返回一个数组,数组中的每一项都是正确分割了的词法元素。比如对于一段 S-dict(t) 代码:
(i (name 'lichray')
(age 13))
它应该返回 ['(', 'i', '(', 'name', "'lichray'", ')', '(', 'age', 13, ')', ')']。当然了,这种做法是比较简易的,通用的做法应当是给每个词法元素一个数据类型,然后返回相应的对象/实例/结构。构造它,根据词法元素表照抄即可。
## 这些全是正则表达式
id   ::  [^#\.\d\(\)\[\]\{\}'"`,;+-][^\s\(\)\[\]\{\}'"`,;]*
bool ::  #t|#f|#T|#F
num  ::  [+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?
str  ::  '\'' [{chr}] '\'' | '"' [{chr}] '"'
low  ::  #[^\s\(\)\[\]\{\}'"`,;]*
main ::  \(
end  ::  \)
arr ::  \[
narr ::  \]

解析字符串的思路大致是这样:对每个词法元素写一个函数,以一个主函数完成词法元素的判断过程,并调用相应的函数解析。每完成一个元素的解析,就返回拆解下来的结果和字符串的剩余部分,将这些数据,结果、剩余字符串和行号返回给主函数继续。
// 这个函数就可以完成这样的工作
// ssub(string, pattern) 同时返回 pattern 匹配 string 后的结果和余下的字符串
function ssub (s, p) {
	var ss = p.exec(s)[0]
	return [ss, s.slice(ss.length)]
}
// 弹出下一个单词
function word (s) {
	return ssub(s, /^[^\s\(\)\[\]\{\}'"`,;]+/)
}
// 弹出下一个“东西”,仅用于报错
function gew (s) {
	return /^[^\s\(\)\[\]'"]+/.exec(s)[0]
}
// 还真的报错了
function error (m, ln) {
	throw Error (m+" in line "+ln)
}

	// 先给出判断语法元素的函数,它们的参数是剩余的字符串
	// 剩余的字符串没有了,整个代码也到头了
	function isEOF (c) { return c == "" }
	function beLine (c) { return c[0] == '\n' }
	function beSpase (c) { return /\s/.test(c[0]) }
	function beId (c) { return /[^#\.\d\(\)\[\]\{\}'"`,;+-]/.test(c[0]) }
	function beBool (c) { return c[0] == '#' }
	function beNum (c) { return /[-+\d\.]/.test(c[0]) }
	function beStr (c) { return c[0] == '\'' || c[0] == '\"' }
	function beMain (c) { return c[0] == '(' }
	function beEnd (c) { return c[0] == ')' }
	function beArr (c) { return c[0] == '[' }
	function beNarr (c) { return c[0] == ']' }

似乎很繁琐。真的吗?可别忘了我整个程序近400行代码可只写了8行注释哦~`
注意:事实上,S-dict(t) 解析器并没有使用独立的词法分析器,而是把词法分析和语法分析同时完成了,而且本文并非以讲解词法分析为主。所以下面的代码虽然放在源代码文件中时几乎实际可运行,但没有被实际采用。
/* 基本函数 */
// 连接项目与列表
function cons (o, l) {
	return [o].concat(l)
}
// 取列表除去第一项后余下的部分
function cdr (l) {
	return l.slice(1)
}
// 消除左侧除换行之外的空白
function strim (s) {
	return s.replace(/^\s+?(?=\n)?/,'')
}

/* 词法分析器 */
// 标准的正则尾递归优化写法。尽管 JavaScript 不支持优化,但不失为一种很好的组织程序的手段
// 使用时调用 slex(string, [], 1),参数 t 用来暂存分析数组
function slex (s, t, ln) {
	if (isEOF(s)) return t.reverse()
	var tmp = []
	// 用 ln 参数保存行数
	if (beLine(s)) return slex(cdr(s), ln+1)
	if (beSpase(s)) return slex(strim(s), ln)

	else if (beBool(s)) tmp = bool(s, ln)
	else if (beNum(s)) tmp = num(s, ln)
	else if (beId(s)) tmp = id(s, ln)
	else if (beStr(s)) tmp = str(s, ln)
	else if (beMain(s)) tmp = main(s, ln)
	else if (beEnd(s)) tmp = end(s, ln)
	else if (beArr(s)) tmp = arr(s, ln)
	else if (beNarr(s)) tmp = narr(s, ln)
	else error("Unknown Value: "+gew(s), ln)

	return slex(tmp[1], cons(tmp[0], t), tmp[2])
}

/* 所有的词法元素的解开过程 */
// 每个函数返回的第三个值是新的行号
// 此处省略,详见附件源代码 179-222 行
// 源代码中 arr、narr、main、end 几个函数都是语法分析的版本,
// 以 arr 举例说明这一系列函数的共性
function arr (s, ln) {
	return ['[', cdr(s), ln]
}
// 其他的函数只不过是使用了 ssub() 函数通过正则表达式解析了而已。

这里注意一下:为什么没有 low 词法元素的处理函数?在源代码中也可以看到,因为 low 和 bool 共用同一个起始字符,所以解析函数也被写到了一起。最后一章将会解释这样做的意义。

语法分析:词法分析立体版

语法分析的目的
无论是生成抽象语法树(AST)还是算符优先栈还是别的什么数据结构,我们发现,最终在根据分析结果执行代码时,其实都是在做一个树形过程,都需要逻辑上的一个立体的数据结构。语法分析的,就是通过获取平面的词法分析结果,根据语法结构描述(比如 BNF)输出立体的数据结构。在 S-dict(t) 这个例子中,我们选择的数据结构是 JavaScript 对象构成的树。例如代码
(i (name 'lichray')
(age 13))
的分析结果应该是:
{i: {name: 'lichray', age:13}}
限制是:一个树枝上如果有叶子或其他树枝,整个树枝的下属必须全部是叶子或树枝而不能是数据;数据只能出现在叶子上。

E-BNF 和 BNF
除了知道语法分析的“来龙去脉”,还需要一样描述语法语法结构的形式语言。现在的教科书上所教授的一般是扩展的巴菲斯-劳尔范式(E-BNF),但是事实,E-BNF 相对于 BNF,存在一个很有意思的问题:那就是自由度过大。除非按照教科书上的方法消除左递归,否则往往很难手工构造。事实上,我上文中给出的 E-BNF 也并非一开始就是那样,
main ::= {tree}
tree ::= '(' id leaf ')'
leaf ::= exps | main

这三句很明显可以被非常直接地合并成
tree ::= '(' id exps | {tree} ')'

一句。但这样你就不得不把构建分析表的工作交给 yacc 之类的工具了,因为你没有写出全部的语法元素。
而 BNF 可以强迫你写出大部分需要递归描述的语法元素,并且可以直接地指定语法的结合方向。S-dict(t) 的 BNF 如下:
// e 代表为空,对应的希腊字母
// 当然也可以消除 e,把为空表现在上级产生式中
main ::= tree | tree main
tree ::= '(' id leaf ')'
leaf ::= exps | main
exps ::= e | exp | exp exps
exp  ::= id | bool | num | str | arr | low
arr  ::= '[' arrc ']'
arrc ::= e | exps

会心一笑:我的 E-BNF 为什么写成了那个怪样子,其实就是从 BNF 逐句转来的。
知道了语法元素的递归方向,不需要消除递归,只需把每个元素的解析过程表示为函数,把要执行的全局代码插入函数体,然后把 BNF 的逻辑直接而机械得转为函数间的递归调用,语法分析器就写出来了。BNF 中只有选择和递归两种逻辑,下面是对它们的编码示例。
// 参数 ls 是词法分析结果
function sdict (ls) {
	var index = ["~"]  //对象树节点名的线型访问栈
	var root = {}      //全局的对象树
	var stack = [root] //对象树节点的线型访问栈

	// 对照 BNF,不难发现解开递归的技巧:
		/* 把对起始符和终结符的处理写在函数中,递归部分一直向下推迟,
		   直到 exps 这个产生式被终结时再调用回 main() */
	// main ::= tree | tree main
	function main (ls, ln) {
		if (ls == false)
			if (!canPop(stack))
				return
			else error("Lack of end quotes", ln)
		else if (beMain(ls[0]))
			tree(cdr(ls), ln)
		else if (beEnd(ls[0]))
			if (canPop(stack)) {
				// 语法分析 main 结束就弹出一个数据栈,一条树枝结束了
				stack.pop()
				main(cdr(ls), ln)
			}
			else error("To many end quotes", ln)
		else error("Wrong Syntax: "+ls[0], ln)
	}

	function tree (ls, ln) {
		// 这是一个一步推导产生式的例子
		// tree ::= '(' id leaf ')'
		if (beId(ls[0])) {
			var tmp = id(ls[0])
			// 这些就是插入的全局数据结构更新代码,向栈中增加索引
			add_index(stack, tmp[0])
			leaf(tmp[1], ln)
		}
		else error("Not an Id: "+ls[0])
	}

	/* 详细参见源文件74-143行,把每个函数处理空白的代码去掉,
	   参数s替换为 ls,函数体内替换为 ls[0] 就是独立语法分析的版本。 */
}


合并词法分析和语法分析
简单的优化
把所有的小函数全部作内联,展开代码到被调用的地方。这样一来,判断起始符号的函数就可以全部消灭(顺便提一句,使用静态多态类型的纯函数式编程语言,如 Haskell、Ocaml、ML,写的语法分析器自动生成器不需要这种优化,因为语言本身就已经帮你做了)。当然了,作为应用“优化”手段的一般代价,就是代码看不懂了。

一趟分析
S-dict(t) 的解析器事实上使用了一趟分析的技术,把词法分析和语法分析合并到一起完成,不使用词法分析生成的中间数组,提高解析效率。方法是把语法分析提取 ls[0] (即当前元素)的过程扩写为从 s (剩余的源代码)中弹出下一个词法元素的过程。实例代码就是源代码中的 sdict() 函数,就不列在这儿了。

引发的联想
从上面三章我们不难看出,词法分析、语法分析、一趟分析其实都是一个非常机械的过程,完全可以用工具自动生成代码。词法分析器生成器是最简单的,可以直接生成全部代码,像 flex 做的那样,连同逻辑一起硬编码;也可以像 lex 那样,提供固定的解析代码,只生成非确定有穷自动机的分析表。语法分析要稍复杂一点,问题在于如何判断什么时候该规约(确定下面调用哪一个分析函数)什么时候该移进(更新分析结果,继续向下递归)。这需要判断终结符和产生式之间的关系。最简单的判断方法是 LL(1),也就是说统一只向下查看一个字符。这也就是我们这个例子中使用的方法。但这种方法较容易产生歧义,需要使用者自己修正解析代码。这就是 S-dict(t) 中 bool 和 low 的解析写成了一个函数的原因。antlr 就是用了 LL(1) 分析,不过它带有连接词法分析和语法分析的能力。yacc 使用的是 LR() 分析,一种带有回溯能力的强大的自动分析方法,克努特天才的创造。也许以后会有机会给大家讲到这个。

  • sdict_t.zip (3.3 KB)
  • 描述: S-dict(t) 解析器完整的源代码和示例。example.txt 中是一个 S-dict(t) 代码的字符串,执行 sdict(aString) 可获得转换后的对象树(但这好像跟本文主旨没什么关系呵呵~`)。
  • 下载次数: 477
分享到:
评论
8 楼 DraculaW 2009-01-06  
最近在看自动机的书
和lz的一起看来
7 楼 abruzzi 2009-01-06  
非常不错的文章,特别是竟然采取javascript来实现,更是非同小可。
FP确实很简洁,写起来也比较顺手。可惜很多人不理解。
6 楼 davies 2008-03-01  
javascript 支持尾递归么?

否则 tb[s[0]]+iter(cdr(s)) 会有效率问题吧,不是哪个地方都适合使用 Scheme的风格写代码的。

5 楼 mahudu 2008-02-07  
antlr不是LL(1)的,而是LL(k)的自顶向下的,k可以自己指定,如果过大,效率则会非常低。
bison是自底向上的用来生成语法分析器的,可以方便地处理左递归。

同意xiaolin0105的看法,如果不是手工打造编译前端,用工具做编译器前端真得没什么意思(纯粹地与正则表达式和BNF范式较劲),后端优化和代码生成才是难点。
4 楼 xiaolin0105 2008-02-04  
懒得看了,本科时候就开发过编译器了,parser的construction就是用的recursive descent的方法。。。。这种方法写编译器确实简单,不过现在都是用工具来生成parser tree,然后加semantic analysis,当然semantic analysis也是用的递归下降法。

其实编译器从scanner, parser一直到semantic的实现都没什么新奇的,现在重点在optimization和code generation。
3 楼 lichray 2008-02-02  
什么叫“FP 那些括号”。Haskell 括号比 C 还少。你说的那是 Lisp/Scheme 吧。其实你真正用过了就会发现:括号真方便。在高级编辑器里可以自动高亮配对的括号,一眼就可以看出表达式返回值的位置;在括号之间用 C+[] 键跳来跳去的感觉也很爽,配合正则表达式查找,不需要用 ctags 之类的工具也能快速定位到函数。反正弱智的 ctags 也不懂 Scheme 的函数是啥概念。

C Ruby 太可怜了,因为使用了太多的语法糖来骗取大众的好感。国人开发的 XRuby 的语法分析器是手写的,效率非常高,Ruby 1.9 可能会用。
2 楼 agile_boy 2008-01-31  
写的很详尽,留待以后慢慢研究
1 楼 Beag.Ye 2008-01-21  
文章看了,还是不怎么懂。倒是在源代码里发现一样好东西,可以把一个字符串反序列化的函数:
function str (t) {
	var tb = {
		'\"':'\\"',
		'\\':'\\\\',
		'\b':'\\b',
		'\f':'\\f',
		'\n':'\\n',
		'\r':'\\r',
		'\t':'\\t',
		'\v':'\\v',
	}
	function iter (s) {
		if (s == "")
			return ''
		if (tb[s[0]] != undefined)
			return tb[s[0]]+iter(cdr(s))
		return s[0]+iter(cdr(s))
	}
	return '"'+iter(t)+'"'
}

测了一下好像可以是的,不过要带上 cdr() 的声明。这个东西找了很久,居然在这儿看到了,晕~`

相关推荐

    Python 递归函数详解及实例

    在python里,递归函数不需要任何特殊的语法,但是它需要付出一定的努力去理解和创建. 我们会以一个简单的例子开始:写一个函数求一个自然数中所有数字的和.在设计递归函数的时候,我们会寻找能把问题分解成...

    SQL Server 树形表非循环递归查询的实例详解

    很多人可能想要查询整个树形表关联的内容都会通过循环递归来查…事实上在微软在SQL2005或以上版本就能用别的语法进行查询,下面是示例。 --通过子节点查询父节点 WITH TREE AS( SELECT * FROM Areas WHERE id = 6 ...

    javascript设计模式之解释器模式详解

    抽象语法树可用一个表驱动的语法分析程序来完成,也可用手写的(通常为递归下降法)语法分析程序创建,或直接client提供。 解析器:指的是把描述客户端调用要求的表达式,经过解析,形成一个抽象语法树的程序。 解释...

    php rmdir使用递归函数删除非空目录实例详解

    语法: bool rmdir ( string $dirname [, resource $context ] ) 尝试删除 dirname 所指定的目录。 该目录必须是空的,而且要有相应的权限。 失败时会产生一个E_WARNING级别的错误。 参数: 1.dirname:目录的路径...

    125集专攻JAVA基础 JAVA零基础入门学习视频教程 动力节点JAVA视频教程.txt

    北京动力节点-Java编程零基础教程-118-Java基本语法-方法详解-方法的调用过程-举例及简单分析.avi 北京动力节点-Java编程零基础教程-119-Java基本语法-方法详解-方法的调用过程-方法调用过程中栈内存的变化.avi ...

    R语言数据分析案例之电商销售案例详解.pdf

    R语言是一种为统计计算和图形显示而设计的编程语言和软件...R语言是一种相当完善、简洁和高效的程序设计语言,包括条件语句、循环语句、用户自定义的递归函数以及输入输出接口。 R语言是彻底面向对象的统计编程语言,支

    对Python生成器、装饰器、递归的使用详解

    语法格式: (expr for iter_var in iterable) (expr for iter_var in iterable ifcond_expr) 2)、自定义生成器 函数中使用yield,会返回一个生成器对象。yieldx 生成器使用示例: In [1]:list((i**2 for i in ...

    《Java和Android开发实战详解》第2到5章源代码-by 南邮-陈杨

    3.2.1 Java的命名语法 36 3.2.2 变量的声明 37 3.2.3 赋值语句 38 3.2.4 常量的声明与使用 40 3.3 Java的数据类型 40 3.3.1 整数类型 41 3.3.2 浮点型 42 3.3.3 布尔型 43 3.3.4 字符型 43 3.4 ...

    精通SQL 结构化查询语言详解

    16.2.8 递归触发器  16.2.9 SQL Server中触发器的管理  16.3 Oracle数据库中触发器的操作  16.3.1 Oracle触发器类型  16.3.2 触发器的创建 16.3.3 创建系统触发器  16.3.4 触发器的触发次序和触发谓词的...

    精通SQL--结构化查询语言详解

    16.2.8 递归触发器 336 16.2.9 sql server中触发器的管理 338 16.3 oracle数据库中触发器的操作 340 16.3.1 oracle触发器类型 340 16.3.2 触发器的创建 341 16.3.3 创建系统触发器 342 16.3.4 触发器的触发...

    java基础案例与开发详解案例源码全

    5.6.9 理解main方法语法及命令行参数147 5.6.1 0递归算法147 5.7 this关键字148 5.8 JavaBean149 5.9 包150 5.9.1 为什么需要包?150 5.9.2 如何创建包151 5.9.3 编译并生成包:151 5.9.4 使用带包的类152 5.9.5 JDK...

    最新Python3.5零基础+高级+完整项目(28周全)培训视频学习资料

    递归 函数式编程与函数不同 高阶函数 第4周 上节内容回顾 心灵鸡汤 装饰器详解 装饰器应用详解 装饰器之函数即变量 装饰器之高阶函数 装饰器之嵌套函数 装饰器之案例剖析 装饰器之高潮讲解 迭代器与生成器 迭代器...

    JavaScript详解(第2版)

     7.1.5 递归   7.1.6 函数是对象   7.2 调试技巧   7.2.1 函数语法   7.2.2 使用try/catch和throw捕捉异常   7.3 应知应会   练习   第8章 对象   8.1 什么是对象   8.1.1 对象及点语法...

    Java基础最全笔记文档

    2. Java基础语法、类型转换、运算符、Scanner 3. 分支结构、循环结构、随机数 4. 数组详解、Debug工具使用 5. 方法详解 6. 编程思维案例 7. 面向对象基础 8. 常用API 9. 综合项目实战 Java加强篇包括: 1. static、...

    linux中chmod命令用法详解

    chmod命令语法 ... ● -R, – recursive(递归更改文件和目录)  ● –help(显示帮助和退出)  ● –version(输出版本信息和退出) 下面是可以为用户,组以及计算机上的其他所有人设置的几个数字权

    webpack4.x最新入门配置详解

    将ES6,ES7等高级语法转化为浏览器可识别的ES5语法 什么是webpack 本质上,webpack 是一个现代 JavaScript应用程序的静态模块打包器(module bundler)。当 webpack处理应用程序时,它会递归地构建一个依赖关系图...

    Python break语句详解

    break语句用来终止循环语句,即循环条件没有False条件或者序列还没被完全递归完,也会停止执行循环语句。break语句用在while和for循环中。如果您使用嵌套循环,break语句将停止执行最深层的循环,并开始执行下一行...

    Linux rgrep命令用法详解

    Linux rgrep命令用于递归查找文件里符合条件的字符串。 rgrep指令的功能和grep指令类似,可查找内容包含指定的范本样式的文件,如果发现某文件的内容符合所指定的范本样式,预设rgrep指令会把含有范本样式的那一列...

    Linux chattr命令用法详解

    Linux chattr命令 Linux chattr命令用于改变文件属性。 这项指令可改变存放在ext2文件系统上的文件或目录属性,... -R 递归处理,将指定目录下的所有文件及子目录一并处理。  -v 设置文件或目录版本。  -V 显示指

    JAVA新手入门 基础面向对象多线编程

    本资料开源与IT开发者 论坛,由创建者:iyangxin 编写,本人只做了整理...基础语法 数组 常用类 容器 面向对象 递归 多线程 GUI 反射机制详解 正则表达式 日期处理 字符串 集合 异常处理 IO 流程控制 继承 JAVA高手进阶

Global site tag (gtag.js) - Google Analytics