第二章 模組系統

本章介紹 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 = []}

在第二種形式中,當本機開啟的主體本身以括號、大括號或方括號分隔時,可以省略本機開啟的括號。例如,

# 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;;

與模組類似,可以包含簽章,將其組件複製到目前的簽章內。例如,我們可以擴展 FIFO 簽章,使其具有 top_optpop_opt 函式

# 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 函子

函子是從模組到模組的「函式」。函子可讓您建立參數化模組,然後提供其他模組作為參數,以取得特定的實作。例如,將集合實作為排序清單的 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 已經在 s 中 *) | Less -> x :: s (* x 小於 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 屬於 s *) | Less -> false (* x 小於 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 (* 具體 *) type set (* 抽象 *) 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: 這個運算式的類型是 string,但預期的運算式類型是 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

與簡單結構的情況一樣,也提供了另一種語法來定義函子並限制它們的結果

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.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)。當所有單元都已編譯時,它們的 .cmo 檔案會使用 ocamlc 命令連結在一起。例如,以下命令會編譯和連結由兩個編譯單元 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.ident 符號參照 Aux.ml 中的定義,前提是這些定義在 Aux.mli 中匯出。

在連結階段將 .cmo 檔案提供給 ocamlc 的順序決定了模組定義發生的順序。因此,在上面的範例中,Aux 首先出現,並且 Main 可以參照它,但 Aux 無法參照 Main

請注意,只有頂層結構才能對應到單獨編譯的檔案,但函子和模組類型都不能。但是,所有模組類物件都可以作為結構的組件出現,因此解決方案是將函子或模組類型放在一個結構內部,然後可以將該結構對應到一個檔案。