物件

物件與類別

OCaml 是一種物件導向、命令式、函數式程式語言。它混合了所有這些範例,讓您可以使用最適合(或最熟悉)的程式設計範例來完成手邊的任務。在本章中,我們將探討 OCaml 中的物件導向程式設計,但我們也會討論您為什麼可能或可能不想編寫物件導向程式。

教科書中用來示範物件導向程式設計的經典範例是堆疊類別。這在許多方面都是一個很糟糕的例子,但我們在這裡使用它來展示編寫物件導向 OCaml 的基礎知識。

以下是一些基本程式碼,可提供整數堆疊。這個類別是使用連結清單實作的。

# class stack_of_ints =
  object (self)
    val mutable the_list = ([] : int list)     (* instance variable *)
    method push x =                            (* push method *)
      the_list <- x :: the_list
    method pop =                               (* pop method *)
      let result = List.hd the_list in
      the_list <- List.tl the_list;
      result
    method peek =                              (* peek method *)
      List.hd the_list
    method size =                              (* size method *)
      List.length the_list
  end;;
class stack_of_ints :
  object
    val mutable the_list : int list
    method peek : int
    method pop : int
    method push : int -> unit
    method size : int
  end

基本模式 class name = object (self) ... end 定義一個名為 name 的類別。

這個類別有一個實體變數,它是可變的(不是常數),名為 the_list。這是底層的連結清單。我們使用您可能不熟悉的某些程式碼初始化它(每次建立 stack_of_ints 物件時)。運算式 ( [] : int list ) 表示「一個空的清單,類型為 int list」。回想一下,簡單的空清單 [] 的類型為 'a list,表示任何類型的清單。然而,我們想要一個 int 的堆疊,而不是其他任何東西,所以在這種情況下,我們想要告訴類型推斷引擎,這個清單不是一般的「任何東西的清單」,而是更窄的「int 的清單」。語法 ( expression : type ) 表示 expression,它的類型為 type。這不是一般的類型轉換,因為您不能使用它來否決類型推斷引擎,只能將一般類型縮小為更具體的類型。因此,例如,您不能寫 ( 1 : float )

# (1 : float);;
Line 1, characters 2-3:
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `1.'?

類型安全得到保留。回到範例...

這個類別有四個簡單的方法

  • push 將一個整數推入堆疊。
  • pop 將堆疊頂端的整數彈出並返回。請注意用於更新我們可變實體變數的 <- 指派運算子。它與用於更新記錄中可變欄位的 <- 指派運算子相同。
  • peek 返回堆疊的頂端(即清單的頭部),而不影響堆疊
  • size 返回堆疊中元素的數量(即清單的長度)。

讓我們編寫一些程式碼來測試 int 的堆疊。首先,讓我們建立一個新的物件。我們使用熟悉的 new 運算子

# let s = new stack_of_ints;;
val s : stack_of_ints = <obj>

現在我們將從堆疊中推送和彈出一些元素

# for i = 1 to 10 do
    s#push i
  done;;
- : unit = ()
# while s#size > 0 do
    Printf.printf "Popped %d off the stack.\n" s#pop
  done;;
Popped 10 off the stack.
Popped 9 off the stack.
Popped 8 off the stack.
Popped 7 off the stack.
Popped 6 off the stack.
Popped 5 off the stack.
Popped 4 off the stack.
Popped 3 off the stack.
Popped 2 off the stack.
Popped 1 off the stack.
- : unit = ()

請注意語法。object#method 表示在 object 上呼叫 method。這與您在命令式語言中熟悉的 object.methodobject->method 相同。

在 OCaml toplevel 中,我們可以更詳細地檢查物件和方法的類型

# let s = new stack_of_ints;;
val s : stack_of_ints = <obj>
# s#push;;
- : int -> unit = <fun>

s 是一個不透明的物件。實作(即清單)對呼叫者隱藏。

多型類別

整數堆疊很好,但是可以儲存任何類型的堆疊呢?(不是可以儲存混合類型的單一堆疊,而是每個都儲存任何單一類型物件的多個堆疊)。與 'a list 一樣,我們可以定義 'a stack

# class ['a] stack =
  object (self)
    val mutable list = ([] : 'a list)    (* instance variable *)
    method push x =                      (* push method *)
      list <- x :: list
    method pop =                         (* pop method *)
      let result = List.hd list in
      list <- List.tl list;
      result
    method peek =                        (* peek method *)
      List.hd list
    method size =                        (* size method *)
      List.length list
  end;;
class ['a] stack :
  object
    val mutable list : 'a list
    method peek : 'a
    method pop : 'a
    method push : 'a -> unit
    method size : int
  end

class ['a] stack 並不只是定義一個類別。它定義了整個「類別的類別」,每個可能的類型都有一個(即,無限大的類別數!)。讓我們嘗試使用我們的 'a stack 類別。在這個範例中,我們建立一個堆疊,並將一個浮點數推入堆疊。請注意堆疊的類型

# let s = new stack;;
val s : '_weak1 stack = <obj>
# s#push 1.0;;
- : unit = ()
# s;;
- : float stack = <obj>

這個堆疊現在是 float stack,而且只能將浮點數推入和彈出這個堆疊。讓我們示範我們新的 float stack 的類型安全性

# s#push 3.0;;
- : unit = ()
# s#pop;;
- : float = 3.
# s#pop;;
- : float = 1.
# s#push "a string";;
Line 1, characters 8-18:
Error: This expression has type string but an expression was expected of type
         float

我們可以定義可以在任何類型的堆疊上運作的多型函式。我們第一次嘗試是這個

# let drain_stack s =
  while s#size > 0 do
    ignore (s#pop)
  done;;
val drain_stack : < pop : 'a; size : int; .. > -> unit = <fun>

請注意 drain_stack 的類型。巧妙地(或許巧妙了),OCaml 的類型推斷引擎已計算出 drain_stack 可以對具有 popsize 方法的任何物件運作!因此,如果我們定義了另一個完全不同的類別,恰好包含具有合適類型簽名的 popsize 方法,那麼我們可能會意外地在該其他類型的物件上呼叫 drain_stack

我們可以透過縮小 s 引數的類型,強制 OCaml 更具體,並且只允許在 'a stack 上呼叫 drain_stack,如下所示

# let drain_stack (s : 'a stack) =
  while s#size > 0 do
    ignore (s#pop)
  done;;
val drain_stack : 'a stack -> unit = <fun>

繼承、虛擬類別、初始化器

我注意到 Java 的程式設計師傾向於過度使用繼承,可能是因為那是該語言中擴展程式碼的唯一合理方式。擴展程式碼更好、更通用的方式通常是使用 Hook(參見 Apache 的模組 API)。儘管如此,在某些狹窄的領域中,繼承還是有用的,其中最重要的是編寫 GUI 小工具函式庫。

讓我們考慮一個虛構的 OCaml 小工具函式庫,類似於 Java 的 Swing。我們將使用以下類別階層定義按鈕和標籤

widget  (superclass for all widgets)
  |
  +----> container  (any widget that can contain other widgets)
  |        |
  |        +----> button
  |
  +-------------> label

(請注意,button 是一個 container,因為它可以包含標籤或圖像,具體取決於按鈕上顯示的內容)。

widget 是所有小工具的虛擬超類別。我希望每個小工具都有一個名稱(只是一個字串),該名稱在小工具的整個生命週期中保持不變。這是我的第一次嘗試

# class virtual widget name =
  object (self)
    method get_name =
      name
    method virtual repaint : unit
  end;;
Error: Some type variables are unbound in this type:
         class virtual widget :
           'a ->
           object method get_name : 'a method virtual repaint : unit end
       The method get_name has type 'a where 'a is unbound

糟糕!我忘記了 OCaml 無法推斷 name 的類型,因此會假設它是 'a。但這定義了一個多型類別,而我沒有將該類別宣告為多型(class ['a] widget)。我需要像這樣縮小 name 的類型

# class virtual widget (name : string) =
  object (self)
    method get_name =
      name
    method virtual repaint : unit
  end;;
class virtual widget :
  string -> object method get_name : string method virtual repaint : unit end

現在這段程式碼中發生了幾個新事情。首先,該類別包含一個初始化器。這是類別的引數(name),您可以將其視為與 Java 中建構函式的引數完全相同

public class Widget
{
  public Widget (String name)
  {
    ...
  }
}

在 OCaml 中,建構函式會建構整個類別;它不僅僅是一個特別命名的函式,因此我們將引數寫成像它們是類別的引數一樣

class foo arg1 arg2 ... =

其次,該類別包含一個虛擬方法,因此整個類別被標記為虛擬。虛擬方法是我們的 repaint 方法。我們需要告訴 OCaml 它是虛擬的(method virtual),而且我們需要告訴 OCaml 該方法的類型。因為該方法不包含任何程式碼,OCaml 無法使用類型推斷來自動為您找出類型,因此您需要告訴它類型。在這種情況下,該方法只會回傳 unit。如果您的類別包含任何虛擬方法(即使只是繼承的方法),您需要使用 class virtual ... 將整個類別指定為虛擬。

如同 C++ 和 Java,虛擬類別無法使用 new 直接實例化

# let w = new widget "my widget";;
Error: Cannot instantiate the virtual class widget

現在我的 container 類別更有趣了。它必須繼承自 widget,並且具有儲存包含的小工具列表的機制。這是我的 container 簡單實作

# class virtual container name =
  object (self)
    inherit widget name
    val mutable widgets = ([] : widget list)
    method add w =
      widgets <- w :: widgets
    method get_widgets =
      widgets
    method repaint =
      List.iter (fun w -> w#repaint) widgets
  end;;
class virtual container :
  string ->
  object
    val mutable widgets : widget list
    method add : widget -> unit
    method get_name : string
    method get_widgets : widget list
    method repaint : unit
  end

注意

  1. container 類別被標記為虛擬。它不包含任何虛擬方法,但在這種情況下,它的作用是防止人們直接建立容器。
  2. container 類別有一個 name 引數,該引數在建構 widget 時直接傳遞上去。
  3. inherit widget name 表示 container 繼承自 widget,並且將 name 引數傳遞給 widget 的建構函式。
  4. container 包含一個可變的小工具列表,以及將小工具 add 到此列表和 get_widgets(回傳小工具列表)的方法。
  5. get_widgets 回傳的小工具列表無法由類別外部的程式碼修改。這樣做的原因有點微妙,但基本上歸結於 OCaml 的連結列表是不可變的這一事實。讓我們想像一下有人寫了這段程式碼
# let list = container#get_widgets in
  x :: list;;

這會透過將 x 前置到小工具列表來修改我的 container 類別的私有內部表示嗎?不會的。如果您執行上述程式碼,您會看到它會擲回錯誤

Error: Unbound value container

然而,私有變數 widgets 不會受到此程式碼或外部程式碼嘗試的任何其他變更的影響。這表示,例如,您可以稍後將內部表示變更為使用陣列,而無需變更類別外部的任何程式碼。

最後但同樣重要的是,我們實作了先前虛擬的 repaint 函式,因此 container#repaint 會重新繪製所有包含的小工具。請注意使用 List.iter 來迭代列表,並且我也使用了一個可能不熟悉的匿名函式表達式

# (fun w -> w#repaint);;
- : < repaint : 'a; .. > -> 'a = <fun>

這定義了一個具有一個引數 w 的匿名函式,該函式只會呼叫 w#repaint(小工具 w 上的 repaint 方法)。

在這種情況下,我們的 button 類別很簡單(事實上簡單得有些不切實際,但沒關係)

# type button_state = Released | Pressed;;
type button_state = Released | Pressed
# class button ?callback name =
  object (self)
    inherit container name as super
    val mutable state = Released
    method press =
      state <- Pressed;
      match callback with
      | None -> ()
      | Some f -> f ()
    method release =
      state <- Released
    method repaint =
      super#repaint;
      print_endline ("Button being repainted, state is " ^
                     (match state with
                      | Pressed -> "Pressed"
                      | Released -> "Released"))
  end;;
class button :
  ?callback:(unit -> unit) ->
  string ->
  object
    val mutable state : button_state
    val mutable widgets : widget list
    method add : widget -> unit
    method get_name : string
    method get_widgets : widget list
    method press : unit
    method release : unit
    method repaint : unit
  end

注意

  1. 此函式有一個可選引數(請參閱上一章),用於傳遞可選的回呼函式。按下按鈕時會呼叫回呼。
  2. 表達式 inherit container name as super 將超類別命名為 super。我在 repaint 方法中使用它:super#repaint。這明確地呼叫了超類別方法。
  3. 按下按鈕(在此相當簡單的程式碼中呼叫 button#press)會將按鈕設定為 Pressed,並呼叫回呼函式(如果已定義)。請注意,callback 變數不是 None 就是 Some f,表示它的類型為 (unit -> unit) option。如果您不確定,請重新閱讀上一章
  4. 請注意 callback 變數的一個奇怪之處。它被定義為類別的引數,但是任何方法都可以看到和使用它。換句話說,該變數是在建構物件時提供的,並且也會在物件的整個生命週期中持續存在。
  5. repaint 方法已實作。它會呼叫超類別(以重新繪製容器),然後重新繪製按鈕,顯示按鈕的目前狀態。

在定義我們的 label 類別之前,讓我們在 OCaml toplevel 中使用 button 類別

# let b = new button ~callback:(fun () -> print_endline "Ouch!") "button";;
val b : button = <obj>

# b#repaint;;
Button being repainted, state is Released
- : unit = ()
# b#press;;
Ouch!
- : unit = ()
# b#repaint;;
Button being repainted, state is Pressed
- : unit = ()
# b#release;;
- : unit = ()

這是我們相對簡單的 label 類別

# class label name text =
  object (self)
    inherit widget name
    method repaint =
      print_endline ("Label: " ^ text)
  end;;
class label :
  string ->
  string -> object method get_name : string method repaint : unit end

讓我們建立一個顯示「Press me!」的標籤,並將其新增到按鈕中

# let l = new label "label" "Press me!";;
val l : label = <obj>
# b#add l;;
- : unit = ()
# b#repaint;;
Label: Press me!
Button being repainted, state is Released
- : unit = ()

關於 self 的注意事項

在上述所有範例中,我們都使用一般模式定義類別

class name =
  object (self)
    (* ... *)
  end

self 的參照會命名物件,允許您在同一個類別中呼叫方法,或將物件傳遞給類別外部的函式。換句話說,它與 C++/Java 中的 this 完全相同。如果您不需要參照自己,則可以完全省略 (self) 部分。事實上,在上述所有範例中,我們都可以這樣做。但是,我們建議您保留它,因為您永遠不知道何時會修改類別並需要參照 self。使用它沒有任何損失。

繼承和強制轉型

# let b = new button "button";;
val b : button = <obj>
# let l = new label "label" "Press me!";;
val l : label = <obj>
# [b; l];;
Error: This expression has type label but an expression was expected of type
         button
       The first object type has no method add

我們建立了一個按鈕 b 和一個標籤 l,然後嘗試建立一個同時包含兩者的列表,但我們收到了錯誤。然而,bl 都是 widget,因此也許我們無法將它們放入同一個列表,因為 OCaml 無法猜到我們想要一個 widget list。讓我們試著告訴它

# let wl = ([] : widget list);;
val wl : widget list = []
# let wl = b :: wl;;
Error: This expression has type widget list
       but an expression was expected of type button list
       Type widget = < get_name : string; repaint : unit >
       is not compatible with type
         button =
           < add : widget -> unit; get_name : string;
             get_widgets : widget list; press : unit; release : unit;
             repaint : unit >
       The first object type has no method add

事實證明,OCaml 預設不會將子類別強制轉型為超類別類型,但是您可以使用 :>(強制轉型)運算子來告訴它

# let wl = (b :> widget) :: wl;;
val wl : widget list = [<obj>]
# let wl = (l :> widget) :: wl;;
val wl : widget list = [<obj>; <obj>]

表達式 (b :> widget) 表示「將按鈕 b 強制轉型為 widget 類型。」型別安全得到保留,因為可以在編譯時完全判斷強制轉型是否會成功。

實際上,強制轉型比上述描述的更微妙,因此請閱讀手冊以了解完整詳細資料。

上面定義的 container#add 方法實際上是不正確的;如果您嘗試將不同類型的小工具新增到 container 中,它會失敗。強制轉型可以解決此問題。

是否可以從超類別(例如 widget)強制轉型為子類別(例如 button)?答案(可能出乎意料地)是「否」!朝此方向強制轉型是不安全的。您可能會嘗試強制轉型一個實際上是 label 而不是 buttonwidget

Oo 模組和比較物件

Oo 模組包含一些用於 OO 程式設計的有用函式。

Oo.copy 會建立物件的淺層副本。Oo.id object 會回傳每個物件的唯一識別號碼(跨所有類別的唯一號碼)。

=<> 可以用於比較物件的實體相等性(物件及其副本在實體上並不相同)。您也可以使用 < 等,它們會根據物件的 ID 提供物件的排序。

沒有類別的物件

在這裡,我們研究如何像使用記錄一樣使用物件,而不必使用類別。

立即物件和物件類型

可以使用物件來代替記錄。此外,它們具有一些良好的屬性,在某些情況下,這些屬性使其比記錄更受歡迎。我們看到建立物件的標準方法是先定義類別,然後使用此類別來建立個別物件。在某些情況下,這可能很麻煩,因為類別定義不僅僅是類型定義,而且無法與類型遞迴定義。但是,物件的類型非常類似於記錄類型,並且可以在類型定義中使用。此外,可以在沒有類別的情況下建立物件。它們被稱為立即物件。以下是立即物件的定義

# let o =
  object
    val mutable n = 0
    method incr = n <- n + 1
    method get = n
  end;;
val o : < get : int; incr : unit > = <obj>

此物件具有類型,該類型僅由其公開方法定義。值是不可見的,私有方法(未顯示)也是如此。與記錄不同,此類型不需要預先明確定義,但這樣做可以使事情更清楚。我們可以這樣做

# type counter = <get : int; incr : unit>;;
type counter = < get : int; incr : unit >

與等效的記錄類型定義比較

# type counter_r =
  {get : unit -> int;
   incr : unit -> unit};;
type counter_r = { get : unit -> int; incr : unit -> unit; }

像我們的物件一樣運作的記錄實作將是

# let r =
  let n = ref 0 in
    {get = (fun () -> !n);
     incr = (fun () -> incr n)};;
val r : counter_r = {get = <fun>; incr = <fun>}

就功能而言,物件和記錄都很相似,但是每個解決方案都有其自身的優點

  • 速度:記錄中更快的欄位存取
  • 欄位名稱:當某些欄位的名稱相同時,操作不同類型的記錄很不方便,但是這對於物件來說不是問題。
  • 子類型化:不可能將記錄類型強制轉型為具有較少欄位的類型。但是,這對於物件來說是可能的,因此可以在一個資料結構中混合使用共享某些方法的不同種類的物件,其中僅顯示其共用方法(請參閱下一節)。
  • 類型定義:不需要預先定義物件類型,因此可以減輕模組之間的依賴關係限制。

類別類型與僅類型

請注意類別類型和物件類型之間的混淆。類別類型不是資料類型,通常在 OCaml 行話中稱為類型。物件類型是一種資料類型,就像記錄類型或元組一樣。

定義類別時,會定義具有相同名稱的類別類型和物件類型

# class t =
  object
    val x = 0
    method get = x
  end;;
class t : object val x : int method get : int end

object val x : int method get : int end 是一個類別類型。

在此範例中,t 也是此類別將建立的物件的類型。只要物件具有相同的類型,就可以將來自不同類別或根本沒有類別(立即物件)的物件混合在一起

# let x = object method get = 123 end;;
val x : < get : int > = <obj>
# let l = [new t; x];;
val l : t list = [<obj>; <obj>]

可以混合使用共享通用子類型的物件,但是這需要使用 :> 運算子進行明確的類型強制轉型

# let x = object method get = 123 end;;
val x : < get : int > = <obj>
# let y = object method get = 80 method special = "hello" end;;
val y : < get : int; special : string > = <obj>
# let l = [x; y];;
Error: This expression has type < get : int; special : string >
       but an expression was expected of type < get : int >
       The second object type has no method special
# let l = [x; (y :> t)];;
val l : t list = [<obj>; <obj>]

仍然需要協助嗎?

協助改進我們的文件

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

OCaml

創新。社群。安全。