第 6 章 多型及其限制



本章涵蓋與多型函式和類型的限制相關的更進階問題。在 OCaml 中,有些情況下,類型檢查器推斷的類型可能不如預期通用。這種非通用性可能源於副作用和類型之間的交互作用,或隱式多型遞迴和高階多型的困難。

本章詳細介紹這些情況,如果可能,也會說明如何恢復通用性。

1 弱多型和變異

1.1 弱多型類型

最常見的非通用性範例可能來自多型類型和變異之間的交互作用。當對以下表達式進行類型檢查時,會出現一個簡單的範例

# let store = ref None ;;
val store : '_weak1 option ref = {contents = None}

由於 None 的類型為 'a option,且函式 ref 的類型為 'b -> 'b ref,因此 store 的自然推斷類型為 'a option ref。但是,推斷出的類型 '_weak1 option ref 卻不同。名稱以 _weak 作為前綴的類型變數(如 '_weak1)是弱多型類型變數,有時縮寫為「弱類型變數」。弱類型變數是目前未知之單一類型的佔位符。一旦佔位符類型 '_weak1 背後的特定類型 t 已知,所有出現的 '_weak1 都將被替換為 t。例如,我們可以定義另一個選項參考,並在其中儲存一個 int

# let another_store = ref None ;;
val another_store : '_weak2 option ref = {contents = None}
# another_store := Some 0; another_store ;;
- : int option ref = {contents = Some 0}

another_store 內部儲存 int 之後,another_store 的類型已從 '_weak2 option ref 更新為 int option ref。弱多型類型變數和泛型多型類型變數之間的這種區別,可以保護 OCaml 程式免於不健全性和執行階段錯誤。為了理解不健全性可能來自何處,請考慮這個簡單的函式,如果存在值,它會將值 x 與儲存在 store 參考中的值交換

# let swap store x = match !store with | None -> store := Some x; x | Some y -> store := Some x; y;;
val swap : 'a option ref -> 'a -> 'a = <fun>

我們可以將此函式套用至我們的 store

# let one = swap store 1 let one_again = swap store 2 let two = swap store 3;;
val one : int = 1 val one_again : int = 1 val two : int = 2

在這三次交換之後,儲存的值為 3。到目前為止一切都很好。然後,我們可以嘗試將 3 與更有趣的值(例如函式)交換

# let error = swap store (fun x -> x);;
Error: 此運算式不應該是函式,預期的類型是 int

此時,類型檢查器正確地抱怨說無法交換整數和函式,並且 int 應該始終與另一個 int 交換。此外,類型檢查器會阻止我們手動更改 store 儲存的值的類型

# store := Some (fun x -> x);;
Error: 此運算式不應該是函式,預期的類型是 int

事實上,查看 store 的類型,我們可以看到弱類型 '_weak1 已被類型 int 取代

# store;;
- : int option ref = {contents = Some 3}

因此,在 store 中放置 int 之後,我們無法使用它儲存 int 以外的任何值。更廣泛地說,弱類型可保護程式免於不適當的變異(具有多型類型的值)。

此外,弱類型不能出現在最上層模組的簽章中:類型必須在編譯時已知。否則,不同的編譯單元可能會以不同且不相容的類型取代弱類型。因此,編譯以下小段程式碼

let option_ref = ref None

會產生編譯錯誤

Error: The type of this expression, '_weak1 option ref,
       contains type variables that cannot be generalized

要解決此錯誤,只需新增明確的類型註釋,以指定宣告時的類型即可

let option_ref: int option ref = ref None

無論如何,對於此類全域可變變數,這是一個良好的實踐。否則,它們會挑選出第一次使用的類型。如果此時出現錯誤,則可能會在稍後將正確的使用方式標記為錯誤時,導致混亂的類型錯誤。

1.2 值限制

以模組化方式識別多型類型應被弱類型取代的確切內容是一個難題。事實上,類型系統必須處理函式可能隱藏持久可變狀態的可能性。例如,以下函式使用內部參考來實作延遲的身分函式

# let make_fake_id () = let store = ref None in fun x -> swap store x ;;
val make_fake_id : unit -> 'a -> 'a = <fun>
# let fake_id = make_fake_id();;
val fake_id : '_weak3 -> '_weak3 = <fun>

將此 fake_id 函式套用至具有不同類型的值是不健全的。因此,函式 fake_id 被正確地指定類型 '_weak3 -> '_weak3,而不是 'a -> 'a。同時,應該可以安全地使用本機可變狀態,而不會影響函式的類型。

為了規避這些雙重困難,類型檢查器認為函式傳回的任何值都可能依賴於幕後持久的可變狀態,並且應給予弱類型。對可變值的類型和函式應用結果的這種限制稱為值限制。請注意,此值限制是保守的:有些情況下,值限制過於謹慎,並給予一個可以安全地廣義化為多型類型的值弱類型

# let not_id = (fun x -> x) (fun x -> x);;
val not_id : '_weak4 -> '_weak4 = <fun>

通常,當使用高階函式定義函式時會發生這種情況。為了避免此問題,解決方案是向函式新增明確的引數

# let id_again = fun x -> (fun x -> x) (fun x -> x) x;;
val id_again : 'a -> 'a = <fun>

使用此引數,類型檢查器會將 id_again 視為函式定義,因此可以將其廣義化。這種操作稱為 lambda 演算中的 eta-expansion,有時也以此名稱引用。

1.3 放寬的值限制

針對不必要的弱類型問題,還有另一種部分解決方案,該解決方案直接在類型檢查器中實作。簡而言之,可以證明,僅在協變位置(也稱為正位置)中顯示為類型參數的弱類型,可以安全地廣義化為多型類型。例如,類型 'a list'a 中是協變的

# let f () = [];;
val f : unit -> 'a list = <fun>
# let empty = f ();;
val empty : 'a list = []

請注意,為 empty 推斷的類型為 'a list,而不是在值限制下應該出現的 '_weak5 list

值限制與協變類型參數的此廣義化稱為放寬的值限制。

1.4 變異和值限制

變異描述了類型建構子在子類型化方面的行為方式。例如,考慮類型為 xxy 的一對,其中 xxy 的子類型,表示為 x :> xy

# type x = [ `X ];;
type x = [ `X ]
# type xy = [ `X | `Y ];;
type xy = [ `X | `Y ]

由於 xxy 的子類型,因此我們可以將 x 類型的值轉換為 xy 類型的值

# let x:x = `X;;
val x : x = `X
# let x' = ( x :> xy);;
val x' : xy = `X

類似地,如果我們有一個類型為 x list 的值,我們可以將其轉換為類型為 xy list 的值,因為我們可以逐個轉換每個元素。

# let l:x list = [`X; `X];;
val l : x list = [`X; `X]
# let l' = ( l :> xy list);;
val l' : xy list = [`X; `X]

換句話說,x :> xy 表示 x list :> xy list,因此類型建構子 'a list 在其參數 'a 中是協變的(它保留了子類型關係)。

相反地,如果我們有一個可以處理類型為 xy 的值的函數

# let f: xy -> unit = function | `X -> () | `Y -> ();;
val f : xy -> unit = <fun>

它也可以處理類型為 x 的值

# let f' = (f :> x -> unit);;
val f' : x -> unit = <fun>

請注意,我們可以將 ff' 的類型重寫為

# type 'a proc = 'a -> unit let f' = (f: xy proc :> x proc);;
type 'a proc = 'a -> unit val f' : x proc = <fun>

在這種情況下,我們有 x :> xy 表示 xy proc :> x proc。請注意,第二個子類型關係反轉了 xxy 的順序:類型建構子 'a proc 在其參數 'a 中是逆變的。更廣泛地說,函數類型建構子 'a -> 'b 在其返回類型 'b 中是協變的,在其參數類型 'a 中是逆變的。

類型建構子在其某些類型參數中也可能是不變的,既不是協變也不是逆變。一個典型的例子是參考 (reference)

# let x: x ref = ref `X;;
val x : x ref = {contents = `X}

如果我們可以將 x 強制轉換為類型 xy ref 作為變數 xy,我們可以使用 xy 在參考中儲存值 `Y,然後使用 x 值將此內容讀取為類型 x 的值,這將破壞類型系統。

更廣泛地說,只要類型變數出現在描述可變狀態的位置,它就會變成不變的。作為一個推論,協變變數永遠不會表示可變的位置,並且可以安全地概括化。如需更詳細的描述,有興趣的讀者可以查閱 Jacques Garrigue 關於 http://www.math.nagoya-u.ac.jp/~garrigue/papers/morepoly-long.pdf 的原始文章。

總體而言,寬鬆的值限制和類型參數的協變性有助於避免在許多情況下進行 eta-expansion。

1.5 抽象資料類型

此外,當類型定義公開時,類型檢查器可以自行推斷變異數 (variance) 資訊,並且即使在不知情的情況下也可以從寬鬆的值限制中受益。但是,當定義新的抽象類型時,情況就不再如此了。作為一個例子,我們可以將模組類型集合定義為

# module type COLLECTION = sig type 'a t val empty: unit -> 'a t end module Implementation = struct type 'a t = 'a list let empty ()= [] end;;
module type COLLECTION = sig type 'a t val empty : unit -> 'a t end module Implementation : sig type 'a t = 'a list val empty : unit -> 'a list end
# module List2: COLLECTION = Implementation;;
module List2 : COLLECTION

在這種情況下,當將模組 List2 強制轉換為模組類型 COLLECTION 時,類型檢查器會忘記 'a List2.t'a 中是協變的。因此,寬鬆的值限制不再適用

# List2.empty ();;
- : '_weak5 List2.t = <abstr>

為了保持寬鬆的值限制,我們需要將抽象類型 'a COLLECTION.t 宣告為在 'a 中是協變的

# module type COLLECTION = sig type +'a t val empty: unit -> 'a t end module List2: COLLECTION = Implementation;;
module type COLLECTION = sig type +'a t val empty : unit -> 'a t end module List2 : COLLECTION

然後我們恢復多型性

# List2.empty ();;
- : 'a List2.t = <abstr>

2 多型遞迴

第二個主要的非泛型類別與多型函數的類型推斷問題直接相關。在某些情況下,OCaml 推斷的類型可能不夠通用,無法定義某些遞迴函數,特別是對於作用於非正規代數資料類型的遞迴函數。

對於正規的多型代數資料類型,類型建構子的類型參數在類型定義內是恆定的。例如,我們可以看看任意嵌套的列表定義為

# type 'a regular_nested = List of 'a list | Nested of 'a regular_nested list let l = Nested[ List [1]; Nested [List[2;3]]; Nested[Nested[]] ];;
type 'a regular_nested = List of 'a list | Nested of 'a regular_nested list val l : int regular_nested = Nested [List [1]; Nested [List [2; 3]]; Nested [Nested []]]

請注意,類型建構子 regular_nested 在上面的定義中始終以 'a regular_nested 的形式出現,並具有相同的參數 'a。有了這個類型,可以使用經典的遞迴函數計算最大深度

# let rec maximal_depth = function | List _ -> 1 | Nested [] -> 0 | Nested (a::q) -> 1 + max (maximal_depth a) (maximal_depth (Nested q));;
val maximal_depth : 'a regular_nested -> int = <fun>

非正規遞迴代數資料類型對應於多型代數資料類型,其參數類型在類型定義的左側和右側之間變化。例如,定義一個資料類型以確保所有列表都嵌套在相同深度可能會很有趣

# type 'a nested = List of 'a list | Nested of 'a list nested;;
type 'a nested = List of 'a list | Nested of 'a list nested

直觀上,類型為 'a nested 的值是一個列表的列表…包含 k 個嵌套列表的元素 a 的列表。然後,我們可以將在 regular_depth 上定義的 maximal_depth 函數改編成計算這個 kdepth 函數。首先,我們可以嘗試定義

# let rec depth = function | List _ -> 1 | Nested n -> 1 + depth n;;
Error: This expression has type 'a list nested but an expression was expected of type 'a nested The type variable 'a occurs inside 'a list

這裡的類型錯誤來自於,在定義 depth 期間,類型檢查器首先將類型 'a -> 'b 分配給 depth。在鍵入模式匹配時,'a -> 'b 變成 'a nested -> 'b,然後在鍵入 List 分支後變成 'a nested -> int。但是,當鍵入 Nested 分支中的應用程式 depth n 時,類型檢查器會遇到問題:depth n 應用於 'a list nested,因此它必須具有類型 'a list nested -> 'b。將此約束與先前的約束統一起來會導致不可能的約束 'a list nested = 'a nested。換句話說,在其定義中,由於類型建構子 nested 的非正規性,遞迴函數 depth 應用於具有不同類型 'a 的類型 'a t 的值。這會造成問題,因為類型檢查器僅在函數 depth 的 *定義* 處引入了一個新的類型變數 'a,而在此處,我們需要為函數 depth 的每個 *應用程式* 使用不同的類型變數。

2.1 顯式多型註解

這個難題的解決方案是為類型 'a 使用顯式多型類型註解。

# let rec depth: 'a. 'a nested -> int = function | List _ -> 1 | Nested n -> 1 + depth n;;
val depth : 'a nested -> int = <fun>
# depth ( Nested(List [ [7]; [8] ]) );;
- : int = 2

depth 的類型 'a.'a nested -> int 中,類型變數 'a 是全稱量化的。換句話說,'a.'a nested -> int 的意思是「對於所有類型 'adepth'a nested 的值映射到整數」。而標準類型 'a nested -> int 可以解釋為「讓 'a 成為一個類型變數,然後 depth'a nested 的值映射到整數」。這兩個類型表達式有兩個主要區別。首先,顯式多型註解告訴類型檢查器,每次應用函數 depth 時,它都需要引入一個新的類型變數。這解決了我們在定義函數 depth 時遇到的問題。

其次,它還通知類型檢查器,函數的類型應該是多型的。事實上,如果沒有顯式多型類型註解,以下類型註解是完全有效的

# let sum: 'a -> 'b -> 'c = fun x y -> x + y;;
val sum : int -> int -> int = <fun>

因為 'a'b'c 表示可能或可能不是多型的類型變數。然而,將顯式多型類型與非多型類型統一會出錯

# let sum: 'a 'b 'c. 'a -> 'b -> 'c = fun x y -> x + y;;
Error: This definition has type int -> int -> int which is less general than 'a 'b 'c. 'a -> 'b -> 'c

這裡一個重要的註解是,不需要完全顯式地指定 depth 的類型:只需為全稱量化的類型變數添加註解就足夠了

# let rec depth: 'a. 'a nested -> _ = function | List _ -> 1 | Nested n -> 1 + depth n;;
val depth : 'a nested -> int = <fun>
# depth ( Nested(List [ [7]; [8] ]) );;
- : int = 2

2.2 更多範例

透過顯式多型註解,就可以實作任何僅依賴巢狀列表的結構而不依賴元素類型的遞迴函式。例如,一個更複雜的範例是計算巢狀列表的元素總數

# let len nested = let map_and_sum f = List.fold_left (fun acc x -> acc + f x) 0 in let rec len: 'a. ('a list -> int ) -> 'a nested -> int = fun nested_len n -> match n with | List l -> nested_len l | Nested n -> len (map_and_sum nested_len) n in len List.length nested;;
val len : 'a nested -> int = <fun>
# len (Nested(Nested(List [ [ [1;2]; [3] ]; [ []; [4]; [5;6;7]]; [[]] ])));;
- : int = 7

同樣地,可能需要使用多個顯式多型類型變數,例如計算巢狀列表的列表長度的巢狀列表

# let shape n = let rec shape: 'a 'b. ('a nested -> int nested) -> ('b list list -> 'a list) -> 'b nested -> int nested = fun nest nested_shape -> function | List l -> raise (Invalid_argument "shape requires nested_list of depth greater than 1") | Nested (List l) -> nest @@ List (nested_shape l) | Nested n -> let nested_shape = List.map nested_shape in let nest x = nest (Nested x) in shape nest nested_shape n in shape (fun n -> n ) (fun l -> List.map List.length l ) n;;
val shape : 'a nested -> int nested = <fun>
# shape (Nested(Nested(List [ [ [1;2]; [3] ]; [ []; [4]; [5;6;7]]; [[]] ])));;
- : int nested = Nested (List [[2; 1]; [0; 1; 3]; [0]])

3 高階多型函數

然而,顯式多型註解不足以涵蓋函數的推斷類型不如預期通用的所有情況。當使用多型函數作為高階函數的參數時,也會出現類似的問題。例如,我們可能想要計算兩個巢狀列表的平均深度或長度

# let average_depth x y = (depth x + depth y) / 2;;
val average_depth : 'a nested -> 'b nested -> int = <fun>
# let average_len x y = (len x + len y) / 2;;
val average_len : 'a nested -> 'b nested -> int = <fun>
# let one = average_len (List [2]) (List [[]]);;
val one : int = 1

很自然地會將這兩個定義因式分解為

# let average f x y = (f x + f y) / 2;;
val average : ('a -> int) -> 'a -> 'a -> int = <fun>

然而,average len 的類型不如 average_len 的類型通用,因為它要求第一個和第二個參數的類型相同

# average_len (List [2]) (List [[]]);;
- : int = 1
# average len (List [2]) (List [[]]);;
Error: This expression has type 'a list but an expression was expected of type int

就像先前使用多型遞迴一樣,問題源於類型變數僅在 let 定義的開頭引入。當我們計算 f xf y 時,xy 的類型會統一在一起。為了避免這種統一,我們需要告訴類型檢查器,f 在它的第一個參數中是多型的。在某種意義上,我們希望 average 具有類型

val average: ('a. 'a nested -> int) -> 'a nested -> 'b nested -> int

請注意,此語法在 OCaml 中無效:average 在其某個參數的類型中具有全稱量化類型 'a,而對於多型遞迴,全稱量化類型是在類型其餘部分之前引入的。全稱量化類型的此位置表示 average 是一個二階多型函數。這種高階函數不被 OCaml 直接支援:二階和更高階多型函數的類型推斷是不可判定的;因此,使用這種高階函數需要手動處理這些全稱量化的類型。

在 OCaml 中,有兩種方法可以引入這種顯式全稱量化類型:全稱量化的記錄欄位,

# type 'a nested_reduction = { f:'elt. 'elt nested -> 'a };;
type 'a nested_reduction = { f : 'elt. 'elt nested -> 'a; }
# let boxed_len = { f = len };;
val boxed_len : int nested_reduction = {f = <fun>}

以及全稱量化的物件方法

# let obj_len = object method f:'a. 'a nested -> 'b = len end;;
val obj_len : < f : 'a. 'a nested -> int > = <obj>

為了解決我們的問題,我們可以採用記錄解決方案

# let average nsm x y = (nsm.f x + nsm.f y) / 2 ;;
val average : int nested_reduction -> 'a nested -> 'b nested -> int = <fun>

或物件解決方案

# let average (obj:<f:'a. 'a nested -> _ > ) x y = (obj#f x + obj#f y) / 2 ;;
val average : < f : 'a. 'a nested -> int > -> 'b nested -> 'c nested -> int = <fun>