关于 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.
网友评论