✨从延迟处理讲起,JavaScript 也能惰性编程?

3,255 阅读8分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

专栏简介

作为一名 5 年经验的 JavaScript 技能拥有者,笔者时常在想,它的核心是什么?后来我确信答案是:闭包和异步。而函数式编程能完美串联了这两大核心,从高阶函数到函数组合;从无副作用到延迟处理;从函数响应式到事件流,从命令式风格到代码重用。所以,本专栏将从函数式编程角度来再看 JavaScript 精要,欢迎关注!传送门

前文回顾

# ✨从历史讲起,JavaScript 基因里写着函数式编程

# ✨从柯里化讲起,一网打尽 JavaScript 重要的高阶函数

# ✨从纯函数讲起,一窥最深刻的函子 Monad

我们从闭包起源开始、再到百变柯里化等一票高阶函数,再讲到纯函数、纯函数的组合以及简化演算;

学到了:

  1. 闭包的设计就是因为 lambda 表达式只能接受一个参数的设计导致的,诞生 1930 ;
  2. 柯里化是闭包的孪生子,柯里化思想是高阶函数的重要指导;
  3. 原来编程函数也可以和数学函数一样运算推导,无副作用的纯函数、函数组合,代码更易读;

本篇将展开“延迟处理”这一话题,闲言少叙,冲了~

21a4462309f790523982fc3802f3d7ca7bcbd513.gif

延迟处理

认真读前面几篇,虽然没有专门讲“延迟处理”,但实际上处处都体现着“延迟处理”。

首先闭包是延迟处理:函数在声明的时候,确定了上下作用域关系。比如以下代码:

function addA(A){
    return function(B){
        return B+A
    }
}

let count = addA(7)
console.log(count(8)) // 15

调用 addA(7) 函数,它说:我并不会执行运算,而会返回给你一个新的函数,以及一个“闭包”,这个闭包里面是被引用的变量值。等到时候你要计算的时候,再从这里面拿值就行了~

其次,柯里化和闭包同宗同源,由 add(1,2,3) 柯里化为 add(1)(2)(3)(),在判定最后的参数为空之前,都是一个待执行的函数,不会进行真正的运算处理。

function addCurry() {
    let arr = [...arguments]
    let fn = function () {
        if(arguments.length === 0) {
	    return arr.reduce((a, b) => a + b) // 当参数为空时才执行求和计算;
        } else {
            arr.push(...arguments)
            return fn
        }
    }
    return fn
}

接着,纯函数中,我们不能保证一直写出不带副作用的函数,HTTP 操作/ IO 操作/ DOM 操作等这些行为是业务场景必做的,于是想了个法子:用一个“盒子”把不纯的函数包裹住,然后一个盒子连着一个盒子声明调用关系,直到最后执行 monad.value() 时才会暴露出副作用,尽最大可能的限制住了副作用的影响,延迟了它的影响。

所以,“延迟处理”思想几乎是根植在函数式编程的每一个要点中~

还没完,从专栏的整体角度来看,至此行文已到中段,除了围绕“闭包”这一核心点,另外一个核心点“异步”也要逐渐拉开帷幕、闪亮登场。

延迟处理是在函数式编程背景下连接 JavaScript 闭包和异步两大核心的重要桥梁。

image.png

惰性求值

“延迟处理”在函数式编程语言中还有一个更加官方、学术的名称,即“惰性求值”。

🌰我们不妨再用一段代码作简要示例:

// 示例代码 1

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;
  }
}

这是一段求值函数,result1、result2、result3 依次经过一段长运算,然后再走一段条件判断,return 结果;

这段代码的不合理之处在于,每次调用 myFunction() 都要把 3 个 longCalculation 计算,很耗时,结果只需要得到其中的某一个运算结果。

于是,根据问题,我们优化代码策略为:需要用到哪个计算,才计算哪个。(言外之意:惰性求值)

// 示例代码 2

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;
    }
  }
}

优化后的这个写法在逻辑上更合理,但是 if...else... 嵌套总让人看的难受。

因为 JavaScript 本身不是惰性求值语言,它和比如 C 语言这类主流语言一样,是【及早求值】,惰性求值语言有比如 Haskell 这类纯粹的函数式编程语言,用 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” ,并且编译器知道在什么情况下,才需要去计算结果,否则将不会提前去计算!这太牛皮了~

在《Haskell 函数式编程入门》,thunk 被解释为:

thunk 意为形实替换程序(有时候也称为延迟计算,suspended computation)。它指的是在计算的过程中,一些函数的参数或者一些结果通过一段程序来代表,这被称为 thunk。可以简单地把 thunk 看做是一个未求得完全结果的表达式与求得该表达式结果所需要的环境变量组成的函数,这个表达式与环境变量形成了一个无参数的闭包(parameterless closure) ,所以 thunk 中有求得这个表达式所需要的所有信息,只是在不需要的时候不求而已。

虽然 JavaScript 本身语言的设计不是惰性求值,但并不意味着它不能用惰性的思想来编程~

从惰性编程的角度来思考问题,可以消除代码中不必要的计算,也可以帮你重构程序,使之能更加直接地面向问题。

惰性编程

什么是惰性编程?

惰性编程是一种将对函数或请求的处理延迟到真正需要结果时进行的通用概念。

有很多应用程序都采用了这种概念,有的非常明显,有些则不太明显。

比如 JavaScript 的“父亲” Scheme 中就有简单的惰性编程,它有两个特殊的结构,delayforce,delay 接收一个代码块,不会立即执行它们,而是将代码和参数作为一个 promise 存储起来。而 force promise 则会运行这段代码,产生一个返回值;

这里提到 promise?在 JS 中也有 Promise,它是 JS 实现惰性的关键吗?

我们不妨用代码来测试一下:

const st=()=>{
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            console.log("done promise")
            resolve(true)
        },1000)
    })
}
let a = st()
console.log(a)

image.png

可以看到,Promise 并不是惰性的,它一旦执行,状态就转为 Pending,不能暂停。我们无法知道 Promise 是刚开始执行,或者是快执行完了,还是其它哪个具体执行阶段;内部的异步任务就已经启动了,执行无法中途取消;这些问题也是面试中常考的 Promise 的缺点有哪些。

好在,后来,Generator 函数的出现,把 JavaScript 异步编程带入了一个全新的阶段。

ES6 引入的 Generator ,为 JavaScript 赋予了惰性的能力! 👏

Generator

Thunk

Generator 就像是 Haskell 中的 thunk,赋值的时候,我不进行计算,把你包装成一个 <suspended> 暂停等待,等你调用 next() 的时候,我再计算;

function* gen(x){
 const y = yield x + 6;
 return y;
}

const g = gen(1);
g.next() // { value: 7, done: false }
g.next() // { value: undefined, done: true }

调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象。下一步,必须调用遍历器对象的 next 方法,使得指针移向下一个状态。

在异步场景下同样适用,将上述 promise 的测试代码改造为:

function * st1(){
    setTimeout(()=>{
        console.log("done promise")
    },1000)
    yield("done promise")
}
let aThunk = st1()
console.log(aThunk)

image.png

只有执行 aThunk.next() 时,异步才开始执行。

迭代生成器

Promise 不能随用随停,而 Generator 可以。我们通过 Generator 生成的序列值是可以迭代的,迭代过程可以操作,比方说在循环中迭代生成器:

//基本的生成器函数产生序列值。
function* gen(){
    yield 'first';
    yield 'second';
    yield 'third';
}
//创建生成器。
var generator = gen();

//循环直到序列结束。
while(true) {
    //获取序列中的下一项。
    let item = generator.next();
    //下一个值等于 'third' 吗
    if(item.value === 'third') {
        break;
    }
    console.log('while', item.value);
}

item.value === 'third',break 跳出循环,迭代结束。

循环+请求

综合循环和异步的问题,抛一个经典的面试题:

如何依次请求一个 api 数组中的接口,需保证一个请求结束后才开始另一个请求?

代码实现如下:

async function* generateSequence(items) {
  for (const i of items) {
    await new Promise(resolve => setTimeout(resolve, i));
    yield i;
  }
}

(async () => {
  let generator = generateSequence(['3000','8000','1000','4000']);
  for await (let value of generator) {
    console.log(value);
  }
})();

这里用 setTimeout 模拟了异步请求,代码可复制到控制台中自行跑一跑、试一试。

无限序列

在函数式编程语言中有一个特殊的数据结构 —— 无限列表,Generator 也可以帮助 JS 实现这一结构:

🌰比如生成一个无限增长的 id 序列:

function* idMaker(){
    let index = 0;
    while(true)
        yield index++;
}

let gen = idMaker(); // "Generator { }"

console.log(gen.next().value);
// 0
console.log(gen.next().value);
// 1
console.log(gen.next().value);
// 2
// ...

🌰比如实现一个循环交替的无限序列:

//一个通用生成器将无限迭代
//提供的参数,产生每个项。
function* alternate(...seq) {
    while (true) {
        for (let item of seq) {
            yield item;
        }
    }
}

//使用新值创建新的生成器实例
//来迭代每个项。
let alternator = alternator('one', 'two', 'three');

//从无限序列中获取前10个项。
for (let i = 0; i < 6; i++) {
    console.log(`"${alternator.next().value}"`);
}
// "one"
// "two"
// "three"
// "one"
// "two"
// "three"

由于 while 循环永远不会退出,for 循环将自己重复。也就是说,参数值会交替出现了。

无限序列是有现实意义的,很多数字组合都是无限的,比如素数,斐波纳契数,奇数等等;

结语

看到这里,大家有没有感觉 Generator 和之前讲过的什么东西有点像?

纯函数的衍生 compose 组合函数,把一个一个函数组装、拼接形成链条;Generator 自定义生成序列,依次执行。二者有异曲同工之妙。前者侧重函数封装、后者侧重异步处理,但二者都有“延迟处理”的思想。真掘了!

JavaScript 也能借助 闭包、柯里化、组合函数、Generator 实现惰性编程,减少不必要的计算、精确控制序列的执行、实现无限列表等。。。

不愧是你,真胶水语言,啥都能干!

image.png

后文会重点讲:JS 异步核心、响应式事件流、RxJS等,敬请期待~


OK,以上便是本篇分享,希望各位工友喜欢~ 欢迎点赞、收藏、评论 🤟

我是掘金安东尼 🤠 100 万人气前端技术博主 💥 INFP 写作人格坚持 1000 日更文 ✍ 关注我,安东尼陪你一起度过漫长编程岁月 🌏

😹 加我微信 ATAR53,拉你入群,定期抽奖、粉丝福利多多。只学习交友、不推文卖课~

😸 我的公众号:掘金安东尼,在上面,不止编程,更多还有生活感悟~

😺 我的 GithubPage: tuaran.github.io,它已经被维护 4 年+ 啦~