可变性和命令式控制流
先决条件
命令式编程和函数式编程各有优劣,OCaml 允许有效地将两者结合起来。在本教程的第一部分,我们将介绍可变状态和命令式控制流。请参见第二部分,了解这些功能的推荐或不推荐使用示例。
不可变数据与可变数据
当使用 let … = …
将值绑定到名称时,此名称-值绑定是 不可变 的,因此无法修改(这是一个表示“更改”、“更新”或“修改”的专业术语)分配给该名称的值。
在以下部分中,我们将介绍 OCaml 中用于处理可变状态的语言功能。
引用
有一种特殊类型的值,称为引用,其内容可以更新
# let d = ref 0;;
val d : int ref = {contents = 0}
# d := 1;;
- : unit = ()
# !d;;
- : int = 1
以下是此示例中的操作步骤
- 值
{ contents = 0 }
绑定到名称d
。这是一个正常的定义。与任何其他定义一样,它是不可变的。但是,d
中的contents
字段中的值0
是可变的,因此可以更新它。 - 赋值运算符
:=
用于将d
中的可变值从0
更新为1
。 - 解引用运算符
!
读取d
中的可变值的内容。
上面的 ref
标识符指的是两个不同的东西
- 创建引用的函数
ref : 'a -> 'a ref
- 可变引用的类型:
'a ref
赋值运算符
# ( := );;
- : 'a ref -> 'a -> unit = <fun>
赋值运算符 :=
只是一个函数。它接受
- 要更新的引用,以及
- 替换先前内容的值。
更新是作为 副作用 完成的,并返回 ()
值。
解引用运算符
# ( ! );;
- : 'a ref -> 'a = <fun>
解引用运算符是一个函数,它接受一个引用并返回其内容。
有关 OCaml 中一元和二元运算符工作方式的更多信息,请参阅 运算符 文档。
在使用 OCaml 中的可变数据时,
- 无法创建未初始化的引用,以及
- 可变内容和引用具有不同的语法和类型:无法混淆两者。
可变记录字段
记录中的任何字段都可以使用 mutable
关键字标记。此类字段可以更新。
# type book = {
series : string;
volume : int;
title : string;
author : string;
mutable stock : int;
};;
type book = {
series : string;
volume : int;
title : string;
author : string;
mutable stock : int;
}
例如,以下是如何使用书店跟踪其库存
- 字段
title
、author
、volume
、series
是常量。 - 字段
stock
是可变的,因为此值会随着每次销售或进货而变化。
此类数据库应该具有如下条目
# let vol_7 = {
series = "Murderbot Diaries";
volume = 7;
title = "System Collapse";
author = "Martha Wells";
stock = 0
};;
val vol_7 : book =
{series = "Murderbot Diaries"; volume = 7; title = "System Collapse";
author = "Martha Wells"; stock = 0}
当书店收到 10 本此书的货运时,我们会更新可变的 stock
字段
# vol_7.stock <- vol_7.stock + 10;;
- : unit = ()
# vol_7;;
- : book =
{series = "Murderbot Diaries"; volume = 7; title = "System Collapse";
author = "Martha Wells"; stock = 10 }
使用左箭头符号 <-
更新可变记录字段。在表达式 vol_7.stock <- vol_7.stock + 10
中,vol_7.stock
的含义取决于其上下文
- 在
<-
的左侧,它指的是要更新的可变字段。 - 在
<-
的右侧,它表示可变字段的内容。
与引用相反,没有特殊语法来解引用可变记录字段。
注意:引用是单字段记录
在 OCaml 中,引用是具有单个可变字段的记录
# #show_type ref;;
type 'a ref = { mutable contents : 'a; }
类型 'a ref
是一个记录,它具有一个名为 contents
的单个字段,该字段用 mutable
关键字标记。
由于引用是单字段记录,我们可以使用可变记录字段更新语法定义函数create
、assign
和deref
。
# let create v = { contents = v };;
val create : 'a -> 'a ref = <fun>
# let assign f v = f.contents <- v;;
val assign : 'a ref -> 'a -> unit = <fun>
# let deref f = f.contents;;
val deref : 'a ref -> 'a = <fun>
# let f = create 0;;
val f : int ref = {contents = 0}
# deref f;;
- : int = 0
# assign f 2;;
- : unit = ()
# deref f;;
- : int = 2
这些函数:
create
的功能与标准库提供的ref
函数相同。assign
的功能与( := )
运算符相同。deref
的功能与( ! )
运算符相同。
数组
在OCaml中,数组是一种可变的固定大小数据结构,可以存储相同类型的元素序列。数组由整数索引,提供常数时间访问,并允许更新元素。
# let g = [| 2; 3; 4; 5; 6; 7; 8 |];;
val g : int array = [|2; 3; 4; 5; 6; 7; 8|]
# g.(0);;
- : int = 2
# g.(0) <- 9;;
- : unit = ()
# g.(0);;
- : int = 9
左箭头符号<-
用于更新给定索引处的数组元素。数组索引访问语法g.(i)
,其中g
是类型为array
的值,i
是整数,表示:
- 要更新的数组位置(在
<-
的左侧),或者 - 单元格的内容(在
<-
的右侧)。
有关数组的更详细讨论,请参阅数组教程。
字节序列
OCaml中的bytes
类型表示一个固定长度的可变字节序列。在类型为bytes
的值中,每个元素都有8位。由于OCaml中的字符使用8位表示,因此bytes
值是可变的char
序列。与数组一样,字节序列支持索引访问。
# let h = Bytes.of_string "abcdefghijklmnopqrstuvwxyz";;
val h : bytes = Bytes.of_string "abcdefghijklmnopqrstuvwxyz"
# Bytes.get h 10;;
- : char = 'k'
# Bytes.set h 10 '_';;
- : unit = ()
# h;;
- : bytes = Bytes.of_string "abcdefghij_lmnopqrstuvwxyz"
可以使用函数Bytes.of_string
从string
值创建字节序列。可以使用Bytes.set
和Bytes.get
通过索引更新或读取序列中的各个元素。
您可以将字节序列视为:
- 不可打印的可更新字符串,或者
- 没有索引读取和更新语法糖的
char
数组。
注意:bytes
类型使用比char array
更紧凑的内存表示。在编写本教程时,bytes
和char array
之间存在8倍的差距。应该始终优先使用前者,除非多态函数处理数组需要使用array
。
get_char
函数
示例:在本节中,我们将比较两种实现get_char
函数的方法。该函数等待按键,并返回相应的字符,但不回显。本教程后面还会使用此函数。
我们使用Unix
模块中的两个函数来读取和更新与标准输入相关的终端属性:
tcgetattr stdin TCSAFLUSH
将终端属性作为记录返回(类似于deref
)。tcsetattr stdin TCSAFLUSH
更新终端属性(类似于assign
)。
这些属性需要正确设置(即关闭回显并禁用规范模式),以便以我们想要的方式读取它们。两种实现中的逻辑相同:
- 读取并记录终端属性
- 设置终端属性
- 等待按键,将其作为字符读取
- 恢复初始终端属性
- 返回读取的字符
我们使用OCaml标准库中的input_char
函数从标准输入读取字符。
以下是第一个实现。如果您在macOS上工作,请先运行#require "unix";;
以避免出现Unbound module error
错误。
# let get_char () =
let open Unix in
let termio = tcgetattr stdin in
let c_icanon, c_echo = termio.c_icanon, termio.c_echo in
termio.c_icanon <- false;
termio.c_echo <- false;
tcsetattr stdin TCSAFLUSH termio;
let c = input_char (in_channel_of_descr stdin) in
termio.c_icanon <- c_icanon;
termio.c_echo <- c_echo;
tcsetattr stdin TCSAFLUSH termio;
c;;
val get_char : unit -> char = <fun>
在此实现中,我们在input_char
之前更新termio
的字段:
- 将
c_icanon
和c_echo
都设置为false
,以及 - 在
input_char
之后,恢复初始值。
以下是第二个实现:
# let get_char () =
let open Unix in
let termio = tcgetattr stdin in
tcsetattr stdin TCSAFLUSH
{ termio with c_icanon = false; c_echo = false };
let c = input_char (in_channel_of_descr stdin) in
tcsetattr stdin TCSAFLUSH termio;
c;;
val get_char : unit -> char = <fun>
在此实现中,不会修改tcgetattr
调用返回的记录。使用{ termio with c_icanon = false; c_echo = false }
制作一个副本。此副本仅在c_icanon
和c_echo
字段上与termio
值不同。
在对tcsetattr
的第二次调用中,我们将终端属性恢复到其初始状态。
命令式控制流
OCaml允许您按顺序评估表达式,并提供for
和while
循环来重复执行代码块。
按顺序评估表达式
let … in
# let () = print_string "This is" in print_endline " really Disco!";;
This is really Disco!
- : unit = ()
使用let … in
结构意味着两件事:
- 可以绑定名称。在本例中,由于使用了
()
,因此没有绑定任何名称。 - 副作用按顺序发生。绑定的表达式(
print_string "This is"
)先被评估,然后是引用的表达式(print_endline " really Disco!"
)被评估。
分号
单个分号;
运算符称为序列运算符。它允许您按顺序评估多个表达式,最后一个表达式的值为整个序列的值。
任何先前表达式的值都会被丢弃。因此,使用具有副作用的表达式是有意义的,除了序列的最后一个表达式,它可以没有副作用。
# let _ =
print_endline "Hello,";
print_endline "world!";
42;;
Hello,
world!
- : int = 42
在本例中,前两个表达式是print_endline
函数调用,它们产生副作用(打印到控制台),最后一个表达式仅仅是整数42
,它成为整个序列的值。;
运算符用于分隔这些表达式。
备注:虽然它被称为序列运算符,但分号并非真正的运算符,因为它不是类型为unit -> 'a -> 'a
的函数。它更像是语言的结构。它允许在序列表达式的末尾添加分号。
# (); 42; ;;
- : int = 42
这里,42后面的分号被忽略了。
begin … end
表达式
在OCaml中,begin … end
和圆括号是相同的。
假设我们想要编写一个函数:
- 有一个包含值n的
int
引用参数 - 将引用的内容更新为2 × (n + 1)
这可以说很复杂,而且无法正常工作
# let f r = r := incr r; 2 * !r;;
Error: This expression has type unit but an expression was expected of type int
但这里是如何使其正常工作:
# let f r = r := begin incr r; 2 * !r end;;
val f : int ref -> unit = <fun>
错误来自赋值:=
,它比分号;
关联性更强。以下是我们想要按顺序执行的操作:
- 递增
r
- 计算
2 * !r
- 赋值到
r
请记住,分号分隔序列的值是其最后一个表达式的值。使用begin … end
对前两个步骤进行分组可以修复错误。
有趣的事实:begin … end
和圆括号实际上是相同的
# begin end;;
- : unit = ()
if … then … else …
和副作用
在OCaml中,if … then … else …
是一个表达式。
# 6 * if "foo" = "bar" then 5 else 5 + 2;;
- : int = 42
如果两个分支都是unit
类型,则条件表达式的返回类型也可以是unit
。
# if 0 = 1 then print_endline "foo" else print_endline "bar";;
bar
- : unit = ()
上面也可以这样表示:
# print_endline (if 0 = 1 then "foo" else "bar");;
bar
- : unit = ()
unit
值()
可以用作无操作,当只有一个分支需要执行时。
# if 0 = 1 then print_endline "foo" else ();;
- : unit = ()
但OCaml还允许编写没有else
分支的if … then …
表达式,这与上面相同。
# if 0 = 1 then print_endline "foo";;
- : unit = ()
在解析中,条件表达式比排序分组更多
# if true then print_endline "A" else print_endline "B"; print_endline "C";;
A
C
- : unit = ()
这里; print_endline "C"
是在整个条件表达式之后执行的,而不是在print_endline "B"
之后执行的。
如果您想在条件表达式分支中进行两次打印,请使用begin … end
。
# if true then
print_endline "A"
else begin
print_endline "B";
print_endline "C"
end;;
A
- : unit = ()
以下是一些您可能会遇到的错误:
# if true then
print_endline "A";
print_endline "C"
else
print_endline "B";;
Error: Syntax error
在第一个分支中未能分组会导致语法错误。分号之前的部分被解析为一个没有else
表达式的if … then …
。分号之后的部分显示为一个悬挂的else
。
For循环
for
循环是一个类型为unit
的表达式。这里,for
、to
、do
和done
都是关键字。
# for i = 0 to 5 do Printf.printf "%i\n" i done;;
0
1
2
3
4
5
- : unit = ()
这里:
i
是循环计数器;它在每次迭代后递增。0
是i
的第一个值。5
是i
的最后一个值。- 表达式
Printf.printf "%i\n" i
是循环体。
迭代会评估循环体表达式(可能包含i
),直到i
达到5
。
for
循环的循环体必须是一个类型为unit
的表达式
# let j = [| 2; 3; 4; 5; 6; 7; 8 |];;
val j : int array = [|2; 3; 4; 5; 6; 7; 8|]
# for i = Array.length j - 1 downto 0 do 0 done;;
Line 1, characters 39-40:
Warning 10 [non-unit-statement]: this expression should have type unit.
- : unit = ()
当您使用downto
关键字(而不是to
关键字)时,计数器在循环的每次迭代中都会递减。
for
循环方便地用于迭代和修改数组
# let sum = ref 0 in
for i = 0 to Array.length j - 1 do sum := !sum + j.(i) done;
!sum;;
- : int = 35
注意:以下是使用迭代器函数执行相同操作的方法:
# let sum = ref 0 in Array.iter (fun i -> sum := !sum + i) j; !sum;;
- : int = 35
While循环
while
循环是一个类型为unit
的表达式。这里,while
、do
和done
都是关键字。
# let i = ref 0 in
while !i <= 5 do
Printf.printf "%i\n" !i;
i := !i + 1;
done;;
0
1
2
3
4
5
- : unit = ()
这里:
!i <= 5
是条件。- 表达式
Printf.printf "%i\n" !i; i := !i + 1;
是循环体。
迭代只要条件保持为真,就会执行循环体表达式。
在本例中,while
循环会一直执行,直到引用i
保存的值小于5
。
使用异常中断循环
抛出Exit
异常是立即退出循环的推荐方法。
以下示例使用我们之前定义的get_char
函数(在示例:get_char
函数部分)。
# try
print_endline "Press Escape to exit";
while true do
let c = get_char () in
if c = '\027' then raise Exit;
print_char c;
flush stdout
done
with Exit -> ();;
此while
循环回显在键盘上输入的字符。当读取 ASCII Escape
字符时,会抛出Exit
异常,这将终止迭代并显示 REPL 响应:- : unit = ()
。
闭包内的引用
在以下示例中,函数create_counter
返回一个闭包,它隐藏了一个可变引用n
。此闭包捕获定义n
的环境,并且可以每次调用时修改n
。n
引用“隐藏”在闭包内,封装了其状态。
# let create_counter () =
let n = ref 0 in
fun () -> incr n; !n;;
val create_counter : unit -> unit -> int = <fun>
首先,我们定义一个名为create_counter
的函数,该函数不接受任何参数。在create_counter
内部,一个引用n
被初始化为值 0。此引用将保存计数器的状态。接下来,我们定义一个不接受任何参数的闭包(fun () ->)。闭包使用incr n
递增n
(计数器)的值,然后使用!n
返回n
的当前值。
# let c1 = create_counter ();;
val c1 : unit -> int = <fun>
# let c2 = create_counter ();;
val c2 : unit -> int = <fun>
现在,我们将创建一个封装计数器的闭包c1
。调用c1 ()
将递增与c1
关联的计数器并返回其当前值。类似地,我们创建另一个带有其自身独立计数器的闭包c2
。
# c1 ();;
- : int = 1
# c1 ();;
- : int = 2
# c2 ();;
- : int = 1
# c1 ();;
- : int = 3
调用c1 ()
将递增与c1
关联的计数器并返回其当前值。由于这是第一次调用,因此计数器从 1 开始。对c1 ()
的另一次调用将再次递增计数器,因此它返回 2。
调用c2 ()
将递增与c2
关联的计数器。由于c2
有其自身独立的计数器,因此它从 1 开始。对c1 ()
的另一次调用将递增其计数器,导致结果为 3。
关于可变状态和副作用的建议
函数式编程和命令式编程风格经常被一起使用。但是,并非所有组合方式都能得到好的结果。本节将介绍与可变状态和副作用相关的模式和反模式。
良好:函数封装可变性
这是一个计算整数数组总和的函数。
# let sum m =
let result = ref 0 in
for i = 0 to Array.length m - 1 do
result := !result + m.(i)
done;
!result;;
val sum : int array -> int = <fun>
函数sum
以命令式风格编写,使用可变数据结构和for
循环。但是,没有暴露任何可变性。它是一个完全封装的实现选择。此函数使用安全;不应出现任何问题。
良好:应用程序范围的状态
一些应用程序在运行时维护一些状态。以下是一些示例:
- 读取-求值-打印-循环 (REPL)。状态是绑定值到名称的环境。在 OCaml 中,环境是追加式的,但一些其他语言允许替换或删除名称-值绑定。
- 有状态协议的服务器。每个会话都有一个状态。全局状态包含所有会话状态。
- 文本编辑器。状态包括最近的命令(允许撤消)、所有打开文件的狀態、设置和 UI 的状态。
- 缓存。
以下是一个玩具行编辑器,使用之前定义的get_char
函数。它等待标准输入上的字符,并在文件结束、回车或换行时退出。否则,如果字符是可打印的,它会打印该字符并将其记录在一个用作堆栈的可变列表中。如果字符是删除代码,则弹出堆栈并擦除最后一个打印的字符。
# let record_char state c =
(String.make 1 c, c :: state);;
val record_char : char list -> char -> string * char list = <fun>
# let remove_char state =
("\b \b", if state = [] then [] else List.tl state);;
val remove_char : 'a list -> string * 'a list = <fun>
# let state_to_string state =
List.(state |> rev |> to_seq |> String.of_seq);;
val state_to_string : char list -> string = <fun>
# let rec loop state =
let c = get_char () in
if c = '\004' || c = '\n' || c = '\r' then raise Exit;
let s, new_state = match c with
| '\127' -> remove_char !state
| c when c >= ' ' -> record_char !state c
| _ -> ("", !state) in
print_string s;
state := new_state;
flush stdout;
loop state;;
val loop : char list ref -> 'a = <fun>
# let state = ref [] in try loop state with Exit -> state_to_string !state;;
执行完最后一个命令后,您可以输入和编辑任何单行文本。然后,按回车键返回 REPL。
此示例说明以下内容:
- 函数
record_char
和remove_char
既不更新状态也不产生副作用。相反,它们都返回包含要打印的字符串和下一个状态new_state
的一对值。 - I/O 和状态更新副作用发生在
loop
函数内部。 - 状态作为参数传递给
loop
函数。
这是一种处理应用程序范围状态的可能方法。与函数封装可变性示例一样,了解状态的代码包含在一个狭窄的范围内;其余代码是纯函数式的。
注意:这里,状态被复制了,这在内存效率方面并不高。在内存感知的实现中,状态更新函数将生成一个“diff”(描述状态的旧版本和更新版本之间差异的数据)。
良好:预计算值
假设您将角度存储为 8 位无符号整数中的圆圈分数,并将其存储为char
值。在这个系统中,64 是 90 度,128 是 180 度,192 是 270 度,256 是整圆,依此类推。如果您需要计算这些值的余弦,实现可能如下所示:
# let char_cos c =
c |> int_of_char |> float_of_int |> ( *. ) (Float.pi /. 128.0) |> cos;;
val char_cos : char -> float = <fun>
但是,可以通过预先计算所有可能的值来实现更快的实现。只有 256 个,您将在第一个结果之后看到这些值列出:
# let char_cos_tab = Array.init 256 (fun i -> i |> char_of_int |> char_cos);;
val char_cos_tab : float array =
# let char_cos c = char_cos_tab.(int_of_char c);;
val char_cos : char -> float = <fun>
良好:记忆化
该记忆化技术依赖于上一节示例中相同的想法:从先前计算值的表中查找结果。
但是,记忆化不预先计算所有内容,而是使用一个缓存,该缓存在调用函数时被填充。或者,提供的参数
- 在缓存中找到(命中),并返回存储的结果,或者
- 未在缓存中找到(未命中),计算结果,存储在缓存中并返回。
您可以在“OCaml 编程:正确 + 高效 + 美观”的记忆化章节中找到记忆化的具体示例和更深入的解释。
良好:默认情况下为函数式
默认情况下,OCaml 程序应该以函数式风格编写。这意味着尽可能避免副作用,并依赖于不可变数据而不是可变状态。
可以使用命令式编程风格,而不会失去类型和内存安全性的好处。但是,通常没有必要只用命令式风格编程。完全不使用函数式编程习惯用法会导致不符合 OCaml 风格的代码。
大多数现有模块提供旨在以函数式方式使用的接口。一些模块需要开发和维护包装器库才能在命令式环境中使用,并且在许多情况下这种使用效率低下。
取决于:模块状态
模块可以以几种不同的方式公开或封装状态
- 良好:公开代表状态的类型,以及状态创建或重置函数
- 取决于:仅公开状态初始化,这意味着只有一个状态
- 糟糕:可变状态没有显式的初始化函数或没有引用可变状态的名称
例如,Hashtbl
模块提供了第一种类型的接口。它具有代表可变数据的类型Hashtbl.t
。它还公开了create
、clear
和reset
函数。clear
和reset
函数返回unit
。这强烈地向读者表明它们执行更新可变数据的副作用。
# #show Hashtbl.t;;
type ('a, 'b) t = ('a, 'b) Hashtbl.t
# Hashtbl.create;;
- : ?random:bool -> int -> ('a, 'b) Hashtbl.t = <fun>
# Hashtbl.reset;;
- : ('a, 'b) Hashtbl.t -> unit = <fun>
# Hashtbl.clear;;
- : ('a, 'b) Hashtbl.t -> unit = <fun>
另一方面,模块可以在内部定义可变数据,从而影响其行为,而不在其接口中公开它。这是不可取的。
糟糕:未记录的变异
这是一个糟糕代码的示例:
# let partition p k =
let m = Array.copy k in
let k_len = ref 0 in
let m_len = ref 0 in
for i = 0 to Array.length k - 1 do
if p k.(i) then begin
k.(!k_len) <- k.(i);
incr k_len
end else begin
m.(!m_len) <- k.(i);
incr m_len
end
done;
(Array.truncate k_len k, Array.truncate m_len m);;
Error: Unbound value Array.truncate
注意:此示例不会在 REPL 中运行,因为函数Array.truncate
没有定义。
为了理解为什么这不好,假设函数Array.truncate
的类型为int -> 'a array -> 'a array
。它的行为是这样的:Array.truncate 3 [5; 6; 7; 8; 9]
返回[5; 6; 7]
,并且返回的数组物理上对应于输入数组的前 3 个单元格。
partition
的类型将是('a -> bool) -> 'a array -> 'a array * 'a array
,它可以记录为
partition p k
返回一对数组(m, n)
,其中m
是包含所有满足谓词p
的k
元素的数组,而n
是包含所有不满足p
的k
元素的数组。输入数组中元素的顺序将被保留。
乍一看,这似乎是函数封装可变性的应用。但是,它不是。输入数组被修改了。此函数有一个副作用,要么是
- 无意,要么是
- 没有记录。
在后一种情况下,该函数应该被赋予不同的名称(例如,partition_in_place
或partition_mut
),并且应该记录对输入数组的影响。
糟糕:未记录的副作用
考虑以下代码:
# module Array = struct
include Stdlib.Array
let copy a =
if Array.length a > 1000000 then Analytics.collect "Array.copy" a;
copy a
end;;
Error: Unbound module Analytics
注意:此代码不会运行,因为没有名为Analytics
的模块。分析是远程监控库。
定义了一个名为Array
的模块;它覆盖并包含Stdlib.Array
模块。有关此模式的详细信息,请参阅模块教程的模块包含部分。
为了理解为什么这不好,请弄清楚Analytics.collect
是一个函数,它建立网络连接以将数据传输到远程服务器。
现在,新定义的Array
模块包含一个copy
函数,该函数具有潜在的意外副作用,但只有在要复制的数组具有百万个单元格或以上时才会发生。
如果您正在编写具有非明显副作用的函数,请不要覆盖现有的定义。相反,请为该函数起一个描述性的名称(例如,Array.copy_with_analytics
)并记录存在调用者可能不知道的副作用这一事实。
糟糕:副作用取决于求值顺序
考虑以下代码:
# let id_print s = print_string (s ^ " "); s;;
val id_print : string -> string = <fun>
# let s =
Printf.sprintf "%s %s %s"
(id_print "Monday")
(id_print "Tuesday")
(id_print "Wednesday");;
Wednesday Tuesday Monday val s : string = "Monday Tuesday Wednesday "
函数id_print
返回其输入不变。但是,它有一个副作用:它首先打印它作为参数接收的字符串。
在第二行中,我们将id_print
应用于参数"Monday"
、"Tuesday"
和"Wednesday"
。然后将Printf.sprintf "%s %s %s "
应用于结果。
由于 OCaml 中函数参数的求值顺序没有明确定义,因此id_print
副作用发生的顺序不可靠。在本例中,参数是从右到左求值的,但可能会在将来的编译器版本中发生变化。
当将参数应用于变体构造函数、构建元组值或初始化记录字段时,也会出现此问题。这里,它在元组值上得到说明:
# let r = ref 0 in ((incr r; !r), (decr r; !r));;
- : int * int = (0, -1)
此表达式的值取决于子表达式求值的顺序。由于此顺序未指定,因此无法可靠地知道此值是什么。在编写本教程时,求值产生了(0, -1)
,但如果您看到其他内容,这不是错误。必须避免这种不可靠的值。
为了确保求值以特定的顺序发生,请使用将表达式放在序列中的方法。请查看表达式在序列中求值部分。
结论
可变状态既不好也不坏。对于可变状态可以使实现变得更简单的用例,OCaml 提供了很好的工具来处理它。我们研究了引用、可变记录字段、数组、字节序列和命令式控制流表达式(如for
和while
循环)。最后,我们讨论了推荐和不推荐使用副作用和可变状态的几个示例。