为 Erlang 编写模板编译器

Erlang 是一个先进的分布式软件平台,非常适合网络和多核计算。我相信它几乎已经为 Web 编程的“黄金时间”做好了准备,但至少有一个大问题阻碍了 Erlang 平台:Erlang语言不适合 Web 程序员面临的许多问题。例如:

  • Erlang 很难重构
  • 哈希映射没有内置语法
  • 字符串操作很难

我将讨论一种规避这些问题的方法,即实现另一种语言以在 Erlang 平台上运行。如果您有野心,您可能会实现一种功能齐全的编程语言,但在这里我将讨论为简单的模板语言编写编译器。

了解你的目标

Erlang 平台是一个虚拟机。Erlang 代码被编译成称为 BEAM 的二进制代码(字节代码),但它在此过程中采用了一些中间形式。作为编译器编写者,您首先需要选择您的目标平台,它可以是以下之一:

  • Erlang – 最高级别,程序员写什么
  • Core Erlang – 较低级别,但程序员可读
  • 中间代码– 甚至更低级别,您或我都无法阅读
  • BEAM – 字节码

要针对 #2、3 或 4,您需要了解有关 Erlang 内部结构的更多信息。我对 Erlang 的内部了解不多,所以我将针对 #1,即 Erlang 语言。也就是说,我的编译器只是将模板文件转换为 Erlang 代码,然后调用标准的 Erlang 编译器。所以实际上,我们在作弊,但最终我们仍在生产编译模块,没有人知道我们作弊了。

编译器#0:语言

首先你需要选择一种语言来编译。如果有语言规范,它会有所帮助。我正在实现 Django 模板语言。没有真正的规范,但 Python 中有一个官方实现,所以每当我不确定某些事情时,我可以查阅它。

本教程的其余部分以我和 Roberto Saccon 的ErlyDTL项目为例。

编译器#1:扫描器

实现语言的第一步是编写一个扫描器。扫描器读入一个文件并决定什么是令牌。标记可以是字符串、括号、标识符、语言关键字或类似的东西。扫描器只返回一个标记的平面列表,其中包含有关每个标记所在行的一些信息。使用 Erlang,您可以使用名为 Leex 的程序来帮助您编写扫描器,或者您可以使用普通的 Erlang 编写扫描器。当我写扫描器的时候我不知道Leex,所以我们只是用老式的方式来做。

这是 ErlyDTL 的扫描器模块 ( erlydtl_scanner.erl ) 中的重要内容。首先是无聊的东西:

-module(erlydtl_scanner).
-export([scan/1]).

scan/1将接收代表我们正在编译的源代码的列表。

scan(Template) ->
    scan(Template, [], {1, 1}, in_text).

如您所见,它只是调用scan/4,其参数是:

  1. 剩余待扫描字符列表
  2. 到目前为止我们已扫描的令牌列表
  3. 当前位置{行,列}
  4. 当前状态

状态只是我们传递的一个原子,它告诉我们是否处于扫描过程中,例如我们是否在等待一个字符串关闭。这就像您的计算理论课程中的有限状态机中的状态。实际上,我们的扫描器将是一台有限状态机。每个函数子句都将定义一个转换规则。这是一个这样的规则:

scan("{{" ++ T, Scanned, {Row, Column}, in_text) ->
    scan(T, [{open_var, {Row, Column}, "{{"} | Scanned], {Row, Column+2}, {in_code, "}}"});

这意味着“如果我们处于 in_text 状态并看到{{,那么:

  1. 推入{{令牌列表
  2. 将列位置增加两个字符
  3. 让我们进入状态 in_code 等着看}}
  4. 扫描输入的其余部分”

很酷,我们如何在两行 Erlang 中完成所有四件事。因为它是 Erlang 是非常好的扫描语言。以下是处理 和 之间的代码注释的一些扫描{#规则#}:

scan("{#" ++ T, Scanned, {Row, Column}, in_text) ->
    scan(T, Scanned, {Row, Column + 2}, {in_comment, "#}"});

scan([_ | T], Scanned, {Row, Column}, {in_comment, Closer}) ->
scan(T, Scanned, {Row, Column + 1}, {in_comment, Closer});

scan("#}" ++ T, Scanned, {Row, Column}, {in_comment, "#}"}) ->
scan(T, Scanned, {Row, Column + 2}, in_text);

这里还有一些仅用于处理模板文件的非代码部分的扫描规则。

scan("
" ++ T, Scanned, {Row, Column}, in_text) ->
    scan(T, append_text_char(Scanned, {Row, Column}, $
), {Row + 1, 1}, in_text);

scan([H | T], Scanned, {Row, Column}, in_text) ->
scan(T, append_text_char(Scanned, {Row, Column}, H), {Row, Column + 1}, in_text);

他们只是增加行和列的位置。注意使用append_text_char. 当前字符应该附加到前一个标记上,因此我们定义了一个函数来为我们跳过:

append_text_char(Scanned, {Row, Column}, Char) ->
    case length(Scanned) of
        0 ->
            [{text, {Row, Column}, [Char]}];
        _ ->
            [Token | Scanned1] = Scanned,
            case element(1, Token) of
                text ->
                    [{text, element(2, Token), [Char | element(3, Token)]} | Scanned1];
                _ ->
                    [{text, element(2, Token), [Char]} | Scanned]
            end
    end.

以下规则处理在 Django 模板语言中具有特殊含义的字符:

scan("," ++ T, Scanned, {Row, Column}, {_, Closer}) ->
    scan(T, [{comma, {Row, Column}, ","} | Scanned], {Row, Column + 1}, {in_code, Closer});

scan("|" ++ T, Scanned, {Row, Column}, {_, Closer}) ->
scan(T, [{pipe, {Row, Column}, "|"} | Scanned], {Row, Column + 1}, {in_code, Closer});

scan("=" ++ T, Scanned, {Row, Column}, {_, Closer}) ->
scan(T, [{equal, {Row, Column}, "="} | Scanned], {Row, Column + 1}, {in_code, Closer});

scan(":" ++ T, Scanned, {Row, Column}, {_, Closer}) ->
scan(T, [{colon, {Row, Column}, ":"} | Scanned], {Row, Column + 1}, {in_code, Closer});

scan("." ++ T, Scanned, {Row, Column}, {_, Closer}) ->
scan(T, [{dot, {Row, Column}, "."} | Scanned], {Row, Column + 1}, {in_code, Closer});

有点冗长,但无论如何。这应该为您提供规则的主要思想。最后,当我们到达输入的末尾时,我们需要反转我们的标记列表并标记我们遇到的任何语言关键字。

scan([], Scanned, _, in_text) ->
    {ok, lists:reverse(lists:map(
                fun
                    ({identifier, Pos, String}) ->
                        RevString = lists:reverse(String),
                        Keywords = [ "if", "else", "endif", "not" ], %many others too
                        Type = case lists:member(RevString, Keywords) of
                            true ->
                                list_to_atom(RevString ++ "_keyword");
                            _ ->
                                identifier
                        end,
                        {Type, Pos, RevString};
                    ({Type, Pos, String}) ->
                        {Type, Pos, lists:reverse(String)}
                end, Scanned))};

我们完成了。扫描器返回{ok, Tokens}。是时候将这些令牌发送到解析器了。

编译器 #2:解析器

解析器获取令牌列表并将它们排列成称为解析树的树结构。你可能在你的第一个 CS 课上做了类似的事情,你取“1+1”并制作一棵树,其中“+”作为父级,“1”和“1”作为子级。这里的想法相同。

我们以名为Yecc的解析器生成器的形式提供了一些帮助。Yecc 与 yacc 类似,但适用于 Erlang。我们将告诉它相邻的令牌是如何相互关联的,剩下的交给 Yecc。

我们的 Yecc 文件如下所示。它包含三个部分:非终结符列表、终结符列表和规则列表。非终端是您可以扩展为终端和非终端列表的东西。终端无法扩展。使用规则,我们将所有非终端扩展为终端。

这是我们的 Django 模板语言 Yecc 文件 ( erlydtl_parser.yrl ) 的子集,足以处理“if”语句。

Nonterminals
    Elements
    Literal
    Value
    Variable
    IfBlock
    IfBraced
    IfExpression
    ElseBraced
    EndIfBraced.

Terminals
close_tag
endif_keyword
if_keyword
else_keyword
dot
open_tag
not_keyword
text.

Rootsymbol
Elements.

这是规则。格式为 [要扩展的东西] -> [扩展的组件] : [将组件重新组合成列表或树的 Erlang 表达式]。在最高级别,我们只是将事物放入一个列表中:

Elements -> '$empty' : [].
Elements -> Elements text : '$1' ++ ['$2'].
Elements -> Elements IfBlock : '$1' ++ ['$2'].

嗯,这很有趣。一个IfBlock呢?

IfBlock -> IfBraced Elements ElseBraced Elements EndIfBraced : {ifelse, '$1', '$2', '$4'}.
IfBlock -> IfBraced Elements EndIfBraced : {'if', '$1', '$2'}.

使用元组,我们构建了一棵树。所有这些非终端扩展到什么?

IfBraced -> open_tag if_keyword IfExpression close_tag : '$3'.
IfExpression -> not_keyword IfExpression : {'not', '$2'}.
IfExpression -> Value : '$1'.

最后得到一些终端。for 的规则Value有点复杂,这里就不赘述了。

正如您在下面看到的,冒号后面的部分是严格可选的。在这种情况下,解析树中不会添加任何内容。

ElseBraced -> open_tag else_keyword close_tag.
EndIfBraced -> open_tag endif_keyword close_tag.

我们确实需要将 Yecc 文件编译成 Erlang 代码。Yecc 是内置在 erlc 中的,所以你可以运行erlc -o src/ src/myfile.yrl它,它应该会输出一个 erl 文件。

ErlyDTL 的 Yecc 文件总共有大约 200 行,与扫描器的长度大致相同。400 行代码,我们完成了 2/3 的编译器!好样的。

编译器 #3:编译器

现在我们有一个解析树。我们要做的就是把它变成 Erlang 代码。实际上,我们将借助 Erlang 模块将其转换为抽象语法树erl_syntax(AST) 。然后我们可以调用一个特殊的函数将 AST 编译成字节码。构建一个 AST 而不是 Erlang ASCII 代码使我们的代码感觉更干净,更容易调试,尽管有时它看起来很冗长。

erl_syntax有一个对应于 Erlang 中每个句法结构的方法。如果你想构建一个原子,你调用erl_syntax:atom/1. 如果要构建函数调用,请调用erl_syntax:application/3. 等等。查看erl_syntax手册页了解详细信息。

编译器代码可以在erlydtl_beam_compiler.erl中找到。

编译器最难看的部分是为已编译模块的骨架制作 AST。我们需要一个 AST 用于-module定义、-export语句和每个编译模板将具有的“渲染”函数。在 ErlyDTL 中,这是在一个名为 erlydtl_beam_compiler:forms/5 ( source ) 的函数中完成的,这可能会让您头疼。您可以根据自己的时间阅读。它的关键部分是对 的调用erl_syntax:revert,它将 AST 转换为称为“表单”的表示,然后您将其直接发送到compile:forms/2.

ErlyDTL 编译器最漂亮的部分也是最精妙的部分。它是一个名为body_ast/3( source ) 的函数,它查看解析树上的每个节点并将其转换为适当的 Erlang AST 节点,然后递归地对每个节点的子节点执行相同的操作。当我们向下解析树时,我们需要传递一些上下文,特别是当前范围内的所有变量。我们可能会在向下解析树时拾取一些范围,并在我们返回时将它们丢弃。

让我们看一下body_ast的片段。它所做的只是获取解析树的顶层并决定如何处理每个父节点。这是处理“if”语句的部分。

body_ast(DjangoParseTree, Context) ->
 lists:map(
        fun
%...
            ({'if', Variable, Contents}) ->
                ifelse_ast(Variable, body_ast(Contents, Context), empty_ast(), Context);
            ({'if', {'not', Variable}, Contents}) ->
                ifelse_ast(Variable, empty_ast(), body_ast(Contents, Context), Context);
            ({'ifelse', Variable, IfContents, ElseContents}) ->
                ifelse_ast(Variable, body_ast(IfContents, Context), 
                    body_ast(ElseContents, Context), Context);
%...
  end, DjangoParseTree).

那个代码是不是就像五月的清风一样?好吧,也许不是,但这样的代码是生活中简单的乐趣之一。这是递归下降编译器的基本结构。该函数处理每个节点并将子节点传递回body_ast. 对于此处的“if”语句,它调用一个函数,该函数ifelse_ast构建一个 AST 以测试是否Variable评估为真并运行适当的代码块。如果没有“else”块,那么它会传入一个空的 AST,这样如果变量为假,就不会运行任何代码。这是ifelse_ast:

ifelse_ast(Variable, IfContentsAst, ElseContentsAst, Context) ->
    % first resolve the variable to the right scope
    {Ast, VarName} = resolve_ifvariable_ast(Variable, Context),
    % now write an if statement
    erl_syntax:case_expr(erl_syntax:application(
        erl_syntax:atom(erlydtl_runtime), erl_syntax:atom(is_false), [Ast]),
            [erl_syntax:clause([erl_syntax:atom(true)], none,
                    [ElseContentsAst]),
                erl_syntax:clause([erl_syntax:underscore()], none,
                    [IfContentsAst])
        ]).

现在好了,你应该对 ErlyDTL 编译器有了基本的了解。编译器部分重约 750 行。我们用一些编译模块所依赖的运行时代码作弊,所以我们的全功能模板编译器大约有 2000 行代码。不过是你可以在一个下午就能完成编码的东西,也不是一项艰巨的任务。

结束语

Erlang 几乎可以肯定是未来的服务器平台,但它需要一些创造性的想法才能让 Web 编程更愉快。实现模板语言是一个开始,但为什么不为模型和控制器也提供新的语言呢?他们说 Rails 之所以成为可能,是因为 Ruby 强大的元编程特性。如果有一个与 Erlang 等效的 Rails,这将是可能的,因为为 Erlang 平台编写编译器很容易。

Related Posts

2021 年你需要知道的关于 Erlang 的一切

今天,我们将看一个相当古老且有些古怪的东西。 你们大多数人可能没有注意到的语言。 虽然 Erlang 不像某些现代编程语言那样流行,但它安静地运行着 WhatsApp 和微信等每天为大量用户提供服务的应用程序。 在这篇文章中,我将告诉你关于这门语言的更多事情、它的历史,以及你是否应该考虑自己学习它。 ## 什么是 Erlang,它在哪里使用? Erl

Read More

Erlang JIT中基于类型的优化

这篇文章探讨了 Erlang/OTP 25 中基于类型的新优化,其中编译器将类型信息嵌入到 BEAM 文件中,以帮助JIT(即时编译器)生成更好的代码。 ## 两全其美 OTP 22 中引入的基于SSA的编译器处理步骤进行了复杂的类型分析,允许进行更多优化和更好的生成代码。然而,Erlang 编译器可以做什么样的优化是有限制的,因为 BEAM 文件必须

Read More

Erlang JIT之路

自从Erlang 存在,就一直有让它更快的需求和野心。这篇博文是一堂历史课,概述了主要的 Erlang 实现以及如何尝试提高 Erlang 的性能。 ## Prolog 解释器 Erlang 的第一个版本是在 1986 年在 Prolog 中实现的。那个版本的 Erlang 对于创建真正的应用程序来说太慢了,但它对于找出Erlang语言的哪些功能有用,哪

Read More