(警告,这个答案很啰嗦。我认为最好包含 lambda 演算的一般知识,因为几乎不可能找到很好的解释)
作者似乎使用语法λg(x) 来表示命名 函数,而不是lambda 演算中的传统函数。作者似乎还详细讨论了 lambda 演算如何不是函数式编程,就像图灵机不是命令式编程一样。那些在经常用于表示它们的编程语言中不存在的抽象存在实用性和理想。但在开始之前,lambda 演算入门可能会有所帮助。在 lambda 演算中,所有函数都是这样的:
λarg.body
就是这样。有一个λ 符号(称为“lambda”,因此得名)后跟一个 命名参数,并且只有 一个 命名参数,然后是句点,然后是表示函数体的表达式。例如,identity 函数可以接受任何内容并立即返回,如下所示:
λx.x
而评估一个表达式只是一系列简单的规则,用于将函数和参数与它们的主体表达式交换出来。表达式具有以下形式:
function-or-expression arg-or-expression
减少它通常有规则“如果左边的东西是表达式,则减少它。否则,它必须是一个函数,所以使用arg-or-expression作为函数的参数,并将这个表达式替换为body非常重要的是要注意,没有要求 arg-or-expression 在被用作参数之前被减少。也就是说,以下两者是表达式λx.x (λy.y 0) 的等效且数学上相同的约简(假设您对 0 有某种定义,因为 lambda 演算要求您将数字定义为函数):
λx.x (λy.y 0)
=> λx.x 0
=> 0
λx.x (λy.y 0)
=> λy.y 0
=> 0
在第一次缩减中,参数在被用于λx.x 函数之前被缩减。在第二个中,参数只是替换到λx.x 函数体中——它在使用之前没有被缩减。在编程中使用这个概念时,它被称为“惰性求值”——在需要之前,您实际上不会求值(归约)表达式。需要注意的重要一点是,在 lambda 演算中,在替换之前参数是否被归约并不重要。 lambda 演算的数学证明只要两者都终止,无论哪种方式你都会得到相同的结果。这在编程语言中绝对不是这样,因为各种各样的事情(通常与程序状态的变化有关)都会使惰性求值与正常求值不同。
Lambda 演算需要一些扩展才能有用。没有办法命名事物。假设我们允许这样做。特别是,让我们为 lambda 演算中的函数创建自己的定义:
λname(arg).body
我们会说这意味着函数λarg.body 被绑定 到name,并且在任何随附的 lambda 表达式中的其他任何地方,我们都可以将name 替换为λarg.body。所以我们可以这样做:
λidentity(x).x
现在当我们写identity 时,我们只需将其替换为λx.x。然而,这引入了一个问题。如果命名函数引用自身会发生什么?
λevil(x).(evil x)
现在我们遇到了一个问题。根据我们的规则,我们应该能够将body 中的evil 替换为名称所绑定的内容。但是由于名称绑定到λx.(evil x),所以我们一试:
λevil(x).(evil x)
=> λevil(x).(λx.(evil x) x)
=> λevil(x).(λx.(λx.(evil x) x) x)
=> ...
我们得到一个无限循环。我们可以永远评估这个表达式,因为我们无法将它从我们特殊命名的 lambda 形式转换为常规 lambda 表达式。我们不能从具有我们特殊扩展的语言转到常规 lambda 演算,因为我们不能满足“用函数表达式替换 evil 绑定到”的规则。有一些技巧可以解决这个问题,但我们稍后会讲到。
这里很重要的一点是,这完全不同于常规的 lambda 演算程序,该程序无限计算并且永远不会完成。例如,考虑 self 应用函数,它接受一些东西并将其应用于自身:
λx.(x x)
如果我们使用 identity 函数对此进行评估,我们会得到:
λx.(x x) λx.x
=> λx.x λx.x
=> λx.x
使用命名函数并命名此函数self:
self identity
=> identity identity
=> identity
但是如果我们将self 传递给本身会发生什么?
λx.(x x) λx.(x x)
=> λx.(x x) λx.(x x)
=> λx.(x x) λx.(x x)
=> ...
我们得到一个表达式,它循环重复地将self self 减少为self self 一遍又一遍。这是在任何(图灵完备的)编程语言中都能找到的简单的旧无限循环。
这与我们的递归定义问题之间的区别在于我们的名称和定义不是 lambda 演算。它们是简写,我们可以通过遵循一些规则将其扩展为 lambda 演算。但在λevil(x).(evil x) 的情况下,我们无法将其扩展为 lambda 演算,因此我们甚至无法运行 lambda 演算表达式。我们命名的函数在某种意义上“编译失败”,类似于当您将编程语言 compiler 发送到无限循环中并且您的代码甚至永远不会启动,而不是实际的 runtime em> 循环。 (没错,就是entirely possible让编译器陷入死循环。)
有一些非常聪明的方法可以解决这个问题,其中之一就是臭名昭著的Y-combinator。基本思想是您将我们有问题的evil 函数更改为,而不是接受一个参数并尝试递归,接受一个参数并返回另一个接受参数的函数,所以你的@987654362 @ 表达式有两个参数可以使用:
λevil(f).λy.(f y)
如果我们评估evil identity,我们将得到一个新函数,它接受一个参数并使用它调用identity。以下评估首先显示了使用 -> 的名称替换,然后显示了使用 => 的缩减:
(evil identity) 0
-> (λf.λy.(f y) identity) 0
-> (λf.λy.(f y) λx.x) 0
=> λy.(λx.x y) 0
=> λx.x 0
=> 0
如果我们将evil 传递给自身而不是identity,那么事情会变得有趣:
(evil evil) 0
-> (λf.λy.(f y) λf.λy.(f y)) 0
=> λy.(λf.λy.(f y) y) 0
=> λf.λy.(f y) 0
=> λy.(0 y)
我们最终得到了一个完全无意义的函数,但我们实现了一些重要的东西 - 我们创建了一层递归。如果我们要评估(evil (evil evil)),我们会得到两个级别。与(evil (evil (evil evil))),三个。因此,我们需要做的不是将evil 传递给自身,而是需要传递一个以某种方式为我们完成此递归的函数。特别是,它应该是具有某种自我应用的功能。我们想要的是 Y-combinator:
λf.(λx.(f (x x)) λx.(f (x x)))
这个函数很难从定义中理解,所以最好直接调用它Y,看看当我们尝试用它评估一些东西时会发生什么:
Y evil
-> λf.(λx.(f (x x)) λx.(f (x x))) evil
=> λx.(evil (x x)) λx.(evil (x x))
=> evil (λx.(evil (x x))
λx.(evil (x x)))
=> evil (evil (λx.(evil (x x))
λx.(evil (x x))))
=> evil (evil (evil (λx.(evil (x x))
λx.(evil (x x)))))
正如我们所见,这种情况会无限进行。我们所做的是采用evil,它接受第一个函数,then 接受一个参数并使用该函数评估该参数,并传递给它一个经过特殊修改的evil 函数版本扩展以提供递归。所以我们可以通过减少evil (Y evil)在evil函数中创建一个“递归点”。所以现在,每当我们看到一个使用递归的命名函数时:
λname(x).(.... some body containing (name arg) in it somewhere)
我们可以将其转换为:
λname-rec(f).λx.(...... body with (name arg) replaced with (f arg))
λname(x).((name-rec (Y name-rec)) x)
我们把函数变成一个版本,首先接受一个函数作为递归点,然后我们提供函数Y name-rec作为递归点。
之所以可行,并且让 waaaaay 回到作者的原点,是因为表达式 name-rec (Y name-rec) 在开始自己的归约之前不必完全归约 Y name-rec。我不能强调这一点。我们已经看到减少Y name-rec 会导致无限循环,因此如果name-rec 函数中存在某种条件,则递归有效,这意味着Y name-rec 的下一步可能不需要减少。
这在许多编程语言中都存在问题,包括函数式语言,因为它们不支持这种惰性求值。此外,几乎所有编程语言都支持mutation。也就是说,如果你定义了一个变量x = 3,那么稍后在相同的代码中你可以创建x = 5,并且所有引用x的旧代码现在都将x视为5。这意味着如果旧代码通过惰性评估“延迟”并且仅在以后计算,您的程序可能会产生完全不同的结果,因为到那时 x 可能是 5。在一种可以随时以任何顺序任意执行事物的语言中,您必须完全消除程序对语句顺序和时变值等事物的依赖。如果你不这样做,你的程序可能会根据你的代码运行的顺序计算出任意不同的结果。
然而,编写毫无秩序感的代码是极其困难的。我们看到了如何复杂的 lambda 演算只是试图让我们的头脑了解微不足道的递归。因此,大多数函数式编程语言都会选择一个系统地定义评估顺序的模型,并且它们永远不会偏离该模型。
Racket,Scheme 的一种方言,规定在正常的 Racket 语言中,所有表达式都“急切”地求值(没有延迟),所有函数参数都急切地从左到右求值,但是 Racket 程序包括特殊形式,可让您选择性地使某些表达式变得惰性,例如 (promise ...)。 Haskell 则相反,表达式默认 为惰性求值,并让编译器运行“严格性分析器”来确定特别声明需要急切求值的参数的函数需要哪些表达式。 p>
主要观点似乎是,设计一种完全允许所有表达式单独惰性或急切的语言太不切实际了,因为这对您可以在该语言中使用的工具造成了严重的限制。因此,重要的是要记住函数式语言为您提供了哪些工具来操作惰性表达式和急切表达式,因为它们在所有实用的函数式编程语言中肯定不是等价的。