值和函数

简介

在 OCaml 中,函数被视为值,因此您可以将函数用作函数的参数并从函数中返回它们。本教程介绍了表达式、值和名称之间的关系。前四个部分讨论非函数值。从 函数作为值 开始的后续部分将讨论函数。

我们使用 UTop 通过示例来理解这些概念。鼓励您修改示例以获得更好的理解。

什么是值?

与大多数函数式编程语言一样,OCaml 是一种 表达式导向 的编程语言。这意味着程序是表达式。实际上,几乎所有东西都是表达式。在 OCaml 中,语句不会指定要对数据执行的操作。所有计算都是通过表达式求值来完成的。计算表达式会产生值。下面,您将找到一些表达式、它们的类型和结果值的示例。有些包含计算,有些则不包含。

# "Every expression has a type";;
- : string = "Every expression has a type"

# 2 * 21;;
- : int = 42

# int_of_float;;
- : float -> int = <fun>

# int_of_float (3.14159 *. 2.0);;
- : int = 6

# fun x -> x * x;;
- : int -> int = <fun>

# print_endline;;
- : string -> unit = <fun>

# print_endline "Hello!";;
Hello!
- : unit

表达式的类型(在求值之前)和其结果值的类型(在计算之后)是相同的。这使得编译器能够避免在二进制文件中进行运行时类型检查。在 OCaml 中,编译器会移除类型信息,因此它在运行时不可用。在编程理论中,这被称为 主题归约

全局定义

每个值都可以被命名。这是 let … = … 语句的目的。名称在左边;表达式在右边。

  • 如果表达式可以求值,则会进行求值。
  • 否则,表达式会原样转换为值。这是函数定义的情况。

这是在 UTop 中编写定义时发生的情况

# let the_answer = 2 * 3 * 7;;
val the_answer : int = 42

全局定义是在顶层输入的定义。这里,the_answer 是全局定义的。

局部定义

局部定义在表达式内部绑定一个名称

# let d = 2 * 3 in d * 7;;
- : int = 42

# d;;
Error: Unbound value d

局部定义由 let … = … in … 表达式引入。在 in 关键字之前绑定的名称仅在 in 关键字之后的表达式中绑定。这里,名称 d 在表达式 d * 7 内部绑定到 6

一些说明

  • 在这个示例中没有引入全局定义,这就是为什么我们得到错误的原因。
  • 2 * 3 的计算将始终在 d * 7 之前进行。

局部定义可以串联(一个接一个)或嵌套(一个在另一个内部)。这是一个串联的示例

# let d = 2 * 3 in
  let e = d * 7 in
  d * e;;
- : int = 252

# d;;
Error: Unbound value d
# e;;
Error: Unbound value e

以下是作用域的工作原理

  • dlet e = d * 7 in d * e 内部绑定到 6
  • ed * e 内部绑定到 42

这是一个嵌套的示例

# let d =
    let e = 2 * 3 in
    e * 5 in
  d * 7;;
- : int = 210

# d;;
Error: Unbound value d
# e;;
Error: Unbound value e

以下是作用域的工作原理

  • ee * 5 内部绑定到 6
  • dd * 7 内部绑定到 30

允许任意组合的串联或嵌套。

在这两个示例中,de 都是局部定义。

定义中的模式匹配

当模式匹配只有一个情况时,它可以在名称定义以及 let ... =fun ... -> 表达式中使用。在这种情况下,可以定义一个或多个名称。这适用于元组、记录和自定义单变体类型。

元组上的模式匹配

一个常见的情况是元组。它允许使用单个 let 创建两个名称。

# List.split;;
- : ('a * 'b) list -> 'a list * 'b list

# let (x, y) = List.split [(1, 2); (3, 4); (5, 6); (7, 8)];;
val x : int list = [1; 3; 5; 7]
val y : int list = [2; 4; 6; 8]

List.split 函数将一对列表转换为列表对。这里,每个结果列表都绑定到一个名称。

记录上的模式匹配

我们可以对记录进行模式匹配

# type name = { first : string; last: string };;
type name = { first : string; last : string; }

# let robin = { first = "Robin"; last = "Milner" };;
val robin : name = {first = "Robin"; last = "Milner"}

# let { first = given_name; last = family_name } = robin;;
val given_name : string = "Robin"
val family_name : string = "Milner"

函数参数中的模式匹配

单一情况的模式匹配也可用于参数声明。

这是一个使用元组的示例

# let get_country ((country, { first; last }) : string * name) = country;;
val get_country : string * name -> string = <fun>

这是一个使用 name 记录的示例

# let introduce {first; last} = "I am " ^ first ^ " " ^ last;;
val introduce : name -> string = <fun>

注意 使用 discard 模式进行参数声明也是可能的。

# let get_meaning _ = 42;;
val get_meaning : 'a -> int = <fun>

unit 上的模式匹配

组合定义和模式匹配的一个特殊情况涉及 unit 类型

# let () = print_endline "ha ha";;
ha ha

注意:如 OCaml 巡礼 教程中所述,unit 类型只有一个值 (),发音为“unit”。

上面,模式不包含任何标识符,这意味着没有定义任何名称。表达式将被求值,并且副作用将发生(将 ha ha 打印到标准输出)。

注意:为了使编译后的文件仅因其副作用而求值表达式,您必须在 let () = 后编写它们。

用户定义类型上的模式匹配

这也适用于用户定义的类型。

# type citizen = string * name;;
type citizen = string * name

# let ((country, { first = forename; last = surname }) : citizen) = ("United Kingdom", robin);;
val country : string = "United Kingdom"
val forename : string = "Robin"
val surname : string = "Milner"

使用模式匹配丢弃值

如最后一个示例所示,通配符模式(_)可以在定义中使用。

# let (_, y) = List.split [(1, 2); (3, 4); (5, 6); (7, 8)];;
val y : int list = [2; 4; 6; 8]

List.split 函数返回一对列表。我们只对第二个列表感兴趣,我们将其命名为 y 并使用 _ 丢弃第一个列表。

作用域和环境

不进行过度简化,OCaml 程序是一系列表达式或全局 let 定义。

执行从上到下评估每个项目。

在评估过程中的任何时间,环境都是可用定义的有序序列。环境在其他语言中也称为上下文

这里,名称 twenty 被添加到顶级环境中。

# let twenty = 20;;
val twenty : int = 20

twenty 的作用域是全局的。此名称在定义之后任何地方都可用。

这里,全局环境保持不变。

# let ten = 10 in 2 * ten;;
- : int = 20

# ten;;
Error: Unbound value ten

评估 ten 会导致错误,因为它尚未添加到全局环境中。但是,在表达式 2 * ten 中,局部环境包含 ten 的定义。

尽管 OCaml 是一种面向表达式的语言,但它也有一些语句。全局 let 通过添加名称-值绑定来修改全局环境。

顶级表达式也是语句,因为它们等效于 let _ = 定义。

# (1.0 +. sqrt 5.0) /. 2.0;;
- : float = 1.6180339887498949

# let _ = (1.0 +. sqrt 5.0) /. 2.0;;
- : float = 1.6180339887498949

内部遮蔽

一旦你创建了一个名称,定义它并将其绑定到一个值,它就不会改变。也就是说,可以再次定义一个名称以创建一个新的绑定。

# let i = 21;;
val i : int = 21

# let i = 7 in i * 2;;
- : int = 14

# i;;
- : int = 21

第二个定义遮蔽第一个。内部遮蔽仅限于局部定义的作用域。因此,之后编写的任何内容仍将采用先前的定义,如上所示。这里,i 的值没有改变。它仍然是 21,如第一个表达式中定义的那样。第二个表达式在 i * 2 内部局部绑定 i,而不是全局绑定。

同级遮蔽

当同一级别存在两个同名的定义时,会发生另一种遮蔽。

# let h = 2 * 3;;
val h : int = 6

# let e = h * 7;;
val e : int = 42

# let h = 7;;
val h : int = 7

# e;;
- : int = 42

现在环境中有两个 h 的定义。第一个 h 保持不变。当第二个 h 被定义时,第一个 h 变得不可访问。

函数作为值

在 OCaml 中,函数是值。这是函数式编程的关键概念。在这种情况下,也可以说 OCaml 具有一等函数。

应用函数

当几个表达式并排编写时,最左边的表达式应该是函数。所有其他表达式都是参数。在 OCaml 中,不需要括号来表达将参数传递给函数。括号只有一个用途:关联表达式以创建子表达式。

# max (21 * 2) (int_of_string "713");;
- : int = 713

max 函数返回其两个参数中最大的一个,它们是

  • 4221 * 2 的结果
  • 713int_of_string "713" 的结果

在创建子表达式时,也可以使用 begin ... end。这与使用括号 ( ... ) 相同。因此,以上内容也可以重写并获得相同的结果。

# max begin 21 * 2 end begin int_of_string "713" end;;
- : int = 713
# String.starts_with ~prefix:"state" "stateless";;
- : bool = true

一些函数,例如 String.starts_with 具有带标签的参数。当函数具有多个相同类型的参数时,标签很有用;命名参数允许猜测其用途。上面,~prefix:"state" 表示 "state" 作为带标签的参数 prefix 传递。

带标签的参数和可选参数在带标签的参数教程中详细介绍。

有两种替代方法可以应用函数。

应用运算符

应用运算符 @@ 运算符。

# sqrt 9.0;;
- : float = 3.

# sqrt @@ 9.0;;
- : float = 3.

@@ 应用运算符将参数(在右侧)应用于函数(在左侧)。当链接多个调用时,它很有用,因为它避免了编写括号,从而创建更易于阅读的代码。这是一个带括号和不带括号的示例。

# int_of_float (sqrt (float_of_int (int_of_string "81")));;
- : int = 9

# int_of_float @@ sqrt @@ float_of_int @@ int_of_string "81";;
- : int = 9

管道运算符

管道运算符 (|>) 也避免了括号,但顺序相反:函数在右侧,参数在左侧。

# "81" |> int_of_string |> float_of_int |> sqrt |> int_of_float;;
- : int = 9

这就像 Unix shell 管道。

匿名函数

函数不必绑定到名称,除非它们是递归的。请看这些例子。

# fun x -> x;;
- : 'a -> 'a = <fun>

# fun x -> x * x;;
- : int -> int = <fun>

# fun s t -> s ^ " " ^ t ;;
- : string -> string-> string = <fun>

# function [] -> None | x :: _ -> Some x;;
- : 'a list -> 'a option = <fun>

未绑定到名称的函数值称为匿名函数

按顺序,以下是它们是什么。

  • 恒等函数,它接受任何内容并将其不变地返回。
  • 平方函数,它接受一个整数并返回其平方。
  • 接受两个字符串并返回它们连接在一起的函数,中间用空格字符分隔。
  • 接受一个列表并返回 None(如果列表为空)或返回其第一个元素的函数。

匿名函数通常作为参数传递给其他函数。

# List.map (fun x -> x * x) [1; 2; 3; 4];;
- : int list = [1; 4; 9; 16]

定义全局函数

您可以使用全局定义将函数全局绑定到名称。

# let f = fun x -> x * x;;
val f : int -> int = <fun>

该表达式(恰好是一个函数)被转换为值并绑定到一个名称。以下是执行相同操作的另一种方法。

# let g x = x * x;;
val g : int -> int = <fun>

前者显式地将匿名函数绑定到一个名称。后者使用更紧凑的语法并避免了 fun 关键字和箭头符号。

定义局部函数

函数可以局部定义。

# let sq x = x * x in sq 7 * sq 7;;
- : int = 2401

# sq;;
Error: Unbound value sq

调用 sq 会出现错误,因为它仅在本地定义。

函数 sq 仅在 sq 7 * sq 7 表达式内部可用。

尽管局部函数通常在函数的作用域内定义,但这并不是必需的。

闭包

此示例使用同级遮蔽说明了闭包

# let j = 2 * 3;;
val j : int = 6

# let k x = x * j;;
val k : int -> int = <fun>

# k 7;;
- : int = 42

# let j = 7;;
val j : int = 7

# k 7;; (* What is the result? *)
- : int = 42

以下是其含义。

  1. 定义常量 j,其值为 6。
  2. 定义函数 k。它有一个参数 x 并返回 x * j 的值。
  3. 计算 k 的 7,其值为 42。
  4. 创建一个新的定义 j,遮蔽第一个。
  5. 再次计算 k 的 7,结果相同:42。

尽管 j 的新定义遮蔽了第一个,但原始的仍然是函数 k 使用的。k 函数的环境捕获了 j 的第一个值,因此每次应用 k(即使在 j 的第二个定义之后)时,您都可以确信该函数的行为将保持一致。

但是,所有未来的表达式都将使用 j 的新值 (7),如下所示。

# let m = j * 3;;
val m : int = 21

将参数部分应用于函数也会创建一个新的闭包。

# let max_42 = max 42;;
val max_42 : int -> int = <fun>

max_42 函数内部,环境包含 max 的第一个参数和值 42 之间的额外绑定。

递归函数

为了执行迭代计算,函数可以调用自身。这样的函数称为递归函数。

# let rec fibo n =
    if n <= 1 then n else fibo (n - 1) + fibo (n - 2);;
val fibo : int -> int = <fun>

# let u = List.init 10 Fun.id;;
val u : int list = [0; 1; 2; 3; 4; 5; 6; 7; 8; 9]

# List.map fibo u;;
- : int list = [0; 1; 1; 2; 3; 5; 8; 13; 21; 34]

这是计算斐波那契数的经典(且非常低效)方法。创建的递归调用次数在每次调用时都会加倍,这会导致指数级增长。

在 OCaml 中,递归函数必须使用 let rec 定义并显式声明。不可能意外地创建递归函数,并且递归函数不能是匿名的。

注意List.init 是一个标准库函数,允许您通过将给定函数应用于一系列整数来创建列表,而 Fun.id 是恒等函数,它返回其参数不变。我们创建了一个包含数字 0-9 的列表并将其命名为 u。我们使用 List.mapfibo 函数应用于列表的每个元素。

这个版本做得更好。

# let rec fib_loop m n i =
    if i = 0 then m else fib_loop n (n + m) (i - 1);;
val fib_loop : int -> int -> int -> int = <fun>

# let fib = fib_loop 0 1;;
val fib : int -> int = <fun>

# List.init 10 Fun.id |> List.map fib;;
- : int list = [0; 1; 1; 2; 3; 5; 8; 13; 21; 34]

第一个版本 fib_loop 有两个额外的参数:前两个计算出的斐波那契数。

第二个版本 fib 使用前两个斐波那契数作为初始值。从递归调用返回时,没有要计算的内容,因此这使编译器能够执行称为尾调用消除的优化。

注意:请注意,fib_loop 函数有三个参数 m n i,但在定义 fib 时,只传递了两个参数 0 1,使用部分应用。

具有多个参数的函数

定义具有多个参数的函数

要定义一个具有多个参数的函数,每个参数都必须列在函数名称(紧跟在 let 关键字之后)和等号之间,用空格隔开。

# let sweet_cat x y = x ^ " " ^ y;;
val sweet_cat : string -> string -> string = <fun>

# sweet_cat "kitty" "cat";;
- : string = "kitty cat"

具有多个参数的匿名函数

我们可以使用匿名函数以不同的方式定义相同的函数。

# let sour_cat = fun x -> fun y -> x ^ " " ^ y;;
val sour_cat : string -> string -> string = <fun>

# sour_cat "kitty" "cat";;
- : string = "kitty cat"

观察 sweet_catsour_cat 具有相同的函数体:x ^ " " ^ y。它们仅在参数列出的方式上有所不同。

  1. sweet_cat 中,名称和 = 之间为 x y
  2. sour_cat 中,= 之后为 fun x -> fun y ->(并且名称之前没有任何内容)。

还要注意 sweet_catsour_cat 具有相同的类型:string -> string -> string

如果使用编译器资源管理器检查生成的汇编代码,您会发现这两个函数的汇编代码相同。

sour_cat 的编写方式更明确地对应于这两个函数的行为。名称 sour_cat 绑定到一个具有参数 x 并返回一个具有参数 y 并返回 x ^ " " ^ y 的匿名函数的匿名函数。

sweet_cat 的编写方式是 sour_cat 的简写版本。这种缩短语法的技巧称为语法糖

部分应用和闭包

我们希望定义类型为 string -> string 的函数,这些函数在其参数前面追加 "kitty "。这可以使用 sour_catsweet_cat 完成。

# let sour_kitty x = sour_cat "kitty" x;;
val sour_kitty : string -> string = <fun>

# let sweet_kitty = fun x -> sweet_cat "kitty" x;;
val sweet_kitty : string -> string = <fun>

# sour_kitty "cat";;
- : string = "kitty cat"

# sweet_kitty "cat";;
- : string = "kitty cat"

但是,这两个定义都可以使用称为部分应用的内容缩短。

# let sour_kitty = sour_cat "kitty";;
val sour_kitty : string -> string = <fun>

# let sweet_kitty = sweet_cat "kitty";;
val sweet_kitty : string -> string = <fun>

由于多参数函数是一系列嵌套的单参数函数,因此您不必一次传递所有参数。

将单个参数传递给 sour_kittysweet_kitty 会返回一个类型为 string -> string 的函数。第一个参数,这里为 "kitty",被捕获,结果是一个闭包

这些表达式具有相同的值。

  • fun x -> sweet_cat "kitty" x
  • sweet_cat "kitty"

多参数函数的类型

让我们看看这里的类型。

# let dummy_cat : string -> (string -> string) = sweet_cat;;
val dummy_cat : string -> string -> string = <fun>

这里类型注释 : string -> (string -> string) 用于显式声明 dummy_cat 的类型。

但是,OCaml 回答说新的定义类型为 string -> string -> string。这是因为类型 string -> string -> stringstring -> (string -> string) 是相同的。

使用括号,很明显,一个多参数函数就是一个单参数函数,它返回一个匿名函数,并去除一个参数。

将括号放在另一边则无效。

# let bogus_cat : (string -> string) -> string = sweet_cat;;
Error: This expression has type string -> string -> string
       but an expression was expected of type (string -> string) -> string
       Type string is not compatible with type string -> string

类型为 (string -> string) -> string 的函数将函数作为参数。函数 sweet_cat 的结果是一个函数,而不是将函数作为参数。

类型箭头运算符右结合。不带括号的函数类型应该被视为在右侧带有括号,就像上面声明 dummy_cat 的类型一样。只是它们没有显示出来。

元组作为函数参数

在 OCaml 中,元组是一种用于对固定数量的值进行分组的数据结构,这些值可以是不同类型。元组用括号括起来,元素之间用逗号分隔。以下是 OCaml 中创建和使用元组的基本语法。

# ("felix", 1920);;
- : string * int = ("felix", 1920)

可以使用元组语法来指定函数参数。以下是如何使用它来定义运行示例的另一个版本的。

# let spicy_cat (x, y) = x ^ " " ^ y;;
val spicy_cat : string * string -> string = <fun>

# spicy_cat ("hello", "world");;
- : string = "hello world"

看起来传递了两个参数:"hello""world"。但是,只传递了一个,即 ("hello", "world") 元组。检查生成的汇编代码会发现它与 sweet_cat 不是同一个函数。它包含更多代码。传递给 spicy_cat 的元组的内容(xy)必须在 x ^ " " ^ y 表达式求值之前提取。这是额外汇编指令的作用。

在许多命令式语言中,spicy_cat ("hello", "world") 语法读作带有两个参数的函数调用;但在 OCaml 中,它表示将函数 spicy_cat 应用于包含 "hello""world" 的元组。

柯里化和反柯里化

在前面的章节中,已经介绍了两种多参数函数。

  • 返回函数的函数,例如 sweet_catsour_cat
  • 将元组作为参数的函数,例如 spicy_cat

有趣的是,这两种函数都提供了一种传递多个数据片的方式,同时仍然是单参数函数。从这个角度来看,说“所有函数都有一个参数”是有道理的。

这更进一步。始终可以将类似 sweet_cat(或 sour_cat)的函数与类似 spicy_cat 的函数相互转换。

这些转换有名称

  • 柯里化spicy_cat 形式转换为 sour_cat(或 sweet_cat)形式。
  • 反柯里化将 sour_cat(或 sweet_cat)形式转换为 spicy_cat 形式。

它还说 sweet_catsour_cat柯里化函数,而 spicy_cat非柯里化函数。

具有以下类型的函数可以相互转换

  • string -> (string -> string) — 柯里化函数类型
  • string * string -> string — 非柯里化函数类型

这些转换归功于 20 世纪的逻辑学家Haskell Curry

这里,使用 string 作为示例进行说明,但它适用于任何三组类型。

在重构时,您可以将柯里化形式更改为非柯里化形式,反之亦然。

但是,也可以从另一个实现一个,以便两种形式都可用。

# let uncurried_cat (x, y) = sweet_cat x y;;
val uncurried_cat : string * string -> string = <fun>

# let curried_cat x y = uncurried_cat (x, y);;
val curried_cat : string -> string -> string = <fun>

在实践中,柯里化函数是默认的,因为

  • 它们允许部分应用
  • 无需括号或逗号
  • 不会对元组进行模式匹配

具有副作用的函数

为了解释副作用,我们需要定义什么是定义域值域。让我们看一个例子。

# string_of_int;;
- : int -> string = <fun>

对于函数 string_of_int

  • 它的定义域int,即其参数的类型
  • 值域string,即其结果的类型

换句话说,定义域位于 -> 的左侧,值域位于右侧。这些术语有助于避免说函数类型箭头“右侧的类型”或“左侧的类型”。

某些函数在定义域或值域之外操作数据。此行为称为效应或副作用。

与操作系统的输入和输出 (I/O) 是副作用的最常见形式。返回随机数(例如 Random.bits 所做)或当前时间(例如 Unix.time 所做)的函数的结果受外部因素的影响,这也称为效应。

同样,函数计算触发的任何可观察现象都是值域外的输出。

在实践中,什么被认为是效应是一个工程选择。在大多数情况下,系统 I/O 操作被认为是效应,除非它们被忽略。处理器在计算函数时发出的热量通常不被认为是相关的副作用,除非在考虑节能设计时。

在 OCaml 社区以及更广泛的功能编程社区中,函数通常被称为或不纯。前者没有副作用,后者有。这种区别是有意义的,也是有用的。了解效应是什么以及何时发生是关键的设计考虑因素。但是,必须记住,这种区别始终假设某种上下文。任何计算都有效应,什么被认为是相关效应是一个设计选择。

由于根据定义,效应位于函数类型之外,因此函数类型无法反映函数的可能效应。但是,记录函数的预期副作用很重要。考虑 Unix.time 函数。它返回自 1970 年 1 月 1 日以来经过的秒数。

# Unix.time ;;
- : unit -> float = <fun>

注意:如果在 macOS 中收到 Unbound module error 错误,请先运行以下命令:#require "unix";;

Unix.time 函数的结果仅由外部因素决定。要执行副作用,必须将函数应用于一个参数。由于不需要传递数据,因此参数为 () 值。

考虑 print_endline。它将传递给它的字符串打印到标准输出,后跟一个换行符。

# print_endline;;
- : string -> unit = <fun>

由于函数的目的是仅产生副作用,因此它没有有意义的数据要返回;它返回 () 值。

这说明了具有副作用的函数与 unit 类型之间的关系。unit 类型的存在并不表示副作用的存在。unit 类型的不存在并不表示副作用的不存在。但是,当不需要将数据作为输入传递或可以作为输出返回时,将使用 unit 类型。

是什么使函数与其他值不同

函数就像其他值;但是,存在一些限制。

  1. 函数值不能在交互式会话中显示。而是显示占位符 <fun>。这是因为没有什么有意义的内容需要打印。一旦解析并类型检查,OCaml 就会丢弃函数的源代码,并且没有任何内容可以打印。
# sqrt;;
- : float -> float = <fun>
  1. 无法测试函数之间的相等性。
# pred;;
- : int -> int = <fun>

# succ;;
- : int -> int = <fun>

# pred = succ;;
Exception: Invalid_argument "compare: functional value".

有两个主要原因解释了这一点

  • 没有算法可以获取两个函数并确定在提供相同输入时它们是否返回相同的输出。
  • 假设这是可能的,这样的算法将声明快速排序和冒泡排序的实现是相等的。这意味着可以将一个替换为另一个,但这可能不明智。

结论

OCaml 的核心是环境的概念。环境充当有序的、追加式的键值存储。这意味着项目无法删除。此外,它通过保留可用定义的序列来保持顺序。

当我们使用 let 语句时,我们会向环境中引入零个、一个或多个名称值对。同样,当将函数应用于某些参数时,我们会通过添加与其参数相对应的名称和值来扩展环境。

帮助改进我们的文档

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

OCaml

创新。社区。安全。