在书Real World OCaml中,作者阐述了为什么OCaml使用let rec
来定义递归函数。
OCaml在很大程度上出于技术原因区分非递归定义(使用let)和递归定义(使用let rec):类型推断算法需要知道一组函数定义何时是相互递归的,并且出于以下原因: ; t适用于像Haskell这样的纯语言,必须由程序员明确标记。
在使用纯函数式语言时强制执行let rec
的技术原因是什么?
答案 0 :(得分:34)
当您定义函数定义的语义时,作为语言设计者,您可以选择:要么使函数的名称在其自己的正文范围内可见,要么在其自身的范围内可见。这两种选择都是完全合法的,例如C系列语言远非功能性,它们的范围内仍然有可见的定义名称(这也扩展到C中的所有定义,使int x = x + 1
合法)。 OCaml语言决定为我们提供额外的灵活性,让我们自己做出选择。这真的很棒。他们决定默认它是隐形的,这是一个相当下降的解决方案,因为我们编写的大多数函数都是非递归的。
关于引用的内容,它并不真正对应于函数定义 - rec
关键字的最常见用法。它主要是关于"为什么函数定义的范围不会扩展到模块的主体"。这是一个完全不同的问题。
经过一些研究后,我发现了一个非常similar question,它有一个answer,可能会让你感到满意,引用它:
因此,鉴于类型检查器需要知道哪些组 定义是相互递归的,它能做什么?一种可能性是 简单地对范围内的所有定义进行依赖性分析, 并将它们重新排序为最小的可能组。实际上是Haskell 这样做,但在F#(和OCaml和SML)这样的语言中有 不受限制的副作用,这是一个坏主意,因为它可能会重新排序 副作用也是。因此,它要求用户明确标记 哪些定义是相互递归的,因此通过扩展而定 应该进行推广。
即使没有任何重新排序,使用任意非纯表达式,可能出现在函数定义中(定义的副作用,而不是评估),也无法构建依赖图。考虑从文件中解组和执行函数。
总结一下,我们有let rec
构造的两个用法,一个是创建一个自递归函数,比如
let rec seq acc = function
| 0 -> acc
| n -> seq (acc+1) (n-1)
另一种是定义相互递归的函数:
let rec odd n =
if n = 0 then true
else if n = 1 then false else even (n - 1)
and even n =
if n = 0 then false
else if n = 1 then true else odd (n - 1)
在第一种情况下,没有技术理由坚持一个或另一个解决方案。这只是品味问题。
第二种情况更难。在推断类型时,您需要将所有函数定义拆分为由相互依赖的定义组成的聚类,以缩小键入环境。在OCaml中,它更难制作,因为您需要考虑副作用。 (或者您可以继续而不将其拆分为主要组件,但这会导致另一个问题 - 您的类型系统将更具限制性,即将禁止更多有效的程序。)
但是,重新审视原始问题和RWO的引用,我仍然非常确定添加rec
标志没有技术原因。考虑一下,SML存在同样的问题,但默认情况下仍然启用了rec
。 是技术原因,用于定义一组相互递归函数的let ... and ...
语法。在SML中,这种语法并不要求我们在OCaml中放置rec
标志,从而为我们提供了更多的灵活性,比如能够用let x = y and y = x
表达式交换值。
答案 1 :(得分:18)
在纯函数式语言中强制执行rec的技术原因是什么?
递归是一种奇怪的野兽。它与纯度有关,但它比这更倾斜。要清楚,你可以编写“alterna-Haskell”,它保留了它的纯度,它的懒惰,但默认情况下没有递归地绑定let
,并且需要某种rec
标记,就像OCaml一样。有些人甚至更喜欢这个。
从本质上讲,只有许多不同类型的“让”可能。如果我们在OCaml中比较let
和let rec
,我们会看到一个小差异。在静态形式语义中,我们可能会写
Γ ⊢ E : A Γ, x : A ⊢ F : B
-----------------------------
Γ ⊢ let x = E in F : B
表示如果我们能够在变量环境Γ
中证明E
具有类型A
,并且我们能够在同一变量环境中证明Γ
使用x : A
F : B
进行扩充,然后我们可以证明在变量环境中Γ
let x = E in F
的类型为B
。
要注意的是Γ
参数。这只是[(x, 3); (y, "hello")]
之类的(“变量名称”,“值”)对的列表,并像Γ, x : A
那样扩充列表只是意味着对它(x, A)
(对不起,语法是翻转)。
特别是,让我们为let rec
Γ, x : A ⊢ E : A Γ, x : A ⊢ F : B
-------------------------------------
Γ ⊢ let rec x = E in F : B
特别是,唯一的区别是我们的前提都不在普通的Γ
环境中工作;允许两者都假定存在x
变量。
从这个意义上讲,let
和let rec
只是不同的野兽。
那么纯粹是什么意思?在严格的定义中,Haskell甚至没有参与,我们必须消除所有效果,包括非终止。实现这一目标的唯一方法是放弃编写无限制递归的能力,并且只能小心地替换它。
存在大量没有递归的语言。也许最重要的是简单类型的Lambda微积分。在它的基本形式中,它是常规的lambda演算,但增加了类型有点像
的打字规则type ty =
| Base
| Arr of ty * ty
事实证明,STLC不能代表递归 - Y组合器和所有其他定点表兄弟组合器都无法输入。因此,STLC并非Turing Complete。
然而,毫不妥协纯。然而,它通过最完整的仪器实现了这种纯度,完全取消了递归。我们真正喜欢的是某种平衡的,谨慎的递归,这种递归不会导致不终止 - 我们仍将是图灵不完整,但不是那么残缺。
有些语言尝试这个游戏。有一些聪明的方法可以在data
和codata
之间的分区中添加类型递归,这可以确保您无法编写非终止函数。如果你有兴趣,我建议学习一点Coq。
但是OCaml的目标(以及Haskell的目标)在这里并不精致。两种语言都是图灵完全(因而“实用”)。因此,让我们讨论一些使用递归来增加STLC的更直接的方法。
最简单的是添加一个名为fix
val fix : ('a -> 'a) -> 'a
或者,更真实的OCaml-y表示法需要eta-expansion
val fix : (('a -> 'b) -> ('a -> 'b)) -> ('a -> 'b)
现在,请记住,我们只考虑添加fix
的原始STLC。我们确实可以在OCaml中写fix
(至少后者),但那是在作弊。 fix
将STLC作为基元购买了什么?
事实证明,答案是:“一切”。 STLC + Fix(基本上是一种名为PCF
的语言)是不纯的,图灵完全。这也非常难以使用。
所以这是跳跃的最后障碍:我们如何让fix
更容易合作?通过添加递归绑定!
STLC已经构建了let
。你可以把它想象成语法糖:
let x = E in F ----> (fun x -> F) (E)
但是一旦我们添加了fix
,我们也有能力引入let rec
绑定
let rec x a = E in F ----> (fun x -> F) (fix (fun x a -> E))
此时应再次明确:let
和let rec
是非常不同的野兽。它们具有不同程度的语言能力,let rec
是通过图灵完整性及其伴侣效应非终止来允许基本杂质的窗口。
所以,在一天结束的时候,两种语言中较纯粹的Haskell做出了取消普通let
绑定的有趣选择,这有点有趣。这是唯一的区别:在Haskell中没有表示非递归绑定的语法。
此时它基本上只是一种风格决定。 Haskell的作者确定递归绑定是如此有用,以至于人们可以假设每个绑定都是递归的(并且相互如此,到目前为止,在这个答案中忽略了一堆蠕虫)。
另一方面,OCaml使您能够完全明确您选择的绑定类型let
或let rec
!
答案 2 :(得分:14)
我认为这与纯粹的功能无关,只是在Haskell中你不允许做的设计决定
let a = 0;;
let a = a + 1;;
而你可以在Caml中完成。
在Haskell中,此代码不起作用,因为let a = a + 1
被解释为递归定义而不会终止。
在Haskell中,您不必指定定义是递归的,因为您无法创建非递归定义(因此关键字rec
无处不在,但未写入)。
答案 3 :(得分:8)
我不是专家,但我会猜测,直到真正知识渊博的家伙出现。在OCaml中,在定义函数期间可能会出现副作用:
let rec f =
let () = Printf.printf "hello\n" in
fun x -> if x <= 0 then 12 else 1 + f (x - 1)
这意味着必须在某种意义上保留函数定义的顺序。现在想象两个不同的相互递归函数集是交错的。编译器在将它们作为两个单独的相互递归的定义集处理时保留顺序似乎并不容易。
使用`let rec ...和``意味着不同的相互递归函数定义集不能像在Haskell中那样在OCaml中交错。 Haskell没有副作用(在某种意义上),因此定义可以自由重新排序。
答案 4 :(得分:6)
这不是一个纯粹的问题,它是一个指定类型检查器应该检查表达式的环境的问题。它实际上给你的力量比你原本提供的更多。例如(我将在这里编写标准ML,因为我知道它比OCaml更好,但我相信这两种语言的类型检查过程几乎相同),它可以让你区分这些情况:
val foo : int = 5
val foo = fn (x) => if x = foo then 0 else 1
现在,在第二次重新定义时,foo
的类型为int -> int
。另一方面,
val foo : int = 5
val rec foo = fn (x) => if x = foo then 0 else 1
没有进行类型检查,因为rec
意味着类型检查器已经确定foo
已经反弹到类型'a -> int
,并且当它试图找出那个'a
时x = foo
1}}需要,统一失败是因为foo
强制rec
有一个数字类型,它没有。
当然可以&#34;看&#34;更为迫切,因为没有val foo : int = 5
val foo = foo + 1
val foo = foo + 1
的情况允许你做这样的事情:
foo
现在foo
的值为7.那不是因为它已被突变,但是名称 foo已被反弹3次,并且碰巧的是,每个绑定都隐藏了一个名为val foo : int = 5
val foo' = foo + 1
val foo'' = foo' + 1
的变量的先前绑定。它与此相同:
foo
除了foo'
和foo
在标识符val foo : int = 5
val foo : real = 5.0
反弹后在环境中不再可用。以下也是合法的:
{{1}}
更清楚地表明原始定义的阴影正在发生,而不是副作用。
重新标识标识符在风格上是否是一个好主意是值得怀疑的 - 它可能会让人感到困惑。它在某些情况下很有用(例如,将函数名称重新绑定到打印调试输出的自身版本)。
答案 5 :(得分:0)
我说在OCaml中他们试图使REPL和源文件以相同的方式工作。因此,重新定义REPL中的某些功能是完全合理的;因此,他们也必须允许它在源头。现在,如果您自己使用(重新定义的)函数,OCaml需要某种方式来了解要使用的定义:前一个或新的定义。
在Haskell中,他们只是放弃并接受REPL在源文件中使用不同的内容。