Tail Call

一、什么是尾调用

尾调用(Tail Call)是函数式编程的一个重要概念。

一个函数里的最后一个动作是返回一个函数的调用结果,用简单的一句话描述就是“在函数的最后一步调用函数”。

function f(x){
    let y = x + 1;

    return g(y);
}

函数 f 的最后一步是调用函数 g,这就是尾调用。

以下几种情况,都不属于尾调用:

function f(x) {    
    return g(x) + 1;
}

function f(x) {    
    var ret = g(x);

    return (ret === 0) ? 1 : ret;
}

这是因为程序必须返回 g(x) 函数的调用以检查、更动 g(x) 的返回值。

二、尾调用优化

传统模式的编译器对于尾调用的处理方式就像处理其他普通函数调用一样,总会在调用时在内存中形成一个“调用记录”,又称“调用帧”(call frame),并将其推入调用栈顶部,用于表示该次函数调用,保存调用位置和内部变量等信息。

当一个函数调用发生时,计算机必须 “记住”调用函数的返回位置,才可以在调用结束时带着返回值回到该位置,返回位置一般存在调用栈上。在尾调用这种特殊情形中,计算机理论上可以不需要记住尾调用的位置而从被调用的函数直接带着返回值返回调用函数的返回位置(相当于直接连续返回两次)。

如果在函数 A 的内部调用函数 B,那么在 A 的调用记录上方,还会形成一条 B 的调用记录,等到 B 运行结束,将结果返回 A,B 的调用记录才会消失。如果函数 B 内部还调用函数 C,那在 B 的调用记录上方还有一个 C 的调用记录栈,以此类推,所有的调用记录,就形成一个调用栈。这有可能会出现函数调用栈过大甚至溢出的情况。

尾调用由于是函数的最后一步,所以当前函数帧上包含调用位置、局部变量等大部分的东西都不需要了,当前的函数帧经过适当的更动以后可以直接当作被尾调用的函数的帧使用,然后程序即可以跳到被尾调用的函数。

  1. 尾调用消除:

    在不改变当前调用栈(也不添加新的返回位置)的情况下跳到新函数的一种优化(完全不改变调用栈是不可能的,还是需要校正调用栈上形式参数与局部变量的信息)。

  2. 尾调用优化:

    只保留内层函数的调用记录,如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。

产生这种函数帧更动代码与 “jump”(而不是一般常规函数调用的代码)的过程称作尾调用消除(Tail Call Elimination)或尾调用优化(Tail Call Optimization, TCO)。尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高,也因此使得高效的结构编程成为现实。

function f() {
    let a = 1, b = 2;
   
    return g(a+b);
}

f(); // 等价于 function f(){ return g(3); }   // 等价于 g(3); 

上面代码中,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 a 和 b 的值、函数 f 的调用位置等信息。但由于调用 g 后,函数 f 就结束了,所以执行到最后一步,完全可以删除函数 f 的调用记录,只保留 g(3) 的调用记录。

然而,对于 C++ 等语言来说,在函数最后 return g(x); 并不一定是尾递归,因为在返回之前很可能涉及到对象的析构函数,使得 g(x) 不是最后执行的那个。这可以通过返回值优化来解决。

三、尾递归

如果尾调用自身,则称为尾递归。

递归非常耗费内存,因为需要同时保存成百上千条调用记录,很容易出现“栈溢出”的错误。但对于尾递归而言,由于只存在一个调用记录,所以不会发生“栈溢出”的错误。

int factorial(n) {
    if(n == 1) {
         return 1;
    }
    return n * factorial(n-1);
}

factorial(5); // 120 

上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,空间复杂度 O(n),当 n 足够大时,则会发生调用栈溢出。如果改写成尾递归,则只保留一个调用记录,空间复杂度 O(1)。

int factorial(n, total) { 
    if(n == 1) {
         return total;
    }
    return factorial(n-1, n*total);
} 

factorial(5,1);

由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。在 ES6 中,严格模式下,宣称支持尾调用优化这个新特性。

但目前 V8 引擎并没有优化尾递归,V8 团队认为做尾递归优化存在一系列问题,因此倾向于支持用显示的语法来实现,而非做优化。在 node 环境和浏览器环境都做了测试,当尾递归函数中传入 n 过大时,同样会出现栈溢出的情况,不管是否开启严格模式,所以似乎尾递归优化并没有起作用。

四、递归函数的改写

尾递归的实现往往需要改写递归函数,确保最后一步只调用自身。就是把所有用到的内部变量改写成函数的参数。

比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点是使函数看起来不太直观,为什么计算 5 的阶乘,需要传入两个参数 5 和 1。两个方法可以解决这个问题。

  1. 在尾递归函数之外,再提供一个正常形式的函数

    int tailFactorial(n, total) {
        if(n == 1) {
    return total;
    }
    return tailFactorial(n - 1, n * total);
    }
    int factorial(n) {
    return tailFactorial(n, 1);
    }
    factorial(5); // 120

    上面代码通过一个正常形式的阶乘函数 factorial,调用尾递归函数 tailFactorial,看起来就正常多了。

    函数式编程中有一个概念叫做柯里化(currying),简单来说就是将多参数的函数转换为单参数函数的形式,这里也可以使用柯里化。

    function currying(fn, n) {
        return function (m) {
    return fn.call(this, m, n);
    }
    }
    function tailFactorial(n, total) {
    if(n===1) {
    return total;
    }
    return tailFactorial(n - 1, n * total);
    }
    const factorial = currying(tailFactorial, 1)
    factorial(5) // 120

    上面代码通过柯里化,将尾递归函数 tailFactorial 变为只接受一个参数的 factorial 函数。

  2. 使用 ES6 的默认值特性。

    function factorial(n, total=1) {
        if(n===1) {
    return total;
    }
    return factorial(n-1, n * total);
    }
    factorial(5);

    上面代码中参数 total 有默认值 1,调用时可以不提供这个值。

总结,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作的命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言,我们需要知道循环可以用递归来代替,而一旦使用递归,就最好使用尾递归。

五、文章

百度百科
尾调用优化(Tail Call Optimization)
深入理解JavaScript中的尾调用(Tail Call)

posted @ 2020-03-15 14:04  和风细羽  阅读(536)  评论(0编辑  收藏  举报