对象

对象和类

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>

现在这个栈变成了一个浮点数栈,并且只能向这个栈中压入和弹出浮点数。让我们演示一下新的浮点数栈的类型安全。

# 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

我们可以强制 OCaml 更具体,并且只允许在'a stack上调用drain_stack,方法是缩小s参数的类型,如下所示

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

继承、虚类、初始化器

我注意到 Java 中的程序员倾向于过度使用继承,可能是因为这是在该语言中扩展代码的唯一合理方法。一种更好、更通用的扩展代码的方法通常是使用钩子(参见 Apache 的模块 API)。然而,在某些狭窄的领域,继承可能是有用的,其中最重要的是编写 GUI 组件库。

让我们考虑一个类似于 Java 的 Swing 的虚构 OCaml 组件库。我们将使用以下类层次结构定义按钮和标签

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;;

这会修改我的container类的私有内部表示形式吗,方法是在组件列表的开头添加x?不会。如果运行上述代码,您会看到它会抛出错误。

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.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

创新。社区。安全。