可变性和命令式控制流

命令式编程和函数式编程各有优劣,OCaml 允许有效地将两者结合起来。在本教程的第一部分,我们将介绍可变状态和命令式控制流。请参见第二部分,了解这些功能的推荐或不推荐使用示例。

不可变数据与可变数据

当使用 let … = … 将值绑定到名称时,此名称-值绑定是 不可变 的,因此无法修改(这是一个表示“更改”、“更新”或“修改”的专业术语)分配给该名称的值。

在以下部分中,我们将介绍 OCaml 中用于处理可变状态的语言功能。

引用

有一种特殊类型的值,称为引用,其内容可以更新

# let d = ref 0;;
val d : int ref = {contents = 0}

# d := 1;;
- : unit = ()

# !d;;
- : int = 1

以下是此示例中的操作步骤

  1. { contents = 0 } 绑定到名称 d。这是一个正常的定义。与任何其他定义一样,它是不可变的。但是,d 中的 contents 字段中的值 0可变的,因此可以更新它。
  2. 赋值运算符 := 用于将 d 中的可变值从 0 更新为 1
  3. 解引用运算符 ! 读取 d 中的可变值的内容。

上面的 ref 标识符指的是两个不同的东西

  • 创建引用的函数 ref : 'a -> 'a ref
  • 可变引用的类型:'a ref

赋值运算符

# ( := );;
- : 'a ref -> 'a -> unit = <fun>

赋值运算符 := 只是一个函数。它接受

  1. 要更新的引用,以及
  2. 替换先前内容的值。

更新是作为 副作用 完成的,并返回 () 值。

解引用运算符

# ( ! );;
- : '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;
}

例如,以下是如何使用书店跟踪其库存

  • 字段 titleauthorvolumeseries 是常量。
  • 字段 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 关键字标记。

由于引用是单字段记录,我们可以使用可变记录字段更新语法定义函数createassignderef

# 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_stringstring值创建字节序列。可以使用Bytes.setBytes.get通过索引更新或读取序列中的各个元素。

您可以将字节序列视为:

  • 不可打印的可更新字符串,或者
  • 没有索引读取和更新语法糖的char数组。

注意bytes类型使用比char array更紧凑的内存表示。在编写本教程时,byteschar array之间存在8倍的差距。应该始终优先使用前者,除非多态函数处理数组需要使用array

示例:get_char函数

在本节中,我们将比较两种实现get_char函数的方法。该函数等待按键,并返回相应的字符,但不回显。本教程后面还会使用此函数。

我们使用Unix模块中的两个函数来读取和更新与标准输入相关的终端属性:

  • tcgetattr stdin TCSAFLUSH将终端属性作为记录返回(类似于deref)。
  • tcsetattr stdin TCSAFLUSH更新终端属性(类似于assign)。

这些属性需要正确设置(即关闭回显并禁用规范模式),以便以我们想要的方式读取它们。两种实现中的逻辑相同:

  1. 读取并记录终端属性
  2. 设置终端属性
  3. 等待按键,将其作为字符读取
  4. 恢复初始终端属性
  5. 返回读取的字符

我们使用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_icanonc_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_icanonc_echo字段上与termio值不同。

在对tcsetattr的第二次调用中,我们将终端属性恢复到其初始状态。

命令式控制流

OCaml允许您按顺序评估表达式,并提供forwhile循环来重复执行代码块。

按顺序评估表达式

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和圆括号是相同的。

假设我们想要编写一个函数:

  1. 有一个包含值nint引用参数
  2. 将引用的内容更新为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>

错误来自赋值:=,它比分号;关联性更强。以下是我们想要按顺序执行的操作:

  1. 递增r
  2. 计算2 * !r
  3. 赋值到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的表达式。这里,fortododone都是关键字。

# for i = 0 to 5 do Printf.printf "%i\n" i done;;
0
1
2
3
4
5
- : unit = ()

这里:

  • i是循环计数器;它在每次迭代后递增。
  • 0i的第一个值。
  • 5i的最后一个值。
  • 表达式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的表达式。这里,whiledodone都是关键字。

# 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的环境,并且可以每次调用时修改nn引用“隐藏”在闭包内,封装了其状态。

# 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_charremove_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 风格的代码。

大多数现有模块提供旨在以函数式方式使用的接口。一些模块需要开发和维护包装器库才能在命令式环境中使用,并且在许多情况下这种使用效率低下。

取决于:模块状态

模块可以以几种不同的方式公开或封装状态

  1. 良好:公开代表状态的类型,以及状态创建或重置函数
  2. 取决于:仅公开状态初始化,这意味着只有一个状态
  3. 糟糕:可变状态没有显式的初始化函数或没有引用可变状态的名称

例如,Hashtbl模块提供了第一种类型的接口。它具有代表可变数据的类型Hashtbl.t。它还公开了createclearreset函数。clearreset函数返回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是包含所有满足谓词pk元素的数组,而n是包含所有不满足pk元素的数组。输入数组中元素的顺序将被保留。

乍一看,这似乎是函数封装可变性的应用。但是,它不是。输入数组被修改了。此函数有一个副作用,要么是

  • 无意,要么是
  • 没有记录。

在后一种情况下,该函数应该被赋予不同的名称(例如,partition_in_placepartition_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 提供了很好的工具来处理它。我们研究了引用、可变记录字段、数组、字节序列和命令式控制流表达式(如forwhile循环)。最后,我们讨论了推荐和不推荐使用副作用和可变状态的几个示例。

仍然需要帮助?

帮助改进我们的文档

所有 OCaml 文档都是开源的。看到错误或不清楚的地方了吗?提交一个拉取请求。

OCaml

创新。社区。安全。