迷失在翻译中(探索编译器的前端)

2018年4月26日 · 作者:Björn Gustavsson

在这篇博客文章中,我们将探索构成编译器前端的编译器过程。

之前的博客文章中,我们展示了如何使用time选项显示正在执行的编译器过程的信息

$ erlc +time trivial.erl
Compiling "trivial"
 remove_file                   :      0.000 s       3.7 kB
 parse_module                  :      0.000 s       5.5 kB
 transform_module              :      0.000 s       5.5 kB
 lint_module                   :      0.002 s       5.5 kB
 expand_records                :      0.000 s       5.3 kB
     .
     .
     .

在之前的博客文章中,我们解释了remove_file过程的作用。在今天的博客文章中,我们将讨论上面输出中列出的其他过程。

这些过程构成了编译器的前端。这些过程的实现模块不在compiler应用程序中,而是在STDLIB中。原因是 Erlang shell 也使用这些模块。这意味着 shell 将在不包含 compiler 应用程序的嵌入式系统中工作。

前端过程在抽象格式上操作。抽象格式非常接近原始的 Erlang 源代码。事实上,通过漂亮地打印抽象格式,我们可以重建原始源代码,尽管不是完美的。

迷失在翻译中 #

为了了解我们在翻译中会丢失多少信息,我们将编译并漂亮地打印此模块

-module(trivial).
-export([example/4]).
-record(rec, {mod,func,result}).

%% Example to help explore the compiler front end.
example(A, B, C, D) ->
    #rec{mod=?MODULE,func=?FUNCTION_NAME,result=A + (B*C*(D+42))}.

我们使用 -P 选项来运行 parse_module 过程并生成结果列表

$ erlc -P +time trivial.erl
Compiling "trivial"
 parse_module                  :      0.000 s       5.5 kB
 transform_module              :      0.000 s       5.5 kB
 lint_module                   :      0.003 s       5.5 kB
 listing                       :      0.001 s       5.5 kB

目前,请忽略 transform_moduleerl_lint 过程。它们不会更改此模块的抽象代码。listing 过程漂亮地打印抽象格式,将其转换回 Erlang 源代码并创建文件 trivial.P

$ cat trivial.P
-file("trivial.erl", 1).

-module(trivial).

-export([example/4]).

-record(rec,{mod,func,result}).

example(A, B, C, D) ->
    #rec{mod = trivial,func = example,result = A + B * C * (D + 42)}.

trivial.P 文件与原始文件进行比较,我们可以看到在翻译中丢失了什么

  • ?MODULE?FUNCTION_NAME 宏调用已分别替换为 trivalexample。这是由预处理器完成的。

  • 注释已消失。变量和运算符周围的空格数量也有一些差异。抽象格式在其表示中不包含空格或注释。

  • 另请注意,表达式 A + (B*C*(D+42)) 中多余的一对括号已被省略。D+42 周围的括号仍然存在,因为否则表达式的值会发生变化。抽象格式没有括号的直接表示。

仔细查看 parse_module 过程 #

既然我们已经了解了翻译中会丢失什么,我们将仔细查看抽象格式。

我们将使用表达式 A+(B*C*(D+42)) 作为示例,并使用与 parse_module 过程用于完成其工作的相同模块将其转换为抽象格式。

使用 erl_scan 进行标记化 #

从 Erlang 源代码进行翻译的第一步是将字符分组为称为标记的逻辑组。这个过程称为标记化扫描,由 erl_scan 模块完成。

我们将使用 erl_scan:string/1 来标记我们的示例。(编译器将使用 erl_scan 中的其他函数,但原理相同。)

1> {ok,Tokens,_} = erl_scan:string("A + (B*C*(D+42))."), Tokens.
[{var,1,'A'},
 {'+',1},
 {'(',1},
 {var,1,'B'},
 {'*',1},
 {var,1,'C'},
 {'*',1},
 {'(',1},
 {var,1,'D'},
 {'+',1},
 {integer,1,42},
 {')',1},
 {')',1},
 {dot,1}]

输出是标记列表。每个元组中的第二个元素是行号。第一个元素是标记的类别。如果存在第三个元素,则它是该类别中的符号。

我们可以看到空格已经丢失。如果有注释,它也会丢失。

要阅读有关标记的更多详细信息,请参阅 erl_scan:string/1

预处理标记 #

在编译器中,下一步是在标记上运行预处理器。在此示例中,没有宏调用,因此无需预处理,我们将跳到下一步。

使用 erl_parse 进行解析 #

下一步是解析标记以生成抽象格式

2> {ok,Abstract} = erl_parse:parse_exprs(Tokens), Abstract.
[{op,1,'+',
     {var,1,'A'},
     {op,1,'*',
         {op,1,'*',{var,1,'B'},{var,1,'C'}},
         {op,1,'+',{var,1,'D'},{integer,1,42}}}}]

结果是一个包含一个表达式的列表。该表达式不是一个列表,而是一个解析树。它可以像这样可视化

Abstract format visualized

括号已丢失,因为树的结构使得评估顺序明确无误。

有关抽象格式的更多详细信息,请参阅 抽象格式

使用 erl_pp 进行漂亮打印 #

listing 过程使用 erl_pp 模块来漂亮地打印抽象格式以生成列表文件。

我们可以漂亮地打印示例的抽象格式

3> lists:flatten(erl_pp:exprs(Abstract)).
"A + B * C * (D + 42)"

在这里,漂亮打印机插入了一对括号,但原始表达式中多余的一对括号已丢失。空格也与原始空格不同。

快速浏览预处理器 #

如前所述,预处理器(epp 模块)在标记化之后和解析之前运行。

预处理器遍历标记,查找后跟变量或原子的问号。例如,源代码文件中的 ?MODULE 将被 erl_scan 标记化,如下所示

[{'?',1},{var,1,'MODULE'}]

假设模块名称为 trivial,预处理器会将这些标记替换为标记

[{atom,1,trivial}]

在抽象格式上操作的其他过程 #

既然已经解释了 parse_module,让我们快速浏览一下前端中的其他过程。

transform_module 过程 #

transform_module 过程运行解析转换,例如用于 QLCms_transform

lint_module 过程 #

lint_module 过程验证代码在语义上是否正确。也就是说,变量必须在使用之前绑定,函数的每个子句都必须具有相同数量的参数,依此类推。

当我们编译一个有问题的模块时,erl_lint 将打印错误消息并终止编译

$ cat bug.erl
-module(bug).
-export([main/0]).

main() ->
    A+B.
$ erlc +time bug.erl
Compiling "bug"
 remove_file                   :      0.000 s       2.1 kB
 parse_module                  :      0.000 s       2.7 kB
 transform_module              :      0.000 s       2.7 kB
 lint_module                   :      0.004 s       2.4 kB
bug.erl:5: variable 'A' is unbound
bug.erl:5: variable 'B' is unbound
$

翻译记录 #

expand_records 过程使用 erl_expand_records 来翻译记录

$ erlc -E +time trivial.erl
Compiling "trivial"
 parse_module                  :      0.000 s       5.5 kB
 transform_module              :      0.000 s       5.5 kB
 lint_module                   :      0.002 s       5.5 kB
 expand_records                :      0.000 s       5.3 kB
 listing                       :      0.001 s       5.3 kB
$ cat trivial.E
-file("trivial.erl", 1).

-module(trivial).

-export([example/4]).

-record(rec,{mod,func,result}).

example(A, B, C, D) ->
    {rec,trivial,example,A + B * C * (D + 42)}.

-E 选项生成 expand_records 过程生成的抽象格式列表。

-record() 声明仍然存在,但记录的构造已替换为元组的构造。同样,记录的匹配将转换为元组的匹配。

提示:使用 -P 生成单个源文件 #

-P 选项可用于将包含多个包含文件的源文件打包到单个独立的源文件中。

如果您想报告编译器错误,但没有时间将源代码最小化到最小示例,则拥有独立的源文件会很有用。

这是一个例子。compile.erl 文件包含两个头文件。像这样直接编译它将不起作用

$ cd lib/compiler/src
$ erlc compile.erl
compile.erl:36: can't find include file "erl_compile.hrl"
   .
   .
   .
$

我们必须给出 Kernel 和 STDLIB 的 include 目录的路径

$ erlc -I ../../kernel/include -I ../../stdlib/include compile.erl
$

要打包来自 compile.erl 的源代码以及头文件的内容,请使用 -P 选项生成 compile.P

$ erlc -P -I ../../kernel/include -I ../../stdlib/include compile.erl

compile.P 可以重命名为 compile.erl,并且可以成功编译,无需任何其他选项

$ mv compile.P $HOME/compile.erl
$ cd $HOME
$ erlc compile.erl
$

需要思考的问题 #

预处理器在标记化之后运行,在运行解析器之前运行。

那么 ?FUNCTION_NAME?FUNCTION_ARITY 宏是如何实现的呢?

这是一个简单的函数标记的示例

1> {ok,T,_} = erl_scan:string("foo({tag,X,Y}) -> ?FUNCTION_ARITY."), T.
[{atom,1,foo},
 {'(',1},
 {'{',1},
 {atom,1,tag},
 {',',1},
 {var,1,'X'},
 {',',1},
 {var,1,'Y'},
 {'}',1},
 {')',1},
 {'->',1},
 {'?',1},
 {var,1,'FUNCTION_ARITY'},
 {dot,1}]