模块
先决条件
简介
在本教程中,我们将了解如何使用和定义模块。
模块是将定义分组在一起的集合。这是组织 OCaml 软件的基本手段。应该将独立的关注点隔离到独立的模块中。
注意: 说明本教程的文件作为一个 Git 仓库 提供。
基本用法
基于文件的模块
在 OCaml 中,每段代码都包装在一个模块中。可选地,模块本身可以是另一个模块的 子模块,有点像文件系统中的目录。
以下程序使用两个文件:athens.ml
和 berlin.ml
。每个文件分别定义了一个名为 Athens
和 Berlin
的模块。
以下是文件 athens.ml
let hello () = print_endline "Hello from Athens"
以下是文件 berlin.ml
let () = Athens.hello ()
要使用 Dune 编译它们,至少需要两个配置文件
dune-project
文件包含项目范围的配置。(lang dune 3.7)
dune
文件包含实际的构建指令。一个项目可能包含多个dune
文件,每个包含要构建内容的目录都有一个。在本例中,这一行就足够了(executable (name berlin))
创建这些文件后,构建并运行它们
$ opam exec -- dune build
$ opam exec -- dune exec ./berlin.exe
Hello from Athens
注意: Dune 将构建工件和源代码副本存储在 _build
目录中,您不应该在其中编辑任何内容。OCaml 项目通常包含 bin
和 lib
目录。与 Unix 不同,它们不包含编译后的二进制文件,而是包含程序和库的源代码。
实际上,opam exec -- dune build
是可选的。运行 opam exec -- dune exec ./berlin.exe
会触发编译。请注意,在 opam exec -- dune exec
命令中,参数 ./berlin.exe
不是文件路径。此命令表示“执行文件 ./berlin.ml
的内容”。但是,可执行文件存储并命名的方式不同。
在一个项目中,最好使用 dune init project
命令来创建 dune
配置文件和目录结构。有关此问题的更多信息,请参阅 Dune 文档。
命名和作用域
在 berlin.ml
中,我们使用 Athens.hello
来引用 athens.ml
中的 hello
。一般来说,要访问模块中的内容,请使用模块的名称(总是以大写字母开头:Athens
)后面跟着一个点,然后是您要使用的内容(hello
)。它可以是值、类型构造函数或模块提供的任何内容。
如果您频繁使用模块,您可能希望 open
它。这会将模块的定义引入作用域。在我们的示例中,berlin.ml
可以写成
open Athens
let () = hello ()
使用 open
是可选的。通常,我们不会打开像 List
这样的模块,因为它提供了其他模块也提供的名称,例如 Array
或 Option
。像 Printf
这样的模块提供了不受冲突影响的名称,例如 printf
。将 open Printf
放在文件顶部可以避免重复编写 Printf.printf
。
open Printf
let data = ["a"; "beautiful"; "day"]
let () = List.iter (printf "%s\n") data
标准库是一个名为 Stdlib
的模块。它包含 子模块 List
、Option
、Either
等等。默认情况下,OCaml 编译器会打开标准库,就像您在每个文件顶部都写了 open Stdlib
一样。如果您需要选择退出,请参阅 Dune 文档。
您可以在定义内部打开模块,使用 let open ... in
结构
# let list_sum_sq m =
let open List in
init m Fun.id |> map (fun i -> i * i) |> fold_left ( + ) 0;;
val list_sum_sq : int -> int = <fun>
模块访问符号可以应用于整个表达式
# let array_sum_sq m =
Array.(init m Fun.id |> map (fun i -> i * i) |> fold_left ( + ) 0);;
val array_sum_sq : int -> int = <fun>
接口和实现
默认情况下,模块中定义的任何内容都可以在其他模块中访问。值、函数、类型或子模块,所有内容都是公开的。这可以被限制,以避免公开外部不相关的定义。
为此,我们必须区分
- 模块内部的定义(模块实现)
- 模块的公开声明(模块接口)
.ml
文件包含模块实现;.mli
文件包含模块接口。默认情况下,当没有提供相应的 .mli
文件时,实现具有一个默认接口,其中所有内容都是公开的。
将 athens.ml
文件复制到 cairo.ml
中,并更改其内容
let message = "Hello from Cairo"
let hello () = print_endline message
按现状,Cairo
具有以下接口
val message : string
val hello : unit -> unit
显式定义模块接口允许限制默认接口。它充当模块实现的遮罩。cairo.ml
文件定义了 Cairo
的实现。添加 cairo.mli
文件定义了 Cairo
的接口。没有扩展名的文件名必须相同。
要将 message
变成私有定义,请不要在 cairo.mli
文件中列出它
val hello : unit -> unit
(** [hello ()] displays a greeting message. *)
注意: 开头处的双星号表示针对 API 文档工具(例如 odoc
)的注释。养成使用此工具支持的格式记录 .mli
文件是一个好习惯。
文件 delhi.ml
定义了调用 Cairo
的程序。
let () = Cairo.hello ()
更新 dune
文件以允许此示例的编译,除了之前的编译之外。
(executables (names berlin delhi))
编译并执行这两个程序。
$ opam exec -- dune exec ./berlin.exe
Hello from Athens
$ opam exec -- dune exec ./delhi.exe
Hello from Cairo
您可以通过尝试编译包含以下内容的 delhi.ml
文件来验证 Cairo.message
是否不是公共的。
let () = print_endline Cairo.message
这将触发编译错误。
抽象类型和只读类型
函数和值的定义是公开的还是私有的。这也适用于类型定义,但还有另外两种情况。
创建名为 exeter.mli
和 exeter.ml
的文件,其内容如下所示。
接口: exeter.mli
type aleph = Ada | Alan | Alonzo
type gimel
val gimel_of_bool : bool -> gimel
val gimel_flip : gimel -> gimel
val gimel_to_string : gimel -> string
type dalet = private Dennis of int | Donald of string | Dorothy
val dalet_of : (int, string) Either.t option -> dalet
实现: exeter.ml
type aleph = Ada | Alan | Alonzo
type bet = bool
type gimel = Christos | Christine
let gimel_of_bool b = if (b : bet) then Christos else Christine
let gimel_flip = function Christos -> Christine | Christine -> Christos
let gimel_to_string x = "Christ" ^ match x with Christos -> "os" | _ -> "ine"
type dalet = Dennis of int | Donald of string | Dorothy
let dalet_of = function
| None -> Dorothy
| Some (Either.Left x) -> Dennis x
| Some (Either.Right x) -> Donald x
更新文件 dune
以包含三个目标;两个可执行文件:berlin
和 delhi
;以及一个库 exeter
。
(executables (names berlin delhi) (modules athens berlin cairo delhi))
(library (name exeter) (modules exeter))
运行 opam exec -- dune utop
命令。这将触发 Exeter
的编译,启动 utop
并加载 Exeter
。
# open Exeter;;
# #show aleph;;
type aleph = Ada | Alan | Alonzo
类型 aleph
是公开的。可以创建或访问值。
# #show bet;;
Unknown element.
类型 bet
是私有的。它在定义它的实现(这里为 Exeter
)之外不可用。
# #show gimel;;
type gimel
# Christos;;
Error: Unbound constructor Christos
# #show_val gimel_of_bool;;
val gimel_of_bool : bool -> gimel
# true |> gimel_of_bool |> gimel_to_string;;
- : string = "Christos"
# true |> gimel_of_bool |> gimel_flip |> gimel_to_string;;
- : string = "Christine"
类型 gimel
是抽象的。可以创建或操作值,但只能作为函数结果或参数。只有提供的函数 gimel_of_bool
、gimel_flip
和 gimel_to_string
或多态函数可以接收或返回 gimel
值。
# #show dalet;;
type dalet = private Dennis of int | Donald of string | Dorothy
# Donald 42;;
Error: Cannot create values of the private type Exeter.dalet
# dalet_of (Some (Either.Left 10));;
- : dalet = Dennis 10
# let dalet_to_string = function
| Dorothy -> "Dorothy"
| Dennis _ -> "Dennis"
| Donald _ -> "Donald";;
val dalet_to_string : dalet -> string = <fun>
类型 dalet
是只读的。模式匹配是可能的,但值只能由提供的函数(这里为 dalet_of
)构造。
抽象类型和只读类型可以是变体(如本节所示)、记录或别名。可以访问只读记录字段的值,但创建此类记录需要使用提供的函数。
子模块
子模块实现
可以在另一个模块内定义模块。这使得它成为一个子模块。让我们考虑文件 florence.ml
和 glasgow.ml
florence.ml
module Hello = struct
let message = "Hello from Florence"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
glasgow.ml
let () =
Florence.Hello.print ();
Florence.print_goodbye ()
子模块中的定义可以通过连接模块名称来访问,这里为 Florence.Hello.print
。以下是更新后的 dune
文件,其中包含一个额外的可执行文件。
dune
(executables (names berlin delhi) (modules athens berlin cairo delhi))
(executable (name glasgow) (modules florence glasgow))
(library (name exeter) (modules exeter))
带有签名的子模块
要定义子模块的接口,我们可以提供一个模块签名。这在 florence.ml
文件的第二个版本中完成。
module Hello : sig
val print : unit -> unit
end = struct
let message = "Hello"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
第一个版本使 Florence.Hello.message
公开。在这个版本中,它无法从 glasgow.ml
中访问。
模块签名是类型
模块签名对实现所起的作用类似于类型对值所起的作用。以下是编写文件 florence.ml
的第三种可能方法。
module type HelloType = sig
val print : unit -> unit
end
module Hello : HelloType = struct
let message = "Hello"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
首先,我们定义了一个名为 HelloType
的 module type
,它定义了与之前相同的模块接口。我们没有在定义 Hello
模块时提供签名,而是使用 HelloType
模块类型。
这允许编写由多个模块共享的接口。实现满足列出其某些内容的任何模块类型。这意味着模块可以有多个类型,并且模块类型之间存在子类型关系。
模块操作
显示模块的接口
您可以使用 OCaml 顶层来查看现有模块(例如 Unit
)的内容。
# #show Unit;;
module Unit :
sig
type t = unit = ()
val equal : t -> t -> bool
val compare : t -> t -> int
val to_string : t -> string
end
OCaml 编译器工具链可用于转储 .ml
文件的默认接口。
$ ocamlc -i cairo.ml
val message : string
val hello : unit -> unit
您也可以使用 Anil Madhavapeddy 的 ocaml-print-intf
工具来完成相同的操作。您需要使用 opam install ocaml-print-intf
安装它。您可以:
- 在
.cmi
文件(编译的 ML 接口)上调用它:ocaml-print-intf cairo.cmi
。 - 使用 Dune 调用它:
dune exec -- ocaml-print-intf cairo.ml
如果您使用的是 Dune,.cmi
文件位于 _build
目录中。否则,您可以手动编译以生成它们。命令 ocamlc -c cairo.ml
将创建 cairo.cmo
(可执行字节码)和 cairo.cmi
(编译的接口)。有关在没有 Dune 的情况下进行编译的详细信息,请参阅 编译 OCaml 项目。
模块包含
假设我们觉得 List
模块缺少一个函数,但我们真的希望它像模块的一部分一样。在 extlib.ml
文件中,我们可以使用 include
指令来实现这种效果。
module List = struct
include Stdlib.List
let uncons = function
| [] -> None
| hd :: tl -> Some (hd, tl)
end
它创建了一个模块 Extlib.List
,该模块包含标准 List
模块中的所有内容,以及一个新的 uncons
函数。为了从另一个 .ml
文件中覆盖默认的 List
模块,我们需要在开头添加 open Extlib
。
有状态模块
模块可能具有内部状态。标准库中的 Random
模块就是这样。函数 Random.get_state
和 Random.set_state
提供对内部状态的读写访问,该状态是无名的并且具有抽象类型。
# let s = Random.get_state ();;
val s : Random.State.t = <abstr>
# Random.bits ();;
- : int = 89809344
# Random.bits ();;
- : int = 994326685
# Random.set_state s;;
- : unit = ()
# Random.bits ();;
- : int = 89809344
当您运行此代码时,Random.bits
返回的值将不同。第一次和第三次调用返回相同的结果,这表明内部状态已重置。
结论
在 OCaml 中,模块是组织软件的基本手段。总而言之,模块是一组在名称下封装的定义。这些定义可以是子模块,这允许创建模块层次结构。顶级模块必须是文件,并且是编译的单元。每个模块都有一个接口,它是模块公开的定义列表。默认情况下,模块的接口公开其所有定义,但可以使用接口语法来限制它。
进一步说,以下是处理 OCaml 软件组件的其他方法:
- 函子,它充当从模块到模块的函数
- 库,它是捆绑在一起的已编译模块
- 包,它是安装和分发单元