这个世界没有什么好畏惧的,反正我们只来一次
说明
学习总结 + 个人理解,巩固 + 方便查阅,大家愿意看的简单看看就好
木桶效应 &
约束理论
木桶效应:
一只木桶能盛多少水,并不取决于最长的那块木板,而是取决于最短的那块木板。也可称为短板效应。
约束理论:
一个系统最薄弱的地方确定了这个系统有多强大,专注于瓶颈。与直觉相反,如果你把整个系统分解,单独优化每个部分,你会降低整个系统的效率。相反,要优化整个系统。
类比我们人类,人无完人,谁都有缺点,缺点越多成功的几率就越小,我们想要成功就需要先改善缺点,要改善缺点前提就是先要找到缺点。那么对于系统而言,就是通过性能测试定位系统短板,然后进行针对性调优。各种各样的系统,只能说这个系统更适合哪方面,不适合哪方面,你总不能拿 B2B
系统去做 C2C
方面的业务,既然是做 B2B
的系统,那针对这个系统你就该在 B2B
方面去针对性改善。
现实社会残酷无情,人们总是会变成最初自己讨厌的模样
性能测试
如题: 如何测试某个运算的速度(即执行时间)?
答:
var start = Date.now();
doSomething(); // 进行一些操作
var end = Date.now();
console.log( "耗时:", (end - start) );
或者:
console.time('A'); // A 为计时器名称
doSomething(); // 进行一些操作
console.timeEnd('A'); // 结束计时器A,程序运行所经过的时间会被自动输出到控制台
以上做法的错误之处:
- 不十分精确:举例,若
0ms < 执行时间 < 15ms
,而IE
早期版本定时器精度只有15ms
,故此时报告时间会是0
- 只能声称这次特定的运行消耗了大概这么长时间,因为你并不明确此时引擎或系统有没有受到什么影响
- 在获得
start
或end
时间戳之间也可能有其他一些延误 - 不明确当前运算测试的环境是否过度优化
提问:你说我不精确,那我用循环让它运行一百一千甚至更多次,取平均值,这不就精确了?
答:
依旧不精确,过高或过低的的异常值也可以影响整个平均值,然后再重复应用,误差继续扩散,只会产生更大的欺骗性。而且你还有许多需要考虑的东西:定时器的精度、异常因素、运行环境(桌面浏览器、移动设备...)等,再者你需要大量的测试样本,然后汇集测试结果,诚然这并不简单。
提问:好吧,我不够专业,那该怎么办?
答:
任何有意义且可靠的性能测试都应该基于统计学上合理的实践。对于统计学,你了解并掌握了多少?
讲真:
唉,我只是一个程序员,不懂这些乱七八糟的...
答:
好吧,那就直接用轮子吧,关于这些已经有聪明的人写好了,这里提供一个优秀的库: Benchmark.js
,另外你还可以去 jsPerf
官网看看,它可以在线分析代码性能,非常棒。
不要沉迷于微性能
科学研究表明可能大脑可以处理的最快速度是 13ms
,假设这里有两个程序 X
和 Y
,
X
的运算速度是人类大脑捕获一个独立的事件发生速度的 125 000
倍,而 Y
只有 100 000
倍,你会觉得 X
比 Y
快很多,但它们的差距在最好情况下也只是人类大脑所能感知到的最小间隙的 65
万分之一,所以这些性能差别无所谓,完全无所谓!
相比之下,我们更应该关注优化的大局,而不是担心这些微观性能的细微差别(比如 ++a
和 a++
谁更快)。我们只需要优化运行在关键路径上的代码,下面引用的话语足以说明:
花费在优化关键路径上的时间不是浪费,不管节省的时间多么少;
而花在非关键路径优化上的时间都不值得,不管节省的时间多么多
尽管程序关键路径上的性能非常重要,但这并不是唯一要考虑的因素。在性能方面大体相似的几个选择中,可读性应该是另外一个重要的考量因素。
举 🌰 :
var x = "42"; // 需要数字42
// 选择1:让隐式类型转换自动发生
var y = x / 2;
// 选择2:使用parseInt(..)
var y = parseInt( x, 0 ) / 2;
// 选择3:使用Number(..)
var y = Number( x ) / 2;
// 选择4:使用一元运算符+
var y = +x / 2;
// 选项5:使用一元运算符|
var y = (x | 0) / 2;
这里 parseInt()
与 Number
是函数调用,所以会比较慢,故撇去 1,2,3
,比较 4
与 5
, 若 5
比 4
快,这点性能也该是微不足道的,此时你亦不该为了这么点微性能去选择 5
而让程序失去了可读性。
调优
什么是尾调用?
尾调用就是一个出现在另一个函数 "结尾" 处的函数调用,即某个函数的最后一步是调用另一个函数。这个调用在结束后就没有其余事情要做了(除了可能要返回结果值)。
举 🌰 :
// 正宗尾调用
function f(x){
return g(x);
}
// 非尾调用,情况一
function f(x){
let y = g(x);
return y;
}
// 非尾调用,情况二
function f(x){
return g(x) + 1;
}
// 非尾调用,情况三
function f(x){
g(x);
}
情况一:调用函数 g
之后,还有赋值操作;
情况二:调用函数 g
之后,还有加操作;
情况二:调用函数 g
之后,未返回,此时默认为 return undefined
;
以上三种情况在函数调用后都做了其余的事情,所以都不是尾调用。
尾调用优化(TCO
)
先来了解下 调用栈
(call stack
) 的概念:
call Stack
就是你代码执行时的地方,定义为解释器追踪函数执行流的一种机制。每调用一个函数,解释器就会把该函数添加进调用栈并开始执行:
- 若正在调用栈中执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈,一旦这个函数被调用,便会立即执行。
- 当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。
- 当分配的调用栈空间被占满时,会引发 "堆栈溢出"(
stack overflow
) 。
JavaScript
是一种单线程编程语言,这意味着它只有一个 Call Stack
。因此,它一次仅能做一件事。
举个网上常见的 🌰 :
function multiply(x, y) {
return x * y;
}
function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}
printSquare(5);
函数调用会在内存形成一个 "调用记录",又称 "调用帧"(call frame
),保存调用位置和内部变量等信息。所有的调用帧,形成一个 "调用栈"(call stack
)。而调用每一个新的函数都需要额外的一块预留内存来管理调用栈,称为栈帧。
这里在函数
printSquare
的内部调用函数 multiply
,那么在 printSquare
的调用帧上方,会形成一个 multiply
的调用帧。等到 multiply
运行结束,将结果返回到 printSquare
,multiply
的调用帧才会消失。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
function foo(x) {
return x;
}
function bar(y) {
return foo(y + 1); // 尾调用
}
bar(18);
结合上面的例子解释,也就是说,如果支持 TCO
的引擎能够意识到 foo(y+1)
调用位于尾部,这意味着 bar(..)
基本上已经完成了,那么在调用 foo(..)
时,foo
的调用帧就可以直接取代 bar
的调用帧,并且 foo
也不需要创建一个新的栈帧,而是可以重用已有的 bar(..)
的栈帧。所以上面的代码就等同于直接调用 foo(19)
。
注意: 只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
Tips: 内层函数如果需要外层函数的内部变量(特指基本值
),此时可以将这个内部变量作为内层函数的参数传入,利用函数参数的按值传递特性,这样内部函数就不会保留对外部函数变量的引用
上述足以体现出尾调用的优势:不仅速度更快,也更节省内存。当然在简单的代码片段中,这类优化算不了什么(我不敢想象将简单代码都写成尾调用的形式,代码可读性会有多差),但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。有了尾调用优化 (TCO
),引擎就可以用同一个栈帧执行所有这类调用,就永远不会出现调用栈空间被占满导致的 "堆栈溢出"(stack overflow
)的情况。
尾递归实现阶乘的 🌰 :
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5) // 120
注意:
ES6
的尾调用优化只在严格模式下开启,正常模式下无效(class
内部默认就是严格模式)。ES6
还规定要求引擎实现TCO
而不是将其留给引擎自由决定。TCO
只用于有实际的尾调用的情况。如果你写了一个没有尾调用的递归函数,那么性能还是会回到普通栈帧分配的情形,引擎对这样的递归调用栈的限制也仍然有效。
尾递归优化的实现
首先来看个相互递归
的 🌰 :
function foo(a) {
if(!a) return a;
return bar(a - 1);
}
function bar(a) {
if(!a) return a;
return foo(a - 1);
}
foo(100); //输出0
foo(100000000); //输出Maximum call stack size exceeded
当相互递归层数过多的时候,依然会发生栈溢出的情况。
- 蹦床函数
我们改写上面的 🌰 :
function foo(a) {
if(!a) return a;
return function() {
return bar(a-1);
}
}
function bar(a) {
if(!a) return a;
return function() {
return foo(a-1);
}
}
foo(3)(); //输出一个函数
foo(3)()()(); //输出0
这里我们用闭包来封装了相互递归的函数,foo
与bar
函数的每次执行都是返回一个函数(这个函数就是返回结果,代表当前函数执行完毕),而这个函数未执行,也就不会被压入栈中(返回的函数由于是引用类型,所以保存在堆内存中),所以foo
或bar
函数执行完,就会清栈弹出,下次再调用返回的函数,进行压栈,然后返回函数再清栈,依次循环,有没有一种跳来跳去的感觉(就像蹦床一样,一直蹦一直爽😂😂),这就是蹦床函数
的名字由来 。
考虑:上面如果调用foo
函数传入的参数是一个很大的数字,那我们岂不是要调用很多次?
所以为了方便,我们将上面的调用形式写成函数,就是蹦床函数
,如下:
function trampoline(fn, ...args) {
fn = fn.call(fn, ...args);
while (fn && fn instanceof Function) {
fn = fn();
}
return fn;
}
它接受一个函数 fn
作为第一参数,args
为函数fn
需要的参数。只要 fn
执行后返回一个函数,就继续执行,直到返回值不是一个函数终止。
注意: 两次 ...args
:
- 第一次为函数
rest参数
,将多余的参数放入数组中 - 第二次为
扩展运算
,将数组转为用逗号分隔的参数序列
当然你里面也可以直接写:let fn = fn.apply(fn, args);
。要意识到,蹦床函数本质上就是将递归改为循环调用的形式,从而把递归的空间复杂度从 O(n)
降到了 O(1)
。