原文:Using Leex and Yecc in Elixir 翻译: horsley 2019-05-31
有时候正则表达式不够强大,我们需要一种更为高阶的组织方式,能够引导我们直接处理解析,构建复杂的数据结构。
几周前我发现自己就处于这种需求环境中。我们效率低下的迁移着我们庞大的、老旧的基于 Perl 的代码到 Elixir中。作为一个团队我们做的最多的事情,就是网络监控相关的事情。工作中我们接触到大量乱七八糟,形式各异的数据,包括有数据库,JSON文件,以及天晓得什么东西。于是我使用正则表达式 \d+(?:.\d+)+ 从 JSON 文件中解析 OIDs,然后将他们转换成一个整数列表
str
|> String.split(".")
|> Enum.map(&String.to_integer/1)
上面的表达式将字符串 "1.3.2.5.6" 转换成 list [1,3,2,5,6].
对于一个标准的 OID,这段代码工作良好。可是我们还有很多是需要计算的东西。这些都是一些需要用 perl 进行运算求值的字符串。考察我们的开发环境,我发现这些表达式,包含一些变异形式,类似如下:
([1.3.1.5]+[1.3.1.6])*100/[1.3.1.7]
说实话,这只是一些简单的算术运算,只不过 OIDs 被放在方括号中,在运算时需要用他们的值来替换。
由于这些算术表达式遵从 Chomsky Type 2 grammar,我没办法使用正则表达式处理。我需要更强力的工具。
Leex and Yecc
著名的 lex 和 yacc 工具迄今已经发布有40年了,但他们的价值从未贬损。如果你发现自己需要快速地为一种编程语言设计一个 parser,那么他们是你最好的伙伴。正因为他们如此有价值,因此几乎扩展到了任何一种语言。Erlang 标准发布版也提供了类似工具,他们是 leex 和 yecc 。
##Leex
leex 意思是 “Lexical analyzer generator for Erlang”(Erlang平台的词法分析生成器)。他的主要功能就是将输入的字符流转化成 tokens 流。一个 token 就是一个 terminal character(用字母表中符号命名),他用来构成一门语言的 grammar。举例来说,一个 token 可以是字符 * 或者 +,一个数字 42。或者就前面这个案例来说,一个表示 OID 的方括号中的字符串 - [1.3.2.4.6]。
是什么明确的构成了一个 token,或者换言之,是什么 rules 定义了将一个字符序列映射为一个 token(当然肯定是用的是正则表达式)。在我们的案例中,我们可以说 token * 就是对应正则的 *,一个 token 的 number 可能是定义为 [0-9]+,一个 token oid 定义为 [[0-9]+(.[0-9]+)+]。
因此字符串 ([1.3.1.5]+[1.3.1.6])100/[1.3.1.7] 就对应 tokens 序列 '(' oid '+' oid ')' '' number '/' oid。
leex 将这些 rules 写到文件里,如下:
%% number
[0-9]+ : {token,
{ number,
TokenLine,
list_to_integer(TokenChars)
}
}.
%% left paren
\( : {token, {'(', TokenLine}}.
%% ... other tokens
%% skip white spaces
[\s\n\r\t]+ : skip_token.
冒号左手边的正则表达式,跟右手边的元祖就定义了一个 token。一个 token ,其元祖不是随便定义的。一个二元元祖 {token,_}表示发出一个 token,原子 skip_token 意味跳过这个 token,会简单的忽略匹配的字符。TokenLine 跟 TokenChars 是预定义变量,用来保存源字符串的行号,跟匹配上的字符。一条 rule 必须以点结束。除此外右侧部分是一个有效的 Erlang 表达式,因此任意 Erlang 函数(比如上面的 list_to_ineger)都是可用的。参考 leex man page 了解更多细节。
将 rules 放到一个扩展名为 .xrl 的文件中,mix 可以对其进行编译,文件需要放到 src/ 目录下。文件名就是生成的词法分析器模块名。比如 src/foo.xrl 编译后就会生成一个模块 :foo,其中包含相应的函数。比如调用:
:foo.string '1+2*(5-6)'
运行结果会生成一个 tuple 包含 token list 以及对应的结束行号:
{ :ok,
[ {:number, 1, 1},
{:"+", 1},
{:number, 1, 2},
{:"*", 1},
{:number, 1, 5},
{:"-"},
{:number, 1, 6} ],
1 }
Yecc
yecc 的非正式名称是 “An LALR-1 parser generator for Erlang, similar to yacc”。这个工具的主要作用是生成一个 parser(语法分析器),输入 tokens 流,然后生成相应的数据结构(比如 AST 抽象语法树)。
要生成一个 prarser 模块,yecc 需要 alphabet2(字符表) 和 grammar rules。它还需要一个 root symbol 用来指定 grammar 的入口。
一个 alphabet(字符表)用来定义 non-terminals 和 terminal 符号。一个 non-terminal 符号(比如表达式)代表一系列的 terminals 和/或 其他 non-terminals。而 terminal 符号仅仅代表他们自身(比如 + 符号或者一个数字),它会直接对应 lexer(词法分析器)产生的 tokens。
grammar 通过一系列的 rules 来定义,比如:
op -> '*' : '$1'.
op -> '/' : '$1'.
op -> '+' : '$1'.
op -> '-' : '$1'.
定义一个 non-terminal 符号 op 代表 *, /, +, -。在 BNF 文法中同一规则可以如下书写:
<op> ::= '*' | '/' | '+' | '-'
再次强调,在 leex rules 中,冒号前面的部分定义 rule ,后面的部分定义输出结构。表达式 $1 表示“首次的符号匹配值”。在上面的例子中 terminals 的值会是一个 token,比如 {'*', 1} or {'+', 1}。
下面是一个更复杂些的例子:
expr -> expr op term : {'$2', '$1', '$3'}.
expr -> term : '$1'.
op -> '+' : '+'.
op -> '-' : '-'.
term -> number : '$1'.
上面这个 grammar 用来描述表达式如 1+1-2 or 1+2+3-5-6。parser 的输出结果是一个由嵌套的 tuples 构成的树,节点是 + 或 - 操作符,树叶就是 numbers。
比如对 1+1-2 表达式,通过上述 rules 会解析成:
{
'-',
{
'+',
{:number, 1, 1},
{:number, 1, 1}
},
{:number, 1, 2}
}
第一次迭代: 算术表达式
前面介绍的工具使得编写一个 parser 超级简单。阅读模块文档和编写一个前面讲的基于 OIDs 的算术表达式的词法和解析规则也就花了我不到2小时。我另外花了1个小时编写了一个解释器,用来在 repl 中进行测试。lexer 定义如下:
Definitions.
Rules.
%% a number
[0-9]+ : {token, {number, TokenLine, list_to_integer(TokenChars)}}.
%% an OID
\[[0-9]+(\.[0-9]+)*\] : {token, {oid, TokenLine, oid(TokenChars)}}.
%% open/close parens
\( : {token, {'(', TokenLine}}.
\) : {token, {')', TokenLine}}.
%% arithmetic operators
\+ : {token, {'+', TokenLine}}.
\- : {token, {'-', TokenLine}}.
\* : {token, {'*', TokenLine}}.
\/ : {token, {'/', TokenLine}}.
%% white space
[\s\n\r\t]+ : skip_token.
Erlang code.
oid(Oid) ->
S = tl(lists:droplast(Oid)),
L = string:split(S, ".", all),
lists:map(fun list_to_integer/1, L).
这就是 lexer.xrl 文件的全部内容。Erlang code. 部分定义了一个辅助函数 oid/1,用来将一个表示 OID 的字符列表转化成一个整数列表。
算术表达式的 grammar 众所周知的简单,寥寥数行即可描述:
Nonterminals expr term factor.
Terminals oid number '+' '-' '*' '/' '(' ')'.
Rootsymbol expr.
expr -> expr '+' term : {plus, '$1', '$3'}.
expr -> expr '-' term : {minus, '$1', '$3'}.
expr -> term : '$1'.
term -> factor '*' term : {mult, '$1', '$3'}.
term -> factor '/' term : {divi, '$1', '$3'}.
term -> factor : '$1'.
factor -> '(' expr ')' : '$2'.
factor -> oid : '$1'.
factor -> number : '$1'.
将以上代码一字不改的存入 parser.yrl 文件。
上述两个文件准备就绪后运行 mix compile 进行编译,会创建两个模块 :lexer 和 :parser ,我在 repl 里演示一下:
iex> {:ok, tokens, _} = :lexer.string '1+2*3'
{:ok,
[{:number, 1, 1},
{:"+", 1},
{:number, 1, 1},
{:"*", 1},
{:number, 1, 2}],
1}
iex> {:ok, ast} = :parser.parse tokens
{:ok,
{ :plus,
{:number, 1, 1},
{:mult, {:number, 1, 2}, {:number, 1, 3}}
}
}
After that defining an interpreter that to walk the tree was a straightforward task -
# 两个互相依赖的递归数据类型,用于定义 AST
@type expr_ast :: {:mult, aterm, aterm} | {:divi, aterm, aterm}
| {:plus, aterm, aterm} | {:minus, aterm, aterm}
| aterm
@type aterm :: {:number, any(), integer()}
| {:oid, any(), SNMP.oid()} | expr_ast
# 定义执行函数的规格
@spec eval(expr_ast, store :: map) :: integer()
# store 是一个 map,存储 oids 和其值的映射
# 计算 number,返回它的值
def eval({:number, _, n}, _store), do: n
# 从 store 根据 oid 返回值
# 找不到就抛出异常
def eval({:oid, _, oid}, store) do
case Map.fetch(store, oid) do
{:ok, val} -> val
:error -> throw({:undefined, oid})
end
end
# 进行二元操作运算,左右两侧依据同样规则递归求值
def eval({:mult, lhs, rhs}, s), do: eval(lhs, s) * eval(rhs, s)
def eval({:divi, lhs, rhs}, s), do: div(eval(lhs, s), eval(rhs, s))
def eval({:plus, lhs, rhs}, s), do: eval(lhs, s) + eval(rhs, s)
def eval({:minus, lhs, rhs}, s), do: eval(lhs, s) - eval(rhs, s)
我的一位同事看了我的代码说“通过模式匹配来设计一个语言的解释器,简直就是投机取巧” :)
不管怎样,寥寥数行代码就实现了一个全功能的 parser 和解释器。难道这个工具不神奇吗?
第二次迭代: lt 和 gt
一切运行良好,直到我将程序迁移至一个更大的网站上。我注意到日志文件中报了一些解析错误:
error: can not parse calc metric "[1.3.5.6] - (2**32)*([1.3.5.7] < 0)"
见鬼了?我可以理解 2**32 表示2的32次方,可 x < 0 到底是什么东西?很明显,< 和 > 表达式在 Perl 语言里面会求值成 1 或 0 (在 Python 语言里面是 True 和 False),因此布尔表达式也是合法的运算,会参与到数学运算中。
好吧,我们对 lexer 和 parser 做点小修改:
Lexer:
%% power (should be defined before `*`)
\*\* : {token, {'**', TokenLine}}
%% less-than and greater-than
\< : {token, {'<', TokenLine}}.
\> : {token, {'>', TokenLine}}.
Parser:
Nonterminals expr term factor val.
Rootsymbol comp_expr.
comp_expr -> expr '<' comp_expr : {lt, '$1', '$3'}.
comp_expr -> expr '>' comp_expr : {gt, '$1', '$3'}.
comp_expr -> expr : '$1'.
%% factor now has a sub-component to maintain the precedence
factor -> val '**' factor : {pow, '$1', '$3'}.
factor -> val : '$1'.
val -> '(' comp_expr ')' : '$2'.
val -> oid : '$1'.
val -> number : '$1'.
对解释器的修改也直接了当 - 增加了三个函数子句,和一个辅助函数。
def eval({:pow, lhs, rhs}, store) do
round(:math.pow(eval(lhs, store), eval(rhs, store)))
end
def eval({:lt, lhs, rhs}, store) do
bool2int(eval(lhs, store) < eval(rhs, store))
end
def eval({:gt, lhs, rhs}, store) do
bool2int(eval(lhs, store) > eval(rhs, store))
end
defp bool2int(true), do: 1
defp bool2int(false), do: 0
第三次迭代: 函数调用
历史再次重演,程序运行良好,直到我将他发布到一个更大的生产环境。我又发现了一些解析错误,相当的摸不着头脑。
error: can not parse: [1.3.6.5]+unpack("I",pack("i",[1.3.6.6]),
illegal characters: ['u']
活见鬼了,问题是真要实现一个 Perl 语言的 pack 和 unpack 函数,我是不是能得图领奖了,至少也要拿个计算机科学学位,顺带能登上“每日工作狂”杂志。
幸运的是,我们只遇到了对 "I" 和 "i" 进行转化这一种情况,因此我们只需要实现这一种功能(不需要实现全功能的 pack 和 unpack),相对来说不算复杂
我撸起袖子加油干(真的撸袖子了哈),在 lexer 中添加如下代码:
Definitions.
%% a symbol which can be a part of a *sensible* function name in Perl
W = [a-zA-Z0-9_]
Rules.
%% identifiers
{W}+ : {token, {ident, TokenLine, list_to_existing_atom(TokenChars)}}.
%% double-quoted strings (do not allow escaped double quotes!)
\"[^\"]*\" : {token, {str, TokenLine, strip_quotes(TokenChars)}}.
\, : {token, {',', TokenLine}}.
Erlang code.
strip_quotes(Str) ->
S = tl(lists:droplast(Str)),
list_to_binary(S).
parser 也做了相应修改:
%% extended alphabet to include function calls and strings
Nonterminals expr term factor val comp funcall funargs.
Terminals oid number ident str '+' '-' '*' '/' '(' ')' '**' '<' '>' ','.
funargs -> comp_expr : ['$1'].
funargs -> funargs ',' comp_expr : ['$3' | '$1'].
%% a function call with no arguments
funcall -> ident '(' ')': {funcall, '$1', []}.
%% we have to reverse the arg list here
funcall ->
ident '(' funargs ')': {funcall, '$1', lists:reverse('$3')}.
%% another case for 'val'
val -> funcall : '$1'.
值得注意的是这里的 funargs ,它将会递归构建一个函数参数列表,其中每个参数可以是任意表达式。因为对 funargs 采用左递归进行生产,因此得到的列表是前后反转的。
解释器也需要进一步增强,因为 store 现在可能包含一个预编译的 identifiers,随后需要解析成 OID 值。这里为求值函数增加两种子句:
# string expressions evaluate to the string itself
def eval({:str, _, s}, _) when is_binary(s), do: s
# function calls
def eval({:funcall, {:ident, _, fun}, args}, store) do
# find the identifier in the store
case Map.fetch(store, fun) do
{:ok, impl_fun} ->
evaled_args = Enum.map(args, fn arg -> eval(arg, store) end)
# this calls a function defined by the atom in `impl_fun`
# in the running process which is potentially dangerous, so `impl_fun`s
# should be very rigorous in validating their arguments
Kernel.apply(__MODULE__, impl_fun, evaled_args)
:error ->
throw({:undefined_fun, fun})
end
end
总结?
我只想完成对所有可能情况的处理,并不想编写一个全功能的 Perl 解释器 :)。
然而这也说明了这些工具(leex、Yecc)的价值。每一次迭代只花了我数小时,没有这些工具的话,我估计数天都不止。