For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/proposal-ptc-syntax.md.
  • 简体中文
  • Updates to Tail Calls to include an explicit syntactic opt-in ?

    中文标题:尾调用更新:引入显式语法选择机制

    提案概览
    提案速览

    该提案为尾调用引入了一种显式的语法选择机制,称为语法尾调用(STC),以解决 ES6 正确尾调用(PTC)功能的问题,例如性能退化、调试困难、Error.stack 不一致、跨 realm 调用和开发者意图不清晰。主要解决方案是 return continue 语句,它将调用标记为尾调用并防止栈增长,同时也在讨论函数符号或 !-return 等替代语法。该提案处于早期阶段,语法替代方案正在 issue #1 中讨论,旨在取代 ECMAScript 规范中的 PTC。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    语法尾调用(STC)

    关于尾调用显式语法选择机制的讨论和规范,称为语法尾调用(STC)。

    示例程序:

    function factorial(n, acc = 1) {
      if (n === 1) {
        return acc;
      }
    
      return continue factorial(n - 1, acc * n)
    }

    或者,使用箭头函数:

    let factorial = (n, acc = 1) =>
      n == 1 ? acc
             : continue factorial(n - 1, acc * n);

    历史与背景

    ES6 规定了正确的尾调用(PTC)。使用 PTC,处于尾位置的调用不得创建额外的栈帧

    在审议过程中,显然对 PTC 的相对优点进行了大量讨论,权衡了潜在的缺点,包括调试场景中的困难和可能的实现困难。不幸的是,此时的 TC39 流程不需要大量的实现参与,因此尽管许多实现者持怀疑态度,该功能仍被包含并作为 ES6 的一部分标准化。

    最近,实现者开始认真考虑实现尾调用。有些实现没有重大担忧,而其他实现则有。许多(如果不是全部)这些担忧可以通过改变 PTC 的设计以要求某种语法选择机制来解决。本仓库试图创建这样一个语法选择机制的提案,作为 ES6 中 PTC 规范的替代品。

    PTC 的优点

    PTC 具有“只需用”的良好特性——以可以利用正确尾调用的方式编写的程序无需开发者额外工作即可利用它们。实际上,开发者甚至可能不知道 PTC 的存在,而运行时却可以优化其代码的栈使用。

    此外,今天对小型输入有效的程序,在支持 PTC 的引擎中可能对更大输入也有效。

    (支持者或许应该将其细分为子部分。有志愿者吗?)

    PTC 的问题

    PTC 设计引发了许多问题。虽然这些问题中有许多在 ES6 之前的规范过程中已考虑,但并非全部,而且并非所有都由实际实现经验和数据支持。此外,虽然支持小组认识到 PTC 及相关概念在过去的几十年中已被广泛研究和辩论,但我们相信,在开发者和实现者今天所处的约束背景下重新审视这些问题并无坏处。

    性能

    一些实现者和广大开发者认为 PTC 是一种优化策略,通过启用 PTC,引擎将使代码运行更快。这一信念得到了 JSC 团队实现的验证,该实现显示在某些情况下有性能提升。然而,这一信念被 v8 团队驳斥,他们将其视为大多性能中性;而 Chakra 团队则可能由于其他限制,无法在不使现有包含尾调用的代码性能退化或牺牲规范符合性的情况下实现该功能。

    由于 PTC 自动将大量 Web 代码纳入尾调用使用范围,性能退化是一个非常严重的问题。(参见 #7)。STC 通过使何时使用该功能变得有意为之来绕开这个问题,这可以涉及对实现的评估以及使用该功能是否会在特定场景下产生理想的性能特征。

    开发者工具

    开发者严重依赖调用栈来调试代码。实现 PTC 将导致许多帧缺失。这可以通过实现某种影子栈(例如 JSC 的 ShadowChicken)来缓解,该影子栈维护一个边栈,在调试期间向用户展示帧。这需要成本,因此可能仅在开发者工具打开时启用(因此无法解决下面的 Error.stack 问题)。它也不是万灵药,可能存在潜在问题,例如向调试工作流引入 GC 不确定性。(参见 #6)。

    例如,考虑这个简单程序:

    function foo(n) {
      return bar(n*2);
    }
    
    function bar() {
      throw new Error();
    }
    
    foo(1);

    这里对 bar 的调用将重用 foo 的帧,因为对 bar 的调用处于尾位置。因此,在 Error.stack 和开发者工具中(不考虑上述缓解措施),foo 帧将缺失。

    STC 通过使何时省略帧成为有意选择来绕开这个问题。开发者工具不需要向开发者显示每个帧,因为开发者已选择省略这些帧。或者,开发者工具可以使用与 PTC 相同的尽力而为的方法,以提供不同且可能更好的体验。

    Error.stack

    启用 PTC 后,JavaScript 异常将具有不同的 error.stack 信息,因为栈帧被省略。再次考虑上面的例子:

    function foo(n) {
      return bar(n*2);
    }
    
    function bar() {
      throw new Error();
    }
    
    try {
      foo(1);
    } catch(e) {
      print(e.stack);
    }
    
    /*
    没有 PTC 的输出
    Error
        at bar
        at foo
        at Global Code
    
    启用 PTC 的输出(注意 bar 似乎是从全局代码调用的)
    Error
        at bar
        at Global Code
    */

    这对依赖这些信息调试程序的开发者可能是个问题。然而,假设我们在未进行实际调试时无法恢复所有被省略的帧,那么担心 Web 分析和遥测应用程序会因浏览器对相同错误具有不同 callstack 而受到影响。这也可以通过更新所有收集这些栈的服务以期望省略帧来在一定程度上缓解。但短期内这并不令人十分安心。

    STC 通过使尾调用成为显式选择机制来避免这个问题。今天有效的代码继续工作,包括收集并发送到服务器的任何 error.stack 信息。

    关于如何在省略帧的情况下标准化 Error.stack 还有额外的复杂性。STC 为我们提供了明确的前进道路,但对于 PTC,这条道路不太明确。(参见 #6)

    跨 Realm 尾调用

    PTC 暴露了以前隐藏的函数对象的实现细节。特别是,Mozilla 表示由于他们的基于膜的安全模型,他们将无法跨 realm 边界实现 PTC。虽然已经有一些讨论(参见 #2)关于这些主张的优点,并且有一个开放的 PR 来解决当前规范中的这些问题,但 STC 提供了一个方便的机会来直接重新审视这些担忧,并允许实现通过在无法以线性资源完成尾调用的情况下发出警告来符合规范。

    开发者意图

    即使对上述问题有变通方法,开发者是否会以 STC 无法提供的方式从 PTC 中受益仍是一个问题。STC 具有编码用户意图的优点,从而明确算法何时显式依赖尾调用语义才能正确运行。这避免了重构危险等。

    STC 的另一个潜在好处是我们知道开发者的意图,并且可以给出警告或错误,例如,声称将要进行尾调用但并未将调用放在尾位置。

    提案

    本提案希望推进尾调用的语法选择机制。上面展示了一个可能的替代方案:return continuereturn continue 语句表明其后的内容是尾调用,因此不应增长栈。在大多数情况下,对非尾调用使用 return continue 可以是语法错误(例如,return continue 1 + foo() 会提前抛出)。return continue 还将对语法上正确但无法保证不增长栈的尾调用启用错误或警告,例如 FireFox 中的跨 realm 调用。此外,选择使用 return continue 的开发者可以管理省略栈帧的复杂性,因此对调试场景需要侵入性较小的解决方案(或者,也许不需要解决方案),并且现有代码的语义得以保持。

    语法替代方案

    本提案的语法正在 #1 中讨论。选项包括在函数级别、语句级别(类似 return)或表达式/调用点级别选择。目前桌上的稻草人包括:

    返回继续
    function factorial(n, acc = 1) {
      if (n === 1) {
        return acc;
      }
    
      return continue factorial(n - 1, acc * n)
    }
    
    let factorial = (n, acc = 1) => continue
      n == 1 ? acc
             : factorial(n - 1, acc * n);
    
    // 或者,如果 continue 是表达式形式:
    let factorial = (n, acc = 1) =>
      n == 1 ? acc
             : continue factorial(n - 1, acc * n);
    函数符号
    // # 符号,尽管它已被私有状态“占用”。
    #function() { /* 所有处于尾位置的调用都是尾调用 */ }
    
    // 注意很难决定如何可读地标记箭头函数。
    
    // 这可能是最可读的。
    () #=> expr
    // 这可能是最符合非箭头符号的。
    #() => expr
    
    // 类似 async 函数的 rec 符号
    rec function() { /* 同样 */ }
    rec () => expr
    !-return
    function () { !return expr }
    
    // 这种方法对箭头函数有点棘手。
    // 显然,我们不能将 ! 推入表达式,甚至
    // 函数级符号也很丑。
    
    // 由于 ! 已经有强烈的含义,很难将其读作
    // 尾递归函数,而不是表达式。
    !() => expr
    
    // 我们可以像上面 # 那样做,但读起来也很奇怪:
    () !=> expr