第四章 具標籤的參數

如果你查看標準函式庫中結尾為 Labels 的模組,你會發現函式型別帶有你在自己定義的函式中沒有的註解。

# ListLabels.map;;
- : f:('a -> 'b) -> 'a list -> 'b list = <fun>
# StringLabels.sub;;
- : string -> pos:int -> len:int -> string = <fun>

這種形式為 name: 的註解稱為標籤。它們旨在為程式碼提供文件說明、允許更多檢查,並為函式應用提供更大的彈性。你可以通過在參數前加上波浪號 ~,在你的程式中為參數提供這種名稱。

# let f ~x ~y = x - y;;
val f : x:int -> y:int -> int = <fun>
# let x = 3 and y = 2 in f ~x ~y;;
- : int = 1

當你想為變數和型別中出現的標籤使用不同的名稱時,你可以使用 ~name: 形式的命名標籤。這也適用於當參數不是變數時。

# let f ~x:x1 ~y:y1 = x1 - y1;;
val f : x:int -> y:int -> int = <fun>
# f ~x:3 ~y:2;;
- : int = 1

標籤遵循與 OCaml 中其他識別符號相同的規則,也就是說你不能使用保留關鍵字(例如 into)作為標籤。

形式參數和實際參數根據它們各自的標籤進行匹配,沒有標籤被解釋為空標籤。這允許在應用程式中交換參數。也可以對任何參數進行部分應用函數,建立一個具有剩餘參數的新函數。

# let f ~x ~y = x - y;;
val f : x:int -> y:int -> int = <fun>
# f ~y:2 ~x:3;;
- : int = 1
# ListLabels.fold_left;;
- : f:('acc -> 'a -> 'acc) -> init:'acc -> 'a list -> 'acc = <fun>
# ListLabels.fold_left [1;2;3] ~init:0 ~f:( + );;
- : int = 6
# ListLabels.fold_left ~init:0;;
- : f:(int -> 'a -> int) -> 'a list -> int = <fun>

如果一個函數的多個參數帶有相同的標籤(或沒有標籤),它們將不會在它們之間交換,順序很重要。但是它們仍然可以與其他參數交換。

# let hline ~x:x1 ~x:x2 ~y = (x1, x2, y);;
val hline : x:'a -> x:'b -> y:'c -> 'a * 'b * 'c = <fun>
# hline ~x:3 ~y:2 ~x:5;;
- : int * int * int = (3, 5, 2)

1 可選參數

具標籤參數的一個有趣的特性是它們可以變成可選的。對於可選參數,問號 ? 取代了非可選參數的波浪號 ~,並且標籤在函式型別中也以 ? 作為前綴。可以為這些可選參數提供預設值。

# let bump ?(step = 1) x = x + step;;
val bump : ?step:int -> int -> int = <fun>
# bump 2;;
- : int = 3
# bump ~step:3 2;;
- : int = 5

一個帶有一些可選參數的函數還必須至少帶有一個非可選參數。決定是否省略可選參數的標準是在函式型別中出現在此可選參數之後的參數的非標籤應用。請注意,如果該參數被標籤,你將只能通過完全應用該函數、省略所有可選參數以及省略所有剩餘參數的標籤來消除可選參數。

# let test ?(x = 0) ?(y = 0) () ?(z = 0) () = (x, y, z);;
val test : ?x:int -> ?y:int -> unit -> ?z:int -> unit -> int * int * int = <fun>
# test ();;
- : ?z:int -> unit -> int * int * int = <fun>
# test ~x:2 () ~z:3 ();;
- : int * int * int = (2, 0, 3)

只要它們同時應用,可選參數也可以與非可選參數或非標籤參數交換。 本質上,可選參數不會與獨立應用的非標籤參數交換。

# test ~y:2 ~x:3 () ();;
- : int * int * int = (3, 2, 0)
# test () () ~z:1 ~y:2 ~x:3;;
- : int * int * int = (3, 2, 1)
# (test () ()) ~z:1 ;;
Error: 這個表達式的型別為 int * int * int 這個不是一個函數;它不能被應用。

在這裡,(test () ()) 已經是 (0,0,0),不能再進一步應用。

可選參數實際上是作為選項型別實現的。如果你不給預設值,你可以存取它們的內部表示,type 'a option = None | Some of 'a。然後你可以在參數存在或不存在時提供不同的行為。

# let bump ?step x = match step with | None -> x * 2 | Some y -> x + y ;;
val bump : ?step:int -> int -> int = <fun>

將可選參數從一個函數呼叫傳遞到另一個函數也可能很有用。這可以通過在應用參數前加上 ? 來完成。這個問號會停用可選參數在選項型別中的包裝。

# let test2 ?x ?y () = test ?x ?y () ();;
val test2 : ?x:int -> ?y:int -> unit -> int * int * int = <fun>
# test2 ?x:None;;
- : ?y:int -> unit -> int * int * int = <fun>

2 標籤和型別推論

雖然它們為編寫函數應用提供了更大的便利性,但標籤和可選參數的缺點是它們不能像語言的其餘部分一樣完全推論出來。

你可以在以下兩個範例中看到這一點。

# let h' g = g ~y:2 ~x:3;;
val h' : (y:int -> x:int -> 'a) -> 'a = <fun>
# h' f ;;
Error: 這個表達式的型別為 x:int -> y:int -> int 但預期的表達式型別為 y:int -> x:int -> 'a
# let bump_it bump x = bump ~step:2 x;;
val bump_it : (step:int -> 'a -> 'b) -> 'a -> 'b = <fun>
# bump_it bump 1 ;;
Error: 這個表達式的型別為 ?step:int -> int -> int 但預期的表達式型別為 step:int -> 'a -> 'b

第一種情況很簡單: g 傳遞 ~y,然後傳遞 ~x,但 f 預期 ~x,然後預期 ~y。 如果我們事先知道 g 的型別為 x:int -> y:int -> int,就可以正確處理這種情況,否則會導致上述型別衝突。 最簡單的解決方法是以標準順序應用形式參數。

第二個範例更微妙:雖然我們希望參數 bump 的型別為 ?step:int -> int -> int,但它被推斷為 step:int -> int -> 'a。 這兩個型別不相容(內部正常參數和可選參數是不同的),當將 bump_it 應用於實際的 bump 時,會發生型別錯誤。

我們在這裡不會詳細解釋型別推論是如何運作的。你只需要理解,上述程式碼沒有足夠的資訊來推導出 gbump 的正確型別。也就是說,單看函式的應用方式,無法知道參數是否為可選,或是哪個才是正確的順序。編譯器採用的策略是假設沒有可選參數,並且應用是按照正確的順序進行。

解決可選參數問題的正確方法是為參數 bump 添加型別註解。

# let bump_it (bump : ?step:int -> int -> int) x = bump ~step:2 x;;
val bump_it : (?step:int -> int -> int) -> int -> int = <fun>
# bump_it bump 1;;
- : int = 3

實際上,當使用方法帶有可選參數的物件時,這類問題最常出現,因此寫出物件參數的型別通常是個好主意。

通常,如果你嘗試將型別與預期不同的參數傳遞給函式,編譯器會產生型別錯誤。然而,在預期型別是非標籤函式型別,而參數是期望可選參數的函式的特殊情況下,編譯器會嘗試轉換參數,使其符合預期型別,方法是對所有可選參數傳遞 None

# let twice f (x : int) = f(f x);;
val twice : (int -> int) -> int -> int = <fun>
# twice bump 2;;
- : int = 8

這種轉換與預期的語意是一致的,包括副作用。也就是說,如果可選參數的應用會產生副作用,這些副作用會延遲到接收到的函式真正應用於參數時才會發生。

3 標籤命名建議

就像名稱一樣,為函式選擇標籤並不是一件容易的事情。好的標籤命名應該

我們在這裡解釋我們在標籤 OCaml 函式庫時應用的規則。

用「物件導向」的方式來說,可以認為每個函式都有一個主要參數,即它的物件,以及其他與其動作相關的參數,即參數。為了允許通過 commuting label 模式的功能來組合函式,該物件不會被標籤。它的角色可以從函式本身清楚地看出來。這些參數會使用提醒它們的性質或角色的名稱來標籤。最好的標籤是結合性質和角色。當這不可能時,應該優先選擇角色,因為性質通常會由型別本身給出。應該避免使用模糊的縮寫。

ListLabels.map : f:('a -> 'b) -> 'a list -> 'b list
UnixLabels.write : file_descr -> buf:bytes -> pos:int -> len:int -> unit

當有幾個性質和角色相同的物件時,它們都不會被標籤。

ListLabels.iter2 : f:('a -> 'b -> unit) -> 'a list -> 'b list -> unit

當沒有首選的物件時,所有參數都會被標籤。

BytesLabels.blit :
  src:bytes -> src_pos:int -> dst:bytes -> dst_pos:int -> len:int -> unit

然而,當只有一個參數時,通常不會被標籤。

BytesLabels.create : int -> bytes

這個原則也適用於多個參數且回傳型別為型別變數的函式,只要每個參數的角色不模稜兩可。標籤這樣的函式可能會導致在使用時嘗試省略標籤時出現尷尬的錯誤訊息,就像我們在 ListLabels.fold_left 中看到的那樣。

以下是一些您將在函式庫中找到的標籤名稱。

標籤意義
f一個要應用的函式
pos字串、陣列或位元組序列中的位置
len長度
buf用作緩衝區的位元組序列或字串
src操作的來源
dst操作的目的地
init迭代器的初始值
cmp比較函式,例如 e.g. Stdlib.compare
mode操作模式或旗標列表

這些都只是建議,但請記住,標籤的選擇對於可讀性至關重要。奇怪的選擇會使程式碼更難以維護。

理想情況下,正確的函式名稱和正確的標籤應該足以理解函式的含義。由於可以使用 OCamlBrowser 或 ocaml toplevel 取得此資訊,因此只有在需要更詳細的規範時才會使用文件。


(章節由 Jacques Garrigue 撰寫)