第三章 OCaml 中的物件



本章概述 OCaml 的物件導向特性。

請注意,OCaml 中物件、類別和型別之間的關係與 Java 和 C++ 等主流物件導向語言不同,因此您不應假設相似的關鍵字表示相同的含義。物件導向特性在 OCaml 中使用頻率遠低於這些語言。OCaml 有其他通常更合適的替代方案,例如模組和仿函數。事實上,許多 OCaml 程式根本不使用物件。

1 類別和物件

下面的類別 point 定義一個實例變數 x 和兩個方法 get_xmove。實例變數的初始值為 0。變數 x 被宣告為可變的,因此方法 move 可以更改其值。

# class point = object val mutable x = 0 method get_x = x method move d = x <- x + d end;;
class point : object val mutable x : int method get_x : int method move : int -> unit end

我們現在建立一個新的點 p,是 point 類別的實例。

# let p = new point;;
val p : point = <obj>

請注意,p 的型別為 point。這是上面類別定義自動定義的縮寫。它代表物件型別 <get_x : int; move : int -> unit>,列出類別 point 的方法及其型別。

我們現在呼叫 p 的一些方法

# p#get_x;;
- : int = 0
# p#move 3;;
- : unit = ()
# p#get_x;;
- : int = 3

類別主體的評估僅在物件建立時發生。因此,在以下範例中,兩個不同物件的實例變數 x 會初始化為不同的值。

# let x0 = ref 0;;
val x0 : int ref = {contents = 0}
# class point = object val mutable x = incr x0; !x0 method get_x = x method move d = x <- x + d end;;
class point : object val mutable x : int method get_x : int method move : int -> unit end
# new point#get_x;;
- : int = 1
# new point#get_x;;
- : int = 2

類別 point 也可以將 x 座標的初始值抽象化。

# class point = fun x_init -> object val mutable x = x_init method get_x = x method move d = x <- x + d end;;
class point : int -> object val mutable x : int method get_x : int method move : int -> unit end

如同函式定義,上面的定義可以縮寫為

# class point x_init = object val mutable x = x_init method get_x = x method move d = x <- x + d end;;
class point : int -> object val mutable x : int method get_x : int method move : int -> unit end

point 類別的實例現在是一個函式,需要一個初始參數才能建立點物件

# new point;;
- : int -> point = <fun>
# let p = new point 7;;
val p : point = <obj>

當然,參數 x_init 在整個定義主體中都是可見的,包括方法。例如,下面類別中的方法 get_offset 會傳回物件相對於其初始位置的位置。

# class point x_init = object val mutable x = x_init method get_x = x method get_offset = x - x_init method move d = x <- x + d end;;
class point : int -> object val mutable x : int method get_offset : int method get_x : int method move : int -> unit end

可以在定義類別的物件主體之前評估和繫結運算式。這對於強制執行不變式非常有用。例如,可以將點自動調整到網格上最近的點,如下所示

# class adjusted_point x_init = let origin = (x_init / 10) * 10 in object val mutable x = origin method get_x = x method get_offset = x - origin method move d = x <- x + d end;;
class adjusted_point : int -> object val mutable x : int method get_offset : int method get_x : int method move : int -> unit end

(如果 x_init 座標不在網格上,也可以引發例外狀況。)事實上,這裡可以藉由使用 origin 的值呼叫類別 point 的定義來獲得相同的效果。

# class adjusted_point x_init = point ((x_init / 10) * 10);;
class adjusted_point : int -> point

另一種替代方案是在特殊的配置函式中定義調整

# let new_adjusted_point x_init = new point ((x_init / 10) * 10);;
val new_adjusted_point : int -> point = <fun>

然而,前一種模式通常更合適,因為調整的程式碼是類別定義的一部分,並且會被繼承。

此功能提供了可以在其他語言中找到的類別建構函式。可以使用這種方式定義數個建構函式,以建立相同類別但具有不同初始化模式的物件;另一種替代方案是使用初始化器,如第 3.4 節中所述。

2 直接物件

還有另一種更直接的方式來建立物件:在不經過類別的情況下建立它。

其語法與類別運算式完全相同,但結果是單個物件而不是類別。本節其餘部分中描述的所有建構也適用於直接物件。

# let p = object val mutable x = 0 method get_x = x method move d = x <- x + d end;;
val p : < get_x : int; move : int -> unit > = <obj>
# p#get_x;;
- : int = 0
# p#move 3;;
- : unit = ()
# p#get_x;;
- : int = 3

與無法在運算式內定義的類別不同,直接物件可以使用其環境中的變數出現在任何地方。

# let minmax x y = if x < y then object method min = x method max = y end else object method min = y method max = x end;;
val minmax : 'a -> 'a -> < max : 'a; min : 'a > = <fun>

相較於類別,立即物件有兩個缺點:它們的類型不會被縮寫,而且你無法從它們繼承。但這兩個缺點在某些情況下反而是優點,我們將在 3.3 節和 3.10 節中看到。

3 參照自身

方法或初始化器可以調用 self(即當前物件)上的方法。為此,self 必須顯式綁定,這裡綁定到變數 ss 可以是任何識別符號,儘管我們通常會選擇名稱 self。)

# class printable_point x_init = object (s) val mutable x = x_init method get_x = x method move d = x <- x + d method print = print_int s#get_x end;;
class printable_point : int -> object val mutable x : int method get_x : int method move : int -> unit method print : unit end
# let p = new printable_point 7;;
val p : printable_point = <obj>
# p#print;;
7- : unit = ()

動態地,變數 s 在方法調用時綁定。特別是,當類別 printable_point 被繼承時,變數 s 將會正確地綁定到子類別的物件。

關於 self 的一個常見問題是,由於它的類型可能會在子類別中擴展,你無法提前固定它。這是一個簡單的例子。

# let ints = ref [];;
val ints : '_weak1 list ref = {contents = []}
# class my_int = object (self) method n = 1 method register = ints := self :: !ints end ;;
Error: This expression has type < n : int; register : 'a; .. > but an expression was expected of type 'weak1 自體類型無法逃脫其類別

你可以忽略錯誤訊息的前兩行。重要的是最後一行:將 self 放入外部參照會使其無法通過繼承來擴展。我們將在 3.12 節中看到這個問題的解決方法。然而請注意,由於立即物件不可擴展,因此不會發生這個問題。

# let my_int = object (self) method n = 1 method register = ints := self :: !ints end;;
val my_int : < n : int; register : unit > = <obj>

4 初始化器

類別定義中的 let 綁定會在物件建構之前進行評估。也可以在物件建構完成後立即評估表達式。此類程式碼會被寫成稱為初始化器的匿名隱藏方法。因此,它可以存取 self 和實例變數。

# class printable_point x_init = let origin = (x_init / 10) * 10 in object (self) val mutable x = origin method get_x = x method move d = x <- x + d method print = print_int self#get_x initializer print_string "new point at "; self#print; print_newline () end;;
class printable_point : int -> object val mutable x : int method get_x : int method move : int -> unit method print : unit end
# let p = new printable_point 17;;
new point at 10 val p : printable_point = <obj>

初始化器不能被覆蓋。相反地,所有的初始化器都會被依序評估。初始化器對於強制執行不變性特別有用。另一個範例可以在 8.1 節中看到。

5 虛擬方法

可以使用關鍵字 virtual 宣告一個方法而不實際定義它。此方法將在稍後的子類別中提供。包含虛擬方法的類別必須標記為 virtual,且不能被實例化(也就是說,無法建立此類別的物件)。它仍然定義類型縮寫(將虛擬方法視為其他方法。)

# class virtual abstract_point x_init = object (self) method virtual get_x : int method get_offset = self#get_x - x_init method virtual move : int -> unit end;;
class virtual abstract_point : int -> object method get_offset : int method virtual get_x : int method virtual move : int -> unit end
# class point x_init = object inherit abstract_point x_init val mutable x = x_init method get_x = x method move d = x <- x + d end;;
class point : int -> object val mutable x : int method get_offset : int method get_x : int method move : int -> unit end

實例變數也可以宣告為虛擬,其效果與方法相同。

# class virtual abstract_point2 = object val mutable virtual x : int method move d = x <- x + d end;;
class virtual abstract_point2 : object val mutable virtual x : int method move : int -> unit end
# class point2 x_init = object inherit abstract_point2 val mutable x = x_init method get_offset = x - x_init end;;
class point2 : int -> object val mutable x : int method get_offset : int method move : int -> unit end

6 私有方法

私有方法是不會出現在物件介面中的方法。它們只能從相同物件的其他方法中調用。

# class restricted_point x_init = object (self) val mutable x = x_init method get_x = x method private move d = x <- x + d method bump = self#move 1 end;;
class restricted_point : int -> object val mutable x : int method bump : unit method get_x : int method private move : int -> unit end
# let p = new restricted_point 0;;
val p : restricted_point = <obj>
# p#move 10 ;;
Error: This expression has type restricted_point 它沒有方法 move
# p#bump;;
- : unit = ()

請注意,這與 Java 或 C++ 中的私有和受保護方法不同,後者可以從同類別的其他物件中調用。這是 OCaml 中類型和類別之間獨立性的直接結果:兩個不相關的類別可能會產生相同類型的物件,並且在類型層級上無法確保物件來自特定的類別。然而,3.17 節中給出了友元方法的可能編碼。

私有方法會被繼承(它們預設在子類別中可見),除非它們被簽章匹配隱藏,如下所述。

私有方法可以在子類別中設為公開。

# class point_again x = object (self) inherit restricted_point x method virtual move : _ end;;
class point_again : int -> object val mutable x : int method bump : unit method get_x : int method move : int -> unit end

此處的註解 virtual 僅用於提及一個方法而不提供其定義。由於我們沒有新增 private 註解,這會使方法公開,並保留原始定義。

另一個定義是

# class point_again x = object (self : < move : _; ..> ) inherit restricted_point x end;;
class point_again : int -> object val mutable x : int method bump : unit method get_x : int method move : int -> unit end

對 self 的類型約束要求必須有一個公開的 move 方法,這足以覆寫 private

有人可能會認為私有方法在子類中應該保持私有。然而,由於該方法在子類中是可見的,因此始終可以選擇其程式碼並定義一個執行該程式碼的同名方法,因此另一種(更複雜的)解決方案是

# class point_again x = object inherit restricted_point x as super method move = super#move end;;
class point_again : int -> object val mutable x : int method bump : unit method get_x : int method move : int -> unit end

當然,私有方法也可以是虛擬的。那麼,關鍵字的順序必須是這樣:method private virtual

7 類別介面

類別介面是從類別定義中推斷出來的。它們也可以直接定義,並用於限制類別的類型。與類別宣告一樣,它們也定義了一個新的類型縮寫。

# class type restricted_point_type = object method get_x : int method bump : unit end;;
class type restricted_point_type = object method bump : unit method get_x : int end
# fun (x : restricted_point_type) -> x;;
- : restricted_point_type -> restricted_point_type = <fun>

除了程式碼文件之外,類別介面還可用於約束類別的類型。具體的實例變數和具體的私有方法都可以透過類別類型約束來隱藏。但是,公開方法和虛擬成員則不能。

# class restricted_point' x = (restricted_point x : restricted_point_type);;
class restricted_point' : int -> restricted_point_type

或者,等效地

# class restricted_point' = (restricted_point : int -> restricted_point_type);;
class restricted_point' : int -> restricted_point_type

類別的介面也可以在模組簽名中指定,並用於限制模組的推斷簽名。

# module type POINT = sig class restricted_point' : int -> object method get_x : int method bump : unit end end;;
module type POINT = sig class restricted_point' : int -> object method bump : unit method get_x : int end end
# module Point : POINT = struct class restricted_point' = restricted_point end;;
module Point : POINT

8 繼承

我們透過定義一個繼承自點類別的彩色點類別來說明繼承。此類別具有類別 point 的所有實例變數和所有方法,以及一個新的實例變數 c 和一個新的方法 color

# class colored_point x (c : string) = object inherit point x val c = c method color = c end;;
class colored_point : int -> string -> object val c : string val mutable x : int method color : string method get_offset : int method get_x : int method move : int -> unit end
# let p' = new colored_point 5 "red";;
val p' : colored_point = <obj>
# p'#get_x, p'#color;;
- : int * string = (5, "red")

點和彩色點具有不相容的類型,因為點沒有 color 方法。但是,下面的 get_x 函數是一個通用函數,它將 get_x 方法應用於任何具有此方法(以及可能其他一些方法,這些方法在類型中以省略號表示)的物件 p。因此,它適用於點和彩色點。

# let get_succ_x p = p#get_x + 1;;
val get_succ_x : < get_x : int; .. > -> int = <fun>
# get_succ_x p + get_succ_x p';;
- : int = 8

方法不需要事先宣告,如以下範例所示

# let set_x p = p#set_x;;
val set_x : < set_x : 'a; .. > -> 'a = <fun>
# let incr p = set_x p (get_succ_x p);;
val incr : < get_x : int; set_x : int -> 'a; .. > -> 'a = <fun>

9 多重繼承

允許使用多重繼承。只保留方法的最後一個定義:在子類別中重新定義父類別中可見的方法會覆寫父類別中的定義。先前的方法定義可以透過繫結相關的祖先來重複使用。在下面,super 被繫結到祖先 printable_point。名稱 super 是一個偽值識別符號,只能用於呼叫超類別方法,如 super#print

# class printable_colored_point y c = object (self) val c = c method color = c inherit printable_point y as super method! print = print_string "("; super#print; print_string ", "; print_string (self#color); print_string ")" end;;
class printable_colored_point : int -> string -> object val c : string val mutable x : int method color : string method get_x : int method move : int -> unit method print : unit end
# let p' = new printable_colored_point 17 "red";;
new point at (10, red) val p' : printable_colored_point = <obj>
# p'#print;;
(10, red)- : unit = ()

在父類別中隱藏的私有方法不再可見,因此不會被覆寫。由於初始化器被視為私有方法,因此沿著類別階層結構的所有初始化器都會按照引入的順序進行評估。

請注意,為了清楚起見,方法 print 被明確標記為透過使用驚嘆號 ! 註解 method 關鍵字來覆寫另一個定義。如果方法 print 沒有覆寫 printable_pointprint 方法,則編譯器會引發錯誤

# object method! m = () end;;
Error: The method m has no previous definition

此明確的覆寫註解也適用於 valinherit

# class another_printable_colored_point y c c' = object (self) inherit printable_point y inherit! printable_colored_point y c val! c = c' end;;
class another_printable_colored_point : int -> string -> string -> object val c : string val mutable x : int method color : string method get_x : int method move : int -> unit method print : unit end

10 參數化類別

參考儲存格可以實作為物件。naive 的定義無法通過類型檢查

# class oref x_init = object val mutable x = x_init method get = x method set y = x <- y end;;
Error: Some type variables are unbound in this type: class oref : 'a -> object val mutable x : 'a method get : 'a method set : 'a -> unit end The method get has type 'a where 'a is unbound

原因是至少有一個方法具有多型類型(此處為參考儲存格中儲存的值的類型),因此類別應該是參數化的,或者方法類型應該約束為單型類型。類別的單型實例可以定義為

# class oref (x_init:int) = object val mutable x = x_init method get = x method set y = x <- y end;;
class oref : int -> object val mutable x : int method get : int method set : int -> unit end

請注意,由於立即物件不定義類別類型,因此它們沒有此類限制。

# let new_oref x_init = object val mutable x = x_init method get = x method set y = x <- y end;;
val new_oref : 'a -> < get : 'a; set : 'a -> unit > = <fun>

另一方面,多型參考的類別必須在其宣告中明確列出型別參數。類別型別參數會列在 [] 之間。型別參數也必須透過型別約束在類別主體中的某處綁定。

# class ['a] oref x_init = object val mutable x = (x_init : 'a) method get = x method set y = x <- y end;;
class ['a] oref : 'a -> object val mutable x : 'a method get : 'a method set : 'a -> unit end
# let r = new oref 1 in r#set 2; (r#get);;
- : int = 2

宣告中的型別參數實際上可以在類別定義的主體中受到約束。在類別型別中,型別參數的實際值會顯示在 constraint 子句中。

# class ['a] oref_succ (x_init:'a) = object val mutable x = x_init + 1 method get = x method set y = x <- y end;;
class ['a] oref_succ : 'a -> object constraint 'a = int val mutable x : int method get : int method set : int -> unit end

讓我們考慮一個更複雜的範例:定義一個圓形,其中心可以是任何種類的點。我們在 move 方法中加入額外的型別約束,因為沒有任何自由變數必須不被類別型別參數考慮在內。

# class ['a] circle (c : 'a) = object val mutable center = c method center = center method set_center c = center <- c method move = (center#move : int -> unit) end;;
class ['a] circle : 'a -> object constraint 'a = < move : int -> unit; .. > val mutable center : 'a method center : 'a method move : int -> unit method set_center : 'a -> unit end

下面顯示了使用類別定義中的 constraint 子句的 circle 的替代定義。下面在 constraint 子句中使用的型別 #point 是由類別 point 的定義產生的縮寫。此縮寫會與屬於類別 point 子類別的任何物件的型別統一。它實際上會展開為 < get_x : int; move : int -> unit; .. >。這導致了 circle 的以下替代定義,它對其引數具有稍微更強的約束,因為我們現在期望 center 具有方法 get_x

# class ['a] circle (c : 'a) = object constraint 'a = #point val mutable center = c method center = center method set_center c = center <- c method move = center#move end;;
class ['a] circle : 'a -> object constraint 'a = #point val mutable center : 'a method center : 'a method move : int -> unit method set_center : 'a -> unit end

類別 colored_circle 是類別 circle 的特殊版本,它要求中心的型別與 #colored_point 統一,並新增一個方法 color。請注意,在特殊化參數化類別時,必須始終明確給出型別參數的實例。它再次寫在 [] 之間。

# class ['a] colored_circle c = object constraint 'a = #colored_point inherit ['a] circle c method color = center#color end;;
class ['a] colored_circle : 'a -> object constraint 'a = #colored_point val mutable center : 'a method center : 'a method color : string method move : int -> unit method set_center : 'a -> unit end

11 多型方法

雖然參數化的類別在其內容中可能是多型的,但它們不足以允許多型方法的使用。

一個經典的範例是定義一個迭代器。

# List.fold_left;;
- : ('acc -> 'a -> 'acc) -> 'acc -> 'a list -> 'acc = <fun>
# class ['a] intlist (l : int list) = object method empty = (l = []) method fold f (accu : 'a) = List.fold_left f accu l end;;
class ['a] intlist : int list -> object method empty : bool method fold : ('a -> int -> 'a) -> 'a -> 'a end

乍看之下,我們似乎有一個多型迭代器,但是這在實務上行不通。

# let l = new intlist [1; 2; 3];;
val l : '_weak2 intlist = <obj>
# l#fold (fun x y -> x+y) 0;;
- : int = 6
# l;;
- : int intlist = <obj>
# l#fold (fun s x -> s ^ Int.to_string x ^ " ") "" ;;
Error: This expression has type int but an expression was expected of type string

我們的迭代器運作正常,如其第一次用於求和所示。然而,由於物件本身不是多型的(只有它們的建構函式是),使用 fold 方法會為這個個別物件固定其型別。我們下一次嘗試將其用作字串迭代器會失敗。

這裡的問題在於量化位置錯誤:我們希望多型的不是類別,而是 fold 方法。這可以透過在方法定義中給定一個明確的多型型別來實現。

# class intlist (l : int list) = object method empty = (l = []) method fold : 'a. ('a -> int -> 'a) -> 'a -> 'a = fun f accu -> List.fold_left f accu l end;;
class intlist : int list -> object method empty : bool method fold : ('a -> int -> 'a) -> 'a -> 'a end
# let l = new intlist [1; 2; 3];;
val l : intlist = <obj>
# l#fold (fun x y -> x+y) 0;;
- : int = 6
# l#fold (fun s x -> s ^ Int.to_string x ^ " ") "";;
- : string = "1 2 3 "

正如您在編譯器顯示的類別型別中所看到的,雖然多型方法型別在類別定義中必須完全明確(出現在方法名稱之後),但量化的型別變數可以在類別描述中保持隱含。為什麼要求型別明確?問題在於 (int -> int -> int) -> int -> int 也會是 fold 的有效型別,並且它恰好與我們給定的多型型別不相容(自動實例化僅適用於頂層型別變數,而不適用於內部量詞,在內部量詞中,它會變成一個無法判定的問題。)因此,編譯器無法在兩個型別之間選擇,必須有人協助。

然而,如果類型已透過繼承或對 self 的類型約束得知,則可以在類別定義中完全省略類型。以下是一個方法覆寫的範例。

# class intlist_rev l = object inherit intlist l method! fold f accu = List.fold_left f accu (List.rev l) end;;

以下慣用法將描述與定義分離。

# class type ['a] iterator = object method fold : ('b -> 'a -> 'b) -> 'b -> 'b end;;
# class intlist' l = object (self : int #iterator) method empty = (l = []) method fold f accu = List.fold_left f accu l end;;

請注意此處的 (self : int #iterator) 慣用法,它確保此物件實作了 iterator 介面。

多型方法的呼叫方式與一般方法完全相同,但您應注意類型推斷的一些限制。也就是說,只有在呼叫點已知多型方法的類型時,才能呼叫該方法。否則,該方法將被假定為單型,並給予不相容的類型。

# let sum lst = lst#fold (fun x y -> x+y) 0;;
val sum : < fold : (int -> int -> int) -> int -> 'a; .. > -> 'a = <fun>
# sum l ;;
Error: This expression has type intlist but an expression was expected of type < fold : (int -> int -> int) -> int -> 'b; .. > The method fold has type 'a. ('a -> int -> 'a) -> 'a -> 'a, but the expected method type was (int -> int -> int) -> int -> 'b

解決方法很簡單:您應該在參數上加上類型約束。

# let sum (lst : _ #iterator) = lst#fold (fun x y -> x+y) 0;;
val sum : int #iterator -> int = <fun>

當然,約束也可以是顯式的方法類型。只需要量化變數的出現。

# let sum lst = (lst : < fold : 'a. ('a -> _ -> 'a) -> 'a -> 'a; .. >)#fold (+) 0;;
val sum : < fold : 'a. ('a -> int -> 'a) -> 'a -> 'a; .. > -> int = <fun>

多型方法的另一個用途是允許方法引數中某種形式的隱式子類型化。我們已經在 3.8 節中看到,某些函數可能在其引數的類別中是多型的。這可以擴展到方法。

# class type point0 = object method get_x : int end;;
class type point0 = object method get_x : int end
# class distance_point x = object inherit point x method distance : 'a. (#point0 as 'a) -> int = fun other -> abs (other#get_x - x) end;;
class distance_point : int -> object val mutable x : int method distance : #point0 -> int method get_offset : int method get_x : int method move : int -> unit end
# let p = new distance_point 3 in (p#distance (new point 8), p#distance (new colored_point 1 "blue"));;
- : int * int = (5, 2)

請注意此處我們必須使用的特殊語法 (#point0 as 'a),以量化 #point0 的可擴展部分。至於變數綁定器,它可以在類別規格中省略。如果要在物件欄位內使用多型,則必須獨立量化。

# class multi_poly = object method m1 : 'a. (< n1 : 'b. 'b -> 'b; .. > as 'a) -> _ = fun o -> o#n1 true, o#n1 "hello" method m2 : 'a 'b. (< n2 : 'b -> bool; .. > as 'a) -> 'b -> _ = fun o x -> o#n2 x end;;
class multi_poly : object method m1 : < n1 : 'b. 'b -> 'b; .. > -> bool * string method m2 : < n2 : 'b -> bool; .. > -> 'b -> bool end

在方法 m1 中,o 必須是一個至少具有一個方法 n1 的物件,而該方法本身是多型的。在方法 m2 中,n2x 的引數必須具有相同的類型,該類型與 'a 在同一層級量化。

12 使用強制轉換

子類型化絕不是隱含的。但是,有兩種方法可以執行子類型化。最通用的結構是完全顯式的:必須給出類型強制轉換的定義域和對應域。

我們已經看到,點和彩色點具有不相容的類型。例如,它們不能混合在同一個列表中。但是,彩色點可以強制轉換為點,隱藏其 color 方法

# let colored_point_to_point cp = (cp : colored_point :> point);;
val colored_point_to_point : colored_point -> point = <fun>
# let p = new point 3 and q = new colored_point 4 "blue";;
val p : point = <obj> val q : colored_point = <obj>
# let l = [p; (colored_point_to_point q)];;
val l : point list = [<obj>; <obj>]

只有當 tt' 的子類型時,才能將類型為 t 的物件視為類型為 t' 的物件。例如,不能將點視為彩色點。

# (p : point :> colored_point);;
Error: Type point = < get_offset : int; get_x : int; move : int -> unit > is not a subtype of colored_point = < color : string; get_offset : int; get_x : int; move : int -> unit > The first object type has no method color

事實上,在沒有執行時期檢查的情況下縮小強制轉換是不安全的。執行時期類型檢查可能會引發例外,並且它們需要在執行時期存在類型資訊,而這在 OCaml 系統中並非如此。由於這些原因,該語言中沒有這種操作可用。

請注意,子類型化和繼承沒有關聯。繼承是類別之間的語法關係,而子類型化是類型之間的語義關係。例如,彩色點的類別可以直接定義,而無需繼承點的類別;彩色點的類型將保持不變,因此仍然是點的子類型。

強制轉換的定義域通常可以省略。例如,可以定義

# let to_point cp = (cp :> point);;
val to_point : #point -> point = <fun>

在這種情況下,函數 colored_point_to_point 是函數 to_point 的一個實例。然而,情況並非總是如此。完全顯式的強制轉換更精確,有時是不可避免的。例如,考慮以下類別

# class c0 = object method m = {< >} method n = 0 end;;
class c0 : object ('a) method m : 'a method n : int end

物件類型 c0<m : 'a; n : int> as 'a 的縮寫。現在考慮類型宣告

# class type c1 = object method m : c1 end;;
class type c1 = object method m : c1 end

物件類型 c1 是類型 <m : 'a> as 'a 的縮寫。從類型為 c0 的物件到類型為 c1 的物件的強制轉換是正確的。

# fun (x:c0) -> (x : c0 :> c1);;
- : c0 -> c1 = <fun>

然而,強制轉換的定義域並非總是能被省略。在這種情況下,解決方案是使用明確的形式。有時,更改類別類型的定義也可以解決問題。

# class type c2 = object ('a) method m : 'a end;;
class type c2 = object ('a) method m : 'a end
# fun (x:c0) -> (x :> c2);;
- : c0 -> c2 = <fun>

雖然類別類型 c1c2 不同,但物件類型 c1c2 都會展開成相同的物件類型(相同的方法名稱和類型)。然而,當強制轉換的定義域被隱式省略,且其值域是已知類別類型的縮寫時,則會使用類別類型,而非物件類型,來推導強制轉換函式。這允許在大多數情況下,當從子類別強制轉換到其超類別時,可以隱式地保留定義域。強制轉換的類型始終可以如下所示:

# let to_c1 x = (x :> c1);;
val to_c1 : < m : #c1; .. > -> c1 = <fun>
# let to_c2 x = (x :> c2);;
val to_c2 : #c2 -> c2 = <fun>

請注意這兩個強制轉換之間的差異:在 to_c2 的情況下,類型 #c2 = < m : 'a; .. > as 'a 是多型遞迴的(根據 c2 的類別類型中的明確遞迴);因此,將此強制轉換應用於類別 c0 的物件是成功的。另一方面,在第一種情況下,c1 僅展開和展開兩次以獲得 < m : < m : c1; .. >; .. > (請記住 #c1 = < m : c1; .. >),而沒有引入遞迴。您也可以注意到,to_c2 的類型是 #c2 -> c2,而 to_c1 的類型比 #c1 -> c1 更通用。這並非總是如此,因為有些類別類型的 #c 的某些實例不是 c 的子類型,如第3.16節中所述。然而,對於無參數類別,強制轉換 (_ :> c) 始終比 (_ : #c :> c) 更通用。

當人們嘗試定義對類別 c 的強制轉換,同時定義類別 c 時,可能會發生常見的問題。問題的原因在於類型縮寫尚未完全定義,因此其子類型不明確。然後,強制轉換 (_ :> c)(_ : #c :> c) 被視為恆等函式,如下所示:

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

因此,如果將強制轉換應用於 self,如下例所示,則 self 的類型會與封閉類型 c 統一(封閉物件類型是不帶省略符號的物件類型)。這會約束 self 的類型為封閉類型,因此被拒絕。實際上,self 的類型不能是封閉的:這會阻止該類別的任何進一步擴展。因此,當此類型與另一類型的統一導致封閉物件類型時,會產生類型錯誤。

# class c = object method m = 1 end and d = object (self) inherit c method n = 2 method as_c = (self :> c) end;;
Error: This expression cannot be coerced to type c = < m : int >; it has type < as_c : c; m : int; n : int; .. > but is here used with type c Self type cannot escape its class

然而,此問題最常見的實例,將 self 強制轉換為其目前的類別,會被類型檢查器偵測為特殊情況,並正確地輸入。

# class c = object (self) method m = (self :> c) end;;
class c : object method m : c end

這允許使用以下慣用語法,保留屬於類別或其子類別的所有物件的清單。

# let all_c = ref [];;
val all_c : '_weak3 list ref = {contents = []}
# class c (m : int) = object (self) method m = m initializer all_c := (self :> c) :: !all_c end;;
class c : int -> object method m : int end

這個慣用語法可以反過來用於檢索類型已被弱化的物件。

# let rec lookup_obj obj = function [] -> raise Not_found | obj' :: l -> if (obj :> < >) = (obj' :> < >) then obj' else lookup_obj obj l ;;
val lookup_obj : < .. > -> (< .. > as 'a) list -> 'a = <fun>
# let lookup_c obj = lookup_obj obj !all_c;;
val lookup_c : < .. > -> < m : int > = <fun>

我們在此處看到的類型 < m : int > 只是 c 的展開,因為使用了參考;我們已成功取回類型為 c 的物件。


透過首先使用類別類型定義縮寫,通常可以避免先前的強制轉換問題。

# class type c' = object method m : int end;;
class type c' = object method m : int end
# class c : c' = object method m = 1 end and d = object (self) inherit c method n = 2 method as_c = (self :> c') end;;
class c : c' and d : object method as_c : c' method m : int method n : int end

也可以使用虛擬類別。從此類別繼承會同時強制 c 的所有方法與 c' 的方法具有相同的類型。

# class virtual c' = object method virtual m : int end;;
class virtual c' : object method virtual m : int end
# class c = object (self) inherit c' method m = 1 end;;
class c : object method m : int end

人們可能會考慮直接定義類型縮寫。

# type c' = <m : int>;;

然而,無法以類似的方式直接定義縮寫 #c'。它只能透過類別或類別類型定義來定義。這是因為 # 縮寫帶有一個無法明確命名的隱式匿名變數 .. 。你最接近的方式是:

# type 'a c'_class = 'a constraint 'a = < m : int; .. >;;

帶有一個額外的類型變數來捕獲開放物件類型。

13 函數式物件

可以編寫一個不對實例變數進行賦值的類別 point 版本。覆蓋建構 {< ... >} 會傳回「self」(也就是目前的物件)的副本,可能會更改某些實例變數的值。

# class functional_point y = object val x = y method get_x = x method move d = {< x = x + d >} method move_to x = {< x >} end;;
class functional_point : int -> object ('a) val x : int method get_x : int method move : int -> 'a method move_to : int -> 'a end
# let p = new functional_point 7;;
val p : functional_point = <obj>
# p#get_x;;
- : int = 7
# (p#move 3)#get_x;;
- : int = 10
# (p#move_to 15)#get_x;;
- : int = 15
# p#get_x;;
- : int = 7

如同記錄一樣,{< x >} 的形式是 {< x = x >} 的省略版本,避免重複使用實例變數名稱。請注意,類型縮寫 functional_point 是遞迴的,這可以在 functional_point 的類別類型中看到:self 的類型是 'a,而 'a 出現在方法 move 的類型中。

上面 functional_point 的定義與以下定義不等價

# class bad_functional_point y = object val x = y method get_x = x method move d = new bad_functional_point (x+d) method move_to x = new bad_functional_point x end;;
class bad_functional_point : int -> object val x : int method get_x : int method move : int -> bad_functional_point method move_to : int -> bad_functional_point end

雖然這兩個類別的物件行為相同,但它們的子類別的物件將會不同。在 bad_functional_point 的子類別中,方法 move 會持續回傳父類別的物件。相反地,在 functional_point 的子類別中,方法 move 會回傳子類別的物件。

功能性更新經常與二元方法一起使用,如第 8.2.1 節所述。

14 物件的複製

物件也可以被複製,無論它們是功能性的還是命令式的。函式庫函式 Oo.copy 會建立物件的淺層副本。也就是說,它會回傳一個新的物件,該物件具有與其引數相同的方法和實例變數。實例變數會被複製,但是它們的內容是共享的。將新的值指派給副本的實例變數(使用方法呼叫)不會影響原始物件的實例變數,反之亦然。更深層的指派(例如,如果實例變數是參考儲存格)當然會影響原始物件和副本。

Oo.copy 的類型如下

# Oo.copy;;
- : (< .. > as 'a) -> 'a = <fun>

該類型中的關鍵字 as 將類型變數 'a 繫結到物件類型 < .. >。因此,Oo.copy 接受一個具有任何方法(以省略號表示)的物件,並回傳一個相同類型的物件。Oo.copy 的類型與類型 < .. > -> < .. > 不同,因為每個省略號都代表不同的方法集合。省略號實際上是作為類型變數運作。

# let p = new point 5;;
val p : point = <obj>
# let q = Oo.copy p;;
val q : point = <obj>
# q#move 7; (p#get_x, q#get_x);;
- : int * int = (5, 12)

事實上,Oo.copy p 的行為會如同 p#copy,假設在 p 的類別中已定義了主體為 {< >} 的公用方法 copy

可以使用泛型比較函式 =<> 來比較物件。當且僅當兩個物件在實體上相等時,它們才相等。特別是,物件及其副本不相等。

# let q = Oo.copy p;;
val q : point = <obj>
# p = q, p = p;;
- : bool * bool = (false, true)

其他泛型比較,例如(<, <=, ...)也可以在物件上使用。關係 < 定義了物件上未指定但嚴格的排序。一旦建立了兩個物件,它們之間的排序關係就會永久固定,並且不受欄位突變的影響。

複製和覆寫之間有非空的交集。當在物件內使用且不覆寫任何欄位時,它們是可互換的。

# class copy = object method copy = {< >} end;;
class copy : object ('a) method copy : 'a end
# class copy = object (self) method copy = Oo.copy self end;;
class copy : object ('a) method copy : 'a end

只有覆寫才能用來實際覆寫欄位,而且只有 Oo.copy 原語才能在外部使用。

複製也可以用來提供儲存和還原物件狀態的功能。

# class backup = object (self : 'mytype) val mutable copy = None method save = copy <- Some {< copy = None >} method restore = match copy with Some x -> x | None -> self end;;
class backup : object ('a) val mutable copy : 'a option method restore : 'a method save : unit end

上述定義只會備份一層。可以使用多重繼承將備份功能新增到任何類別。

# class ['a] backup_ref x = object inherit ['a] oref x inherit backup end;;
class ['a] backup_ref : 'a -> object ('b) val mutable copy : 'b option val mutable x : 'a method get : 'a method restore : 'b method save : unit method set : 'a -> unit end
# let rec get p n = if n = 0 then p # get else get (p # restore) (n-1);;
val get : (< get : 'b; restore : 'a; .. > as 'a) -> int -> 'b = <fun>
# let p = new backup_ref 0 in p # save; p # set 1; p # save; p # set 2; [get p 0; get p 1; get p 2; get p 3; get p 4];;
- : int list = [2; 1; 1; 1; 1]

我們可以定義一個保留所有副本的備份變體。(我們還新增了一個方法 clear 來手動清除所有副本。)

# class backup = object (self : 'mytype) val mutable copy = None method save = copy <- Some {< >} method restore = match copy with Some x -> x | None -> self method clear = copy <- None end;;
class backup : object ('a) val mutable copy : 'a option method clear : unit method restore : 'a method save : unit end
# class ['a] backup_ref x = object inherit ['a] oref x inherit backup end;;
class ['a] backup_ref : 'a -> object ('b) val mutable copy : 'b option val mutable x : 'a method clear : unit method get : 'a method restore : 'b method save : unit method set : 'a -> unit end
# let p = new backup_ref 0 in p # save; p # set 1; p # save; p # set 2; [get p 0; get p 1; get p 2; get p 3; get p 4];;
- : int list = [2; 1; 0; 0; 0]

15 遞迴類別

遞迴類別可用來定義類型相互遞迴的物件。

# class window = object val mutable top_widget = (None : widget option) method top_widget = top_widget end and widget (w : window) = object val window = w method window = window end;;
class window : object val mutable top_widget : widget option method top_widget : widget option end and widget : window -> object val window : window method window : window end

儘管它們的類型是相互遞迴的,但類別 widgetwindow 本身是獨立的。

16 二元方法

二元方法是一種將自身類型作為參數的方法。下面的 comparable 類別是一個範本,用於具有二元方法 leq 的類別,其類型為 'a -> bool,其中類型變數 'a 綁定到自身的類型。因此,#comparable 會展開為 < leq : 'a -> bool; .. > as 'a。我們在這裡看到綁定符 as 也允許寫入遞迴類型。

# class virtual comparable = object (_ : 'a) method virtual leq : 'a -> bool end;;
class virtual comparable : object ('a) method virtual leq : 'a -> bool end

接著我們定義 comparable 的子類別 moneymoney 類別只是將浮點數包裝為可比較的物件。1 我們將在下面使用更多操作來擴展 money。我們必須在類別參數 x 上使用類型約束,因為基本運算符 <= 在 OCaml 中是多型的函數。inherit 子句確保此類別的物件類型是 #comparable 的實例。

# class money (x : float) = object inherit comparable val repr = x method value = repr method leq p = repr <= p#value end;;
class money : float -> object ('a) val repr : float method leq : 'a -> bool method value : float end

請注意,類型 money 不是類型 comparable 的子類型,因為自身類型以逆變的位置出現在方法 leq 的類型中。實際上,類別 money 的物件 m 具有一個方法 leq,該方法期望類型為 money 的參數,因為它會存取其 value 方法。將 m 視為類型 comparable 會允許使用不具有方法 value 的參數來呼叫 m 上的方法 leq,這將會是一個錯誤。

同樣地,下面的類型 money2 也不是類型 money 的子類型。

# class money2 x = object inherit money x method times k = {< repr = k *. repr >} end;;
class money2 : float -> object ('a) val repr : float method leq : 'a -> bool method times : float -> 'a method value : float end

然而,可以定義操作類型為 moneymoney2 的物件的函式:函式 min 將會回傳任何兩個類型與 #comparable 統一的物件的最小值。min 的類型與 #comparable -> #comparable -> #comparable 不同,因為縮寫 #comparable 隱藏了一個類型變數(省略符號)。此縮寫的每次出現都會產生一個新的變數。

# let min (x : #comparable) y = if x#leq y then x else y;;
val min : (#comparable as 'a) -> 'a -> 'a = <fun>

此函式可以應用於類型為 moneymoney2 的物件。

# (min (new money 1.3) (new money 3.1))#value;;
- : float = 1.3
# (min (new money2 5.0) (new money2 3.14))#value;;
- : float = 3.14

可以在 8.2.18.2.3 節中找到更多二元方法的範例。

請注意方法 times 中使用的覆寫。寫入 new money2 (k *. repr) 而不是 {< repr = k *. repr >} 在繼承方面會表現不好:在 money2 的子類別 money3 中,times 方法將會回傳類別 money2 的物件,而不是預期的類別 money3 的物件。

money 類別自然可以攜帶另一個二元方法。這是一個直接的定義

# class money x = object (self : 'a) val repr = x method value = repr method print = print_float repr method times k = {< repr = k *. x >} method leq (p : 'a) = repr <= p#value method plus (p : 'a) = {< repr = x +. p#value >} end;;
class money : float -> object ('a) val repr : float method leq : 'a -> bool method plus : 'a -> 'a method print : unit method times : float -> 'a method value : float end

17 朋友

上面的類別 money 揭示了二元方法中經常出現的問題。為了與相同類別的其他物件互動,必須使用類似於 value 的方法來揭示 money 物件的表示形式。如果我們移除所有二元方法(此處為 plusleq),則可以透過也移除 value 方法,輕鬆地將表示形式隱藏在物件內部。但是,只要某些二元方法需要存取相同類別(而非自身)的物件表示形式,就不可能這樣做。

# class safe_money x = object (self : 'a) val repr = x method print = print_float repr method times k = {< repr = k *. x >} end;;
class safe_money : float -> object ('a) val repr : float method print : unit method times : float -> 'a end

在這裡,物件的表示形式只有特定物件才知道。為了使相同類別的其他物件可以使用它,我們被迫讓全世界都可以使用它。但是,我們可以使用模組系統輕鬆地限制表示形式的可見性。

# module type MONEY = sig type t class c : float -> object ('a) val repr : t method value : t method print : unit method times : float -> 'a method leq : 'a -> bool method plus : 'a -> 'a end end;;
# module Euro : MONEY = struct type t = float class c x = object (self : 'a) val repr = x method value = repr method print = print_float repr method times k = {< repr = k *. x >} method leq (p : 'a) = repr <= p#value method plus (p : 'a) = {< repr = x +. p#value >} end end;;

可以在 8.2.3 節中找到朋友函式的另一個範例。當一組物件(此處為相同類別的物件)和函式應該看到彼此的內部表示形式時,就會發生這些範例,而它們的表示形式應該對外部隱藏。解決方案始終是在同一個模組中定義所有朋友,授予對表示形式的存取權,並使用簽名約束使表示形式在模組外部是抽象的。


1
浮點數是十進制數的近似值,它們不適合用於大多數貨幣計算,因為它們可能會引入錯誤。

(本章由 Jérôme Vouillon、Didier Rémy 和 Jacques Garrigue 編寫)