前言
关于 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
对象则是利用了当状态发生变化,会触发 then
或 catch
方法的机制。
如果使用 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.