第 1 章 核心语言

本部分手册是对 OCaml 语言的教程式介绍。假设您对传统语言(例如 C 或 Java)的编程有很好的了解,但不需要事先接触函数式语言。本章介绍了核心语言。第2 章讨论了模块系统,第3 章讨论了面向对象特性,第4 章讨论了带标签的参数,第5 章讨论了多态变体,第6 章讨论了多态的局限性,第8 章给出了一些进阶示例。

1 基础

在本 OCaml 概述中,我们使用交互式系统,它通过在 Unix shell 或 Windows 命令提示符下运行ocaml 来启动。本教程以交互式系统的转录形式呈现:以# 开头的行代表用户输入;系统响应在下面打印,没有前导#

在交互式系统下,用户在# 提示符后输入以;; 结尾的 OCaml 语句,系统会实时编译、执行这些语句并打印出评估结果。语句可以是简单的表达式,也可以是标识符(值或函数)的let 定义。

# 1 + 2 * 3;;
- : int = 7
# let pi = 4.0 *. atan 1.0;;
val pi : float = 3.14159265358979312
# let square x = x *. x;;
val square : float -> float = <fun>
# square (sin pi) +. square (cos pi);;
- : float = 1.

OCaml 系统计算每个语句的值和类型。即使函数参数也不需要显式类型声明:系统会根据它们在函数中的使用情况推断它们的类型。还要注意,整数和浮点数是不同的类型,有不同的运算符:+* 作用于整数,而+.*. 作用于浮点数。

# 1.0 * 2;;
错误: 此表达式类型为 float,但期望类型为 int

递归函数使用let rec 绑定来定义

# let rec fib n = if n < 2 then n else fib (n - 1) + fib (n - 2);;
val fib : int -> int = <fun>
# fib 10;;
- : int = 55

2 数据类型

除了整数和浮点数,OCaml 还提供常用的基本数据类型

预定义数据结构包括元组、数组和列表。还有定义自己的数据结构的通用机制,例如记录和变体,这些将在后面详细介绍;现在,我们重点关注列表。列表可以扩展形式给出,作为用分号分隔的元素括起来的列表,也可以从空列表[](发音为“nil”)构建,使用::(“cons”)运算符在前面添加元素。

# let l = ["is"; "a"; "tale"; "told"; "etc."];;
val l : string list = ["is"; "a"; "tale"; "told"; "etc."]
# "Life" :: l;;
- : string list = ["Life"; "is"; "a"; "tale"; "told"; "etc."]

与所有其他 OCaml 数据结构一样,列表不需要从内存中显式分配和释放:OCaml 中的所有内存管理都是完全自动的。类似地,没有显式指针处理:OCaml 编译器会在必要时静默地引入指针。

与大多数 OCaml 数据结构一样,检查和解构列表是通过模式匹配来执行的。列表模式与列表表达式具有完全相同的形式,标识符代表列表中未指定的那些部分。例如,以下是对列表进行插入排序的函数

# let rec sort lst = match lst with [] -> [] | head :: tail -> insert head (sort tail) and insert elt lst = match lst with [] -> [elt] | head :: tail -> if elt <= head then elt :: lst else head :: insert elt tail ;;
val sort : 'a list -> 'a list = <fun> val insert : 'a -> 'a list -> 'a list = <fun>
# sort l;;
- : string list = ["a"; "etc."; "is"; "tale"; "told"]

sort 推断的类型'a list -> 'a list 意味着sort 实际上可以应用于任何类型的列表,并返回相同类型的列表。类型'a 是一个类型变量,代表任何给定的类型。sort 可以应用于任何类型列表的原因是,OCaml 中的比较运算符(=<= 等)是多态的:它们可以在任何两个相同类型的 value 之间进行操作。这使得sort 本身对所有列表类型都是多态的。

# sort [6; 2; 5; 3];;
- : int list = [2; 3; 5; 6]
# sort [3.14; 2.718];;
- : float list = [2.718; 3.14]

上面的sort 函数不会修改其输入列表:它会构建并返回一个新的列表,该列表包含与输入列表相同的元素,但按升序排列。实际上,在 OCaml 中,没有办法在构建列表后就地修改列表:我们说列表是不可变的数据结构。大多数 OCaml 数据结构都是不可变的,但少数数据结构(最明显的是数组)是可变的,这意味着它们可以随时就地修改。

OCaml 中对具有多个参数的函数的类型的表示方法为
arg1_type -> arg2_type -> ... -> return_type。例如,为insert 推断的类型'a -> 'a list -> 'a list 意味着insert 接受两个参数,一个类型为'a 的元素和一个具有相同类型'a 元素的列表,并返回一个相同类型的列表。

3 函数作为值

OCaml 是一种函数式语言:支持完全意义上的数学函数,并且可以像任何其他数据一样自由地传递。例如,以下是一个deriv 函数,它接受任何浮点函数作为参数,并返回其导数函数的近似值

# let deriv f dx = fun x -> (f (x +. dx) -. f x) /. dx;;
val deriv : (float -> float) -> float -> float -> float = <fun>
# let sin' = deriv sin 1e-6;;
val sin' : float -> float = <fun>
# sin' pi;;
- : float = -1.00000000013961143

即使函数组合也是可以定义的

# let compose f g = fun x -> f (g x);;
val compose : ('a -> 'b) -> ('c -> 'a) -> 'c -> 'b = <fun>
# let cos2 = compose square cos;;
val cos2 : float -> float = <fun>

接受其他函数作为参数的函数被称为“函数式”或“高阶函数”。函数式特别有用,可以为数据结构提供迭代器或类似的通用操作。例如,标准的 OCaml 库提供了一个 List.map 函数式,它将给定的函数应用于列表的每个元素,并返回结果列表。

# List.map (fun n -> n * 2 + 1) [0;1;2;3;4];;
- : int list = [1; 3; 5; 7; 9]

这个函数式,以及其他一些列表和数组函数式,都是预定义的,因为它们经常很有用,但它并没有什么神奇之处:它可以很容易地定义如下。

# let rec map f l = match l with [] -> [] | hd :: tl -> f hd :: map f tl;;
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>

4 记录和变体

用户定义的数据结构包括记录和变体。两者都用 type 声明定义。在这里,我们声明一个记录类型来表示有理数。

# type ratio = {num: int; denom: int};;
type ratio = { num : int; denom : int; }
# let add_ratio r1 r2 = {num = r1.num * r2.denom + r2.num * r1.denom; denom = r1.denom * r2.denom};;
val add_ratio : ratio -> ratio -> ratio = <fun>
# add_ratio {num=1; denom=3} {num=2; denom=5};;
- : ratio = {num = 11; denom = 15}

记录字段也可以通过模式匹配访问

# let integer_part r = match r with {num=num; denom=denom} -> num / denom;;
val integer_part : ratio -> int = <fun>

由于此模式匹配中只有一个情况,因此可以在记录模式中直接扩展参数 r

# let integer_part {num=num; denom=denom} = num / denom;;
val integer_part : ratio -> int = <fun>

不需要的字段可以省略

# let get_denom {denom=denom} = denom;;
val get_denom : ratio -> int = <fun>

可选地,可以使用尾部通配符 _ 明确缺少的字段:

# let get_num {num=num; _ } = num;;
val get_num : ratio -> int = <fun>

= 符号两侧都相同时,可以通过省略 =field 部分来避免重复字段名称

# let integer_part {num; denom} = num / denom;;
val integer_part : ratio -> int = <fun>

此简短的字段表示法在构造记录时也适用

# let ratio num denom = {num; denom};;
val ratio : int -> int -> ratio = <fun>

最后,可以一次更新记录的几个字段

# let integer_product integer ratio = { ratio with num = integer * ratio.num };;
val integer_product : int -> ratio -> ratio = <fun>

使用此函数式更新表示法,with 左侧的记录将被复制,除了右侧更新的字段。

变体类型的声明列出了该类型值的所有可能形式。每个情况都由一个名称标识,称为构造函数,它既用于构造变体类型的值,也用于通过模式匹配检查它们。构造函数名称以大写字母开头,以将其与变量名称(必须以小写字母开头)区分开来。例如,这里是一个变体类型,用于执行混合算术(整数和浮点数)

# type number = Int of int | Float of float | Error;;
type number = Int of int | Float of float | Error

此声明表明 number 类型的值可以是整数、浮点数,或表示无效操作结果(例如零除)的常量 Error

枚举类型是变体类型的一种特例,其中所有备选方案都是常量

# type sign = Positive | Negative;;
type sign = Positive | Negative
# let sign_int n = if n >= 0 then Positive else Negative;;
val sign_int : int -> sign = <fun>

为了定义 number 类型的算术运算,我们使用对所涉及的两个数字进行模式匹配

# let add_num n1 n2 = match (n1, n2) with (Int i1, Int i2) -> (* 检查整数加法溢出 *) if sign_int i1 = sign_int i2 && sign_int (i1 + i2) <> sign_int i1 then Float(float i1 +. float i2) else Int(i1 + i2) | (Int i1, Float f2) -> Float(float i1 +. f2) | (Float f1, Int i2) -> Float(f1 +. float i2) | (Float f1, Float f2) -> Float(f1 +. f2) | (Error, _) -> Error | (_, Error) -> Error;;
val add_num : number -> number -> number = <fun>
# add_num (Int 123) (Float 3.14159);;
- : number = Float 126.14159

另一个变体类型的有趣示例是内置的 'a option 类型,它表示 'a 类型的值或值的缺失

# type 'a option = Some of 'a | None;;
type 'a option = Some of 'a | None

此类型在定义可能在常见情况下失败的函数时特别有用,例如

# let safe_square_root x = if x >= 0. then Some(sqrt x) else None;;
val safe_square_root : float -> float option = <fun>

变体类型的最常见用法是描述递归数据结构。例如,考虑二叉树的类型

# type 'a btree = Empty | Node of 'a * 'a btree * 'a btree;;
type 'a btree = Empty | Node of 'a * 'a btree * 'a btree

此定义如下:包含 'a 类型值(任意类型)的二叉树要么为空,要么是一个节点,包含一个 'a 类型的值和两个子树,也包含 'a 类型的值,也就是说,两个 'a btree

二叉树上的操作自然地表示为递归函数,其结构与类型定义本身相同。例如,以下函数在有序二叉树(元素从左到右递增)中执行查找和插入

# let rec member x btree = match btree with Empty -> false | Node(y, left, right) -> if x = y then true else if x < y then member x left else member x right;;
val member : 'a -> 'a btree -> bool = <fun>
# let rec insert x btree = match btree with Empty -> Node(x, Empty, Empty) | Node(y, left, right) -> if x <= y then Node(y, insert x left, right) else Node(y, left, insert x right);;
val insert : 'a -> 'a btree -> 'a btree = <fun>

4.1 记录和变体消歧

( 本节可以在第一次阅读时跳过 )

敏锐的读者可能想知道,当两个或多个记录字段或构造函数共享相同的名称时会发生什么

# type first_record = { x:int; y:int; z:int } type middle_record = { x:int; z:int } type last_record = { x:int };;
# type first_variant = A | B | C type last_variant = A;;

答案是,当遇到多个选项时,OCaml 尝试使用本地可用信息来区分各个字段和构造函数。首先,如果记录或变体的类型已知,OCaml 可以明确地选择相应的字段或构造函数。例如

# let look_at_x_then_z (r:first_record) = let x = r.x in x + r.z;;
val look_at_x_then_z : first_record -> int = <fun>
# let permute (x:first_variant) = match x with | A -> (B:first_variant) | B -> A | C -> C;;
val permute : first_variant -> first_variant = <fun>
# type wrapped = First of first_record let f (First r) = r, r.x;;
type wrapped = First of first_record val f : wrapped -> first_record * int = <fun>

在第一个例子中,(r:first_record) 是一个显式注解,告诉 OCaml r 的类型是 first_record。有了这个注解,OCaml 就知道 r.x 指的是第一个记录类型中的 x 字段。类似地,第二个例子中的类型注解让 OCaml 清楚地知道构造器 ABC 来自第一个变体类型。相反,在最后一个例子中,OCaml 自己推断出 r 的类型只能是 first_record,不需要显式类型注解。

这些显式类型注解实际上可以在任何地方使用。大多数情况下它们是不必要的,但它们对于指导消除歧义、调试意外类型错误或与 OCaml 后面章节中描述的一些更高级特性结合使用非常有用。

其次,对于记录,OCaml 也可以通过查看表达式或模式中使用的所有字段来推断出正确的记录类型。

# let project_and_rotate {x; y; _} = { x= - y; y = x; z = 0} ;;
val project_and_rotate : first_record -> first_record = <fun>

由于字段 xy 只能同时出现在第一个记录类型中,因此 OCaml 推断出 project_and_rotate 的类型是 first_record -> first_record

最后,如果信息不足以区分不同的字段或构造器,OCaml 会在所有局部有效的选项中选择最后定义的类型。

# let look_at_xz {x; z} = x;;
val look_at_xz : middle_record -> int = <fun>

在这里,OCaml 推断出 {x;z} 类型可能的选项是 first_recordmiddle_record,因为类型 last_record 没有字段 z。然后 OCaml 选择类型 middle_record 作为这两个选项中最后定义的类型。

注意,这种最后的消除歧义是局部的:一旦 OCaml 选择了一个消除歧义,它就会坚持这个选择,即使它会导致后续的类型错误。

# let look_at_x_then_y r = let x = r.x in (* OCaml 推断出 [r: last_record] *) x + r.y;;
Error: 此表达式类型为 last_record 类型 last_record 中没有字段 y
# let is_a_or_b x = match x with | A -> true (* OCaml 推断出 [x: last_variant] *) | B -> true;;
Error: 此变体模式预期类型为 last_variant 类型 last_variant 中没有构造器 B

此外,作为最后定义的类型是一个相当不稳定的位置,它可能会在添加或移动类型定义后,或在打开模块后(参见第 2 章)发生潜移默化的改变。因此,添加显式类型注解来指导消除歧义比依赖最后定义的类型消除歧义更健壮。

5 命令式特性

虽然到目前为止所有的例子都是用纯函数式风格编写的,但 OCaml 也配备了完整的命令式特性。这包括通常的 whilefor 循环,以及可变数据结构,如数组。数组可以通过在 [||] 括号之间列出用分号分隔的元素值来创建,或者使用 Array.make 函数分配和初始化,然后通过赋值填充。例如,下面的函数按元素对两个向量(表示为浮点数数组)求和。

# let add_vect v1 v2 = let len = min (Array.length v1) (Array.length v2) in let res = Array.make len 0.0 in for i = 0 to len - 1 do res.(i) <- v1.(i) +. v2.(i) done; res;;
val add_vect : float array -> float array -> float array = <fun>
# add_vect [| 1.0; 2.0 |] [| 3.0; 4.0 |];;
- : float array = [|4.; 6.|]

如果在记录类型定义中将记录字段声明为 mutable,那么也可以通过赋值来修改记录字段。

# type mutable_point = { mutable x: float; mutable y: float };;
type mutable_point = { mutable x : float; mutable y : float; }
# let translate p dx dy = p.x <- p.x +. dx; p.y <- p.y +. dy;;
val translate : mutable_point -> float -> float -> unit = <fun>
# let mypoint = { x = 0.0; y = 0.0 };;
val mypoint : mutable_point = {x = 0.; y = 0.}
# translate mypoint 1.0 2.0;;
- : unit = ()
# mypoint;;
- : mutable_point = {x = 1.; y = 2.}

OCaml 没有内置的变量概念 - 通过赋值可以改变其当前值的标识符。(let 绑定不是赋值,它引入一个新的标识符,并具有新的作用域。)但是,标准库提供了引用,它们是可变的间接单元,使用运算符 ! 获取引用的当前内容,使用 := 赋值内容。然后可以通过 let 绑定引用来模拟变量。例如,下面是在数组上进行的原地插入排序。

# let insertion_sort a = for i = 1 to Array.length a - 1 do let val_i = a.(i) in let j = ref i in while !j > 0 && val_i < a.(!j - 1) do a.(!j) <- a.(!j - 1); j := !j - 1 done; a.(!j) <- val_i done;;
val insertion_sort : 'a array -> unit = <fun>

引用也有助于编写在两次调用函数之间维护当前状态的函数。例如,下面的伪随机数生成器将最后返回的数字保存在一个引用中。

# let current_rand = ref 0;;
val current_rand : int ref = {contents = 0}
# let random () = current_rand := !current_rand * 25713 + 1345; !current_rand;;
val random : unit -> int = <fun>

同样,引用没有什么神奇之处:它们是作为单个字段的可变记录来实现的,如下所示。

# type 'a ref = { mutable contents: 'a };;
type 'a ref = { mutable contents : 'a; }
# let ( ! ) r = r.contents;;
val ( ! ) : 'a ref -> 'a = <fun>
# let ( := ) r newval = r.contents <- newval;;
val ( := ) : 'a ref -> 'a -> unit = <fun>

在某些特殊情况下,您可能需要将多态函数存储在数据结构中,保留其多态性。执行此操作需要用户提供的类型注解,因为多态性仅对全局定义自动引入。但是,您可以为记录字段显式地给出多态类型。

# type idref = { mutable id: 'a. 'a -> 'a };;
type idref = { mutable id : 'a. 'a -> 'a; }
# let r = {id = fun x -> x};;
val r : idref = {id = <fun>}
# let g s = (s.id 1, s.id true);;
val g : idref -> int * bool = <fun>
# r.id <- (fun x -> print_string "called id\n"; x);;
- : unit = ()
# g r;;
called id called id - : int * bool = (1, true)

6 异常

OCaml 提供了异常来指示和处理异常情况。异常也可以用作通用的非局部控制结构,尽管不应该过度使用它,因为它会使代码更难理解。异常用 exception 结构声明,并用 raise 运算符指示。例如,下面的函数用于获取列表的头,它使用异常来指示给出空列表的情况。

# exception Empty_list;;
exception Empty_list
# let head l = match l with [] -> raise Empty_list | hd :: tl -> hd;;
val head : 'a list -> 'a = <fun>
# head [1; 2];;
- : int = 1
# head [];;
Exception: Empty_list.

异常在整个标准库中使用,以指示库函数无法正常完成的情况。例如,List.assoc 函数返回在(键,数据)对列表中与给定键关联的数据,当列表中没有出现该键时,它会引发预定义的异常 Not_found

# List.assoc 1 [(0, "zero"); (1, "one")];;
- : string = "one"
# List.assoc 2 [(0, "zero"); (1, "one")];;
Exception: Not_found.

异常可以使用 trywith 结构捕获。

# let name_of_binary_digit digit = try List.assoc digit [0, "zero"; 1, "one"] with Not_found -> "not a binary digit";;
val name_of_binary_digit : int -> string = <fun>
# name_of_binary_digit 0;;
- : string = "zero"
# name_of_binary_digit (-1);;
- : string = "not a binary digit"

with 部分对异常值进行模式匹配,与 match 具有相同的语法和行为。因此,一个 trywith 结构可以捕获多个异常。

# let rec first_named_value values names = try List.assoc (head values) names with | Empty_list -> "no named value" | Not_found -> first_named_value (List.tl values) names;;
val first_named_value : 'a list -> ('a * string) list -> string = <fun>
# first_named_value [0; 10] [1, "one"; 10, "ten"];;
- : string = "ten"

此外,可以通过捕获所有异常,执行最终化,然后重新抛出异常来执行最终化。

# let temporarily_set_reference ref newval funct = let oldval = !ref in try ref := newval; let res = funct () in ref := oldval; res with x -> ref := oldval; raise x;;
val temporarily_set_reference : 'a ref -> 'a -> (unit -> 'b) -> 'b = <fun>

除了 trywith 之外,还可以通过模式匹配来捕获异常。

# let assoc_may_map f x l = match List.assoc x l with | exception Not_found -> None | y -> f y;;
val assoc_may_map : ('a -> 'b option) -> 'c -> ('c * 'a) list -> 'b option = <fun>

请注意,这种构造只有在异常在 matchwith 之间被抛出时才有用。异常模式可以与顶层的普通模式组合,

# let flat_assoc_opt x l = match List.assoc x l with | None | exception Not_found -> None | Some _ as v -> v;;
val flat_assoc_opt : 'a -> ('a * 'b option) list -> 'b option = <fun>

但它们不能嵌套在其他模式中。例如,模式 Some (exception A) 无效。

当异常用作控制结构时,通过使用局部定义的异常,可以使它们尽可能地局部化。例如,使用

# let fixpoint f x = let exception Done in let x = ref x in try while true do let y = f !x in if !x = y then raise Done else x := y done with Done -> !x;;
val fixpoint : ('a -> 'a) -> 'a -> 'a = <fun>

函数 f 不能抛出 Done 异常,这消除了整个类别的行为异常的函数。

7 惰性表达式

OCaml 允许我们推迟某些计算,直到我们真正需要该计算的结果时才执行。

我们使用 lazy (expr) 来推迟对某个表达式 expr 的求值。例如,我们可以推迟对 1+1 的计算,直到我们需要该表达式的结果 2。让我们看看如何初始化一个惰性表达式。

# let lazy_two = lazy (print_endline "lazy_two evaluation"; 1 + 1);;
val lazy_two : int lazy_t = <lazy>

我们添加了 print_endline "lazy_two evaluation" 来查看惰性表达式何时被求值。

lazy_two 的值显示为 <lazy>,这意味着该表达式尚未被求值,其最终值未知。

请注意,lazy_two 的类型为 int lazy_t。但是,类型 'a lazy_t 是一个内部类型名称,因此在可能的情况下应优先使用类型 'a Lazy.t

当我们最终需要一个惰性表达式的结果时,我们可以对该表达式调用 Lazy.force 来强制其求值。函数 force 来自标准库模块 Lazy

# Lazy.force lazy_two;;
lazy_two evaluation - : int = 2

请注意,上面的函数调用打印了“lazy_two evaluation”,然后返回了计算的纯值。

现在,如果我们查看 lazy_two 的值,我们会发现它不再显示为 <lazy>,而是显示为 lazy 2

# lazy_two;;
- : int lazy_t = lazy 2

这是因为 Lazy.force 会记忆强制表达式的结果。换句话说,对该表达式随后每次调用 Lazy.force 都会返回第一次计算的结果,而不会重新计算惰性表达式。让我们再次强制执行 lazy_two

# Lazy.force lazy_two;;
- : int = 2

这次表达式没有被求值;请注意,“lazy_two evaluation”没有被打印出来。只是简单地返回了初始计算的结果。

惰性模式提供了另一种强制执行惰性表达式的方案。

# let lazy_l = lazy ([1; 2] @ [3; 4]);;
val lazy_l : int list lazy_t = <lazy>
# let lazy l = lazy_l;;
val l : int list = [1; 2; 3; 4]

我们也可以在模式匹配中使用惰性模式。

# let maybe_eval lazy_guard lazy_expr = match lazy_guard, lazy_expr with | lazy false, _ -> "matches if (Lazy.force lazy_guard = false); lazy_expr not forced" | lazy true, lazy _ -> "matches if (Lazy.force lazy_guard = true); lazy_expr forced";;
val maybe_eval : bool lazy_t -> 'a lazy_t -> string = <fun>

惰性表达式 lazy_expr 只有在 lazy_guard 值在计算后产生 true 时才会被强制执行。事实上,一个简单的通配符模式(不是惰性的)永远不会强制执行惰性表达式的求值。但是,带有关键字 lazy 的模式,即使是通配符模式,也会始终强制执行延迟计算的求值。

8 表达式的符号处理

我们以一个更完整的示例结束本介绍,该示例代表了 OCaml 在符号处理中的应用:包含变量的算术表达式的形式操作。以下变体类型描述了我们将要操作的表达式。

# type expression = Const of float | Var of string | Sum of expression * expression (* e1 + e2 *) | Diff of expression * expression (* e1 - e2 *) | Prod of expression * expression (* e1 * e2 *) | Quot of expression * expression (* e1 / e2 *) ;;
type expression = Const of float | Var of string | Sum of expression * expression | Diff of expression * expression | Prod of expression * expression | Quot of expression * expression

我们首先定义一个函数,用于在给定一个将变量名映射到其值的(环境)关联列表的情况下对表达式进行求值。为简单起见,环境表示为一个关联列表。

# exception Unbound_variable of string;;
exception Unbound_variable of string
# let rec eval env exp = match exp with Const c -> c | Var v -> (try List.assoc v env with Not_found -> raise (Unbound_variable v)) | Sum(f, g) -> eval env f +. eval env g | Diff(f, g) -> eval env f -. eval env g | Prod(f, g) -> eval env f *. eval env g | Quot(f, g) -> eval env f /. eval env g;;
val eval : (string * float) list -> expression -> float = <fun>
# eval [("x", 1.0); ("y", 3.14)] (Prod(Sum(Var "x", Const 2.0), Var "y"));;
- : float = 9.42

现在,对于真正的符号处理,我们定义表达式相对于变量 dv 的导数

# let rec deriv exp dv = match exp with Const c -> Const 0.0 | Var v -> if v = dv then Const 1.0 else Const 0.0 | Sum(f, g) -> Sum(deriv f dv, deriv g dv) | Diff(f, g) -> Diff(deriv f dv, deriv g dv) | Prod(f, g) -> Sum(Prod(f, deriv g dv), Prod(deriv f dv, g)) | Quot(f, g) -> Quot(Diff(Prod(deriv f dv, g), Prod(f, deriv g dv)), Prod(g, g)) ;;
val deriv : expression -> string -> expression = <fun>
# deriv (Quot(Const 1.0, Var "x")) "x";;
- : expression = Quot (Diff (Prod (Const 0., Var "x"), Prod (Const 1., Const 1.)), Prod (Var "x", Var "x"))

9 格式化打印

如上例所示,表达式的内部表示(也称为抽象语法)随着表达式的增大,很快变得难以阅读和编写。我们需要一个打印器和一个解析器在抽象语法和具体语法之间来回转换,在表达式的情况下,就是我们熟悉的代数符号(例如 2*x+1)。

对于打印函数,我们考虑了通常的优先级规则(即 * 绑定比 + 更紧密)以避免打印不必要的括号。为此,我们维护当前的运算符优先级,只在运算符的优先级低于当前优先级时才在运算符周围打印括号。

# let print_expr exp = (* 局部函数定义 *) let open_paren prec op_prec = if prec > op_prec then print_string "(" in let close_paren prec op_prec = if prec > op_prec then print_string ")" in let rec print prec exp = (* prec 是当前优先级 *) match exp with Const c -> print_float c | Var v -> print_string v | Sum(f, g) -> open_paren prec 0; print 0 f; print_string " + "; print 0 g; close_paren prec 0 | Diff(f, g) -> open_paren prec 0; print 0 f; print_string " - "; print 1 g; close_paren prec 0 | Prod(f, g) -> open_paren prec 2; print 2 f; print_string " * "; print 2 g; close_paren prec 2 | Quot(f, g) -> open_paren prec 2; print 2 f; print_string " / "; print 3 g; close_paren prec 2 in print 0 exp;;
val print_expr : expression -> unit = <fun>
# let e = Sum(Prod(Const 2.0, Var "x"), Const 1.0);;
val e : expression = Sum (Prod (Const 2., Var "x"), Const 1.)
# print_expr e; print_newline ();;
2. * x + 1. - : unit = ()
# print_expr (deriv e "x"); print_newline ();;
2. * 1. + 0. * x + 0. - : unit = ()

10 Printf 格式

Printf 模块中有一个 printf 函数(参见第 ‍2 章),它允许您更简洁地进行格式化输出。它遵循 C 标准库中 printf 函数的行为。 printf 函数接受一个格式字符串,该字符串描述了所需的输出,作为文本与说明符(例如 %d%f)交织在一起。接下来,说明符将被以下参数替换,其出现顺序与格式字符串中的出现顺序一致

# Printf.printf "%i + %i is an integer value, %F * %F is a float, %S\n" 3 2 4.5 1. "this is a string";;
3 + 2 is an integer value, 4.5 * 1. is a float, "this is a string" - : unit = ()

OCaml 类型系统会检查参数类型和说明符是否兼容。如果您传递了与格式说明符不匹配的类型的参数,编译器将显示一条错误消息

# Printf.printf "Float value: %F" 42;;
Error: This expression has type int but an expression was expected of type float Hint: Did you mean 42.?

fprintf 函数类似于 printf,只是它将输出通道作为第一个参数。 %a 说明符可用于定义自定义打印器(用于自定义类型)。例如,我们可以创建一个打印模板,将整数参数转换为带符号的十进制

# let pp_int ppf n = Printf.fprintf ppf "%d" n;;
val pp_int : out_channel -> int -> unit = <fun>
# Printf.printf "Outputting an integer using a custom printer: %a " pp_int 42;;
Outputting an integer using a custom printer: 42 - : unit = ()

基于 %a 说明符的这些打印器的优点是,它们可以组合在一起,一步一步地创建更复杂的打印器。我们可以定义一个组合器,它可以将 'a 类型的打印器转换为 'a optional 的打印器

# let pp_option printer ppf = function | None -> Printf.fprintf ppf "None" | Some v -> Printf.fprintf ppf "Some(%a)" printer v;;
val pp_option : (out_channel -> 'a -> unit) -> out_channel -> 'a option -> unit = <fun>
# Printf.fprintf stdout "The current setting is %a. \nThere is only %a\n" (pp_option pp_int) (Some 3) (pp_option pp_int) None ;;
The current setting is Some(3). There is only None - : unit = ()

如果其参数的值是 None,由 pp_option printer 返回的打印器会打印 None,否则它会使用提供的打印器打印 Some

以下是使用 fprintf 重写格式化打印器的代码

# let pp_expr ppf expr = let open_paren prec op_prec output = if prec > op_prec then Printf.fprintf output "%s" "(" in let close_paren prec op_prec output = if prec > op_prec then Printf.fprintf output "%s" ")" in let rec print prec ppf expr = match expr with | Const c -> Printf.fprintf ppf "%F" c | Var v -> Printf.fprintf ppf "%s" v | Sum(f, g) -> open_paren prec 0 ppf; Printf.fprintf ppf "%a + %a" (print 0) f (print 0) g; close_paren prec 0 ppf | Diff(f, g) -> open_paren prec 0 ppf; Printf.fprintf ppf "%a - %a" (print 0) f (print 1) g; close_paren prec 0 ppf | Prod(f, g) -> open_paren prec 2 ppf; Printf.fprintf ppf "%a * %a" (print 2) f (print 2) g; close_paren prec 2 ppf | Quot(f, g) -> open_paren prec 2 ppf; Printf.fprintf ppf "%a / %a" (print 2) f (print 3) g; close_paren prec 2 ppf in print 0 ppf expr;;
val pp_expr : out_channel -> expression -> unit = <fun>
# pp_expr stdout e; print_newline ();;
2. * x + 1. - : unit = ()
# pp_expr stdout (deriv e "x"); print_newline ();;
2. * 1. + 0. * x + 0. - : unit = ()

由于格式字符串的构建方式,存储格式字符串需要显式类型注释

# let str : _ format = "%i is an integer value, %F is a float, %S\n";;
# Printf.printf str 3 4.5 "string value";;
3 is an integer value, 4.5 is a float, "string value" - : unit = ()

11 独立 OCaml 程序

到目前为止给出的所有示例都是在交互式系统下执行的。OCaml 代码也可以使用批处理编译器 ocamlcocamlopt 分别编译并执行非交互式。源代码必须放在扩展名为 .ml 的文件中。它由一系列短语组成,这些短语将在运行时按其在源文件中的出现顺序进行评估。与交互模式不同,类型和值不会自动打印;程序必须显式调用打印函数才能产生一些输出。在用于 OCaml 编译器的源文件中不需要交互式示例中使用的 ;;,但它有助于明确地标记顶层表达式的结束,即使存在语法错误。以下是一个打印两个数的最大公约数 (gcd) 的独立程序示例

(* File gcd.ml *)
let rec gcd a b =
  if b = 0 then a
  else gcd b (a mod b);;

let main () =
  let a = int_of_string Sys.argv.(1) in
  let b = int_of_string Sys.argv.(2) in
  Printf.printf "%d\n" (gcd a b);
  exit 0;;
main ();;

Sys.argv 是一个包含命令行参数的字符串数组。因此,Sys.argv.(1) 是第一个命令行参数。上面的程序使用以下 shell 命令编译和执行

$ ocamlc -o gcd gcd.ml
$ ./gcd 6 9
3
$ ./gcd 7 11
1

更复杂的独立 OCaml 程序通常由多个源文件组成,并且可以链接到预编译的库。第 ‍13 章和 ‍16 章解释了如何使用批处理编译器 ocamlcocamlopt。可以使用第三方构建系统(如 dune)自动执行多文件 OCaml 项目的重新编译。