Thunk 函数与 Generator 函数

简介: Thunk 函数与 Generator 函数

前言


关于 Thunk 这个词,其实第一次看到是 redux-thunk 库。还长时间内都没有理解 “Thunk” 是什么意思,当初想可能只是类似 Foo、Bar 等,就一个名称罢了。


正文


一、Thunk


早在上世纪 60 年代 Thunk 函数就诞生了。那时候,编程语言刚起步,计算机学家还在研究,编译器怎么写比较好。其中一个争论的焦点是“求值策略”,即函数的参数到底应何时求值?


存在两派意见:


  • 传值调用(call by value)
  • 传名调用(call by name)


比如,以下示例:

var x = 1
function fn(m) {
  return m * 2
}
fn(x + 4)


对于“传值调用”的话,在进入函数体之前,计算 x + 4 的值(等于 5),再将这个值传入函数 fn。JavaScript、C 语言就是采用这种策略。

若对于“传名调用”的话,直接将表达式 x + 4 传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。


至于“传值调用”和“传名调用”,哪一种比较好?


回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上没用到这个参数,有可能造成性能损失。

var x = 5
function fn(m, n) {
  return n
}
fn(8 * x * x - 3 * x -1, x)


上面示例中,如果采用“传值调用”的策略,函数 fn 的第一个参数是一个复杂的表达式,但是函数体内根本没用到,对这个参数求值,实际上是没必要的。因此,有些计算机科学家倾向于“传名调用”。


二、Thunk 函数的含义


编译器的“传名调用”实现,往往是将参数放到一个临时函数中,再将这个临时函数传入函数体。这个临时函数就被叫做 Thunk 函数

var x = 1
function fn(m) {
  return m * 2
}
fn(x + 4)
// 相当于
var thunk = function() {
  return x + 4
}
function fn(thunk) {
  return thunk() * 2
}


上面的示例中,函数 fn 的参数 x + 4 被一个函数替换了。凡是用到原参数的地方,对于 Thunk 函数求值即可。


以下这个是我的疑问?


其实我认为,“传名调用”也是有性能影响的,例如:

var x = 1
function fn(m) {
  return m * m * 2 // 这里我们调整一下,调用两次参数 m
}
fn(x + 4)
// 按前面的定义,自然就变成如下这样
var thunk = function() {
  return x + 4
}
function fn(thunk) {
  return thunk() * thunk() * 2 // 执行了两遍 thunk 函数
}


上面示例中,fn 函数的参数 m 被不止一次地使用,那不是会执行多次 thunk 函数吗?如果这样同样会有性能问题吧。还是说,使用“传名调用”的策略的时候,编译器内部在第一次计算得到结果后,会记录起来。若再有引用,直接取上一次的计算结果,而不是重复执行 Thunk 函数?求解,谢谢!!!


三、JavaScript 语言的 Thunk 函数


JavaScript 是传值调用,它的 Thunk 函数含义有所不同。


在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

以下是 Node.js 中 fs 模块的 readFile 方法,它是一个多参数函数。

fs.readFile('data.json', {}, (err, data) => {
  // do something...
})


那么 Thunk 版的 readFile 如下:

function thunk(path, options) {
  return function (callback) {
    return fs.readFile(path, options, callback)
  }
}
var readFileThunk = thunk('data.json', {})
readFileThunk((err, data) => {
  // do something...
})


上面的示例中,经过 thunk 函数转换处理,它变成了单一参数函数,只接受回调函数作为参数。这个 thunk 函数就被叫做 Thunk 函数。

任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。

const thunk = function(fn) {
  return function(...args) {
    return function(callback) {
      fn.apply(this, ...args, callback)
    }
  }
}


使用上面的转换器,生成 fs.readFile 的 Thunk 函数。

const readFileThunk = thunk(fs.readFile)
readFileThunk('data.json', {})((err, data) => {
  // do something...
})


看到这里,还是没懂这么做意义在哪,感觉多此一举对吧。应用场景后面会讲到。


四、Thunkify 模块


thunkify 模块,将常规 Node 函数转换为返回 Thunk 的函数,这对于基于生成器的流程控制非常有用,例如将其应用于 co

使用方式非常地简单,如下:

$ npm i thunkify

var thunkify = require('thunkify')
var fs = require('fs')
var read = thunkify(fs.readFile)
read('data.json', {})((err, data) => {
  // do something...
})


同样 thunkify源码也很简单,如下:

/**
 * Wrap a regular callback `fn` as a thunk.
 *
 * @param {Function} fn
 * @return {Function}
 * @api public
 */
function thunkify(fn) {
  return function () {
    var args = new Array(arguments.length);
    var ctx = this;
    for (var i = 0; i < args.length; ++i) {
      args[i] = arguments[i];
    }
    return function (done) {
      var called;
      args.push(function () {
        if (called) return; // 确保回调函数 done 只会执行一遍
        called = true;
        done.apply(null, arguments);
      });
      try {
        fn.apply(ctx, args);
      } catch (err) {
        done(err);
      }
    }
  }
};


思路跟前面的大致相同,区别在于它针对回调函数多了一个检查机制,确保回调函数(即源码中的 done)最多只会执行一遍。比如:

function fn(x, y, cb) {
  const sum = x + y
  cb(sum)
  cb(sum)
}
const testThunk = thunkify(fn)
testThunk(1, 2)(sum => {
  console.log(sum) // 3,且只会打印一次
})


这个检查机制,像给前面提出的关于“传名调用”可能存在性能损耗问题,提供了一种思路。但在 JavaScript 中 Thunk 的理解,跟开头提到的 Thunk 函数是有区别的,所以疑问点还在!


五、Generator 与 Thunk


我们都知道 Generator 函数,需要自己实现执行器,自动去执行生成器。

在我认为 Generator 函数,主要用途是自定义迭代器、异步编程。在我印象中,实际项目里几乎没遇到需要自定义迭代器的。跟多的是异步编程中用到 Generator 函数去控制。


但后面 ES2017 标准中,又引入了语法、语义更好的 Async/Await,但尽管如此,也不影响 Generator 的强大和重要性。因为 Async 函数本质上就是 Generator 函数的语法糖而已。


举个例子,

const thunkify = require('thunkify')
const fs = require('fs')
const readFileThunk = thunkify(fs.readFile)
function* generatorFn() {
  const data1 = yield readFileThunk('./js/data.json', 'utf-8')
  console.log('data1', data1)
  const data2 = yield readFileThunk('./js/data.json', 'utf-8')
  console.log('data2', data2)
}


利用 Thunk 函数,我们就可以实现一个 Generator 执行器了,如下:

function runAuto(genFn) {
  const gen = genFn()
  const step = iteratorResult => {
    const { done, value } = iteratorResult
    if (done) return
    // iteratorResult.value 就是 Thunk 函数,
    // 即 readFileThunk('data.json', 'utf-8') 返回值,它返回一个 Thunk 函数。
    value((err, data) => {
      // 只要在其回调中,执行下一步操作,就能达到按“顺序”执行的效果,
      // 为了使 yield 得到对应的值,需要在 next 方法中传入 data。
      step(gen.next(data))
    })
  }
  step(gen.next())
  // 注意,若 Generator 函数中存在异步操作是不能使用类似 while 等语句去迭代其实例的,
  // 例如本实例中,若使用 while 语句就会不断地调用 fs.readFile 读取文件,导致报错!
}


调用方式如下:

runAuto(generatorFn)
// 依次打印出
// data1 "data.json's value"
// data2 "data.json's value"


一般函数内含有 yield 关键字表示含有异步操作,示例中 readFileThunk 就是异步操作。若一个函数内没有异步操作,没必要用 yield 表达式,更没必要使用 Generator 函数(自定义迭代器除外)。

Thunk 函数与 Generator 能联系在一起的挈机,就是因为 Thunk 函数接受一个回调函数作为参数。刚好 Generator 函数某个异步操作的结果与往后的代码有关联,需要在异步操作的回调函数中执行生成器的 next() 方法,那么 yield 关键字后面跟着一个 Thunk 函数,就能达到按编写“顺序”去执行代码的效果了。


前面的 runAuto 方法还有再简化一下:

function runAuto(genFn) {
  const gen = genFn()
  const step = (err, data) => {
    const { done, value } = gen.next(data)
    if (done) return
    // 怕有人不理解,说明一下:
    // 注意 value 就是一个 Thunk 函数,即前面的 readFileThunk(),
    // 它接受一个回调函数,那么我们把 step 传进去就好了。
    value(step)
  }
  step()
}
// 这里没有去捕获 Generator 内部的异常哈,
// 若有需要在 step 内部使用 try...catch 捕获,
// 并使用 gen.throw() 抛出对应原因即可。


⚠️ 请注意,如果按照上述 runAuto 去迭代 Generator 函数,其函数体内的 yield 关键字后面必须是 Thunk 函数。否则将可能会报错


thunkify 模块的作者 TJ Holowaychuk 开源了另一模块: co。它允许 yield 后面跟着一个 Thunk 函数或者是 Promise 对象。因为两种思路是相似的,Thunk 是利用其回到,而 Promise 对象则是利用了当状态发生变化,会触发 thencatch 方法的机制。


如果使用 co 模块,可以这样用:

$ npm i co

const fs = require('fs')
const co = require('co')
const thunkify = require('thunkify')
const readFileThunk = thunkify(fs.readFile)
function* generatorFn() {
  const data1 = yield readFileThunk('./js/data.json', 'utf-8')
  console.log('data1', data1)
  const data2 = yield readFileThunk('./js/data.json', 'utf-8')
  console.log('data2', data2)
}
co(generatorFn)
// 依次打印出
// data1 "data.json's value"
// data2 "data.json's value"


注意,使用 co 包装的 Generator 函数的 yield 表达式接受 Thunk 函数Promise 对象。当使用 Promise 对象的形式,co 就充当了类似 Async 函数内部执行器的角色。

反正自从 Async/Await 面世之后,我接触到的项目,几乎没有人使用 Generator 函数去封装异步流程了,都是全面拥护 Async 了。我猜这个是不是 co 不再更新的原因,是不是它的使命完成了,哈哈。

至于 Async 函数内部执行器是怎么实现的,结合上面的 runAuto 方法,再动下脑子就应该能大致想到了


本文到这里,好像就要完了。


The end.


目录
相关文章
|
1月前
|
JavaScript 前端开发
Generator 函数的执行流程是怎样的?
【10月更文挑战第30天】Generator函数的执行流程通过 `next()` 方法的调用实现暂停和恢复执行,结合 `yield` 关键字以及参数传递和错误处理机制,提供了一种灵活而强大的编程模式,可用于处理异步操作、数据生成与迭代等多种复杂的编程任务。
|
2月前
|
Python
Generator 函数
Generator 函数是 ES6 引入的一种异步编程解决方案,它允许函数执行过程中暂停并保存当前状态,待需要时再恢复执行。通过 `function*` 定义,使用 `yield` 关键字控制暂停点。
|
7月前
|
Android开发
Xposed模块 -- Hook函数参数
Xposed模块 -- Hook函数参数
106 0
手写call-apply-bind以及了解柯里化
手写call-apply-bind以及了解柯里化
|
程序员 编译器 数据库连接
lodash函数传递
lodash函数传递
104 0
lodash函数传递
lodash函数包装
lodash函数包装
107 0
lodash函数包装
Lodash学习之集合调用函数处理
Lodash学习之集合调用函数处理
103 0
Lodash学习之集合调用函数处理
|
前端开发
es6 Promise,生成器函数,async
es6 Promise,生成器函数,async
|
测试技术 API
【pytest】使用parametrize将参数化变量传递到fixture
【pytest】使用parametrize将参数化变量传递到fixture