循环和递归

与其他 OCaml.org 文档一样,代码示例要么是您可以测试的,要么是代码示例。以 CLI 提示符 # 开头,以 ;; 结尾,并有明确输出的代码片段可以在 OCaml 顶层(ocamlutop)中测试或粘贴到 OCaml 游乐场中。如果代码没有以 # 开头,也没有以 ;; 结尾,那么它就是关于如何编写代码的示例。

for 循环和 while 循环

OCaml 支持一种相当有限的熟悉的 for 循环形式

for variable = start_value to end_value do
  expression
done

for variable = start_value downto end_value do
  expression
done

来自 LablGtk 的一个简单但真实的例子

for i = 1 to n_jobs () do
  do_next_job ()
done

在 OCaml 中,for 循环只是编写

let i = 1 in
do_next_job ();
let i = 2 in
do_next_job ();
let i = 3 in
do_next_job ();
  ...
let i = n_jobs () in
do_next_job ();
()

OCaml 不支持从 for 循环中提前退出的概念,即它没有 breakcontinuelast 语句。(您可以抛出一个异常并外部捕获它,这将很快运行,但通常看起来很笨拙。)

OCaml for 循环中的表达式应该计算为 unit(否则您将收到警告),并且整个 for 循环表达式返回 unit

# for i = 1 to 10 do i done;;
Line 1, characters 20-21:
Warning 10 [non-unit-statement]: this expression should have type unit.
- : unit = ()

函数式程序员倾向于使用递归而不是显式循环,并且明智的做法是怀疑for 循环,因为它不能返回任何东西,因此 OCaml 的for 循环功能相对较弱。我们将在下面讨论递归。

OCaml 中的while 循环写成

while boolean-condition do
  expression
done

for 循环一样,该语言不提供从 while 循环中退出的方法,除非通过抛出异常,这意味着 while 循环的使用相当有限。再次提醒,函数式程序员喜欢递归,因此 while 循环在 OCaml 中是二等公民。

如果您停下来考虑 while 循环,您可能会发现它们实际上毫无用处,除非与我们的老朋友引用结合使用。让我们假设 OCaml 暂时没有引用

let quit_loop = false in
  while not quit_loop do
    print_string "Have you had enough yet? (y/n) ";
    let str = read_line () in
      if str.[0] = 'y' then
        (* how do I set quit_loop to true ?!? *)
  done

请记住,quit_loop 不是一个真正的“变量”。let 绑定只是使 quit_loop 成为 false 的简写。这意味着 while 循环条件(以红色显示)始终为真,并且循环永远运行!

幸运的是,OCaml确实有引用,所以我们可以根据需要编写上面的代码。不要混淆,不要认为 !(感叹号)表示“非”,如 C/Java 中那样。它在这里用于表示“取消引用指针”,实际上类似于 Forth。您最好将 ! 理解为“获取”或“取消引用”。

let quit_loop = ref false in
  while not !quit_loop do
    print_string "Have you had enough yet? (y/n) ";
    let str = read_line () in
      if str.[0] = 'y' then quit_loop := true
  done;;

遍历列表

如果您想遍历列表,不要做一个命令式程序员,伸手去拿你信赖的六连发左轮手枪先生 For 循环!OCaml 有些更好更快的方法来遍历列表,它们都位于 List 模块中。事实上,List 中有几十个有用的函数,但我只在这里谈论最常用的函数。

首先,让我们定义一个我们要使用的列表

# let my_list = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10];;
val my_list : int list = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]

如果您想对列表中的每个元素调用一次函数,请使用 List.iter,如下所示

# let f elem =
    Printf.printf "I'm looking at element %d now\n" elem
  in
    List.iter f my_list;;
I'm looking at element 1 now
I'm looking at element 2 now
I'm looking at element 3 now
I'm looking at element 4 now
I'm looking at element 5 now
I'm looking at element 6 now
I'm looking at element 7 now
I'm looking at element 8 now
I'm looking at element 9 now
I'm looking at element 10 now
- : unit = ()

事实上,List.iter 是您每次小脑建议您使用 for 循环时应该考虑使用的。

如果您想转换列表中的每个元素——例如,将列表中的每个元素加倍——那么使用 List.map

# List.map (( * ) 2) my_list;;
- : int list = [2; 4; 6; 8; 10; 12; 14; 16; 18; 20]

函数 List.filter 只收集满足某些条件的列表元素,例如,返回列表中的所有偶数。

# let is_even i =
    i mod 2 = 0
  in
    List.filter is_even my_list;;
- : int list = [2; 4; 6; 8; 10]

要找出列表是否包含某个元素,请使用 List.mem(成员的简称)

# List.mem 12 my_list;;
- : bool = false

List.for_allList.exists 与谓词逻辑中的“forall”和“exist”运算符相同。

对于同时操作两个列表,有一些“-2”变体,即 iter2map2for_all2exists2

mapfilter 函数独立地操作单个列表元素。Fold 是一种更不寻常的操作,最好将其视为“在列表的每个元素之间插入一个运算符”。假设我想将列表中的所有数字加在一起。用简单的话说,我想在列表中的元素之间插入一个加号(+)

# 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10;;
- : int = 55

fold 操作会这样做,虽然具体细节有点棘手。首先,如果我尝试对一个空列表求和,如果答案是零,而不是错误,那会很好。但是,如果我尝试找到列表的乘积,最好答案是 1 而不是其他。有必要为 fold 提供某种“默认”参数。第二个问题在 +* 这样的简单运算符中没有出现:如果使用的运算符不是结合的,即 (a op b) op c 不等于 a op (b op c) 会发生什么?在这种情况下,如果我从列表的左侧开始,向右工作,与从右侧开始,向左工作相比,结果会有所不同。因此,fold 有两个版本,称为 List.fold_left(从左到右工作)和 List.fold_right(从右到左工作,效率也更低)。

让我们使用 List.fold_left 为整数列表定义 sumproduct 函数

# let sum = List.fold_left ( + ) 0;;
val sum : int list -> int = <fun>
# let product = List.fold_left ( * ) 1;;
val product : int list -> int = <fun>
# sum my_list;;
- : int = 55
# product my_list;;
- : int = 3628800

这太容易了!请注意,我无意中找到了一种计算数学阶乘的方法

# let fact n = product (range 1 n);;
val fact : int -> int = <fun>
# fact 10;;
- : int = 3628800

(请注意,这个阶乘函数不是很有用,因为它会溢出整数,即使对于相当小的 n 值也会给出错误的答案。)

遍历字符串

String 模块还包含数十个有用的与字符串相关的函数,其中一些与遍历字符串有关。

String.copy 复制字符串,类似于 strdup。 还有一个 String.iter 函数,其工作方式类似于 List.iter,只是遍历字符串中的字符。

递归

现在我们来谈论一个比较难的话题:递归。 函数式编程人员以他们对递归函数的热爱而著称,在很多方面,函数式编程中的递归函数相当于命令式编程中的循环。 在函数式语言中,循环是二等公民,而递归函数则获得了最佳支持。

编写递归函数需要改变从编写 for 循环和 while 循环的思维方式,所以本节只是一个介绍和一些例子。

在第一个例子中,我们将把整个文件读入内存(到一个长字符串中)。 主要有三种方法可以做到这一点。

方法 1

获取文件长度,然后使用 really_input 方法一次性读取整个文件。 这是最简单的,但它可能不适用于不是真正文件的通道(例如,读取键盘输入),这就是我们有另外两种方法的原因。

方法 2

命令式方法使用一个 while 循环,该循环使用异常跳出。

方法 3

递归循环再次使用异常跳出递归。

我们将在这里介绍一些新的概念。 我们的第二种和第三种方法将使用 Buffer 模块,这是一个可扩展的缓冲区。 想想它就像一个字符串,你可以在它的末尾高效地追加更多文本。 我们还将捕获 End_of_file 异常,输入函数在到达输入结尾时会抛出该异常。 最后,我们将使用 Sys.argv.(1) 获取第一个命令行参数。

(* Read whole file: Approach 1 *)
open Printf

let read_whole_chan chan =
  let len = in_channel_length chan in
  let result = (Bytes.create len) in
    really_input chan result 0 len;
    (Bytes.to_string result)

let read_whole_file filename =
  let chan = open_in filename in
    read_whole_chan chan

let main () =
  let filename = Sys.argv.(1) in
  let str = read_whole_file filename in
    printf "I read %d characters from %s\n" (String.length str) filename

方法 1 可行,但它并不令人满意,因为 read_whole_chan 无法在非文件通道上工作,例如键盘输入或套接字。 方法 2 包含一个 while 循环。

(* Read whole file: Approach 2 *)
open Printf

let read_whole_chan chan =
  let buf = Buffer.create 4096 in
  try
    while true do
      let line = input_line chan in
        Buffer.add_string buf line;
        Buffer.add_char buf '\n'
    done;
    assert false (* This is never executed
	                (always raises Assert_failure). *)
  with
    End_of_file -> Buffer.contents buf

let read_whole_file filename =
  let chan = open_in filename in
    read_whole_chan chan

let main () =
  let filename = Sys.argv.(1) in
  let str = read_whole_file filename in
    printf "I read %d characters from %s\n" (String.length str) filename

方法 2 的关键是观察中央 while 循环。 请记住,从 while 循环中提前跳出的唯一方法是使用异常,这正是我们在这里所做的。 虽然我还没有介绍异常,但你可能不会对上面的代码中 input_line 在到达文件末尾时抛出的 End_of_file 异常感到困惑。 缓冲区 buf 累积文件内容,当我们到达文件末尾时,我们将其返回(Buffer.contents buf)。

关于这一点有一个奇怪的地方,就是在 while 循环之后有一个明显的多余的语句(assert false)。 它有什么作用呢? 请记住,while 循环和 for 循环一样,只是表达式,它们返回 unit 对象(())。 但是,OCaml 要求 try 中的返回值类型与捕获的每个异常的返回值类型匹配。 在这种情况下,因为 End_of_file 会导致 string,所以 try 的主体也必须“返回”一个字符串(因为无限的 while 循环,字符串实际上永远不会被返回)。 assert false 具有多态类型,因此它将与 with 分支返回的任何值统一。

这是递归版本。 注意它比方法 2 更短,但对于命令式编程人员来说,它并不容易理解。

(* Read whole file: Approach 3 *)
open Printf

let read_whole_chan chan =
  let buf = Buffer.create 4096 in
  let rec loop () =
    let line = input_line chan in
      Buffer.add_string buf line;
      Buffer.add_char buf '\n';
      loop ()
  in
    try loop () with
      End_of_file -> Buffer.contents buf

let read_whole_file filename =
  let chan = open_in filename in
    read_whole_chan chan

let main () =
  let filename = Sys.argv.(1) in
  let str = read_whole_file filename in
  printf "I read %d characters from %s\n" (String.length str) filename

我们再次遇到了无限循环,但这次是使用递归实现的。 loop 在函数末尾调用自身。 当 input_line 抛出 End_of_file 异常时,无限递归被打破。

看起来方法 3 可能会在给它一个特别大的文件时导致堆栈溢出,但事实并非如此! 由于尾递归(将在下面讨论),编译器将把递归的 loop 函数转换为真正的 while 循环(!),它在恒定的堆栈空间中运行。

在下一个例子中,我们将展示递归在构造或检查某些类型的数据结构(尤其是树)方面是多么强大。 让我们有一个递归类型来表示文件系统中的文件。

# type filesystem = File of string | Directory of filesystem list;;
type filesystem = File of string | Directory of filesystem list

opendirreaddir 函数用于打开目录并从目录中读取元素。 我将定义一个方便的 readdir_no_ex 函数,它隐藏了 readdir 在到达目录结尾时抛出的烦人的 End_of_file 异常。

# #load "unix.cma";;
# open Unix;;
# let readdir_no_ex dirh =
  try
    Some (readdir dirh)
  with
    End_of_file -> None;;
val readdir_no_ex : dir_handle -> string option = <fun>

readdir_no_ex 的类型是这个。 回想一下我们之前关于空指针的讨论。

# readdir_no_ex;;
- : dir_handle -> string option = <fun>

我还要定义一个简单的递归函数,我可以使用它将 filesystem 类型转换为字符串(例如)进行打印。

# let rec string_of_filesystem fs =
  match fs with
  | File filename -> filename ^ "\n"
  | Directory fs_list ->
      List.fold_left (^) "" (List.map string_of_filesystem fs_list);;
val string_of_filesystem : filesystem -> string = <fun>

注意 fold_leftmap 的使用。 map 用于(递归地)将列表中的每个 filesystem 转换为 string。 然后,fold_left (^) "" 将列表连接在一起,形成一个大的字符串。 还要注意模式匹配的使用。(库定义了一个名为 String.concat 的函数,它本质上等同于 fold_left (^) ,但实现效率更高)。

现在让我们定义一个函数来递归地读取目录结构,并返回一个递归的 filesystem 数据结构。 我将分步骤展示此函数,但我会在本节末尾打印出整个函数。 首先是函数的概要

let rec read_directory path =
  let dirh = opendir path in
  let rec loop () =
    (* ..... *) in
  Directory (loop ())

opendir 的调用打开给定的路径并返回一个 dir_handle,稍后我们将能够使用 readdir_no_ex 从中读取名称。 函数的返回值将是一个 Directory fs_list,因此我们需要做的就是编写 loop 函数,该函数返回一个 filesystem 列表。 loop 的类型将是

loop : unit -> filesystem list

我们如何定义 loop? 让我们再次分步骤进行。

let rec loop () =
  let filename = readdir_no_ex dirh in
  (* ..... *)

首先,我们从目录句柄中读取下一个文件名。 filename 的类型为 string option,换句话说,它可以是 NoneSome "foo",其中 foo 是目录中下一个文件名的名称。 我们还需要忽略 "."".." 文件(即当前目录和父目录)。 我们可以通过一个不错的模式匹配来完成所有这些操作。

let rec loop () =
  let filename = readdir_no_ex dirh in
    match filename with
    | None -> []
    | Some "." -> loop ()
    | Some ".." -> loop ()
    | Some filename ->
        (* ..... *)

None 情况很简单。 递归地思考(!)如果调用 loop,并且我们已经到达目录结尾,则 loop 需要返回一个条目列表。 由于没有条目,因此它返回空列表([])。

对于 ".""..",我们只需忽略文件并再次调用 loop

loop 读取真实文件名(下面的 Some filename 匹配)时,我们该怎么办? 设 pathname 为文件的完整路径。 我们对文件进行“stat”,以查看它是否真的是一个目录。 如果它是目录,则通过递归调用 read_directory 设置 this,该函数将返回 Directory something。 注意,read_directory 的总体结果是 Directory (loop ())。 如果文件真的是一个文件(不是目录),那么我们让 thisFile pathname。 接下来,我们做了一些巧妙的事情:我们返回 this :: loop ()。 这是对 loop () 的递归调用,以便计算剩余的目录成员(一个列表),然后在其中添加 this

# let rec read_directory path =
  let dirh = opendir path in
  let rec loop () =
    let filename = readdir_no_ex dirh in
      match filename with
      | None -> []
      | Some "." -> loop ()
      | Some ".." -> loop ()
      | Some filename ->
          let pathname = path ^ "/" ^ filename in
          let stat = lstat pathname in
          let this =
            if stat.st_kind = S_DIR then
              read_directory pathname
            else
              File pathname
          in
            this :: loop ()
  in
    Directory (loop ());;
val read_directory : string -> filesystem = <fun>

这是一段非常复杂的递归代码,但虽然这是一个虚构的例子,但它在现实世界的函数式程序中发现的复杂递归模式中相当典型。 从中汲取的两个重要经验是

  • 使用递归构建列表
let rec loop () =
  match data with (* Could also be an if statement *)
  | base case -> []
  | recursive case -> element :: loop ()

将此与我们之前的 range 函数进行比较。 递归模式完全相同。

let rec range a b =
  if a > b then []              (* Base case *)
  else a :: range (a + 1) b     (* Recursive case *)
  • 使用递归构建树
let rec read_directory path =
  (* blah blah *)
  if file_is_a_directory path then
    read_directory path_to_file
  else
    Leaf file

现在只剩下一些代码来调用 read_directory 并显示结果,才能使它成为一个可工作的程序。

let path = Sys.argv.(1) in
let fs = read_directory path in
print_endline (string_of_filesystem fs)

递归示例:列表中的最大元素

请记住列表的基本递归模式。

let rec loop () =
  a match or if statement
  | base case -> []
  | recursive case -> element :: loop ()

这里关键实际上是使用匹配/基本情况/递归情况模式。 在这个例子中(查找列表中的最大元素),我们将有两个基本情况和一个递归情况。 但在我们直接跳到代码之前,让我们先退一步,想想问题。 通过思考问题,解决方案将如同魔法般出现。(这是真的!)

首先,让我们明确一点,列表的最大元素只是最大的那个,例如,列表 [1; 2; 3; 4; 1] 的最大元素是 4

当然,也有例外。 空列表 []没有最大元素。 如果传递给我们一个空列表,它将抛出错误。

单个元素列表(例如 [4])的最大元素是什么? 这很简单! 它就是元素本身,所以 list_max [4] 应该返回 4,或者一般情况下,list_max [x] 应该返回 x

一般列表 x :: remainder(这是列表的“cons”表示法,所以 remainder 是尾部 - 也是一个列表)的最大元素是什么?

思考一下。 假设你知道 remainder 的最大元素,比如 yx :: remainder 的最大元素是什么? 这取决于 x > y 还是 x <= y。 如果 x 大于 y,那么总体最大值为 x,反之,如果 x 小于 y,那么总体最大值为 y

这真的有效吗?

再次考虑 [1; 2; 3; 4; 1]。 这是 1 :: [2; 3; 4; 1]。 现在,余数 [2; 3; 4; 1] 的最大元素是 4。 所以现在我们对 x = 1y = 4 感兴趣。 头部元素 x = 1 并不重要,因为 y = 4 更大,所以整个列表的总体最大值为 y = 4

现在让我们将上面的规则编码成一个可工作的函数。

# let rec list_max xs =
  match xs with
  | [] -> (* empty list: fail *)
      failwith "list_max called on empty list"
  | [x] -> (* single element list: return the element *)
      x
  | x :: remainder -> (* multiple element list: recursive case *)
      max x (list_max remainder);;
val list_max : 'a list -> 'a = <fun>

我已经添加了注释,这样你就可以看到我们上面决定的规则/特殊情况是如何真正对应于代码行的。

它有效吗?

# list_max [1; 2; 3; 4; 1];;
- : int = 4
# list_max [];;
Exception: Failure "list_max called on empty list".
# list_max [5; 4; 3; 2; 1];;
- : int = 5
# list_max [5; 4; 3; 2; 1; 100];;
- : int = 100

注意,提出的解决方案既(a)与命令式 for 循环解决方案截然不同,又(b)与问题规范更加紧密地联系在一起。 函数式编程人员会告诉你,这是因为函数式风格比命令式风格处于更高层次,因此更好也更容易。 你是否相信他们取决于你自己。 毫无疑问,对函数式版本进行逻辑推理要简单得多,这对于你想正式证明 list_max 是否正确(“正确”是数学上说一个程序是可证明无错误的方式,这对航天飞机、核电站和更高质量的软件非常有用)非常有用。

尾递归

让我们再次看看 range 函数,大约是第二十次。

# let rec range a b =
  if a > b then []
  else a :: range (a+1) b;;
val range : int -> int -> int list = <fun>

我将稍微改写它,以使程序的结构更加清晰(但仍然是同一个函数)。

# let rec range a b =
  if a > b then [] else
    let result = range (a+1) b in
      a :: result;;
val range : int -> int -> int list = <fun>

我们称之为

# List.length (range 1 10);;
- : int = 10
# List.length (range 1 1000000);;
Stack overflow during evaluation (looping recursion?).

嗯……乍一看,这似乎是递归编程的一个问题,因此也是整个函数式编程的一个问题! 如果你使用递归而不是迭代的方式编写代码,那么在大型输入上必然会用完堆栈空间,对吧?

实际上,错了。 编译器可以在某些类型的递归函数上执行一个简单的优化,将它们转换为 while 循环。 因此,这些特定类型的递归函数在恒定的堆栈空间中运行,并具有与命令式 while 循环相同的效率。 这些函数称为尾递归函数

在尾递归函数中,递归调用发生在最后。 还记得我们上面的 loop () 函数吗? 它们都有以下形式

let rec loop () =
  (* do something *)
  loop ()

因为对 loop () 的递归调用发生在最后,所以 loop 是尾递归的,编译器将把它转换为 while 循环。

不幸的是,range 不是尾递归的,上面的长版本解释了原因。对 range 的递归调用不是作为最后一件事情发生的。事实上,最后的事情是 ::(cons)操作。因此,编译器不会将递归转换为 while 循环,并且该函数在使用堆栈空间方面效率不高。

使用累积参数或 accumulator 允许以尾递归的方式编写诸如 range 之类的函数,这意味着它们将是高效的并且在大型输入上能够正常工作。让我们规划我们重写的 range 函数,该函数将使用累积参数来存储“迄今为止的结果”。

let rec range2 a b accum =
  (* ... *)

let range a b =
  range2 a b []

accum 参数将累积结果。它是“迄今为止的结果”。我们传入空列表(“迄今为止没有结果”)。当 a > b 时,情况很简单。

let rec range2 a b accum =
  if a > b then accum
  else
    (* ... *)

如果 a > b(即,如果我们已经到达递归的末尾),那么停止并返回结果(accum)。

现在,诀窍是编写 else 子句,并确保对 range2 的调用是我们所做的最后一件事,这样该函数才是尾递归的。

# let rec range2 a b accum =
  if a > b then accum
  else range2 (a + 1) b (a :: accum);;
val range2 : int -> int -> int list -> int list = <fun>

这个函数只有一个小的缺陷。它反向构造列表!但是,这很容易通过重新定义 range 来纠正。

# let range a b = List.rev (range2 a b []);;
val range : int -> int -> int list = <fun>

这次它可以工作了,尽管它的运行速度有点慢,因为它确实需要构造一个包含一百万个元素的列表。

# List.length (range 1 1000000);;
- : int = 1000000

以下实现是先前实现的两倍快,因为它不需要反转列表。

# let rec range2 a b accum =
  if b < a then accum
  else range2 a (b - 1) (b :: accum);;
val range2 : int -> int -> int list -> int list = <fun>
# let range a b =
  range2 a b [];;
val range : int -> int -> int list = <fun>

这是对尾递归的简要概述,但在现实世界中,确定一个函数是否是尾递归的可能非常困难。

我们在这里真正学到了什么?

有一点是,递归函数对没有经验的程序员来说是一个危险的陷阱。你的函数可能看起来在小输入上工作(在测试期间),但随后在暴露于大输入时在现场灾难性地失败。这是反对使用递归函数的一个论点;相反,尽可能使用显式的 while 循环。

可变记录、引用(再次!)和数组

之前我们顺便提到了记录。它们类似于 C 的 struct

# type pair_of_ints = {a : int; b : int};;
type pair_of_ints = { a : int; b : int; }
# {a = 3; b = 5};;
- : pair_of_ints = {a = 3; b = 5}
# {a = 3};;
Line 1, characters 1-8:
Error: Some record fields are undefined: b

让我们继续另一个有趣的特性:OCaml 记录可以具有可变字段。通常,像 {a = 3; b = 5} 这样的表达式是一个不可变的、恒定的对象。但是,如果记录具有可变字段,则有一种方法可以在记录中更改这些字段。这是 OCaml 的一个命令式特性,因为函数式语言通常不允许可变对象(或引用或可变数组,我们将在稍后讨论)。

下面是一个使用可变字段定义的对象,用于计算访问对象的次数。你可以想象它被用于缓存方案中,以决定从内存中驱逐哪些对象。

# type name = {name : string; mutable access_count : int};;
type name = { name : string; mutable access_count : int; }

这是一个在名称上定义的函数,它打印 name 字段并递增可变的 access_count 字段。

# let print_name name =
  print_endline ("The name is " ^ name.name);
  name.access_count <- name.access_count + 1;;
val print_name : name -> unit = <fun>

注意 print_name 的一个奇怪(而且非常不函数化)的特性:它修改了它的 access_count 参数。这个函数不是“纯函数”。OCaml 是一种函数式语言,但它并没有强迫你进行函数式编程。

无论如何,让我们看看 print_name 的实际操作。

# let n = {name = "Richard Jones"; access_count = 0};;
val n : name = {name = "Richard Jones"; access_count = 0}
# n;;
- : name = {name = "Richard Jones"; access_count = 0}
# print_name n;;
The name is Richard Jones
- : unit = ()
# n;;
- : name = {name = "Richard Jones"; access_count = 1}
# print_name n;;
The name is Richard Jones
- : unit = ()
# n;;
- : name = {name = "Richard Jones"; access_count = 2}

只有明确标记为 mutable 的字段可以使用 <- 运算符赋值。如果你尝试对一个不可变字段赋值,OCaml 不会允许你这样做。

# n.name <- "John Smith";;
Line 1, characters 1-23:
Error: The record field name is not mutable

引用,我们现在应该很熟悉了,是用带有可变 contents 字段的记录实现的。查看 Stdlib 中的定义。

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

仔细看看 OCaml 顶层为引用值打印的内容。

# let r = ref 100;;
val r : int Stdlib.ref = {Stdlib.contents = 100}

数组是 OCaml 提供的另一种可变结构。在 OCaml 中,普通列表是用链表实现的,而链表对于某些类型的操作来说很慢。例如,获取列表的头或遍历列表以对每个元素执行某些操作,速度相当快。但是,当你跳到列表的第 n 个元素或尝试随机访问列表时,你会发现这两者都是缓慢的操作。OCaml 的 Array 类型是一个真正的数组,因此随机访问速度很快,但插入和删除元素速度很慢。 Array 也是可变的,因此你也可以随机更改元素。

数组的基本原理很简单。

# let a = Array.create 10 0;;
Line 1, characters 9-21:
Alert deprecated: Stdlib.Array.create
Use Array.make/ArrayLabels.make instead.
val a : int array = [|0; 0; 0; 0; 0; 0; 0; 0; 0; 0|]
# for i = 0 to Array.length a - 1 do
    a.(i) <- i
  done;;
- : unit = ()
# a;;
- : int array = [|0; 1; 2; 3; 4; 5; 6; 7; 8; 9|]

注意编写数组的语法:[| element; element; ... |]

OCaml 编译器是在大量数值处理(传统上使用 FORTRAN 来完成的这类事情)的考虑下设计的,因此它包含专门针对数字、向量和矩阵数组的各种优化。以下是一些用于执行稠密矩阵乘法的基准代码。注意它使用了 for 循环,并且通常非常命令式。

# let size = 30;;
val size : int = 30

# let mkmatrix rows cols =
  let count = ref 1
  and last_col = cols - 1
  and m = Array.make_matrix rows cols 0 in
    for i = 0 to rows - 1 do
      let mi = m.(i) in
        for j = 0 to last_col do
          mi.(j) <- !count;
          incr count
        done;
    done;
    m;;
val mkmatrix : int -> int -> int array array = <fun>

# let rec inner_loop k v m1i m2 j =
  if k < 0 then v
  else inner_loop (k - 1) (v + m1i.(k) * m2.(k).(j)) m1i m2 j;;
val inner_loop : int -> int -> int array -> int array array -> int -> int =
  <fun>

# let mmult rows cols m1 m2 m3 =
  let last_col = cols - 1
  and last_row = rows - 1 in
    for i = 0 to last_row do
      let m1i = m1.(i) and m3i = m3.(i) in
      for j = 0 to last_col do
        m3i.(j) <- inner_loop last_row 0 m1i m2 j
      done;
    done;;
val mmult :
  int -> int -> int array array -> int array array -> int array array -> unit =
  <fun>

# let () =
  let n =
    try int_of_string Sys.argv.(1)
    with Invalid_argument _ -> 1
  and m1 = mkmatrix size size
  and m2 = mkmatrix size size
  and m3 = Array.make_matrix size size 0 in
    for i = 1 to n - 1 do
      mmult size size m1 m2 m3
    done;
    mmult size size m1 m2 m3;
    Printf.printf "%d %d %d %d\n" m3.(0).(0) m3.(2).(3) m3.(3).(2) m3.(4).(4);;
Exception: Failure "int_of_string".

相互递归函数

假设我想定义两个相互调用的函数。这实际上不是一件很常见的事情,但有时可能有用。这里有一个人为的例子(感谢 Ryan Tarpine):数字 0 是偶数。其他大于 0 的数字如果它们的前面是奇数,那么它们就是偶数。因此。

# let rec even n =
  match n with
  | 0 -> true
  | x -> odd (x - 1);;
Line 4, characters 10-13:
Error: Unbound value odd

上面的代码无法编译,因为我们还没有定义 odd 函数!不过这很容易。零不是奇数,其他大于 0 的数字如果它们的前面是偶数,那么它们就是奇数。因此,为了使代码完整,我们还需要该函数。

# let rec even n =
  match n with
  | 0 -> true
  | x -> odd (x - 1);;
Line 4, characters 10-13:
Error: Unbound value odd

# let rec odd n =
  match n with
  | 0 -> false
  | x -> even (x - 1);;
Line 4, characters 10-14:
Error: Unbound value even

唯一的问题是……这个程序无法编译。为了编译 even 函数,我们需要先拥有 odd 的定义,而为了编译 odd,我们需要 even 的定义。因此,交换两个定义的顺序也不会起作用。

OCaml 中没有“前向原型”(如 C 的后代语言中所见),但有一种特殊的语法用于定义一组两个或更多个相互递归的函数,例如 oddeven

# let rec even n =
    match n with
    | 0 -> true
    | x -> odd (x - 1)
  and odd n =
    match n with
    | 0 -> false
    | x -> even (x - 1);;
val even : int -> bool = <fun>
val odd : int -> bool = <fun>

你也可以使用类似的语法来编写相互递归的类定义和模块。

帮助改进我们的文档

所有 OCaml 文档都是开源的。发现错误或不清楚的地方?提交一个 pull request。

OCaml

创新。社区。安全。