你的第一个 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 中包含编译二进制文件的目录不同,lib
和 bin
目录分别包含库和程序的源代码文件。这是许多 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
输出中sig
和end
之间的内容)。这将在专门介绍模块的教程中详细解释。
模块接口也用于创建私有定义。如果模块定义未在其对应的模块接口中列出,则该定义为私有。如果不存在模块接口文件,则所有内容都为公共的。
在您喜欢的编辑器中修改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 build
和dune 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
(()())
使用预处理器生成代码
注意:此示例已在使用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.ml
和lib/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
;当然,它不会显示任何内容,但它将是一个有效的项目!
结论
本教程是“入门”系列的最后一个。接下来,您有足够的知识来选择其他教程,以遵循自己的学习路径。