预处理器和 PPX
预处理器是在编译时调用的程序,用于在实际编译之前更改程序。它们在许多方面都非常有用,例如包含文件、条件编译、样板生成或扩展语言。
首先来看一个例子,以下源代码将如何被此预处理器更改
Printf.printf "This program has been compiled by user: %s" [%get_env "USER"]
当您使用预处理器编译代码时,它会将 [%get_env "USER"] 替换为 USER 环境变量的内容。例如,如果 USER 环境变量设置为“JohnDoe”,则该行将变为
Printf.printf "This program has been compiled by user: %s" "JohnDoe"
通过此修改,预处理器将在编译时将 [%get_env "USER"] 替换为 USER 环境变量的内容,并且此代码应该在大多数系统上无需任何额外配置即可工作。在编译时,预处理器会将 [%get_env "USER"] 替换为包含 USER 环境变量内容的字符串,该变量通常包含编译程序的人员的用户名。这发生在编译时,因此在运行时,USER 变量的值将不起作用,因为它仅在已编译的程序中用于信息目的。
在本指南中,我们尽可能少地使用先决条件来解释 OCaml 中预处理器的不同机制。如果您只对如何在项目中使用 PPX 感兴趣,请跳转到此部分或dune 文档。如果您有兴趣编写 PPX,请跳转到此部分。
OCaml 中的预处理
某些语言内置支持预处理,这意味着语言的一小部分专门用于在编译时执行。例如,C 就是这种情况,其中 C 预处理器的语法和语义是语言的一部分;Rust 也有自己的宏系统。
在 OCaml 中,没有语言本身的宏系统,所有预处理器都是独立的程序。但是,即使它不是语言的一部分,OCaml 平台也正式支持一个用于编写此类预处理器的库。
OCaml 支持执行两种预处理器:一种在源代码级别工作(如 C),另一种在程序的更结构化表示上工作(如 Rust 的宏):AST 级别。后者称为“PPX”,是预处理器扩展(Pre-Processor eXtension)的首字母缩写。
虽然这两种类型的预处理都有其用例,但在 OCaml 中,建议尽可能使用 PPX,原因如下
本指南介绍了 OCaml 中两种预处理器的状态,但重点介绍了 PPX。尽管后者是编写预处理器的推荐方法,但我们从源代码预处理开始,以便更好地理解为什么 PPX 在 OCaml 中是必要的。
源代码预处理器
如引言中所述,预处理源文件对于可以通过字符串操作解决的问题很有用,例如文件包含、条件编译或宏扩展。任何预处理器都可以使用,例如C预处理器或通用预处理器,例如m4
。但是,一些预处理器,例如cppo
,专门为了与OCaml良好集成而开发。
在OCaml中,文本文件的预处理没有语言的特定支持。相反,构建系统负责驱动预处理。因此,应用预处理器将归结为告诉Dune如何进行。仅出于教育目的,在下一节中,我们将展示如何使用OCaml的编译器预处理文件,更相关的部分是使用Dune进行预处理。
ocamlc
和ocamlopt
进行预处理
使用OCaml的编译器ocamlc
和ocamlopt
提供了-pp
选项,可以在编译阶段预处理文件(但请记住,建议使用Dune来驱动预处理)。考虑以下简单的预处理器,它将字符串"World"
替换为字符串"Universe"
,这里以shell脚本的形式给出
$ cat preprocessor.sh
#!/bin/sh
sed 's/World/Universe/g' $1
使用此选项编译经典的“Hello, World!”程序将改变编译过程
$ cat hello.ml
print_endline "Hello, World!";;
$ ocamlopt -pp ./preprocessor.sh hello.ml
$ ./a.out
Hello, Universe!
使用Dune进行预处理
Dune的构建系统有一个特定的段落用于将预处理应用于文件。完整的文档可以在这里找到这里,并应作为参考。在本节中,我们只给出一些示例。
用于将预处理应用于源文件的段落是(preprocess (action (<action>)))
。<action>
部分可以是任何用户定义的动作,例如对外部程序的调用,如(system <program>)
指定的那样。
综合起来,以下dune
文件将使用我们之前编写的preprocessor.sh
重写相应的模块文件
namepreprocessactionrun
操作文本文件的局限性
编程语言语法的复杂性使得以与语法相关的方式操作文本变得非常困难。例如,假设与前面的示例类似,您希望将所有出现的“World”重写为“Universe”,但仅在程序的OCaml字符串内部。这非常复杂,需要很好地了解OCaml语法才能做到,因为字符串有几个分隔符(例如{| ...|}
)和换行符,或者注释可能会妨碍…
再举一个例子。假设您定义了一个类型,并且希望在编译时从这个特定类型生成一个序列化器到其在json
格式中的编码,例如Yojson
(有关必须在编译时生成的原因,请参阅这里)。此序列化代码可以由预处理器编写,它将在文件中查找类型并根据类型结构对其进行不同的序列化;也就是说,它是变体类型、记录类型还是其子类型的结构…
所有这些困难都源于我们想要生成一个程序,但我们却在操作其作为纯文本的扁平表示。这种表示缺乏结构存在几个缺点
- 很难读取程序的部分内容,例如上面示例中用于生成序列化器的类型。
- 将程序编写为纯文本容易出错,因为无法保证生成的代码始终符合编程语言的语法。代码生成中的此类错误可能难以调试!
使用更结构化的程序表示可以解决读取和写入问题。这正是PPX的作用!
PPX
PPX是一种不同类型的预处理器——它不是在文本源代码上运行,而是在解析结果上运行:抽象语法树(AST),在OCaml编译器中称为Parsetree。为了很好地理解PPX,我们需要了解这个Parsetree是什么。
OCaml的Parsetree:OCaml AST
在编译阶段,OCaml的编译器会将输入文件解析成其内部表示,称为Parsetree。程序表示为一棵树,具有一个复杂的OCaml类型,您可以在Parsetree
模块中找到。
让我们看一下这棵树的一些属性
- AST中的每个节点都有一个对应于不同角色的类型,例如“let定义”、“表达式”、“模式”等。
- 树的根是一个
structure_item
列表。 - 一个
structure_item
可以表示一个顶层表达式、一个类型定义、一个let定义等。这是使用变体类型确定的。
有几种互补的方式可以掌握Parsetree类型。一种是阅读API文档,其中包含每个类型和值代表什么的示例。另一种是检查精心制作的OCaml代码的Parsetree值。这可以通过使用外部工具astexplorer、我们的OCaml VSCode扩展(在命令面板中打开OCaml: Open AST explorer
)甚至直接使用带-dparsetree
选项的OCaml toplevel来实现(在UTop中也可用)。
$ ocaml -dparsetree
[Omitted output]
# let x = 1 + 2 ;;
Ptop_def
[
structure_item (//toplevel//[1,0+0]..[1,0+13])
Pstr_value Nonrec
[
<def>
pattern (//toplevel//[1,0+4]..[1,0+5])
Ppat_var "x" (//toplevel//[1,0+4]..[1,0+5])
expression (//toplevel//[1,0+8]..[1,0+13])
Pexp_apply
expression (//toplevel//[1,0+10]..[1,0+11])
Pexp_ident "+" (//toplevel//[1,0+10]..[1,0+11])
[
<arg>
Nolabel
expression (//toplevel//[1,0+8]..[1,0+9])
Pexp_constant PConst_int (1,None)
<arg>
Nolabel
expression (//toplevel//[1,0+12]..[1,0+13])
Pexp_constant PConst_int (2,None)
]
]
]
val x : int = 3
请注意,Parsetree是代码的内部表示,它发生在对程序进行类型检查之前,因此可以重写类型错误的程序。类型检查后的内部表示称为Typedtree
。您可以使用ocaml
的-dtypedtree
选项检查它。在使用utop-full
时,您可以通过运行Clflags.dump_typedtree := true
在REPL中启用-dtypedtree
。在下文中,我们将使用AST来指代parsetree。
PPX重写器
从本质上讲,PPX重写器只是一个转换,它接收一个Parsetree并返回一个可能已修改的Parsetree,但有一些细微差别。首先,PPX在Parsetree上工作,Parsetree是OCaml解析的结果,因此源文件需要具有有效的OCaml语法。因此,我们不能引入自定义语法,例如C预处理器中的#if
。相反,我们将使用OCaml 4.02中引入的两种特殊语法:扩展节点和属性。
其次,大多数PPX的代码转换不需要给出完整的AST;它们可以在其中的子部分局部工作。有两种针对通用PPX重写器的此类局部限制,涵盖了大多数用例:扩展器和派生器。它们分别对应于刚刚提到的OCaml 4.02的两种新语法,扩展节点和属性。
属性和派生器
属性是可以附加到任何AST节点的附加信息。该信息可以由编译器本身使用(例如,启用或禁用警告)、添加“已弃用”警告或强制/检查函数是否内联。编译器使用的属性完整列表可在手册中找到。
属性的语法是在节点后缀[@attribute_name payload]
,其中payload
本身是一个OCaml AST。@
的数量决定了属性附加到哪个节点:@
用于最接近的节点(表达式、模式等),@@
用于最接近的块(类型声明、类字段等),@@@
是浮动属性。有关语法的更多信息,请参阅手册。
module X = struct
[@@@warning "+9"] (* locally enable warning 9 in this structure *)
…
end
[@@deprecated "Please use module 'Y' instead."]
前面的示例使用了编译器保留的属性,但任何属性名称都可以在源代码中使用,并由PPX用于其预处理。
type int_pair = (int * int) [@@deriving yojson]
与属性相关联的一种特定类型的PPX是:派生器,一种将从结构或签名项(例如类型定义)生成(或派生)一些代码的PPX。它是使用上述语法应用的,其中多个派生器用逗号分隔。请注意,生成的代码添加到输入代码之后,输入代码保持不变。
派生器非常适合生成依赖于定义类型结构的函数(这是在操作文本文件的局限性中给出的示例)。实际上,恰好将正确数量的信息传递给PPX,并且我们还知道PPX不会修改源代码的任何部分。
派生器的示例有
ppx_show
从类型生成此类型值的漂亮打印器。- 从OCaml类型派生到其他格式的序列化器的派生器,例如使用
ppx_yojson_conv
的JSON、使用ppx_deriving_yaml
的YAML或使用ppx_sexp_conv
的SEXP。 ppx_accessor
为给定记录类型的字段生成访问器。
扩展节点和扩展器
扩展节点是Parsetree中的“空洞”。解析器在许多地方都接受它们,例如模式、表达式、核心类型或模块类型。要确定某个特定位置是否允许扩展节点,您可以查看Parsetree以查看相应的节点是否具有extension
构造函数。但是,扩展节点随后会被编译器拒绝。因此,它们必须由PPX重写才能继续编译。
扩展节点的语法是[%extension_name payload]
,其中,%
的数量再次确定扩展节点的类型:%
用于“节点内部”,例如表达式和模式,%%
用于“顶层节点”,例如结构/签名项或类字段。有效负载是结构节点;也就是说,解析器接受与扩展节点的有效负载相同的.ml
文件内容。请参阅正式语法。
(* An extension node as an expression *)
let v = [%html "<a href='ocaml.org'>OCaml!</a>"]
(* An extension node as a let-binding *)
[%%html let v = "<a href='ocaml.org'>OCaml!</a>"]
当扩展节点和有效负载类型相同时,可以使用更短的 infix 语法。该语法要求将扩展节点的名称附加到定义块的关键字(例如let
、begin
、module
、val
等),并且等效于将整个块包装在有效负载中。可以在OCaml 手册中找到语法的正式定义。
(* An equivalent syntax for [%%html let v = ...] *)
let%html v = "<a href='ocaml.org'>OCaml!</a>"
请注意,有一种方法可以更改有效负载的预期类型。通过在扩展名称后添加一个:
,预期有效负载现在是一个签名节点(即,与.mli
文件中接受的内容相同)。类似地,?
会将预期有效负载变成模式节点。
(* Two equivalent syntaxes, with signatures as payload *)
[%ext_name: val foo : unit]
val%ext_name foo : unit
(* An extension node with a pattern as payload *)
let [%ext_name? a :: _ ] = ()
扩展节点旨在由PPX重写,在这方面,对应于一种特定类型的PPX:扩展器。扩展器是一种PPX重写器,它将用匹配名称的所有扩展节点替换为生成的代码。它使用仅依赖于有效负载的某些生成的代码来执行此操作,而无需有关扩展节点上下文的信息(即,无需访问其余代码)并且无需修改其他任何内容。
扩展器的示例包括
- 允许用户直接用另一种语言编写表示该语言的OCaml值的扩展器。例如: -
ppx_yojson
通过编写JSON代码生成Yojson
值 -tyxml-ppx
通过编写HTML代码生成Tyxml.Html
值 -ppx_mysql
通过编写MySQL查询生成ocaml-mysql
查询 ppx_expect
从扩展节点的有效负载生成CRAM测试。
使用PPX
与文本源代码的预处理器类似,OCaml的编译器提供了-ppx
选项,以便在编译阶段运行PPX。PPX将从文件中读取Parsetree,其中已转储已封送的值,并以相同的方式输出重写的Parsetree。
但是,由于负责驱动编译的工具是 Dune,使用 PPXs 仅仅是编写 dune
文件的问题。应该使用相同的 preprocess
节,这次使用 pps
。
preprocesspps
这就是使用 PPXs 所需的一切!虽然这些说明适用于大多数 PPXs,但请注意,第一个信息来源将是包文档,因为某些 PPXs 可能需要一些特殊的处理才能与 Dune 集成。
[@@deriving_inline]
删除 PPXs 依赖
使用 有些派生器仅用于生成样板代码。在这种情况下,不需要将它们作为硬依赖项包含进来:添加的样板代码可以通过 PPX 进行漂亮打印并添加到源代码中。此机制可以使用 Dune 和 ppxlib
实现。
将 [@@deriving_inline <deriver_name>]
附加到某个项目上,将像通常的 [@@deriving <deriver_name>]
属性一样从该项目派生一些代码。但是,它不会将生成的代码追加到带属性的项目之后,而是会检查生成的代码是否已存在于带属性的项目之后。如果存在,则无需执行任何操作。否则,它将生成一个正确的文件,Dune 将为您提供使用此正确文件更新源代码的可能性(使用 dune promote
命令)。
由于新文件包含生成的代码,因此不再需要由 PPX 预处理,可以按原样编译和分发,并且可以从依赖项中删除 PPX。但是,每当附加属性的项目发生更改时,仍然需要运行 PPX。这可以通过在 @lint
目标上运行 PPX 来实现。让我们看一个例子,以及以下文件
$ cat dune
(()(()))
$ cat lib.ml
type t = int [@@deriving_inline yojson]
[@@@deriving.end]
现在,我们运行 PPX 并将生成的代码提升到原始文件中
$ opam exec -- dune build @lint
File "lib/lib.ml", line 1, characters 0-0:
diff --git a/_build/default/lib/lib.ml b/_build/default/lib/lib.ml.lint-corrected
index 4999e06..5516d41 100644
--- a/_build/default/lib/lib.ml
+++ b/_build/default/lib/lib.ml.lint-corrected
@@ -1,3 +1,8 @@
type t = int [@@deriving_inline yojson]
+let _ = fun (:) -> ()
+let t_of_yojson = (:>)
+let _ = t_of_yojson
+let yojson_of_t = (:>)
+let _ = yojson_of_t
[@@@deriving.end]
Promoting _build/default/lib/lib.ml.lint-corrected to lib/lib.ml.
该文件现在包含生成的值。虽然它仍然是开发依赖项,但可以删除 PPX 依赖项以编译项目
$ cat lib.ml
type t = int [@@deriving_inline yojson]
let _ = fun (:) -> ()
let t_of_yojson = (:>)
let _ = t_of_yojson
let yojson_of_t = (:>)
let _ = yojson_of_t
[@@@deriving.end]
请注意,虽然它允许删除项目对 ppxlib
和所用 PPX 的依赖关系,但使用 deriving_inline
有一些缺点。它可能会增加代码库的大小(和可读性),并且依赖于从 AST 到源代码的打印机,这可能不可靠。无论如何,如果内联失败,ppxlib
将通过往返检查来检测它,方法是解析生成的源代码并检查它是否与生成的 AST 相对应。这样,错误会在编译时被捕获。
为什么 PPXs 在 OCaml 中特别有用
现在我们知道了什么是 PPX 并看到了它的例子,让我们看看它为什么在 OCaml 中特别有用。
首先,类型在运行时丢失。这意味着类型结构无法在运行时被解构以控制流程。这就是为什么在编译后的二进制文件中不存在通用的 to_string : 'a -> string
或 print : 'a -> ()
函数(在 toplevel 中,类型会被保留)。
因此,任何依赖于类型结构的通用函数都 *必须* 在编译时编写,此时类型仍然可用。
其次,OCaml 的强大功能之一是其类型系统,可用于检查许多事物的正确性。一个例子是 Tyxml
库,它使用类型系统来确保生成的 HTML 值满足大多数 W3C 标准。但是,与编写 HTML 语法相比,编写 Tyxml
值可能很麻烦。在编译时将 HTML 代码转换为 OCaml 值允许用户同时保留对生成值的类型检查器和编写 HTML 代码的熟悉度。
其他重写器,例如 ppx_expect
,表明能够通过 PPX 重写器丰富语法非常有用,即使在 OCaml 的特殊性之外也是如此。
ppxlib
控制 PPX 生态系统的必要性:尽管 PPXs 非常适合在编译时生成代码,但它们也引发了一些问题,尤其是在存在多个 PPX 重写器的情况下。
- 多个 PPXs 组合的语义是什么?顺序可能很重要。
- 如何相信在使用第三方 PPXs 时我的代码的某些部分不会被修改?
- 在使用许多重写器时,如何保持编译时间短?
- 如果必须处理解析或反序列化 AST,如何轻松地编写 PPX?
- 如何处理像 Parsetree 中那样的 冗长而复杂的类型?
- 如何解决新 OCaml 版本倾向于向语言添加新功能,因此需要丰富并破坏 Parsetree 类型的问题?
许多这些问题源于 OCaml 中没有宏语言部分,并且预处理器始终是独立程序。这意味着它们可以执行任何操作(而宏通常会限制预处理的表达能力),并且编译器无法控制它们。但是,OCaml 平台包含一个用于编写 PPXs 的库,它在某种程度上充当宏语言,而不会失去 PPXs 的全部通用性:ppxlib。此库提供了编写 扩展器 和 派生器 的通用方法,确保它们能够很好地协同工作,并解决了我们之前遇到的多个任意转换的组合问题。ppxlib
还提供了一个驱动程序,即使在注册多个转换的情况下,它也会输出一个二进制文件。
使用 ppxlib
,PPX 作者可以专注于他们自己的部分:重写逻辑。然后,他们可以注册他们的转换,ppxlib
将负责所有 PPXs 必须执行的其余工作:获取 Parsetree,将其返回给编译器,并创建一个具有良好 CLI 的可执行文件。
对于那些对 OCaml 历史感兴趣的人,请注意,在 ppxlib
之前,还有其他用于处理 PPXs 的“官方”库。Camlp4
是一种用添加的构造扩展 OCaml 解析器、重写它并以常规 OCaml 语法对其进行漂亮打印的方法。OMP
是一个用于使 PPXs 在 OCaml 版本之间兼容的工具,现在已包含在 ppxlib
中。
一个 PPX 用于多个 OCaml 版本
编写 PPX 的一个微妙之处在于,当向语言添加新功能时,Parsetree 模块的类型可能会发生变化。为了使 PPX 与新版本兼容,它必须更新从旧类型到新类型的转换。但是,这样做会失去与旧 OCaml 版本的兼容性。理想情况下,单个版本的 PPX 可以预处理不同的 OCaml 版本。
ppxlib
通过将 Parsetree 类型转换为最新版本并从最新版本转换来解决此问题。然后,PPX 作者只需要维护他们对 OCaml 最新版本的转换,并获得一个可以在任何 OCaml 版本上运行的 PPX。例如,当在使用 OCaml 4.08 的编译过程中应用为 OCaml 5.0 编写的 PPX 时,ppxlib
将获取 4.08 Parsetree,将其转换为 5.00 Parsetree,使用注册的 PPX 转换 Parsetree,并将其转换回 4.08 Parsetree 以继续编译。
限制 PPXs 用于组合、速度和安全性
ppxlib
明确支持注册对应于扩展器和派生器的受限转换。编写这些受限 PPXs 具有许多优点
- 扩展器和派生器不会修改您现有的代码,除了扩展节点之外。这减少了错误,错误的影响较小,用户可以确信他们的代码中没有合理的部件会被更改。
- 由于扩展器和派生器是“上下文无关”的,因为它们仅以 AST 的有限部分作为输入运行,因此它们都可以在 AST 的单次传递中运行。此外,它们不是“一个接一个”运行,而是在同一时间运行,因此它们的组合语义不依赖于执行顺序。
- 此单次传递还意味着更快的重写,从而使使用多个 PPXs 的项目的编译时间更快。
相比之下,在整个 AST 上工作的重写器也可以在 ppxlib
中注册,并且它们将在上下文无关传递之后按名称的字母顺序运行。
请注意,Dune 将所有使用 ppxlib
编写的 PPXs 组合到一个预处理器二进制文件中,即使这些 PPXs 来自不同的包。
编写 PPX
如果要编写自己的 PPX,则从 ppxlib 的文档 开始。