前端小知识

110 阅读3分钟

1、 尾调用优化和尾递归

尾调用优化

function f() {
    let m = 1; 
    let n = 2; 
    return g(m + n); 
} 
f(); 

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

// 等同于 
g(3);

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

递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(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

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

1.2 如何判断一个对象是不是空对象?

推荐:Object.keys(obj).length === 0

// 方法1 `Object.keys(obj).length === 0`  

不推荐:

// stringify对于undefined和方法会不处理的
//let obj = {a: undefined,b:function(){}},该对象stringify后也是'{}'
// 方法2 `JSON.stringify(obj) === '{}'`

1.3 Javascript 为什么会存在数字精度丢失的问题,以及如何进行解决?

计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号位+(指数位+指数偏移量的二进制)+小数部分}存储二进制的科学记数法
因为存储时有位数限制(64位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0舍1入),当再转换为十进制时就造成了计算误差
解决方法
使用 toPrecision 凑整并 parseFloat 转成数字后再显示

function strip(num, precision = 12) { 
    return +parseFloat(num.toPrecision(precision)); 
}

对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。以加法为例

/** 
* 精确加法 
*/ 
function add(num1, num2) { 
    const num1Digits = (num1.toString().split('.')[1] || '').length; 
    const num2Digits = (num2.toString().split('.')[1] || '').length; 
    const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits)); 
    return (num1 * baseNum + num2 * baseNum) / baseNum; 
}