第 2 章:导览(A Guided Tour)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 2。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
本章通过一系列小例子概览 OCaml。这些例子覆盖了这门语言的大多数主要特性。它应该能让你对 OCaml 可以做什么形成感觉,但不会在任何单一主题上钻得太深。
本书会一直使用 Base,这是一个比 OCaml 标准库功能更完整、能力也更强的替代标准库。我们还会使用 utop,它是一个 shell,允许你输入表达式并交互式地求值。utop 是 OCaml 标准顶层环境(你可以在命令行输入 ocaml 启动它)的一个更易用版本。下面的说明会假设你使用的是 utop,但普通顶层环境大体也能工作。
在继续之前,请确保你已经按照安装页面完成了设置。
Base 与 Core(Base and Core)
Base 还伴随另一个更大、更全面的标准库替代品 Core 一起发布。本书主要会坚持使用 Base,不过理解这两个库之间的差异也很有价值。
- Base 被设计成轻量、可移植、稳定,同时提供标准库所需的所有基础能力。它的外部依赖极少,因此构建和安装 Base 只需要几秒钟。
- Core 在许多方面扩展了 Base:它加入了堆、哈希集合、函数式队列等新数据结构;提供了表示时间和时区的类型;集成了高效二进制序列化器支持;还有很多其他内容。与此同时,它的依赖也多得多,因此构建时间更长,也会让可执行文件体积增加。
在本书使用的 Base 和 Core 版本(v0.14)中,Core 的可移植性不如 Base,只能运行在类 UNIX 系统上。因此还有一个名为 Core_kernel 的包,它是 Core 的可移植子集。不过,在最新稳定版 v0.15 中(它发布得太晚,未能被本版采用),Core 已经可移植,而 Core_kernel 也已经弃用。基于这一点,本书不会使用 Core_kernel。
在开始之前,请确保你已经有一个可以工作的 OCaml 安装,这样就能在阅读本章时尝试这些例子。
2.1 把 OCaml 当作计算器(OCaml as a Calculator)
第一步是打开 Base:
# open Base;;
通过打开 Base,我们可以直接使用其中包含的定义,而不必显式写出 Base 前缀。这对本导览以及本书后续许多示例都是必要的。
现在来尝试几个简单的数值计算:
# 3 + 4;;
- : int = 7
# 8 / 3;;
- : int = 2
# 3.5 +. 6.;;
- : float = 9.5
# 30_000_000 / 300_000;;
- : int = 100
# 3 * 5 > 14;;
- : bool = true
总体上,这和你在其他编程语言中看到的东西很相似,但有几点会立刻跳出来:
- 我们需要输入
;;,以告诉顶层环境应该对一个表达式求值。这是顶层环境的一个特性,在独立程序中并不需要这样写。不过有时加入;;有助于改进 OCaml 的错误报告,因为它更明确地标出了某个顶层声明原本应该在哪里结束。 - 对表达式求值之后,顶层环境会先打印结果的类型,然后再打印结果本身。
- OCaml 允许你在数值字面量中间放下划线,以提高可读性。注意,下划线可以放在数字内部的任何位置,不只限于每三位一组。
- OCaml 会谨慎地区分
float(浮点数类型)和int(整数类型)。二者有不同的字面量写法(6.而不是6),也有不同的中缀运算符(+.而不是+),并且 OCaml 不会在这些类型之间自动转换。这有时有点麻烦,但也有好处,因为它可以防止其他语言中由于int和float行为意外不同而产生的一些 bug。例如,在许多语言中,1 / 3是零,而1.0 /. 3.0是三分之一。OCaml 要求你明确说明自己使用的是哪一种运算。
我们还可以使用 let 关键字创建一个变量,为某个表达式的值命名。这称为 let 绑定:
# let x = 3 + 4;;
val x : int = 7
# let y = x + x;;
val y : int = 14
创建新变量之后,顶层环境会告诉我们变量名(x 或 y)、变量类型(int)以及变量值(7 或 14)。
注意,可以用作变量名的标识符受到一些限制。除了 _ 和 ' 之外,标点符号都不能使用;变量名必须以小写字母或下划线开头。因此,下面这些都是合法的:
# let x7 = 3 + 4;;
val x7 : int = 7
# let x_plus_y = x + y;;
val x_plus_y : int = 21
# let x' = x + 1;;
val x' : int = 8
不过,下面这些例子是不合法的:
# let Seven = 3 + 4;;
Line 1, characters 5-10:
Error: Unbound constructor Seven
# let 7x = 7;;
Line 1, characters 5-7:
Error: Unknown modifier 'x' for literal 7x
# let x-plus-y = x + y;;
Line 1, characters 7-11:
Error: Syntax error
这表明变量不能大写,不能以数字开头,也不能包含连字符。
2.2 函数与类型推断(Functions and Type Inference)
let 语法也可以用来定义函数:
# let square x = x * x;;
val square : int -> int = <fun>
# square 2;;
- : int = 4
# square (square 2);;
- : int = 16
在 OCaml 中,函数和其他值一样都是值。这就是为什么我们用 let 关键字把函数绑定到变量名上,正如我们用 let 把整数这样的简单值绑定到变量名上一样。用 let 定义函数时,let 后面的第一个标识符是函数名,后续每个标识符都是函数的一个不同参数。因此,square 是一个带有单个参数的函数。
现在我们开始创建函数这样更有趣的值,类型也变得更有趣了。int -> int 是一个函数类型,在这里表示一个接收 int 并返回 int 的函数。我们也可以编写接收多个参数的函数。(提醒:别忘了 open Base,否则这些例子不能工作!)
# let ratio x y =
Float.of_int x /. Float.of_int y;;
val ratio : int -> int -> float = <fun>
# ratio 4 7;;
- : float = 0.571428571428571397
注意,在 OCaml 中,函数参数用空格分隔,而不是用括号和逗号分隔。这更像 UNIX shell,而不是 Python 或 Java 这样的传统编程语言。
前面的例子也是我们第一次使用模块。在这里,Float.of_int 指的是 Float 模块中包含的 of_int 函数。这不同于你可能从面向对象语言中期待的写法;在面向对象语言中,点号通常用于访问某个对象的方法。注意,模块名总是以大写字母开头。
模块也可以被打开,使其内容无需显式加模块名前缀就能使用。我们之前已经这样做过一次,也就是打开 Base。我们可以利用这一点让代码更易读,既避免上面重复写 Float,也避免使用稍显别扭的 /. 运算符。下面的例子打开了 Float.O 模块;这个模块提供了一组适合在这类上下文中使用的有用运算符和函数。注意,这会在局部遮蔽标准的、只适用于 int 的算术运算符。
# let ratio x y =
let open Float.O in
of_int x / of_int y;;
val ratio : int -> int -> float = <fun>
这里使用了略有不同的打开模块语法,因为我们只想在 ratio 定义内部的局部作用域中打开它。局部打开还有一种更简洁的语法,如下所示:
# let ratio x y =
Float.O.(of_int x / of_int y);;
val ratio : int -> int -> float = <fun>
多参数函数类型签名的写法一开始可能有点令人意外,但等到第 3.2.2 节“多参数函数(Multiargument Functions)”讨论函数柯里化时,我们会解释它从何而来。现在,你可以把箭头理解为分隔函数的不同参数,最后一个箭头之后的类型就是返回值。因此,int -> int -> float 描述的是一个接收两个 int 参数并返回 float 的函数。
我们也可以编写接收其他函数作为参数的函数。下面是一个接收三个参数的函数示例:一个测试函数和两个整数参数。该函数会返回通过测试的整数之和:
# let sum_if_true test first second =
(if test first then first else 0)
+ (if test second then second else 0);;
val sum_if_true : (int -> bool) -> int -> int -> int = <fun>
如果细看推断出的类型签名,可以看到第一个参数是一个接收整数并返回布尔值的函数,另外两个参数则是整数。下面是这个函数的运行示例:
# let even x =
x % 2 = 0;;
val even : int -> bool = <fun>
# sum_if_true even 3 4;;
- : int = 4
# sum_if_true even 2 4;;
- : int = 6
注意,在 even 的定义中,我们以两种不同方式使用了 =:一次是在 let 绑定中,用来把被定义的东西和它的定义分开;另一次是在比较 x % 2 和 0 时作为相等性测试。尽管二者共享了一些语法,但它们是非常不同的操作。
2.2.1 类型推断(Type Inference)
随着我们遇到的类型越来越复杂,你可能会问:既然我们没有写下任何显式类型信息,OCaml 是如何算出它们的?OCaml 使用一种称为类型推断(type inference)的技术来确定表达式的类型,也就是根据表达式各组成部分的已有类型信息推断整个表达式的类型。
举个例子,我们逐步看看 sum_if_true 的类型是如何推断出来的:
- OCaml 要求
if表达式两个分支具有相同类型,因此表达式:
if test first then first else 0
要求 first 必须与 0 具有相同类型,所以 first 的类型必须是 int。类似地,从:
if test second then second else 0
可以推断出 second 的类型也是 int。
-
test被传入了first作为参数。由于first的类型是int,test的输入类型必须是int。 -
test first被用作if表达式的条件,因此test的返回类型必须是bool。 -
+返回int这一事实意味着sum_if_true的返回值必须是int。
把这些放在一起,就确定了所有变量的类型,也就确定了 sum_if_true 的整体类型。
随着时间推移,你会逐渐对 OCaml 推断引擎的工作方式形成粗略直觉,这会让你更容易推理自己的程序。你也可以添加显式类型标注,让某个表达式的类型更容易理解。这些标注不会改变 OCaml 程序的行为,但可以作为有用的文档,也可以捕获意外的类型变化。它们还有助于弄清楚某段代码为什么编译失败。
下面是带有标注的 sum_if_true 版本:
# let sum_if_true (test : int -> bool) (x : int) (y : int) : int =
(if test x then x else 0)
+ (if test y then y else 0);;
val sum_if_true : (int -> bool) -> int -> int -> int = <fun>
在上面的代码中,我们为函数的每个参数都标出了类型,最后的标注则表示返回值类型。这样的类型标注可以放在 OCaml 程序中的任意表达式上。
2.2.2 推断泛型类型(Inferring Generic Types)
有时候,可用信息不足以完全确定某个值的具体类型。考虑这个函数:
# let first_if_true test x y =
if test x then x else y;;
val first_if_true : ('a -> bool) -> 'a -> 'a -> 'a = <fun>
first_if_true 的参数包括一个函数 test,以及两个值 x 和 y。如果 test x 求值为 true,就返回 x,否则返回 y。那么 first_if_true 的 x 参数是什么类型呢?这里没有算术运算符或字面量之类的明显线索来收窄类型范围。因此,first_if_true 看起来应该可以作用在任意类型的值上。
确实,如果我们查看顶层环境返回的类型,会看到 OCaml 并没有选择单一的具体类型,而是引入了类型变量 'a 来表达这个类型是泛型的。(它前面的单引号说明这是一个类型变量。)具体来说,test 参数的类型是 ('a -> bool),表示 test 是一个单参数函数,返回值为 bool,参数可以是任意类型 'a。但是,无论 'a 是什么类型,它都必须与另外两个参数 x 和 y 的类型相同,也必须与 first_if_true 的返回值类型相同。这种泛型性称为参数多态(parametric polymorphism),因为它通过类型变量对相关类型进行参数化。它与 C# 和 Java 中的泛型非常相似。
由于 first_if_true 的类型是泛型的,我们可以写出下面的代码:
# let long_string s = String.length s > 6;;
val long_string : string -> bool = <fun>
# first_if_true long_string "short" "loooooong";;
- : string = "loooooong"
也可以写成这样:
# let big_number x = x > 3;;
val big_number : int -> bool = <fun>
# first_if_true big_number 4 3;;
- : int = 4
long_string 和 big_number 都是函数,并且都与另外两个适当类型的参数一起传给了 first_if_true(第一个例子中是字符串,第二个例子中是整数)。但是,在同一次调用 first_if_true 时,我们不能为 'a 混用两种不同的具体类型:
# first_if_true big_number "short" "loooooong";;
Line 1, characters 26-33:
Error: This expression has type string but an expression was expected
of type
int
在这个例子中,big_number 要求 'a 被实例化为 int,而 "short" 和 "loooooong" 又要求 'a 被实例化为 string,二者不能同时成立。
类型错误与异常(Type Errors Versus Exceptions)
在 OCaml 中,编译期捕获的错误和运行时捕获的错误之间有很大区别。越早在开发过程中捕获错误越好,而编译期是最好的时机。
在顶层环境中工作会在一定程度上模糊运行时错误与编译期错误的区别,但这种区别仍然存在。一般来说,像下面这样的类型错误:
# let add_potato x =
x + "potato";;
Line 2, characters 9-17:
Error: This expression has type string but an expression was expected
of type
int
是编译期错误,因为 + 要求它的两个参数都具有 int 类型。另一方面,类型系统无法捕获的错误,例如除以零,会导致运行时异常:
# let is_a_multiple x y =
x % y = 0;;
val is_a_multiple : int -> int -> bool = <fun>
# is_a_multiple 8 2;;
- : bool = true
# is_a_multiple 8 0;;
Exception:
(Invalid_argument "8 % 0 in core_int.ml: modulus should be positive")
这里的区别在于,无论有问题的代码是否真的会被执行,类型错误都会阻止你。仅仅定义 add_potato 就是错误;而 is_a_multiple 只有在被调用时才会失败,并且只有当调用输入触发异常时才会失败。
2.3 元组、列表、选项与模式匹配(Tuples, Lists, Options, and Pattern Matching)
2.3.1 元组(Tuples)
到目前为止,我们已经遇到了一些基本类型,例如 int、float 和 string,以及像 string -> int 这样的函数类型。但我们还没有讨论任何数据结构。我们先从一种特别简单的数据结构开始:元组。元组是一个有序值集合,其中每个值都可以有不同类型。你可以用逗号把值连接在一起来创建元组。
# let a_tuple = (3,"three");;
val a_tuple : int * string = (3, "three")
# let another_tuple = (3,"four",5.);;
val another_tuple : int * string * float = (3, "four", 5.)
对有数学背景的读者来说,类型 t * s 中使用 *,是因为该类型对应于所有包含一个 t 类型值和一个 s 类型值的二元组集合。换句话说,它是两个类型的笛卡尔积,因此我们使用乘积符号 *。
你可以使用 OCaml 的模式匹配语法抽取元组中的组成部分,如下所示:
# let (x,y) = a_tuple;;
val x : int = 3
val y : string = "three"
这里,let 绑定左侧的 (x,y) 就是模式。这个模式让我们创建新变量 x 和 y,并将它们分别绑定到被匹配值的不同组成部分。现在它们可以用于后续表达式:
# x + String.length y;;
- : int = 8
注意,同样的语法既用于构造元组,也用于对元组进行模式匹配。
模式匹配也可以出现在函数参数中。下面这个函数用于计算平面上两点之间的距离,每个点都表示为一对浮点数。模式匹配语法让我们以最少的麻烦拿到所需的值:
# let distance (x1,y1) (x2,y2) =
Float.sqrt ((x1 -. x2) **. 2. +. (y1 -. y2) **. 2.);;
val distance : float * float -> float * float -> float = <fun>
上面使用的 **. 运算符用于对浮点数求幂。
这只是模式匹配的初步体验。模式匹配是 OCaml 中无处不在的工具,而且正如你将看到的,它拥有令人惊讶的能力。
Base 与 Stdlib 中的运算符(Operators in Base and the Stdlib)
OCaml 标准库和 Base 在大多数情况下会为相同事物使用相同运算符,但也存在一些差异。例如,在 Base 中,**. 是浮点数求幂,** 是整数求幂;而在标准库中,** 是浮点数求幂,整数求幂并没有以运算符形式暴露。
Base 这样做是为了与其他数值运算符保持一致,例如 *. 和 *,其中末尾的点号用于标记浮点版本。
一般来说,当这样做有助于一致性和清晰性时,Base 并不羞于提供与 OCaml 标准库不同的 API。
2.3.2 列表(Lists)
元组允许你组合固定数量、可能具有不同类型的项目;列表则允许你保存任意数量、但类型相同的项目。考虑下面的例子:
# let languages = ["OCaml";"Perl";"C"];;
val languages : string list = ["OCaml"; "Perl"; "C"]
注意,和元组不同,你不能在同一个列表中混合不同类型的元素:
# let numbers = [3;"four";5];;
Line 1, characters 18-24:
Error: This expression has type string but an expression was expected
of type
int
List 模块(The List Module)
Base 附带一个 List 模块,它提供了丰富的列表处理函数。我们可以使用点号表示法访问模块内部的值。例如,可以这样计算列表长度:
# List.length languages;;
- : int = 3
下面是稍微复杂一点的例子。我们可以按如下方式计算每种语言名字的长度列表:
# List.map languages ~f:String.length;;
- : int list = [5; 4; 1]
List.map 接收两个参数:一个列表,以及一个用于转换列表元素的函数。它会返回一个包含转换后元素的新列表,并且不会修改原始列表。
值得注意的是,传给 List.map 的函数是通过带标签参数 ~f 传入的。带标签参数通过名字而不是位置指定,因此允许你改变参数呈现给函数的顺序,而不改变函数行为,如下所示:
# List.map ~f:String.length languages;;
- : int list = [5; 4; 1]
我们会在第 3 章“变量与函数(Variables and Functions)”中进一步学习带标签参数,以及它们为什么重要。
使用 :: 构造列表(Constructing Lists with ::)
除了使用方括号构造列表之外,还可以使用列表构造子 :: 把元素添加到列表前端:
# "French" :: "Spanish" :: languages;;
- : string list = ["French"; "Spanish"; "OCaml"; "Perl"; "C"]
这里我们创建的是一个新的、扩展后的列表,而不是改变原先的列表,如下所示:
# languages;;
- : string list = ["OCaml"; "Perl"; "C"]
分号与逗号(Semicolons Versus Commas)
不同于许多其他语言,OCaml 使用分号分隔列表中的元素,而不是使用逗号。逗号则用于分隔元组中的元素。如果你试图在列表中使用逗号,会看到代码可以编译,但结果并不完全符合预期:
# ["OCaml", "Perl", "C"];;
- : (string * string * string) list = [("OCaml", "Perl", "C")]
具体来说,我们得到的不是一个包含三个字符串的列表,而是一个包含单个三元字符串元组的列表。
这个例子揭示了一个事实:逗号会创建元组,即使周围没有括号。因此,我们可以写:
# 1,2,3;;
- : int * int * int = (1, 2, 3)
来分配一个整数组成的元组。不过,这通常被认为是不良风格,应该避免。
列表的方括号记法其实只是 :: 的语法糖。因此,下面这些声明都是等价的。注意,[] 用于表示空列表,而 :: 是右结合的:
# [1; 2; 3];;
- : int list = [1; 2; 3]
# 1 :: (2 :: (3 :: []));;
- : int list = [1; 2; 3]
# 1 :: 2 :: 3 :: [];;
- : int list = [1; 2; 3]
:: 构造子只能用于向列表前端添加一个元素,并且列表最终要以空列表 [] 结束。还有一个列表连接运算符 @,可以连接两个列表:
# [1;2;3] @ [4;5;6];;
- : int list = [1; 2; 3; 4; 5; 6]
必须记住,与 :: 不同,这不是常量时间操作。连接两个列表所需的时间与第一个列表的长度成正比。
使用 match 的列表模式(List Patterns Using Match)
列表元素可以通过模式匹配访问。列表模式基于两个列表构造子:[] 和 ::。下面是一个简单例子:
# let my_favorite_language (my_favorite :: the_rest) =
my_favorite;;
Lines 1-2, characters 26-16:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
[]
val my_favorite_language : 'a list -> 'a = <fun>
通过使用 :: 进行模式匹配,我们隔离并命名了列表的第一个元素(my_favorite)以及列表的其余部分(the_rest)。如果你了解 Lisp 或 Scheme,那么我们所做的事情就相当于使用 car 和 cdr 函数,分别取出列表的第一个元素和剩余部分。
不过可以看到,顶层环境不喜欢这个定义,并吐出了一个警告,说明这个模式并不穷尽。这意味着该类型中存在一些值不能被这个模式捕获。这个警告甚至给出了一个不匹配所提供模式的值示例,也就是空列表 []。如果尝试运行 my_favorite_language,我们会看到它在非空列表上可以工作,但在空列表上会失败:
# my_favorite_language ["English";"Spanish";"French"];;
- : string = "English"
# my_favorite_language [];;
Exception: "Match_failure //toplevel//:1:26"
你可以使用 match 表达式来避免这些警告;更重要的是,它能确保你的代码确实处理了所有可能情况。
match 表达式有点像 C 和 Java 中 switch 语句的增强版。它本质上允许你列出一系列模式,用竖线分隔。(第一个分支前的竖线是可选的。)编译器会派发到第一个匹配模式后面的代码。正如我们已经看到的,模式可以创建与被匹配值某些部分对应的新变量。
下面是使用 match 的新版 my_favorite_language,它不会触发编译器警告:
# let my_favorite_language languages =
match languages with
| first :: the_rest -> first
| [] -> "OCaml" (* A good default! *);;
val my_favorite_language : string list -> string = <fun>
# my_favorite_language ["English";"Spanish";"French"];;
- : string = "English"
# my_favorite_language [];;
- : string = "OCaml"
前面的代码还包含了我们第一个注释。OCaml 注释由 (* 和 *) 包围,可以任意嵌套,也可以跨多行。它没有 C++ 风格的、以 // 为前缀的单行注释。
第一个模式 first :: the_rest 覆盖了 languages 至少有一个元素的情况,因为除了空列表以外,每个列表都可以用一个或多个 :: 写出。第二个模式 [] 只匹配空列表。这些情况是穷尽的,因为每个列表要么为空,要么至少有一个元素,而这一事实会由编译器验证。
递归列表函数(Recursive List Functions)
递归函数,也就是调用自身的函数,是使用 OCaml 或任何函数式语言时的重要组成部分。设计递归函数的典型方式,是把逻辑拆分为一组可以直接求解的基础情况,以及一组归纳情况;在归纳情况中,函数把问题拆成更小的部分,然后调用自身来解决这些更小的问题。
编写递归列表函数时,这种基础情况与归纳情况的分离通常通过模式匹配完成。下面是一个简单例子,它对列表元素求和:
# let rec sum l =
match l with
| [] -> 0
(* base case *)
| hd :: tl -> hd + sum tl
(* inductive case *);;
val sum : int list -> int = <fun>
# sum [1;2;3];;
- : int = 6
遵循 OCaml 的常见惯用法,我们用 hd 表示列表头,用 tl 表示列表尾。注意,我们必须使用 rec 关键字,才允许 sum 引用自身。你可以想象,基础情况和归纳情况是 match 的不同分支。
从逻辑上看,可以把像 sum 这样的简单递归函数的求值近似想象成一个数学方程,含义会一步步展开:
sum [1;2;3]
= 1 + sum [2;3]
= 1 + (2 + sum [3])
= 1 + (2 + (3 + sum []))
= 1 + (2 + (3 + 0))
= 1 + (2 + 3)
= 1 + 5
= 6
这提供了一个合理的心智模型,尽管它并不完全准确地描述 OCaml 实际如何对递归函数求值。
我们还可以引入更复杂的列表模式。下面这个函数用于移除连续重复项:
# let rec remove_sequential_duplicates list =
match list with
| [] -> []
| first :: second :: tl ->
if first = second then
remove_sequential_duplicates (second :: tl)
else
first :: remove_sequential_duplicates (second :: tl);;
Lines 2-8, characters 5-61:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
_::[]
val remove_sequential_duplicates : int list -> int list = <fun>
同样,match 的第一个分支是基础情况,第二个分支是归纳情况。不幸的是,这段代码有一个问题,警告信息已经指出了这一点。具体来说,它没有处理只有一个元素的列表。我们可以通过向 match 添加另一个情况来修复这个警告:
# let rec remove_sequential_duplicates list =
match list with
| [] -> []
| [x] -> [x]
| first :: second :: tl ->
if first = second then
remove_sequential_duplicates (second :: tl)
else
first :: remove_sequential_duplicates (second :: tl);;
val remove_sequential_duplicates : int list -> int list = <fun>
# remove_sequential_duplicates [1;1;2;3;3;4;4;1;1;1];;
- : int list = [1; 2; 3; 4; 1]
注意,这段代码使用了列表模式的另一个变体 [x],用于匹配只有一个元素的列表。我们可以用这种方式匹配任意固定长度的列表;例如,[x;y;z] 会匹配任何恰好有三个元素的列表,并把这些元素分别绑定到变量 x、y 和 z。
在前面几个例子中,我们的列表处理代码包含了很多递归函数。实践中,这通常不是必要的。大多数时候,你会乐于使用 List 模块中的迭代函数。不过,了解如何在需要以新方式迭代时使用递归仍然很有价值。
2.3.3 选项(Options)
OCaml 中另一种常见数据结构是 option。option 用来表达某个值可能存在,也可能不存在。例如:
# let divide x y =
if y = 0 then None else Some (x / y);;
val divide : int -> int -> int option = <fun>
如果除数为零,函数 divide 会返回 None;否则会返回包含除法结果的 Some。Some 和 None 是构造可选值的构造子,就像 :: 和 [] 是构造列表的构造子一样。你可以把 option 看作一种特殊列表,它只能包含零个或一个元素。
为了检查 option 的内容,我们会使用模式匹配,正如处理元组和列表时所做的那样。来看一个小例子。我们会编写一个函数,它接收一个文件名,并返回一个文件扩展名(点号之后的部分)被转换成小写的文件名版本。我们会基于 String.rsplit2 函数来完成,它会根据字符串中最靠右的句点拆分字符串。注意,String.rsplit2 的返回类型是 (string * string) option;当没有找到可拆分的字符时,它会返回 None。
# let downcase_extension filename =
match String.rsplit2 filename ~on:'.' with
| None -> filename
| Some (base,ext) ->
base ^ "." ^ String.lowercase ext;;
val downcase_extension : string -> string = <fun>
# List.map ~f:downcase_extension
[ "Hello_World.TXT"; "Hello_World.txt"; "Hello_World" ];;
- : string list = ["Hello_World.txt"; "Hello_World.txt";
"Hello_World"]
注意,我们使用了 ^ 运算符来连接字符串。连接运算符由 Stdlib 模块提供,而该模块会在每个 OCaml 程序中自动打开。
option 很重要,因为它是 OCaml 中编码“某个值可能不存在”的标准方式;OCaml 中没有 NullPointerException 这种东西。这不同于大多数其他语言,包括 Java 和 C#。在这些语言中,大多数乃至所有数据类型都是可空的,这意味着无论某个值的类型是什么,它也总有可能是 null。换言之,null 无处不在。
但在 OCaml 中,缺失值是显式的。类型为 string * string 的值永远包含两个明确定义的 string 类型值。如果你希望允许其中第一个值缺失,就需要把类型改成 string option * string。正如我们会在第 8 章“错误处理(Error Handling)”中看到的,这种显式性允许编译器提供大量帮助,确保你正确处理了数据缺失的可能性。
2.4 记录与变体(Records and Variants)
到目前为止,我们只看过语言中预定义的数据结构,例如列表和元组。但 OCaml 也允许我们定义新的数据类型。下面是一个玩具示例,它定义了表示二维空间中点的数据类型:
type point2d = { x : float; y : float }
point2d 是一个记录类型。你可以把它想成一种元组,只是其中各个字段是有名字的,而不是按位置定义的。记录类型很容易构造:
# let p = { x = 3.; y = -4. };;
val p : point2d = {x = 3.; y = -4.}
我们也可以通过模式匹配访问这些类型中的内容:
# let magnitude { x = x_pos; y = y_pos } =
Float.sqrt (x_pos **. 2. +. y_pos **. 2.);;
val magnitude : point2d -> float = <fun>
这里的模式匹配会把变量 x_pos 绑定到 x 字段中的值,把变量 y_pos 绑定到 y 字段中的值。
可以使用称为字段 punning 的写法,把它写得更简洁。具体来说,当字段名和要绑定到的变量名相同时,我们不必把二者都写出来。利用这一点,magnitude 函数可以改写如下:
# let magnitude { x; y } = Float.sqrt (x **. 2. +. y **. 2.);;
val magnitude : point2d -> float = <fun>
或者,我们可以使用点号表示法访问记录字段:
# let distance v1 v2 =
magnitude { x = v1.x -. v2.x; y = v1.y -. v2.y };;
val distance : point2d -> point2d -> float = <fun>
当然,我们也可以把新定义的类型作为更大类型中的组成部分。例如,下面这些类型用于建模不同几何对象,其中包含 point2d 类型的值:
type circle_desc =
{ center: point2d; radius: float }
type rect_desc =
{ lower_left: point2d; width: float; height: float }
type segment_desc =
{ endpoint1: point2d; endpoint2: point2d }
现在,假设你想把这些类型的多个对象组合在一起,作为一个多对象场景的描述。你需要某种统一方式,把这些对象一起表示在单一类型中。变体类型正是为此而生:
type scene_element =
| Circle of circle_desc
| Rect of rect_desc
| Segment of segment_desc
| 字符分隔变体的不同情况(第一个 | 是可选的),每个情况都有一个大写标签,例如 Circle、Rect 或 Segment,用于把这个情况与其他情况区分开。
下面展示如何编写一个函数,测试某个点是否位于一个 scene_element 列表中某个元素的内部。注意,这里连续出现了两个 let 绑定,中间没有双分号。这是因为双分号只是用来告诉 utop 处理输入的,并不是用来分隔两个声明的。
# let is_inside_scene_element point scene_element =
let open Float.O in
match scene_element with
| Circle { center; radius } ->
distance center point < radius
| Rect { lower_left; width; height } ->
point.x > lower_left.x && point.x < lower_left.x + width
&& point.y > lower_left.y && point.y < lower_left.y + height
| Segment _ -> false
let is_inside_scene point scene =
List.exists scene
~f:(fun el -> is_inside_scene_element point el);;
val is_inside_scene_element : point2d -> scene_element -> bool = <fun>
val is_inside_scene : point2d -> scene_element list -> bool = <fun>
# is_inside_scene {x=3.;y=7.}
[ Circle {center = {x=4.;y= 4.}; radius = 0.5 } ];;
- : bool = false
# is_inside_scene {x=3.;y=7.}
[ Circle {center = {x=4.;y= 4.}; radius = 5.0 } ];;
- : bool = true
你也许会注意到,这里使用 match 的方式让人想起我们处理 option 和 list 时使用 match 的方式。这并非巧合:option 和 list 只是变体类型的例子,它们足够重要,因此被定义在标准库中(而列表甚至还拥有一些特殊语法)。
我们还在调用 List.exists 时第一次使用了匿名函数。匿名函数使用 fun 关键字声明,不需要显式命名。这类函数在 OCaml 中很常见,尤其是在使用 List.exists 这样的迭代函数时。
List.exists 的目的,是检查给定列表中是否存在某些元素,使得提供的函数在这些元素上求值为 true。在这个例子中,我们用 List.exists 检查是否存在某个场景元素,使我们的点位于其中。
Base 与多态比较(Base and Polymorphic Comparison)
还需要注意的一点是,我们在 is_inside_scene_element 的定义中打开了 Float.O。这让我们可以使用简单的、没有点号的中缀运算符;更重要的是,它把浮点比较运算符带入了作用域。
使用 Base 时,默认比较运算符只适用于整数;如果你想比较其他类型,就需要显式选择其他比较运算符。OCaml 还提供了一组特殊的多态比较运算符,它们几乎可以作用于任何类型,但这些运算符被认为存在问题,因此 Base 默认隐藏它们。我们会在第 4.6 节“更简洁、更快速的模式(Terser and Faster Patterns)”中进一步学习多态比较。
2.5 命令式编程(Imperative Programming)
到目前为止,我们编写的代码几乎完全是纯的或者说函数式的。粗略地说,这意味着相关代码在执行过程中不会修改变量或值。事实上,我们遇到的几乎所有数据结构都是不可变的,也就是说语言中没有办法修改它们。这与命令式编程风格非常不同;在命令式编程中,计算被组织为一系列指令,这些指令通过修改程序状态来工作。
函数式代码是 OCaml 的默认风格,变量绑定以及大多数数据结构都是不可变的。但 OCaml 也非常好地支持命令式编程,包括数组和哈希表这样的可变数据结构,以及 for 和 while 循环这样的控制流结构。
2.5.1 数组(Arrays)
OCaml 中最简单的可变数据结构也许就是数组。OCaml 中的数组与 C 等其他语言中的数组非常相似:索引从 0 开始,访问或修改数组元素都是常量时间操作。在内存利用方面,数组比 OCaml 中大多数其他数据结构都更紧凑,包括列表。下面是一个例子:
# let numbers = [| 1; 2; 3; 4 |];;
val numbers : int array = [|1; 2; 3; 4|]
# numbers.(2) <- 4;;
- : unit = ()
# numbers;;
- : int array = [|1; 2; 4; 4|]
.(i) 语法用于引用数组中的某个元素,<- 语法用于修改。由于数组元素从零开始计数,numbers.(2) 是第三个元素。
前面代码中看到的 unit 类型很有意思,因为它只有一个可能的值,写作 ()。这意味着 unit 类型的值不会传达任何信息,因此通常用作占位符。所以,像设置可变字段这样的操作通过副作用而不是通过返回值进行通信,其返回值就会使用 unit。unit 也会用作那些不需要输入值的函数的参数。这类似于 C 和 Java 等语言中 void 扮演的角色。
2.5.2 可变记录字段(Mutable Record Fields)
数组是重要的可变数据结构,但并不是唯一的可变数据结构。记录默认不可变,但也可以把其中一些字段显式声明为可变。下面是一个可变数据结构示例,用于保存一组数字的运行统计摘要:
type running_sum =
{ mutable sum: float;
mutable sum_sq: float; (* sum of squares *)
mutable samples: int;
}
running_sum 中的字段被设计成便于增量扩展,并且足以计算均值和标准差,如下面的例子所示:
# let mean rsum = rsum.sum /. Float.of_int rsum.samples;;
val mean : running_sum -> float = <fun>
# let stdev rsum =
Float.sqrt
(rsum.sum_sq /. Float.of_int rsum.samples -. mean rsum **. 2.);;
val stdev : running_sum -> float = <fun>
我们还需要用于创建和更新 running_sum 的函数:
# let create () = { sum = 0.; sum_sq = 0.; samples = 0 };;
val create : unit -> running_sum = <fun>
# let update rsum x =
rsum.samples <- rsum.samples + 1;
rsum.sum <- rsum.sum +. x;
rsum.sum_sq <- rsum.sum_sq +. x *. x;;
val update : running_sum -> float -> unit = <fun>
create 返回一个对应于空集合的 running_sum,而 update rsum x 会通过更新样本数、总和以及平方和,改变 rsum,使其反映把 x 加入样本集合后的状态。
注意这里使用了单分号来为操作排序。当我们以纯函数式方式工作时,这并不是必要的;但一旦开始编写命令式代码,就会需要它。
下面是 create 和 update 的运行示例。注意,这段代码使用了 List.iter,它会对给定列表中的每个元素调用函数 ~f:
# let rsum = create ();;
val rsum : running_sum = {sum = 0.; sum_sq = 0.; samples = 0}
# List.iter [1.;3.;2.;-7.;4.;5.] ~f:(fun x -> update rsum x);;
- : unit = ()
# mean rsum;;
- : float = 1.33333333333333326
# stdev rsum;;
- : float = 3.94405318873307698
警告:前面的算法在数值上比较朴素;当存在许多相互抵消的值时,它的精度很差。关于计算方差算法的维基百科文章提供了更多细节。
2.5.3 引用(Refs)
我们可以使用 ref 创建单个可变值。ref 类型在标准库中预定义,但它并没有什么真正特殊之处。它只是一个带有单个可变字段 contents 的记录类型:
# let x = { contents = 0 };;
val x : int ref = {contents = 0}
# x.contents <- x.contents + 1;;
- : unit = ()
# x;;
- : int ref = {contents = 1}
为了让 ref 使用起来更方便,还定义了一些有用的函数和运算符:
# let x = ref 0
(* create a ref, i.e., { contents = 0 } *);;
val x : int ref = {Base.Ref.contents = 0}
# !x
(* get the contents of a ref, i.e., x.contents *);;
- : int = 0
# x := !x + 1
(* assignment, i.e., x.contents <- ... *);;
- : unit = ()
# !x;;
- : int = 1
这些运算符也没有任何魔法。只用几行代码就可以完整地重新实现 ref 类型以及所有这些运算符:
# type 'a ref = { mutable contents : 'a };;
type 'a ref = { mutable contents : 'a; }
# let ref x = { contents = x };;
val ref : 'a -> 'a ref = <fun>
# let (!) r = r.contents;;
val ( ! ) : 'a ref -> 'a = <fun>
# let (:=) r x = r.contents <- x;;
val ( := ) : 'a ref -> 'a -> unit = <fun>
ref 前面的 'a 表明 ref 类型是多态的,与列表是多态的方式相同,意味着它可以包含任意类型的值。! 和 := 周围的括号是必需的,因为它们是运算符,而不是普通函数。
尽管 ref 只是另一种记录类型,但它很重要,因为它是 OCaml 中模拟大多数语言里传统可变变量的标准方式。例如,我们可以通过调用 List.iter,对列表中的每个元素调用一个简单函数,并用 ref 累积结果,从而以命令式方式对列表元素求和:
# let sum list =
let sum = ref 0 in
List.iter list ~f:(fun x -> sum := !sum + x);
!sum;;
val sum : int list -> int = <fun>
这并不是求列表和最符合惯用法的方式,但它展示了如何用 ref 代替可变变量。
使用 let 和 in 嵌套 let(Nesting lets with let and in)
上面 sum 的定义是我们第一次在函数体内使用 let 定义新变量。let 配合 in 可以在任意局部作用域中引入新的绑定,包括函数体。in 标记了新变量可用的作用域起点。因此,我们可以写:
# let z = 7 in
z + z;;
- : int = 14
注意,let 绑定的作用域会被双分号终止,因此 z 的值不再可用:
# z;;
Line 1, characters 1-2:
Error: Unbound value z
我们也可以连续写多个 let 绑定,每一个都向之前的内容添加一个新的变量绑定:
# let x = 7 in
let y = x * x in
x + y;;
- : int = 56
这种嵌套 let 绑定是构建复杂表达式的常见方式:每个 let 为某个组成部分命名,最后再把它们组合成一个最终表达式。
2.5.4 for 与 while 循环(For and While Loops)
OCaml 也支持传统命令式控制流结构,例如 for 和 while 循环。举例来说,下面是一段使用 for 循环对数组进行置乱的代码:
# let permute array =
let length = Array.length array in
for i = 0 to length - 2 do
(* pick a j to swap with *)
let j = i + Random.int (length - i) in
(* Swap i and j *)
let tmp = array.(i) in
array.(i) <- array.(j);
array.(j) <- tmp
done;;
val permute : 'a array -> unit = <fun>
这是我们第一次使用 Random 模块。注意,Random 会以固定种子开始,但你可以调用 Random.self_init 来随机选择一个新种子。
从语法角度看,你应该注意区分 for 循环的关键字:for、to、do 和 done。
下面是这段代码的一次运行示例:
# let ar = Array.init 20 ~f:(fun i -> i);;
val ar : int array =
[|0; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18;
19|]
# permute ar;;
- : unit = ()
# ar;;
- : int array =
[|12; 16; 5; 13; 1; 6; 0; 7; 15; 19; 14; 4; 2; 11; 3; 8; 17; 9; 10;
18|]
OCaml 也支持 while 循环,如下面这个函数所示,它用于寻找数组中第一个负数项的位置。注意,while 和 for 一样也是关键字:
# let find_first_negative_entry array =
let pos = ref 0 in
while !pos < Array.length array && array.(!pos) >= 0 do
pos := !pos + 1
done;
if !pos = Array.length array then None else Some !pos;;
val find_first_negative_entry : int array -> int option = <fun>
# find_first_negative_entry [|1;2;0;3|];;
- : int option = None
# find_first_negative_entry [|1;-2;0;3|];;
- : int option = Some 1
顺带一提,前面的代码利用了这样一个事实:&& 这个 OCaml 的“与”运算符会短路。具体来说,在形如 expr1 && expr2 的表达式中,只有当 expr1 求值为 true 时,expr2 才会被求值。如果不是这样,前面的函数就会导致越界错误。事实上,如果我们改写函数以避免这种短路,就可以触发越界错误:
# let find_first_negative_entry array =
let pos = ref 0 in
while
let pos_is_good = !pos < Array.length array in
let element_is_non_negative = array.(!pos) >= 0 in
pos_is_good && element_is_non_negative
do
pos := !pos + 1
done;
if !pos = Array.length array then None else Some !pos;;
val find_first_negative_entry : int array -> int option = <fun>
# find_first_negative_entry [|1;2;0;3|];;
Exception: (Invalid_argument "index out of bounds")
“或”运算符 || 也会以与 && 类似的方式短路。
2.6 完整程序(A Complete Program)
到目前为止,我们一直通过 utop 试验语言的基本特性。现在来展示如何创建一个简单的独立程序。具体来说,我们会创建一个程序,对从标准输入读取的一组数字求和。
下面是代码,你可以把它保存到名为 sum.ml 的文件中。注意,这里不需要用 ;; 终止表达式,因为在顶层环境之外并不需要这样做。
open Base
open Stdio
let rec read_and_accumulate accum =
let line = In_channel.input_line In_channel.stdin in
match line with
| None -> accum
| Some x -> read_and_accumulate (accum +. Float.of_string x)
let () =
printf "Total: %F\n" (read_and_accumulate 0.)
这是我们第一次使用 OCaml 的输入输出例程。为了访问它们,我们需要打开另一个库 Stdio。函数 read_and_accumulate 是一个递归函数,它使用 In_channel.input_line 从标准输入逐行读取,每次迭代时用更新后的累计和再次调用自己。注意,input_line 返回一个可选值,其中 None 表示输入流结束。
在 read_and_accumulate 返回之后,需要打印总和。这通过 printf 命令完成;它支持类型安全的格式字符串。格式字符串由编译器解析,并用于确定还需要多少个剩余参数以及这些参数的类型。在这个例子中,格式字符串中只有一个格式指示符 %F,因此 printf 期待一个额外的 float 类型参数。
2.6.1 编译与运行(Compiling and Running)
我们会使用 dune 编译程序。dune 是一个为 OCaml 项目设计的构建系统。首先,需要编写一个 dune-project 文件来指定项目的根目录。
(lang dune 2.9)
(name rwo-example)
然后,需要编写一个 dune 文件,用来指定具体要构建的东西。注意,一个项目只会有一个 dune-project 文件,但可能有许多子目录,各自包含不同的 dune 文件。
不过,在这里我们只有一个:
(executable
(name sum)
(libraries base stdio))
我们只需要说明自己正在构建一个可执行文件(而不是库)、可执行文件的名字,以及依赖库的名字。
现在可以调用 dune 来构建这个可执行文件。
$ dune build sum.exe
.exe 后缀表示我们正在构建原生代码可执行文件,这一点会在第 5 章“文件、模块与程序(Files, Modules, and Programs)”中进一步讨论。构建完成后,就可以像使用任何命令行工具一样使用得到的程序。我们可以通过输入一系列数字(每行一个),并在完成时按下 Ctrl-D,把输入喂给 sum.exe:
$ ./_build/default/sum.exe
1
2
3
94.5
Total: 100.5
要做出真正可用的命令行程序,还需要更多工作,包括合适的命令行解析接口和更好的错误处理。所有这些内容都会在第 16 章“命令行解析(Command-Line Parsing)”中介绍。
2.7 下一步去哪里(Where to Go from Here)
导览到此结束!还有许多特性没有涉及,也有大量细节需要解释,但我们希望你现在已经对 OCaml 能带来什么形成了感觉,并且因此能更轻松地阅读本书余下内容。