第 2 章 模块系统

本章介绍 OCaml 的模块系统。

1 结构体

模块的主要动机之一是将相关的定义(例如数据类型及其操作的定义)打包在一起,并为这些定义强制执行一致的命名方案。这避免了名称用尽或意外混淆名称。这样的包称为结构体,由structend结构引入,其中包含任意顺序的定义。结构体通常使用module绑定命名。例如,以下是一个将 FIFO 队列类型及其操作打包在一起的结构体:

# module Fifo = struct type 'a queue = { front: 'a list; rear: 'a list } let make front rear = match front with | [] -> { front = List.rev rear; rear = [] } | _ -> { front; rear } let empty = { front = []; rear = [] } let is_empty = function { front = []; _ } -> true | _ -> false let add x q = make q.front (x :: q.rear) exception Empty let top = function | { front = []; _ } -> raise Empty | { front = x :: _; _ } -> x let pop = function | { front = []; _ } -> raise Empty | { front = _ :: f; rear = r } -> make f r end;;
module Fifo : sig type 'a queue = { front : 'a list; rear : 'a list; } val make : 'a list -> 'a list -> 'a queue val empty : 'a queue val is_empty : 'a queue -> bool val add : 'a -> 'a queue -> 'a queue exception Empty val top : 'a queue -> 'a val pop : 'a queue -> 'a queue end

在结构体外部,可以使用“点表示法”引用其组件,即由结构体名称限定的标识符。例如,Fifo.add 是在结构体Fifo中定义的函数add,而Fifo.queue 是在Fifo中定义的类型queue

# Fifo.add "hello" Fifo.empty;;
- : string Fifo.queue = {Fifo.front = ["hello"]; rear = []}

另一种可能性是打开模块,这会将模块中定义的所有标识符引入当前结构体的作用域。

# open Fifo;;
# add "hello" empty;;
- : string Fifo.queue = {front = ["hello"]; rear = []}

打开模块可以更轻松地访问其组件,但代价是难以识别标识符是在哪个模块中定义的。特别是,打开的模块可能会隐藏当前作用域中存在的标识符,这可能导致令人困惑的错误。

# let empty = [] open Fifo;;
val empty : 'a list = []
# let x = 1 :: empty ;;
Error: 此表达式类型为 'a Fifo.queue,但预期表达式类型为 int list

解决此难题的部分方法是在本地打开模块,使模块的组件仅在相关的表达式中可用。这还可以使代码更易于阅读(因为 open 语句更靠近其使用位置)和更易于重构(因为代码片段更自包含)。为此提供了两种构造:

# let open Fifo in add "hello" empty;;
- : string Fifo.queue = {front = ["hello"]; rear = []}

以及

# Fifo.(add "hello" empty);;
- : string Fifo.queue = {front = ["hello"]; rear = []}

在第二种形式中,当局部 open 的主体本身由括号、大括号或方括号分隔时,可以省略局部 open 的括号。例如,

# Fifo.[empty] = Fifo.([empty]);;
- : bool = true
# Fifo.[|empty|] = Fifo.([|empty|]);;
- : bool = true
# Fifo.{ contents = empty } = Fifo.({ contents = empty });;
- : bool = true

此第二种形式也适用于模式

# let at_most_one_element x = match x with | Fifo.{ front = ([] | [_]); rear = [] } -> true | _ -> false ;;
val at_most_one_element : 'a Fifo.queue -> bool = <fun>

还可以使用include语句将模块的组件复制到另一个模块中。这对于扩展现有模块特别有用。作为说明,我们可以添加一些函数,当队列为空时返回可选值而不是异常。

# module FifoOpt = struct include Fifo let top_opt q = if is_empty q then None else Some(top q) let pop_opt q = if is_empty q then None else Some(pop q) end;;
module FifoOpt : sig type 'a queue = 'a Fifo.queue = { front : 'a list; rear : 'a list; } val make : 'a list -> 'a list -> 'a queue val empty : 'a queue val is_empty : 'a queue -> bool val add : 'a -> 'a queue -> 'a queue exception Empty val top : 'a queue -> 'a val pop : 'a queue -> 'a queue val top_opt : 'a queue -> 'a option val pop_opt : 'a queue -> 'a queue option end

2 签名

签名是结构体的接口。签名指定结构体的哪些组件可从外部访问,以及使用哪种类型。它可以用来隐藏结构体的某些组件(例如局部函数定义)或以受限类型导出某些组件。例如,以下签名指定队列操作emptyaddtoppop,但不包括辅助函数make。类似地,它使queue类型成为抽象类型(不提供其作为具体类型的实际表示形式)。这确保了Fifo模块的用户无法违反操作依赖的数据结构不变式,例如“如果 front 列表为空,则 rear 列表也必须为空”。

# module type FIFO = sig type 'a queue (* 现在是抽象类型 *) val empty : 'a queue val add : 'a -> 'a queue -> 'a queue val top : 'a queue -> 'a val pop : 'a queue -> 'a queue exception Empty end;;
module type FIFO = sig type 'a queue val empty : 'a queue val add : 'a -> 'a queue -> 'a queue val top : 'a queue -> 'a val pop : 'a queue -> 'a queue exception Empty end

Fifo结构体限制为此签名会导致Fifo结构体的另一种视图,其中make函数不可访问,并且队列的实际表示形式被隐藏。

# module AbstractQueue = (Fifo : FIFO);;
module AbstractQueue : FIFO
# AbstractQueue.make [1] [2;3] ;;
Error: 未绑定值 AbstractQueue.make
# AbstractQueue.add "hello" AbstractQueue.empty;;
- : string AbstractQueue.queue = <abstr>

限制也可以在结构体的定义期间执行,如下所示:

module Fifo = (struct ... end : FIFO);;

为上述提供了另一种语法

module Fifo : FIFO = struct ... end;;

与模块类似,可以包含一个签名以将其组件复制到当前签名中。例如,我们可以使用top_optpop_opt函数扩展FIFO签名

# module type FIFO_WITH_OPT = sig include FIFO val top_opt: 'a queue -> 'a option val pop_opt: 'a queue -> 'a queue option end;;
module type FIFO_WITH_OPT = sig type 'a queue val empty : 'a queue val add : 'a -> 'a queue -> 'a queue val top : 'a queue -> 'a val pop : 'a queue -> 'a queue exception Empty val top_opt : 'a queue -> 'a option val pop_opt : 'a queue -> 'a queue option end

3 函数子

函子(Functor)是将模块映射到模块的“函数”。函子允许你创建参数化的模块,然后提供其他模块作为参数来获得特定的实现。例如,一个使用排序列表实现集合的 Set 模块可以被参数化,使其能够处理任何提供元素类型和比较函数 compare 的模块(例如 OrderedString)。

# type comparison = Less | Equal | Greater;;
type comparison = Less | Equal | Greater
# module type ORDERED_TYPE = sig type t val compare: t -> t -> comparison end;;
module type ORDERED_TYPE = sig type t val compare : t -> t -> comparison end
# module Set = functor (Elt: ORDERED_TYPE) -> struct type element = Elt.t type set = element list let empty = [] let rec add x s = match s with [] -> [x] | hd::tl -> match Elt.compare x hd with Equal -> s (* x is already in s *) | Less -> x :: s (* x is smaller than all elements of s *) | Greater -> hd :: add x tl let rec member x s = match s with [] -> false | hd::tl -> match Elt.compare x hd with Equal -> true (* x belongs to s *) | Less -> false (* x is smaller than all elements of s *) | Greater -> member x tl end;;
module Set : functor (Elt : ORDERED_TYPE) -> sig type element = Elt.t type set = element list val empty : 'a list val add : Elt.t -> Elt.t list -> Elt.t list val member : Elt.t -> Elt.t list -> bool end

通过将 Set 函子应用于实现有序类型的结构,我们就可以获得该类型的集合操作。

# module OrderedString = struct type t = string let compare x y = if x = y then Equal else if x < y then Less else Greater end;;
module OrderedString : sig type t = string val compare : 'a -> 'a -> comparison end
# module StringSet = Set(OrderedString);;
module StringSet : sig type element = OrderedString.t type set = element list val empty : 'a list val add : OrderedString.t -> OrderedString.t list -> OrderedString.t list val member : OrderedString.t -> OrderedString.t list -> bool end
# StringSet.member "bar" (StringSet.add "foo" StringSet.empty);;
- : bool = false

4 函子和类型抽象

就像在 Fifo 示例中一样,隐藏类型 set 的实际实现是一种良好的风格,这样使用该结构的用户就不会依赖于集合是列表,并且我们以后可以切换到另一种更有效的集合表示方式,而不会破坏他们的代码。这可以通过使用合适的函子签名来限制 Set 来实现。

# module type SETFUNCTOR = functor (Elt: ORDERED_TYPE) -> sig type element = Elt.t (* concrete *) type set (* abstract *) val empty : set val add : element -> set -> set val member : element -> set -> bool end;;
module type SETFUNCTOR = functor (Elt : ORDERED_TYPE) -> sig type element = Elt.t type set val empty : set val add : element -> set -> set val member : element -> set -> bool end
# module AbstractSet = (Set : SETFUNCTOR);;
module AbstractSet : SETFUNCTOR
# module AbstractStringSet = AbstractSet(OrderedString);;
module AbstractStringSet : sig type element = OrderedString.t type set = AbstractSet(OrderedString).set val empty : set val add : element -> set -> set val member : element -> set -> bool end
# AbstractStringSet.add "gee" AbstractStringSet.empty;;
- : AbstractStringSet.set = <abstr>

为了更优雅地编写上述类型约束,你可能希望为函子返回的结构的签名命名,然后在约束中使用该签名。

# module type SET = sig type element type set val empty : set val add : element -> set -> set val member : element -> set -> bool end;;
module type SET = sig type element type set val empty : set val add : element -> set -> set val member : element -> set -> bool end
# module WrongSet = (Set : functor(Elt: ORDERED_TYPE) -> SET);;
module WrongSet : functor (Elt : ORDERED_TYPE) -> SET
# module WrongStringSet = WrongSet(OrderedString);;
module WrongStringSet : sig type element = WrongSet(OrderedString).element type set = WrongSet(OrderedString).set val empty : set val add : element -> set -> set val member : element -> set -> bool end
# WrongStringSet.add "gee" WrongStringSet.empty ;;
Error: This expression has type string but an expression was expected of type WrongStringSet.element = WrongSet(OrderedString).element

这里的问题是 SET 以抽象的方式指定了类型 element,因此函子结果中的 element 与其参数中的 t 之间的类型相等性被遗忘了。因此,WrongStringSet.elementstring 不是同一类型,并且 WrongStringSet 的操作不能应用于字符串。如上所示,在签名 SET 中,类型 element 必须声明为与 Elt.t 相等;不幸的是,这在上面是不可能的,因为 SET 在一个 Elt 不存在的上下文中定义。为了克服这个困难,OCaml 提供了一个 with type 结构,它允许用额外的类型相等性来丰富签名。

# module AbstractSet2 = (Set : functor(Elt: ORDERED_TYPE) -> (SET with type element = Elt.t));;
module AbstractSet2 : functor (Elt : ORDERED_TYPE) -> sig type element = Elt.t type set val empty : set val add : element -> set -> set val member : element -> set -> bool end

与简单结构的情况一样,OCaml 提供了另一种语法来定义函子并限制其结果。

module AbstractSet2(Elt: ORDERED_TYPE) : (SET with type element = Elt.t) =
  struct ... end;;

在函子结果中抽象类型组件是一种强大的技术,它提供了高度的类型安全性,我们现在将对此进行说明。考虑一个字符字符串上的排序,它不同于 OrderedString 结构中实现的标准排序。例如,我们在比较字符串时不区分大小写。

# module NoCaseString = struct type t = string let compare s1 s2 = OrderedString.compare (String.lowercase_ascii s1) (String.lowercase_ascii s2) end;;
module NoCaseString : sig type t = string val compare : string -> string -> comparison end
# module NoCaseStringSet = AbstractSet(NoCaseString);;

module NoCaseStringSet : sig type element = NoCaseString.t type set = AbstractSet(NoCaseString).set val empty : set val add : element -> set -> set val member : element -> set -> bool end
# NoCaseStringSet.add "FOO" AbstractStringSet.empty ;;
错误: 此表达式的类型为 AbstractStringSet.set = AbstractSet(OrderedString).set,但期望的表达式类型为 NoCaseStringSet.set = AbstractSet(NoCaseString).set

请注意,这两个类型 AbstractStringSet.setNoCaseStringSet.set 不兼容,并且这两种类型的值不匹配。这是正确行为:即使这两种集合类型都包含相同类型(字符串)的元素,它们也是基于该类型的不同排序构建的,并且操作需要维护不同的不变性(对于标准排序和不区分大小写的排序而言,都必须严格递增)。将 AbstractStringSet 中的操作应用于 NoCaseStringSet.set 类型的值可能会导致错误的结果,或者构建违反 NoCaseStringSet 不变性的列表。

5 模块和分离编译

到目前为止,所有模块示例都已在交互式系统上下文中给出。但是,模块对于大型、批处理编译程序最有用。对于这些程序,将源代码拆分为多个文件(称为编译单元)是一种实际需要,这些文件可以单独编译,从而最大限度地减少更改后的重新编译。

在 OCaml 中,编译单元是结构和签名的特例,并且单元之间关系可以用模块系统轻松解释。编译单元 A 包含两个文件

这两个文件一起定义了一个名为 A 的结构,就像在顶层输入以下定义一样

module A: sig (* contents of file A.mli *) end
        = struct (* contents of file A.ml *) end;;

可以使用 ocamlc -c 命令分别编译定义编译单元的文件(-c 选项表示“仅编译,不要尝试链接”);这会生成编译的接口文件(扩展名为 .cmi)和编译的对象代码文件(扩展名为 .cmo)。当所有单元都编译完成后,可以使用 ocamlc 命令将它们的 .cmo 文件链接在一起。例如,以下命令编译并链接由两个编译单元 AuxMain 组成的程序

$ ocamlc -c Aux.mli                     # produces aux.cmi
$ ocamlc -c Aux.ml                      # produces aux.cmo
$ ocamlc -c Main.mli                    # produces main.cmi
$ ocamlc -c Main.ml                     # produces main.cmo
$ ocamlc -o theprogram Aux.cmo Main.cmo

程序的行为与在顶层输入以下短语完全相同

module Aux: sig (* contents of Aux.mli *) end
          = struct (* contents of Aux.ml *) end;;
module Main: sig (* contents of Main.mli *) end
           = struct (* contents of Main.ml *) end;;

特别是,Main 可以引用 AuxMain.mlMain.mli 中包含的定义和声明可以引用 Aux.ml 中的定义,使用 Aux.ident 表示法,前提是这些定义在 Aux.mli 中导出。

在链接阶段将 .cmo 文件传递给 ocamlc 的顺序决定了模块定义出现的顺序。因此,在上面的示例中,Aux 首先出现,并且 Main 可以引用它,但 Aux 不能引用 Main

请注意,只有顶层结构才能映射到单独编译的文件,而函子或模块类型则不能。但是,所有模块类对象都可以作为结构的组件出现,因此解决方案是将函子或模块类型放在结构内,然后可以将其映射到文件。