Real World OCaml

编译器前端:解析和类型检查

这是《真实世界 OCaml》一书中“编译器前端:解析和类型检查”一章的改编,经许可在此转载。

将源代码编译成可执行程序涉及一组相当复杂的库、链接器和汇编器。虽然 Dune 大致上隐藏了这种复杂性,但了解这些部分如何工作仍然很有用,这样你就可以调试性能问题,或者为现有工具处理不好的不寻常情况找到解决方案。

OCaml 非常重视静态类型安全,并尽早拒绝不符合其要求的源代码。编译器通过对源代码进行一系列检查和转换来做到这一点。每个阶段执行其工作(例如,类型检查、优化或代码生成)并丢弃来自上一阶段的一些信息。最终的原生代码输出是低级汇编代码,它对编译器开始使用的 OCaml 模块或对象一无所知。

在本章中,我们将涵盖以下主题

  • 编译器代码库和编译管道的概述,以及每个阶段的含义
  • 解析,从原始文本到抽象语法树
  • PPX,进一步转换 AST
  • 类型检查,包括模块解析

编译过程其余部分的细节,这些细节将一直持续到可执行代码,将在“编译器后端:字节码和原生代码”中介绍。

工具链概述

OCaml 工具接受文本源代码作为输入,使用文件名扩展名 .ml.mli 分别表示模块和签名。我们假设你已经构建了一些 OCaml 程序。

每个源文件表示一个编译单元,它被单独构建。编译器生成具有不同文件名扩展名的中间文件,以便在编译阶段推进时使用。链接器接受一系列编译单元,并生成一个独立的可执行文件或库存档,这些存档可以被其他应用程序重用。

整个编译管道如下所示

请注意,管道在最后分支。OCaml 有多个编译器后端,它们重用早期阶段的编译,但生成截然不同的最终输出。字节码可以由可移植的解释器运行,甚至可以转换为 JavaScript(通过 js_of_ocaml)或 C 源代码(通过 OCamlCC)。原生代码编译器生成适合高性能应用程序的专用可执行二进制文件。

获取编译器源代码

虽然不需要理解这些示例,但在阅读本章时,你可能需要检查出 OCaml 源代码树的副本。源代码可在 Git 存储库中获得,其中包含所有历史记录和开发分支,可在线浏览 GitHub

源代码树被分成子目录。核心编译器包括

asmcomp/:原生代码编译器,将 OCaml 转换为高性能原生代码可执行文件。

bytecomp/:字节码编译器,将 OCaml 转换为解释的可执行格式。

driver/:编译器工具的命令行界面。

file_formats/:编译器驱动程序使用的磁盘上文件的序列化器和反序列化器。

lambda/:lambda 转换过程。

middle_end/:clambda、闭包和 flambda 过程。

parsing/:OCaml 词法分析器、解析器和用于操作它们的库。

runtime/:带有垃圾回收器的运行时库。

typing/:静态类型检查实现和类型定义。

许多工具和脚本也与核心编译器一起构建

debugger/:交互式字节码调试器。

toplevel/ : 交互式顶层控制台。

stdlib/ : 编译器标准库,包括 Pervasives 模块。

otherlibs/ : 可选库,例如 Unix 和图形模块。

tools/ : 与编译器一起安装的命令行工具,例如 ocamldep

testsuite/ : 核心编译器的回归测试。

我们现在将介绍每个编译阶段,并解释它们在日常 OCaml 开发中如何对您有所帮助。

解析源代码

当源文件传递给 OCaml 编译器时,它的第一个任务是将文本解析成更结构化的抽象语法树 (AST)。解析逻辑是用 OCaml 本身实现的,使用的是 使用 Ocamllex 和 Menhir 解析 中描述的技术。词法分析器和解析器规则可以在源代码发行版的 parsing 目录中找到。

语法错误

OCaml 解析器的目标是向编译的下一阶段输出一个格式良好的 AST 数据结构,因此它会对任何不符合基本语法要求的源代码失败。在这种情况下,编译器会发出一个语法错误,并指向文件名以及尽可能接近错误的行号和字符号。

以下是一个语法错误示例,我们通过将模块赋值作为语句而不是 let 绑定来获取它

let () =
  module MyString = String;
  ()

这段代码在编译时会导致语法错误

$ ocamlc -c broken_module.ml
File "broken_module.ml", line 2, characters 2-8:
2 |   module MyString = String;
      ^^^^^^
Error: Syntax error
[2]

这段源代码的正确版本通过局部打开正确创建了 MyString 模块,并成功编译

let () =
  let module MyString = String in
  ()

语法错误指向无法解析的第一个标记的行号和字符号。在错误示例中,module 关键字在解析的该点不是有效的标记,因此错误位置信息是正确的。

从接口生成文档

空格和源代码注释在解析过程中被删除,在确定程序语义方面并不重要。但是,OCaml 分发版中的其他工具可以出于自己的目的解释注释。

OCaml 在源代码中使用特殊格式的注释来生成文档包。这些注释与函数定义和签名结合在一起,并以各种格式输出为结构化文档。诸如 odococamldoc 等工具可以生成 HTML 页面、LaTeX 和 PDF 文档、UNIX 手册页,甚至可以通过 Graphviz 查看的模块依赖关系图。

以下是一些使用文档字符串注释进行注释的源代码示例

(** The first special comment of the file is the comment associated
   with the whole module. *)

(** Comment for exception My_exception. *)
exception My_exception of (int -> int) * int

(** Comment for type [weather]  *)
type weather =
  | Rain of int (** The comment for constructor Rain *)
  | Sun         (** The comment for constructor Sun *)

(** Find the current weather for a country
    @author Anil Madhavapeddy
    @param location The country to get the weather for.
*)
let what_is_the_weather_in location =
  match location with
  | `Cambridge  -> Rain 100
  | `New_york   -> Rain 20
  | `California -> Sun

文档字符串以双星号开头。注释内容的格式约定用于标记元数据。例如,@tag 字段标记了特定属性,例如该代码部分的作者。

有两个主要工具用于操作文档字符串注释:与编译器一起提供的 ocamldoc 工具,以及在编译器之外开发的 odoc 工具,但旨在成为长期替代品。尝试通过在源文件上运行 ocamldoc 来编译 HTML 文档和 UNIX 手册页

$ mkdir -p html man/man3
$ ocamldoc -html -d html doc.ml
$ ocamldoc -man -d man/man3 doc.ml
$ man -M man Doc

您现在应该在 html/ 目录中拥有 HTML 文件,并且还可以查看保存在 man/man3 中的 UNIX 手册页。有很多注释格式和选项可以控制各种后端的输出。有关完整列表,请参阅 OCaml 手册

您也可以使用 odoc 通过与 dune 集成来生成项目的完整快照,如 "生成文档" 中所述。

使用 ppx 预处理

OCaml 中一个强大的功能是通过扩展点扩展标准语言的功能。这些代表 OCaml 语法树中的占位符,除了被分隔并与普通解析的源代码一起存储在抽象语法树中外,标准编译器工具会忽略它们。它们旨在由外部工具扩展,这些工具可以选择可以解释它们的扩展节点。外部工具可以选择通过转换输入语法树来生成更多 OCaml 代码,从而构成语言可扩展预处理器的基础。

OCaml 中有两种主要的扩展点形式:属性扩展节点。让我们首先浏览一些它们的示例,然后看看如何在您自己的代码中使用它们。

扩展属性

属性提供附加信息,这些信息附加到 OCaml 语法树中的节点,并随后由外部工具解释和扩展。

属性的基本形式是 [@ ... ] 语法。@ 符号的数量定义了属性绑定到语法树的哪个部分

  • 单个 [@ 使用后缀表示法绑定到代数类别,例如表达式或类型定义中的单个构造函数。
  • [@@ 绑定到代码块,例如模块定义、类型声明或类字段。
  • [@@@ 作为模块实现或签名中的独立条目出现,并且不绑定到任何特定的源代码节点。

OCaml 编译器具有一些有用的内置属性,我们可以使用它们来说明它们的使用,而无需任何外部工具。让我们首先看看使用独立属性 @@@warning 来切换 OCaml 编译器警告。

# module Abc = struct

  [@@@warning "+non-unit-statement"]
  let a = Sys.get_argv (); ()

  [@@@warning "-non-unit-statement"]
  let b = Sys.get_argv (); ()
  end;;
Line 4, characters 11-26:
Warning 10 [non-unit-statement]: this expression should have type unit.
module Abc : sig val a : unit val b : unit end

我们示例中的警告取自 编译器手册页。如果序列中的表达式类型不是 unit,则此警告会发出消息。模块实现中的 @@@warning 节点会导致编译器仅在该结构范围内更改其行为。

注释也可以更窄地附加到代码块。例如,可以使用 @@deprecated 对模块实现进行注释,以指示它不应在新的代码中使用

# module Planets = struct
    let earth = true
    let pluto = true
  end [@@deprecated "Sorry, Pluto is no longer a planet. Use the Planets2016 module instead."];;
module Planets : sig val earth : bool val pluto : bool end
# module Planets2016 = struct
    let earth = true
    let pluto = false
  end;;
module Planets2016 : sig val earth : bool val pluto : bool end

在这个示例中,@@deprecated 注释仅附加到 Planets 模块,并且人类可读的参数字符串将开发人员重定向到更新的代码。现在,如果我们尝试使用被标记为已弃用的值,编译器将发出警告。

# let is_pluto_a_planet = Planets.pluto;;
Line 1, characters 25-38:
Alert deprecated: module Planets
Sorry, Pluto is no longer a planet. Use the Planets2016 module instead.
val is_pluto_a_planet : bool = true
# let is_pluto_a_planet = Planets2016.pluto;;
val is_pluto_a_planet : bool = false

最后,属性也可以附加到单个表达式。在下一个示例中,@warn_on_literal_pattern 属性指示类型构造函数的参数不应使用常量文字进行模式匹配。

# type program_result =
  | Error of string [@warn_on_literal_pattern]
  | Exit_code of int;;
type program_result = Error of string | Exit_code of int
# let exit_with = function
  | Error "It blew up" -> 1
  | Exit_code code -> code
  | Error _ -> 100;;
Line 2, characters 11-23:
Warning 52 [fragile-literal-pattern]: Code should not depend on the actual values of
this constructor's arguments. They are only for information
and may change in future versions. (See manual section 11.5)
val exit_with : program_result -> int = <fun>

常用的扩展属性

我们已经在 使用 S 表达式进行数据序列化 中使用扩展点来生成用于处理 S 表达式的样板代码。这些是由第三方库使用 dune 文件中的 (preprocess) 指令引入的,例如

(library
 (name hello_world)
 (libraries core)
 (preprocess (pps ppx_jane))

这使您可以利用语法增强社区。核心 OCaml 编译器中还有一些内置属性。有些是面向性能的,并向编译器发出指令,而另一些则会激活使用警告。完整的列表可以在 OCaml 手册的 属性部分 中找到。

扩展节点

虽然扩展点对于注释现有源代码很有用,但我们还需要一种机制来在 OCaml AST 中存储通用占位符,以便进行代码生成。OCaml 通过扩展节点语法提供了此功能。

扩展节点的一般语法是 [%id expr],其中 id 是特定扩展节点重写器的标识符,而 expr 是重写器要解析的有效负载。当有效负载是相同类型的语法时,也提供一个中缀形式。例如,let%foo bar = 1 等同于 [%foo let bar = 1]

我们之前已经看到扩展节点通过 Core 语法扩展在本书中使用,它们充当错误处理(let%bind)、命令行解析(let%map)或内联测试(let%expect_test)的语法糖。扩展节点通过 dune 规则以与扩展属性相同的方式引入,通过 (preprocess) 属性。

静态类型检查

在获得有效的抽象语法树后,编译器必须验证代码是否遵守 OCaml 类型系统的规则。语法正确但误用值的代码会被拒绝,并给出问题的解释。

虽然类型检查在 OCaml 中以单次遍历完成,但它实际上包含三个同时发生的步骤

自动类型推断:一种无需手动类型注释即可计算模块类型的算法

模块系统:将软件组件与它们类型签名的显式知识结合在一起

显式子类型化:检查对象和多态变体

自动类型推断使您能够为特定任务编写简洁的代码,并让编译器确保您对变量的使用在本地一致。

类型推断无法扩展到依赖于文件单独编译的非常大的代码库。一个模块中的微小变化可能会影响数千个其他文件和库,并需要重新编译所有这些文件。模块系统通过提供将模块的显式类型签名组合和操作的功能以及通过函子和头等模块重用它们的功能来解决此问题。

OCaml 对象中的子类型化始终是显式操作(通过 :> 运算符)。这意味着它不会使核心类型推断引擎复杂化,并且可以作为单独的问题进行测试。

显示编译器推断的类型

我们已经看到如何直接从顶层探索类型推断。还可以通过要求编译器为您完成工作来生成整个文件的类型签名。创建一个包含单个类型定义和值的代码文件

type t = Foo | Bar
let v = Foo

现在使用 -i 标志运行编译器来推断该文件的类型签名。这会运行类型检查器,但在显示到标准输出的接口后不会进一步编译代码

$ ocamlc -i typedef.ml
type t = Foo | Bar
val v : t

输出是代表输入文件的模块的默认签名。将此输出重定向到mli文件通常很有用,这样就可以得到一个初始签名来编辑外部接口,而不必手动键入所有内容。

编译器将接口的编译版本存储为cmi文件。此接口是从编译模块的mli签名文件获得的,或者是在只有ml实现存在的情况下从推断类型获得的。

编译器确保您的mlmli文件具有兼容的签名。如果情况并非如此,类型检查器会立即抛出错误。例如,如果您的ml文件是这样的

type t = Foo

\noindent 以及您的mli文件是这样的

type t = Bar

\noindent 那么,当您尝试构建时,会收到以下错误

$ ocamlc -c conflicting_interface.mli conflicting_interface.ml
File "conflicting_interface.ml", line 1:
Error: The implementation conflicting_interface.ml
       does not match the interface conflicting_interface.cmi:
       Type declarations do not match:
         type t = Foo
       is not included in
         type t = Bar
       Constructors have different names, Foo and Bar.
       File "conflicting_interface.mli", line 1, characters 0-12:
         Expected declaration
       File "conflicting_interface.ml", line 1, characters 0-12:
         Actual declaration
[2]

哪个先来:ml 还是 mli?

关于以何种顺序编写 OCaml 代码,存在两种不同的观点。从一个ml文件开始编写代码,然后使用类型推断作为构建函数的指南,这非常容易。然后,可以像描述的那样生成mli文件,并对导出的函数进行文档记录。

如果您正在编写跨多个文件的代码,有时从编写所有mli签名开始,并检查它们是否彼此类型检查,会更容易。一旦签名到位,就可以编写实现,并确信它们会正确地组合在一起,并且模块之间不会出现循环依赖关系。

与任何此类风格辩论一样,您应该尝试哪种系统最适合您。不过,每个人都同意一件事:无论以何种顺序编写,生产代码都应该为项目中的每个ml文件显式定义一个mli文件。如果您只声明签名(例如模块类型),那么没有对应ml文件的mli文件也是完全可以的。

签名文件提供了一个编写简洁文档并抽象化不应导出的内部细节的地方。维护单独的签名文件还可以加快大型代码库中的增量编译速度,因为重新编译mli签名比将实现完全编译为本地代码要快得多。

类型推断

类型推断是根据表达式的用法确定表达式适当类型的过程。这是一个部分存在于许多其他语言(如 Haskell 和 Scala)中的功能,但 OCaml 将其作为核心语言中的基本功能嵌入。

OCaml 类型推断基于 Hindley-Milner 算法,该算法以其能够在不需要任何显式类型注解的情况下推断出表达式最一般类型的能力而著称。该算法可以推断出表达式的多种类型,并具有主要类型的概念,它是从可能的推断中得出的最一般选择。手动类型注解可以显式地专门化类型,但自动推断会选择最一般的类型,除非另有说明。

OCaml 确实有一些语言扩展超出了主要类型推断的限制,但总的来说,您编写的多数程序都不会需要注解(尽管它们有时有助于编译器生成更好的错误消息)。

添加类型注解以查找错误

人们常说,编写 OCaml 代码最难的部分是通过类型检查器,但一旦代码编译成功,它就能在第一次运行时正常工作!当然,这是一种夸张的说法,但在从动态类型语言转换过来时,确实会有这种感觉。OCaml 静态类型系统通过在编译时拒绝您的程序,而不是在运行时生成错误,来保护您免受某些类型的错误(如内存错误和抽象违反)的侵害。学习如何使用类型检查器的编译时反馈是构建能够充分利用这些静态检查的健壮库和应用程序的关键。

有一些技巧可以使您更容易快速定位代码中的类型错误。第一个技巧是引入手动类型注解,以更准确地缩小错误的来源。这些注解实际上不应更改您的类型,并且可以在您的代码正确后删除。但是,它们充当定位错误的锚点,以便您在编写代码时使用。

如果您使用大量多态变体或对象,手动类型注解特别有用。使用行多态性的类型推断可能会生成一些非常大的签名,并且错误的传播范围比使用更显式类型的变体或类更广。

例如,考虑以下错误示例,它表达了对整数的一些简单的代数运算

let rec algebra =
  function
  | `Add (x,y) -> (algebra x) + (algebra y)
  | `Sub (x,y) -> (algebra x) - (algebra y)
  | `Mul (x,y) -> (algebra x) * (algebra y)
  | `Num x     -> x

let _ =
  algebra (
    `Add (
      (`Num 0),
      (`Sub (
          (`Num 1),
          (`Mul (
              (`Nu 3),(`Num 2)
            ))
        ))
    ))

代码中有一个字符拼写错误,它使用了Nu而不是Num。由此产生的类型错误令人印象深刻

$ ocamlc -c broken_poly.ml
File "broken_poly.ml", lines 9-18, characters 10-6:
 9 | ..........(
10 |     `Add (
11 |       (`Num 0),
12 |       (`Sub (
13 |           (`Num 1),
14 |           (`Mul (
15 |               (`Nu 3),(`Num 2)
16 |             ))
17 |         ))
18 |     ))
Error: This expression has type
         [> `Add of
              ([< `Add of 'a * 'a
                | `Mul of 'a * 'a
                | `Num of int
                | `Sub of 'a * 'a
                > `Num ]
               as 'a) *
              [> `Sub of 'a * [> `Mul of [> `Nu of int ] * [> `Num of int ] ]
              ] ]
       but an expression was expected of type
         [< `Add of 'a * 'a | `Mul of 'a * 'a | `Num of int | `Sub of 'a * 'a
          > `Num ]
         as 'a
       The second variant type does not allow tag(s) `Nu
[2]

类型错误是完全准确的,但相当冗长,并且行号没有指向不正确变体名称的确切位置。编译器能做的最好的事情就是将您指向algebra函数应用的大致方向。

这是因为类型检查器没有足够的信息来将algebra定义的推断类型与几行后的应用匹配。它分别计算两个表达式的类型,并且当它们不匹配时,会尽其所能输出差异。

让我们看看使用显式类型注解来帮助编译器的结果

type t = [
  | `Add of t * t
  | `Sub of t * t
  | `Mul of t * t
  | `Num of int
]

let rec algebra (x:t) =
  match x with
  | `Add (x,y) -> (algebra x) + (algebra y)
  | `Sub (x,y) -> (algebra x) - (algebra y)
  | `Mul (x,y) -> (algebra x) * (algebra y)
  | `Num x     -> x

let _ =
  algebra (
    `Add (
      (`Num 0),
      (`Sub (
          (`Num 1),
          (`Mul (
              (`Nu 3),(`Num 2)
            ))
        ))
    ))

这段代码与之前的代码包含完全相同的错误,但我们添加了多态变体的封闭类型定义,以及对algebra定义的类型注解。我们得到的编译器错误现在更有用

$ ocamlc -i broken_poly_with_annot.ml
File "broken_poly_with_annot.ml", line 22, characters 14-21:
22 |               (`Nu 3),(`Num 2)
                   ^^^^^^^
Error: This expression has type [> `Nu of int ]
       but an expression was expected of type t
       The second variant type does not allow tag(s) `Nu
[2]

此错误直接指向包含拼写错误的正确行号。修复问题后,如果您更喜欢更简洁的代码,可以删除手动注解。当然,您也可以保留这些注解,以帮助将来进行重构和调试。

强制主要类型

编译器还有一种更严格的主要类型检查模式,该模式通过-principal标志激活。这会警告关于类型信息使用风险,以确保类型推断有一个主要结果。如果类型推断的成功或失败取决于子表达式的类型化顺序,则该类型被认为是有风险的。

主要性检查只影响一些语言特性

  • 对象的泛型方法

  • 将函数中标记参数的顺序从其类型定义中置换

  • 丢弃可选的标记参数

  • 从 OCaml 4.0 开始出现的一般化代数数据类型 (GADT)

  • 记录字段和构造函数名称的自动消除歧义(从 OCaml 4.1 开始)

以下是在与记录消除歧义一起使用时出现主要性警告的示例。

type s = { foo: int; bar: unit }
type t = { foo: int }

let f x =
  x.bar;
  x.foo

使用-principal推断签名会显示一个新的警告

$ ocamlc -i -principal non_principal.ml
File "non_principal.ml", line 6, characters 4-7:
6 |   x.foo
        ^^^
Warning 18 [not-principal]: this type-based field disambiguation is not principal.
type s = { foo : int; bar : unit; }
type t = { foo : int; }
val f : s -> int

此示例不是主要的,因为x.foo的推断类型受x.bar的推断类型引导,而主要类型化要求每个子表达式的类型都可以独立计算。如果从f的定义中删除了x.bar的使用,它的参数将是t类型,而不是type s类型。

您可以通过置换类型声明的顺序,或者通过添加显式类型注解来解决此问题

type s = { foo: int; bar: unit }
type t = { foo: int }

let f (x:s) =
  x.bar;
  x.foo

现在,推断的类型没有歧义,因为我们显式地为参数指定了类型,并且子表达式的推断顺序不再重要。

$ ocamlc -i -principal principal.ml
type s = { foo : int; bar : unit; }
type t = { foo : int; }
val f : s -> int

dune 等效项是在构建描述中添加-principal标志。

(executable
  (name principal)
  (flags :standard -principal)
  (modules principal))

(executable
  (name non_principal)
  (flags :standard -principal)
  (modules non_principal))

:standard 指令将包含所有默认标志,然后-principal将在编译器构建标志之后追加。

$ opam exec -- dune build principal.exe
$ opam exec -- dune build non_principal.exe
File "non_principal.ml", line 6, characters 4-7:
6 |   x.foo
        ^^^
Error (warning 18 [not-principal]): this type-based field disambiguation is not principal.
[1]

理想情况下,所有代码都应该系统地使用-principal。它减少了类型推断的差异,并强制执行了单一已知类型的概念。但是,此模式有一些缺点:类型推断速度较慢,并且cmi文件会变大。这通常只会在您大量使用对象时才会出现问题,因为对象通常具有更大的类型签名来涵盖其所有方法。

如果在主要模式下编译成功,则保证程序在非主要模式下也会通过类型检查。请记住,在主要模式下生成的cmi文件与默认模式不同。尝试确保您使用它激活了整个项目的编译。混淆文件不会让您违反类型安全,但偶尔会导致类型检查器意外失败。在这种情况下,只需使用干净的源代码树重新编译即可。

模块和独立编译

OCaml 模块系统使较小的组件能够在大型项目中有效地重复使用,同时仍保留静态类型安全的全部优点。我们之前在文件模块和程序中介绍了使用模块的基本知识。在这些签名上运行的模块语言还扩展到函子,以及一等模块,分别在函子一等模块中进行了描述。

本节将更详细地讨论编译器如何实现它们。模块对于由多个源文件(也称为编译单元)组成的较大项目至关重要。在更改一两个文件时重新编译每个源文件是不切实际的,而模块系统最大限度地减少了这种重新编译,同时仍然鼓励代码重复使用。

文件和模块之间的映射

单个编译单元提供了一种便捷的方式,可以将大型模块层次结构分解为文件集合。文件和模块之间的关系可以用模块系统来直接解释。

创建一个名为alice.ml的文件,内容如下

let friends = [ Bob.name ]

以及一个对应的签名文件

val friends : Bob.t list

这两个文件产生的结果与以下代码基本相同。

module Alice : sig
  val friends : Bob.t list
end = struct
  let friends = [ Bob.name ]
end

定义模块搜索路径

在前面的示例中,Alice还引用了另一个模块Bob。为了使Alice的整体类型有效,编译器还需要检查Bob模块是否至少包含一个Bob.name值,并定义了一个Bob.t类型。

类型检查器将此类模块引用解析为具体的结构和签名,以便在模块边界上统一类型。它通过在目录列表中搜索与该模块名称匹配的编译接口文件来实现此目的。例如,它将在搜索路径上查找alice.cmibob.cmi,并将遇到的第一个文件用作AliceBob的接口。

模块搜索路径通过向编译器命令行添加-I标志来设置,其中目录包含cmi文件作为参数。当您有很多库时,手动指定这些标志会变得很复杂,这也是duneocamlfind等工具存在的原因。它们都自动将第三方包名称和构建描述转换为传递给编译器命令行的命令行标志。

默认情况下,只会搜索当前目录和 OCaml 标准库来查找 cmi 文件。标准库中的 Stdlib 模块也会在每个编译单元中默认打开。标准库位置可以通过运行 ocamlc -where 获取,也可以通过设置 CAMLLIB 环境变量来覆盖。不用说,除非你有充分的理由(例如设置交叉编译环境),否则不要覆盖默认路径。

使用 ocamlobjinfo 检查编译单元

为了使单独编译合理,我们需要确保用于类型检查模块的所有 cmi 文件在编译运行之间保持一致。如果它们发生变化,就会出现两个模块检查具有相同名称的公共模块的不同类型签名的可能性。这反过来会使程序完全违反静态类型系统,并可能导致内存损坏和崩溃。

OCaml 通过在每个 cmi 中记录 MD5 校验和来防止这种情况。让我们更仔细地检查一下我们之前的 typedef.ml

$ ocamlc -c typedef.ml
$ ocamlobjinfo typedef.cmi
File typedef.cmi
Unit name: Typedef
Interfaces imported:
    cdd43318ee9dd1b187513a4341737717    Typedef
    9b04ecdc97e5102c1d342892ef7ad9a2    Pervasives
    79ae8c0eb753af6b441fe05456c7970b    CamlinternalFormatBasics

ocamlobjinfo 检查已编译的接口,并显示它依赖的其他编译单元。在本例中,我们没有使用除 Pervasives 之外的任何外部模块。每个模块默认情况下都依赖于 Pervasives,除非您使用 -nopervasives 标志(这是一个高级用例,您通常不需要它)。

每个模块名称旁边的长字母数字标识符是从该编译单元导出的所有类型和值计算出的哈希值。它在类型检查和链接期间使用,以确保所有编译单元已针对彼此一致地编译。哈希值的差异意味着具有相同模块名称的编译单元可能在不同模块中具有冲突的类型签名。编译器将使用类似于此的错误拒绝此类程序

$ ocamlc -c foo.ml
File "foo.ml", line 1, characters 0-1:
Error: The files /home/build/bar.cmi
       and /usr/lib/ocaml/map.cmi make inconsistent assumptions
       over interface Map

此哈希检查非常保守,但确保单独编译在整个链接阶段始终保持类型安全。您的构建系统应确保您永远不会看到前面的错误消息,但如果您遇到了问题,只需清理中间文件并从头开始重新编译。

使用模块别名包装库

到目前为止描述的模块到文件映射严格地执行顶级模块和文件之间的 1:1 映射。将较大的模块拆分成单独的文件以简化编辑通常很方便,但仍将它们全部编译成单个 OCaml 模块。

Dune 通过自动生成一个顶级模块别名文件来提供一种非常方便的方法来完成此操作,该文件将给定库中的所有文件作为子模块放置在该库的顶级模块中。这被称为包装库,工作原理如下。

让我们定义一个具有两个文件 a.mlb.ml 的简单库,它们分别定义一个值。

let v = "hello"
let w = 42

dune 文件定义了一个名为 hello 的库,其中包含这两个模块。

(library
  (name hello)
  (modules a b))
(executable
  (name test)
  (libraries hello)
  (modules test))

如果我们现在构建此库,我们可以查看 dune 如何将模块组装成 Hello 库。

$ opam exec -- dune build
$ cat _build/default/hello.ml-gen
(** @canonical Hello.A *)
module A = Hello__A


(** @canonical Hello.B *)
module B = Hello__B

Dune 生成了一个 hello.ml 文件,该文件构成了库公开的顶级模块。它还将各个模块重命名为内部混淆名称,例如 Hello__A,并将这些内部模块作为别名分配给生成的 hello.ml 文件中。然后,这允许库的用户以 Hello.A 的形式访问这些值。例如,我们的测试可执行文件包含以下内容

let v = Hello.A.v
let w = Hello.B.w

这种模块别名方案的一个很好的方面是,单个顶级模块提供了一个中心位置来编写有关如何使用库公开的所有子模块的文档。我们可以手动将 hello.mlhello.mli 添加到我们的库中,这些文件正是这样做的。首先将 hello 模块添加到 dune 文件中

(library
  (name hello)
  (modules a b hello))
(executable
  (name test)
  (libraries hello)
  (modules test))

然后 hello.ml 文件包含模块别名(以及您可能要添加到顶级模块的任何其他代码)。

module A = A
module B = B

最后,hello.mli 接口文件可以引用所有子模块并包含文档字符串

(** Documentation for module A *)
module A : sig
  (** [v] is Hello *)
  val v : string
end

(** Documentation for module B *)
module B : sig
  (** [w] is 42 *)
  val w : int
end

如果您想禁用 dune 的这种行为并故意包含多个顶级模块,您可以在库 stanza 中添加 (wrapped false)。但是,由于当您有很多库依赖项时,链接冲突的可能性会增加,因此一般不建议这样做,因为链接到可执行文件的每个模块在 OCaml 中都必须具有唯一的名称。

类型错误中的较短模块路径

Core 广泛使用 OCaml 模块系统来提供完整的替换标准库。它将这些模块收集到一个名为 Std 的模块中,该模块提供一个需要打开的单个模块,以导入替换模块和函数。

这种方法有一个缺点:类型错误突然变得更加冗长。如果运行的是普通 OCaml 顶级(不是 utop),我们可以看到这一点。

$ ocaml
# List.map print_endline "";;
Error: This expression has type string but an expression was expected of type
         string list

没有 Core 的这个类型错误有一个简单的类型错误。但是,当我们切换到 Core 时,它会变得更加冗长

$ ocaml
# open Core;;
# List.map ~f:print_endline "";;
Error: This expression has type string but an expression was expected of type
         'a Core.List.t = 'a list

OCaml 中的默认 List 模块被 Core.List 覆盖。编译器尽力显示类型等价性,但代价是更冗长的错误消息。

编译器可以通过所谓的短路径启发式方法来解决这个问题。这会导致编译器搜索所有类型别名以找到最短的模块路径,并将其用作首选输出类型。可以通过向编译器传递 -short-paths 选项来激活该选项,并且在顶级上也能正常工作。

$ ocaml -short-paths
# open Core;;
# List.map ~f:print_endline "foo";;
Error: This expression has type string but an expression was expected of type
         'a list

utop 增强的顶级默认情况下会激活短路径,这就是为什么我们之前在交互式示例中不必这样做。但是,编译器不会默认使用短路径启发式方法,因为在某些情况下,类型别名信息是有用的,如果始终选择最短的模块路径,则该信息会在错误中丢失。

您需要自己选择在自己的项目中是否更喜欢短路径或默认行为,如果需要,请向编译器传递 -short-paths 标志。

类型化语法树

当类型检查过程成功完成时,它将与 AST 结合形成类型化抽象语法树。它包含输入文件中每个标记的精确位置信息,并用具体类型信息修饰每个标记。

编译器可以将其输出为已编译的 cmtcmti 文件,这些文件包含编译单元的实现和签名的类型化 AST。这可以通过向编译器传递 -bin-annot 标志来激活。

cmt 文件对于 IDE 工具来说特别有用,可以将特定位置的 OCaml 源代码与推断的或外部类型匹配起来。例如,merlinocaml-lsp-server opam 包都使用此信息在您的编辑器中提供工具提示和文档字符串,如OCaml 平台中所述。

直接检查类型化语法树

编译器有一些高级标志可以转储内部 AST 表示的原始输出。您不能依赖这些标志在不同的编译器版本中给出相同的输出,但它们是有用的学习工具。

我们再次使用我们的玩具 typedef.ml

type t = Foo | Bar
let v = Foo

首先让我们看看从解析阶段生成的未类型化语法树。

$ ocamlc -dparsetree typedef.ml 2>&1
[
  structure_item (typedef.ml[1,0+0]..[1,0+18])
    Pstr_type Rec
    [
      type_declaration "t" (typedef.ml[1,0+5]..[1,0+6]) (typedef.ml[1,0+0]..[1,0+18])
        ptype_params =
          []
        ptype_cstrs =
          []
        ptype_kind =
          Ptype_variant
            [
              (typedef.ml[1,0+9]..[1,0+12])
                "Foo" (typedef.ml[1,0+9]..[1,0+12])
                []
                None
              (typedef.ml[1,0+13]..[1,0+18])
                "Bar" (typedef.ml[1,0+15]..[1,0+18])
                []
                None
            ]
        ptype_private = Public
        ptype_manifest =
          None
    ]
  structure_item (typedef.ml[2,19+0]..[2,19+11])
    Pstr_value Nonrec
    [
      <def>
        pattern (typedef.ml[2,19+4]..[2,19+5])
          Ppat_var "v" (typedef.ml[2,19+4]..[2,19+5])
        expression (typedef.ml[2,19+8]..[2,19+11])
          Pexp_construct "Foo" (typedef.ml[2,19+8]..[2,19+11])
          None
    ]
]

对于一个简单的两行程序来说,这相当多的输出,但它表明即使从一个小源文件中,OCaml 解析器也会生成多少结构。

AST 的每个部分都用精确的位置信息(包括文件名和标记的字符位置)进行修饰。此代码尚未进行类型检查,因此所有原始标记都已包含在内。

通常以已编译的 cmt 文件形式输出的类型化 AST 可以通过 -dtypedtree 选项以更易于开发人员阅读的形式显示。

$ ocamlc -dtypedtree typedef.ml 2>&1
[
  structure_item (typedef.ml[1,0+0]..typedef.ml[1,0+18])
    Tstr_type Rec
    [
      type_declaration t/267 (typedef.ml[1,0+0]..typedef.ml[1,0+18])
        ptype_params =
          []
        ptype_cstrs =
          []
        ptype_kind =
          Ttype_variant
            [
              (typedef.ml[1,0+9]..typedef.ml[1,0+12])
                Foo/268
                []
                None
              (typedef.ml[1,0+13]..typedef.ml[1,0+18])
                Bar/269
                []
                None
            ]
        ptype_private = Public
        ptype_manifest =
          None
    ]
  structure_item (typedef.ml[2,19+0]..typedef.ml[2,19+11])
    Tstr_value Nonrec
    [
      <def>
        pattern (typedef.ml[2,19+4]..typedef.ml[2,19+5])
          Tpat_var "v/270"
        expression (typedef.ml[2,19+8]..typedef.ml[2,19+11])
          Texp_construct "Foo"
          []
    ]
]

类型化 AST 比未类型化语法树更明确。例如,类型声明被赋予了一个唯一的名称(t/1008),v 值(v/1011)也是如此。

除非您正在构建 IDE 工具或破解核心编译器本身的扩展,否则您很少需要查看编译器的这种原始输出。但是,在我们进一步深入代码生成过程之前,了解这种中间形式的存在是有用的,在编译器后端:字节码和本地代码中会详细介绍。

仍然需要帮助?

帮助改进我们的文档

鼓励您为 Real World OCaml GitHub 存储库中此页面的原始来源做出贡献。

OCaml

创新。社区。安全。