深入理解JavaScript中的尾调用(Tail Call)(2)

之所以需要优化,是因为调用栈过多,那么只要避免了函数内部的递归调用就可以解决掉这个问题,其中一个方法是用循环代替递归。还是以 Fibonacci 数列举例:

function fibonacciLoop(n, a = 0, b = 1) { while (n--) { [a, b] = [b, a + b] } return a }

这样,不存在函数的多次调用,将递归转变为循环,避免了调用栈的无限增加。

蹦床函数

另一个优化方法是借助一个蹦床函数的帮助,它的原理是接受一个函数作为参数,在蹦床函数内部执行函数,如果函数的返回是也是一个函数,就继续执行。

function trampoline(f) { while (f && f instanceof Function) { f = f() } return f }

可以看到,这里也没有在函数内部调用函数,而是在循环中重复调用同一个函数,这也避免了增加调用栈长度,下面要做的是将原来的 Fibonacci 函数改写为每次返回另一个函数的版本:

function fibonacciFunc(n, a = 0, b = 1) { if (n > 0) { [a, b] = [b, a + b] return fibonacciFunc.bind(null, n - 1, a, b) } else { return a } } trampoline(fibonacciFunc(5)) // return 5

实际的尾递归优化

实际上,真正的尾递归优化并非像上面一样,上面的两种方法实际上都改写了尾递归函数本身,而真正的尾递归优化应该是非入侵式的,下面是尾递归优化的一种实现:

function tailCallOptimize(f) { let value, active = false const accumulated = [] return function accumulator() { accumulated.push(arguments) if (!active) { active = true while (accumulated.length) { value = f.apply(this, accumulated.shift()) } active = false return value } } }

然后将原来的 fibonacciTail 函数传入 tailCallOptimize 函数,得到一个新函数,这个新函数的执行过程就是经过尾递归优化的了:

const fibonacciTail = tailCallOptimize(function(n, a = 0, b = 1) { if (n === 0) return a return fibonacciTail(n - 1, b, a + b) }) fibonacciTail(5) // return 5

下面解释一下这种优化方式的原理。

1. 首先通过闭包,在 tailCallOptimize 的作用域中保存唯一的 active 和 accumulated,其中 active 指示尾递归优化过程是否开始,accumulated 用来存放每次递归调用的参数,push 方法将参数入列,shift 方法将参数出列,保证先进先出顺序执行。

2. 经过 tailCallOptimize 包装后返回的是一个新函数 accumulator,执行 fibonacciTail 时实际执行的是这个函数,第一次执行时,现将 arguments0 推入队列,active 会被标记为 true,然后进入 while 循环,取出 arguments0。在 while 循环的执行中,会将参数类数组 arguments1 推入 accumulated 队列,然后直接返回 undefined,不会递归调用增加调用栈。

3. 随后 while 循环会发现 accumulated 中又多了一个 arguments1,然后再将 arguments2 推入队列。这样,在 while 循环中对 accumulated 的操作就是放进去一个、拿出来一个、再放进去一个、再拿出来一个,以此类推。

4. 最后一次 while 循环返回的就是尾递归的结果了。

问题

实际上,现在的尾递归优化在引擎实现层面上还是有问题的。拿 V8 引擎来说,尾递归优化虽然已经实现了,但默认是不开启的,V8 团队还是更倾向于用显式的语法来优化。原因是在他们看来,尾调用优化仍然存在一些问题,主要有两点:

难以辨别

在引擎层面消除尾递归是一个隐式行为,函数是不是符合尾调用的要求,可能程序员在写代码的时候不会意识到,另外由于开启了尾调用优化,一旦出现了死循环尾递归,又不会引发溢出,难以辨别。下面介绍一些识别尾调用要注意的地方:

首先,调用函数的方式不重要,以下几种调用方式只要出现在尾调用位置上都可以被优化: + 普通调用:func(...) + 作为方法调用:obj.method(...) + 使用 call 或 apply 调用:func.call(..) 或 func.apply(...)

表达式中的尾调用

ES6 的箭头函数可以使用一个表达式作为自己的函数体,函数返回值就是这个表达式的返回值,在表达式中,以下几种情况可能包含尾调用:

三元运算符(? :)

const a = x => x ? f() : g()

在这里,f 和 g 函数都在尾调用位置上。为了便于理解,可以将函数改写一下:

const a = x => { if (x) { return f() } else { return g() } }

可见 f 和 g 的返回值都是直接被返回的,符合尾调用的定义。

逻辑运算符(|| 与 &&)

首先是 || 运算符:

const a = () => f() || g()

这里 f 函数不在尾递归位置上,而 g 函数在尾递归位置上,为什么,把函数改写一下就清楚了:

const a = () => { const result = f() if (result) { return result } else { return g() } }

|| 运算符的结果依赖于 f 函数的返回值,而不是直接返回 f 的返回值,直接返回的只有 g 函数的返回值。&& 运算符的情况也同理:

const a = () => f() && g()

将函数改写为:

const a = () => { const result = f() if (!result) { return result } else { return g() } }

说明 f 函数也不在尾递归位置上,而 g 函数在尾递归位置上。

逗号运算符(,)

const a = () => (f(), g())

将函数改写一下:

const a = () => { f() return g() }

可见,在尾递归位置上的仍然只有一个 g 函数。

语句中的尾调用

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wwgffj.html