Elixir 中对 Leex 和 Yecc 的应用 - 译

原文: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)的价值。每一次迭代只花了我数小时,没有这些工具的话,我估计数天都不止。

转载于:https://my.oschina.net/u/4130622/blog/3057407

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值