由于 OCaml 是一种函数式语言,因此有很多关于函数的内容。让我们开始吧。
方法和函数不是同一个概念。【方法是对象的一个组件】,它隐式地有一个接收者,通常通过像
this或self这样的关键字访问。OCaml 函数不是方法:它们不是对象的组件,也没有接收者。【注】
我喜欢 Julia 语言中方法的定义:方法是函数某些具体参数类型的实现。例如,下面是函数
sum(x,y)的不同方法:sum(x:int,y:int)与sum(x:float,y:float)、sum(x:int,y:float)和sum(x:float,y:int)是不同的。我有一种预感,这种定义可以被限制为与面向对象静态语义相匹配,可以这样说:面向对象中的方法是一个函数的实现,其中只有第一个参数的类型用于方法调度;当方法定义在类(数据类型)的词法作用域内时,第一个参数隐式定义为:ClassType…
我希望这至少对某些PL(语言编译系统?)科学家有意义。如果是这样,我希望看到一种方法的定义,它可以专门匹配不同语言的语义。
有些人可能会说所有的方法都是函数,但并不是所有的函数都是方法。有些人甚至可能对此吹毛求疵,将函数和过程区分开来。后者是不返回任何有意义值的函数,例如 Java 中的
void返回类型或 Python 中的None返回值。因此,如果你有面向对象的背景,请注意术语。这里的一切都是严格意义上的函数,而不是方法。
函数定义
下面的代码
let x = 42
有一个表达式 (42),但本身不是一个表达式。相反,它是一种定义。定义将值绑定到名称,在本例中,值 42 绑定到名称 x。OCaml 手册描述了定义(见该页上第三大主标题为 “定义(definitions)" 的部分),但该手册页主要是用于参考,而不是用于研究。定义不是表达式,表达式也不是定义 —— 它们是不同的语法类。
现在,让我们关注一种特殊的定义,函数定义。非递归函数是这样定义的:
let f x = ...
递归函数是这样定义的:
let rec f x = ...
区别只在于 rec 关键字。你必须显式地添加一个关键字才能使函数递归,这可能有点令人惊讶,因为大多数语言默认都是递归的。但是 OCaml 并没有做这样的假设。(Scheme 家族的语言也是如此。)
最著名的递归函数之一是阶乘函数。在 OCaml 中,可以这样写:
(** [fact n] is [n]!.
Requires: [n >= 0]. *)
let rec fact n = if n = 0 then 1 else n * fact (n - 1)
val fact : int -> int = <fun>
我们在函数上面提供了一个规范注释来记录函数的前置条件(Requires)和后置条件(is)。
注意,与许多语言一样,OCaml 整数不是“数学”整数,而是被限制为固定位数。该手册规定(有符号)整数至少31位,但可以更大。随着架构的增长,规模也在增长。在当前的实现中,OCaml 整数为 63 位。因此,如果你在足够大的输入上进行测试,可能会开始看到奇怪的结果。问题在于机器算术,而非 OCaml。(对于感兴趣的读者:为什么是 31 位或 63 位而不是 32 位或 64 位?OCaml 垃圾收集器需要区分整数和指针。因此,它们的运行时表示会使用一位来标记一个字是整数还是指针。)
下面是另一个递归函数:
(** [pow x y] is [x] to the power of [y].
Requires: [y >= 0]. *)
let rec pow x y = if y = 0 then 1 else x * pow x (y - 1)
val pow : int -> int -> int = <fun>
注意,我们不需要在两个函数中编写任何类型:OCaml 编译器会自动为我们推断出它们。编译器通过算法解决了这种类型推断 问题,但我们也可以自己做。这就像一个谜,可以通过我们的推理能力来解决:
-
由于
if表达式可以在then分支中返回1,根据if的类型规则,我们知道整个if表达式的类型为int。 -
由于
if表达式的类型是int,所以函数的返回类型必须是int。 -
由于用相等运算符将
y与0进行比较,所以y必须是int型。 -
由于
x使用*运算符与另一个表达式相乘,所以x必须是int型。
如果我们出于某种原因想要写下类型,我们可以这样做:
let rec pow (x : int) (y : int) : int = ...
当我们为 x 和 y 编写类型注解 时,括号是必须的。我们通常会省略这些注解,因为让编译器推断它们更简单。还有一些时候,你需要显式地写下类型。一种特别有用的情况是,当你从编译器那里得到一个你不理解的类型错误时。显式地注解类型有助于调试此类错误信息。
语法。 函数定义的语法:
let rec f x1 x2 ... xn = e
f 是一个元变量,指示将标识符用作函数名。这些标识符必须以小写字母开头。关于小写标识符的其余规则可以在手册中找到。x1 到 xn 是表示参数标识符的元变量。它们遵循与函数标识符相同的规则。如果 f 是一个递归函数,则必须使用 rec 关键字;非递归函数可以省略 rec 关键字。
请注意,与 OCaml 所允许的相比,函数定义的语法实际上被简化了。在接下来的几周中,我们将学习更多关于函数定义的增强语法。但就目前而言,这个简化版本将有助于我们集中精力。
可以使用 and 关键字定义相互递归函数:
let rec f x1 ... xn = e1
and g y1 ... yn = e2
例如:
(** [even n] is whether [n] is even.
Requires: [n >= 0]. *)
let rec even n =
n = 0 || odd (n - 1)
(** [odd n] is whether [n] is odd.
Requires: [n >= 0]. *)
and odd n =
n != 0 && even (n - 1);;
val even : int -> bool = <fun>
val odd : int -> bool = <fun>
函数类型的语法如下:
t -> u
t1 -> t2 -> u
t1 -> ... -> tn -> u
t 和 u 是表示类型的元变量。类型 t -> u 是接受 t 类型输入并返回 u 类型输出的函数的类型。我们可以将 t1 -> t2 -> u 看作接受两个输入的函数的类型,第一个输入为 t1 ,第二个输入为 t2,并返回 u 类型的输出。接受 n 个参数的函数也是如此。
**动态语义。**函数定义没有动态语义。没有什么可以计算。OCaml 仅记录绑定到函数的名称 f ,该函数具有给定参数 x1..xn 和给定函数体 e。只有在后面应用该函数时,才会进行一些计算。
静态语义。 函数定义的静态语义是:
-
对于非递归函数:如果我们假设
x1 : t1、x2 : t2……xn : tn,我们能推断出e : u,则可得到f : t1 -> t2 -> ... -> tn -> u。(注:也就是对于非递归函数,如果有x1的类型是t1,x2的类型是t2……xn的类型是tn,我们能够推断出 表达式e的类型是u的话,则函数f的类型是t1 -> t2 -> ... -> tn -> u) -
对于递归函数:如果我们假设
x1 : t1、x2 : t2……xn : tn并且f : t1 -> t2 -> ... -> tn -> u,我们能够推断出 表达式e : u,则可以得到f : t1 -> t2 -> ... -> tn -> u。(注:也就是对于递归函数,如果有x1的类型是t1,x2的类型是t2……xn的类型是tn,并且f : t1 -> t2 -> ... -> tn -> u,我们能够推断出 表达式e的类型是u的话,则函数f的类型是t1 -> t2 -> ... -> tn -> u)
请注意,递归函数的类型检查规则【假设函数标识符 f 具有特定的类型】,然后检查函数体在该假设下是否具有良好的类型。这是因为函数 f 也在函数体本身的作用域内(就像参数在作用域内一样)。
匿名函数
我们已经知道可以使用不绑定到名称的值。例如,可以在 toplevel 输入整数 42,而不用给它指定名称:
42
- : int = 42
或者我们可以将它绑定到一个名称:
let x = 42
val x : int = 42
类似地,OCaml 函数也可以没有名称;他们可能是匿名的。例如,下面是一个递增输入的匿名函数:fun x -> x + 1。这里 fun 是一个表示匿名函数的关键字,x 是参数,-> 将参数与函数体分隔开。
现在我们有两种编写递增函数的方式:
let inc x = x + 1
let inc = fun x -> x + 1
【它们在语法上不同,但在语义上是相同的。】也就是说,尽管它们涉及不同的关键字,并将一些标识符放在不同的位置,但它们的意思是相同的。
匿名函数也叫 lambda 表达式,这个术语来自 lambda 演算,lambda 演算是一种计算过程的数学模型,就像图灵机是一种计算过程模型一样。在lambda演算中,fun x -> e 写作 。 表示匿名函数。
为什么我们需要没有名字的函数,现在看起来可能有点神秘。别担心;我们将在后面的课程中看到使用它的好处,特别是当我们学习所谓的“高阶编程”时。尤其是我们经常会创建匿名函数,并将它们作为输入传递给其他函数。
语法
fun x1 ... xn -> e
静态语义
- 如果我们假设
x1 : t1、x2 : t2……xn : tn,我们能够推断出e : u,则我们可得到fun x1 ... xn -> e : t1 -> t2 -> ... -> tn -> u.
**动态语义。**匿名函数已经是一个值。没有要执行的计算。
函数应用
与 OCaml 实际允许的语法相比,这里我们介绍的函数应用的语法有些简化。
语法
e0 e1 e2 ... en
第一个表达式 e0 是函数,它应用参数 e1 到 en。注意,不需要像 C 家族语言(包括 Java)那样,用括号括起参数来表示函数应用。
Static semantics. 静态语义
-
If
e0 : t1 -> ... -> tn -> uande1 : t1and ... anden : tnthene0 e1 ... en : u.如果
e0 : t1 -> ... -> tn -> u并且e1 : t1……en : tn则有e0 e1 ... en : u.
动态语义
计算 e0 e1 ... en:
-
计算
e0为一个函数。计算参数表达式e1 ~ en的值为v1 ~ vn。 -
对于
e0,结果可能是一个匿名函数fun x1 ... xn -> e或一个名称f。对于后一种情况,我们需要找到f的定义,我们可以假设它是let rec f x1 ... xn = e的形式。无论哪种方式,现在我们知道了参数名称x1到xn和函数体e。 -
在函数体
e中将每个值vi替换为对应的参数名xi。这个替换会产生一个新的表达式e'。 -
计算
e'的值为v,v是计算e0 e1 ... en的结果。
如果将这些计算规则与 let 表达式的规则进行比较,你会注意到它们都涉及到替换。这并非偶然。事实上,在程序中任何地方出现的 let x = e1 in e2 ,我们都可以用 (fun x -> e2) e1 来替换它。它们在语法上不同,但在语义上是等价的。在本质上,let 表达式只是匿名函数应用的语法糖。
管道
在 OCaml 函数应用中有一个内置的中缀运算符,称为管道 运算符,写成 |> 。把它想象成一个指向右边的三角形。这个比喻是,值通过管道从左到右发送。例如,假设我们有上面的递增函数 inc,以及对输入值求平方的函数 square :
let square x = x * x
val square : int -> int = <fun>
下面是两种计算 6 的平方的等价写法:
square (inc 5);;
5 |> inc |> square;;
- : int = 36
- : int = 36
后者使用管道运算符发送 5 到 inc 函数,之后又通过管道运算符发送计算结果到 square 函数。这是在 OCaml 中表示计算的一种很好的惯用方式。前一种方式可以说没有那么优雅:它需要写额外的括号,要求读者的眼睛四处跳跃,而不是从左往右线性移动。当所应用的函数数量增加时,后一种方法可以很好地扩展,而前一种方法需要越来越多的括号:
5 |> inc |> square |> inc |> inc |> square;;
square (inc (inc (square (inc 5))));;
一开始可能会觉得奇怪,但下次当你发现自己在编写一个大的函数应用链时,请尝试在自己的代码中使用管道运算符。
【因为 e1 |> e2 只是 e2 e1 的另一种写法,我们不需要声明 |> 的语义:它与函数应用程序是一样的。】这两个程序是语法上不同但语义上相同的表达式的另一个例子。
【注]】因此,|> 是复合运算符(或组合运算符)
多态函数
【恒等 函数】是简单地返回其输入的函数:
【注】也就是恒等 I 组合子。
let id x = x
val id : 'a -> 'a = <fun>
或者等价于一个匿名函数:
let id = fun x -> x
val id : 'a -> 'a = <fun>
'a 是一个类型变量:它代表未知的类型,就像普通变量代表未知的值一样。类型变量总是以单引号开始。常用的类型变量包括 'a 、'b和 'c,OCaml 程序员通常读作希腊语的:alpha、beta 和 gamma。
我们可以将恒等函数应用于任何类型的值:
id 42;;
id true;;
id "bigred";;
- : int = 42
- : bool = true
- : string = "bigred"
因为你可以将 id 应用于许多类型的值,所以它是一个 多态函数 :它可以应用于许多( 多 )形式( 形态 )。
使用手动类型注解,可以为多态函数提供比编译器自动推断的类型更严格的类型。例如:
let id_int (x : int) : int = x
val id_int : int -> int = <fun>
除了两个手动类型注解之外,这个函数与 id 相同。由于这些原因,我们不能像使用 id 那样将 id_int 应用于 bool 类型:
id_int true
File "[14]", line 1, characters 7-11:
1 | id_int true
^^^^
Error: This expression has type bool but an expression was expected of type
int
id_int 的另一种写法是用 id 表示:
let id_int' : int -> int = id
实际上,我们取了一个类型 'a -> 'a 的值,并将其绑定到一个名称上,该名称的类型被手动指定为 int -> int。你可能会问,为什么能这样?他们毕竟不是同一类型。
我们可以从行为的角度考虑这个问题。id_int 的类型指定了其行为的一个方面:给定一个 int 作为输入,它承诺生成一个 int 作为输出。事实证明,id 也做出了同样的承诺:给定一个 int 作为输入,它也将返回一个int 作为输出。现在 id 还做出了更多的承诺,例如:给定 bool 值作为输入,它将返回 bool 值作为输出。因此,通过将 id 绑定到一个更严格的 int -> int 类型,我们丢弃所有其他不相关的承诺。当然,这是信息丢失,但至少不会违背承诺。当我们需要的是 int -> int 类型的函数时,使用类型为 'a -> 'a 的函数总是安全的。
反之则不然。如果我们需要一个类型为 'a -> 'a 的函数,但试图使用类型为int -> int的函数,一旦有人传递另一种类型的输入,例如 bool,我们就会遇到麻烦。为了避免这种问题,OCaml 使用以下代码做了一些可能令人惊讶的事情:
let id' : 'a -> 'a = fun x -> x + 1
函数 id' 实际上是递增函数,而不是恒等函数。所以传递 bool 或 string 或一些复杂的数据结构是不安全的;+ 运算符只能安全地操作整数。因此,OCaml 将类型变量 'a 实例化 为 int,从而防止我们将 id 应用于非整数:
id' true
File "[17]", line 1, characters 4-8:
1 | id' true
^^^^
Error: This expression has type bool but an expression was expected of type
int
这就引出了另一种更机械的方法,从应用的角度来考虑这些问题。这里我们指的是函数如何应用参数的概念:当我们计算该应用 id 5 时,参数 x 被 实例化 为值 5。同样,id 类型中的 'a 在该应用中被实例化为 int 类型。所以如果我们编写
let id_int' : int -> int = id
val id_int' : int -> int = <fun>
实际上,我们将 id 类型中的 'a 实例化为 int 类型。就像无法“取消应用”一个函数 —— 例如,给定 5,我们无法向后计算 id 5,我们也无法取消该类型实例化并将 int 改回 'a。
为了更准确地说明这一点,假设我们有一个 let 定义 [或 let 表达式]:
let x = e [in e']
OCaml 推断 x 的类型为 t,其中包括一些类型变量 'a、 'b 等。然后允许实例化这些类型变量。我们可以通过将函数应用参数来揭示类型实例化应该是什么(如 id 5)或通过类型注释(如 id_int'),以及其他方式来做到这一点。但我们必须与实例化保持一致。例如,我们不能将 'a -> 'b -> 'a 类型的函数实例化为 int -> 'b -> string 类型,因为 'a 的实例化在它出现的两个地方没有相同的类型:
let first x y = x;;
let first_int : int -> 'b -> int = first;;
let bad_first : int -> 'b -> string = first;;
val first : 'a -> 'b -> 'a = <fun>
val first_int : int -> 'b -> int = <fun>
File "[19]", line 3, characters 38-43:
3 | let bad_first : int -> 'b -> string = first;;
^^^^^
Error: This expression has type int -> 'b -> int
but an expression was expected of type int -> 'b -> string
Type int is not compatible with type string
标签参数和可选参数
函数的类型和名称通常会让你很好地了解参数应该是什么。但是,对于有很多参数的函数(特别是相同类型的参数),给它们标上标签是很有用的。例如,你可能猜测函数 String.sub 是返回给定字符串的子字符串(你可能是正确的)。你可以输入 String.sub 查看它的类型:
String.sub;;
- : string -> int -> int -> string = <fun>
但从类型上看不清楚如何使用它,你不得不查阅文档。
OCaml 支持带标签参数的函数。你可以使用下面的语法声明这种函数:
let f ~name1:arg1 ~name2:arg2 = arg1 + arg2;;
val f : name1:int -> name2:int -> int = <fun>
调用这个函数时,可以按任意顺序传入带标签的参数:
f ~name2:3 ~name1:4
参数的标签通常与变量名相同。OCaml 为这种情况提供了一种简写。以下两种写法相同:
let f ~name1:name1 ~name2:name2 = name1 + name2
let f ~name1 ~name2 = name1 + name2
使用带标签的参数很大程度上取决于个人喜好。它们传递了额外的信息,但也会让类型变得混乱 。
同时使用带标签的参数和显式的类型注解的语法如下:
let f ~name1:(arg1 : int) ~name2:(arg2 : int) = arg1 + arg2
也可以将一些参数设置为可选的。当我们调用函数但不指定可选参数时,它将提供一个默认值。要声明这样的函数,使用如下语法:
let f ?name:(arg1=8) arg2 = arg1 + arg2
val f : ?name:int -> int -> int = <fun>
然后,你可以使用或不使用参数来调用函数:
f ~name:2 7
- : int = 9
f 7
- : int = 15
部分应用
我们可以这样定义一个加法函数:
let add x y = x + y
val add : int -> int -> int = <fun>
下面是一个类似的函数:
let addx x = fun y -> x + y
val addx : int -> int -> int = <fun>
函数 addx 接受一个整数 x 作为输入,并返回一个 int -> int 类型的函数,它将 x 与传递给它的任何值相加。
addx 的类型为 int -> int -> int。add 的类型也是 int -> int -> int。所以从它们的类型来看,它们是相同的函数。但是 addx 的形式表明了一些有趣的事情:我们可以将它应用于单个参数。
let add5 = addx 5
val add5 : int -> int = <fun>
add5 2
- : int = 7
事实证明,同样的事情也可以用 add 函数来完成:
let add5 = add 5
val add5 : int -> int = <fun>
add5 2;;
- : int = 7
我们刚才所做的被称为部分应用:我们将函数 add 部分应用到一个参数上,尽管你通常会认为它是一个多参数函数。这是可行的,因为以下三个函数在 语法上不同,但在语义上是相同的。也就是说,它们是表示同一种计算的不同方式:
let add x y = x + y
let add x = fun y -> x + y
let add = fun x -> (fun y -> x + y)
所以 add 实际上是一个函数,它接受一个参数 x,并返回一个函数 (fun y -> x + y)。这让我们认识到一个深刻的道理……
函数结合性
你准备好接受真相了吗?做个深呼吸。这里是……
每个 OCaml 函数都只有一个参数。
为什么?以 add 为例:虽然我们可以写成 let add x y = x + y,但我们知道这在语义上等价于 let add = fun x -> (fun y -> x + y)。 一般来说 ,
let f x1 x2 ... xn = e
在语义上等价于
let f =
fun x1 ->
(fun x2 ->
(...
(fun xn -> e)...))
即使你认为 f 是一个有 n 个参数的函数,但实际上它是一个有 1 个参数的函数,然后返回一个函数。
这种函数的类型
t1 -> t2 -> t3 -> t4
实际上等于
t1 -> (t2 -> (t3 -> t4))
也就是说,函数类型是右结合 的:函数类型周围有从右到左的隐式括号。这里的直观感觉是,函数接受一个参数,并返回一个新函数,该函数接收剩余的参数。
另一方面,函数应用是 左结合 的:函数应用周围有从左到右的隐式括号。所以
e1 e2 e3 e4
实际上等于
((e1 e2) e3) e4
这里的直觉是,最左边的表达式将其右边的下一个表达式作为其唯一的参数。
运算符作为函数
加法运算符 + 的类型为 int -> int -> int。通常写成中缀,如 3 + 4。通过给它加上一对括号,我们可以把它变成一个 前缀 运算符:
( + )
- : int -> int -> int = <fun>
( + ) 3 4;;
- : int = 7
let add3 = ( + ) 3
val add3 : int -> int = <fun>
add3 2
- : int = 5
同样的技术适用于任何内置运算符。
通常情况下,空格不是必须的。我们可以写 (+) 或 ( + ),但最好包含它们。注意乘法运算,它必须写成 ( * ),因为 (*) 会被解析为注释的开头。
我们甚至可以定义自己的中缀运算符,例如:
let ( ^^ ) x y = max x y
现在 2 ^^ 3 计算结果为 3。
使用标点创建中缀运算符的规则并不直观。解析这些运算符时的相对优先级也不直观。所以要小心这种用法。
尾递归
Consider the following seemingly uninteresting function, which counts from 1 to n:
考虑下面这个看似 "无趣" 的函数,它从 1 计数到 n:
(** [count n] 为 [n],通过计算 [n] 次加 1。也就是说,该函数从 1 到 [n] 依次递增。 *)
let rec count n =
if n = 0 then 0 else 1 + count (n - 1)
val count : int -> int = <fun>
计数到 10 没有问题:
count 10
- : int = 10
计数 10 万也没有问题:
count 100_000
- : int = 100000
但是尝试计数 100,0000,你会得到以下错误:
Stack overflow during evaluation (looping recursion?).
这是怎么回事?
调用栈。 问题在于调用栈的大小有限。你可能在编程入门课上学过,大多数语言都用栈实现函数调用。对于每个已经开始但尚未完成的函数调用,该栈都包含一个元素。每个元素都存储一些信息,比如局部变量的值以及函数中当前正在执行的指令。当一个函数体的计算调用另一个函数时,一个新元素被压入调用栈,当被调用的函数完成时,它被弹出。
栈的大小通常受到操作系统的限制。因此,如果栈空间耗尽,就不可能再进行另一个函数调用。通常情况下,这种情况不会发生,因为没有理由在返回之前进行那么多连续的函数调用。在这种情况下,操作系统有充分的理由让该程序停止:它可能正在耗尽整个计算机上的所有可用内存,从而损害在同一台计算机上运行的其他程序。count 函数不太可能做到这一点,但下面的函数可以做到:
let rec count_forever n = 1 + count_forever n
val count_forever : 'a -> int = <fun>
(* 调用该函数,会提示计算期间栈溢出 *)
count_forever 1;;
Stack overflow during evaluation (looping recursion?).
因此,为了安全起见,操作系统限制了调用栈的大小。这意味着在足够大的输入上,最终 count 将耗尽栈空间。请注意,这种选择实际上是独立于编程语言的。因此,同样的问题也会在 OCaml 以外的语言中发生,包括 Python 和 Java。你不太可能在那里看到它的出现,因为你可能从来没有在那些语言中写过这么多的递归函数。
尾递归。 1977年 Guy Steele 在一篇关于 LISP 的论文中描述了这个问题的解决方案。解决方案是尾部调用优化,它需要程序员和编译器之间的某种合作。程序员稍微重写一下函数,然后编译器会注意到并进行优化。让我们看看它是如何工作的。
假设一个递归函数 f 调用自己,然后返回递归调用的结果。我们的 count 函数不会 这样做:
let rec count n =
if n = 0 then 0 else 1 + count (n - 1)
val count : int -> int = <fun>
相反,在递归调用 count (n - 1) 之后,还有剩余的计算:计算机仍然需要在该调用的结果上加 1。
但是作为程序员,我们可以重写 count 函数,使它在递归调用后不需要做任何额外的计算。技巧在于创建一个带有额外参数的辅助函数:
let rec count_aux n acc =
if n = 0 then acc else count_aux (n - 1) (acc + 1)
let count_tr n = count_aux n 0
val count_aux : int -> int -> int = <fun>
val count_tr : int -> int = <fun>
函数 count_aux 几乎与原来的 count 相同,但它添加了一个额外的参数 acc,这是一个惯用的参数,代表“累加器”。其思想是,我们希望从函数返回的值是缓慢的,每次递归调用都在其中积累。“剩余计算”(1 的加法) 现在发生在递归调用之前,而不是之后。当递归的主线计算结束时,该函数现在返回已经累加出了最后结果的 acc。
但是最初 0 的主线计算仍然需要存在于代码的某个地方。它确实这样做了,因为 acc 的初值被传递给 count_aux。现在 count_tr (我们将在一分钟后了解为什么名称是“tr”) 可以替换我们原来的 count 。
在这一点上,我们已经完成了程序员的职责,但可能不清楚我们为什么要这样做。毕竟,count_aux 仍然会像 count 那样多次递归调用自己,并最终溢出栈。
这就是编译器的职责所在。一个好的编译器 (OCaml 编译器在这方面很好) 可以注意到递归调用在尾部 的情况,这是一种技术上的说法,表示“在它返回后不再需要进行更多的计算”。对 count_aux 的递归调用位于尾部;而对 count 的递归调用则不是这样。下面是它们的对比:
let rec count n =
if n = 0 then 0 else 1 + count (n - 1)
let rec count_aux n acc =
if n = 0 then acc else count_aux (n - 1) (acc + 1)
这就是为什么尾部位置很重要:尾部位置的递归调用不需要新的栈帧。它可以重用现有的栈帧。 这是因为现有的栈帧中已经没有任何东西可用!因为已经没有计算要做,所以局部变量、下一条要执行的指令等等都不再重要。这些内存都不需要再次读取,因为调用实际上已经完成。因此,编译器不再通过分配另一个栈帧来浪费空间,而是“回收”前一栈帧使用的空间。
这就是尾部调用优化。如果调用函数的栈帧与被调用函数适当兼容,它甚至可以应用于递归函数之外的情况。这是一件大事。尾部调用优化将栈空间要求从线性降低到常数。而 count 需要 栈帧,count_aux 只需要 ,因为每次递归调用都会重复使用相同的栈帧。这意味着 count_tr 实际上可以计数到100,0000:
count_tr 1_000_000
最后,为什么我们将这个函数命名为 count_tr ?“tr”代表尾递归。尾部递归函数是递归调用都在尾部位置的递归函数。换句话说,它是一个不会耗尽栈空间的函数(除非有其他异常)。
尾递归的重要性。 有时,初学函数式编程的人会过分关注它。如果你只关心编写函数的第一个草稿,那么可能不需要担心尾递归。如果需要的话,很容易让它变成尾部递归,只需要添加一个累加器参数。或者你应该重新考虑如何设计这个函数。以 count 为例,它有点笨。但稍后我们会看到一些不那么笨的例子,比如遍历包含数千个元素的列表。
编译器支持这种优化是很重要的。否则,作为程序员,对代码进行的转换不会产生任何影响。事实上,大多数编译器都支持,至少作为一种选择。Java 是一个明显的例外。
尾部递归诀窍。 简而言之,下面就是如何让函数成为尾递归的方法:
-
Change the function into a helper function. Add an extra argument: the accumulator, often named
acc.将函数改为辅助函数。添加一个额外的累加器参数,通常命名为
acc。 -
编写一个调用辅助函数的新的主要函数的版本。它传递原始主线计算的返回值作为累加器的初始值。
-
将辅助函数更改为返回主线计算中的累加器。
-
更改辅助函数的递归情况。现在它需要在递归调用之前对累加器参数做额外的工作。这是唯一需要很多独创性的步骤。
示例:阶乘。 下面将阶乘函数转换为尾部递归:
(* [fact n] is [n] factorial *)
let rec fact n =
if n = 0 then 1 else n * fact (n - 1)
val fact : int -> int = <fun>
首先,我们修改它的名字并添加一个累加器参数:
let rec fact_aux n acc = ...
其次,编写一个新的主要函数,它使用原始的主线计算作为累加器来调用辅助函数:
let rec fact_tr n = fact_aux n 1
第三,修改辅助函数,使其在主线计算下返回累加器:
if n = 0 then acc ...
最后,我们修改递归的条件:
else fact (n - 1) (n * acc)
Putting it all together, we have:
综上所述,我们有:
let rec fact_aux n acc =
if n = 0 then acc else fact_aux (n - 1) (n * acc)
let fact_tr n = fact_aux n 1
val fact_aux : int -> int -> int = <fun>
val fact_tr : int -> int = <fun>
这是一个很好的练习,但可能不值得。实际上在栈空间耗尽之前,计算过程就会出现整数溢出:
fact 50
为了解决这个问题,我们求助于 OCaml 的大整数库 Zarith 。在这里,我们使用了一些 OCaml 特性,这些特性超出了我们迄今为止看到的任何特性,但希望不会太惊讶。(如果你想继续执行这段代码,请先使用 opam install zarith 在 OPAM 中安装 Zarith。)
#require "zarith.top";;
let rec zfact_aux n acc =
if Z.equal n Z.zero then acc else zfact_aux (Z.pred n) (Z.mul acc n);;
let zfact_tr n = zfact_aux n Z.one;;
zfact_tr (Z.of_int 50)
val zfact_aux : Z.t -> Z.t -> Z.t = <fun>
val zfact_tr : Z.t -> Z.t = <fun>
- : Z.t = 30414093201713378043612608166064768844377641568960512000000000000
如果愿意,可以使用这段代码计算 zfact_tr 1_000_000 ,不需要栈溢出或整数溢出,不过这需要几分钟的时间。
在模块章节将详细解释我们上面使用的 OCaml 特性,但现在:
-
#require用于加载库,它提供了一个名为Z的模块。回想一下 这个符号,它在数学中用来表示整数。 -
Z.n表示在Z中定义的名称n。 -
类型
Z.t是大整数类型的库名。 -
我们使用标准库值
Z.equal进行相等比较,Z.zero表示 0,Z.pred表示前值(即减去 1),Z.mul表示乘法,Z.one表示 1,Z.of_int将基本类型的整数转换为大整数。
注:本书是康奈尔大学 CS 3110 数据结构和函数式编程的教材。原书为英文版,在学习的过程中,根据自己的理解,翻译了一些,做一个记录,版权归原作者所有,如有侵权,请联系我删除。