模組

簡介

在本教學中,我們將探討如何使用和定義模組。

模組是將定義組合在一起的集合。這是組織 OCaml 軟體的基本方法。不同的關注點可以且應該隔離到不同的模組中。

注意:本教學中使用的檔案可以從 Git 儲存庫取得。

基本用法

基於檔案的模組

在 OCaml 中,每一段程式碼都包裝在一個模組中。選擇性地,一個模組本身可以成為另一個模組的子模組,非常像檔案系統中的目錄。

以下程式使用兩個檔案:athens.mlberlin.ml。每個檔案分別定義一個名為 AthensBerlin 的模組。

以下是檔案 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 專案通常包含 binlib 目錄。與 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.mlhello。通常,要存取模組中的內容,請使用模組的名稱(始終以大寫字母開頭:Athens),後跟一個點和您要使用的內容 (hello)。它可能是值、型別建構子或模組提供的任何內容。

如果您大量使用模組,您可能想要 open 它。這會將模組的定義引入範圍。在我們的範例中,berlin.ml 可以寫成

open Athens
let () = hello ()

使用 open 是可選的。通常,我們不會打開像 List 這樣的模組,因為它提供的名稱與其他模組(例如 ArrayOption)提供的名稱相同。像 Printf 這樣的模組提供的名稱不會發生衝突,例如 printf。將 open Printf 放在檔案的頂部可以避免重複寫入 Printf.printf

open Printf
let data = ["a"; "beautiful"; "day"]
let () = List.iter (printf "%s\n") data

標準函式庫是一個名為 Stdlib 的模組。它包含子模組 ListOptionEither 等。預設情況下,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.mliexeter.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 檔案,使其包含三個目標;兩個可執行檔:berlindelhi;以及一個程式庫 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_boolgimel_flipgimel_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.mlglasgow.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"

首先,我們定義一個名為 HelloTypemodule 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_stateRandom.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 軟體元件的其他方法

  • 函子,其作用類似於從模組到模組的函式
  • 程式庫,它們是捆綁在一起的已編譯模組
  • 套件,它們是安裝和發佈的單位

幫助改進我們的文件

所有 OCaml 文件都是開放原始碼。發現錯誤或不清楚的地方嗎?提交提取請求。