第 5 章:文件、模块与程序(Files, Modules, and Programs)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 5。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
到目前为止,我们主要是通过顶层环境(toplevel)来体验 OCaml。随着你从练习走向真实世界的程序,就需要离开顶层环境,开始从文件构建程序。文件不只是存储和管理代码的便利方式;在 OCaml 中,文件还对应模块,而模块是把程序划分为概念单元的边界。
本章会展示如何从一组文件构建 OCaml 程序,也会介绍使用模块和模块签名的基础知识。
5.1 单文件程序(Single-File Programs)
我们从一个例子开始:一个工具,它从 stdin 读取行,计算这些行出现频率,并打印最常见的前十行。先从一个简单实现开始,把它保存为文件 freq.ml。
这个实现会用到 List.Assoc 模块中的两个函数。该模块提供处理关联列表(association list)的工具函数,所谓关联列表就是键值对列表。具体来说,我们会使用 List.Assoc.find 在关联列表中查找键,并使用 List.Assoc.add 向关联列表添加新的绑定,如下所示:
open Base;;
let assoc = [("one", 1); ("two",2); ("three",3)];;
>val assoc : (string * int) list = [("one", 1); ("two", 2); ("three", 3)]
List.Assoc.find ~equal:String.equal assoc "two";;
>- : int option = Some 2
List.Assoc.add ~equal:String.equal assoc "four" 4;;
>- : (string, int) Base.List.Assoc.t =
>[("four", 4); ("one", 1); ("two", 2); ("three", 3)]
List.Assoc.add ~equal:String.equal assoc "two" 4;;
>- : (string, int) Base.List.Assoc.t = [("two", 4); ("one", 1); ("three", 3)]
注意,List.Assoc.add 并不会修改原始列表,而是分配一个新列表,把所需的键值对加入进去。
现在可以编写 freq.ml。
open Base
open Stdio
let build_counts () =
In_channel.fold_lines In_channel.stdin ~init:[] ~f:(fun counts line ->
let count =
match List.Assoc.find ~equal:String.equal counts line with
| None -> 0
| Some x -> x
in
List.Assoc.add ~equal:String.equal counts line (count + 1))
let () =
build_counts ()
|> List.sort ~compare:(fun (_, x) (_, y) -> Int.descending x y)
|> (fun l -> List.take l 10)
|> List.iter ~f:(fun (line, count) -> printf "%3d: %s\n" count line)
函数 build_counts 从 stdin 读入各行,并根据这些行构造一个关联列表,其中保存每行的出现频率。它通过调用 In_channel.fold_lines 来完成这件事;这个函数类似第 4 章“列表与模式(Lists and Patterns)”中介绍的 List.fold,会逐行读取输入,并为每一行调用提供的 fold 函数来更新累加器。这里的累加器初始化为空列表。
定义好 build_counts 之后,我们调用该函数构建关联列表,按频率降序排序,取列表前十个元素,然后遍历这十个元素并把它们打印到屏幕上。这些操作通过第 3 章“变量与函数(Variables and Functions)”中介绍过的 |> 运算符连接在一起。
main 在哪里?(Where Is main?)
不同于 C、Java 或 C# 程序,OCaml 程序没有唯一的 main 函数。当一个 OCaml 程序被求值时,所有实现文件中的语句会按照它们被链接在一起的顺序求值。这些实现文件可以包含任意表达式,而不只是函数定义。在这个例子中,以 let () = 开头的声明扮演了 main 函数的角色,启动了整个处理流程。但实际上,整个文件都会在启动时被求值,所以从某种意义上说,整个代码库就是一个巨大的 main 函数。
写成 let () = 的习惯用法看起来也许有点奇怪,但它有自己的目的。这里的 let 绑定会对一个 unit 类型的值做模式匹配,其作用是确保右侧表达式返回 unit,这对于主要通过副作用工作的函数来说很常见。
如果没有使用 Base 或其他外部库,我们可以像这样构建可执行文件:
ocamlopt freq.ml -o freq
>File "freq.ml", line 1, characters 5-9:
>1 | open Base
> ^^^^
>Error: Unbound module Base
[2]
但正如你看到的,这会失败,因为它找不到 Base 和 Stdio。我们需要一个稍微复杂一些的调用,把它们链接进来:
ocamlfind ocamlopt -linkpkg -package base -package stdio freq.ml -o freq
这里使用了 ocamlfind。它是一个工具,会以适当标志调用 OCaml 工具链的其他部分(在这个例子中是 ocamlopt),以便链接特定库和包。在这里,-package base 表示要求 ocamlfind 链接 Base 库;-linkpkg 则要求 ocamlfind 在构建可执行文件时按需链接这些包。
对于单文件项目来说,这种方式已经够用;但更复杂的项目需要一个工具来编排构建。完成这项工作的一个好工具是 dune。要调用 dune,需要两个文件:一个用于整个项目的 dune-project 文件,以及一个配置特定目录的 dune 文件。这是一个单目录项目,所以每种文件都只需要一个;但更真实的项目通常会有一个 dune-project 文件和许多个 dune 文件。
最简单的 dune-project 只需要指定正在使用的 dune 配置语言版本。
(lang dune 3.0)
我们还需要一个 dune 文件,用来声明想要构建的可执行文件,以及它依赖的库。
(executable
(name freq)
(libraries base stdio))
有了这些内容之后,就可以按如下方式调用 dune。
dune build freq.exe
可以从命令行运行生成的可执行文件 freq.exe。由 dune 构建的可执行文件会被放在 _build/default 目录下,可以从那里调用。下面这个具体调用会统计 freq.ml 文件自身中出现的单词。
grep -Eo '[[:alpha:]]+' freq.ml | ./_build/default/freq.exe
> 5: line
> 5: List
> 5: counts
> 4: count
> 4: fun
> 4: x
> 4: equal
> 3: let
> 2: f
> 2: l
方便的是,dune 允许我们把构建和运行可执行文件合并为一次操作,可以通过 dune exec 来做到这一点。
grep -Eo '[[:alpha:]]+' freq.ml | dune exec ./freq.exe
> 5: line
> 5: List
> 5: counts
> 4: count
> 4: fun
> 4: x
> 4: equal
> 3: let
> 2: f
> 2: l
关于 dune 能做的事,我们这里只是浅尝辄止。第 22 章“OCaml 平台(The OCaml Platform)”会更详细地讨论 dune。
字节码与原生代码(Bytecode Versus Native Code)
OCaml 附带两个编译器:ocamlopt 原生代码编译器和 ocamlc 字节码编译器。用 ocamlc 编译的程序会由虚拟机解释执行,而用 ocamlopt 编译的程序会被编译成机器码,在特定操作系统和处理器架构上运行。使用 dune 时,以 .bc 结尾的目标会被构建为字节码可执行文件,以 .exe 结尾的目标会被构建为原生代码。
除了性能之外,这两个编译器生成的可执行文件行为几乎相同。不过有几件事需要注意。第一,字节码编译器可用于更多架构,并且拥有一些原生代码不可用的工具。例如,OCaml 调试器只适用于字节码(尽管 GNU 调试器 gdb 在一些限制下也能用于 OCaml 原生代码应用)。字节码编译器也比原生代码编译器更快。另外,如果要运行字节码可执行文件,通常需要在目标系统上安装 OCaml。不过这并不是绝对要求,因为可以使用 -custom 编译器标志构建带有嵌入式运行时的字节码可执行文件。
一般来说,生产环境可执行文件通常应该使用原生代码编译器构建;但开发构建有时适合使用字节码。当然,当目标平台不被原生代码编译器支持时,字节码也是合理选择。第 27 章“编译器后端:字节码与原生代码(The Compiler Backend: Byte Code And Native Code)”会更详细地介绍这两个编译器。
5.2 多文件程序与模块(Multifile Programs and Modules)
OCaml 中的源文件与模块系统联系在一起,每个文件都会被编译成一个模块,模块名由文件名推导而来。我们已经遇到过模块,例如使用 List.Assoc 模块中的 find 和 add 这样的函数时。最简单地说,可以把模块看作存放在某个命名空间中的一组定义。
现在考虑如何使用模块重构 freq.ml 的实现。请记得,变量 counts 包含一个关联列表,用来表示目前已经看到的各行计数。但更新关联列表所需时间与列表长度成线性关系,这意味着处理一个文件的时间复杂度会随着该文件中不同文本行的数量呈二次增长。
可以通过把关联列表替换为更高效的数据结构来解决这个问题。为此,我们先把关键功能分解到一个带有显式接口的单独模块中。一旦有了清晰的接口可以依赖,就可以考虑替代实现,也就是更高效的实现。
先创建一个文件 counter.ml,其中包含维护关联列表的逻辑,这个关联列表用来表示频率计数。关键函数名为 touch,它会把给定文本行的频率计数加一。
open Base
let touch counts line =
let count =
match List.Assoc.find ~equal:String.equal counts line with
| None -> 0
| Some x -> x
in
List.Assoc.add ~equal:String.equal counts line (count + 1)
文件 counter.ml 会被编译成名为 Counter 的模块,其中模块名会自动从文件名推导出来。即使文件名没有大写,模块名也会大写。事实上,模块名总是大写的。
现在可以重写 freq.ml,让它使用 Counter。
open Base
open Stdio
let build_counts () =
In_channel.fold_lines In_channel.stdin ~init:[] ~f:Counter.touch
let () =
build_counts ()
|> List.sort ~compare:(fun (_, x) (_, y) -> Int.descending x y)
|> (fun l -> List.take l 10)
|> List.iter ~f:(fun (line, count) -> printf "%3d: %s\n" count line)
得到的代码仍然可以用 dune 构建;dune 会发现依赖关系,并意识到 counter.ml 也需要被编译。
dune build freq.exe
5.3 签名与抽象类型(Signatures and Abstract Types)
虽然已经把一部分逻辑推到了 Counter 模块中,freq.ml 中的代码仍然可能依赖 Counter 实现的细节。事实上,如果查看 build_counts 的定义,会发现它依赖这样一个事实:空的频率计数集合被表示为空列表。我们想防止这类依赖,这样就可以改变 Counter 的实现,而不必修改 freq.ml 这样的客户端代码。
可以通过附加接口(interface)来隐藏模块的实现细节。注意,在 OCaml 语境中,接口(interface)、签名(signature)和模块类型(module type)这些术语可以互换使用。由文件 filename.ml 定义的模块,可以通过放在 filename.mli 文件中的签名加以约束。
对于 counter.mli,我们先写一个接口,描述 counter.ml 中当前可用的内容,不隐藏任何东西。签名中使用 val 声明来指定值。val 声明的语法如下:
val <identifier> : <type>
使用这种语法,可以把 counter.ml 的签名写成下面这样。
open Base
(** Bump the frequency count for the given string. *)
val touch : (string * int) list -> string -> (string * int) list
注意,dune 会自动检测 mli 文件的存在,并把它纳入构建。
为了隐藏频率计数被表示为关联列表这一事实,需要把频率计数类型做成抽象类型。如果类型名暴露在接口中,而类型定义没有暴露,那么这个类型就是抽象的。下面是 Counter 的一个抽象接口:
open Base
(** A collection of string frequency counts *)
type t
(** The empty set of frequency counts *)
val empty : t
(** Bump the frequency count for the given string. *)
val touch : t -> string -> t
(** Converts the set of frequency counts to an association list. A
string shows up at most once, and the counts are >= 1. *)
val to_list : t -> (string * int) list
我们向 Counter 添加了 empty 和 to_list,因为没有它们,就无法创建 Counter.t,也无法从中取出数据。
我们也借这个机会为模块编写文档。mli 文件用于指定模块接口,因此也是放置文档的自然位置。注释以双星号开头,可以让 odoc 工具在生成 API 文档时拾取它们。第 22 章“OCaml 平台(The OCaml Platform)”会进一步讨论 odoc。
下面是与新的 counter.mli 匹配的 counter.ml 重写版本:
open Base
type t = (string * int) list
let empty = []
let to_list x = x
let touch counts line =
let count =
match List.Assoc.find ~equal:String.equal counts line with
| None -> 0
| Some x -> x
in
List.Assoc.add ~equal:String.equal counts line (count + 1)
如果现在尝试编译 freq.ml,会得到下面的错误:
dune build freq.exe
>File "freq.ml", line 5, characters 53-66:
>5 | In_channel.fold_lines In_channel.stdin ~init:[] ~f:Counter.touch
> ^^^^^^^^^^^^^
>Error: This expression has type Counter.t -> Export.string -> Counter.t
> but an expression was expected of type
> 'a list -> Export.string -> 'a list
> Type Counter.t is not compatible with type 'a list
[1]
原因是 freq.ml 依赖了“频率计数被表示为关联列表”这一事实,而我们刚刚把这个事实隐藏起来。只需要修复 build_counts,让它使用 Counter.empty 而不是 [],并使用 Counter.to_list 把完成后的计数转换为关联列表即可。得到的实现如下:
open Base
open Stdio
let build_counts () =
In_channel.fold_lines
In_channel.stdin
~init:Counter.empty
~f:Counter.touch
let () =
build_counts ()
|> Counter.to_list
|> List.sort ~compare:(fun (_, x) (_, y) -> Int.descending x y)
|> (fun counts -> List.take counts 10)
|> List.iter ~f:(fun (line, count) -> printf "%3d: %s\n" count line)
使用这个实现后,构建现在会成功。
dune build freq.exe
现在可以转向优化 Counter 的实现。下面是另一个高效得多的实现,它基于 Base 的 Map 数据结构。
open Base
type t = int Map.M(String).t
let empty = Map.empty (module String)
let to_list t = Map.to_alist t
let touch t s =
let count =
match Map.find t s with
| None -> 0
| Some x -> x
in
Map.set t ~key:s ~data:(count + 1)
上面的例子中有一些还不熟悉的语法,尤其是使用 int Map.M(String).t 表示映射类型,以及用 Map.empty (module String) 生成空映射。这里使用的是 OCaml 中更高级的特性,具体来说是函子和一等模块,这些内容会在后续章节中介绍。第 15 章“映射与哈希表(Maps and Hash Tables)”会专门讲解这些特性在 Map 数据结构中的用法。
5.4 签名中的具体类型(Concrete Types in Signatures)
在频率计数示例中,模块 Counter 有一个抽象类型 Counter.t,用于表示一组频率计数。有时,你会希望接口中的某个类型是具体的(concrete),也就是在接口中包含该类型定义。
例如,设想我们想给 Counter 添加一个函数,返回出现频率位于中位数位置的行。如果行数为偶数,就不存在唯一的中位数,此时函数会返回中位数前后的两行。我们会使用自定义类型表示存在两种可能返回值这一事实。下面是一种可能实现:
type median =
| Median of string
| Before_and_after of string * string
let median t =
let sorted_strings =
List.sort (Map.to_alist t) ~compare:(fun (_, x) (_, y) ->
Int.descending x y)
in
let len = List.length sorted_strings in
if len = 0 then failwith "median: empty frequency count";
let nth n = fst (List.nth_exn sorted_strings n) in
if len % 2 = 1
then Median (nth (len / 2))
else Before_and_after (nth ((len / 2) - 1), nth (len / 2))
在上面的代码中,我们用 failwith 针对空列表情况抛出异常。第 8 章“错误处理(Error Handling)”会进一步讨论异常。还要注意,函数 fst 只是返回任意二元组的第一个元素。
现在,为了在接口中有用地暴露它,需要同时暴露函数和带定义的 median 类型。注意,值(函数就是值的一种)和类型拥有不同命名空间,所以这里不会发生命名冲突。把下面两行加入 counter.mli 就可以做到这一点。
(** Represents the median computed from a set of strings. In the case
where there is an even number of choices, the one before and after
the median is returned. *)
type median =
| Median of string
| Before_and_after of string * string
val median : t -> median
一个给定类型应该是抽象的还是具体的,这是一个重要设计决策。抽象类型让你更能控制值如何被创建和访问,也更容易强制执行那些类型本身无法保证的不变量;具体类型则以轻量方式向客户端代码暴露更多细节和结构。正确选择很大程度上取决于上下文。
5.5 嵌套模块(Nested Modules)
到目前为止,我们只考虑了与文件对应的模块,例如 counter.ml。但模块(以及模块签名)也可以嵌套在其他模块内部。来看一个简单例子:某个程序需要处理多种标识符,例如用户名和主机名。如果只是把它们表示为字符串,就很容易把一种标识符和另一种混淆。
更好的做法是为每一种标识符生成新的抽象类型,而这些类型在底层只是用字符串实现。这样,类型系统就会防止你把用户名和主机名混在一起;如果确实需要转换,也可以通过与字符串类型之间的显式转换来完成。
下面展示了如何在子模块中创建这样一个抽象类型:
open Base
module Username : sig
type t
val of_string : string -> t
val to_string : t -> string
val ( = ) : t -> t -> bool
end = struct
type t = string
let of_string x = x
let to_string x = x
let ( = ) = String.( = )
end
注意,上面的 to_string 和 of_string 函数只是作为恒等函数实现的,这意味着它们没有运行时效果。它们存在的目的完全在于通过类型系统对代码施加纪律。我们还选择放入一个相等性函数,这样就可以检查两个用户名是否匹配。在真实应用中,可能还需要更多功能,例如对用户名进行哈希和比较的能力;不过这里故意让例子保持简单。
像这样的模块声明,其基本结构是:
module <name> : <signature> = <implementation>
也可以稍微换一种写法,把签名放到自己的顶层 module type 声明中。这样就可以用轻量方式创建多个不同类型,并让它们共享同一个底层实现:
open Base
module Time = Core.Time
module type ID = sig
type t
val of_string : string -> t
val to_string : t -> string
val ( = ) : t -> t -> bool
end
module String_id = struct
type t = string
let of_string x = x
let to_string x = x
let ( = ) = String.( = )
end
module Username : ID = String_id
module Hostname : ID = String_id
type session_info =
{ user : Username.t
; host : Hostname.t
; when_started : Time.t
}
let sessions_have_same_user s1 s2 = Username.( = ) s1.user s2.host
前面的代码中有一个 bug:它把一个会话中的用户名和另一个会话中的主机进行了比较,而本来应该比较两个会话中的用户名。不过,由于我们定义类型的方式,编译器会替我们标出这个 bug。
dune build session_info.exe
>File "session_info.ml", line 29, characters 59-66:
>29 | let sessions_have_same_user s1 s2 = Username.( = ) s1.user s2.host
> ^^^^^^^
>Error: This expression has type Hostname.t
> but an expression was expected of type Username.t
[1]
这是一个微不足道的例子,但混淆不同种类的标识符确实是非常真实的 bug 来源。为不同类别的标识符生成抽象类型,是避免这类问题的有效方法。
5.6 打开模块(Opening Modules)
大多数时候,你会通过模块名作为显式限定符来引用模块中的值和类型。例如,写 List.map 表示引用 List 模块中的 map 函数。不过有时,你希望不使用这种显式限定也能引用模块内容,这正是 open 语句的用途。
我们已经遇到过 open,具体来说就是写 open Base 来访问 Base 库中的标准定义。一般而言,打开一个模块会把该模块的内容添加到编译器用于查找各种标识符定义的环境中。下面是一个例子:
module M = struct let foo = 3 end;;
>module M : sig val foo : int end
foo;;
>Line 1, characters 1-4:
>Error: Unbound value foo
open M;;
foo;;
>- : int = 3
下面给出一些关于如何有效使用 open 的一般建议。
很少打开模块(Open Modules Rarely)
当使用 Base 这样的替代标准库时,open 是必不可少的;但一般来说,尽量少打开模块是一种好风格。打开模块本质上是在简短和显式之间做权衡:打开的模块越多,需要写的模块限定就越少,但看到一个标识符时也越难判断它来自哪里。
确实使用 open 时,通常应该主要用于那些被设计成可打开的模块,例如 Base 本身,或者 Base 中的 Option.Monad_infix、Float.O。
优先使用局部打开(Prefer Local Opens)
通常最好减少受一次 open 影响的代码范围。一个很好的工具是局部打开(local open),它可以把打开模块的作用域限制到任意表达式。局部打开有两种语法。下面的例子展示了 let open 语法:
let average x y =
let open Int64 in
(x + y) / of_int 2;;
>val average : int64 -> int64 -> int64 = <fun>
这里的 of_int 和中缀运算符都来自 Int64 模块。
下面展示一种更轻量的语法,它尤其适合小表达式。
let average x y =
Int64.((x + y) / of_int 2);;
>val average : int64 -> int64 -> int64 = <fun>
改用模块快捷名(Using Module Shortcuts Instead)
局部 open 的另一种替代做法,是在局部重新绑定模块名。这能让代码更简短,同时不放弃显式性。因此,使用 Counter.median 类型时,与其写成:
let print_median m =
match m with
| Counter.Median string -> printf "True median:\n %s\n" string
| Counter.Before_and_after (before, after) ->
printf "Before and after median:\n %s\n %s\n" before after
不如写成:
let print_median m =
let module C = Counter in
match m with
| C.Median string -> printf "True median:\n %s\n" string
| C.Before_and_after (before, after) ->
printf "Before and after median:\n %s\n %s\n" before after
由于模块名 C 只在很短的作用域中存在,读者很容易理解并记住 C 代表什么。在模块顶层把模块重新绑定为很短的名字通常是个错误。
5.7 包含模块(Including Modules)
打开模块会影响用于搜索标识符的环境;而包含(include)一个模块,则是一种向模块本身添加新标识符的方式。考虑下面这个简单模块,它用于表示整数区间:
module Interval = struct
type t = | Interval of int * int
| Empty
let create low high =
if high < low then Empty else Interval (low,high)
end;;
>module Interval :
> sig type t = Interval of int * int | Empty val create : int -> int -> t end
可以使用 include 指令创建一个新的、经过扩展的 Interval 模块版本:
module Extended_interval = struct
include Interval
let contains t x =
match t with
| Empty -> false
| Interval (low,high) -> x >= low && x <= high
end;;
>module Extended_interval :
> sig
> type t = Interval.t = Interval of int * int | Empty
> val create : int -> int -> t
> val contains : t -> int -> bool
> end
Extended_interval.contains (Extended_interval.create 3 10) 4;;
>- : bool = true
include 和 open 的区别在于:这里不只是改变了标识符的搜索方式,还改变了模块中包含的内容。如果使用的是 open,会得到非常不同的结果:
module Extended_interval = struct
open Interval
let contains t x =
match t with
| Empty -> false
| Interval (low,high) -> x >= low && x <= high
end;;
>module Extended_interval :
> sig val contains : Extended_interval.t -> int -> bool end
Extended_interval.contains (Extended_interval.create 3 10) 4;;
>Line 1, characters 29-53:
>Error: Unbound value Extended_interval.create
再考虑一个更真实的例子。假设想构建一个扩展版 Option 模块,给它添加一些 Base 中分发的 Option 模块没有的功能。这就是 include 适合处理的任务。
open Base
(* The full contents of the option module *)
include Option
(* The new function we're going to add *)
let apply f_opt x =
match f_opt with
| None -> None
| Some f -> Some (f x)
现在,如何为这个新模块编写接口?事实证明,include 也可以作用于签名,所以我们可以使用基本相同的技巧来编写 mli。唯一的问题是,我们需要拿到 Option 模块的签名。这可以通过 module type of 完成,它会从一个模块计算出签名:
open Base
(* Include the interface of the option module from Base *)
include module type of Option
(* Signature of function we're adding *)
val apply : ('a -> 'b) t -> 'a -> 'b t
mli 中声明的顺序不需要与 ml 中声明的顺序匹配。ml 中声明顺序的重要性,主要体现在它会影响哪些值被遮蔽。如果想用同名新函数替换 Option 中的某个函数,那么在 ml 中,这个函数的声明就必须出现在 include Option 声明之后。
现在可以把 Ext_option 当作 Option 的替代品。如果希望在项目中优先使用 Ext_option 而不是 Option,可以创建一个公共定义文件,这里称为 import.ml。
module Option = Ext_option
然后,通过打开 Import,就可以用我们的扩展模块遮蔽 Base 的 Option 模块。
open Base
open Import
let lookup_and_apply map key x = Option.apply (Map.find map key) x
5.8 模块常见错误(Common Errors with Modules)
当 OCaml 编译同时带有 ml 和 mli 的程序时,如果检测到二者不匹配,就会报错。下面是一些常见错误。
类型不匹配(Type Mismatches)
最简单的一类错误,是签名中指定的类型与模块实现中的类型不匹配。例如,如果我们替换 counter.mli 中的 val 声明,把前两个参数的类型调换:
(** Bump the frequency count for the given string. *)
val touch : string -> t -> t
然后尝试编译,就会得到下面的错误。
dune build freq.exe
>File "counter.ml", line 1:
>Error: The implementation counter.ml
> does not match the interface .freq.eobjs/byte/dune__exe__Counter.cmi:
> Values do not match:
> val touch :
> ('a, int, 'b) Base.Map.t -> 'a -> ('a, int, 'b) Base.Map.t
> is not included in
> val touch : string -> t -> t
> The type ('a, int, 'b) Base.Map.t -> 'a -> ('a, int, 'b) Base.Map.t
> is not compatible with the type string -> t -> t
> Type ('a, int, 'b) Base.Map.t is not compatible with type string
> File "counter.mli", line 16, characters 0-28: Expected declaration
> File "counter.ml", line 8, characters 4-9: Actual declaration
[1]
缺少定义(Missing Definitions)
我们也许会决定,希望在 Counter 中增加一个新函数,用于取出给定字符串的频率计数。可以向 mli 添加下面这一行。
(** Returns the frequency count for the given string *)
val count : t -> string -> int
现在,如果没有真正添加实现就尝试编译,会得到这个错误。
dune build freq.exe
>File "counter.ml", line 1:
>Error: The implementation counter.ml
> does not match the interface .freq.eobjs/byte/dune__exe__Counter.cmi:
> The value `count' is required but not provided
> File "counter.mli", line 15, characters 0-30: Expected declaration
[1]
缺少类型定义会导致类似错误。
类型定义不匹配(Type Definition Mismatches)
出现在 mli 中的类型定义,需要与 ml 中的相应定义匹配。再次考虑 median 类型的例子。变体构造器的声明顺序对 OCaml 编译器来说很重要,因此如果实现中 median 的定义以不同顺序列出这些选项:
(** Represents the median computed from a set of strings. In the case
where there is an even number of choices, the one before and after
the median is returned. *)
type median =
| Before_and_after of string * string
| Median of string
val median : t -> median
就会导致编译错误。
dune build freq.exe
>File "counter.ml", line 1:
>Error: The implementation counter.ml
> does not match the interface .freq.eobjs/byte/dune__exe__Counter.cmi:
> Type declarations do not match:
> type median = Median of string | Before_and_after of string * string
> is not included in
> type median = Before_and_after of string * string | Median of string
> Constructor Before_and_after has been moved from position 1 to 2.
> File "counter.mli", lines 21-23, characters 0-20: Expected declaration
> File "counter.ml", lines 17-19, characters 0-39: Actual declaration
[1]
顺序对其他类型声明同样重要,包括记录字段的声明顺序,以及函数参数的顺序(包括带标签参数和可选参数)。
循环依赖(Cyclic Dependencies)
在大多数情况下,OCaml 不允许循环依赖,也就是一组定义彼此互相引用。如果你想创建这样的定义,通常必须用特殊方式标记它们。例如,在定义一组相互递归的值时(例如第 3 章“递归函数(Recursive Functions)”中 is_even 和 is_odd 的定义),需要使用 let rec 而不是普通的 let。
模块层面也是如此。默认情况下,模块之间不允许循环依赖,而文件之间永远不允许循环依赖。递归模块是可能的,但它属于少见情况,这里不再展开讨论。
最简单的非法循环引用例子,就是某个模块引用自己的模块名。因此,如果尝试在 counter.ml 内部添加对 Counter 的引用:
let singleton l = Counter.touch Counter.empty
尝试构建时会看到这个错误:
dune build freq.exe
>File "counter.ml", line 18, characters 18-31:
>18 | let singleton l = Counter.touch Counter.empty
> ^^^^^^^^^^^^^
>Error: The module Counter is an alias for module Dune__exe__Counter, which is the current compilation unit
[1]
如果在文件之间创建循环引用,问题会以另一种方式表现出来。可以通过在 counter.ml 中添加对 Freq 的引用来制造这种情况,例如添加下面这一行。
let _build_counts = Freq.build_counts
在这个例子中,dune 会发现错误,并明确指出存在循环:
dune build freq.exe
>Error: Dependency cycle between the following files:
> _build/default/.freq.eobjs/freq.impl.all-deps
>-> _build/default/.freq.eobjs/counter.impl.all-deps
>-> _build/default/.freq.eobjs/freq.impl.all-deps
[1]
5.9 使用模块进行设计(Designing with Modules)
模块系统是 OCaml 程序结构中的关键部分。因此,本章最后给出一些建议,帮助你有效思考这种结构的设计。
少暴露具体类型(Expose Concrete Types Rarely)
设计 mli 时,需要做出的一个选择是:暴露类型的具体定义,还是让它们保持抽象。大多数时候,抽象是正确选择,原因有两点:它增强了设计的灵活性,也让你能够对模块的使用强制执行不变量。
抽象通过限制用户与你的类型交互的方式来增强灵活性,从而减少用户依赖实现细节的机会。如果显式暴露类型,那么用户就可以依赖你选择的类型的任何一个细节。如果类型是抽象的,那么只有你想暴露的那些特定操作可用。这意味着,只要保留这些操作的语义,就可以自由改变实现,而不会影响客户端。
类似地,抽象允许你强制执行类型上的不变量。如果类型被暴露出去,那么模块使用者就可以以底层类型允许的任意方式创建该类型的新实例;如果它是可变的,还可以以任意方式修改已有实例。这可能违反某个期望的不变量,也就是关于你的类型并且应该始终为真的性质。抽象类型让你能够确保只暴露会保留这些不变量的函数,从而保护不变量。
尽管有这些好处,这里仍然存在权衡。特别是,具体暴露类型会让你能够对这些类型使用模式匹配,而正如第 4 章“列表与模式(Lists and Patterns)”中所见,模式匹配是一种强大且重要的工具。一般来说,只有当模式匹配能力具有显著价值,并且你关心的不变量已经由数据类型本身强制保证时,才应该暴露类型的具体实现。
为调用点而设计(Design for the Call Site)
编写接口时,你不应该只考虑一个人阅读精心记录的 mli 文件时理解接口有多容易;更重要的是,你希望某人在调用点阅读代码时,这次调用本身尽可能清楚。
原因在于,大多数时候,与 API 交互的人会通过阅读和修改使用该 API 的代码来工作,而不是通过阅读接口定义本身来工作。从这个角度让 API 尽可能显而易见,可以简化使用者的生活。
有许多方式可以改善客户端代码的可读性。一个例子是第 3 章“带标签参数(Labeled Arguments)”中讨论过的带标签参数,它们能在调用点提供文档。
也可以仅仅通过为函数、变体标签和记录字段选择好名字来改善可读性。需要明确的是,好名字并不总是长名字。如果要写一个把数字加倍的匿名函数:(fun x -> x * 2),像 x 这样的短变量名就是最好的。一个经验法则是:作用域小的名字应该短,而作用域大的名字,例如模块接口中的函数名,应该更长、更具描述性。
这里当然也存在权衡,因为让 API 更显式往往也会让它们更冗长。另一个有用的经验法则是:越少用到的名字越应该更长、更显式,因为名字使用得越少,冗长带来的成本就越低,而显式带来的收益就越高。
创建一致的接口(Create Uniform Interfaces)
模块接口的设计不应该被孤立看待。代码库中出现的接口应该能够和谐地协同工作。达成这一点的一部分方法,是对接口的某些方面进行标准化。
Base、Core 以及相关库在设计模块接口时,围绕一组统一标准进行了设计。下面是它们使用的一些准则。
- 几乎每个类型都有一个模块。你应该为程序中几乎每个类型生成一个模块,并把某个模块的主类型命名为
t。 - 把
t放在第一位。如果有一个模块M,其主类型是M.t,那么M中接收M.t类型值的函数,应该把它作为第一个参数。 - 经常抛出异常的函数应当以
_exn结尾。否则,错误应该通过返回option或Or_error.t来表示,这两者都会在第 8 章“错误处理(Error Handling)”中讨论。
在 Base 中,也存在关于特定函数类型签名应该是什么样的标准。例如,无论 map 应用于哪种底层类型,它的签名本质上总是相同的。这种逐函数的 API 一致性通过签名包含(signature includes)来实现,签名包含允许不同模块共享接口中的组成部分。第 11 章“函子(Functors)”中的“使用多个接口(Using Multiple Interfaces)”会介绍这种方法。
Base 的标准不一定适合你的项目,但你可以通过找到一组一致的标准并加以应用,来改善代码库的可用性。
先接口,后实现(Interfaces Before Implementations)
OCaml 简洁且灵活的类型语言支持一种面向类型的软件设计方法。这种方法要求在开始实现之前,先思考并写出将要使用的类型。
无论是在核心语言层面,还是在模块层面,这都是一种好方法。在核心语言层面,你会先写类型定义,再编写计算逻辑;在模块层面,则会先写出 mli 的初稿,再开始处理 ml。
当然,设计过程会双向进行。在实现过程中,你常常会根据新学到的东西回过头修改类型。不过,类型和签名提供了一种轻量工具,可以帮助你构建设计骨架,在投入大量时间和精力填充细节之前,先澄清目标和意图。