try ai
科普
编辑
分享
反馈
  • 传名调用

传名调用

SciencePedia玻尔百科
核心要点
  • 传名调用是一种求值策略,其中函数参数作为未求值的过程(thunk)传递,并在每次使用时重新求值。
  • 该机制支持强大的编程范式,例如处理无限数据结构和避免不必要或易出错的计算。
  • 按需调用(惰性求值)通过在首次求值后记忆化(缓存)结果来优化传名调用,从而在灵活性与性能之间取得平衡。
  • 延迟计算的核心原则在科学计算、容错分布式系统和编译器设计中有着广泛的应用。

引言

在编程世界中,我们如何向函数传递信息是一个基础性选择,它决定了语言的行为。最常见的方法是传值调用,即我们计算一个值并将结果传递过去。但如果存在一种更灵活,尽管也更复杂的替代方案呢?如果我们不给函数一个现成的答案,而是给它一个“配方”,让它在自己选择的任何时候去寻找答案,会怎么样?这就是传名调用的核心前提,一种将计算延迟到最后一刻的求值策略。本文旨在揭开这一强大概念的神秘面纱,弥合其理论优雅性与对现代计算产生的深远实际影响之间的鸿沟。

首先,在“原理与机制”一章中,我们将剖析传名调用背后的机制,探讨 thunk、强制求值(forcing)以及环境的关键作用等概念。我们将揭示重复求值带来的令人惊讶甚至棘手的后果,尤其是在涉及副作用时,并了解更具实用性的按需调用策略如何提供一个有力的折衷方案。随后,“应用与跨学科联系”一章将拓宽我们的视野,揭示“拖延”这一简单思想如何让我们能够处理无限数据结构、构建更安全的代码,甚至设计出稳健的、容错的分布式系统。读完本文,您将看到传名调用不仅是一项语言特性,更是一个具有深远影响的基本计算原则。

原理与机制

想象一下,你向朋友要一个蛋糕。在最直接的情况下,他们会走进厨房,烤好一个蛋糕,然后把成品交给你。这就是​​传值调用​​(call-by-value)的精髓,也是程序传递信息最常见的方式。函数请求一个值,计算机计算出它,然后将最终结果传递过去。函数不关心蛋糕是怎么做的,只关心它得到了一个蛋糕。

但如果还有另一种方式呢?如果你的朋友给你的不是蛋糕,而是一张详细的食谱卡片呢?现在你没有蛋糕本身,但你得到了一个蛋糕的承诺——一套你可以在任何想做的时候遵循的指令。这就是​​传名调用​​(call-by-name)背后的核心思想。函数得到的不是一个值,而是一个用于获取该值的过程。

这看似一个奇怪的区别,但它改变了一切。有了食谱在手,如果你想在甜点时吃一片,在午夜小吃时再吃一片,你就必须完整地执行两次食谱——打鸡蛋、混合面糊、预热烤箱。如果食谱中有一个奇特的步骤,比如“一边搅拌一边大声唱歌”,你将需要大声唱两次歌。每次使用都重新求值是传名调用的决定性特征,它会导致一些极其复杂、有时甚至令人费解的行为。

承诺的机制:Thunk 与强制求值

计算机如何传递“食谱”呢?当然不是用纸和墨水。它使用一种巧妙的数据结构,称为 ​​thunk​​。Thunk 是承诺的程序化体现。它是一个包含两个基本组件的包:

  1. 待执行的​​代码​​——即“食谱”本身,也就是你传递给函数的参数表达式。
  2. 代码运行所需的​​环境​​——即备有所有正确原料和用具的“厨房”。该环境捕获了函数调用处的所有变量,确保食谱能按预期工作。

因此,当你调用函数 f(x + 1) 时,f 内部的参数不是一个数字,而是一个 thunk,一个小小的待打开的包裹。我们可以将其表示为 ⟨code for x+1,caller’s environment⟩\langle \text{code for } x+1, \text{caller's environment} \rangle⟨code for x+1,caller’s environment⟩。

函数体可以携带这个 thunk,将它传递给其他函数,或者完全忽略它。但当函数真正需要这个值时,它会执行一个称为​​强制求值​​(forcing)thunk 的操作。强制求值意味着在其存储的环境中执行 thunk 的代码。如果一个函数体在其执行路径中引用了传名调用参数 kkk 次,那么相应的 thunk 将被强制求值,原始表达式也将被重新求值,不多不少正好 kkk 次。考虑一个简单的函数 f(x) = x + x。如果我们用一个涉及某种动作的参数来调用它,比如 f(get_sensor_reading()),那么传感器将在第一个 x 处被读取一次,在第二个 x 处被读取第二次。结果是两次可能不同的读数之和。

双刃剑:副作用与意外结果

这种持续的重新求值正是传名调用展现其棘手之处的地方。如果表达式是一个纯粹的数学计算,如 2 * 3,重新求值只是无害的冗余。但如果表达式有​​副作用​​——也就是说,如果它改变了自身之外的世界的状态——事情就变得有趣了。

想象一个日志函数 log("event"),它向文件写入一行并返回 1。如果我们有一个函数 f(a, b) 并使用传值调用方式 f(log("event"), log("event")) 来调用它,那么 log 函数会在 f 开始执行之前被调用两次,而 f 只是接收到值 1 和 1。但在传名调用下,f 接收到两个 thunk。如果 f 的函数体是 a + b + a,那么 a 的 thunk 会被强制求值两次,而 b 的 thunk 会被强制求值一次。log("event") 表达式总共执行了三次,创建了三个日志条目。副作用的次数不是由调用决定的,而是由函数内部参数的使用决定的。

这可能导致真正令人费解的情景。考虑一个 thunk,其表达式不仅返回一个值,还修改了它所依赖的变量。在一个这样的假设性难题中,参数 u 绑定到表达式 x ← x + y; x,该表达式首先更新变量 x,然后返回其新值。如果函数体是 u() + (u() * v()) + x,追踪其执行过程就成了一场微妙的舞蹈。第一次调用 u() 会改变状态,这会影响第二次调用 u() 的结果,而第二次调用又会影响对 v() 的调用,依此类推。最终的结果是程序状态在这种错综复杂的、逐步演化下的产物,这有力地展示了在传名调用下计算与状态可以变得何等紧密地交织在一起。求值的次数会根据函数的结构迅速增长;一个每次递归使用其参数一次的递归函数,将在递归的每一步都强制求值该 thunk。

不只是宏:环境的关键作用

此时,你可能会想:“这不就像 C 预处理器中的简单文本替换宏吗?”这是一个绝妙的问题,答案是响亮的否定。区别虽然微妙但至关重要,它就在于 thunk 的第二个组成部分:​​环境​​。

让我们想象一个程序,其中函数 incTwice(u) 包含自己的局部变量 x,并将其设置为 1。该函数的主体是 u + u。现在,假设我们从另一个地方调用这个函数,那里也有一个名为 x 的变量,其值为 10,并且我们传递表达式 x + 1作为参数。

如果这是一个简单的宏,文本 x + 1 会被盲目地替换到函数中,得到 (x + 1) + (x + 1)。但这是哪个 x 呢?在 incTwice 内部,可见的是局部变量 x(值为 1)。因此,宏会错误地计算出 (1 + 1) + (1 + 1) = 4。参数的变量被函数的局部作用域“捕获”了。

而使用 thunk 正确实现的传名调用要复杂得多。x + 1 的 thunk 是一个​​闭包​​——它“闭合”了其创建时所在的环境。它随身携带了调用者的世界。当这个 thunk 在 incTwice 内部被强制求值时,它会使用调用者的 x(值为 10)来计算 x + 1。这就是​​词法作用域​​。因此,每次使用 u 都会正确地得到 11,函数最终返回 22。这种卫生性,即对意外变量捕获的免疫力,正是使传名调用成为一个健壮的编程特性,而不仅仅是一个脆弱的文本技巧的原因。

一个更懒、更明智的近亲:按需调用

为每一片蛋糕都重新烤一个新蛋糕的想法似乎极为浪费。事实也确实如此!纯粹的传名调用正因为这种性能成本,在现代语言中很少被使用。取而代之的是一种更务实的变体:​​按需调用​​(call-by-need),也称为​​惰性求值​​(lazy evaluation)。

按需调用始于相同的原则:传递 thunk,而非值。然而,它增加了一个关键的优化:​​记忆化​​(memoization)。当一个 thunk 第一次被强制求值时,其结果被计算出来,然后与一个表示“我已被求值”的标志一起存储在 thunk 内部。此后每次使用该参数时,系统只需返回存储的值,而无需重新运行计算。

让我们重新审视那个有副作用的参数 p,它执行一次 I/O 操作并返回 1。如果一个函数在其主体中使用了这个参数六次,传名调用会触发六次 I/O 操作。但使用按需调用,thunk 仅在第一次使用时被强制求值。I/O 操作只发生一次,结果 1 被保存下来,接下来的五次使用会立即且无声地获得已保存的 1。副作用的次数从六次骤降到一次。这种“求值一次”的策略将延迟计算的灵活性与更可预测的性能结合起来,使其成为像 Haskell 这样的语言中惰性求值的主要形式。

深入底层:隐藏的工程

实现这些基于承诺的机制是一项精美的编译器工程,充满了需要巧妙解决方案的迷人挑战。

首先,一个优化的编译器必须极其小心。一个常见的优化是识别相同的表达式并只计算一次。一个天真的编译器可能会看到 u + u 并将其转换为 t = u; t + t,以为这样可以节省工作。但如果 u 是一个带有副作用的传名调用参数,这种转换会改变程序的含义!例如,如果 u 是一个递增全局计数器的表达式,原始代码会将其递增两次,而“优化后”的代码只递增一次。一个正确的编译器必须执行​​纯度分析​​,在应用此类优化之前证明一个表达式没有副作用。

其次,一个更深层次的问题出现在内存管理上。函数通常将其局部变量存储在​​栈​​上的一个临时工作区中。当函数返回时,其工作区被清除。但如果一个函数创建了一个 thunk(它捕获了指向其工作区的指针),然后将该 thunk 传递给另一个函数,后者又将其存储在​​堆​​上的一个长生命周期的数据结构中呢?原始函数返回,其工作区消失了,但 thunk 仍然存在,持有一个指向无效内存的指针——一个​​悬垂指针​​。这被称为​​向上 funarg 问题​​。如果这个 thunk 被强制求值,程序就会崩溃。解决方案是​​逃逸分析​​:编译器检测到 thunk 可能“逃逸”其原始作用域,并巧妙地将捕获的变量分配在持久的堆上,而不是临时的栈上,从而确保它们的生命周期与 thunk 一样长。

最后,即使使用堆分配,也存在浪费的风险。一个函数的工作区可能非常庞大,包含巨大的数据数组,但 thunk 可能只需要其中的一个很小的变量。如果一个简单的实现为了一个变量而让整个工作区保持活动,将导致大规模的内存泄漏。最终的改进是​​环境裁剪​​。编译器分析 thunk 的代码,找出它实际使用的变量(即其​​自由变量​​),然后在堆上构建一个自定义的、最小化的环境,其中只包含那些特定变量的位置。这精确地捕获了所需内容,别无他物,从而将语义正确性与内存效率结合起来。

从一个简单的想法——传递承诺而非实物——演化出一幅由语义、性能权衡和复杂编译器技术交织而成的丰富画卷。这是一个完美的例子,说明了理论上优雅的概念在实践中需要深入而细致的工程才能实现。

应用与跨学科联系

在我们之前的讨论中,我们剖析了传名调用的机制,发现它是一种奇特而强大的计算拖延形式。我们看到,计算机不是在看到表达式时就急切地求值,而是将其打包成一个“thunk”——一个稍后完成工作的承诺——并且只有在真正需要结果时才着手处理。这似乎只是一个奇闻异事,是某些编程语言的一个奇怪特性。但正如我们即将看到的,这种延迟求值的简单思想不仅仅是新奇事物;它是一个具有深远影响的基本原则,为数据处理、科学计算以及构建大规模容错分布式系统等不同领域的问题解锁了优雅的解决方案。这是一个一旦理解便会改变你对计算本身看法的思想。

惰性之力:驯服无限与避免错误

让我们从一个简单而实际的好处开始。假设你正在编写一个程序,需要计算像 y/xy/xy/x 这样的值。这里总有一个恼人的危险:如果 xxx 是零怎么办?通常,你会写一个检查:“如果 xxx 不为零,则计算 y/xy/xy/x”。有了传名调用,这种安全性被内置得更为优雅。考虑一个表达式,如 if(x, y/x, 0)。如果参数 x 是通过传名调用传递并且恰好是零,那么条件会被判定为假。表达式的“then”部分,即危险的 y/xy/xy/x,甚至根本不会被触及。它的 thunk 被简单地丢弃,其可能导致除零错误的承诺也未被兑现。计算安全地沿着“else”路径继续进行。这种非严格求值,即不计算你不需要的东西的原则,是传名调用策略的直接结果。

这不仅仅是避免错误的一个聪明技巧,它还是驯服无限的关键。在传统编程中,如果我们想要一个包含前一百万个素数的列表,我们必须首先计算出所有一百万个素数并将它们存储在内存中,这是一项成本高昂且耗时的工作。如果我们能谈论所有素数的列表,一个无限序列,并将其视为一个单一、具体的对象,会怎么样?通过传名调用,我们可以做到。

我们可以定义一个“生成器”,这是一个小程序,当被调用时,它会产生序列中的下一个值以及一个用于生成序列其余部分的新生成器。例如,一个自然数的生成器,在被调用时,会产生 1 和一个准备好产生 2 的新生成器,依此类推。通过传名调用传递这个生成器,我们传递的是一个产生序列的承诺,而不是序列本身。如果我们随后请求这个无限列表的前十个元素,计算机将精确地调用生成器十次,仅计算满足我们请求所需的无限序列部分。无限列表的其余部分仍然是一个潜力,一个未求值的 thunk,等待着一个可能永远不会到来的未来需求。这种将无限数据结构模拟为有限数据结构的能力,正是惰性求值的魔力所在,也是一种由传名调用哲学直接促成的范式。它构成了现代数据科学中基于流的处理的支柱,在这些场景中,数据通常过于庞大而无法装入内存,最好作为一条流动的河来处理,一次处理一部分。

力量的代价:驾驭副作用

到目前为止,传名调用似乎近乎神奇。但这种力量是有代价的,当我们的计算开始与外部世界交互时,这个代价就变得显而易见。如果求值一个表达式不仅仅是产生一个数字,还会打印一条消息、发射一枚导弹或从银行账户中扣款呢?我们称这些为“副作用”。

在纯粹的传名调用下,每次使用参数时,其原始表达式都会从头开始重新求值。如果该表达式有副作用,那么每次都会触发副作用。想象一个函数,它接受两个参数 uuu 和 vvv,并先使用 vvv,再使用 uuu,然后再次使用 vvv。如果 uuu 的表达式打印“E”,vvv 的表达式打印“G”,那么输出将不会是像你从函数调用中可能预期的那样先“E”后“G”。相反,输出将遵循函数内部的使用顺序,并且副作用会重复出现:“G”,然后是“E”,然后又是“G”。

这可能导致一些效率极低且语义上奇异的行为。考虑一个表示读取文件内容的参数。如果这个参数在一个函数中被使用了三次,一个简单的传名调用实现会打开文件、读取其内容、然后关闭它……总共三次!这不仅速度慢,而且如果文件内容在两次读取之间发生变化,结果也可能不正确。

这就是根本的权衡。传名调用的重新求值功能强大,但对于有副作用或计算成本高昂的表达式来说,它往往是浪费和不可取的。这一认识直接导向了一个关键的改进:​​按需调用​​,也称为惰性求值。这是一个巧妙的折衷方案。第一次强制求值 thunk 时,我们像往常一样求值表达式,但我们也保存结果。在随后的每次使用中,我们简单地返回已保存的值而不进行重新求值。这种策略,也称为*记忆化*,在许多情况下为我们提供了两全其美的方案:求值仍然延迟到需要时,因此我们保留了处理无限数据结构和避免未使用计算的能力,但我们最多只执行一次工作。大多数现代的“惰性”函数式语言,如 Haskell,实际上都使用按需调用作为其默认设置。这是一种优化,它保留了传名调用的精神,同时通过处理副作用来驯服其更狂野的倾向。

跨学科的桥梁:作为统一原则的惰性

惰性思维——延迟工作和缓存结果——是一种如此强大的优化模式,以至于它以多种形式出现在整个计算机科学领域。

科学计算

想象你是一位物理学家,正在模拟一个复杂的系统,你的模拟核心部分涉及求解线性方程 Ax=bA x = bAx=b。矩阵 AAA 可能代表你系统的固定法则,而向量 bbb 代表随时间变化的某些外部输入。从头开始求解这个系统在计算上是昂贵的,需要大约 Θ(n3)\Theta(n^3)Θ(n3) 次操作。如果你需要为许多不同的输入 b1,b2,…,bmb_1, b_2, \ldots, b_mb1​,b2​,…,bm​ 求解,一个简单的方法是每次都重复完整的 Θ(n3)\Theta(n^3)Θ(n3) 工作。

一位精明的计算科学家知道,求解中最昂贵的部分——将矩阵 AAA 分解为其 LULULU 成分——仅取决于固定的 AAA。一旦你有了这个分解,为新的 bbb 求解就便宜得多,只需 Θ(n2)\Theta(n^2)Θ(n2)。这是一个适用于更复杂形式惰性的完美场景。我们可以创建一个 thunk,在第一次强制求值时,计算昂贵的 LULULU 分解并缓存它。对于第一次求解以及之后的每一次求解,它都使用缓存的分解和当前的向量 bbb 来找到解 xxx。这种“部分记忆化”尊重了 bbb 的变化性,同时避免了与不变的 AAA 相关的冗余重新计算。它完美地保留了问题的语义,同时显著提高了性能,是算法洞察力与求值策略的美妙融合。

分布式系统

当我们涉足分布式系统的世界时,这种联系变得更加深刻。想象一个计算,其中求值一个表达式涉及通过网络对服务器进行远程过程调用(RPC)。那么,传名调用在这里意味着什么?如果一个对应于此 RPC 的参数被使用了 kkk 次,语言语义要求进行 kkk 次独立的逻辑求值。这意味着我们必须向服务器发送 kkk 个独立的请求。

但网络是不可靠的。请求可能会丢失,或者服务器的回复可能会消失。处理这种情况的一个常用策略是,如果客户端没有及时收到响应,就重试请求。这会导致“至少一次”的交付。但如果服务器操作有副作用,比如向用户的购物车中添加一件商品呢?重试可能会导致添加两件商品!

问题在于,尽管网络具有至少一次送达的特性,如何保证我们的 kkk 次逻辑求值中的每一次都恰好发生一次。该解决方案是现代分布式系统设计的基石。对于 kkk 次逻辑求值中的每一次,我们都生成一个唯一的请求 ID。客户端对该次逻辑请求的所有重试都使用相同的 ID。服务器维护一个已处理过的 ID 日志。如果它看到一个新的 ID,它就执行操作并记录下来。如果它看到一个它已经处理过的 ID,它就简单地重新发送之前的结果,而不再重新执行操作。这种使用幂等性密钥的技术,完美地将传名调用的语言级语义转化为一个健壮的、容错的协议。这是一个惊人的例子,展示了抽象的语言理论如何为构建可靠的现实世界系统提供精确的蓝图。

编译器工程

惰性求值的原则甚至会反作用于其自身,出现在实现它的编译器的构建过程中。一个优化的编译器是一个由多个遍(pass)组成的流水线,这些遍会分析和转换程序的中间表示(IR)。一些分析非常昂贵。后一个遍可能需要前一个遍的分析结果。如果 IR 在两个遍之间没有发生相关变化,重新运行分析纯属浪费。

现代编译器,如 LLVM,表现得很惰性。它们将分析结果视为按需计算的值。当一个遍请求一项分析时,遍管理器首先检查是否已有有效的缓存结果。如果有,就立即返回。如果没有,就运行分析。至关重要的是,如果另一个遍修改了 IR,编译器足够聪明,会使可能受影响的任何缓存分析结果失效。这通常通过对 IR 状态进行版本控制来完成。这种优雅的机制——需求驱动的计算加上基于版本的缓存失效机制——是按需调用哲学的直接应用,用于使编译器本身快速高效。

机器中的幽灵

为了让所有这些魔法得以实现,底层需要一些真正聪明的工程设计。一个 thunk 的核心是一个闭包,它捕获了要运行的代码以及它寻找变量所需的词法环境。当被强制求值时,它在那个保存的环境中运行该代码,但使用世界的当前状态(内存存储),这就是它如何与可变变量正确交互的方式。

但在一个递归定义中会发生什么,比如 let rec x = 1 + x?在这里,x 的 thunk 需要自己的值来计算自己的值!如果处理不当,强制求值 x 的 thunk 会立即再次尝试强制求值 x 的 thunk,导致无限循环和栈溢出。为了防止这种情况,惰性求值运行时使用一种称为“黑洞化”的技巧。当一个 thunk 的求值开始时,运行时会用一个特殊的“黑洞”占位符替换它。如果求值过程中再次尝试强制求值同一个 thunk,它就会碰到这个黑洞,系统就知道它发现了一个无限循环。这个虽小但至关重要的机制使得惰性求值在面对自引用定义时是安全的。

从一条简单的规则——“非到必需,不做计算”——我们穿越了一片广阔的知识版图。传名调用及其更实用的近亲按需调用,不仅仅是编译器的细枝末节。它们是一种设计哲学。它们赋予我们操控无限、编写更安全更模块化的代码、以及通过揭示连接编程语言逻辑与其执行物理(无论是在单个芯片上还是跨越全球网络)的深层统一原则来构建更快、更稳健的系统的能力。这是一个单一、优雅思想力量的美丽证明。