美文网首页细品 JavaScript
Thunk 函数与 Generator 函数

Thunk 函数与 Generator 函数

作者: 越前君 | 来源:发表于2021-08-12 20:08 被阅读0次

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

    相关文章

      网友评论

        本文标题:Thunk 函数与 Generator 函数

        本文链接:https://www.haomeiwen.com/subject/zejcbltx.html