不纯的function语言OCaml的“let rec”的原因是什么?

在“ Real World OCaml ”一书中,作者提出了为什么OCaml使用let rec来定义recursion函数。

OCaml区分非recursion定义(使用let)和recursion定义(使用let rec)主要是出于技术原因:types推断algorithm需要知道何时一组函数定义是相互recursion的,并且由于不适用于像Haskell这样的纯粹语言,这些都必须由程序员明确标记。

什么是强制执行let rec技术上的原因,而纯粹的function语言呢?

当你定义一个函数定义的语义时,作为一个语言devise者,你可以有select:要么使得函数的名字在自己的主体范围内可见,要么不是。 这两种select都是完全合法的,例如C族语言远没有function,仍然有定义的名称在其范围内可见(这也延伸到C中的所有定义,使得这个int x = x + 1合法)。 OCaml语言决定给我们额外的灵活性,让我们自己做出select。 这真的很棒。 他们决定把它隐藏在默认情况下,一个相当下降的解决scheme,因为我们写的大部分函数都是非recursion的。

与引用有关的是,它并不真正对应于函数定义rec关键字最常见的用法。 主要是关于“为什么函数定义的范围不扩展到模块的主体”。 这是一个完全不同的问题。 经过一番研究,我发现了一个非常相似的问题 ,那个答案可能会让你满意,

因此,鉴于types检查器需要知道哪些定义是相互recursion的,它可以做什么? 一种可能性是简单地对范围内的所有定义进行依赖性分析,并将其重新排列成尽可能最小的组。 Haskell实际上是这样做的,但是在像F#(和OCaml和SML)这样的语言中,这些语言有不受限制的副作用, 这是一个坏主意,因为它可能会重新sorting副作用 。 因此,它要求用户明确地标记哪些定义是相互recursion的,从而扩展到泛化应该发生的地方。

即使没有任何重新sorting,使用任意的非纯expression式,可能出现在函数定义中(定义的副作用,而不是评估),不可能构build依赖关系图。 考虑从文件解码和执行function。

总而言之,我们有两种使用let rec构造,一种是创build一个自recursion函数,就像

  let rec seq acc = function | 0 -> acc | n -> seq (acc+1) (n-1) 

另一个是定义相互recursion函数:

 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) 

在第一种情况下,没有技术上的理由坚持一个或另一个解决scheme。 这只是一个品味的问题。

第二种情况更难。 在推断types时,您需要将所有函数定义拆分为由相互依赖的定义组成的群集,以缩小打字环境。 在OCaml中很难做到,因为你需要考虑到副作用。 (或者你可以继续而不分解成主要组件,但是这会导致另一个问题 – 你的types系统将会受到更多限制,也就是说将不允许更有效的程序)。

但是,重新回顾一下原来的问题和RWO的报价,我还是很确定没有技术上的理由来添加rec标志。 考虑一下,SML具有相同的问题,但仍然默认启用rec 。 有一个技术原因, let ... and ...语法来定义一组相互recursion函数。 在SML中,这个语法并不要求我们把这个rec标志放在OCaml中,因此给了我们更多的灵活性,就像使用let x = y and y = xexpression式交换值的能力一样。

什么是强制执行让技术上的原因,而纯粹的function语言呢?

recursion是一个奇怪的野兽。 它与纯度有关系,但比这更略微一点。 要清楚的是,你可以编写“alterna-Haskell”,保留其纯度,懒惰,但没有recursion绑定let默认,并要求某种rec标记就像OCaml一样。 有些人甚至更喜欢这个。


从本质上讲,有许多不同的“让”是可能的。 如果我们比较let let rec在OCaml中let rec ,我们会看到一个小的差异。 在静态forms语义中,我们可能会写

 Γ ⊢ E : A Γ, x : A ⊢ F : B ----------------------------- Γ ⊢ let x = E in F : B 

如果我们可以在variables环境ΓcertificateE具有typesA并且如果我们可以certificate在相同的variables环境Γ中用x : A 增加 x : AF : B那么我们可以certificate在variables环境中Γ let x = E in FB型。

要注意的是Γ参数。 这只是(“variables名称”,“值”)对的列表,如[(x, 3); (y, "hello")] [(x, 3); (y, "hello")]并且像Γ, x : A一样扩充列表就意味着包含(x, A) (遗憾的是语法被翻转了)。

特别是,让我们写出let rec的相同forms

 Γ, x : A ⊢ E : A Γ, x : A ⊢ F : B ------------------------------------- Γ ⊢ let rec x = E in F : B 

尤其唯一的区别是,我们的处所都不在平原的环境中工作, 两者都被允许假设xvariables的存在。

从这个意义上说, let和只是不同的野兽。


那么纯粹是什么意思? 在Haskell甚至没有参与的最严格的定义中,我们必须消除所有的影响,包括不终止。 实现这一目标的唯一方法就是放弃我们写无限制recursion的能力,并只是仔细地将其replace。

有很多没有recursion的语言。 也许最重要的是简单typesLambda微积分。 它是基本的forms,它是正规的lambda演算,但增加了types有点类似的打字学科

 type ty = | Base | Arr of ty * ty 

事实certificate,STLC不能表示recursion— Y组合器和所有其他定点表兄弟组合器不能被input。 因此,STLC不是图灵完成的。

然而,这是毫不妥协的纯粹 。 它通过最乐器的手段达到了这种纯度,但是完全取消了recursion。 我们真正喜欢的是一种平衡的,谨慎的recursion,不会导致无法终止 – 我们仍然是图灵不完整的,但不是那么残缺。

有些语言尝试这个游戏。 有一些聪明的方法可以在datadata之间进行划分,从而保证不能写入非终止函数。 如果你有兴趣,我build议学习一下Coq。


但是OCaml的目标(和Haskell也是如此)在这里不会变得微妙。 这两种语言都毫不妥协地图灵完成(因此“实用”)。 那么让我们来讨论一些更直接的用recursion来增强STLC的方法。

这一组中最喜欢的是添加一个名为fix内置函数

 val fix : ('a -> 'a) -> 'a 

或者更真实的OCaml-y符号,它需要eta-expansion

 val fix : (('a -> 'b) -> ('a -> 'b)) -> ('a -> 'b) 

现在,请记住,我们只考虑添加了fix的原始STLC。 我们确实可以在OCaml中编写fix (至less是后者),但目前这是作弊。 什么fix购买STLC作为一个原始?

事实certificate,答案是:“一切”。 STLC + Fix(基本上是一种称为PCF的语言)是不纯和Turing Complete。 这也是非常困难的使用。


所以这是跳跃的最后障碍:我们如何使fix更容易使用? 通过添加recursion绑定!

已经,STLC有一个build设。 你可以把它看作只是语法糖:

 let x = E in F ----> (fun x -> F) (E) 

但是一旦我们添加了fix我们也有能力介绍let rec绑定

 let rec xa = E in F ----> (fun x -> F) (fix (fun xa -> E)) 

在这一点上,应该再次明确: letlet rec是非常不同的野兽。 它们体现了不同程度的语言能力, let rec是通过Turing Completeness及其伙伴效应不终止允许基本不纯的窗口。


所以,在这一天结束的时候,Haskell(这两种语言的更高版本)做出了废除简单的绑定的有趣select有点有趣。 这实际上是唯一的区别:在Haskell中没有表示非recursion绑定的语法。

在这一点上,它基本上只是一个风格决定。 Haskell的作者确定,recursion绑定是非常有用的,所以人们可能会认为每个绑定都是recursion的(相互之间,迄今为止这个答案中忽略了一堆蠕虫)。

另一方面,OCaml使您能够完全明确您select的绑定types, letlet rec

我认为这与纯粹的function无关,这只是一个devise决定,在Haskell中你是不被允许的

 let a = 0;; let a = a + 1;; 

而你可以在Caml做。

在Haskell中,这段代码将不起作用,因为let a = a + 1被解释为一个recursion定义,并且不会终止。 在Haskell中,你不必指定一个定义是recursion的,因为你不能创build一个非recursion的定义(所以关键字rec在任何地方都是不写的)。

我不是专家,但我会猜测,直到真正知道的人出现。 在OCaml中,定义函数时可能会产生副作用:

 let rec f = let () = Printf.printf "hello\n" in fun x -> if x <= 0 then 12 else 1 + f (x - 1) 

这意味着函数定义的顺序在某种意义上必须保留。 现在设想两个不同组的相互recursion函数是交错的。 编译器在将它们处理为两个单独的相互recursion定义集的同时保留顺序似乎并不容易。

使用“let rec”和“`意味着不同的相互recursion函数定义集合不能像在Haskell中那样在OCaml中交织。 Haskell没有副作用(从某种意义上说),所以定义可以自由地重新sorting。

这不是一个纯粹的问题,这是一个问题,指定types检查者应该检查expression式的环境。它实际上给了你比其他方式更多的权力。 例如(我将在这里编写Standard ML,因为我知道这比OCaml好,但我相信这两种语言的types检查过程几乎是一样的),它可以让你区分这些情况:

 val foo : int = 5 val foo = fn (x) => if x = foo then 0 else 1 

现在从第二个重定义开始, foo的types是int -> int 。 另一方面,

 val foo : int = 5 val rec foo = fn (x) => if x = foo then 0 else 1 

不会检查,因为rec意味着typechecker已经决定foo已经被反弹到typesa- 'a -> int ,并且当它试图找出需要的时候,就会出现统一失败,因为x = foo强制foo有一个数字types,而不是。

它可以肯定地“看”更加迫切,因为没有rec的情况允许你做这样的事情:

 val foo : int = 5 val foo = foo + 1 val foo = foo + 1 

现在foo的值为7.这不是因为它已经被改变了,而是foo的名字已经被反弹了三次,而且这些绑定中的每一个绑定了一个名为foo的variables的前一个绑定。 这是一样的:

 val foo : int = 5 val foo' = foo + 1 val foo'' = foo' + 1 

只是在标识符foo被反弹之后, foofoo'在环境中不再可用。 以下也是合法的:

 val foo : int = 5 val foo : real = 5.0 

这更清楚地说明发生的事情是原始定义的阴影 ,而不是副作用。

无论是在风格上重新标识标识符是一个好主意,都是值得怀疑的 – 它可能会让人困惑。 在某些情况下它可能很有用(例如,将函数名称重新绑定到打印debugging输出的本身的版本)。

我会说,在OCaml他们正在试图使REPL和源文件以相同的方式工作。 所以,在REPL中重新定义一些函数是完全合理的。 因此,他们也必须允许它在源头上。 现在,如果你自己使用(重新定义的)函数,OCaml需要一些方法来知道使用哪个定义:前一个或者新的定义。

在Haskell,他们刚刚放弃并接受REPL与源文件不同的地方。