尾调用和尾递归

尾调用 #

尾调用是函数式编程中的一个重要概念。当函数执行的最后一个步骤(不一定是最后一行)是函数调用,就叫做尾调用。

// f() 和 g() 都在尾部调用
const a = x => x ? f() : g()
1
2

函数在调用的时候会在调用栈(call stack)中存有记录,每一条记录叫做一个调用帧(call frame),每调用一个函数,就向栈中 push 一条记录,函数执行结束后依次向外弹出,直到清空调用栈:

function foo () { console.log(111) }
function bar () { foo() }
function baz () { bar() }

baz()
1
2
3
4
5

img

造成这种结果是因为每个函数在尾调用另一个函数的时候,并没有**return **该调用,所以 js 引擎会认为你还没有执行完,会保留你的调用帧。

baz() 里面调用了 bar() 函数,并没有 return 该调用,所以在调用栈中保持自己的调用帧,同时 bar() 函数的调用帧在调用栈中生成,同理,bar() 函数又调用了 foo() 函数,最后执行到 foo() 函数的时候,没有再调用其他函数,这里没有显示声明 return,所以这里默认 return undefined

foo() 执行完了,销毁调用栈中自己的记录,依次销毁 bar()baz() 的调用帧,最后完成整个流程。

尾调用优化就是在将尾调用的函数的返回值,直接 return,作为函数返回值。如:

function foo () { console.log(111) }
function bar () { return foo() }
function baz () { return bar() }

baz()
1
2
3
4
5

**尾调用优化仅仅是我们在编码层面的优化,但是只是在严格模式下少数引擎下才有效。**现如今浏览器并未完全支持,原因有二:

  1. 在引擎层面消除递归是一个隐式行为,程序员意识不到;

  2. 堆栈信息丢失了,开发者难已调试,所以大多数引擎会保留 func.argumentsfunc 最近一次调用所包含的参数) 和 func.callerfunc 最近一次调用的函数):

    function foo(n) {   
        return bar(n*2)
    } 
    function bar() {   
        //查看调⽤帧   
        console.trace() 
    } 
    foo(1)
    
    1
    2
    3
    4
    5
    6
    7
    8

    尾递归 #

    函数调用自身,就是递归;函数尾调用自身,就是尾递归。

    以阶乘为例:

    function factorial(n) {
        if (n === 1) {
            return 1
        }
        return n * factorial(n - 1)
    }
    
    factorial(5)           // 120
    factorial(10)          // 3628800
    factorial(10000)      // Uncaught RangeError: Maximum call stack size exceeded
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    上面函数最后一步是 n * factorial(n - 1),并不是尾递归,n 过大就会爆栈,适用尾递归计算阶乘如下:

    'use strict';
    function factorial(num, total = 1) {
        if (num === 1) {
            return total
        }
        return factorial(num - 1, num * total)
    }
    
    factorial(5)               // 120
    factorial(10)              // 3628800
    factorial(10000)          // 分情况
    
    // 注意,虽然说这里启用了严格模式,但是经测试,在Chrome和Firefox下,还是会报栈溢出错误,并没有进行尾调用优化
    // Safari浏览器进行了尾调用优化,factorial(500000, 1)结果为Infinity,因为结果超出了JS可表示的数字范围
    // 如果在node v6版本下执行,需要加--harmony_tailcalls参数,node --harmony_tailcalls test.js
    // node最新版本已经移除了--harmony_tailcalls功能
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    我们在代码层面做的尾递归优化在大多数浏览器中并不支持,因此可采用以下方法优化:

    递归改成迭代 #

    递归改写成 while 或者 reduce

    function factorial(n) {
        let res = 1
        while(n > 1) {
            res *= n
            n--
        }
        return res
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    蹦床函数 #

    蹦床函数,其思想是使用延迟计算稍后执行递归调用,每次执行一个递归。

    // 蹦床函数
    function trampoline(f) {
        while (f && f instanceof Function) {
            f = f()
        }
        return f
    }
    
    // 返回一个函数
    function factorial(num, total = 1) {
        if (num === 1) {
            return total
        }
        return () => factorial(num - 1, num * total)
    }
    
    trampoline(factorial(10000)) // => Infinity
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17