Previous Up Next

第 2 章  模块系统

本章介绍了 OCaml 的模块系统。

2.1  结构

模块的主要动机是将相关定义(例如数据类型的定义和该类型上的操作)打包在一起,并为这些定义强制赋予一致的命名模式。这样可以避免名称耗尽或意外地混淆名称。这样的包被称为结构(structure),由 structend 构造引入,该结构可以包含任意定义序列。结构通常用 module 绑定来命名。例如,下面是将一种优先队列及其操作打包在一起的结构:

module PrioQueue = struct type priority = int type 'a queue = Empty | Node of priority * 'a * 'a queue * 'a queue let empty = Empty let rec insert queue prio elt = match queue with Empty -> Node(prio, elt, Empty, Empty) | Node(p, e, left, right) -> if prio <= p then Node(prio, elt, insert right p e, left) else Node(p, e, insert right prio elt, left) exception Queue_is_empty let rec remove_top = function Empty -> raise Queue_is_empty | Node(prio, elt, left, Empty) -> left | Node(prio, elt, Empty, right) -> right | Node(prio, elt, (Node(lprio, lelt, _, _) as left), (Node(rprio, relt, _, _) as right)) -> if lprio <= rprio then Node(lprio, lelt, remove_top left, right) else Node(rprio, relt, left, remove_top right) let extract = function Empty -> raise Queue_is_empty | Node(prio, elt, _, _) as queue -> (prio, elt, remove_top queue) end;;
module PrioQueue : sig type priority = int type 'a queue = Empty | Node of priority * 'a * 'a queue * 'a queue val empty : 'a queue val insert : 'a queue -> priority -> 'a -> 'a queue exception Queue_is_empty val remove_top : 'a queue -> 'a queue val extract : 'a queue -> priority * 'a * 'a queue end

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

PrioQueue.insert PrioQueue.empty 1 "hello";;
- : string PrioQueue.queue = PrioQueue.Node (1, "hello", PrioQueue.Empty, PrioQueue.Empty)

另一种方式是打开模块,它将模块中定义的所有标识符都放在当前结构的范围内。

open PrioQueue;;
insert empty 1 "hello";;
- : string PrioQueue.queue = Node (1, "hello", Empty, Empty)

打开模块可以更方便地访问其组件,但代价是更难识别在哪个模块中定义了哪个标识符。特别是,打开的模块可能会覆盖当前作用域中出现的标识符,从而可能导致混淆错误:

let empty = [] open PrioQueue;;
val empty : 'a list = []
let x = 1 :: empty ;;
Error: This expression has type 'a PrioQueue.queue but an expression was expected of type int list

这个问题的部分解决方案是在本地打开模块,使模块的组件仅在相关表达式中可用。这也可以使代码更容易阅读(模块打开语句更接近于使用它的地方)与重构(代码片段更加自包含)。为此我们可以使用如下两种结构:

let open PrioQueue in insert empty 1 "hello";;
- : string PrioQueue.queue = Node (1, "hello", Empty, Empty)

或者

PrioQueue.(insert empty 1 "hello");;
- : string PrioQueue.queue = Node (1, "hello", Empty, Empty)

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

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

可以写作

PrioQueue.[insert empty 1 "hello"];;
- : string PrioQueue.queue list = [Node (1, "hello", Empty, Empty)]

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

module PrioQueueOpt = struct include PrioQueue let remove_top_opt x = try Some(remove_top x) with Queue_is_empty -> None let extract_opt x = try Some(extract x) with Queue_is_empty -> None end;;
module PrioQueueOpt : sig type priority = int type 'a queue = 'a PrioQueue.queue = Empty | Node of priority * 'a * 'a queue * 'a queue val empty : 'a queue val insert : 'a queue -> priority -> 'a -> 'a queue exception Queue_is_empty val remove_top : 'a queue -> 'a queue val extract : 'a queue -> priority * 'a * 'a queue val remove_top_opt : 'a queue -> 'a queue option val extract_opt : 'a queue -> (priority * 'a * 'a queue) option end

2.2  签名

签名是结构的接口。签名指定了结构的哪些组件可以由外部访问,以及暴露哪些类型。签名可以用于隐藏结构的某些组件(例如本地函数定义),或者导出具有受限类型的组件。例如,下面的签名指定了三个优先队列操作:emptyinsertextract,但没有指定辅助函数 remove_top。类似地,它使 queue 类型抽象化(不以具体类型提供其实际表示)。

module type PRIOQUEUE = sig type priority = int (* still concrete *) type 'a queue (* now abstract *) val empty : 'a queue val insert : 'a queue -> int -> 'a -> 'a queue val extract : 'a queue -> int * 'a * 'a queue exception Queue_is_empty end;;
module type PRIOQUEUE = sig type priority = int type 'a queue val empty : 'a queue val insert : 'a queue -> int -> 'a -> 'a queue val extract : 'a queue -> int * 'a * 'a queue exception Queue_is_empty end

通过这个签名限制 PrioQueue 结构会产生 PrioQueue 结构的另一个视图,其中 remove_top 函数不可以被访问,并且优先队列的实际表示是隐藏的:

module AbstractPrioQueue = (PrioQueue : PRIOQUEUE);;
module AbstractPrioQueue : PRIOQUEUE
AbstractPrioQueue.remove_top ;;
Error: Unbound value AbstractPrioQueue.remove_top
AbstractPrioQueue.insert AbstractPrioQueue.empty 1 "hello";;
- : string AbstractPrioQueue.queue = <abstr>

这种限制也可以在结构的定义过程中执行,如:

module PrioQueue = (struct ... end : PRIOQUEUE);;

也可以使用另一种语法完成限制:

module PrioQueue : PRIOQUEUE = struct ... end;;

与模块一样,可以包含一个签名来将其组件复制到当前签名中。例如,我们可以使用 extract_opt 函数扩展 PRIOQUEUE 签名:

module type PRIOQUEUE_WITH_OPT = sig include PRIOQUEUE val extract_opt : 'a queue -> (int * 'a * 'a queue) option end;;
module type PRIOQUEUE_WITH_OPT = sig type priority = int type 'a queue val empty : 'a queue val insert : 'a queue -> int -> 'a -> 'a queue val extract : 'a queue -> int * 'a * 'a queue exception Queue_is_empty val extract_opt : 'a queue -> (int * 'a * 'a queue) option end

2.3  仿函数

仿函数(functor,又称为函子)是从一个模块到另一个模块的“函数”。仿函数允许您创建参数化模块,然后将其他模块作为参数以获得特定的实现。例如,可以参数化 Set 模块,将 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

2.4  仿函数和类型抽象

就像在 PrioQueue 示例中一样,隐藏 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.element 与 string 是不同的类型, 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 ;;
Error: This expression has type AbstractStringSet.set = AbstractSet(OrderedString).set but an expression was expected of type NoCaseStringSet.set = AbstractSet(NoCaseString).set

注意,这两种类型 AbstractStringSet.set 和 NoCaseStringSet.set 是互不兼容的,这两种类型的值也是不匹配的。这是正确的行为:尽管这两种集合类型都包含相同类型(字符串)的元素,但它们是基于该类型的不同顺序构建的,操作需要维护不同的不变式(基于标准排序的严格递增与大小写不敏感的递增)。将 AbstractStringSet 中的操作应用到 NoCaseStringSet.set 类型的值可能会给出不正确的结果,或者构建违背出 NoCaseStringSet 不变式的列表。

2.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 文件链接到一起。例如,下面的命令编译并链接了一个由 Aux 和 Main 编译单元组成的程序:

$ ocamlc -c Aux.mli                     # 生成 aux.cmi
$ ocamlc -c Aux.ml                      # 生成 aux.cmo
$ ocamlc -c Main.mli                    # 生成 main.cmi
$ ocamlc -c Main.ml                     # 生成 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 可以引用 Aux 模块:Main.ml 和 Main.mli 中包含的定义和声明可以通过 Aux.ident 的方式引用 Aux.ml 中由 Aux.mli 所导出的定义。

在链接阶段,ocamlc 参数中 .cmo 文件列表的顺序决定了模块定义生成的顺序。因此,在上面的例子中,Aux 应位于 Main 前面,这样 Main 可以引用 Aux,但是 Aux 不能引用 Main

需要注意,只能将顶级结构映射到单独编译的文件,但仿函数或模块类型不能被直接映射到文件。但是,所有的模块类对象都可以作为结构的组件出现,因此我们可以将仿函数或模块类型放置在结构中,然后将结构映射到文件中。


Previous Up Next