【问题标题】:What are the boundaries of recursion?递归的边界是什么?
【发布时间】:2017-03-22 19:05:32
【问题描述】:

给定

let doAsynchronousStuff = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("abcdefg"[Math.floor(Math.random() * 7)])
    }, Math.PI * 1 + Math.random())
  })
  .then(data => console.log(data))
  .then(doAsynchronousStuff)
}

该模式是

的实现吗
  • 递归;
  • 尾调用优化;
  • 迭代;
  • 碰巧引用自身的非终止过程;

或者;上面未列出的其他常见模式?

从可靠和/或官方来源寻找答案。

【问题讨论】:

  • TCO 是运行时/编译器的属性,而不是代码。所以 TCO 项目根本不适用(直到它被重新措辞)。
  • 是1,3,4中的每一个。
  • 嗯,它肯定是递归,因为它显然是在调用自己。 en.wikipedia.org/wiki/…
  • 我不明白“调用堆栈必须增长”或“它必须是同步的”是递归函数的要求。
  • 概念上它当然应该是递归的。 “递归”的计算机科学定义是否与常识定义不同我不知道。我认为实现细节(例如TCO)不应该有任何影响,否则代码的“递归性”会根据执行代码的引擎而改变。但这是我个人的看法。

标签: javascript recursion language-agnostic computer-science


【解决方案1】:

我重写了代码,删除了所有不相关的内容,并使用了一种我认为在这种情况下更具可读性和吸引力的风格。

function doAsynchronousStuff()
{
   return new Promise((resolve, reject) => 
   {
      setTimeout(() => {resolve("test")}, 0)
   })
  .then(console.log)
  .then(doAsynchronousStuff);
}

我们应该记住JS有an event loop来分析执行流程,特别是

  • setTimeout 发布要在事件循环的下一个1 周期执行的参数函数。
  • then 发布要在事件循环的下一个周期执行的参数函数。

事件循环的存在很重要,因为函数在重新进入循环之前将消息发布到它run-to-completion

还需要对 Promise 有很好的了解,例如知道 then 返回一个新的 Promise。

doAsynchronousStuff被执行时,Promise对象被构造并且它的参数函数被立即调用。

Execution stack                      Event loop messages

doAsynchronousStuff
Promise constructor
Closure (resolve, reject)

这反过来又调用setTimeout 发布事件并返回。

Execution stack                      Event loop messages

doAsynchronousStuff                  resolve("test")
Promise constructor
Closure (resolve, reject)
setTimeout

执行回退到doAsynchronousStuff,它为Promise 对象设置了延续,但当然不执行它们。所以最后doAsynchronousStuff 返回并且我们有一个run-to-completion的情况。

Execution stack                      Event loop messages

                                     resolve("test")

事件循环执行resolve("test")(或更好的包含它的闭包),将承诺设置为已解决,并安排其在下一个循环中继续

 Execution stack                      Event loop messages

 resolve                              console.log

resolve 结束,我们又遇到了 run-to-completion 的情况。

 Execution stack                      Event loop messages

                                      console.log

console.log 被执行。实际上是执行了一个调用console.log的函数,这个函数是在调用then时由promise对象设置的。
console.log 返回时,它的promise 就解决了,doAsynchronousStuff 被发布到事件循环中。

 Execution stack                      Event loop messages

 resolve                              doAsynchronousStuff

resolve 结束时,我们有一个run-to-completiondoAsynchronousStuff 再次执行。


现在我不会在数学意义上或在 CS 理论意义上对您问题列表中的术语进行过多挖掘,这样做不会有任何实际好处,因为我不认为这是一个理论问题。
相反,我将把自己限制在编程的角度。

当第二个 doAsynchronousStuff 实例被调用时,第一个实例早已不复存在(它运行到完成)。
基本上情况就相当于这样做了

let f = () => { console.log('hi!'); setTimeout(f, 0); }

不会将此函数称为递归,因为递归意味着将问题分解为更小的自动相似部分。
递归函数不必直接调用自身或不必“使堆栈增长”,但它必须根据自身定义

如果是这样的话

let f = () => { f(); }

我将其称为(严重)递归。那么它是什么?
我想说一个函数在编程意义上是递归的,如果你不能在不完成它对自身的所有调用的情况下完成它。
第一个示例可以在不等待f 的后续调用完成的情况下完成,而第二个则不能。
在我看来,我将f 的第一个版本称为已安排

关于尾调用优化,与此无关。
TCO transform a particular kind of recursion into a loop,这是编译器优化,不是代码的属性。
tail-call 是代码的一个属性,但该代码不是 tail-call,因为它一开始就不是递归的。

在编程意义上也不是迭代(而在理论意义上它是),因为迭代是通过特定的构造实现的(如for、@987654356 @, goto)。
由于迭代、递归和调度重叠,这里的边界很模糊。

最后肯定是一个碰巧引用自身的非终止过程的情况。


1我们在这里做一个简化,它不一定是非常下一个循环,它只是一个未来的循环。

【讨论】:

  • “我不会将此函数称为递归” 这是否意味着“不,模式不是递归的;不是递归的”?正如@zerkms stackoverflow.com/questions/40499044/… 所指出的那样,当一个函数不是由它自己调用而是由它调用的另一个函数(直接或间接)调用时,就会发生间接递归。例如,如果 f 调用 f,那就是直接递归,但是如果 f 调用了调用 f 的 g,那么这就是 f 的间接递归。” 应该编辑维基百科吗?或者使用 NTPTHTRTI 作为模式的描述?
  • @guest271314 我的意思是doAsynchronousStuff 不会调用自己(也不会直接或间接地),它只是进行设置,以便稍后(由 JS 引擎)再次调用它。这与打电话不同。无论如何,这就是 解释代码的方式。如果您正在寻找代码模式的分类法,您可能会感到失望。递归/迭代的正式定义涉及更多,需要转向逻辑。我从一个programming pov 中解释了答案,其中recursion 的意思是普通的、非正式的、意义上的。顺便说一句,我称这种模式为“调度”。
  • 您能否在答案中包含带有描述的递归模式,以便比较和消除歧义?
  • @guest271314 你的意思是像老好的斐波那契系列吗? fib(n) = fib(n-1) + fib(n-2); f(0) = f(1) = 1 实现为 function f(n){ return n <= 1 ? 1 : f(n-1) + f(n-2); }。这可以变成TC recursion
【解决方案2】:

以上都不是。有问题的代码不是递归的,不是完全迭代的(尽管从英语的角度来看它是迭代的,从我们通常在编程中称为迭代的角度来看它不是,请注意,从英语递归的角度来看,它是迭代的,但我们并不是说它是在编程中的),因为它不是递归的,因此短语“tail-call-optimized”不适用并且它不是非终止的因为函数以 return 结束。

它是一个函数,它安排一系列函数在以后执行,其中一个是它自己。

调度一种设计模式。 调度最古老的例子之一是操作系统所做的进程调度。下一个最古老的例子之一是 cron。

调度的工作原理是运行时环境(Linux 内核、Windows 内核、cron 进程、javascript)保存一个“数据库”(它可能像链表一样简单,也可能像 SQL 那样高级或低技术作为文本文件)对它应该运行的代码和触发它们的条件的某种引用(查看 AWS Lambda 服务以获得该想法的非常高级的实现)并定期以某种方式检查以查看如果条件满足则执行代码。

对于操作系统内核,一组条件包括某种公平算法,以确保所有程序都能使用 CPU。对于 cron,条件是 crontab 中的时间规范。对于 javascript,条件是注册回调的事件(对于 setTimeout,它是超时事件)。

传统上,如果您要为此编写自己的软件,您会将其编写为简单的状态机。以下是实现与上面示例相同的类似 C 的伪代码

int tick = 0;

// Assume that there is an API for registering 1ms periodic interrupt
interrupt_1ms periodic () {
    tick++;
}

int main (void) {
    int timeout = PI + rand(); // a fairly silly way to randomly select 3 or 4 ms
    char state = 0;
    char result = nul;
    char* data = "abcdefg";

    while (1) {
        if (tick >= timeout && state == 0) {
            state = 1;
            tick = 0;
            timeout = PI + rand();
        }

        switch (state) {
            case 1:
                result = data[floor(rand() * 7)];
                state = 2;
                break;
            case 2:
                printf("%c", result);
                state = 3;
                break;
            case 3:
                state = 0; // reschedule the doAsynchronousStuff
                break;
        }
    }
}

这是一种传统的方式。 javascript 所做的并不完全相同,但在概念上相似。它仍然使用永久循环作为事件循环的核心,但它不会连续运行(这会浪费 CPU 时间、加热 CPU 并耗尽电池)。相反,它阻止调用异步 I/O API 之一(选择、轮询、epoll、kqueue 等 - libuv 将在编译时选择)并将控制权传递给操作系统,这将使进程进入休眠状态,直到其中一个注册的 I/ O 事件被触发。

现在,注意您的代码:

let doAsynchronousStuff = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("abcdefg"[Math.floor(Math.random() * 7)])
    }, Math.PI * 1 + Math.random())
  })
  .then(data => console.log(data))
  .then(doAsynchronousStuff)
}

我不了解你,但对我来说,它比传统的状态机更容易推理。好的,对于这个非常简单的例子,上面的 C 伪代码很容易理解,但是考虑一个具有数十或数百个复杂事件的真实 node.js 或 jQuery 应用程序(在传统的 jQuery 应用程序的情况下,这些事件甚至可能会自行取消调度或安排更多的事件处理程序)。随着您必须处理的事件数量的增加,javascript 在其语法中为您提供的内容变得更具可读性,即使对于 one 事件,不熟悉匿名函数和异步代码的初学者可能更喜欢我的伪 C例子。

即使是老式的非承诺回调也比伪 C 代码更具可读性:

function doAsynchronousStuff () {
    setTimeout(function () {
      console.log("abcdefg"[Math.floor(Math.random() * 7)]);
      doAsynchronousStuff();
    }, Math.PI * 1 + Math.random());
}

所以语法可能是新的(好吧,不是那么新,Lispers 在 70 年代一直在做这种事情),但这个想法是旧的。由于语法,核心概念可能无法识别,因此不要被语法分心。它只是安排使用计时器运行某些东西。我们简单地将重复调度称为“重复调度”(Google 日历和 Apple 日历都称它们为“重复”)。

【讨论】:

  • 您能否在答案中包含带有描述的递归模式,以便比较和消除歧义?
猜你喜欢
  • 2016-11-01
  • 2014-03-18
  • 1970-01-01
  • 2012-03-22
  • 2019-04-18
  • 1970-01-01
  • 2015-10-08
  • 2013-08-02
  • 1970-01-01
相关资源
最近更新 更多