Open toFrankie opened 1 year ago
关于 Thunk 这个词,其实第一次看到是 redux-thunk 库。还长时间内都没有理解 “Thunk” 是什么意思,当初想可能只是类似 Foo、Bar 等,就一个名称罢了。
早在上世纪 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
5
fn
若对于“传名调用”的话,直接将表达式 x + 4 传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。
至于“传值调用”和“传名调用”,哪一种比较好?
回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上没用到这个参数,有可能造成性能损失。
var x = 5 function fn(m, n) { return n } fn(8 * x * x - 3 * x -1, x)
上面示例中,如果采用“传值调用”的策略,函数 fn 的第一个参数是一个复杂的表达式,但是函数体内根本没用到,对这个参数求值,实际上是没必要的。因此,有些计算机科学家倾向于“传名调用”。
编译器的“传名调用”实现,往往是将参数放到一个临时函数中,再将这个临时函数传入函数体。这个临时函数就被叫做 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 函数?求解,谢谢!!!
m
thunk
JavaScript 是传值调用,它的 Thunk 函数含义有所不同。
在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。
以下是 Node.js 中 fs 模块的 readFile 方法,它是一个多参数函数。
fs
readFile
fs.readFile('data.json', {}, (err, data) => { // do something... })
那么 Thunk 版的 readFile 如下:
Thunk
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 函数。
fs.readFile
const readFileThunk = thunk(fs.readFile) readFileThunk('data.json', {})((err, data) => { // do something... })
看到这里,还是没懂这么做意义在哪,感觉多此一举对吧。应用场景后面会讲到。
thunkify 模块,将常规 Node 函数转换为返回 Thunk 的函数,这对于基于生成器的流程控制非常有用,例如将其应用于 co。
thunkify
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)最多只会执行一遍。比如:
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 函数,需要自己实现执行器,自动去执行生成器。
在我认为 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 函数(自定义迭代器除外)。
yield
readFileThunk
Thunk 函数与 Generator 能联系在一起的挈机,就是因为 Thunk 函数接受一个回调函数作为参数。刚好 Generator 函数某个异步操作的结果与往后的代码有关联,需要在异步操作的回调函数中执行生成器的 next() 方法,那么 yield 关键字后面跟着一个 Thunk 函数,就能达到按编写“顺序”去执行代码的效果了。
next()
前面的 runAuto 方法还有再简化一下:
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 方法的机制。
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.
关于 Thunk 这个词,其实第一次看到是 redux-thunk 库。还长时间内都没有理解 “Thunk” 是什么意思,当初想可能只是类似 Foo、Bar 等,就一个名称罢了。
一、Thunk
早在上世纪 60 年代 Thunk 函数就诞生了。那时候,编程语言刚起步,计算机学家还在研究,编译器怎么写比较好。其中一个争论的焦点是“求值策略”,即函数的参数到底应何时求值?
存在两派意见:
比如,以下示例:
对于“传值调用”的话,在进入函数体之前,计算
x + 4
的值(等于5
),再将这个值传入函数fn
。JavaScript、C 语言就是采用这种策略。若对于“传名调用”的话,直接将表达式
x + 4
传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。至于“传值调用”和“传名调用”,哪一种比较好?
回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上没用到这个参数,有可能造成性能损失。
上面示例中,如果采用“传值调用”的策略,函数
fn
的第一个参数是一个复杂的表达式,但是函数体内根本没用到,对这个参数求值,实际上是没必要的。因此,有些计算机科学家倾向于“传名调用”。二、Thunk 函数的含义
编译器的“传名调用”实现,往往是将参数放到一个临时函数中,再将这个临时函数传入函数体。这个临时函数就被叫做 Thunk 函数。
上面的示例中,函数
fn
的参数x + 4
被一个函数替换了。凡是用到原参数的地方,对于 Thunk 函数求值即可。以下这个是我的疑问?
其实我认为,“传名调用”也是有性能影响的,例如:
上面示例中,
fn
函数的参数m
被不止一次地使用,那不是会执行多次thunk
函数吗?如果这样同样会有性能问题吧。还是说,使用“传名调用”的策略的时候,编译器内部在第一次计算得到结果后,会记录起来。若再有引用,直接取上一次的计算结果,而不是重复执行 Thunk 函数?求解,谢谢!!!三、JavaScript 语言的 Thunk 函数
JavaScript 是传值调用,它的 Thunk 函数含义有所不同。
在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。
以下是 Node.js 中
fs
模块的readFile
方法,它是一个多参数函数。那么
Thunk
版的readFile
如下:上面的示例中,经过
thunk
函数转换处理,它变成了单一参数函数,只接受回调函数作为参数。这个thunk
函数就被叫做 Thunk 函数。任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。
使用上面的转换器,生成
fs.readFile
的 Thunk 函数。四、Thunkify 模块
thunkify
模块,将常规 Node 函数转换为返回 Thunk 的函数,这对于基于生成器的流程控制非常有用,例如将其应用于co
。使用方式非常地简单,如下:
同样
thunkify
的源码也很简单,如下:思路跟前面的大致相同,区别在于它针对回调函数多了一个检查机制,确保回调函数(即源码中的
done
)最多只会执行一遍。比如:五、Generator 与 Thunk
我们都知道 Generator 函数,需要自己实现执行器,自动去执行生成器。
在我认为 Generator 函数,主要用途是自定义迭代器、异步编程。在我印象中,实际项目里几乎没遇到需要自定义迭代器的。跟多的是异步编程中用到 Generator 函数去控制。
举个例子,
利用 Thunk 函数,我们就可以实现一个 Generator 执行器了,如下:
调用方式如下:
一般函数内含有
yield
关键字表示含有异步操作,示例中readFileThunk
就是异步操作。若一个函数内没有异步操作,没必要用yield
表达式,更没必要使用 Generator 函数(自定义迭代器除外)。Thunk 函数与 Generator 能联系在一起的挈机,就是因为 Thunk 函数接受一个回调函数作为参数。刚好 Generator 函数某个异步操作的结果与往后的代码有关联,需要在异步操作的回调函数中执行生成器的
next()
方法,那么yield
关键字后面跟着一个 Thunk 函数,就能达到按编写“顺序”去执行代码的效果了。前面的
runAuto
方法还有再简化一下:thunkify
模块的作者 TJ Holowaychuk 开源了另一模块:co
。它允许yield
后面跟着一个 Thunk 函数或者是Promise
对象。因为两种思路是相似的,Thunk 是利用其回到,而Promise
对象则是利用了当状态发生变化,会触发then
或catch
方法的机制。如果使用
co
模块,可以这样用:注意,使用
co
包装的 Generator 函数的 yield 表达式接受 Thunk 函数或Promise
对象。当使用Promise
对象的形式,co
就充当了类似 Async 函数内部执行器的角色。反正自从 Async/Await 面世之后,我接触到的项目,几乎没有人使用 Generator 函数去封装异步流程了,都是全面拥护 Async 了。我猜这个是不是
co
不再更新的原因,是不是它的使命完成了,哈哈。至于 Async 函数内部执行器是怎么实现的,结合上面的
runAuto
方法,再动下脑子就应该能大致想到了,具体可以看下我的另外一篇文章,文中末尾有介绍。本文到这里,好像就要完了。
The end.