Modules
基本用法
在OCaml中, 每一段代码都被包成一个模块。一个模块可以选择性地作为另外一个模块的子模块,很像文件系统中的目录-但是我们不经常这样做。
当你写一个程序使用两个文件amodule.ml
和bmodule.ml
,它们中的每一个都自动定义一个模块,名字叫Amodule
和Bmodule
,模块的内容就是你写到文件中的东西。
这里是文件amodule.ml
里面的代码:
let hello () = print_endline "Hello"
还有bmodule.ml
里面的:
Amodule.hello ()
通常文件一个一个编译,让我们来编译:
ocamlopt -c amodule.ml
ocamlopt -c bmodule.ml
ocamlopt -o hello amodule.cmx bmodule.cmx
现在我们有一个很好的可执行文件用来打印 “Hello”。如你所见,如果你要访问一个给定模块的任何东西,你要用模块的名字(通常是大写字母开头)后面跟一个点号,然后是你要用的东西。可能是一个值,一个类型构造器,或者是给定模块能提供的任何东西。
程序库,包括标准库,提供模块的集合。比如,List.iter
表示List
模块中的iter
函数。
好了,如果你正在大量使用一个给定的模块,你可以使这个模块的内容直接可以访问。我们要使用open
指令来达到这个目的。在我们的例子中,bmodule.ml
可以写成这样:
open Amodule;;
hello ();;
注意,人们倾向于避免使用丑陋的“;;”,所以这样写更加普遍:
open Amodule
let () =
hello ()
不管怎样,用不用open
是个人选择的问题。一些模块使用了很多很普遍的名字。List
模块就是这样的例子。通常我们不用open List
。像Printf
的其他模块,提供通常不受冲突的名字,比如printf
。为了避免到处写Printf.printf
,在文件开头放一句open Printf
是有道理的。
以下是一个简短的例子(在toplevel中)。
# open Printf
let my_data = [ "a"; "beautiful"; "day" ]
let () = List.iter (fun s -> printf "%s\n" s) my_data;;
a
beautiful
day
val my_data : string list = ["a"; "beautiful"; "day"]
接口(Interfaces)和签名(Signatures)
一个模块可以给其他程序提供很多功能(函数,类型,子模块,……)。如果没有什么特别指定,在模块中定义的一切可以从外部访问。这么做在小程序中是一般可以的,但是在很多情况下,一个模块更应该只提供一系列有限(但是有用的)接口,而隐藏一些辅助的函数和类型。
为此,我们得定义模块接口,掩盖模块的实现细节。就像模块从 .ml 文件得到,相应的模块接口或者叫签名从 .mli 文件得到。它包含了一个带有类型的值的列表,及其他。以下重新实现amodule.ml
。
let message = "Hello"
let hello () = print_endline message
事实上,Amodule
有下面的接口:
val message : string
val hello : unit -> unit
假设不想让其他模块直接访问message
,我们需要定义一个严格的接口来隐藏它。这是我们的amodule.mli
文件。
val hello : unit -> unit
(** 显示一句问候消息。 *)
(注意,使用 ocamldoc 支持的格式来写 .mli 文件的文档是个好习惯。)
.mli 文件必须在对应的 .ml 文件之前编译。它们用ocamlc
来编译,而 .ml 文件用ocamlopt
来编译成原生码。
ocamlc -c amodule.mli
ocamlopt -c amodule.ml
...
抽象类型(Abstract Types)
类型定义是怎么样的呢?我们已经看到值可以通过把它们的名字和类型放到 .mli 文件的方式来导出。
val hello : unit -> unit
但是模块经常定义新的类型。让我们来定义一个简单的record类型,用来表达一个日期。
type date = { day : int; month : int; year : int }
有四个选择编写 .mli 文件:
- 类型在签名中完全忽略
- 把类型定义拷贝到签名
- 类型做成抽象的:只给出名字
- record的域做成只读的:
type date = private { ... }
在第3种情况中,应该是下面代码这样:
type date
现在,这个模块的用户能操作date
类型的对象,但是他们不能直接访问record的域,他们必须使用模块提供的函数。假设这个模块提供三个函数,一个用来创建一个日期,一个用来计算两个日期之间的间隔,还有一个用年的形式返回一个日期。
type date
val create : ?days:int -> ?months:int -> ?years:int -> unit -> date
val sub : date -> date -> date
val years : date -> float
只有create
和sub
才能用来创建date
record。因此,这个模块的用户不可能创建不规范的record。实际上,我们的实现使用record,但是我们可以修改它,并且确保不破坏任何依赖这个模块的代码!这在一个库中很重要,同一个库之后的版本能够暴露同样的接口,同时可以内部改变实现,包括数据结构。
子模块(Submodules)
模块实现
example.ml
文件自身就可以代表Example
模块。其模块签名是所有定义的符号,又或者可以用一个example.mli
文件来约束它。
一个给定的模块也可以在一个文件中显式地定义,成为当前模块的一个子模块。让我们来看看example.ml
文件:
module Hello = struct
let message = "Hello"
let hello () = print_endline message
end
let goodbye () = print_endline "Goodbye"
let hello_goodbye () =
Hello.hello ();
goodbye ()
从另一个文件中可以看出,很明显我们有两个层次的模块。我们可以这样写:
let () =
Example.Hello.hello ();
Example.goodbye ()
子模块接口
我们可以约束一个给定子模块的接口,这叫做模块类型(Module Types)。我们在example.ml
文件中做一下:
module Hello : sig
val hello : unit -> unit
end =
struct
let message = "Hello"
let hello () = print_endline message
end
(* 在这里 Hello.message 不再能被访问。 *)
let goodbye () = print_endline "Goodbye"
let hello_goodbye () =
Hello.hello ();
goodbye ()
上面Hello
模块的定义和写一对hello.mli
/hello.ml
文件是等价的。把所有东西写在一个代码块里面是不优雅的,所以我们一般选择单独定义模块签名。
module type Hello_type = sig
val hello : unit -> unit
end
module Hello : Hello_type = struct
...
end
Hello_type
是一个命名的模块类型,并且可以重用,用来定义其他的模块接口。
虽然子模块在一些情况下可能有用,但是它们和函子一起用的时候效果比较明显。这个下一部分讲。
函子(Functors,也作仿函数)
函子可能是OCaml中最复杂的特性之一,但是你想成为一个成功的OCaml程序员不需要大量地使用函子。实际上,你可能从来不用自己定义一个函子,不过你确实会在标准库中遇到它们。函子是使用 Set 和 Map 模块的唯一途径,不过使用它们并不困难。
译注:如果你对C衍生的语言比较熟悉而对函数式语言所知甚少,那么可能会对这里的Functors有所误会。在C++,C#,Java都有能够被称作Functor的东西,分别是括号操作符重载,委托(delegate),匿名内部类。但是这里的Functors更加接近lambda表达式,而不是Ocaml中的模块化参数。
什么是函子,为什么需要它们?
函子是用另一个模块来参数化的模块,就像函数是用其他的值,也就是参数,来参数化的值一样。
基本上,函子允许传入一个类型作为参数,这个在OCaml中直接做是不可能地。比如说,我们可以定义一个函子接受一个整数 n,返回一系列只能用在长度为 n 的数组上的操作。如果程序员犯错误,把一个常规的数组传给这些操作,则会造成编译错误。如果我们不是使用这个函子,而是标准数组类型,编译器就不能识别出错误,我们将在未来不确定时刻得到运行时错误,这样会更加糟糕。
怎么使用现存的函子?
标准库定义了Set
模块,它提供了一个Make
函子。这个函子接受一个参数,这个参数是一个提供两样东西的模块:用t
来给出的元素类型,和用compare
给出的比较函数。这个函子的重点是即使程序员犯错误也确保同样的比较函数总是被使用。
举个例子,如果我们要使用整型的集合,我们将会这样做:
module Int_set = Set.Make (struct
type t = int
let compare = compare
end)
对于字符串的集合甚至更简单,因为标准库提供一个String
模块,有一个类型t
和一个函数compare
。如果你仔细地看下来的话,到现在你肯定会猜怎么去创建一个用来操作字符串集合的模块。
# module String_set = Set.Make (String);;
module String_set :
sig
type elt = String.t
type t = Set.Make(String).t
val empty : t
val is_empty : t -> bool
val mem : elt -> t -> bool
val add : elt -> t -> t
val singleton : elt -> t
val remove : elt -> t -> t
val union : t -> t -> t
val inter : t -> t -> t
val disjoint : t -> t -> bool
val diff : t -> t -> t
val compare : t -> t -> int
val equal : t -> t -> bool
val subset : t -> t -> bool
val iter : (elt -> unit) -> t -> unit
val map : (elt -> elt) -> t -> t
val fold : (elt -> 'a -> 'a) -> t -> 'a -> 'a
val for_all : (elt -> bool) -> t -> bool
val exists : (elt -> bool) -> t -> bool
val filter : (elt -> bool) -> t -> t
val partition : (elt -> bool) -> t -> t * t
val cardinal : t -> int
val elements : t -> elt list
val min_elt : t -> elt
val min_elt_opt : t -> elt option
val max_elt : t -> elt
val max_elt_opt : t -> elt option
val choose : t -> elt
val choose_opt : t -> elt option
val split : elt -> t -> t * bool * t
val find : elt -> t -> elt
val find_opt : elt -> t -> elt option
val find_first : (elt -> bool) -> t -> elt
val find_first_opt : (elt -> bool) -> t -> elt option
val find_last : (elt -> bool) -> t -> elt
val find_last_opt : (elt -> bool) -> t -> elt option
val of_list : elt list -> t
val to_seq_from : elt -> t -> elt Seq.t
val to_seq : t -> elt Seq.t
val add_seq : elt Seq.t -> t -> t
val of_seq : elt Seq.t -> t
end
(圆括号是必须的)
怎么定义函子?
带有一个参数的函子可以这样来定义:
module F (X : X_type) = struct
...
end
X
是作为参数被传递的模块,X_type
是它的签名,这个是强制的。
返回模块的签名是可以被约束的,使用这样的语法:
module F (X : X_type) : Y_type =
struct
...
end
或者在.mli
文件中指定:
module F (X : X_type) : Y_type
一般来说,函子的语法理解起来比较困难。最好的方法可能是去看标准库中的源代码set.ml
和map.ml
。
结束语:函子是用来帮助程序员写出正确的程序的,而不是用来提高性能的,甚至会有运行时的损耗,除非使用像 ocamldefun 这样的解函器,ocamldefun 需要访问函子的源代码。
模块实际操作
显示模块接口
在ocaml
的 toplevel 中,下面的技巧可以让一个现存的模块的内容可视化,比如List
:
module M = List;;
另外对于大多数的库有在线的文档,或者你可以使用 labltk(Ocaml的Tk图形用户界面) 做的ocamlbrowser
。
模块包含
如果我们觉得在标准的List
模块中缺少一个函数,但是如果里面有我们确实需要它。在文件extensions.ml
中,我们可以用include
指令来实现这个效果。
module List = struct
include List
let rec optmap f = function
| [] -> []
| hd :: tl ->
match f hd with
| None -> optmap f tl
| Some x -> x :: optmap f tl
end
它创建了Extensions.List
模块,这个模块有标准的List
模块的所有东西,加上一个新的optmap
函数。从另一个文件看,要覆盖默认的List
模块我们所要做的只是在 .ml 文件的开头open Extensions
open Extensions
...
List.optmap ...