循环和递归
与其他 OCaml.org 文档一样,代码示例要么是您可以测试的,要么是代码示例。以 CLI 提示符 #
开头,以 ;;
结尾,并有明确输出的代码片段可以在 OCaml 顶层(ocaml
或 utop
)中测试或粘贴到 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
循环中提前退出的概念,即它没有 break
、continue
或 last
语句。(您可以抛出一个异常并在外部捕获它,这将很快运行,但通常看起来很笨拙。)
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_all
和 List.exists
与谓词逻辑中的“forall”和“exist”运算符相同。
对于同时操作两个列表,有一些“-2”变体,即 iter2
、map2
、for_all2
、exists2
。
map
和 filter
函数独立地操作单个列表元素。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
为整数列表定义 sum
和 product
函数
# 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
opendir
和 readdir
函数用于打开目录并从目录中读取元素。 我将定义一个方便的 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_left
和 map
的使用。 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
,换句话说,它可以是 None
或 Some "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 ())
。 如果文件真的是一个文件(不是目录),那么我们让 this
为 File 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
的最大元素,比如 y
。 x :: 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 = 1
和 y = 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 的后代语言中所见),但有一种特殊的语法用于定义一组两个或更多个相互递归的函数,例如 odd
和 even
。
# 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>
你也可以使用类似的语法来编写相互递归的类定义和模块。