阮一峰ES6入门读书笔记(四):函数

  • Post author:
  • Post category:其他




阮一峰ES6入门读书笔记(四):函数



rest 参数

ES6 引入了 rest 参数(形式为 …变量名),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

function add(...values) {
  let sum = 0;

  for (var val of values) {
    sum += val;
  }

  return sum;
}

add(2, 5, 3) // 10

下面是一个 rest 参数代替 arguments 变量的例子。

// arguments变量的写法
function sortNumbers() {
  return Array.from(arguments).sort();
}

// rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();



箭头函数



1.this指向

对于普通函数来说,内部的 this 指向函数运行时所在的对象。箭头函数没有自己的 this 对象,内部的 this 就是定义时上层作用域中的 this。并且指向不可(不可通过call,apply,bind等方法)改变。

箭头函数实际上可以让

this

指向固定化,绑定

this

使得它不再可变,这种特性很有利于封装回调函数。

总之,箭头函数根本没有自己的

this

,导致内部的

this

就是外层代码块的

this

。正是因为它没有

this

,所以也就不能用作构造函数。

下面是 Babel 转箭头函数产生的 ES5 代码,就能清楚地说明

this

的指向。

// ES6
function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

// ES5
function foo() {
  var _this = this;

  setTimeout(function () {
    console.log('id:', _this.id);
  }, 100);
}

除了

this

,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:

arguments



super



new.target



什么时候不应该使用箭头函数?

  1. 定义对象的方法

    这样在全局下定义的对象会默认指向全局,还不是你的对象

  2. 动态需要 this 的时候

    比如 dom 二级事件监听的时候,

    var button = document.getElementById('press');
    button.addEventListener('click', () => {
      this.classList.toggle('on');
    });
    

    上面代码运行时,点击按钮会报错,因为

    button

    的监听函数是一个箭头函数,导致里面的

    this

    就是全局对象。如果改成普通函数,

    this

    就会动态指向被点击的按钮对象。



尾调用优化

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

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

尾调用为什么能优化呢?函数在调用时会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用尾椎和内部变量等信息。如果函数 A 的内部调用函数 B,那么在 A 的调用帧上方还会形成一个 B 的调用帧。等到 B 运行结束,将结果返回到 A, B的调用帧才会消失。如果函数 B 的内部还调用函数 C,那就还有一个 C 的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。

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

这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。

注意,目前只有 Safari 浏览器支持尾调用优化,Chrome 和 Firefox 都不支持。



尾递归

相信大家了解了尾调用,尾递归就不言而喻了吧。函数尾调用自身,就称为尾递归。

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

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

factorial(5) // 120

上面代码是一个阶乘函数,计算

n

的阶乘,最多需要保存

n

个调用记录,复杂度 O(n) 。

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

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

factorial(5, 1) // 120

还有一个比较著名的例子,就是计算 Fibonacci 数列,也能充分说明尾递归优化的重要性。

非尾递归的 Fibonacci 数列实现如下。

function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超时
Fibonacci(500) // 超时

尾递归优化过的 Fibonacci 数列实现如下。

function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity



递归函数的改写

相信大家都发现了,尾递归的实现,往往要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算 5 的阶乘,需要传入两个参数 5 和 1?

有两种方法可以解决这个问题。方法一是在尾递归函数之外,在提供一个正常形式的函数。

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

function factorial(n) {
  return tailFactorial(n, 1);
}

factorial(5) // 120

函数式编程有一个概念,叫做柯里化(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

第二种方法就简单多了,就是采用 ES6 的函数默认值。

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

factorial(5) // 120

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

ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。

这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。


  • func.arguments

    :返回调用时函数的参数。

  • func.caller

    :返回调用当前函数的那个函数。

尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。



尾递归优化的实现

有什么办法可以在非严格模式下也可以实现尾递归优化?很简单,尾递归优化之所以需要优化,就是调用栈太多,造成移除,那么只要减少调用栈,就不会溢出。最好的办法就是采用“循环”替换“递归”。方法如下:

function sum(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1);
  } else {
    return x;
  }
}

sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)

上面代码中,

sum

是一个递归函数,参数

x

是需要累加的值,参数

y

控制递归次数。一旦指定

sum

递归 100000 次,就会报错,提示超出调用栈的最大次数。

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

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

上面就是蹦床函数的一个实现,它接受一个函数

f

作为参数。只要

f

执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。

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

function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1);
  } else {
    return x;
  }
}

上面代码中,

sum

函数的每次执行,都会返回自身的另一个版本。

现在,使用蹦床函数执行

sum

,就不会发生调用栈溢出。

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

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

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

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

var sum = tco(function(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  }
  else {
    return x
  }
});

sum(1, 100000)
// 100001

上面代码中,

tco

函数是尾递归优化的实现,它的奥妙就在于状态变量

active

。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归

sum

返回的都是

undefined

,所以就避免了递归执行;而

accumulated

数组存放每一轮

sum

执行的参数,总是有值的,这就保证了

accumulator

函数内部的

while

循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。