• JS—尾调用优化、尾递归、递归函数的改写


    1.尾调用(Tail Call)

    尾调用是函数式编程的一个重要概念,本身非常检点。就是:某个函数的最后一步是调用另一个函数

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

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

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

     1     // 情况一
     2     function f(x) {
     3         let y = g(x)
     4         return y
     5     }
     6 
     7     // 情况二
     8     function f(x) {
     9         return g(x) + 1
    10     }
    11 
    12     // 情况三
    13     function f(x) {
    14         g(x)
    15     }

    上面代码中:

    • 情况一:调用函数g之后,还有赋值操作,所以不属于尾调用
    • 情况二:调用后还有操作
    • 情况三等同于:
      • 1 function f(x) {
        2   if (x > 0) {
        3     return m(x)
        4   }
        5   return n(x);
        6 }

    尾调用不一定出现在函数尾部,只要是最后一步操作即可:

    1 function f(x) {
    2   if (x > 0) {
    3     return m(x)
    4   }
    5   return n(x);
    6 }

    上面代码中:函数m和n都属于尾调用,因为它们都是函数f的最后一步操作

    2.尾调用优化

    尾调用之所以与其他调用不同,就在于它的特殊的调用位置。

    (1)正常函数调用:

      函数调用会在内存形成一个“调用记录”,又称“调用帧(call frame)”,保存调用位置和内部变量等信息,调用帧可以理解为栈中的一格,如下所示:

       

      如一个函数:

    • function a(){
        b()
      }
    • 上面函数a执行的过程如下:
      • 执行到a()时,a被推入调用帧底部
      • 执行到a()中的b()方法时,b被压入a上方
      • b()执行结束,b()从调用栈中移除
      • a()执行结束,a()从调用栈移除,调用栈清空
      • 以此类推,多个执行语句先后入栈出栈

    (2)函数尾调用

      尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。

     1 function f(){
     2     let n = 1
     3     let m = 2
     4     return g(n + m)
     5 }
     6 f()
     7 
     8 // 等同于
     9 function f(){
    10     return g(3)
    11 }
    12 f()
    13 
    14 // 等同于
    15 g(3) 

    上面代码,如果函数g不是尾调用,函数f就需要保存内部变量m和n的值,g的调用位置等信息。但是由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除f(x)的调用帧,只保留g(3)的调用帧。这就叫尾调用优化(Tail call optimization),即只保留内层函数的调用帧,如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化“的意义。

    注意:只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则无法进行”尾调用优化“

    1 function addOne(a){
    2   var one = 1;
    3   function inner(b){
    4     return b + one;
    5   }
    6   return inner(a);
    7 }

    因为内层函数inner用到了外层函数addOne的内部变量one。

    注意:

    • 目前自会有Safari浏览器支持尾调用优化,Chrome和Fiefox都不支持
    • ES6的尾调用优化只在严格模式下开启,正常模式下是无效的(因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈)
      • func.arguments:返回调用时函数的参数
      • func.caller:返回调用当前函数的那个函数
      • 尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式下禁用了这两个变量,所以尾调用优化仅在严格模式下生效

    3.尾递归

    函数调用自身,称为递归,如果尾调用自身,就称为尾递归

    递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易法伤”栈溢出“(stack overflow)。但是对于尾递归来说,由于只存在一个调用帧,所以永远不会发生栈溢出错误。

    (1)示例:计算阶乘

    非尾调用:

    1 function factorial(n) {
    2   if (n === 1) return 1;
    3   return n * factorial(n -1)
    4 }
    5 factorial(5)

    上面代码,计算n的阶乘,最多需要保存n个调用记录,复杂度O(n)

    尾调用:

    1 function factorial(n,total) {
    2   if (n === 1) return total;
    3   return factorial(n - 1, n * total)
    4 }
    5 
    6 factorial(5,1)  // 120

    改成尾递归,只保留一个调用记录,复杂度O(1)

    (2)示例:计算斐波那契数列

    1 function Fibonacci (n) {
    2   if ( n <= 1 ) {return 1};
    3 
    4   return Fibonacci(n - 1) + Fibonacci(n - 2);
    5 }
    6 
    7 Fibonacci(10) // 89
    8 Fibonacci(100) // 超时
    9 Fibonacci(500) // 超时

    尾递归优化:

    1 function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
    2   if( n <= 1 ) {return ac2};
    3 
    4   return Fibonacci2 (n - 1, ac2, ac1 + ac2);
    5 }
    6 
    7 Fibonacci2(100) // 573147844013817200000
    8 Fibonacci2(1000) // 7.0330367711422765e+208
    9 Fibonacci2(10000) // Infinity

    由此可见,”尾调用优化“对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有ECMAScript的实现,都必须部署"尾调用优化"。这就是说,ES6中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。

    (3)递归函数的改写

    尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。如何做到这一点:

    ①把所有用到的内部变量改写成函数的参数(缺点:不太直观)

      如上面的阶乘函数factorial需要用到一个中间变量total,那就把这个中间比那辆改写成函数的参数。  

    有两种方法可以解决这个问题:

    方法一:再尾递归函数之外,再提供一个正常形式的函数

     1 function tailFactorial(n, total) {
     2   if (n === 1) return total;
     3   return tailFactorial(n - 1, n * total);
     4 }
     5 
     6 function factorial(n) {
     7   return tailFactorial(n, 1);
     8 }
     9 
    10 factorial(5) // 120

    上面代码通过一个正常形式的阶乘函数,调用尾递归函数。看起来会正常一点。

    函数柯里化:是函数式编程中的一个概念,意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化:

     1 function currying(fn,n){
     2     return function(m){
     3         return fn.call(this,m,n)
     4     }
     5 }
     6 
     7 function tailFactorial(n,total){
     8     if(n === 1) return total
     9     return tailFactorial(n -1, n * total)
    10 }
    11 
    12 const factorial = currying(tailFactorial, 1)
    13 
    14 factorial(5)

    方法二:ES6的函数默认值

     这种方法比较简单。

    1 function factorial(n, total = 1) {
    2   if (n === 1) return total;
    3   return factorial(n - 1, n * total);
    4 }
    5 
    6 factorial(5) // 120

     参数total有默认值1,所以调用时不用提供这个值

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

    (4)尾递归优化的实现

    尾递归优化只在严格模式下生效,正常情况下,或者那些不支持该功能的环境中,可以自己实现尾递归优化。

    原理:

      尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。如何实现减少调用栈?就是:使用“循环”换掉“递归”

    一个正常的递归函数:

     1 function sum(x, y) {
     2   if (y > 0) {
     3     return sum(x + 1, y - 1);
     4   } else {
     5     return x;
     6   }
     7 }
     8 
     9 sum(1, 100000)
    10 // Uncaught RangeError: Maximum call stack size exceeded(…)

    上面代码中,sum是一个递归函数,参数x是需要累加的值,参数y控制递归次数。一旦指定sum递归 100000 次,就会报错,提示超出调用栈的最大次数。

    蹦床函数(trampoline)可以将递归执行转为循环执行。

    1 function trampoline(f) {
    2   while (f && f instanceof Function) {
    3     f = f();
    4   }
    5   return f;
    6 }

    上面就是蹦床函数的一个实现,它接受一个函数f作为参数。只要f执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。

     然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。

    1 function sum(x, y) {
    2   if (y > 0) {
    3     return sum.bind(null, x + 1, y - 1);
    4   } else {
    5     return x;
    6   }
    7 }

     上面代码中,sum函数的每次执行,都会返回自身的另一个版本。

     现在,使用蹦床函数执行sum,就不会发生调用栈溢出。

    trampoline(sum(1, 100000))
    // 100001

     蹦床函数并不是真正的尾递归优化,下面的实现才是。

     1 function tco(f) {
     2   var value;
     3   var active = false;
     4   var accumulated = [];
     5 
     6   return function accumulator() {
     7     accumulated.push(arguments);
     8     if (!active) {
     9       active = true;
    10       while (accumulated.length) {
    11         value = f.apply(this, accumulated.shift());
    12       }
    13       active = false;
    14       return value;
    15     }
    16   };
    17 }
    18 
    19 var sum = tco(function(x, y) {
    20   if (y > 0) {
    21     return sum(x + 1, y - 1)
    22   }
    23   else {
    24     return x
    25   }
    26 });
    27 
    28 sum(1, 100000)
    29 // 100001

     上面代码中,tco函数是尾递归优化的实现,它的奥妙就在于状态变量active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum返回的都是undefined,所以就避免了递归执行;而accumulated数组存放每一轮sum执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。

  • 相关阅读:
    Spring基础——小的知识点
    Spring总结—— IOC 和 Bean 的总结
    UML 类图
    Spring基础—— 泛型依赖注入
    Spring重点—— IOC 容器中 Bean 的生命周期
    Spring基础—— SpEL
    《大话设计模式》学习笔记13:适配器模式
    《大话设计模式》学习笔记12:状态模式
    《大话设计模式》学习笔记11:抽象工厂模式
    《大话设计模式》学习笔记10:观察者模式
  • 原文地址:https://www.cnblogs.com/codexlx/p/14338479.html
Copyright © 2020-2023  润新知