javascript函数式编程

长久以来,面向对象在 JavaScript 编程范式中占据着主导地位。不过,最近人们对函数式编程的兴趣正在增长。函数式编程是一种编程风格,它强调将程序状态变化(即副作用[side effect])的次数减到最小。因此,函数式编程鼓励使用不可变数据(immutable data)和纯函数(pure functions)(“纯”意味着没有副作用的)。它也更倾向于使用声明式的风格,鼓励使用命名良好的函数,这样就能使用在我们视线之外的那些打包好的细节实现,通过描述希望发生什么以进行编码。

概念:
命令式vs 声明式
纯函数
高阶函数
函数组合
偏函数
函数柯里化

应用:
函数柯里化的使用:
函数等价范式
js中的链式调用
函数节流与防抖
惰性载入
尾递归优化

编码风格
让JS代码更优雅

概念
命令式vs声明式

  • 命令式编程:命令“机器”如何去做事情(how),这样不管你想要的是什么(what),它都会按照你的命令实现。
  • 声明式编程:告诉“机器”你想要的是什么(what),让机器想出如何去做(how)。

举例
让一个数组里的数值翻倍。
我们用命令式编程风格实现,像下面这样:
var numbers = [1,2,3,4,5]
var doubled = []
for(var i = 0; i < numbers.length; i++) {
var newNumber = numbers[i] * 2
doubled.push(newNumber)
}
console.log(doubled) //=> [2,4,6,8,10]

我们直接遍历整个数组,取出每个元素,乘以二,然后把翻倍后的值放入新数组,每次都要操作这个双倍数组,直到计算完所有元素。

而使用声明式编程方法,我们可以用 Array.map 函数,像下面这样:
var numbers = [1,2,3,4,5]
var doubled = numbers.map(function(n) {
return n * 2
})
console.log(doubled) //=> [2,4,6,8,10]

一个list求和,命令式编程会这样做:
var numbers = [1,2,3,4,5]
var total = 0
for(var i = 0; i < numbers.length; i++) {
total += numbers[i]
}
console.log(total) //=> 15

而在声明式编程方式里,我们使用 reduce 函数:
var numbers = [1,2,3,4,5]
var total = numbers.reduce(function(sum, n) {
return sum + n
});
console.log(total) //=> 15

reduce 函数利用传入的函数把一个 list 运算成一个值。它以这个函数为参数,数组里的每个元素都要经过它的处理。每一次调用,第一个参数(这里是sum)都是这个函数处理前一个值时返回的结果,而第二个参数(n)就是当前元素。这样下来,每此处理的新元素都会合计到sum中,最终我们得到的是整个数组的和。

纯函数
函数内的运算对函数外无副作用
不改变外部
不依赖被外部改变的
结果来自于传入的参数
并且当输入相同时输出保持一致
// 纯函数
const add10 = (a) => a + 10
// 依赖于外部变量的非纯函数
let x = 10
const addx = (a) => a + x
// 会产生副作用的非纯函数
const setx = (v) => x = v
非纯函数间接地依赖于参数 x。如果你改变了 x 的值,对于相同的 a,addx 会输出不同的结果。

写纯函数的优点:
纯函数降低了程序的认知难度。写纯函数时,你仅仅需要关注函数体本身。不必去担心一些外部因素所带来的问题,比如在 addx 函数中的 x 被改变。

不改变外部——这意味着函数求值的结果只是其返回值,而惟一影响其返回值的就是函数的参数。如果一个函数式程序不如你期望地运行,调试是轻而易举的。因为函数式程序的 bug 不依赖于执行前与其无关的代码路径,你遇到的问题就总是可以再现。

在单元测试中,你只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参数。

纯函数 不会改变程序的状态,也不会产生可感知的副作用。纯函数的输出,仅仅取决于输入值。无论何时何地被调用,只要输入值相同,返回值也就一样。

高阶函数
高阶函数就是函数当参数,把传入的函数做一个封装,然后返回这个封装函数。
例如我们要实现一个计算1和任意数字的和的函数:
var partAdd = function(p1){
this.add = function (p2){
return p1 + p2;
};
return add;
};
var add = partAdd(1);
add(2); // 3

执行 partAdd(1) 时返回的任然是一个函数,当再次传入第二个参数时,就可以计算出和了。
上面的例子只是为了理解高阶函数,实际运用如下例所示:
var add = function(a,b){
return a + b;
};
function math(func,array){
return func(array[0],array[1]);
}
math(add,[1,2]); // 3

函数组合
通过函数组合,可能将函数组合在一起形成新的函数。一起来看例子:
// 通过 add 和 square 函数组合生成 addThenSquare
const add = function ( x, y ) {
return x + y;
};

const square = function ( x ) {
return x * x;
};

const addThenSquare = function ( x, y ) {
return square(add( x, y ));
};
你可能会发现一直在重复这种利用更小的函数生成一个更复杂的函数的形式。通常编写一个组合函数会更有效率:
const add = function ( x, y ) {
return x + y;
};

const square = function ( x ) {
return x * x;
};

const composeTwo = function ( f, g ) {
return function ( x, y ) {
return g( f ( x, y ) );
};
};

函数式编程中还有一个很棒的东西就是你可以在新的函数中组合它们。一用于在 lambda 运算中描述程序的很特殊的运算符就是组合。组合将两个函数在一个新的函数中『组合』到一起。如下:
const add1 = (a) => a + 1
const times2 = (a) => a * 2
const compose = (a, b) => (c) => a(b(c))
const add1OfTimes2 = compose(add1, times2)
add1OfTimes2(5) // => 11

借助函数组合,我们可以通过将多个小函数结合在一起来构建更复杂的数据变化。这篇文章详细地展示了函数组合是如何帮助你以更加干净简洁的方式来处理数据。
从实际来讲,组合可以更好地替代面向对象中的继承。下面是一个有点牵强但很实际的示例。假如你需要为你的用户创建一个问候语。

const greeting = (name) => Hello ${name}
很棒!一个简单的纯函数。突然,你的项目经理说你现在需要为用户展示更多的信息,在名字前添加前缀。所以你可能把代码改成下面这样:
const greeting = (name, male=false, female=false) =>
Hello ${male ? ‘Mr. ‘ : female ? ‘Ms. ‘ : ‘’} ${name}
代码并不是很糟糕,不过如果我们又要添加越来越多的判断逻辑,比如『Dr.』或『Sir』呢?如果我们要添加『MD』或者『PhD』前缀呢?又或者我们要变更下问候的方式,用『Sup』替代『Hello』呢?现在事情已然变得很棘手。像这样为函数添加判断逻辑并不是面向对象中的继承,不过与继承并且重写对象的属性和方法的情况有些类似。既然反对添加判断逻辑,那我们就来试试函数组合的方式:
const formalGreeting = (name) => Hello ${name}
const casualGreeting = (name) => Sup ${name}
const male = (name) => Mr. ${name}
const female = (name) => Mrs. ${name}
const doctor = (name) => Dr. ${name}
const phd = (name) => ${name} PhD
const md = (name) => ${name} M.D.
formalGreeting(male(phd("Chet"))) // => "Hello Mr. Chet PhD"
这就是更加可维护和一读的原因。每个函数仅完成了一个简单的事情,我们很容易就可以将它们组合在一起。现在,我们还没有完成整个实例,接下来使用 pipe 函数!
const identity = (x) => x
const greet = (name, options) => {
return pipe([
// greeting
options.formal ? formalGreeting :
casualGreeting,
// prefix
options.doctor ? doctor :
options.male ? male :
options.female ? female :
identity,
// suffix
options.phd ? phd :
options.md ?md :
identity
])(name)
}
另外一个使用纯函数和函数组合的好处是更加容易追踪错误。无论在什么时候出现一个错误,你都能够通过每个函数追溯到问题的缘由。在面向对象编程中,这通常会相当的复杂,因为你一般情况下并不知道引发改问题的对象的其他状态。

偏函数
Partial 函数应用 指定函数参数中的一个或多个,然后返回一个稍后会被完整调用的函数。
在下面的例子中,double、triple 和 quadruple 都是 multiply 函数的 partial 应用。
const multiply = function ( x, y ) {
return x * y;
};

const partApply = function ( fn, x ) {
return function ( y ) {
fn( x, y );
};
};

const double = partApply( multiply, 2 );
const triple = partApply( multiply, 3 );
const quadruple = partApply( multiply, 4 );

函数柯里化
函数柯里化就是对高阶函数的降阶处理。是将接收多个参数的函数转换为一系列只接收一个参数的函数的过程。
eg:
function(arg1,arg2)变成function(arg1)(arg2)
function(arg1,arg2,arg3,arg4)变成function(arg1)(arg2)(arg3)(arg4)

柯里化把一个结果分成了多个步骤去走,把前面的步骤缓存起来,直至达到最后一步才出结果,这样可以很好区分什么参数在哪个步骤做了哪些事,用于debug和理解代码很有帮助。
这有别于一个封装好的大函数,传入需要的参数,一步到位!
代码实例:
var aaa = function(p1){
return function (p2){
return p1 + ' ' + p2;
};
};
console.log(aaa('Hello')('World')); // Hello World
在这个函数中,当调用a('hello')时,这个函数的功能并没有完成,直至再次调用才打出了日志。

如果把函数式编程这样的嵌套返回,说成层,那就可以怎么理解。通常情况下,前面几层会做参数验证,数据准备,边界排查等前期工作,最后一层为核心代码。

应用
函数柯里化的应用
实现方式:
实际应用:
//这是一个Ajax页面局部刷新的例子
//替换DOM中某个节点的html
function update(id){
return function (data){
$("div#"+id).html(data.text);
}
}
//Ajax局部刷新
function refresh(url, callback){
$.ajax({
type:"get",
url:url,
dataType:"json",
success: function (data){
callback(data);
});
}
//刷新两个区域
refresh("friends.php", update("friendsDiv"));
refresh("newfeeds.php", update("feedsDiv"));

首先声明一个柯里化的函数 update,这个函数会将传入的参数作为选择器的 id,并更新这个 div 的内容 (innerHTML)。
然后声明一个函数 refresh,refresh 接受两个参数,第一个参数为服务器端的 url,第二个参数为一个回调函数,当服务器端成功返回时,调用该函数。
然后我们陆续调用 refresh,每次的 url 和 id 都不同,这样可以将内容通过异步方式更新。
其中如何与服务器通信,以及如果选取页面内容的部分被很好的抽象成了函数。

函数等价范式
functional将函数本身看成输入和输出的映射,或者说是一种确定的运算。那么我们可以定义两个函数是否相等,对于任意函数a、b,如果给定的参数x、y,返回值z,当xa === xb且ya === yb时,总有za === zb,那么我们认为a、b等价。当然,对于js的函数,除了这一点,还必须包括一个附加条件,那就是this上下文,也就是说,对于函数a、b当this上下文、参数都相同时,两个函数总是返回相同的结果,那么我们就说方法a和方法b完全等价。

function equal(func){
return function(){
var ret = func.apply(this, arguments);
return ret;
}
}
someObj. doSomething = equal(someObj.doSomething);
不论someObj和doSomething如何实现的,上面的赋值语句都完全不会让代码运行结果有一丝一毫改变

有了这个等价范式,我们确保不会影响函数运算产生的结果,而不用考虑函数运算的具体过程,而且,我们可以在这个产生结果的过程中添加我们的干预,只要我们的干预并不去影响a的输入参数和返回值,那么这层“干预”并不会对系统造成任何不可预知的影响。

function safeTransform(func){
return function(){
做一些事情,但不去改变 arguments
var ret = func.apply(this, arguments);
做一些事情,但不去改变 ret
return ret;
}
}

我们通过范式约定,用模式把对系统的影响控制在可控的范围内,使得对系统的干预造成的风险可预知可控,而这一切,基于基础的函数式编程原理。

有什么用?
我在项目中需要使用其中的 Swipable组件 , 但是呢,我发现一个问题,按照产品设计,我的swipable元素有一个伪滚动条,这个滚动条要根据Swipable组件的滚动条状态同步更新,也就是说我必须拿到组件当前的滚动状态,而这个状态在组件设计对外的接口中并没有暴露出来。

那么怎么办呢?无外乎有几种办法:

  1. 放弃用这个组件来实现这个需求
  2. 自己修改组件,将滚动状态暴露出来
  3. 让团队负责这个组件的同学升级组件
  4. 利用函数式编程思想,不修改组件的情况下对这个组件的方法做一些额外的修饰

1)不说了,
2)的问题是将来组件升级了我就不能享受新的功能,
3)的问题是这个项目时间上不允许。
因此我选择了第四种方案——

var fn = {
watch: function(func, before, after){
return function(){
var args = [].slice.call(arguments);
before && before.apply(this, args);
var ret = func.apply(this, arguments);
after && after.apply(this, [ret].concat(args));
return ret;
}
}
}

var swipable = new Swipable({
element: '.swipable-wrap',
dir: 'horizontal'
});

swipable._scroll = fn.watch(swipable._scroll, function(pos){
var p = pos / (swipable.min - swipable.max);
$('#progress-bar .current').css('width', Math.min(p, 1.0) * 240 + 'px');
});

基本思路就是上面的代码,首先定义一个watch的高阶函数,它可以"watch"任意一个方法,在它被调用之前(before)和被调用之后(after)拦截进去。

然后我就watch了swipable组件中的_scroll方法,在它被调用前拿到了它的参数pos,进行计算,同步更新伪滚动条。

这样的话我就在没有对swipable组件本身改动一行代码的情况下得到了我要的功能。

函数节流与防抖

Debounce
debounce 函数所做的事情就是,强制一个函数在某个连续时间段内只执行一次,哪怕它本来会被调用多次。我们希望在用户停止某个操作一段时间之后才执行相应的监听函数,而不是在用户操作的过程当中,浏览器触发多少次事件,就执行多少次监听函数。
比如,在某个 3s 的时间段内连续地移动了鼠标,浏览器可能会触发几十(甚至几百)个 mousemove 事件,不使用 debounce 的话,监听函数就要执行这么多次;如果对监听函数使用 100ms 的“去弹跳”,那么浏览器只会执行一次这个监听函数,而且是在第 3.1s 的时候执行的。
有时候我们希望函数在某些操作执行完成之后被触发。例如,实现搜索框的 Suggest 效果,如果数据是从服务器端读取的,为了限制从服务器读取数据的频率,我们可以等待用户输入结束 100ms 之后再触发Suggest 查询:

现在,我们就来实现一个 debounce 函数。
实现:
function debounce(fn, delay){
var timer = null; // 定时器,用来 setTimeout
// 返回一个函数,这个函数会在一个时间区间结束后的 delay 毫秒时执行 fn 函数
return function(...args){
// 每这当返回的函数被调用,就清除定时器,以保证不执行 fn
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
}

其实思路很简单,debounce 返回了一个闭包,这个闭包依然会被连续频繁地调用,但是在闭包内部,却限制了原始函数 fn 的执行,强制 fn 只在连续操作停止后只执行一次。

debounce 的使用方式如下:
$(document).on('mouvemove', debounce(function(e) {
// 代码
}, 250))

用例
考虑一个场景:根据用户的输入实时向服务器发 ajax 请求获取数据。我们知道,浏览器触发 key* 事件也是非常快的,即便是正常人的正常打字速度,key* 事件被触发的频率也是很高的。以这种频率发送请求,一是我们并没有拿到用户的完整输入发送给服务器,二是这种频繁的无用请求实在没有必要。
更合理的处理方式是,在用户“停止”输入一小段时间以后,再发送请求。那么 debounce 就派上用场了:
$('input').on('keyup', debounce(function(e) {
// 发送 ajax 请求
}, 300))

Throttle
固定函数执行速率,即所谓的“节流”。
在实际项目中,我们有时候会遇到限制某函数调用频率的需求。例如,防止一个按钮短时间的的重复点击,防止 resize、scroll 和 mousemove 事件过于频繁地触发等。

实现
接收两个参数,一个实际要执行的函数 fn,一个执行间隔。
// throttle 的简单实现
function throttle(fn, wait){
var timer;
return function(...args){
if(!timer){
timer = setTimeout(()=>timer=null, wait);
return fn.apply(this, args);
}
}
}

//按钮每500ms一次点击有效
btn.onclick = throttle(function(){
console.log("button clicked");
}, 500);

尾调用优化
尾调用指的是,某个函数的最后一步动作是调用函数。尾调用优化指的是,当语言编译器识别到尾调用的时候,会对其复用相同的调用帧。这意味着,在编写尾调用的递归函数时,调用帧的限制永远不会被超出,因为调用帧会被反复使用。
下面是将前面的递归函数采用尾递归优化重写之后的例子:
const factorial = function ( n, base ) {
if ( n === 0 ) {
return base;
}
base *= n;
return factorial( n - 1, base );
};

console.log(factorial( 10, 1 )); // 3628800

尾调用优化
尾调用的概念非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
function f(x){
return g(x);
}
上面代码中,函数f的最后一步是调用函数g,这就叫尾调用。
以下两种情况,都不属于尾调用。
// 情况一
function f(x){
let y = g(x);
return y;
}

// 情况二
function f(x){
return g(x) + 1;
}
上面代码中,情况一是调用函数g之后,还有别的操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。
我们知道函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。多层次的调用记录形成了调用栈。尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();

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

// 等同于
g(3);
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。基于尾调用优化的原理,我们可以对尾递归进行优化。递归需要保存大量的调用记录,很容易发生栈溢出错误,如果使用尾递归优化,将递归变为循环,那么只需要保存一个调用记录,这样就不会发生栈溢出错误了。
例如计算阶乘的函数:
// 不是尾递归,无法优化
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}

// 尾递归,可以优化
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
目前的ES5中并没有规定尾调用优化,但是ES6中明确规定了必须实现尾调用优化,也就是ES6中只要使用尾递归,就不会发生栈溢出。所以 对于递归函数尽量改写为尾递归形式 。

函数式代码风格 让JS代码更优雅
在函数式的语言中,连续运算是被推崇的方法
连续赋值
var a = b = c = d = 100;

“短路”条件
||和&&(||用来提供变量的默认值,&&可避免从undefined中取值抛出异常),也是连续运算的体现。

三元表达式
var objType = getFromInput();
var cls = ((objType == 'String') ? String :
(objType == 'Array') ? Array :
(objType == 'Number') ? Number :
(objType == 'Boolean') ? Boolean :
(objType == 'RegExp') ? RegExp :
Object
);
var obj = new cls();
如果你要用if/else,switch,甚至于“多态”的手段去重写上面的方法,可以想象一下是多么长的一坨……

链式调用
例如jQuery的DOM操作:
$('#item').width('100px').height('100px').css('padding','20px').click(function(){ alert('hello'); });
其实原理就是在函数最后return this,即可接着之前的上下文环境继续调用函数。

数组 foo 中的对象结构更改,然后从中挑选出一些符合条件的对象,并且把这些对象放进新数组 result 里。
var foo = [{
name: 'Stark',
age: 21
},{
name: 'Jarvis',
age: 20
},{
name: 'Pepper',
age: 16
}]

//我们希望得到结构稍微不同,age大于16的对象:
var result = [{
person: {
name: 'Stark',
age: 21
},
friends: []
},{
person: {
name: 'Jarvis',
age: 20
},
friends: []
}]
从直觉上我们很容易写出这样的代码:
var result = [];

//有时甚至是普通的for循环
foo.forEach(function(person){
if(person.age > 16){
var newItem = {
person: person,
friends: [];
};
result.push(newItem);
}
})
然而用函数式的写法,代码可以优雅得多:
var result = foo
.filter(person => person.age > 16)
.map(person => ({
person: person,
friends: []
}))

数组求和:
var foo = [1, 2, 3, 4, 5];

//不优雅
function sum(arr){
var x = 0;
for(var i = 0; i < arr.length; i++){
x += arr[i];
}
return x;
}
sum(foo) // => 15

//优雅
foo.reduce((a, b) => a + b) // => 15

lodash里一些很好用的东西
lodash是一个JS工具库,里面存在众多函数式的方法和接口
1、_.flow解决函数嵌套过深
//很难看的嵌套
a(b(c(d(...args))));

//可以这样改善
_.flowRight(a,b,c,d)(...args)

//或者
_.flow(d,c,b,a)(...args)

2、_.memoize加速数学计算
在写一些Canvas游戏或者其他WebGL应用的时候,经常有大量的数学运算,例如:
Math.sin(1)
Math.sin()的性能比较差,如果我们对精度要求不是太高,我们可以使用 _.memoize 做一层缓存
var Sin = _.memoize(function(x){
return Math.sin(x);
})

Sin(1) //第一次使用速度比较慢
Sin(1) //第二次使用有了cache,速度极快
注意此处传入的 x 最好是整数或者较短的小数,否则memoize会极其占用内存。
事实上,不仅是数学运算,任何函数式的方法都有可缓存性,这是函数式编程的一个明显的优点

3、_.flatten解构嵌套数组
_.flatten([1, 2], [3, 4]); // => [1, 2, 3, 4]
这个方法和 Promise.all 结合十分有用处。
假设我们爬虫程序有个 getFansList 方法,它可以根据传入的值 x ,异步从粉丝列表中获取第 x20 到 (x+1)20 个粉丝,现在我们希望获得前1000个粉丝:
var works = [];

for (var i = 0; i < 50; i++) {
works.push(getFansList(i))
}

Promise.all(works)
.then(ArrayOfFansList=> _.flatten(ArrayOfFansList))
.then(result => console.log(result))

4、.once配合单例模式
有些函数会产生一个弹出框/遮罩层,或者负责app的初始化,因此这个函数是执行且只执行一次的。这就是所谓的单例模式,
.once大大简化了我们的工作量
var initialize = _.once(createApplication);

initialize();
initialize();
// 这里实际上只执行了一次 initialize
// 不使用 once 的话需要自己手写一个闭包

Generator + Promise改善异步流程
有时我们遇到这样的情况:
getSomethingAsync()
.then( a => method1(a) )
.then( b => method2(b) )
.then( c => method3(a,b,c) ) //a和b在这里是undefined!!!
只用 Promise 的话,解决方法只有把 a、b 一层层 return 下去,或者声明外部变量,把a、b放到 Promise 链的外部。但无论如何,代码都会变得很难看。
用 Generator 可以大大改善这种情况(这里使用了Generator的执行器co):
import co from 'co';

function* foo(){
var a = yield getSomethingAsync();
var b = yield method1(a);
var c = yield method2(b);
var result = yield method3(a,b,c);
console.log(result);
}

co(foo());
当然,Generate 的用处远不止如此,在异步递归中它能发挥更大的用处。比如我们现在需要搜索一颗二叉树中value为100的节点,而这颗二叉树的取值方法是异步的(比如它在某个数据库中)
import co from 'co';

function* searchBinaryTree(node, value){
var nowValue = yield node.value();
if(nowValue == value){
return node;
}else if(nowValue < value){
var rightNode = yield node.getRightNode()
return searchBinaryTree(rightNode, value);
}else if(nowValue > value){
var leftNode = yield node.getLeftNode()
return searchBinaryTree(leftNode, value);
}
}

co(searchBinaryTree(rootNode, 100))

惰性求值
顾名思义,只有在需要用到的才去计算。这里强行设定一种情景,如一个加法函数:没有惰性求值
function add(n1,n2){
if(n1<5){
return n1
}else{
return n1+n2
}
}
result = add(add(1,2),add(3,4)) //相当于add(3,4)的计算是浪费的。
result//3
惰性求值
function add(n1,n2){
return n1+n2;
}
function preAdd(n1,n2){
return function(){
return add(n1,n2)
}
}
function doAdd(fn1,fn2){
n = fn1()
if(n<5){
return n //只需要运行fn1,得到一个计算结果即可。
}else{
return add(fn1,fn2())
}
}
result = doAdd(preAdd(1,2),preAdd(3,4))
result//10
对比一下可知,在javascript中的惰性求值,相当于先把参数先缓存着,return一个真正执行的计算的函数,等到需要结果采去执行。这样的好处在于比较节省计算,尤其有时候这个在函数是不一定需要这个参数的时候。

延迟计算
延迟计算是一类包含了很多类似 thunk 和 generator 规范概念的通用术语。延迟计算就和你所想的一样:不会在必须做某件事情之前做任何事,尽可能长时间的延后。一个类比就是假如你有无限量的盘子要洗。你就不会将所有的盘子都放到水池中然后一次性清洗它们,我们可以偷懒一下,一次仅仅洗一个盘子。
在 Haskell 中,延迟计算的本质更加容易理解,所以我会从它说起。首先,我们需要理解程序是如何计算的。我们所使用的大部分语言使用的都是由内而外的规约,就像下面这样:
square(3 + 4)
square(7) // 计算最内层的表达式
7 * 7
49
这也是比较明智的程序计算方式。不过我们先来看一下向外而内的规约。
square(3 + 4)
(3 + 4) * (3 + 4) // 计算最外层的表达式
7 * (3 + 4)
7 * 7
49
显然,由外而内规约的方式不够明智——我们需要计算两次 3 + 4,所以程序共花费了 5 步。这有点糟糕。不过 Haskell 保留了对每个表达式的引用并且在它们由外而内规约时传递共享的引用。这样,当 3 + 4 被首次计算后,这个表达式的引用会指向新的表达式 7。这样我们就跳过了重复的步骤。
square(3 + 4)
(3 + 4) * (3 + 4) // 计算最外面的表达式
7 * 7 // 由于引用共享的存在,计算此时减少了一步
49
本质上,延迟计算就是引用共享的由外而内计算。
Haskell 在内部为你做了很多事情,并且这也意味着你可以像无限的列表一样定义东西。比如,你可以递归地定义一个无限的列表。
ones = 1 : ones
假设现在有一个 take(n, list) 函数,它的第一个参数是一个 n 元素的列表。如果我们使用由内而外的规约,可能会出现无限递归计算一个列表,因为它是无限的。不过,借助由外而内计算,我们可以实现按需延迟计算!
然而,由于 JavaScript 和大多数编程语言都使用了由内而外的规约,我们复制这种架构的唯一方式就是将数组看成是函数,如下示例:
const makeOnes = () => {next: () => 1}
ones = makeOnes()
ones.next() // => 1
ones.next() // => 1
现在,我们已经基于相同的递归定义创建了一个延时计算无限列表的表达式。现在我们创建一个自然数的无限列表:
const makeNumbers = () => {
let n = 0
return {next: () => {
n += 1
return n
}
}
numbers = makeNumbers()
numbers.next() // 1
numbers.next() // 2
numbers.next() // 3
在 ES2015 中,确实为此实现了一个标准,并且称为函数 generator。
function* numbers() {
let n = 0
while(true) {
n += 1
yield n
}
}
延迟可以带来巨大的性能效益。比如,你可以对比一下每秒钟 Lazy.js 计算与 Underscore 和 Lodash 的区别:

下面是解释它的原理的一个很好的示例(Lazy.js 网站给出的)。假定你现在有一个巨大的数组(元素是人),并且你想对它执行某些转换:
const results = _.chain(people)
.pluck('lastName')
.filter((name) => name.startsWith('Smith'))
.take(5)
.value()
完成这件事最原始的方式就是将所有的名字拣出来,过滤整个数组,然后使用前 5 个。这就是 Underscore.js 以及绝大多数类库的做法。不过使用 generator,我们可以使用延迟计算 每次仅计算一个值,直到我们拿到了以 『Smith』开头的名字。
Haskell 给我们最大的惊喜就是所有的这些都在语言内部借助由外而内规约和引用共享实现了。在 JavaScript 中,我们能够借助于 Lazy.js,不过如果你想要自己创建这类东西,你就需要理解上述的每一步,返回一个新的 generator。想要拿到一个 generator 中的所有值,你就需要为它们调用 .next()。这个链方法会将数组编程一个 generator。然后,当你调用 .value()时,它就会反复的调用 next() 方法,直到没有更多的值存在时。并且 .take(5) 可以确保不会去计算比你需要的更多的的计算!
现在回忆一下之前提到的定理:
list.map(inc).map(isZero) // => [false, true, false]
list.map(compose(isZero, inc)) // => [false, true, false]
延迟计算,在内部帮你完成了这类优化。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,517评论 0 38
  • JavaScript是一门很神奇的语言,作为一门现代化的语言,他有很多很有特色的东西,这些东西,让我们看到了一个十...
    一只当飞行员的兔子阅读 657评论 0 9
  • 1.函数参数的默认值 (1).基本用法 在ES6之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
    赵然228阅读 648评论 0 0
  • 编程范式 编程范式是:解决编程中的问题的过程中使用到的一种模式,体现在思考问题的方式和代码风格上。这点很像语言,语...
    vivaxy阅读 333评论 0 3
  • SwiftDay011.MySwiftimport UIKitprintln("Hello Swift!")var...
    smile丽语阅读 3,806评论 0 6