尾调用优化
最近遇到一个堆栈溢出的问题,分析后发现可收敛为递归边界问题。结合“红宝书”中相关内容和ES6
规范中的一些优化机制,整理记录如下。
前言
程序运行时,计算机会为应用程序分配一定的内存空间。应用程序会自行分配所获得的内存空间,一部分用于记录程序中调用的各个函数的运行情况,称为函数的调用栈(call stack
)。函数的调用会在调用栈的最上层添加一个新的栈帧(stack frame
),这个过程被称为入栈/压栈(push
)。当函数的调用层数非常多时,调用栈会消耗掉大量内存,甚至会导致爆栈或溢出、导致程序卡顿或崩溃。
递归
通俗的说,递归是指方法自己调用自己。递归可以将一个复杂问题转化为一个与原问题相似且规模较小的问题来求解,如常见的深拷贝、斐波那契数列、阶乘、求和等。
优点:代码简洁,符合一般思维习惯,易于理解。
缺点:重复计算,耗内存,效率低,调用栈溢出等。
简单示例如下。可以发现,当n
达到一定数值时,程序基本处于假死状态,需要等待很长时间才能返回结果。
// 递归实现 斐波那契数列
function fib(n) {
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
当然我们可以通过一些方法对递归进行优化,如:
-
改为非递归实现(大部门递归都可以使用循环实现)
function fib(n) { let arr = [0, 1, 1] for(let i=3; i<=n; i++) { arr[i] = arr[i-1] + arr[i-2] } return arr[n] }
-
使用缓存
function fib(n) { let cache = [0, 1, 1] function _fib(n) { if(cache[n]){ return cache[n] } cache[n] = _fib(n-1) + _fib(n-2) return cache[n] } return _fib(n) }
可以看到,上面两种方式,要么代码不够简洁,要么不能直观看出实现的是什么功能。于是我们可以采用尾递归来改写:
function fib(n, n1 = 1, n2 = 1) {
if (n <= 2) {
return n2;
}
return fib(n - 1, n2, n1 + n2);
}
尾递归(tail recursion)
尾递归是递归的一个特例:函数在尾位置直接调用自身。
以阶乘函数为例
一般递归:每次递归调用,都会产生新的调用栈帧,用于保存当前函数上下文的信息(当函数返回时,会弹出这个栈帧)。随着递归的复杂度和深度的增加,栈帧数会以指数级增长,容易导致程序运行缓慢甚至爆栈。
尾递归:将递归方法中需要的状态数据通过参数的形式传给下一次调用,过程中只有一个栈帧被不断更新。当函数返回时,直接返回结果,无需其它操作,可以节省存储空间,提高运行效率。
常规递归与尾递归的主要区别:函数的调用、返回顺序和对栈空间利用方式不同。
尾调用(tail call)
尾递归是尾调用的一个特例。尾调用:外部函数的返回值是一个内部函数的返回值。
// 尾调用一般形式
function outerFunction() {
return innerFunction();
}
ECMAScript 6
规范新增了一项内存管理优化机制,让 JavaScript
引擎在满足条件时可以重用栈帧。也就是说,形式正确的尾调用(Proper Tail Call
,PTC
)是可以被优化的,即尾调用优化(Tail Call Optimization
,TCO
)。
在传统的程序调用过程中,计算机必须记住调用函数的返回位置,才能在调用结束后返回该位置继续执行后续命令,该位置信息(即下一条指令的内存地址)一般被存放在调用栈上。
不同的是,在尾调用中,由于调用下级函数后,其所对应的上级函数也就结束了。所以执行到最后一步,不需要记住尾调用的返回位置,而是带着返回值直接从被调用函数直接跳转到调用函数的返回位置,减少了调用帧的存取次数(即可以用内层函数的栈帧覆盖掉外层函数的栈帧,而不是在外层函数栈帧下再新开一个)。
下面以官方展示的过程为例,说明示例程序的执行过程
-
ES6
优化之前(1) 执行到
outerFunction
函数体,第一个栈帧被推到栈上。(2) 执行
outerFunction
函数体,到return
语句。计算返回值必须先计算innerFunction
。(3) 执行到
innerFunction
函数体,第二个栈帧被推到栈上。(4) 执行
innerFunction
函数体,计算其返回值。(5) 将返回值传回
outerFunction
,然后outerFunction
再返回值。(6) 将栈帧弹出栈外。
-
ES6
优化之后(1) 执行到
outerFunction
函数体,第一个栈帧被推到栈上。(2) 执行
outerFunction
函数体,到达return
语句。为求值返回语句,必须先求值innerFunction
。(3) 引擎发现把第一个栈帧弹出栈外也没问题,因为
innerFunction
的返回值也是outerFunction
的返回值。(4) 弹出
outerFunction
的栈帧。(5) 执行到
innerFunction
函数体,栈帧被推到栈上。(6) 执行
innerFunction
函数体,计算其返回值。(7) 将
innerFunction
的栈帧弹出栈外。
很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是ES6
尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。
尾调用优化的条件:
- 代码在严格模式下执行
- 外部函数的返回值是对尾调用函数的调用
- 尾调用函数返回后不需要执行额外的逻辑
- 尾调用函数不是引用外部函数作用域中自由变量的闭包
为什么要求严格模式:
在非严格模式下函数调用中允许使用 f.arguments
和 f.caller
,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。
那么我们可以对斐波那契数列进行如下改造:
"use strict";
// 外层函数
function fib(n) {
return fibImpl(0, 1, n);
}
// 内层函数
function fibImpl(a, b, n) {
if (n === 0) {
return a;
}
return fibImpl(b, a + b, n - 1);
}
运行程序,发现到达到某个数量级还是会堆栈溢出。
不是说TCO
后只有一个栈帧吗,为什么还是会爆栈呢?
兼容性
调用栈的深度限制不由JS/ES
规范控制。一般根据浏览器或设备(版本)不同而有所差异。考虑到递归编程逻辑复杂时,调用栈很容易达到成千上万甚至更多,容易导致爆栈/内存溢出。
因此JavaScript
引擎设置了一个限制来防止这种操作引起的浏览器或设备内存耗尽而崩溃,所以当达到这个限制时,我们会看到一个报错:RangeError: Maximum call stack size exceeded
。
目前浏览器兼容性如下(一般认为只有Safari
支持尾调用优化,经测试后确实如此,执行亿次递归也能顺畅执行)。
我们可以在V8的官方资料中找到如下解释
Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.
由于一些原因,这个优化方案并没有被所有浏览器厂商接受
-
优化后实际上会丢失堆栈信息,开发调试过程比较困难
-
开发者有可能写了死循环,但优化后一直运行不报错,会导致更严重的问题
-
这个方案实际上是“隐式优化”,可能会带来一些副作用。从开发角度来说,更希望有“显式优化”,如提案中的一种方式:基于语法的尾调用(
Syntactic Tail Calls
),通过continue
来表明应用此项优化function factorial(n,acc){ if(n==1){ return acc } return continue factorial(n-1,acc*n) }