toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

Thunk 函数与 Generator 函数 #261

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

关于 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.