Skip to main content

第 14 章:类(Classes)

原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 14。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。

本章由 Leo White 和 Jason Hickey 撰写。

直接使用对象很适合做封装,但面向对象编程的主要目标之一,是通过继承实现代码复用。为了使用继承,就需要引入(classes)。在面向对象编程中,类本质上是创建对象的配方。这个配方可以通过添加新的方法和字段来改变,也可以通过修改已有方法来改变。

14.1 OCaml 类(OCaml Classes)

在 OCaml 中,类定义必须作为模块中的顶层语句出现。类定义使用关键字 class

# open Base;;
# class istack = object
val mutable v = [0; 2]

method pop =
match v with
| hd :: tl ->
v <- tl;
Some hd
| [] -> None

method push hd =
v <- hd :: v
end;;
class istack :
object
val mutable v : int list
method pop : int option
method push : int -> unit
end

结果中的 class istack : object ... end 表明我们创建了一个名为 istack 的类,它的类类型(class type)是 object ... end。和模块类型一样,类类型与普通 OCaml 类型(例如 intstringlist)完全分离,尤其不要把它和对象类型(例如 < get : int; .. >)混淆。类类型描述的是类本身,而不是类所创建的对象。这里的类类型说明,istack 类定义了一个可变字段 v、一个返回 int optionpop 方法,以及一个类型为 int -> unitpush 方法。

要产生对象,可以用关键字 new 实例化类:

# let s = new istack;;
val s : istack = <obj>
# s#pop;;
- : int option = Some 0
# s#push 5;;
- : unit = ()
# s#pop;;
- : int option = Some 5

你可能注意到,对象 s 的类型被写成了 istack。但前面强调过,类不是类型,这是怎么回事?事实上,类和类名确实不是类型。不过为了方便,定义类 istack 时,OCaml 也会定义一个同名对象类型 istack,它拥有和该类相同的方法。这个类型定义等价于:

# type istack = < pop: int option; push: int -> unit >;;
type istack = < pop : int option; push : int -> unit >

注意,这个类型代表任何拥有这些方法的对象。用 istack 类创建出来的对象会拥有这个类型,但拥有这个类型的对象不一定是由 istack 类创建的。

14.2 类参数与多态(Class Parameters and Polymorphism)

类定义也充当类的构造器。一般来说,类定义可以带参数;用 new 创建对象时必须提供这些参数。

下面实现 istack 的一个变体,它能保存任意值,而不只是整数。定义类时,类型参数放在类名前的方括号中。我们还添加一个参数 init,表示栈的初始内容:

# class ['a] stack init = object
val mutable v : 'a list = init

method pop =
match v with
| hd :: tl ->
v <- tl;
Some hd
| [] -> None

method push hd =
v <- hd :: v
end;;
class ['a] stack :
'a list ->
object
val mutable v : 'a list
method pop : 'a option
method push : 'a -> unit
end

注意,定义中的类型参数 ['a] 使用方括号,但在类型的其他使用处会省略方括号。如果有多个类型参数,则会用圆括号。

字段 v 声明上的类型标注用于约束类型推断。如果省略这个标注,类的推断类型会“过于多态”:init 可能具有某个 'b list 类型。

# class ['a] stack init = object
val mutable v = init

method pop =
match v with
| hd :: tl ->
v <- tl;
Some hd
| [] -> None

method push hd =
v <- hd :: v
end;;
Lines 1-13, characters 1-6:
Error: Some type variables are unbound in this type:
class ['a] stack :
'b list ->
object
val mutable v : 'b list
method pop : 'b option
method push : 'b -> unit
end
The method pop has type 'a option where 'a is unbound

一般来说,需要提供足够约束,让编译器推断出正确类型。可以给参数、字段和方法加类型约束。加多少是风格问题:三处都可以加,但额外文本不一定更清晰。一个方便的折中是给字段和/或类参数加标注,只有必要时再给方法加约束。

14.3 对象类型作为接口(Object Types as Interfaces)

有时我们希望遍历栈中的元素。在面向对象语言中,一种常见风格是定义一个 iterator 对象类。迭代器提供一种通用机制,用来检查和遍历集合元素。

有两种常见方式定义这类抽象接口。在 Java 中,迭代器通常会用 interface 指定,它说明一组方法类型:

// Java-style iterator, specified as an interface.
interface <T> iterator {
T Get();
boolean HasValue();
void Next();
};

在没有 interface 的语言中,例如 C++,通常用抽象类指定方法而不实现它们。C++ 中的 = 0 定义表示“未实现”。

// Abstract class definition in C++.
template<typename T>
class Iterator {
public:
virtual ~Iterator() {}
virtual T get() const = 0;
virtual bool has_value() const = 0;
virtual void next() = 0;
};

OCaml 两种风格都支持。事实上,OCaml 比这些方式更灵活,因为任何拥有合适方法的对象都可以实现某个对象类型;并不需要预先由对象所属的类指定。抽象类稍后再讲,这里先用对象类型展示这个技巧。

首先定义一个对象类型 iterator,指定迭代器中的方法:

# type 'a iterator = < get : 'a; has_value : bool; next : unit >;;
type 'a iterator = < get : 'a; has_value : bool; next : unit >

接着,为列表定义一个真正的迭代器。可以用它遍历栈中的内容:

# class ['a] list_iterator init = object
val mutable current : 'a list = init

method has_value = not (List.is_empty current)

method get =
match current with
| hd :: tl -> hd
| [] -> raise (Invalid_argument "no value")

method next =
match current with
| hd :: tl -> current <- tl
| [] -> raise (Invalid_argument "no value")
end;;
class ['a] list_iterator :
'a list ->
object
val mutable current : 'a list
method get : 'a
method has_value : bool
method next : unit
end

最后,给 stack 类添加一个 iterator 方法来产生迭代器。为此,构造一个引用当前栈内容的 list_iterator

# class ['a] stack init = object
val mutable v : 'a list = init

method pop =
match v with
| hd :: tl ->
v <- tl;
Some hd
| [] -> None

method push hd =
v <- hd :: v

method iterator : 'a iterator =
new list_iterator v
end;;
class ['a] stack :
'a list ->
object
val mutable v : 'a list
method iterator : 'a iterator
method pop : 'a option
method push : 'a -> unit
end

现在可以构建一个新栈,压入一些值,然后迭代它们:

# let s = new stack [];;
val s : '_weak1 stack = <obj>
# s#push 5;;
- : unit = ()
# s#push 4;;
- : unit = ()
# let it = s#iterator;;
val it : int iterator = <obj>
# it#get;;
- : int = 4
# it#next;;
- : unit = ()
# it#get;;
- : int = 5
# it#next;;
- : unit = ()
# it#has_value;;
- : bool = false

14.3.1 函数式迭代器(Functional Iterators)

实践中,大多数 OCaml 程序员会避免使用迭代器对象,而更偏向函数式风格技术。例如,下面这个替代版本的 stack 类接收一个函数 f,并把它应用到栈中的每个元素:

# class ['a] stack init = object
val mutable v : 'a list = init

method pop =
match v with
| hd :: tl ->
v <- tl;
Some hd
| [] -> None

method push hd =
v <- hd :: v

method iter f =
List.iter ~f v
end;;
class ['a] stack :
'a list ->
object
val mutable v : 'a list
method iter : ('a -> unit) -> unit
method pop : 'a option
method push : 'a -> unit
end

mapfold 这类函数式操作怎么办?一般来说,这些方法接收一个函数,而这个函数会产生一个和集合元素类型不同的值。

例如,对 ['a] stack 类来说,fold 方法应该有类型 ('b -> 'a -> 'b) -> 'b -> 'b,其中 'b 是多态的。为了表达这种多态方法类型,必须使用类型量词,如下所示:

# class ['a] stack init = object
val mutable v : 'a list = init

method pop =
match v with
| hd :: tl ->
v <- tl;
Some hd
| [] -> None

method push hd =
v <- hd :: v

method fold : 'b. ('b -> 'a -> 'b) -> 'b -> 'b =
(fun f init -> List.fold ~f ~init v)
end;;
class ['a] stack :
'a list ->
object
val mutable v : 'a list
method fold : ('b -> 'a -> 'b) -> 'b -> 'b
method pop : 'a option
method push : 'a -> unit
end

类型量词 'b. 可以读作“对所有 'b”。类型量词只能直接放在方法名之后,这意味着方法参数必须用 funfunction 表达式来表示。

14.4 继承(Inheritance)

继承使用已有类来定义新类。例如,下面的类定义继承字符串栈版本的 stack 类,并添加一个新方法 print,用于打印栈中的所有字符串:

# class sstack init = object
inherit [string] stack init

method print =
List.iter ~f:Stdio.print_endline v
end;;
class sstack :
string list ->
object
val mutable v : string list
method pop : string option
method print : unit
method push : string -> unit
end

类可以覆盖从父类继承来的方法。例如,下面这个类创建整数栈,并在整数入栈前把它们加倍:

# class double_stack init = object
inherit [int] stack init as super

method push hd =
super#push (hd * 2)
end;;
class double_stack :
int list ->
object
val mutable v : int list
method pop : int option
method push : int -> unit
end

上面的 as super 语句创建了一个特殊对象 super,可用于调用父类方法。注意,super 不是真正的对象,只能用于调用方法。

14.5 类类型(Class Types)

如果希望另一个文件或模块中的代码继承某个类,就必须暴露这个类,并为它给出类类型。那么类类型是什么?

举例来说,把 stack 类包装进显式模块中。这里为了说明使用显式模块;定义 .mli 文件时流程类似。按照模块的一般风格,定义一个类型 'a t 表示栈的类型:

module Stack = struct
class ['a] stack init = object
...
end

type 'a t = 'a stack

let make init = new stack init
end

定义模块类型时有多个选择,取决于希望暴露多少实现细节。一个极端是最大程度抽象的签名,它会完全隐藏类定义:

module AbstractStack : sig
type 'a t = < pop: 'a option; push: 'a -> unit >

val make : 'a list -> 'a t
end = Stack

这个抽象签名很简单,因为它忽略了类。但如果想把类也包含在签名中,让其他模块可以继承这些类定义,该怎么办?这时需要为类指定类型,也就是类类型

类类型并不出现在主流面向对象编程语言中,所以你可能不熟悉它,但概念很简单。类类型指定类中每个可见部分的类型,包括字段和方法。和模块类型一样,不必给所有内容都写类型;省略的内容会被隐藏:

module VisibleStack : sig

type 'a t = < pop: 'a option; push: 'a -> unit >

class ['a] stack : object
val mutable v : 'a list
method pop : 'a option
method push : 'a -> unit
end

val make : 'a list -> 'a t
end = Stack

在这个签名中,我们选择让所有内容可见。stack 的类类型指定了字段 v 的类型,以及每个方法的类型。

14.6 开放递归(Open Recursion)

开放递归允许对象的方法调用同一对象上的其他方法。这些调用会动态查找,因此如果两个类被同一个对象继承,一个类中的方法可以调用另一个类中的方法。这允许对象中相互递归的部分分开定义。

这种从独立组件定义相互递归方法的能力,是类的关键特性。用数据类型或模块实现类似功能会笨拙得多,也冗长得多。

例如,考虑为一种简单文档格式编写递归函数。这个格式表示为一棵树,其中有三类节点:

type doc =
| Heading of string
| Paragraph of text_item list
| Definition of string list_item list

and text_item =
| Raw of string
| Bold of text_item list
| Enumerate of int list_item list
| Quote of doc

and 'a list_item =
{ tag: 'a;
text: text_item list }

写一个通过递归遍历这些数据来工作的函数很容易。但如果需要写许多类似递归函数,该如何抽出公共部分,避免重复样板代码?

最简单的方式是使用类和开放递归。例如,下面这个类定义了对文档数据进行折叠的对象:

class ['a] folder = object(self)
method doc acc = function
| Heading _ -> acc
| Paragraph text -> List.fold ~f:self#text_item ~init:acc text
| Definition list -> List.fold ~f:self#list_item ~init:acc list

method list_item: 'b. 'a -> 'b list_item -> 'a =
fun acc {tag; text} ->
List.fold ~f:self#text_item ~init:acc text

method text_item acc = function
| Raw _ -> acc
| Bold text -> List.fold ~f:self#text_item ~init:acc text
| Enumerate list -> List.fold ~f:self#list_item ~init:acc list
| Quote doc -> self#doc acc doc
end

object (self) 语法把 self 绑定到当前对象,使 doclist_itemtext_item 方法可以相互调用。

通过继承这个类,可以创建折叠文档数据的函数。例如,count_doc 函数统计文档中不在列表内部的粗体标签数量:

class counter = object
inherit [int] folder as super

method list_item acc li = acc

method text_item acc ti =
let acc = super#text_item acc ti in
match ti with
| Bold _ -> acc + 1
| _ -> acc
end

let count_doc = (new counter)#doc

注意,text_item 中使用特殊对象 super 调用 [int] folder 类的 text_item 方法,从而折叠 text_item 节点的子节点。

14.7 私有方法(Private Methods)

方法可以声明为私有(private),这意味着子类可以调用它们,但除此之外不可见,类似 C++ 中的 protected 方法。

例如,我们可能想在 folder 类中加入处理 doctext_item 中不同情况的方法。但也许不想强迫 folder 的子类暴露这些方法,因为它们很可能不应被直接调用:

class ['a] folder2 = object(self)
method doc acc = function
| Heading str -> self#heading acc str
| Paragraph text -> self#paragraph acc text
| Definition list -> self#definition acc list

method list_item: 'b. 'a -> 'b list_item -> 'a =
fun acc {tag; text} ->
List.fold ~f:self#text_item ~init:acc text

method text_item acc = function
| Raw str -> self#raw acc str
| Bold text -> self#bold acc text
| Enumerate list -> self#enumerate acc list
| Quote doc -> self#quote acc doc

method private heading acc str = acc
method private paragraph acc text =
List.fold ~f:self#text_item ~init:acc text
method private definition acc list =
List.fold ~f:self#list_item ~init:acc list

method private raw acc str = acc
method private bold acc text =
List.fold ~f:self#text_item ~init:acc text
method private enumerate acc list =
List.fold ~f:self#list_item ~init:acc list
method private quote acc doc = self#doc acc doc
end

let f :
< doc : int -> doc -> int;
list_item : 'a . int -> 'a list_item -> int;
text_item : int -> text_item -> int > = new folder2

最后构造值 f 的语句展示了:实例化 folder2 对象得到的对象类型会隐藏私有方法。

准确地说,私有方法是类类型的一部分,但不是对象类型的一部分。例如,对象 f 没有 bold 方法。不过,私有方法对子类可用,因此可以用它们简化 counter 类:

class counter_with_private_method = object
inherit [int] folder2 as super

method list_item acc li = acc

method private bold acc txt =
let acc = super#bold acc txt in
acc + 1
end

私有方法的关键性质是:它们对子类可见,但其他地方不可见。如果想要更强保证,即某个方法真的私有,连子类也不能访问,可以使用显式类类型并从中省略该方法。下面代码中,私有方法被明确从 counter_with_sig 的类类型中省略,因此 counter_with_sig 的子类也不能调用它们:

class counter_with_sig : object
method doc : int -> doc -> int
method list_item : int -> 'b list_item -> int
method text_item : int -> text_item -> int
end = object
inherit [int] folder2 as super

method list_item acc li = acc

method private bold acc txt =
let acc = super#bold acc txt in
acc + 1
end

14.8 二元方法(Binary Methods)

二元方法(binary method)是接收 self 类型对象作为参数的方法。一个常见例子是定义相等性方法:

# class square w = object(self : 'self)
method width = w
method area = Float.of_int (self#width * self#width)
method equals (other : 'self) = other#width = self#width
end;;
class square :
int ->
object ('a)
method area : float
method equals : 'a -> bool
method width : int
end
# class circle r = object(self : 'self)
method radius = r
method area = 3.14 *. (Float.of_int self#radius) **. 2.0
method equals (other : 'self) = other#radius = self#radius
end;;
class circle :
int ->
object ('a)
method area : float
method equals : 'a -> bool
method radius : int
end

注意,可以用类型标注 (self: 'self) 获得当前对象的类型。

现在可以用 equals 这个二元方法测试不同对象实例是否相等:

# (new square 5)#equals (new square 5);;
- : bool = true
# (new circle 10)#equals (new circle 7);;
- : bool = false

这能工作,但里面潜伏着一个问题。equals 方法接收的是精确类型 squarecircle 的对象。因此,我们不能定义一个也包含相等性方法的通用基类 shape

# type shape = < equals : shape -> bool; area : float >;;
type shape = < area : float; equals : shape -> bool >
# (new square 5 :> shape);;
Line 1, characters 1-24:
Error: Type square = < area : float; equals : square -> bool; width : int >
is not a subtype of shape = < area : float; equals : shape -> bool >
Type shape = < area : float; equals : shape -> bool >
is not a subtype of
square = < area : float; equals : square -> bool; width : int >
The first object type has no method width

问题在于,square 期望和另一个 square 比较,而不是任意形状;circle 也一样。这是一个根本问题。许多语言会用收窄(动态类型检查)或方法重载解决它。但 OCaml 两者都没有,那么该怎么办?

由于有问题的方法是相等性,一种提议是干脆把它从基础类型 shape 中去掉,改用多态相等。但内置多态相等应用到对象上时行为很差:

# Poly.(=)
(object method area = 5 end)
(object method area = 5 end);;
- : bool = false

这里的问题是,只有当两个对象物理相同时,内置多态相等才会认为它们相等。使用内置多态相等还有其他理由需要谨慎,而这些假阴性已经足以让它不能接受。

如果想为一般形状定义相等性,剩下的解决方案就是使用前面讨论收窄时的做法:引入一个用变体实现的表示类型(representation type),并基于这个表示类型实现比较。

# type shape_repr =
| Square of int
| Circle of int;;
type shape_repr = Square of int | Circle of int
# type shape =
< repr : shape_repr; equals : shape -> bool; area : float >;;
type shape = < area : float; equals : shape -> bool; repr : shape_repr >
# class square w = object(self)
method width = w
method area = Float.of_int (self#width * self#width)
method repr = Square self#width
method equals (other : shape) =
match (self#repr, other#repr) with
| Square x, Square x' -> Int.(=) x x'
| _ -> false
end;;
class square :
int ->
object
method area : float
method equals : shape -> bool
method repr : shape_repr
method width : int
end

二元方法 equals 现在基于具体类型 shape_repr 实现。使用这种模式时,无法隐藏 repr 方法,但可以用模块系统隐藏类型定义:

module Shapes : sig
type shape_repr
type shape =
< repr : shape_repr; equals : shape -> bool; area: float >

class square : int ->
object
method width : int
method area : float
method repr : shape_repr
method equals : shape -> bool
end
end = struct
type shape_repr =
| Square of int
| Circle of int
...
end

注意,这种方案会阻止我们在不向 shape_repr 类型添加新构造器的情况下添加新的形状种类,这相当受限。不过,可以用 OCaml 少用但仍然有用的可扩展变体(extensible variants)来修复这一点。

可扩展变体允许把变体类型定义和它的构造器定义分离。得到的类型从定义上就是开放的,也就是说,总是可以添加新变体。因此,编译器无法检查对这种变体的模式匹配是否穷尽。幸运的是,这里并不需要穷尽性。

下面是用可扩展变体重写前面例子的方式。

# type shape_repr = ..;;
type shape_repr = ..
# type shape =
< repr : shape_repr; equals : shape -> bool; area : float >;;
type shape = < area : float; equals : shape -> bool; repr : shape_repr >
# type shape_repr += Square of int;;
type shape_repr += Square of int
# class square w = object(self)
method width = w
method area = Float.of_int (self#width * self#width)
method repr = Square self#width
method equals (other : shape) =
match (self#repr, other#repr) with
| Square x, Square x' -> Int.(=) x x'
| _ -> false
end;;
class square :
int ->
object
method area : float
method equals : shape -> bool
method repr : shape_repr
method width : int
end

表示类型方法有个奇怪之处:这些类创建的对象和表示类型成员之间是一一对应的,这会让对象显得有些多余。

但相等性是二元方法的极端例子:它需要访问另一个对象的全部信息。许多其他二元方法只需要对象的部分信息。例如,可以考虑一个按大小比较形状的方法:

# class square w = object(self)
method width = w
method area = Float.of_int (self#width * self#width)
method larger (other : shape) = Float.(self#area > other#area)
end;;
class square :
int ->
object
method area : float
method larger : shape -> bool
method width : int
end

larger 方法可以用于 square,也可以应用到任意 shape 类型对象上。

14.9 虚类与虚方法(Virtual Classes and Methods)

(virtual)类是某些方法或字段只被声明但没有实现的类。这不应和 C++ 中的 virtual 混淆。C++ 中的 virtual 方法使用动态分派,而普通非 virtual 方法是静态分派。在 OCaml 中,所有方法都使用动态分派;关键字 virtual 表示方法或字段没有实现。包含虚方法的类本身也必须标记为 virtual,并且不能直接实例化,也就是不能创建这个类的对象。

为了探索这一点,把形状例子扩展成简单的交互式图形。这里会使用 Async 并发库,以及 Async_graphics 库,后者为 OCaml 内置 Graphics 库提供异步接口。第 17 章会再讨论 Async;现在可以忽略细节。只需要运行 opam install async_graphics 安装这个库。

给每个形状添加一个 draw 方法,描述如何在 Async_graphics 显示上绘制该形状:

open Core
open Async
open Async_graphics

type drawable = < draw: unit >

14.9.1 创建一些简单形状(Create Some Simple Shapes)

现在添加创建正方形和圆形的类。我们会包含一个 on_click 方法,为形状添加事件处理器。

class square w x y = object(self)
val mutable x: int = x
method x = x

val mutable y: int = y
method y = y

val mutable width = w
method width = width

method draw = fill_rect x y width width

method private contains x' y' =
x <= x' && x' <= x + width &&
y <= y' && y' <= y + width

method on_click ?start ?stop f =
on_click ?start ?stop
(fun ev ->
if self#contains ev.mouse_x ev.mouse_y then
f ev.mouse_x ev.mouse_y)
end

square 类相当直接,下面的 circle 类也很相似:

class circle r x y = object(self)
val mutable x: int = x
method x = x

val mutable y: int = y
method y = y

val mutable radius = r
method radius = radius

method draw = fill_circle x y radius

method private contains x' y' =
let dx = x' - x in
let dy = y' - y in
dx * dx + dy * dy <= radius * radius

method on_click ?start ?stop f =
on_click ?start ?stop
(fun ev ->
if self#contains ev.mouse_x ev.mouse_y then
f ev.mouse_x ev.mouse_y)
end

这些类有很多共同点,把共同功能提取到父类中会很有用。可以轻松把 xy 的定义移到父类中,但 on_click 怎么办?它的定义依赖 contains,而每个类中的 contains 定义都不同。解决方案是创建虚类:这个类声明一个 contains 方法,但把它的定义留给子类。

下面是更简洁的定义,先从一个虚 shape 类开始,它实现 on_clickon_mousedown

class virtual shape x y = object(self)
method virtual private contains: int -> int -> bool

val mutable x: int = x
method x = x

val mutable y: int = y
method y = y

method on_click ?start ?stop f =
on_click ?start ?stop
(fun ev ->
if self#contains ev.mouse_x ev.mouse_y then
f ev.mouse_x ev.mouse_y)

method on_mousedown ?start ?stop f =
on_mousedown ?start ?stop
(fun ev ->
if self#contains ev.mouse_x ev.mouse_y then
f ev.mouse_x ev.mouse_y)
end

现在可以通过继承 shape 来定义 squarecircle

class square w x y = object
inherit shape x y

val mutable width = w
method width = width

method draw = fill_rect x y width width

method private contains x' y' =
x <= x' && x' <= x + width &&
y <= y' && y' <= y + width
end

class circle r x y = object
inherit shape x y

val mutable radius = r
method radius = radius

method draw = fill_circle x y radius

method private contains x' y' =
let dx = x' - x in
let dy = y' - y in
dx * dx + dy * dy <= radius * radius
end

看待 virtual 类的一种方式是把它想成函子,其中“输入”是已声明但未定义的虚方法和字段。函子应用则通过继承实现,也就是在继承时给虚方法提供具体实现。

14.10 初始化器(Initializers)

可以在实例化类时执行表达式,做法是把表达式放在对象表达式之前,或放在字段的初始值中:

# class obj x =
let () = Stdio.printf "Creating obj %d\n" x in
object
val field = Stdio.printf "Initializing field\n"; x
end;;
class obj : int -> object val field : int end
# let o = new obj 3;;
Creating obj 3
Initializing field
val o : obj = <obj>

不过,这些表达式会在对象创建前执行,不能引用对象方法。如果实例化期间需要使用对象方法,可以使用初始化器(initializer)。初始化器是在实例化期间执行的表达式,但它会在对象创建之后运行。

例如,假设要用 growing_circle 类扩展前面的形状模块,让圆在被点击时扩张。可以继承 circle,并使用继承来的 on_click 为点击事件添加处理器:

class growing_circle r x y = object(self)
inherit circle r x y

initializer
self#on_click (fun _x _y -> radius <- radius * 2)
end

14.11 多重继承(Multiple Inheritance)

当一个类从多个父类继承时,就是在使用多重继承(multiple inheritance)。多重继承扩展了组合类的方式,尤其在配合虚类时可能很有用。不过,它也可能很棘手,特别是当继承层次是图而不是树时,因此应谨慎使用。

14.11.1 名称如何解析(How Names Are Resolved)

多重继承的主要棘手之处来自命名:当某个方法或字段名在多个类中都有定义时,会发生什么?

如果只记住 OCaml 继承的一件事,那就是:继承像文本包含。如果一个名字有多个定义,最后一个定义获胜。

例如,考虑下面这个类,它继承 square,并定义一个新的 draw 方法,用 draw_rect 而不是 fill_rect 来绘制正方形:

class square_outline w x y = object
inherit square w x y
method draw = draw_rect x y width width
end

由于 inherit 声明出现在方法定义之前,新的 draw 方法会覆盖旧方法,于是正方形会用 draw_rect 绘制。但如果把 square_outline 定义成下面这样呢?

class square_outline w x y = object
method draw = draw_rect x y w w
inherit square w x y
end

这里 inherit 声明在方法定义之后,所以来自 squaredraw 方法会覆盖另一个定义,正方形会用 fill_rect 绘制。

再强调一次:要理解继承的含义,可以把每个 inherit 指令替换成它的定义,然后取每个方法或字段的最后一个定义。注意,继承添加的方法和字段,是其类类型中列出的那些;被类型隐藏的私有方法不会被包含进来。

14.11.2 Mixin

什么时候该使用多重继承?问不同的人,很可能会得到多个甚至激烈的答案。有些人会说多重继承过于复杂;另一些人会说继承本身就有问题,应该改用对象组合。但无论问谁,你都很少会听到“多重继承很棒,应该广泛使用”。

无论如何,如果你在用对象编程,有一种多重继承的一般模式既有用又相对简单:mixin 模式。一般来说,mixin 只是一个虚类,它基于另一个特性实现某个特性。如果你有一个类实现方法 A,又有一个 mixin M 能基于 A 提供方法 B,那么就可以继承 M,也就是把它“混入”进来,从而得到特性 B

这太抽象了,所以用交互式形状举例。也许我们希望允许用户用鼠标拖动某个形状。可以为任何拥有可变 xy 字段,并且拥有用于添加事件处理器的 on_mousedown 方法的对象定义这个功能:

class virtual draggable = object(self)
method virtual on_mousedown:
?start:unit Deferred.t ->
?stop:unit Deferred.t ->
(int -> int -> unit) -> unit
val virtual mutable x: int
val virtual mutable y: int

val mutable dragging = false
method dragging = dragging

initializer
self#on_mousedown
(fun mouse_x mouse_y ->
let offset_x = x - mouse_x in
let offset_y = y - mouse_y in
let mouse_up = Ivar.create () in
let stop = Ivar.read mouse_up in
dragging <- true;
on_mouseup ~stop
(fun _ ->
Ivar.fill mouse_up ();
dragging <- false);
on_mousemove ~stop
(fun ev ->
x <- ev.mouse_x + offset_x;
y <- ev.mouse_y + offset_y))
end

这样就可以用多重继承创建可拖动形状:

class small_square = object
inherit square 20 40 40
inherit draggable
end

也可以用 mixin 创建动画形状。每个动画形状都有一组更新函数,会在动画期间被调用。创建一个 animated mixin,用来提供这个更新列表,并确保形状动画运行时定期调用其中函数:

class virtual animated span = object(self)
method virtual on_click:
?start:unit Deferred.t ->
?stop:unit Deferred.t ->
(int -> int -> unit) -> unit
val mutable updates: (int -> unit) list = []
val mutable step = 0
val mutable running = false

method running = running

method animate =
step <- 0;
running <- true;
let stop =
Clock.after span
>>| fun () -> running <- false
in
Clock.every ~stop (Time.Span.of_sec (1.0 /. 24.0))
(fun () ->
step <- step + 1;
List.iter ~f:(fun f -> f step) updates
)

initializer
self#on_click (fun _x _y -> if not self#running then self#animate)
end

我们用初始化器向这个更新列表添加函数。例如,下面这个类会产生被点击时向右移动一秒的圆:

class my_circle = object
inherit circle 20 50 50
inherit animated Time.Span.second
initializer updates <- [fun _ -> x <- x + 5]
end

这些初始化器也可以通过 mixin 添加:

class virtual linear x' y' = object
val virtual mutable updates: (int -> unit) list
val virtual mutable x: int
val virtual mutable y: int

initializer
let update _ =
x <- x + x';
y <- y + y'
in
updates <- update :: updates
end

let pi = (Float.atan 1.0) *. 4.0

class virtual harmonic offset x' y' = object
val virtual mutable updates: (int -> unit) list
val virtual mutable x: int
val virtual mutable y: int

initializer
let update step =
let m = Float.sin (offset +. ((Float.of_int step) *. (pi /. 64.))) in
let x' = Float.to_int (m *. Float.of_int x') in
let y' = Float.to_int (m *. Float.of_int y') in
x <- x + x';
y <- y + y'
in
updates <- update :: updates
end

由于 linearharmonic mixin 只用于副作用,它们可以在同一对象中被多次继承,用来产生多种不同动画:

class my_square x y = object
inherit square 40 x y
inherit draggable
inherit animated (Time.Span.of_int_sec 5)
inherit linear 5 0
inherit harmonic 0.0 7 ~-10
end

let my_circle = object
inherit circle 30 250 250
inherit animated (Time.Span.minute)
inherit harmonic 0.0 10 0
inherit harmonic (pi /. 2.0) 0 10
end

14.11.3 显示动画形状(Displaying the Animated Shapes)

最后,通过创建一个 main 函数在图形显示上绘制一些形状,并用 Async 调度器运行这个函数,完成形状模块:

let main () =
let shapes = [
(my_circle :> drawable);
(new my_square 50 350 :> drawable);
(new my_square 50 200 :> drawable);
(new growing_circle 20 70 70 :> drawable);
] in
let repaint () =
clear_graph ();
List.iter ~f:(fun s -> s#draw) shapes;
synchronize ()
in
open_graph "";
auto_synchronize false;
Clock.every (Time.Span.of_sec (1.0 /. 24.0)) repaint

let () = never_returns (Scheduler.go_main ~main ())

main 函数创建一个要显示的形状列表,并定义真正把它们画到显示上的 repaint 函数。然后打开图形显示,并要求 Async 定期运行 repaint

最后,通过链接 async_graphics 包来构建二进制,它会拉入所有其他依赖:

(executable
(name shapes)
(modules shapes)
(libraries async_graphics))
$ dune build shapes.exe

运行这个二进制后,应当出现一个新的图形窗口。在 macOS 上,需要先安装 X11 包,系统会提示你。试着点击各种组件,看看随之展开的动画效果。

这里描述的图形库是 OCaml 内置的图形库,与其说它适合实际生产,不如说更适合学习。还有几个第三方库为不同图形子系统提供了更复杂的绑定:

Lablgtk : GTK widget 库的强类型接口。

LablGL : OCaml 与 OpenGL 之间的接口。OpenGL 是受到广泛支持的 3D 渲染标准。

js_of_ocaml : 把 OCaml 代码编译成 JavaScript,并提供 WebGL 绑定。这正在成为浏览器中 3D 渲染的新兴标准。