模組
先決條件
簡介
在本教學中,我們將探討如何使用和定義模組。
模組是將定義組合在一起的集合。這是組織 OCaml 軟體的基本方法。不同的關注點可以且應該隔離到不同的模組中。
注意:本教學中使用的檔案可以從 Git 儲存庫取得。
基本用法
基於檔案的模組
在 OCaml 中,每一段程式碼都包裝在一個模組中。選擇性地,一個模組本身可以成為另一個模組的子模組,非常像檔案系統中的目錄。
以下程式使用兩個檔案:athens.ml
和 berlin.ml
。每個檔案分別定義一個名為 Athens
和 Berlin
的模組。
以下是檔案 athens.ml
let hello () = print_endline "Hello from Athens"
以下是檔案 berlin.ml
let () = Athens.hello ()
要使用 Dune 編譯它們,至少需要兩個組態檔案
dune-project
檔案包含專案範圍的組態。(lang dune 3.7)
dune
檔案包含實際的建置指令。一個專案可以有多個dune
檔案,每個檔案對應一個包含要建置內容的目錄。在這個例子中,單行就足夠了(executable (name berlin))
建立這些檔案後,建置並執行它們
$ opam exec -- dune build
$ opam exec -- dune exec ./berlin.exe
Hello from Athens
注意:Dune 將建置產生的檔案和原始程式碼的副本儲存在 _build
目錄中,您不應編輯其中的任何內容。OCaml 專案通常包含 bin
和 lib
目錄。與 Unix 不同,它們不包含編譯後的二進位檔案,而是程式和函式庫的原始程式碼。
實際上,opam exec -- dune build
是可選的。執行 opam exec -- dune exec ./berlin.exe
會觸發編譯。請注意,在 opam exec -- dune exec
命令中,參數 ./berlin.exe
不是檔案路徑。此命令表示「執行 ./berlin.ml
檔案的內容」。但是,可執行檔的儲存和命名方式不同。
在專案中,最好使用 dune init project
命令建立 dune
組態檔案和目錄結構。有關此事的更多資訊,請參閱 Dune 文件。
命名與範圍
在 berlin.ml
中,我們使用 Athens.hello
來引用來自 athens.ml
的 hello
。通常,要存取模組中的內容,請使用模組的名稱(始終以大寫字母開頭:Athens
),後跟一個點和您要使用的內容 (hello
)。它可能是值、型別建構子或模組提供的任何內容。
如果您大量使用模組,您可能想要 open
它。這會將模組的定義引入範圍。在我們的範例中,berlin.ml
可以寫成
open Athens
let () = hello ()
使用 open
是可選的。通常,我們不會打開像 List
這樣的模組,因為它提供的名稱與其他模組(例如 Array
或 Option
)提供的名稱相同。像 Printf
這樣的模組提供的名稱不會發生衝突,例如 printf
。將 open Printf
放在檔案的頂部可以避免重複寫入 Printf.printf
。
open Printf
let data = ["a"; "beautiful"; "day"]
let () = List.iter (printf "%s\n") data
標準函式庫是一個名為 Stdlib
的模組。它包含子模組 List
、Option
、Either
等。預設情況下,OCaml 編譯器會打開標準函式庫,就像您在每個檔案的頂部寫入 open Stdlib
一樣。如果您需要選擇退出,請參閱 Dune 文件。
您可以使用 let open ... in
建構在定義中打開模組
# let list_sum_sq m =
let open List in
init m Fun.id |> map (fun i -> i * i) |> fold_left ( + ) 0;;
val list_sum_sq : int -> int = <fun>
模組存取表示法可以應用於整個表示式
# let array_sum_sq m =
Array.(init m Fun.id |> map (fun i -> i * i) |> fold_left ( + ) 0);;
val array_sum_sq : int -> int = <fun>
介面與實作
預設情況下,模組中定義的任何內容都可以從其他模組存取。值、函式、型別或子模組,所有內容都是公開的。可以限制此行為,以避免公開外部不相關的定義。
為此,我們必須區分
- 模組內的定義(模組實作)
- 模組的公開宣告(模組介面)
.ml
檔案包含模組實作;.mli
檔案包含模組介面。預設情況下,當未提供對應的 .mli
檔案時,實作具有預設介面,其中所有內容都是公開的。
將 athens.ml
檔案複製到 cairo.ml
並變更其內容
let message = "Hello from Cairo"
let hello () = print_endline message
就這樣,Cairo
具有以下介面
val message : string
val hello : unit -> unit
明確定義模組介面可以限制預設介面。它充當模組實作的遮罩。cairo.ml
檔案定義 Cairo
的實作。新增 cairo.mli
檔案會定義 Cairo
的介面。不含副檔名的檔案名稱必須相同。
若要將 message
變成私有定義,請不要在 cairo.mli
檔案中列出它
val hello : unit -> unit
(** [hello ()] displays a greeting message. *)
注意:開頭的雙星號表示用於 API 文件工具的註解,例如 odoc
。使用此工具支援的格式記錄 .mli
檔案是一個好習慣。
檔案 delhi.ml
定義呼叫 Cairo
的程式
let () = Cairo.hello ()
更新 dune
檔案,使其允許編譯此範例,並保留先前的範例。
(executables (names berlin delhi))
編譯並執行兩個程式
$ opam exec -- dune exec ./berlin.exe
Hello from Athens
$ opam exec -- dune exec ./delhi.exe
Hello from Cairo
您可以嘗試編譯一個包含以下內容的 delhi.ml
檔案,來檢查 Cairo.message
是否為公開的
let () = print_endline Cairo.message
這會觸發編譯錯誤。
抽象類型與唯讀類型
函式和值的定義可以是公開或私有的。這也適用於類型定義,但還有另外兩種情況。
建立名為 exeter.mli
和 exeter.ml
的檔案,內容如下
介面:exeter.mli
type aleph = Ada | Alan | Alonzo
type gimel
val gimel_of_bool : bool -> gimel
val gimel_flip : gimel -> gimel
val gimel_to_string : gimel -> string
type dalet = private Dennis of int | Donald of string | Dorothy
val dalet_of : (int, string) Either.t option -> dalet
實作:exeter.ml
type aleph = Ada | Alan | Alonzo
type bet = bool
type gimel = Christos | Christine
let gimel_of_bool b = if (b : bet) then Christos else Christine
let gimel_flip = function Christos -> Christine | Christine -> Christos
let gimel_to_string x = "Christ" ^ match x with Christos -> "os" | _ -> "ine"
type dalet = Dennis of int | Donald of string | Dorothy
let dalet_of = function
| None -> Dorothy
| Some (Either.Left x) -> Dennis x
| Some (Either.Right x) -> Donald x
更新 dune
檔案,使其包含三個目標;兩個可執行檔:berlin
和 delhi
;以及一個程式庫 exeter
。
(executables (names berlin delhi) (modules athens berlin cairo delhi))
(library (name exeter) (modules exeter))
執行 opam exec -- dune utop
命令。這會觸發 Exeter
的編譯,啟動 utop
,並載入 Exeter
。
# open Exeter;;
# #show aleph;;
type aleph = Ada | Alan | Alonzo
類型 aleph
是公開的。可以建立或存取值。
# #show bet;;
Unknown element.
類型 bet
是私有的。在它被定義的實作之外不可用,這裡指的是 Exeter
。
# #show gimel;;
type gimel
# Christos;;
Error: Unbound constructor Christos
# #show_val gimel_of_bool;;
val gimel_of_bool : bool -> gimel
# true |> gimel_of_bool |> gimel_to_string;;
- : string = "Christos"
# true |> gimel_of_bool |> gimel_flip |> gimel_to_string;;
- : string = "Christine"
類型 gimel
是*抽象*的。可以建立或操作值,但只能作為函式結果或參數。只有提供的函式 gimel_of_bool
、gimel_flip
和 gimel_to_string
或多型函式可以接收或返回 gimel
值。
# #show dalet;;
type dalet = private Dennis of int | Donald of string | Dorothy
# Donald 42;;
Error: Cannot create values of the private type Exeter.dalet
# dalet_of (Some (Either.Left 10));;
- : dalet = Dennis 10
# let dalet_to_string = function
| Dorothy -> "Dorothy"
| Dennis _ -> "Dennis"
| Donald _ -> "Donald";;
val dalet_to_string : dalet -> string = <fun>
類型 dalet
是*唯讀*的。可以使用模式匹配,但值只能由提供的函式建構,這裡指的是 dalet_of
。
抽象類型和唯讀類型可以是變體(如本節所示)、紀錄或別名。可以存取唯讀紀錄欄位的值,但建立這樣的紀錄需要使用提供的函式。
子模組
子模組實作
模組可以在另一個模組內部定義。這使其成為一個*子模組*。讓我們考慮檔案 florence.ml
和 glasgow.ml
florence.ml
module Hello = struct
let message = "Hello from Florence"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
glasgow.ml
let () =
Florence.Hello.print ();
Florence.print_goodbye ()
來自子模組的定義通過串聯模組名稱來存取,此處為 Florence.Hello.print
。以下是更新後的 dune
檔案,其中包含一個額外的可執行檔
dune
(executables (names berlin delhi) (modules athens berlin cairo delhi))
(executable (name glasgow) (modules florence glasgow))
(library (name exeter) (modules exeter))
帶有簽名的子模組
為了定義子模組的介面,我們可以提供一個*模組簽名*。這在第二個版本的 florence.ml
檔案中完成
module Hello : sig
val print : unit -> unit
end = struct
let message = "Hello"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
第一個版本使 Florence.Hello.message
為公開的。在這個版本中,無法從 glasgow.ml
存取它。
模組簽名是類型
模組簽名對實作所起的作用類似於類型對值所起的作用。這是第三種可能的 florence.ml
檔案寫法
module type HelloType = sig
val print : unit -> unit
end
module Hello : HelloType = struct
let message = "Hello"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
首先,我們定義一個名為 HelloType
的 module type
,它定義了與之前相同的模組介面。我們不是在定義 Hello
模組時提供簽名,而是使用 HelloType
模組類型。
這允許編寫多個模組共用的介面。實作滿足任何列出其某些內容的模組類型。這意味著一個模組可以有多個類型,並且模組類型之間存在子類型關係。
模組操作
顯示模組的介面
您可以使用 OCaml 的頂層介面來查看現有模組的內容,例如 Unit
# #show Unit;;
module Unit :
sig
type t = unit = ()
val equal : t -> t -> bool
val compare : t -> t -> int
val to_string : t -> string
end
OCaml 編譯器工具鏈可用於轉儲 .ml
檔案的預設介面。
$ ocamlc -i cairo.ml
val message : string
val hello : unit -> unit
您也可以使用 Anil Madhavapeddy 的 ocaml-print-intf
工具來執行相同的操作。您必須使用 opam install ocaml-print-intf
來安裝它。您可以選擇
- 在
.cmi
檔案(已編譯的 ML 介面)上呼叫它:ocaml-print-intf cairo.cmi
。 - 使用 Dune 呼叫它:
dune exec -- ocaml-print-intf cairo.ml
如果您使用 Dune,則 .cmi
檔案位於 _build
目錄中。否則,您可以手動編譯以產生它們。命令 ocamlc -c cairo.ml
將建立 cairo.cmo
(可執行位元組碼)和 cairo.cmi
(已編譯的介面)。請參閱編譯 OCaml 專案,了解如何在沒有 Dune 的情況下進行編譯的詳細資訊。
模組包含
假設我們覺得 List
模組缺少一個函式,但我們真的希望它像成為其中一部分一樣。在 extlib.ml
檔案中,我們可以通過使用 include
指示來實現此效果
module List = struct
include Stdlib.List
let uncons = function
| [] -> None
| hd :: tl -> Some (hd, tl)
end
它建立了一個模組 Extlib.List
,其中包含標準 List
模組的所有內容,以及一個新的 uncons
函式。為了覆蓋另一個 .ml
檔案的預設 List
模組,我們需要在開頭新增 open Extlib
。
具狀態的模組
模組可能具有內部狀態。標準程式庫中的 Random
模組就是這種情況。函式 Random.get_state
和 Random.set_state
提供對內部狀態的讀取和寫入存取權限,該狀態是匿名的並且具有抽象類型。
# let s = Random.get_state ();;
val s : Random.State.t = <abstr>
# Random.bits ();;
- : int = 89809344
# Random.bits ();;
- : int = 994326685
# Random.set_state s;;
- : unit = ()
# Random.bits ();;
- : int = 89809344
當您執行此程式碼時,Random.bits
返回的值將會不同。第一次和第三次呼叫返回相同的結果,表明內部狀態已重置。
結論
在 OCaml 中,模組是組織軟體的基本方式。總之,模組是包裹在名稱下的一組定義。這些定義可以是子模組,這允許建立模組層次結構。頂層模組必須是檔案,並且是編譯的單位。每個模組都有一個介面,它是模組公開的定義清單。預設情況下,模組的介面會公開其所有定義,但可以使用介面語法來限制這一點。
更進一步,以下是處理 OCaml 軟體元件的其他方法
- 函子,其作用類似於從模組到模組的函式
- 程式庫,它們是捆綁在一起的已編譯模組
- 套件,它們是安裝和發佈的單位