最近在利用python做语法解析,使用了PLY,就是python里的lex+yacc。
一些使用心得记录一下。
词法解析
- 以t_打头的lex规则如果出现冲突,并不完全是以出现的位置先后来确定优先级。从使用来看,字符串写法匹配时,正则表达式越长,则匹配优先级更高,比如下面的例子:
t_AND = 'and'
t_OR = 'or'
t_ID = r'[a-zA-Z_][a-zA-Z0-9_]*'
会导致将关键字and和or也解析为ID,这不是我们想要的行为。可以定义保留字字典来解决:
RESERVED = {
"and": "AND",
"or": "OR",
}
def t_ID(t):
r'[a-zA-Z_][a-zA-Z0-9_]*'
t.type = RESERVED.get(t.value, "ID")
return t
但是,两个函数写法的匹配规则之间,还是以函数出现的位置先后定优先级的,比如下面例子,如果掉个个,就不行了:
def t_FLOATING(t):
r'\d+\.\d+'
t.value = float(t.value)
return t
def t_INTEGER(t):
r'\d+'
t.value = int(t.value)
return t
如果INTEGER规则在先,那么10.0会解析为3个token:
(10, INTEGER) (‘.’, DOT) (0, INTEGER)
这就不是我们想要的了。
- 终结符用tokens变量表示,是一个tuple
条件词法解析(conditional lex)
states = (("specmode", "exclusive"),)
def t_begin_specmode(t):
"{"
t.lexer.push_state("specmode")
def t_specmode_end(t):
"}"
t.lexer.pop_state()
def t_specmode_ID(t):
"[a-zA-Z_][a-zA-Z0-9_]*"
t.type = "ID"
return t
t_ANY_ignore = " \t"
PLY的默认状态是INITIAL。
上述例子中,当遇到{时,我们会进入一个独占状态specmode,当遇到}时,我们会退出状态specmode。t_specmode_ID告诉PLY,只有在specmode状态中,我们才需要解析ID变量,其它状态的字母数字组合,我们会忽略。
t_ANY_ignore意思是,在任意状态(这里是INITIAL或specmode)下,空格都会忽略掉。
语法解析
- 算符优先级使用precedence来定义,这样在写语法规则的时候,可以将规则写得简单很多,比如spel语言的算符优先级定义如下:
precedence = (
('right', 'ASSIGN'),
('left', 'ELVIS', 'QMARK', 'COLON'),
('left', 'OR'),
('left', 'AND'),
## 注意,这里的nonassoc放的位置也很重要,代表了操作符的优先级,不可随意放置
('nonassoc', 'LE', 'LT', 'GE', 'GT'),
('left', 'EQ', 'NE', 'LE', 'LT', 'GE', 'GT', 'REG_MATCH', 'INST_OF'),
('left', 'PLUS', 'MINUS'),
('left', 'MULT', 'DIV', 'MOD'),
('left', 'INC', 'DEC'),
('right', 'POWER'),
('right', 'NOT', 'UMINUS', 'UINC', 'UDEC'),
)
越靠前的,算符优先级越低,同一层的算符优先级相同。当优先级相同的算符连续出现时,就要看是左结合还是右结合(代码里left和right所表示的含义),比如+和-是左结合,那么a + b - c执行顺序是(a+b)-c;再比如赋值是右结合,那么a=b=c的执行顺序是a=(b=c)。
nonassoc表示不允许形成链式调用,比如a > b > c。
这里有个特殊的符号UMINUS,表示负号,它不是一个真实存在的token,仅作为算符优先级占位之用,要在语法规则里结合%prec使用,例如:
def p_arith_expr(p):
"""
arith_expr : expr PLUS expr
| expr MINUS expr
| expr MULT expr
| expr DIV expr
| expr MOD expr
| expr POWER expr
| MINUS expr %prec UMINUS
"""
- 语法是以p_打头的函数,语法规则写在函数的doc里,形如:
def p_var(p):
"""
var : HASH ID
| var member_ref ID
"""
if len(p) == 3:
p.parser.context.do_sth1(p[2])
else:
p.parser.context.do_sth2(p[3])
p是一条规则的节点集合。p.parser就是yacc解析器。我个人的习惯,会做一个自己的ParseContext,放到p.parser里,收集各语法节点信息。
最后,可以用一个类把lex和yacc都封装起来:
class MyInterpreter(object):
def __init__(self):
self.lexer = lex.lex()
self.parser = yacc.yacc()
# 这里的self.parser就是p.parser,可以把自己的ParseContext塞进去,记录必要的信息
self.parser.context = ParseContext()
def parse(self, code):
self.parser.context.clear()
# 如果引入了conditional lex,每次parse前,还要重置lexer的状态为INITIAL
self.lexer.begin("INITIAL")
# 这里需指定lexer
self.parser.parse(code, lexer=self.lexer)
return self.parser.context
一旦yacc.yacc()执行,PLY就会开始分析我们的语法,如果提示里出现shift/reduce conflict,一般要通过增加算符优先级或改变语法规则的写法来解决。例如下面的四则运算规则:
def p_arith_expr(p):
"""
arith_expr : expr PLUS expr
| expr MINUS expr
| expr MULT expr
| expr DIV expr
| expr MOD expr
| expr POWER expr
"""
如果不设置几个算符的优先级,肯定会出现shift/reduce conflict的。
还有一种情况,也可能出现shift/reduce conflict:
rule1
A : C
rule2
B : C
其实是C在reduce的时候产生了疑惑:到底该用rule1还是rule2做reduce?要解决该问题,可以把C后面的符号纳入规则,这样写:
rule1
A : C D
rule2
B : C E
最后,如果上面两种方法都无法解决冲突,可以去查看PLY自动生成的parser.out文件,里面记录了编译器内部的状态流转,搜索conflict关键字,就能找到具体哪个状态下的哪条流转规则出了错。正常的规则形如:
PLUS shift and go to state 35
出错的流转规则前会有个感叹号,形如:
! PLUS reduce using rule 6
我们要思考为何出现这条错误的reduce,一个可行的思路是:回退到无conflict时的正确语法,对比正确语法与错误语法,思考新引入的语法片段对编译器内部状态的流转有何不利影响,并修改其写法。
常用技巧
- 常量字符串匹配规则。我们选一个较复杂的,字符串既可用单引号也可用双引号括起,如字符串内部还有单引号或双引号,用反斜杠转义。lex规则这么写:
def t_STR(t):
r""""([^\\"]|\\")*"|'([^\\']|\\')*'"""
t.value = t.value[1:-1].encode().decode("unicode-escape")
return t
拿到结果后,要注意做一个string-escape动作,把字符串内的反斜杠清掉。
验证的UT:
def test_lex_str(self):
self.assertEqual(self.parse_token("'abc'"), [('abc', 'STR')])
self.assertEqual(self.parse_token('"abc"'), [('abc', 'STR')])
self.assertEqual(self.parse_token("'ab\\'c'"), [("ab'c", 'STR')])
self.assertEqual(self.parse_token('"ab\\"c"'), [('ab"c', 'STR')])
关于ply的异常处理,ply在调用每条p_打头的规则时,会吃掉其抛出的SyntaxError异常,并恢复错误状态,继续执行后面的语法规则,所以,如果不希望ply帮我们做错误恢复,可抛出非SyntaxError类型的异常,让解析过程中断。