止观初探
我们习惯将代码编写为 一系列的命令,程序会按照它们的 顺序 进行执行:
思考以下代码:
const myFunction = function(a, b, c) { let result1 = longCalculation1(a,b); let result2 = longCalculation2(b,c); let result3 = longCalculation3(a,c); if (result1 < 10) { return result1; } else if (result2 < 100) { return result2; } else { return result3; } }
没错,再正常不过的 myFucntion
,依次声明了 result1
、result2
、resulit3
3 个变量,分别赋值为 longCalculation1(a,b)
、longCalculation2(b,c)
、longCalculation3(a,c)
,longCalculation1/2/3
顾名思义,是一些包含很长的计算过程的函数;
然后进入 if...else if...else...
判断;
最后 return
输出;
那这段代码 合理吗?
只要调用 myFunction
,longCalculation1/2/3
都必将执行!但是实际上,我们可能不需要它们所有的运算结果;无差别 的完成 3 个很长过程的计算会很影响效率;
有了这个认识之后,我们再来改进代码:
const myFunction = function(a, b, c) { let result1 = longCalculation1(a,b); if (result1 < 10) { return result1; } else { let result2 = longCalculation2(b,c); if (result2 < 100) { return result2; } else { let result3 = longCalculation3(a,c); return result3; } } }
没错,这确实是改进过后的代码 ╮(╯▽╰)╭
虽然在结构上看,更难看了(多层嵌套,确实难受),但是:
它让 longCalculation1/2/3
不用每次都全部执行,只有在进入确定的条件,需要对值进行返回的时候,才需要计算;
这,就,是, —— 惰性求值的思想体现(不需要立即返回的值,就先别计算;)
庐山面目
来看下 wiki 释义:
惰性求值又叫惰性计算、懒惰求值,也称为传需求调用,是一个计算机编程中的一个概念,目的是要 最小化 计算机要做的工作。
在使用惰性求值的时候,表达式不在它被绑定到变量之后就立即求值,而是在该值被取用的时候求值。
这句话很重要!怎么理解?
比如:let result1 = longCalculation1(a,b);
这个表达式,意思是把 longCalculation1(a,b)
计算的返回值赋给 result1
;
在惰性求值中,赋值时,先不对 longCalculation1(a,b)
进行计算,而是等result1
被取用的时候(在示例中,就是 return
的时候)再进行计算。
那它是怎样实现的呢?
引用 Reincarnation 的回答:
通过将表达式包装成一个thunk实现的;
例如计算f (g x),实际上给f传递的参数就是一个类似于包装成(_ -> (g x))的一个thunk;
然后在真正需要计算g x的时候才会调用这个thunk;
事实上这个thunk里面还包含一个boolean表示该thunk是否已经被计算过(若已经被计算过,则还包含一个返回值),用来防止重复计算;
第一节示例的 JavaScript 的代码虽然是有惰性求值的思想体现,但是其本身并不是惰性求值;
惰性求值是编程语言的特性设计,很多纯粹的函数式编程语言都支持这种设计;
比如在 Haskell 中实现上述示例:
myFunction :: Int -> Int -> Int -> Int myFunction a b c = let result1 = longCalculation1 a b result2 = longCalculation2 b c result3 = longCalculation3 a c in if result1 < 10 then result1 else if result2 < 100 then result2 else result3
看上去,这和 JavaScript 示例代码 1 一样,但是它实际上实现的却是 JavaScript 示例代码 2 的效果;
在 GHC 编译器中,result1
, result2
, 和 result3
被存储为 “thunk”
,并且编译器知道在什么情况下,才需要去计算结果,否则将不会提前去计算!
有点像 Promise 的意思,你不告诉我 resolve/reject
,我就 pending
;Haskell 中,你不告诉我什么时候调用这个值,我就维持 thunk
的状态;
无限列表
在 Haskell 中可以定义一个数组,它的项是无限多的;
let infList = [1..] // 定义一个 1,2,3... 不断递增的数组;
为什么在 Haskell 中行,在 JavaScript 中不行?
因为它是懒惰的,你定义归你定义,反正定义的时候,我又不用分配无穷大的内存,等你开始调用的时候,我再开始计算分配吧!
延迟计算很棒,不过事物都有两面性,这样做坏处是什么?
举例🌰:要计算 1 到 1 亿的和;
用 JavaScript 很快得解;
let sum = 0 for(let i=0;i<=100000000;i++){ sum=sum+i } console.log(sum) //5000000050000000
而在 Haskell 中,则会报错 内存溢出;
foldl (+) 0 [1..100000000] *** Exception: stack overflow
因为前者是对变量 sum
不断进行累加,而后者是:
(((((1 + 2) + 3) + 4) + …) + 100000000)
该运行记录中涉及的所有计算都是懒惰的;也就是说,所有单独的数字都同时在内存中,因为只有在 +
操作执行时,才会调用值去计算;
所以,惰性计算带来的最大麻烦就是:内存泄露;
内存泄露 → 剩余内存不足 → 后续申请不到足够内存 →内存溢出;
不过,它也是有解决办法的,有兴趣了解:
- strictness-points;
- What does seq actually do in Haskell?(思路:强制求值第一个参数,返回第二个参数;)
- 函数式语言和命令式语言的内存模型;
懒惰奥义
听君一席话,如听一席话,希望看完本篇后,有人再问你“什么是惰性求值”,能心里有个基本的谱~~
人天性爱偷懒,能不做的事儿先不做,先放着,等要做的时候再去做,这也未尝不是一种智慧;要知道激情是最容易被磨灭的,别让琐碎的提前“计算”消磨掉仅有不多的激情~
看准再做,“慢”也是一种“快”!
我是掘进安东尼,公众号同名,输出暴露输入,技术洞见生活,再会~