第 7 章:变体(Variants)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 7。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
变体类型是 OCaml 最有用的特性之一,也是最不寻常的特性之一。它们让你能够表示可能采取多种不同形式的数据,其中每种形式都由一个显式标签标记。正如我们将看到的,当变体与模式匹配结合时,会提供一种强大的方式来表示复杂数据,并组织围绕这些信息进行的情况分析。
变体类型声明的基本语法如下:
type <variant> =
| <Tag> [ of <type> [* <type>]... ]
| <Tag> [ of <type> [* <type>]... ]
| ...
每一行本质上代表变体的一种情况。每种情况都有一个关联标签,也称为构造器,因为你会用它来构造值;它还可以可选地带有一组字段,每个字段都有指定类型。
来考虑一个具体例子,看看变体为什么有用。大多数类 UNIX 操作系统都支持终端,作为一种基础的、基于文本的用户界面。几乎所有这类终端都支持一组八种基本颜色。
这些颜色可以很自然地表示为变体。每种颜色都声明为一个简单标签,使用竖线分隔不同情况。注意,变体标签必须大写。
open Base
open Stdio
type basic_color =
| Black | Red | Green | Yellow | Blue | Magenta | Cyan | White
如下所示,由 basic_color 定义引入的变体标签,可以用于构造该类型的值。
# Cyan;;
- : basic_color = Cyan
# [Blue; Magenta; Red];;
- : basic_color list = [Blue; Magenta; Red]
下面的函数使用模式匹配,把每种颜色转换为与其对应的整数码;这些整数码用于把颜色信息传递给终端。
# let basic_color_to_int = function
| Black -> 0 | Red -> 1 | Green -> 2 | Yellow -> 3
| Blue -> 4 | Magenta -> 5 | Cyan -> 6 | White -> 7;;
val basic_color_to_int : basic_color -> int = <fun>
# List.map ~f:basic_color_to_int [Blue;Red];;
- : int list = [4; 1]
我们知道上面的函数处理了 basic_color 中的每一种颜色,因为如果漏掉某一种,编译器会发出警告:
# let incomplete_color_to_int = function
| Black -> 0 | Red -> 1 | White -> 7;;
Lines 1-2, characters 31-41:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
(Green|Yellow|Blue|Magenta|Cyan)
val incomplete_color_to_int : basic_color -> int = <fun>
无论如何,使用正确函数后,就可以生成转义码,改变终端中显示的给定字符串颜色。
# let color_by_number number text =
Printf.sprintf "\027[38;5;%dm%s\027[0m" number text;;
val color_by_number : int -> string -> string = <fun>
# let blue = color_by_number (basic_color_to_int Blue) "Blue";;
val blue : string = "\027[38;5;4mBlue\027[0m"
# printf "Hello %s World!\n" blue;;
Hello Blue World!
- : unit = ()
在大多数终端上,单词 “Blue” 会被渲染成蓝色。
在这个例子中,变体的情况只是没有关联数据的简单标签。这实质上与 C 和 Java 等语言中的枚举相同。但正如我们将看到的,变体能做的事情远不止表示简单枚举。
碰巧的是,枚举不足以有效描述现代终端能够显示的完整颜色集合。许多终端,包括历史悠久的 xterm,都支持 256 种不同颜色,这些颜色分为以下几组:
- 八种基本颜色,各有常规和加粗版本。
- 一个
6 * 6 * 6的 RGB 色彩立方体。 - 一个 24 级灰度梯度。
我们也会把这个更复杂的颜色空间表示为变体,但这一次,不同标签会带有参数,用来描述每种情况中可用的数据。注意,变体可以有多个参数,这些参数用 * 分隔。
type weight = Regular | Bold
type color =
| Basic of basic_color * weight (* basic colors, regular and bold *)
| RGB of int * int * int (* 6x6x6 color cube *)
| Gray of int (* 24 grayscale levels *)
和之前一样,可以使用这些引入的标签构造新定义类型的值。
# [RGB (250,70,70); Basic (Green, Regular)];;
- : color list = [RGB (250, 70, 70); Basic (Green, Regular)]
同样,我们会使用模式匹配把颜色转换为对应整数。在这个例子中,模式匹配做的不只是区分不同情况;它还允许我们抽取与每个标签关联的数据:
# let color_to_int = function
| Basic (basic_color,weight) ->
let base = match weight with Bold -> 8 | Regular -> 0 in
base + basic_color_to_int basic_color
| RGB (r,g,b) -> 16 + b + g * 6 + r * 36
| Gray i -> 232 + i;;
val color_to_int : color -> int = <fun>
现在,可以使用完整可用颜色集合打印文本:
# let color_print color s =
printf "%s\n" (color_by_number (color_to_int color) s);;
val color_print : color -> string -> unit = <fun>
# color_print (Basic (Red,Bold)) "A bold red!";;
A bold red!
- : unit = ()
# color_print (Gray 4) "A muted gray...";;
A muted gray...
- : unit = ()
变体、元组与括号(Variants, Tuples and Parens)
带有多个参数的变体看起来非常像元组。考虑前面定义的 color 类型中的这个值:
# RGB (200,0,200);;
- : color = RGB (200, 0, 200)
它确实看起来像是创建了一个三元组,然后用 RGB 构造器把它包了起来。但实际并非如此。可以先创建一个元组,再试着把它放进 RGB 构造器中:
# let purple = (200,0,200);;
val purple : int * int * int = (200, 0, 200)
# RGB purple;;
Line 1, characters 1-11:
Error: The constructor RGB expects 3 argument(s),
but is applied here to 1 argument(s)
也可以创建明确包含元组的变体,如下所示:
# type tupled = Tupled of (int * int);;
type tupled = Tupled of (int * int)
这种语法差异不幸地相当微妙,归根结底就是参数外面多了一组括号。但以这种方式定义后,就可以自由地把元组放进去和取出来。
# let of_tuple x = Tupled x;;
val of_tuple : int * int -> tupled = <fun>
# let to_tuple (Tupled x) = x;;
val to_tuple : tupled -> int * int = <fun>
另一方面,如果定义一个没有括号的变体,就会得到与 RGB 构造器相同的行为。
# type untupled = Untupled of int * int;;
type untupled = Untupled of int * int
# let of_tuple x = Untupled x;;
Line 1, characters 18-28:
Error: The constructor Untupled expects 2 argument(s),
but is applied here to 1 argument(s)
# let to_tuple (Untupled x) = x;;
Line 1, characters 14-26:
Error: The constructor Untupled expects 2 argument(s),
but is applied here to 1 argument(s)
注意,虽然不能直接从这种类型中整体抓取元组,但可以通过显式解构和重构所需数据,达到差不多的目的。
# let of_tuple (x,y) = Untupled (x,y);;
val of_tuple : int * int -> untupled = <fun>
# let to_tuple (Untupled (x,y)) = (x,y);;
val to_tuple : untupled -> int * int = <fun>
多参数变体和包含元组的变体之间的差异,主要与性能有关。多参数变体在内存中是单个已分配块,而包含元组的变体需要为元组额外分配一个堆块。第 24 章“值的内存表示(Memory Representation of Values)”会进一步介绍 OCaml 的内存表示。
7.1 捕获所有情况与重构(Catch-All Cases and Refactoring)
OCaml 的类型系统可以充当重构工具,在接口发生变化时,警告你哪些地方的代码需要更新。这在变体语境中特别有价值。
考虑如果把 color 的定义改成下面这样,会发生什么:
type color =
| Basic of basic_color (* basic colors *)
| Bold of basic_color (* bold basic colors *)
| RGB of int * int * int (* 6x6x6 color cube *)
| Gray of int (* 24 grayscale levels *)
我们本质上把 Basic 情况拆成了 Basic 和 Bold 两种情况,并且 Basic 从带有两个参数变成了带有一个参数。之前写的 color_to_int 仍然期望变体的旧结构,如果尝试再次编译同一段代码,编译器会注意到不一致:
# let color_to_int = function
| Basic (basic_color,weight) ->
let base = match weight with Bold -> 8 | Regular -> 0 in
base + basic_color_to_int basic_color
| RGB (r,g,b) -> 16 + b + g * 6 + r * 36
| Gray i -> 232 + i;;
Line 2, characters 13-33:
Error: This pattern matches values of type 'a * 'b
but a pattern was expected which matches values of type basic_color
这里,编译器抱怨 Basic 标签使用了错误数量的参数。不过,如果修复这一点,编译器会标出第二个问题,也就是我们没有处理新的 Bold 标签:
# let color_to_int = function
| Basic basic_color -> basic_color_to_int basic_color
| RGB (r,g,b) -> 16 + b + g * 6 + r * 36
| Gray i -> 232 + i;;
Lines 1-4, characters 20-24:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Bold _
val color_to_int : color -> int = <fun>
继续修复后,就会得到正确实现:
# let color_to_int = function
| Basic basic_color -> basic_color_to_int basic_color
| Bold basic_color -> 8 + basic_color_to_int basic_color
| RGB (r,g,b) -> 16 + b + g * 6 + r * 36
| Gray i -> 232 + i;;
val color_to_int : color -> int = <fun>
如我们所见,类型错误指出了完成代码重构所需修复的事项。这非常有用,但为了让它可靠地发挥作用,你需要用一种最大化编译器帮助你发现 bug 的机会的方式编写代码。为此,一个有用的经验法则是:在模式匹配中避免捕获所有情况。
下面的例子说明了捕获所有情况如何与穷尽性检查交互。假设我们想要一个用于旧终端的 color_to_int 版本:前 16 种颜色(八种 basic_color 的常规和加粗版本)正常渲染,其他所有内容都渲染成白色。我们可能会把函数写成下面这样:
# let oldschool_color_to_int = function
| Basic (basic_color,weight) ->
let base = match weight with Bold -> 8 | Regular -> 0 in
base + basic_color_to_int basic_color
| _ -> basic_color_to_int White;;
val oldschool_color_to_int : color -> int = <fun>
如果随后应用上面相同的修复,就会得到这个版本:
# let oldschool_color_to_int = function
| Basic basic_color -> basic_color_to_int basic_color
| _ -> basic_color_to_int White;;
val oldschool_color_to_int : color -> int = <fun>
由于存在捕获所有情况,我们不会再收到遗漏 Bold 情况的警告。这就是为什么你应该小心捕获所有情况:它们会抑制穷尽性检查。
7.2 组合记录与变体(Combining Records and Variants)
术语代数数据类型(algebraic data types)常用于描述一组包含变体、记录和元组的类型。代数数据类型是一种特别有用且强大的数据描述语言。它们实用性的核心在于,它们组合了两类不同类型:积类型(product types),例如元组和记录,它们把多个不同类型组合在一起,在数学上类似笛卡尔积;以及和类型(sum types),例如变体,它们让你把多种不同可能性合并进同一个类型,在数学上类似不交并。
代数数据类型的许多能力来自构造“和”与“积”的分层组合。我们通过重新考虑第 6 章“记录(Records)”中描述的 Log_entry 消息类型,看看这能带来什么。
module Time_ns = Core.Time_ns
module Log_entry = struct
type t =
{ session_id : string
; time : Time_ns.t
; important : bool
; message : string
}
end
这个记录类型把多块数据组合成单个值。具体来说,单个 Log_entry.t 有一个 session_id,并且有一个 time,并且有一个 important 标志,并且有一个 message。更一般地说,可以把记录类型看作合取。另一方面,变体是析取,它们让你能够表示多种可能性。为了构造一个展示其用处的例子,先写出与 Log_entry 同时出现的其他消息类型。
module Heartbeat = struct
type t =
{ session_id : string
; time : Time_ns.t
; status_message : string
}
end
module Logon = struct
type t =
{ session_id : string
; time : Time_ns.t
; user : string
; credentials : string
}
end
当想表示可能是这三种类型中任意一种的值时,变体就派上用场了。下面的 client_message 类型正允许你这样做。
type client_message =
| Logon of Logon.t
| Heartbeat of Heartbeat.t
| Log_entry of Log_entry.t
具体来说,一个 client_message 要么是 Logon,要么是 Heartbeat,要么是 Log_entry。如果想编写通用处理消息的代码,而不是专门处理固定消息类型的代码,就需要类似 client_message 的东西,作为不同可能消息的总括类型。随后可以对 client_message 做匹配,确定正在处理的具体消息类型。
可以使用变体表示不同情况之间的差异,并使用记录表示共享结构,从而提高类型精度。考虑下面这个函数,它接受一个 client_message 列表,并返回给定用户生成的所有消息。相关代码通过对消息列表执行 fold 来实现,其中累加器是一对值:
- 目前为止已经看到的该用户会话标识符集合。
- 目前为止与该用户关联的消息集合。
下面是具体代码:
# let messages_for_user user messages =
let (user_messages,_) =
List.fold messages ~init:([], Set.empty (module String))
~f:(fun ((messages,user_sessions) as acc) message ->
match message with
| Logon m ->
if String.(m.user = user) then
(message::messages, Set.add user_sessions m.session_id)
else acc
| Heartbeat _ | Log_entry _ ->
let session_id =
match message with
| Logon m -> m.session_id
| Heartbeat m -> m.session_id
| Log_entry m -> m.session_id
in
if Set.mem user_sessions session_id then
(message::messages,user_sessions)
else acc)
in
List.rev user_messages;;
val messages_for_user : string -> client_message list -> client_message list =
<fun>
在上面的代码中,我们利用了记录 m 的类型已知这一事实,因此无需用字段所属模块来限定记录字段。例如,我们写 m.user,而不是 m.Logon.user。
上面代码的一个烦人之处是,确定会话 ID 的逻辑有些重复:它考虑每种可能的消息类型(包括 Logon 情况,虽然在代码的那个位置实际上不可能出现),并在每种情况中抽取会话 ID。这种逐消息类型处理似乎没有必要,因为所有消息类型中的会话 ID 工作方式相同。
可以通过重构类型来显式反映不同消息之间共享的信息,从而改进代码。第一步是缩减每种逐消息记录的定义,让它们只包含该记录独有的信息:
module Log_entry = struct
type t =
{ important : bool
; message : string
}
end
module Heartbeat = struct
type t = { status_message : string }
end
module Logon = struct
type t =
{ user : string
; credentials : string
}
end
然后可以定义一个组合这些类型的变体类型:
type details =
| Logon of Logon.t
| Heartbeat of Heartbeat.t
| Log_entry of Log_entry.t
另外,还需要一个记录,包含所有消息共有的字段:
module Common = struct
type t =
{ session_id : string
; time : Time_ns.t
}
end
完整消息可以表示为一对 Common.t 和 details。利用这一点,可以把前面的例子改写如下。注意,我们添加了额外类型标注,好让 OCaml 正确识别记录字段。否则,就需要显式限定这些字段。
# let messages_for_user user (messages : (Common.t * details) list) =
let (user_messages,_) =
List.fold messages ~init:([],Set.empty (module String))
~f:(fun ((messages,user_sessions) as acc) ((common,details) as message) ->
match details with
| Logon m ->
if String.(=) m.user user then
(message::messages, Set.add user_sessions common.session_id)
else acc
| Heartbeat _ | Log_entry _ ->
if Set.mem user_sessions common.session_id then
(message::messages, user_sessions)
else acc)
in
List.rev user_messages;;
val messages_for_user :
string -> (Common.t * details) list -> (Common.t * details) list = <fun>
可以看到,抽取会话 ID 的代码已经被简单表达式 common.session_id 取代。
此外,这种设计允许我们抓取具体消息,并分发代码来只处理该消息类型。具体来说,虽然可以使用类型 Common.t * details 表示任意消息,但可以使用 Common.t * Logon.t 表示 logon 消息。因此,如果有处理单独消息类型的函数,就可以像下面这样编写分发函数:
# let handle_message server_state ((common:Common.t), details) =
match details with
| Log_entry m -> handle_log_entry server_state (common,m)
| Logon m -> handle_logon server_state (common,m)
| Heartbeat m -> handle_heartbeat server_state (common,m);;
val handle_message : server_state -> Common.t * details -> unit = <fun>
并且,在类型层面上可以明确看出,handle_log_entry 只看到 Log_entry 消息,handle_logon 只看到 Logon 消息,如此类推。
7.2.1 嵌入式记录(Embedded Records)
如果不需要能够把记录类型与变体分开传递,那么 OCaml 允许我们把记录直接嵌入变体中。
type details =
| Logon of { user : string; credentials : string }
| Heartbeat of { status_message : string }
| Log_entry of { important : bool; message : string }
尽管类型不同,仍然可以用和之前基本相同的方式编写 messages_for_user。
# let messages_for_user user (messages : (Common.t * details) list) =
let (user_messages,_) =
List.fold messages ~init:([],Set.empty (module String))
~f:(fun ((messages,user_sessions) as acc) ((common,details) as message) ->
match details with
| Logon m ->
if String.(=) m.user user then
(message::messages, Set.add user_sessions common.session_id)
else acc
| Heartbeat _ | Log_entry _ ->
if Set.mem user_sessions common.session_id then
(message::messages, user_sessions)
else acc)
in
List.rev user_messages;;
val messages_for_user :
string -> (Common.t * details) list -> (Common.t * details) list = <fun>
带内联记录的变体,比让变体包含对独立记录类型的引用更简洁,也更高效,因为它们不需要为变体内容单独分配对象。
主要缺点很明显:内联记录不能被当作独立对象处理。如下所示,OCaml 会拒绝尝试这样做的代码。
# let get_logon_contents = function
| Logon m -> Some m
| _ -> None;;
Line 2, characters 23-24:
Error: This form is not allowed as the type of the inlined record could escape.
7.3 变体与递归数据结构(Variants and Recursive Data Structures)
变体的另一个常见应用,是表示树状递归数据结构。我们会通过设计一种简单的布尔表达式语言,展示如何做到这一点。这类语言在任何需要指定过滤器的地方都可能有用,从数据包分析器到邮件客户端都会用到过滤器。
这种语言中的表达式会由变体 expr 定义;我们想支持的每种表达式都有一个标签:
type 'a expr =
| Base of 'a
| Const of bool
| And of 'a expr list
| Or of 'a expr list
| Not of 'a expr
注意,类型 expr 的定义是递归的,这意味着一个 expr 可以包含其他 expr。此外,expr 由多态类型 'a 参数化,该类型用于指定放在 Base 标签下的值的类型。
每个标签的目的都相当直接。And、Or 和 Not 是构建布尔表达式的基本运算符,Const 允许你输入常量 true 和 false。
Base 标签允许你把 expr 连接到应用程序中,让你指定某种基础谓词类型的元素,而这些元素的真假由应用程序决定。如果正在为邮件处理器编写过滤语言,那么基础谓词可能会指定要针对邮件运行的测试,如下面这个例子所示:
type mail_field = To | From | CC | Date | Subject
type mail_predicate =
{ field : mail_field
; contains : string
}
使用前面的代码,可以构造一个以 mail_predicate 为基础谓词的简单表达式:
# let test field contains = Base { field; contains };;
val test : mail_field -> string -> mail_predicate expr = <fun>
# And [ Or [ test To "doligez"; test CC "doligez" ];
test Subject "runtime";
];;
- : mail_predicate expr =
And
[Or
[Base {field = To; contains = "doligez"};
Base {field = CC; contains = "doligez"}];
Base {field = Subject; contains = "runtime"}]
能够构造这样的表达式还不够;还需要能够对它们求值。下面的函数正是做这件事的:
# let rec eval expr base_eval =
(* a shortcut, so we don't need to repeatedly pass [base_eval]
explicitly to [eval] *)
let eval' expr = eval expr base_eval in
match expr with
| Base base -> base_eval base
| Const bool -> bool
| And exprs -> List.for_all exprs ~f:eval'
| Or exprs -> List.exists exprs ~f:eval'
| Not expr -> not (eval' expr);;
val eval : 'a expr -> ('a -> bool) -> bool = <fun>
代码结构相当直接:我们只是对数据结构进行模式匹配,并根据看到的标签执行适当计算。要在具体例子中使用这个求值器,只需要编写 base_eval 函数,让它能够对基础谓词求值。
表达式上的另一个有用操作是简化(simplification),也就是把一个布尔表达式化简为等价但更小的表达式的过程。首先,构建几个简化构造函数,它们映射 expr 的标签。
下面的 and_ 函数做了几件事:
- 如果
And的任意分支本身是false,就把整个表达式化简为常量false。 - 删除
And中任何值为常量true的分支。 - 如果
And只有一个分支,就删除And。 - 如果
And没有分支,就把它化简为Const true。
代码如下。
# let and_ l =
if List.exists l ~f:(function Const false -> true | _ -> false)
then Const false
else
match List.filter l ~f:(function Const true -> false | _ -> true) with
| [] -> Const true
| [ x ] -> x
| l -> And l;;
val and_ : 'a expr list -> 'a expr = <fun>
Or 是 And 的对偶,可以看到,or_ 的代码遵循与 and_ 类似的模式,主要是反转 true 和 false 的角色。
# let or_ l =
if List.exists l ~f:(function Const true -> true | _ -> false) then Const true
else
match List.filter l ~f:(function Const false -> false | _ -> true) with
| [] -> Const false
| [x] -> x
| l -> Or l;;
val or_ : 'a expr list -> 'a expr = <fun>
最后,not_ 只对常量做特殊处理,把普通布尔取反函数应用到它们上面。
# let not_ = function
| Const b -> Const (not b)
| e -> Not e;;
val not_ : 'a expr -> 'a expr = <fun>
现在可以写一个基于前面函数的简化例程。注意,这个函数是递归的,因为它会以自底向上的方式,把所有这些简化应用到整个表达式上。
# let rec simplify = function
| Base _ | Const _ as x -> x
| And l -> and_ (List.map ~f:simplify l)
| Or l -> or_ (List.map ~f:simplify l)
| Not e -> not_ (simplify e);;
val simplify : 'a expr -> 'a expr = <fun>
现在可以把它应用到一个布尔表达式上,看看它简化得如何。
# simplify (Not (And [ Or [Base "it's snowing"; Const true];
Base "it's raining"]));;
- : string expr = Not (Base "it's raining")
这里,它正确地把 Or 分支转换为 Const true,然后完全消除了 And,因为此时 And 只剩下一个非平凡组成部分。
不过,它也会漏掉一些简化。特别是,如果加入双重否定,看看会发生什么。
# simplify (Not (And [ Or [Base "it's snowing"; Const true];
Not (Not (Base "it's raining"))]));;
- : string expr = Not (Not (Not (Base "it's raining")))
它没有移除双重否定,原因很容易看出。not_ 函数有一个捕获所有情况,因此除了它显式考虑的一种情况,也就是对常量取反,其他情况都会被忽略。捕获所有情况通常不是好主意;如果把代码写得更显式,就会看到漏掉双重否定这件事更加明显:
# let not_ = function
| Const b -> Const (not b)
| (Base _ | And _ | Or _ | Not _) as e -> Not e;;
val not_ : 'a expr -> 'a expr = <fun>
当然,可以简单地为双重否定添加一个显式情况来修复它:
# let not_ = function
| Const b -> Const (not b)
| Not e -> e
| (Base _ | And _ | Or _ ) as e -> Not e;;
val not_ : 'a expr -> 'a expr = <fun>
布尔表达式语言这个例子不只是玩具。Core 中有一个非常符合这种精神的模块,名为 Blang,是 boolean language 的缩写,并且在各种应用中有许多实际用途。简化算法尤其有用:当一些基础谓词的求值结果已知时,可以用它来特化表达式求值。
更一般地说,使用变体构建递归数据结构是一种常见技术,从设计小语言到构建复杂数据结构都能看到它。
7.4 多态变体(Polymorphic Variants)
除了目前已经看到的普通变体之外,OCaml 还支持所谓的多态变体。正如我们将看到的,多态变体比普通变体更灵活,语法上也更轻量,但这种额外能力是有代价的。
在语法上,多态变体通过开头的反引号与普通变体区分。并且与普通变体不同,多态变体可以不经过显式类型声明就使用:
# let three = `Int 3;;
val three : [> `Int of int ] = `Int 3
# let four = `Float 4.;;
val four : [> `Float of float ] = `Float 4.
# let nan = `Not_a_number;;
val nan : [> `Not_a_number ] = `Not_a_number
# [three; four; nan];;
- : [> `Float of float | `Int of int | `Not_a_number ] list =
[`Int 3; `Float 4.; `Not_a_number]
可以看到,多态变体类型会被自动推断出来,并且当我们把带有不同标签的变体组合在一起时,编译器会推断出一个知道所有这些标签的新类型。注意,在前面的例子中,标签名(例如 `Int)与类型名(int)匹配。这是 OCaml 中的常见约定。
如果类型系统看到同一标签的不兼容用法,就会抱怨:
# let five = `Int "five";;
val five : [> `Int of string ] = `Int "five"
# [three; four; five];;
Line 1, characters 15-19:
Error: This expression has type [> `Int of string ]
but an expression was expected of type
[> `Float of float | `Int of int ]
Types for tag `Int are incompatible
上面变体类型开头的 > 很关键,因为它标记这些类型可以与其他变体类型组合。可以把类型 [> Float of float | Int of int] 读作:描述一个标签包含 `Float of float 和 `Int of int、但还可能包含更多标签的变体。换句话说,可以粗略地把 > 翻译成“这些标签或更多”。
在一些情况下,OCaml 会推断出带 < 的变体类型,表示“这些标签或更少”,如下例所示:
# let is_positive = function
| `Int x -> x > 0
| `Float x -> Float.(x > 0.);;
val is_positive : [< `Float of float | `Int of int ] -> bool = <fun>
这里有 <,是因为 is_positive 无法处理除了 `Float of float 或 `Int of int 之外带其他标签的值,但可以处理拥有这两个标签之一或二者都有的类型。
可以把这些 < 和 > 标记看成所涉及标签的上界和下界。如果同一组标签既是上界又是下界,就会得到一个精确的多态变体类型,它没有任何标记。例如:
# let exact = List.filter ~f:is_positive [three;four];;
val exact : [ `Float of float | `Int of int ] list = [`Int 3; `Float 4.]
也许令人意外,我们还可以创建具有不同上下界的多态变体类型。注意,下面例子中的 Ok 和 Error 来自 Base 的 Result.t 类型。
# let is_positive = function
| `Int x -> Ok (x > 0)
| `Float x -> Ok Float.O.(x > 0.)
| `Not_a_number -> Error "not a number";;
val is_positive :
[< `Float of float | `Int of int | `Not_a_number ] -> (bool, string) result =
<fun>
# List.filter [three; four] ~f:(fun x ->
match is_positive x with Error _ -> false | Ok b -> b);;
- : [< `Float of float | `Int of int | `Not_a_number > `Float `Int ] list =
[`Int 3; `Float 4.]
这里,推断出的类型说明,标签最多可以是 `Float、 `Int 和 `Not_a_number,并且至少必须包含 `Float 和 `Int。正如你已经开始看到的,多态变体可能导致相当复杂的推断类型。
多态变体与捕获所有情况(Polymorphic Variants and Catch-All Cases)
正如在 is_positive 的定义中看到的,match 表达式可能导致推断出变体类型的上界,把可能标签限制在这个匹配能够处理的标签上。如果给 match 表达式添加捕获所有情况,就会得到一个带下界的类型。
# let is_positive_permissive = function
| `Int x -> Ok Int.(x > 0)
| `Float x -> Ok Float.(x > 0.)
| _ -> Error "Unknown number type";;
val is_positive_permissive :
[> `Float of float | `Int of int ] -> (bool, string) result = <fun>
# is_positive_permissive (`Int 0);;
- : (bool, string) result = Ok false
# is_positive_permissive (`Ratio (3,4));;
- : (bool, string) result = Error "Unknown number type"
即使使用普通变体,捕获所有情况也容易出错;而与多态变体一起使用时尤其如此。原因是你没有办法限定函数可能必须处理哪些标签。这样的代码特别容易受拼写错误影响。例如,如果使用 is_positive_permissive 的代码把 Float 错拼成 Floot,错误代码也会毫无怨言地通过编译。
# is_positive_permissive (`Floot 3.5);;
- : (bool, string) result = Error "Unknown number type"
如果使用普通变体,这类拼写错误会因为未知标签而被捕获。一般来说,应该警惕把捕获所有情况和多态变体混在一起。
7.4.1 示例:重新审视终端颜色(Example: Terminal Colors Redux)
为了看看如何在实践中使用多态变体,我们回到终端颜色。设想有一种新的终端类型添加了更多颜色,例如通过添加 alpha 通道,让你能够指定半透明颜色。可以使用普通变体把这个扩展颜色集合建模如下:
type extended_color =
| Basic of basic_color * weight (* basic colors, regular and bold *)
| RGB of int * int * int (* 6x6x6 color space *)
| Gray of int (* 24 grayscale levels *)
| RGBA of int * int * int * int (* 6x6x6x6 color space *)
我们想写一个 extended_color_to_int 函数,它对所有旧颜色类型都像 color_to_int 一样工作,只对包含 alpha 通道的颜色使用新逻辑。可能会尝试把这样的函数写成下面这样:
# let extended_color_to_int = function
| RGBA (r,g,b,a) -> 256 + a + b * 6 + g * 36 + r * 216
| (Basic _ | RGB _ | Gray _) as color -> color_to_int color;;
Line 3, characters 59-64:
Error: This expression has type extended_color
but an expression was expected of type color
这段代码看起来足够合理,但会导致类型错误,因为在编译器看来,extended_color 和 color 是不同且无关的类型。例如,编译器不会认为这两个类型中的 Basic 标签有任何相等关系。
我们想做的是在两个不同变体类型之间共享标签,而多态变体允许我们以自然方式做到这一点。首先,用多态变体重写 basic_color_to_int 和 color_to_int。这里的转换相当直接:
# let basic_color_to_int = function
| `Black -> 0 | `Red -> 1 | `Green -> 2 | `Yellow -> 3
| `Blue -> 4 | `Magenta -> 5 | `Cyan -> 6 | `White -> 7;;
val basic_color_to_int :
[< `Black | `Blue | `Cyan | `Green | `Magenta | `Red | `White | `Yellow ] ->
int = <fun>
# let color_to_int = function
| `Basic (basic_color,weight) ->
let base = match weight with `Bold -> 8 | `Regular -> 0 in
base + basic_color_to_int basic_color
| `RGB (r,g,b) -> 16 + b + g * 6 + r * 36
| `Gray i -> 232 + i;;
val color_to_int :
[< `Basic of
[< `Black
| `Blue
| `Cyan
| `Green
| `Magenta
| `Red
| `White
| `Yellow ] *
[< `Bold | `Regular ]
| `Gray of int
| `RGB of int * int * int ] ->
int = <fun>
现在可以尝试编写 extended_color_to_int。这段代码的关键问题在于,extended_color_to_int 需要以更窄的类型调用 color_to_int,也就是包含更少标签的类型。如果写得恰当,这种变窄可以通过模式匹配完成。特别是在下面的代码中,变量 color 的类型只包含 `Basic、 `RGB 和 `Gray 这些标签,不包含 `RGBA:
# let extended_color_to_int = function
| `RGBA (r,g,b,a) -> 256 + a + b * 6 + g * 36 + r * 216
| (`Basic _ | `RGB _ | `Gray _) as color -> color_to_int color;;
val extended_color_to_int :
[< `Basic of
[< `Black
| `Blue
| `Cyan
| `Green
| `Magenta
| `Red
| `White
| `Yellow ] *
[< `Bold | `Regular ]
| `Gray of int
| `RGB of int * int * int
| `RGBA of int * int * int * int ] ->
int = <fun>
前面的代码比想象中更微妙。特别是,如果使用捕获所有情况,而不是显式枚举这些情况,类型就不会再变窄,因此编译会失败:
# let extended_color_to_int = function
| `RGBA (r,g,b,a) -> 256 + a + b * 6 + g * 36 + r * 216
| color -> color_to_int color;;
Line 3, characters 29-34:
Error: This expression has type [> `RGBA of int * int * int * int ]
but an expression was expected of type
[< `Basic of
[< `Black
| `Blue
| `Cyan
| `Green
| `Magenta
| `Red
| `White
| `Yellow ] *
[< `Bold | `Regular ]
| `Gray of int
| `RGB of int * int * int ]
The second variant type does not allow tag(s) `RGBA
来考虑如何把代码变成一个真正的库:实现放在 ml 文件中,接口放在单独的 mli 中,就像第 5 章“文件、模块与程序(Files, Modules, and Programs)”中看到的那样。先从 mli 开始:
open Base
type basic_color =
[ `Black | `Blue | `Cyan | `Green
| `Magenta | `Red | `White | `Yellow ]
type color =
[ `Basic of basic_color * [ `Bold | `Regular ]
| `Gray of int
| `RGB of int * int * int ]
type extended_color =
[ color
| `RGBA of int * int * int * int ]
val color_to_int : color -> int
val extended_color_to_int : extended_color -> int
这里,extended_color 被定义为 color 的显式扩展。还要注意,我们把这些类型都定义为精确变体。可以像下面这样实现这个库:
open Base
type basic_color =
[ `Black | `Blue | `Cyan | `Green
| `Magenta | `Red | `White | `Yellow ]
type color =
[ `Basic of basic_color * [ `Bold | `Regular ]
| `Gray of int
| `RGB of int * int * int ]
type extended_color =
[ color
| `RGBA of int * int * int * int ]
let basic_color_to_int = function
| `Black -> 0 | `Red -> 1 | `Green -> 2 | `Yellow -> 3
| `Blue -> 4 | `Magenta -> 5 | `Cyan -> 6 | `White -> 7
let color_to_int = function
| `Basic (basic_color,weight) ->
let base = match weight with `Bold -> 8 | `Regular -> 0 in
base + basic_color_to_int basic_color
| `RGB (r,g,b) -> 16 + b + g * 6 + r * 36
| `Gray i -> 232 + i
let extended_color_to_int = function
| `RGBA (r,g,b,a) -> 256 + a + b * 6 + g * 36 + r * 216
| `Grey x -> 2000 + x
| (`Basic _ | `RGB _ | `Gray _) as color -> color_to_int color
在前面的代码中,我们在 extended_color_to_int 的定义中做了一件有趣的事,它突出了多态变体的一些缺点。具体来说,我们为灰色添加了一些特殊情况处理,而不是使用 color_to_int。不幸的是,我们把 Gray 拼成了 Grey。这正是普通变体会被编译器捕获的那类错误,但使用多态变体时,这会毫无问题地通过编译。发生的只是编译器为 extended_color_to_int 推断了一个更宽的类型,而这个类型恰好与 mli 中列出的较窄类型兼容。因此,这个库可以无错误构建。
$ dune build @all
如果给代码本身添加显式类型标注(而不只是放在 mli 中),编译器就有足够信息警告我们:
let extended_color_to_int : extended_color -> int = function
| `RGBA (r,g,b,a) -> 256 + a + b * 6 + g * 36 + r * 216
| `Grey x -> 2000 + x
| (`Basic _ | `RGB _ | `Gray _) as color -> color_to_int color
具体来说,编译器会抱怨 `Grey 情况未被使用:
$ dune build @all
File "terminal_color.ml", line 30, characters 4-11:
30 | | `Grey x -> 2000 + x
^^^^^^^
Error: This pattern matches values of type [? `Grey of 'a ]
but a pattern was expected which matches values of type extended_color
The second variant type does not allow tag(s) `Grey
一旦有了类型定义,就可以重新审视如何编写让类型变窄的模式匹配。特别是,可以把类型名作为模式匹配的一部分显式使用,方法是在它前面加上 #:
let extended_color_to_int : extended_color -> int = function
| `RGBA (r,g,b,a) -> 256 + a + b * 6 + g * 36 + r * 216
| #color as color -> color_to_int color
当你想缩窄到某个定义很长的类型,而又不想在匹配中啰嗦地写出所有标签时,这很有用。
7.4.2 何时使用多态变体(When to Use Polymorphic Variants)
乍看之下,多态变体像是对普通变体的严格改进。普通变体能做的事它都能做,而且更灵活、更简洁。有什么不好呢?
现实中,大多数时候常规变体是更务实的选择。这是因为多态变体的灵活性有代价。下面是一些缺点:
- 复杂性。多态变体的类型规则比常规变体复杂得多。这意味着大量使用多态变体可能会让你摸不着头脑,难以弄清某段代码为什么能编译或不能编译。它也可能导致长得离谱且难以解读的错误消息。事实上,值层面的简洁往往会被类型层面的冗长抵消。
- 错误发现能力。多态变体是类型安全的,但由于它施加的类型纪律具有灵活性,因此更不容易捕获程序中的 bug。
- 效率。这个影响并不巨大,但多态变体比常规变体稍重一些,而且 OCaml 无法为多态变体上的匹配生成与常规变体匹配一样高效的代码。
尽管如此,多态变体仍然是有用且强大的特性,只是值得理解它们的限制,并明智、适度地使用它们。
多态变体最安全、最常见的用例,大概是普通变体本来已经足够,但语法上太笨重的地方。例如,你经常想创建一种变体类型来编码某个函数的输入或输出,而为了这件事单独声明一个类型并不值得。多态变体在这里非常有用;只要有类型标注约束它们拥有显式、精确的类型,这种做法通常效果很好。
多态变体最容易出问题的地方,恰恰是你充分利用其能力之处;尤其是利用多态变体类型在所支持标签上重叠的能力时。这牵涉到 OCaml 对子类型的支持。正如第 13 章“对象(Objects)”中会进一步讨论的,子类型会带来许多复杂性,而大多数时候,这正是你想避免的复杂性。