第一章 导览

优质
小牛编辑
119浏览
2023-12-01

语言概念

第一部分涵盖了构建 OCaml 程序所需的基本语言概念。开始的导览使用一个交互式命令行界面给你一个这门语言的快速概述。后面的章节涵盖了导览中触及的一些内容的更多细节,覆盖了 OCaml 编写命令式程序的细节。

最后几章介绍了 OCaml 的强大抽象设施。 我们先使用算子构建了一个库来进行区间编程,然后使用第一等民的模块来构建一个插件系统。OCaml 也支持面向对象编程,第一部分的最后两章我们会涵盖对象系统,前一章讲如何直接使用 OCaml 的对象系统,后一章讲如何使用类系统和更多的高级特性,如继承。最后通过设计一个简单的面向对象的图库来汇总这些知识。

第一章 导览

本章通过一系列覆盖了大部分主要语言特性的小例子给出了 OCaml 的一个概观。这提供了 OCaml 语言能做什么的直观印象,但对每一个话题都不深入讨论。

贯穿本书我们会一直使用 Core,一个更全功能的 OCaml 标准库的兼容替代。我们也会使用 utop,一个 shell,允许你键入表达式并交互式求值。 utop 是 OCaml 标准顶层(toplevel,你可以从命令行输入ocaml启动)的一个更易用的版本。这些指南会明确假设你使用的是 utop

开始之前,确保你安装了可工作的 OCaml,这样就可以试验本章读到的例子。

OCaml 作为计算器

使用 Core 要做的第一件事就是打开Core.Std

$ utop

# open Core.Std;;

(* OCaml Utop * guided-tour/main.topscript * all code *)

这使得 Core 中的定义可以使用,在本书的大部分例子中都需要。

现在我们可以尝试一些简单的数值运算:

# 3 + 4;;
- : int = 7
# 8 / 3;;
- : int = 2
# 3.5 +. 6.;;
- : float = 9.5
# 30_000_000 / 300_000;;
- : int = 100
# sqrt 9.;;
- : float = 3.

(* OCaml Utop * guided-tour/main.topscript , continued (part 1) * all code *)

总的来说,这和其它编程语言很相似,但还是有几件事要注意:

  • 我们需要键入;;以告诉 tolevel 它应该求值一个表达式。这是 toplevel 独有的,在独立的程序中并不需要(尽管有时包含;;会使顶层声明的结束更明显,从而改善 OCaml 的错误报告)。
  • 对表达式求值之后,toplevel 先打印出结果类型,然后是结果本身。
  • 函数参数以空格分隔而不是括号和逗号,这更像 UNIX 的 shell 而不是 C 或 Java 这样的传统语言。
  • OCaml 允许你在数字字面值中间加下划线来增加可读性。注意下划线可以放在数字的任何位置,而不限于每三个数字一组。
  • OCaml 严格区分float(浮点数类型)和int(整数类型)。不同类型的字面值不同(6.6),中缀操作符也不同(+.+),而且 OCaml 不会在这些类型之间自动转换。这可能有点麻烦,但是也有其好处,因为可以阻止其它语言因为intfloat行为不同而引发的 bug。比如,在许多语言中1 / 3等于0,而1 / 3.0却等于三分之一。OCaml 要求你必须明确要执行什么操作。

我们也可以使用let关键字创建一个变量来命名给定表达式的值。这就是一个let绑定

# let x = 3 + 4;;
val x : int = 7
# let y = x + x;;
val y : int = 14

(* OCaml Utop * guided-tour/main.topscript , continued (part 2) * all code *)

创建新的变量后,除了变量类型(int)和值(714),toplevel 还告诉了我们变量名(xy)。

注意能用在变量名里的标识符是有限制的。标点符号只允许使用_',并且变量名只能以小写字母或下划线开头。因此,下面的变量名都是合法的:

# 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 _x' = x' + x';;
# _x';;
- : int = 16

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 3) ∗ all code *)

注意默认情况下, utop不会打印下划线开头的变量。

下面的例子是不合法的:

# let Seven = 3 + 4;;
Characters 4-9:
Error: Unbound constructor Seven
# let 7x = 7;;
Characters 5-10:
Error: This expression should not be a function, the expected type is
int
# let x-plus-y = x + y;;

Characters 4-5:
Error: Parse error: [fun_binding] expected after [ipatt] (in [let_binding]) 

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 4) ∗ all code *)

错误信息有点诡异,但当你对 OCaml 了解更多时就会明白了。

函数和类型推导

let语法也可以用以定义函数:

# let square x = x * x ;;
val square : int -> int = <fun>
# square 2;;
- : int = 4
# square (square 2);;
- : int = 16

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 5) ∗ all code *)

OCaml 中的函数和其它值是一样的,这就是为什么我们使用let关键字来把函数绑定到一个变量名,就和绑定一个整数这样的简单值到变量名一样。当使用let定义函数时,let后的第一个标识符是函数名,后面跟着的每一个标识符都是这个函数的一个不同参数。因此,上面的square就是一个只有一个参数的函数。

现在我们创建了如函数这样更有趣的值,其类型也变得更有趣。int -> int是一个函数类型,表示一个接收一个int型参数并返回int型结果的参数。我们也可以写出接收多个参数的函数。(注意下面的例子只有打开Core.Std时才能工作。)

# let ratio x y =
     Float.of_int x /. Float.of_int y
  ;;
val ratio : int -> int -> float = <fun>
# ratio 4 7;;
- : float = 0.571428571429

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 6) ∗ all code *)

上面恰好也是我们第一次使用模块。这里,Float.of_int引用了Float模块中的of_int函数。这与你在面向对象语言中的经验不同,在那里点号通常被用来访问对象的方法。注意模块名必须以大写字母开头。

多参数函数类型签名的记法开始看着会有点不适应,这一点我们在“多参数函数”一节讲函数柯里化时会解释。现在只要记住,箭头作为函数参数的分隔符,最后一个箭头后面跟着返回值类型。因此,int -> int -> float描述了一个接收两个int参数返回一个float的函数。

我们也可以写出以其它为参数的函数。下面的例子是一个接收 3 个参数的函数:一个测试函数和两个整数参数。这个函数返回可以通过测试函数的两个整数参数之和:

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

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 7) ∗ all code *)

仔细观察推导出的类型签名,我们会看到第一个参数是一个函数,它接收一个整数参数并返回一个布尔值,剩下的两个参数是整型的。下面是如何使用该函数的例子:

# let even x =
    x mod 2 = 0 ;;
val even : int -> bool = <fun>
# sum_if_true even 3 4;;
- : int = 4
# sum_if_true even 2 4;;
- : int = 6

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 8) ∗ all code *)

注意在even的定义中,=有两种不同的使用方式:一是在let绑定中分隔内容和其定义;一是在相等测试中,用于比较x mod 20。尽管形式一样,但却是非常不同的操作符。

类型推导

当类型越来越复杂时,你可能会问 OCaml 是如何在我们没有给出显式类型信息的情况推导出类型的。

OCaml 使用一种叫作类型推导的技术来确定表达式的类型,使用这种技术,可以从表达式中已知的组件类型信息推导出整个表达式的类型。

作为例子,我们过一遍推导sum_if_true的类型的过程:

  • OCaml要求if语句的两个分支有相同的类型,所以表达式if test first then first else 0要求first必须和0类型相同,所以first必须是int型。同样从if test second then second else 0我们也能推导出secondint型的。
  • testfirst为参数。因为firstint型的,所以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 Utop * guided-tour/main.topscript , continued (part 9) * all code *)

上面,我们用其类型标注了函数的每个参数,最后还指出了返回值类型。这样的标注可以用在 OCaml 程序的任何表达式上。

泛型类型推导

有时没有足够的信息来完全推导出一个值的具体类型。看下面这个函数。

# let first_if_true test x y =
    if test x then x else y
  ;;
val first_if_true : ('a -> bool) -> 'a -> 'a -> 'a = <fun>

(* OCaml Utop * guided-tour/main.topscript , continued (part 10) * all code *)

first_if_true以一个test函数和xy两个值作为参数,如果test x为真则返回x,否则返回y。那么first_if_true是什么类型呢?没有像算术运算符或字面值这样明显的线索可以告诉你xy的类型。这使得first_if_true似乎可以用在任何类型的值上。

事实上,如果查看 toplevel 返回的类型,我们就会看到 OCaml 没有选择一个单独的具体类型,而是引入了一个类型变量'a来表示此类型是一个泛型。(你可以把单引号开头的称为类型变量。)特别是test参数的类型是('a -> bool),表示test是一个单参数函数,返回值是bool型,参数可以任何类型'a。但是,无论'a是什么类型,都要和其它两个参数以及first_if_true返回值类型相同。这种泛化叫作参数多态,因为它用类型变量将问题中的类型参数化来解决问题。和 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"

(* OCaml Utop * guided-tour/main.topscript , continued (part 11) * all code *)

也可以这样:

# let big_number x = x > 3;;
val big_number : int -> bool = <fun>
# first_if_true big_number 4 3;;
- : int = 4

(* OCaml Utop * guided-tour/main.topscript , continued (part 12) * all code *)

long_stringbig_number都是函数,都可以和其它两个类型一致的参数一起传给first_if_true(第一个例子中是字符串,第二个例子中是整数)。但同一次fist_if_true调用中我们不能混合使用不同具体类型的'a

# first_if_true big_number "short" "loooooong";;
Characters 25-32:
Error: This expression has type string but an expression was expected of type int

(* OCaml Utop * guided-tour/main.topscript , continued (part 13) * all code *)

上面的例子中big_number需要把'a实例化为int,但是"short"和"loooooong"却要将'a实例化为string,这不可能同时成立。

类型错误 VS 表达式

OCaml(实际上是任何编译型语言)中编译期和运行时捕获的错误是非常不同的。开发过程中越早捕获错误越好,编译期是最好的。

在 toplevel 上工作有时编译期错误和运行时错误之间的差异并不明显,但依然存在。通常,下面这样的类型错误是编译期错误(因为+要求其两个参数都是int型):

# let add_potato x =
     x + "potato";;
Characters 28-36:
Error: This expression has type string but an expression was expected of type int

(* OCaml Utop * guided-tour/main.topscript , continued (part 14) * all code *)

反之那些不能被类型系统捕获的错误,如除 0 错误,会引发运行时异常:

# let is_a_multiple x y =
     x mod y = 0 ;;
val is_a_multiple : int -> int -> bool = <fun>
# is_a_multiple 8 2;;
- : bool = true
# is_a_multiple 8 0;;
Exception: Division_by_zero.

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 15) ∗ all code *)

这里的区别就是无论你是否运行出错的代码,类型错误都会阻止你。仅定义add_potato就会出错,而is_a_multiple只有在被调用且输入触发异常时才会失败。

元组,列表,option 和模式匹配

不知道该怎么翻译 option,就直接不翻译了吧。by clark。

元组

目前为止我们已经见过了几个基本类型,如intfloatstring,还有函数类型,如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.)

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 16) ∗ all code *)

(出于数学上的倾向,使用*是因为所有的t * s对的集合对应于t类型元素集合和s类型元素的笛卡尔积。)

你可以使用 OCaml 的模式匹配语法提取元组的元素,就像下面这样:

# let (x,y) = a_tuple;;
 val x : int = 3
 val y : string = "three"

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 17) ∗ all code *)

其中let绑定左边的(x, y)就是模式。这个模式让我们创建新的变量xy,并且分别绑定到匹配的值的不同部分。在接下来的表达式中就可以使用这两个变量了:

# x + String.length y;;
- : int = 8

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 18) ∗ all code *)

注意元组的构建和模式匹配使用相同的语法。

模式匹配也可以出现在函数参数中。下面是一函数,用以计算平面上两个点之间的距离,每个点用一对float表示。模式匹配语法可以让我们轻松获得需要的值:

# let distance (x1,y1) (x2,y2) =
    sqrt ((x1 -. x2) ** 2. +. (y1 -. y2) ** 2.)
  ;;
val distance : float * float -> float * float -> float = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 19) ∗ all code *)

上面的**用以计算浮点数的乘方。

这只是与模式匹配的首次尝试。模式匹配是 OCaml 中的普遍工具,你将会看到,它异常强大。

列表

元组让你可以组合固定数量,通常类型不同的值,而使用列表你可以保存任意数量类型相同的元素。看下面的例子:

# let languages = ["OCaml";"Perl";"C"];;
val languages : string list = ["OCaml"; "Perl"; "C"]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 20) ∗ all code *)

注意,和元组不同,同一个列表中不能混合类型不同的值:

# let numbers = [3;"four";5];;
Characters 17-23:
Error: This expression has type string but an expression was expected of type int

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 21) ∗ all code *)
List模块

Core有一个List模块,里面有丰富的列表操作函数。我们可以用点号访问模块中的值。如,下面演示如何计算列表的长度:

# List.length languages;;
- : int = 3

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 22) ∗ all code *)

下面是一个更复杂的例子。我们像下面这样可以计算出一个包含每一种语言长度的列表:

# List.map languages ~f:String.length;;
- : int list = [5; 4; 1]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 23) ∗ all code *)

List.map接收两个参数:一个列表和一个用以变换列表元素的函数。它返回转换后的元素组成的新列表,并不改变原列表。

注意,List.map的函数参数是在 标签参数~f下传入的。标签参数以名称标识而非位置,因此允许你改变它们在函数参数中出现的位置而不影响函数行为,如下所示:

# List.map ~f:String.length languages;;
- : int list = [5; 4; 1]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 24) ∗ all code *)

我们会在第二章,变量和函数中学习更多关于标签参数的内容,并了解它们的重要性。

::构造列表

除了使用方括号构造列表,你也可以使用::操作符向一个列表前面添加元素:

# "French" :: "Spanish" :: languages;;
- : string list = ["French"; "Spanish"; "OCaml"; "Perl"; "C"]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 25) ∗ all code *)

这里我们创建了一个新的扩展列表,但并没有改变开始的列表,如下所示:

# languages;;
- : string list = ["OCaml"; "Perl"; "C"]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 26) ∗ all code *)

分号 VS 逗号

和其它语言不同,OCaml 使用分号而不是逗号来分隔列表中的元素,逗号被用来分隔元组中的元素。如果你在列表中使用了逗号,代码也可以编译过,但和你的预期会大不相同。

# ["OCaml", "Perl", "C"];;
- : (string * string * string) list = [("OCaml", "Perl", "C")]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 27) ∗ all code *)

你不会得到含有三个元素的列表,而会得到一个只有一个元素的列表,这个元素是一个三元组。

这个例子也揭示了即使没有括号包围,逗号也能创建元组。因此我们可以这样分配一个整数元组:

# 1,2,3;;
- : int * int * int = (1, 2, 3)

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 28) ∗ all code *)

但这通常被认为是不好的风格,应该避免。

列表的方括号记法实际上是::的语法糖。因此,下面的声明是等价的。注意[]用以表示空列表,::是右结合的:

# [1; 2; 3];;
- : int list = [1; 2; 3]
# 1 :: (2 :: (3 :: []));;
- : int list = [1; 2; 3]
# 1 :: 2 :: 3 :: [];;
- : int list = [1; 2; 3]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 29) ∗ all code *)

::操作符只能用以在列表前面添加元素,所以最后要有一个[],即空列表。还有一个列表连接操作符,@,可以用以连接两个列表:

# [1;2;3] @ [4;5;6];;
- : int list = [1; 2; 3; 4; 5; 6]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 30) ∗ all code *)

有一点必须记住,和::不同,@的时间复杂度不是常数级的,拼接两个列表的时间和第一个列表的长度成正比。

使用match的列表模式

列表元素可以用模式匹配访问。列表模式基于这两个列表构造器:::[]。下面是简单例子:

# let my_favorite_language (my_favorite :: the_rest) =
     my_favorite
  ;;

Characters 25-69:
Warning 8: this pattern-matching is not exhaustive. Here is an example of a value that is not matched:
[]
val my_favorite_language : 'a list -> 'a = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 31) ∗ all code *)

使用::进行的模式匹配,我们分离并命名了列表的第一个元素(my_favorite)和剩下的元素(the_rest)。如果你熟悉 Lisp 或 Scheme,那么我们现在做的和使用car以及cdr函数来分离列表的第一个元素和剩下的部分是等价的。

正如你看到的,toplevel 并不满意这个定义,它会给出一个警告说这个模式不完整。这意味着有一些该类型的值不能被这个模式捕获。警告中甚至给出了不能和给定模式匹配的示例值,即[],空列表。如果执行my_favorite_language就会发现,在非空列表上正常,但对空列表会失败:

# my_favorite_language ["English";"Spanish";"French"];;
- : string = "English"
# my_favorite_language [];;
Exception: (Match_failure //toplevel// 0 25).

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 32) ∗ all code *)

使用match语句,你可以避免这些警告,更重要的是,可以确保你的代码会处理所有的情况。

match语句是 C 或 Java 中switch的加强版。它本质上是允许你列出一组模式(用|分隔,第一个分支前的可以省略)。编译器会将代码分配至第一个匹配的模式。如前所示,模式可以创建和匹配值子结构关联的新变量。

下面是新版的my_favorite_language,使用了match,不会触发编译器警告:

# 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 Utop ∗ guided-tour/main.topscript , continued (part 33) ∗ all code *)

上面还包含了我们第一个 OCaml 注释。OCaml 注释用(**)包围,可以任意嵌套,可以跨多行。OCaml中没有类似 C++ 风格的以//开头的单行注释。

第一个模式,fist :: the_rest,涵盖了languages有至少一元素的情况,因为除了空列表,每一个列表都能写成使用一个或多个::表示的形式。第二个模式,[],只匹配空列表。这样模式就完整了,因为列表或是空的,或是至少有一个元素,这是编译器保证的。

递归列表函数

递归函数,就是调用自身的函数,是 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 Utop ∗ guided-tour/main.topscript , continued (part 34) ∗ all code *)

按照 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 ∗ guided-tour/recursion.ml ∗ all code *)

这建立了一个 OCaml 求值递归函数实际操作的合理的心理模型。

我们可以提出更复杂的列表模式。下面是一个消除列表中连续重复的函数:

# let rec destutter list =
    match list with
    | [] -> []
    | hd1 :: hd2 :: tl ->
      if hd1 = hd2 then destutter (hd2 :: tl)
      else hd1 :: destutter (hd2 :: tl)
  ;;

Characters 29-171:
Warning 8: this pattern-matching is not exhaustive. Here is an example of a value that is not matched:
_::[]val destutter : 'a list -> 'a list = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 35) ∗ all code *)

和上面一样,match的第一项是基本分支,第二项是归纳分支。但是,如警告信息所言,这段代码是有问题的。我们没有处理只有一个元素的列表。可以给match添加一个分支来解决这个问题:

# let rec destutter list =
    match list with
    | [] -> []
    | [hd] -> [hd]
    | hd1 :: hd2 :: tl ->
      if hd1 = hd2 then destutter (hd2 :: tl)
      else hd1 :: destutter (hd2 :: tl)
  ;;
val destutter : 'a list -> 'a list = <fun>
# destutter ["hey";"hey";"hey";"man!"];;
- : string list = ["hey"; "man!"]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 36) ∗ all code *)

注意上面的代码中使用了列表模式的又一个变体[hd]来匹配只有一个元素的列表。我们可以用这种方法匹配任何有固定数量元素的列表,如[x;y;z]会匹配所有有三个元素的列表,并会把元素分别绑定到变量xyz上。

最近的几个例子中,我们的列表处理函数包含了许多递归函数。实际中,这通常都是不必要的。大多数情况下,你会更乐于使用List模块中的迭代函数。但是当需要做点新的事情时,如何使用递归是有好处的。

Options

OCaml 中的另一个常用数据结构是option。option`用以表示一个可能存在或不存在的值。如:

# let divide x y =
    if y = 0 then None else Some (x/y) ;;
val divide : int -> int -> int option = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 37) ∗ all code *)

如果除数为 0,divide函数就返回None,否则返回除法结果的SomeSomeNoneoption值的构造器,就和::[]是列表的构造器一样。你可以把option看作只能有零个和一个元素的列表。

与元组和列表一样,我们可以使用模式匹配来检查option的内容。看下面这个函数,可以从一个可选的时间和一条消息创建一条日志。如果没有给定时间(即,时间为None),就使用当前时间:

# let log_entry maybe_time message =
    let time =
      match maybe_time with
      | Some x -> x
      | None -> Time.now ()
    in
    Time.to_sec_string time ^ " -- " ^ message
  ;;
val log_entry : Time.t option -> string -> string = <fun>
# log_entry (Some Time.epoch) "A long long time ago";;
- : string = "1970-01-01 01:00:00 -- A long long time ago"
# log_entry None "Up to the minute";;
- : string = "2013-08-18 14:48:08 -- Up to the minute"

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 38) ∗ all code *)

本例中使用 Core 中的Time模块来处理时间,使用了^来拼接字符串,^来自Pervasives模块,这个模块在每一个 OCaml 程序中都会默认打开。

使用letin实现嵌套let

log_entry中我们首次在函数体中用let定义变量。letin一起可以在包括函数体在内的任何局部作用域中引入新的绑定。in标志了新变量可以在其中使用的作用域的开头。因此,我们可以这样写:

# let x = 7 in
  x + x
  ;;
- : int = 14

(* OCaml Utop ∗ guided-tour/local_let.topscript ∗ all code *)

注意let绑定的作用域是以双分号结束的,所以x的值以后就不能用了:

# x;;
Characters -1-1:
Error: Unbound value x

(* OCaml Utop ∗ guided-tour/local_let.topscript , continued (part 1) ∗ all code *)

我们也可以在一行上有多个let,每个都会在前面的基础上添加一个新变量:

# let x = 7 in
  let y = x * x in
  x + y
  ;;
- : int = 56

(* OCaml Utop ∗ guided-tour/local_let.topscript , continued (part 2) ∗ all code *)

这种嵌套let绑定是构建复杂表达式的通用方法,每个let都命名了一部分组件,最后在一个最终表达式中组合在一起。

option非常重要,因为它是 OCaml 中表示一个可能不存在的值的标准方法,OCaml 是没有NullPointException这类东西的。这与大多数语言都不一样,包括 Java 和 C#,在这些语言中即使不是所有的,起码也有大部分数据类型是可以为空的(nullable),就是说,不管什么类型,它们的值都可能是一个空值。这些语言中,到处都潜伏着空值。

OCaml 中,不存在的值是显式的。类型为string * string值一定总是真的包含两个正确定义的string型值。如果你想要第一个字符串可以不存在,那么就要把类型改为string option * string。在第七章错误处理中我们会看到,这种显式声明使编译器可以给我们提供巨大的帮助,以确保我们已经正确处理了值不存在的情况。

记录(Record)和变体(Variant)

到目前为止我们见到的数据结构都是语言预定义的,像列表和元组。但 OCaml 同样也允许我们定义新的数据类型。下面是一个玩具示例,定义了一个表示二维点的数据类型:

# type point2d = { x : float; y : float };;
type point2d = { x : float; y : float; }

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 41) ∗ all code *)

point2d是一个记录类型,你可以把记录想成是一个元组,但每个字段都有命名,而不是按位置区分。记录类型很容易构造:

# let p = { x = 3.; y = -4. };;
val p : point2d = {x = 3.; y = -4.}

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 42) ∗ all code *)

并且我们可以用模式匹配访问这些类型的内容:

# let magnitude { x = x_pos; y = y_pos } =
    sqrt (x_pos ** 2. +. y_pos ** 2.);;
val magnitude : point2d -> float = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 43) ∗ all code *)

这里模式匹配把x_pos变量绑定到x字段的值,把y_pos变量绑定到y字段的值。

我们可以一种 字段名双关(field punning)技术把上面的代码写得更精炼,字段名和其绑定的变量名在匹配中势必会相关联,这样我们就不用两个都写了。使用这种技术,magnitude函数可以像下面这样重写:

# let magnitude { x; y } = sqrt (x ** 2. +. y ** 2.);;
val magnitude : point2d -> float = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 44) ∗ all code *)

也可以使用点号访问记录的字段:

# let distance v1 v2 =
     magnitude { x = v1.x -. v2.x; y = v1.y -. v2.y };;
val distance : point2d -> point2d -> float = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 45) ∗ all code *)

我们当然可以在更大的类型中使用新创建的类型。下面的例子中,是一些建摸不同几何物体的类型,其中用到了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 circle_desc = { center : point2d; radius : float; } type rect_desc = { lower_left : point2d; width : float; height : float; } type segment_desc = { endpoint1 : point2d; endpoint2 : point2d; }

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 46) ∗ all code *)

现在想象一下你需要把这些类型的多个物体组合在一起作为一个多物体场景的描述。你需要一些统一的方法将这些物体用一种类型表示。变体类型是实现这种需求的一个方法:

# type scene_element =
    | Circle  of circle_desc
    | Rect    of rect_desc
    | Segment of segment_desc
  ;;
type scene_element = Circle of circle_desc | Rect of rect_desc | Segment of segment_desc

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 47) ∗ all code *)

变体的不同情况用|分开(第一个|是可选的),并一种情况都有一个大写字母开头的标签以彼此区分,像CircleRectSegment

现在来看看我们如何写一个函数来测试一个点是否在一个sense_element列表的一些元素内部:

# let is_inside_scene_element point scene_element =
     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 { endpoint1; endpoint2 } -> false
  ;;
val is_inside_scene_element : point2d -> scene_element -> bool = <fun>
# let is_inside_scene point scene =
     List.exists scene
       ~f:(fun el -> is_inside_scene_element point el)
   ;;
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

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 48) ∗ all code *)

这里match的使用可能会让你想起matchoption以及list一都使用的情景。这并不意外:optionlist实际上都是变体类型的例子,只是他们太重要了,以至于需要在标准库中定义(列表还有特殊的语法)。

调用List.exists时我们首次使用了 匿名函数。匿名函数使用fun关键字声明,不需要显式命名。这种函数在 OCaml 中很常用,特别是在使用List.exists这种迭代函数时。

List.exists函数可以检查给定的列表中是否存在在上面调用给定的函数时值为true的元素。这里,我们用List.exists来检查是否存在一个我们给定的点在其内部的元素。

命令式编程

目前为止我们写的代码几乎都是纯函数式,函数式大至说就是代码运行时不修改变量或值。实际上,我们目前为止碰到的所有数据结构几乎都是不可变的,就是说语言中是没有办法改变它们的。这和命令式编程有很大的不同,在命令式编程中,计算结构就是一些指令序列,这些指令以修改程序的状态的方式执行。

OCaml 中默认的是函数式代码,使用变量绑定,大多数数据结构都是不可变的。但 OCaml 也为命令式编程提供了很好的支持,包含可变数据结构,如数组和哈希表等,还有像forwhile循环这样的控制流结构。

数组(Array)

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

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 49) ∗ all code *)

.(i)语法用来引用一个数组元素,<-语法用以修改。因为数组元素从0开始计数,所以.(2)是第三个元素。

上面出现的unit类型很有意思,它只能有一个值,就是()。这意味着unit的值不能传递任何信息,所以通常被用作占位符。因此,我们用uint作为设置可变字段这类操作的返回值,这类操作使用副作用而不是返回值和外界通信。它也被用作函数参数,表明函数不需要任何输入。和 C 语言以及 Java 语言中void的角色类似。

可变记录字段

数组是重要的可变数据结构,但不是唯一的。记录默认是不可变的,但是其中的一些字段可以显式声明成可变的。下面这个小例子中,是一个数据结构,用以存储一组数连续的统计摘要。基本数据结构如下:

# type running_sum =
   { mutable sum: float;
     mutable sum_sq: float; (* sum of squares *)
     mutable samples: int;
   }
  ;;
type running_sum = { mutable sum : float; mutable sum_sq : float; mutable samples : int; }

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 50) ∗ all code *)

running被设计为易于增量扩展,并足以计算均值和标准差,如下所示。注意两个let绑定之间没有双分号,因为双分号只有在告诉utop执行输入时才需要,不是用来分隔两个声明的:

# let mean rsum = rsum.sum /. float rsum.samples
  let stdev rsum =
     sqrt (rsum.sum_sq /. float rsum.samples
           -. (rsum.sum /. float rsum.samples) ** 2.) ;;
val mean : running_sum -> float = <fun> val stdev : running_sum -> float = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 51) ∗ all code *)

上面我们使用了float函数,是Float.of_int的方便替代,由Pervasives模块提供。

我们还需要用以创建和更新running_sum的函数:

# let create () = { sum = 0.; sum_sq = 0.; samples = 0 }
  let update rsum x =
     rsum.samples <- rsum.samples + 1;
     rsum.sum     <- rsum.sum     +. x;
     rsum.sum_sq  <- rsum.sum_sq  +. x *. x
  ;;
val create : unit -> running_sum = <fun> val update : running_sum -> float -> unit = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 52) ∗ all code *)

create返回一个和空集相关的running_sumupdate rsum x通过更新样本数、和以及平方和来修改rsum,以反映将x添加到了样本集合中。

注意上面代码中操作序列之间单引号的使用。当我们之写纯函数式代码时,这是不需要的,但是当写命令式代码时,就要开始使用它了。

下面是使用createupdate的例子。代码中使用了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.33333333333
# stdev rsum;;
- : float = 3.94405318873

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 53) ∗ all code *)

需要指出的是上面的算法在数学上是很幼稚的,面对删除操作时精度很底。你可以看看维基百科上的这篇文章,特别关注一下加权增量和并行算法。

引用(Ref)

我们可以使用ref创建一个单独的可变值。ref类型是标准库中预定义的,并没有什么特别的,它只是一个普通的记录类型,拥有一个名为contents的单独的可变字段:

# let x = { contents = 0 };;
val x : int ref = {contents = 0}
# x.contents <- x.contents + 1;;
- : unit = ()
# x;;
- : int ref = {contents = 1}

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 54) ∗ all code *)

为了让ref更方便使用,定义了几个函数和操作符:

# let x = ref 0  (* create a ref, i.e., { contents = 0 } *) ;;
val x : int 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

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 55) ∗ all code *)

这些操作符也没有什么神奇的。你完全可以用几行代码重新实现ref类型和所有这些操作符:

# type 'a ref = { mutable contents : 'a }

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

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 56) ∗ all code *)

ref前面的'a表示ref类型是多态的,和列表多态一样,指可以持有任何类型的值。!:=周围的括号是必须的,因为它们是操作符,而不是普通函数。

尽管ref只是另外一个记录类型,它也是很重要的,因为它是模拟传统语言可变变量的标准方法。例如,我们可以通过调用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>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 57) ∗ all code *)

这并不是求和一个列表的最惯用的(或者说最快的)方法,但是它向你展示了如何用ref来取代可变变量。

For和while循环

OCaml 也支持传统命令式的控制流结构,如forwhile循环。下面的例子中使用for循环来重排数组。我们使用Random模块作为随机源。Random从一个默认种子开始,但是你也可以调用Random.self_init来选择一个新的随机种子:

# let permute array =
    let length = Array.length array in
    for i = 0 to length - 2 do
       (* pick a j that is after i and before the end of the array *)
       let j = i + 1 + Random.int (length - i - 1) in
       (* Swap i and j *)
       let tmp = array.(i) in
       array.(i) <- array.(j);
       array.(j) <- tmp
    done
  ;;
val permute : 'a array -> unit = <fun>

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 58) ∗ all code *)

从语法的角度上,你应该会注意到区分出for循环的关键字:fortododone

下面是执行这段代码的例子:

# 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 =
[|1; 2; 4; 6; 11; 7; 14; 9; 10; 0; 13; 16; 19; 12; 17; 5; 3; 18; 8; 15|]

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 59) ∗ all code *)

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 Utop ∗ guided-tour/main.topscript , continued (part 60) ∗ all code *)

要作为旁注指出的是,上面的代码利用了&&的特性,OCaml 中的与操作符,有短路效应。在形如expr1 && expr2的表达式中,只有expr1求值为真时,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").

(* OCaml Utop ∗ guided-tour/main.topscript , continued (part 61) ∗ all code *)

或操作符||也有和&&类似的短路效用。

一个完整的程序

目前为止,我们已经使用 utop把玩了基本的语言特性。现在我们要展示如何创建一个简单的独立程序。我们会建立一个程序,求从标准输入读取的一组数字之和。

下面是代码,你可以保存到一个名为 sum.ml 的文件中。注意我们没有使用;;来结束表达式,因为在 toplevel 之外这是不需要的:

open Core.Std

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 ∗ guided-tour/sum.ml ∗ all code *)

这是我们首次使用 OCaml 的输入输出例程。read_and_accumulate是一个递归函数,用In_channel.input_line来按行读取标准输入,每次迭代都使用更新后的累加值sum调用自身。input_line返回一个option值,None表明输入流结束。

read_and_accumulate返回后,需要打印和。这是使用printf命令完成的,它提供了类型安全的格式化字符串支持,就和你在许多其它语言看到的一样。格式化字符串由编译器解析并用以确定剩余参数的数量和类型。这里只有一个单独的格式化指令,%F,所以printf还需要一个float类型的参数。

编译和运行

我们使用 corebuild来编译我们的程序,这是一个在 ocamlbuild基础上的小包装器, ocamlbuild是一个使用 OCaml 编译器的构建工具。 corebuild脚本随Core一起安装,目的是传递使用Core的程序所需的标志:

$ corebuild sum.native

# Terminal ∗ guided-tour/build_sum.out ∗ all code

.native 后缀表示我们要构建本地可执行代码,我们会在第4章文件、模块和程序中详细讨论。构建完成后,我们就可以像其它命令行工具一样使用产生的结果。我们向 sum.native 输入一系列数字,一行一个,敲 Ctrl-D结束输入:

$ ./sum.native
1
2
3
94.5
Total: 100.5

# Terminal ∗ guided-tour/sum.out ∗ all code

要创建一个真正有用的命令行程序还有许多工作要做,包括一个合适的命令行解析接口和更好的错误处理,所有这些会在第14章命令行解析中介绍。

下一步干什么

导览到此为止!还有许多特性没有介绍,许多细节需要解释,但我们希望你已经建立了对 OCaml 的大致印象,并在本书接下来的阅读中更舒服。