你的第一个 OCaml 程序

要完成本教程,您需要已经安装了 OCaml。此外,我们建议您配置您的编辑器

我们将使用包含 OCaml 源代码的文件,并将其编译以生成可执行二进制文件。但是,这不是关于 OCaml 编译、项目模块化或依赖项管理的详细教程;它只提供了对这些主题的概览。目的是勾勒出更宏观的画面,以避免陷入细节之中。换句话说,我们采用广度优先学习而不是深度优先学习。

在之前的教程中,大多数命令都是在 UTop 中输入的。在本教程中,大多数命令应该在终端中输入。以美元符号 $ 开头的代码示例旨在在终端中输入,而以井号 # 开头的代码示例旨在在 UTop 中输入。

完成本教程后,您应该能够使用 Dune(OCaml 的构建系统)创建、编译和执行 OCaml 项目。您将能够处理文件,在模块内进行私有定义,并了解如何安装和使用 opam 包。

注意:说明本教程的文件可作为Git 仓库获得。

在 opam 切换中工作

安装 OCaml 时,会自动创建一个全局 opam 切换。可以在此全局 opam 切换内完成本教程。

当您同时处理多个 OCaml 项目时,您应该创建更多 opam 切换。有关如何执行此操作的说明,请参阅opam 切换介绍

编译 OCaml 程序

默认情况下,OCaml 带有两个编译器:一个将源代码转换为本地二进制文件,另一个将源代码转换为字节码格式。OCaml 还带有一个用于该字节码格式的解释器。本教程演示如何使用本地编译器编写 OCaml 程序。

我们首先使用 Dune 设置一个传统的“Hello, World!”项目。确保已安装 3.12 或更高版本。以下操作创建一个名为 hello 的项目

$ opam exec -- dune init proj hello
Success: initialized project component named hello

注意 1:如果您在当前终端会话开始时运行了 eval $(opam env),或者在运行 opam init 时回答了是,则可以省略 dune 命令开头的 opam exec --

注意 2:在本教程中,由于安装的 Dune 版本不同,Dune 生成的输出可能略有不同。本教程显示了 Dune 3.12 的输出。如果您想获取 Dune 的最新版本,请在终端中运行 opam update; opam upgrade dune

该项目存储在名为 hello 的目录中。tree 命令列出创建的文件和目录。如果您没有看到以下内容,则可能需要安装 tree。例如,通过 Homebrew,运行 brew install tree

注意 3:如果您在 Apple 硅芯片 macOS 上的 Homebrew 中遇到了此错误,则可能是从 Intel 到 ARM 的架构切换问题。请参阅ARM64 修复以解决 ARM64 错误。

注意 4:与 Unix 中包含编译二进制文件的目录不同,libbin 目录分别包含库和程序的源代码文件。这是许多 OCaml 项目中使用的约定,也是 Dune 创建的预设项目。所有构建工件以及源代码的副本都存储在 _build 目录中。您不应编辑 _build 目录中的任何内容。

$ cd hello
$ tree
.
├── bin
│   ├── dune
│   └── main.ml
├── _build
│   └── log
├── dune-project
├── hello.opam
├── lib
│   └── dune
└── test
    ├── dune
    └── hello.ml

4 directories, 8 files

OCaml 源文件具有 .ml 扩展名,代表“元语言”。元语言 (ML) 是 OCaml 的祖先。这也是“OCaml”中的“ml”所代表的含义。以下是 bin/main.ml 文件的内容

let () = print_endline "Hello, World!"

项目范围的元数据在 dune-project 文件中可用。它包含有关项目名称、依赖项和全局设置的信息。

每个包含需要构建的源文件的目录都必须包含一个 dune 文件来描述如何构建。

这将构建项目

$ opam exec -- dune build

这将启动它创建的可执行文件

$ opam exec -- dune exec hello
Hello, World!

让我们看看当我们直接编辑 bin/main.ml 文件时会发生什么。在您的编辑器中打开它,并将单词 World 替换为您的名字。像以前一样使用 dune build 重新编译项目,然后使用 dune exec hello 再次启动它。

瞧!您刚刚编写了您的第一个 OCaml 程序。

在本教程的其余部分,我们将对该项目进行更多更改,以说明 OCaml 的工具。

监视模式

在深入研究之前,请注意,您通常希望使用 Dune 的监视模式来持续编译并可选地重新启动您的程序。这确保了语言服务器拥有项目最新的数据,因此您的编辑器支持将是最佳的。要使用监视模式,只需添加 -w 标志

$ opam exec -- dune build -w
$ opam exec -- dune exec hello -w

为什么没有 main 函数?

尽管 bin/main.ml 的名称表明它包含应用程序进入项目的入口点,但它不包含专用的 main 函数,并且没有要求项目必须包含具有该名称的文件才能生成可执行文件。编译后的 OCaml 文件的行为就像该文件逐行输入到顶层环境一样。换句话说,可执行 OCaml 文件的入口点是它的第一行。

在源文件中不需要双分号,就像在顶层环境中一样。语句只是按照从上到下的顺序处理,每个语句都会触发它可能具有的副作用。定义将添加到环境中。来自无名表达式的值将被忽略。所有这些的副作用将按相同的顺序发生。这就是 OCaml 的 main。

但是,通常的做法是挑选一个触发所有副作用的值,并将其标记为预期的主要入口点。在 OCaml 中,这就是 let () = 的作用,它评估右侧的表达式(包括所有副作用),而无需创建名称。

模块和标准库,续

让我们总结一下OCaml 导览中关于模块的内容。

  • 模块是命名值的集合。
  • 来自不同模块的相同名称不会发生冲突。

  • 标准库是由多个模块组成的集合。

模块有助于组织项目;可以将关注点分离到独立的模块中。这将在下一节中概述。在创建我们自己的模块之前,我们将演示如何使用标准库模块中的定义。将文件bin/main.ml的内容更改为以下内容

let () = Printf.printf "%s\n" "Hello, World!"

这将函数print_endline替换为标准库中Printf模块的函数printf。构建和执行此修改后的版本应该会产生与之前相同的输出。使用dune exec hello自己尝试一下。

每个文件都定义一个模块

每个OCaml文件编译后都会定义一个模块。这就是OCaml中单独编译的工作方式。每个足够独立的关注点都应隔离到一个模块中。对外部模块的引用会创建依赖关系。模块之间的循环依赖关系是不允许的。

要创建一个模块,让我们创建一个名为lib/en.ml的新文件,其中包含以下内容

let v = "Hello, world!"

以下是bin/main.ml文件的更新版本

let () = Printf.printf "%s\n" Hello.En.v

现在执行生成的项目

$ opam exec -- dune exec hello
Hello, world!

文件lib/en.ml创建了一个名为En的模块,该模块又定义了一个名为v的字符串值。Dune将En包装到另一个名为Hello的模块中;此名称由文件lib/dune中的name hello语句定义。字符串定义是来自bin/main.ml文件的Hello.En.v

Dune可以启动UTop以交互方式访问项目公开的模块。方法如下

$ opam exec -- dune utop

然后,在utop顶层,可以检查我们的Hello.En模块

# #show Hello.En;;
module Hello : sig val v : string end

现在使用Ctrl-D退出utop或在进入下一节之前输入#quit;;

注意:如果在lib目录中添加了一个名为hello.ml的文件,Dune将认为这是整个Hello模块,并且它将使En不可访问。如果希望模块En可见,则需要在hello.ml文件中添加以下内容

module En = En

定义模块接口

UTop的#show命令显示一个API(在软件库意义上):模块提供的定义列表。在OCaml中,这称为模块接口.ml文件定义了一个模块。类似地,.mli文件定义了一个模块接口。与模块文件对应的模块接口文件必须具有相同的基名称,例如,en.mli是模块en.ml的模块接口。创建一个包含以下内容的lib/en.mli文件

val v : string

观察到,在接口文件lib/en.mli中只写了模块签名声明的列表(即#show输出中sigend之间的内容)。这将在专门介绍模块的教程中详细解释。

模块接口也用于创建私有定义。如果模块定义未在其对应的模块接口中列出,则该定义为私有。如果不存在模块接口文件,则所有内容都为公共的。

在您喜欢的编辑器中修改lib/en.ml文件;用以下内容替换其中的内容

let hello = "Hello"
let v = hello ^ ", world!"

还要像这样编辑bin/main.ml文件

let () = Printf.printf "%s\n" Hello.En.hello

尝试编译此文件将失败。

$ opam exec -- dune build
File "hello/bin/main.ml", line 1, characters 30-43:
1 | let () = Printf.printf "%s\n" Hello.En.hello
                                  ^^^^^^^^^^^^^^
Error: Unbound value Hello.En.hello

这是因为我们没有更改lib/en.mli。由于它没有列出hello,因此它是私有的。

在一个库中定义多个模块

可以在单个库中定义多个模块。为了演示这一点,创建一个名为lib/es.ml的新文件,内容如下

let v = "¡Hola, mundo!"

并在bin/main.ml中使用新模块

let () = Printf.printf "%s\n" Hello.Es.v
let () = Printf.printf "%s\n" Hello.En.v

最后,运行dune builddune exec hello以查看新的输出,使用您在hello库中创建的模块。

$ opam exec -- dune exec hello
¡Hola, mundo!
Hello, world!

可以在模块中找到关于模块的更详细的介绍。

安装和使用来自包的模块

OCaml拥有活跃的开源贡献者社区。大多数项目都可以使用opam包管理器获得,您在安装OCaml教程中安装了它。下一节将向您展示如何从opam的开源存储库安装和使用包。

为了说明这一点,让我们更新我们的hello项目以解析包含S表达式的字符串并使用Sexplib打印回字符串。首先,通过运行opam update更新opam的包列表。然后,使用以下命令安装Sexplib

$ opam install sexplib

接下来,在bin/main.ml中定义一个包含有效S表达式的字符串。使用Sexplib.Sexp.of_string函数将其解析为S表达式,然后使用Sexplib.Sexp.to_string将其转换回字符串并打印它。

(* Read in Sexp from string *)
let exp1 = Sexplib.Sexp.of_string "(This (is an) (s expression))"

(* Do something with the Sexp ... *)

(* Convert back to a string to print *)
let () = Printf.printf "%s\n" (Sexplib.Sexp.to_string exp1)

您输入的表示有效S表达式的字符串被解析为S表达式类型,该类型定义为Atom(字符串)或S表达式的List(它是一种递归类型)。有关更多信息,请参阅Sexplib文档

在示例构建并运行之前,您需要告诉Dune它需要Sexplib来编译项目。通过将Sexplib添加到bin/dune文件的library语句中来实现这一点。然后,完整的bin/dune文件应与以下内容匹配。

(executable
 (public_name hello)
 (name main)
 (libraries hello sexplib))

有趣的事实:Dune配置文件是S表达式。

最后,像以前一样执行

$ opam exec -- dune exec hello
(This(is an)(s expression))

使用预处理器生成代码

注意:此示例已在使用DkML 2.1.0的Windows上成功测试。运行dkml version以查看版本。

让我们假设我们希望hello显示其输出,就像它是UTop中的字符串列表一样:["hello"; "using"; "an"; "opam"; "library"]。为此,我们需要一个将string list转换为string的函数,添加括号、空格和逗号。与其自己定义,不如使用包自动生成它。我们将使用ppx_deriving。以下是安装方法

$ opam install ppx_deriving

Dune需要被告知如何使用它,这在lib/dune文件中完成。请注意,这与您之前编辑的bin/dune文件不同!打开lib/dune文件,并将其编辑为如下所示

(library
 (name hello)
 (preprocess (pps ppx_deriving.show)))

(preprocess (pps ppx_deriving.show))行表示在编译之前,需要使用包ppx_deriving提供的预处理器show转换源代码。不需要编写(libraries ppx_deriving),Dune会从preprocess语句中推断出来。

文件lib/en.mllib/en.mli也需要编辑

lib/en.mli

val string_of_string_list : string list -> string
val v : string list

lib/en.ml

let string_of_string_list = [%show: string list]

let v = String.split_on_char ' ' "Hello using an opam library"

让我们从下往上阅读

  • v的类型为string list。我们使用String.split_on_char通过在空格字符处分割字符串将string转换为string list
  • string_of_string_list的类型为string list -> string。这将字符串列表转换为字符串,应用预期的格式。

最后,您还需要编辑bin/main.ml

let () = print_endline Hello.En.(string_of_string_list v)

以下是结果

$ opam exec -- dune exec hello
["Hello"; "using"; "an"; "opam"; "library"]

Dune 作为一站式商店的快速了解

本节解释了dune init proj创建的文件和目录的目的,这些文件和目录之前没有提到。

在OCaml的历史中,使用了多个构建系统。在撰写本教程时(2023年夏季),Dune是主流构建系统,因此本教程中使用了它。Dune会自动从文件中提取模块之间的依赖关系,并以兼容的顺序编译它们。它只需要每个包含要构建内容的目录中一个dune文件。dune init proj创建的三个目录具有以下用途

  • bin:可执行程序
  • lib:库
  • test:测试

将会有一个专门介绍Dune的教程。本教程将介绍Dune的许多功能,其中一些功能列在此处

  • 运行测试
  • 生成文档
  • 生成打包元数据(此处在hello.opam中)
  • 使用通用规则创建任意文件

_build目录是Dune存储所有生成文件的目录。可以随时删除它,但后续构建将重新创建它。

最小设置

在本节中,让我们创建一个最小的项目,突出显示Dune工作真正需要的内容。我们首先创建一个新的项目目录

$ cd ..
$ mkdir minimo
$ cd minimo

至少,Dune只需要两个文件:dune-project和一个dune文件。以下是如何用尽可能少的文本编写它们

dune-project

(lang dune 3.6)

dune

(executable (name minimo))

minimo.ml

let () = print_endline "My name is Minimo"

就是这样!这足以让Dune构建和执行minimo.ml文件。

$ opam exec -- dune exec ./minimo.exe
My name is Minimo

注意minimo.exe不是文件名。这是告诉Dune使用OCaml的原生编译器而不是字节码编译器编译minimo.ml文件的方式。有趣的是,请注意空文件是有效的OCaml语法。您可以使用它来进一步简化minimo;当然,它不会显示任何内容,但它将是一个有效的项目!

结论

本教程是“入门”系列的最后一个。接下来,您有足够的知识来选择其他教程,以遵循自己的学习路径。

帮助改进我们的文档

所有OCaml文档都是开源的。看到错误或不清楚的地方?提交一个拉取请求。

OCaml

创新。社区。安全。