Thunk 函数的含义和用法

作者: 阮一峰

日期: 2015年5月 1日

本文是《深入掌握 ECMAScript 6 异步编程》系列文章的第二篇。

一、参数的求值策略

Thunk函数早在上个世纪60年代就诞生了。

那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是"求值策略",即函数的参数到底应该何时求值。


var x = 1;

function f(m){
  return m * 2;     
}

f(x + 5)

上面代码先定义函数 f,然后向它传入表达式 x + 5 。请问,这个表达式应该何时求值?

一种意见是"传值调用"(call by value),即在进入函数体之前,就计算 x + 5 的值(等于6),再将这个值传入函数 f 。C语言就采用这种策略。


f(x + 5)
// 传值调用时,等同于
f(6)

另一种意见是"传名调用"(call by name),即直接将表达式 x + 5 传入函数体,只在用到它的时候求值。Hskell语言采用这种策略。


f(x + 5)
// 传名调用时,等同于
(x + 5) * 2

传值调用和传名调用,哪一种比较好?回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。


function f(a, b){
  return b;
}

f(3 * x * x - 2 * x - 1, x);

上面代码中,函数 f 的第一个参数是一个复杂的表达式,但是函数体内根本没用到。对这个参数求值,实际上是不必要的。

因此,有一些计算机学家倾向于"传名调用",即只在执行时求值。

二、Thunk 函数的含义

编译器的"传名调用"实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。


function f(m){
  return m * 2;     
}

f(x + 5);

// 等同于

var thunk = function () {
  return x + 5;
};

function f(thunk){
  return thunk() * 2;
}

上面代码中,函数 f 的参数 x + 5 被一个函数替换了。凡是用到原参数的地方,对 Thunk 函数求值即可。

这就是 Thunk 函数的定义,它是"传名调用"的一种实现策略,用来替换某个表达式。

三、JavaScript 语言的 Thunk 函数

JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。


// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);

// Thunk版本的readFile(单参数版本)
var readFileThunk = Thunk(fileName);
readFileThunk(callback);

var Thunk = function (fileName){
  return function (callback){
    return fs.readFile(fileName, callback); 
  };
};

上面代码中,fs 模块的 readFile 方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。

任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。


var Thunk = function(fn){
  return function (){
    var args = Array.prototype.slice.call(arguments);
    return function (callback){
      args.push(callback);
      return fn.apply(this, args);
    }
  };
};

使用上面的转换器,生成 fs.readFile 的 Thunk 函数。


var readFileThunk = Thunk(fs.readFile);
readFileThunk(fileA)(callback);

四、Thunkify 模块

生产环境的转换器,建议使用 Thunkify 模块

首先是安装。


$ npm install thunkify

使用方式如下。


var thunkify = require('thunkify');
var fs = require('fs');

var read = thunkify(fs.readFile);
read('package.json')(function(err, str){
  // ...
});

Thunkify 的源码与上一节那个简单的转换器非常像。


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;
        called = true;
        done.apply(null, arguments);
      });

      try {
        fn.apply(ctx, args);
      } catch (err) {
        done(err);
      }
    }
  }
};

它的源码主要多了一个检查机制,变量 called 确保回调函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子。


function f(a, b, callback){
  var sum = a + b;
  callback(sum);
  callback(sum);
}

var ft = thunkify(f);
ft(1, 2)(console.log); 
// 3

上面代码中,由于 thunkify 只允许回调函数执行一次,所以只输出一行结果。

五、Generator 函数的流程管理

你可能会问, Thunk 函数有什么用?回答是以前确实没什么用,但是 ES6 有了 Generator 函数,Thunk 函数现在可以用于 Generator 函数的自动流程管理。

以读取文件为例。下面的 Generator 函数封装了两个异步操作。


var fs = require('fs');
var thunkify = require('thunkify');
var readFile = thunkify(fs.readFile);

var gen = function* (){
  var r1 = yield readFile('/etc/fstab');
  console.log(r1.toString());
  var r2 = yield readFile('/etc/shells');
  console.log(r2.toString());
};

上面代码中,yield 命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数。

这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数。为了便于理解,我们先看如何手动执行上面这个 Generator 函数。


var g = gen();

var r1 = g.next();
r1.value(function(err, data){
  if (err) throw err;
  var r2 = g.next(data);
  r2.value(function(err, data){
    if (err) throw err;
    g.next(data);
  });
});

上面代码中,变量 g 是 Generator 函数的内部指针,表示目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)。

仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用递归来自动完成这个过程。

六、Thunk 函数的自动流程管理

Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。


function run(fn) {
  var gen = fn();

  function next(err, data) {
    var result = gen.next(data);
    if (result.done) return;
    result.value(next);
  }

  next();
}

run(gen);

上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。

有了这个执行器,执行 Generator 函数方便多了。不管有多少个异步操作,直接传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。


var gen = function* (){
  var f1 = yield readFile('fileA');
  var f2 = yield readFile('fileB');
  // ...
  var fn = yield readFile('fileN');
};

run(gen);

上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。本系列的下一篇,将介绍基于 Promise 的自动执行器。

(完)

留言(34条)

满满的都是函数式编程的方式,thunk,就是柯里化 的概念,一直不太明白柯里化的用处在哪里,generator的自动切换,也一直没有研究透(python的gevent也是类似实现),看了这篇文章,似是搞明白了。

Haskell用的是 call-by-need 策略,比 call-by-value 更优化。如果某个参数传入而没被使用,Haskell根本就不去计算这个参数的值。

传值调用和传名调用部分的解释很赞,讲的很有节奏,顿时理解的更透彻了!非常感谢!

Thunkify 的实现中,对于包装函数执行时的上下文对象没有处理,例如以对象方法形式调用的函数,可能无法正常执行:


var o = { name: 'luobotang' };
o.hi = function () { console.log('Hi, I am ' + this.name); }

thunkify(o.hi)()(); // ?


@luobotang:

thunkify 确实没绑定this,我觉得可能因为各种情况难以兼顾,就把它留给开发者自己来做。

下面的方式都可以正确返回结果。

thunkify(o.hi.bind(o))()();
thunkify(o.hi).bind(o)()();
thunkify(o.hi).call(o)();
thunkify(o.hi).apply(o)();

引用DamonChen的发言:

满满的都是函数式编程的方式,thunk,就是柯里化 的概念,一直不太明白柯里化的用处在哪里,generator的自动切换,也一直没有研究透(python的gevent也是类似实现),看了这篇文章,似是搞明白了。

忍不住说一句, 柯里化不是thunk, 柯里化就是柯里化, 为什么要柯里化是为了部分配置,重用函数,提供更好地接口
http://blog.oyanglul.us/javascript/functional-javascript.html#sec-2-2

阮老师写的真棒!

必须支持阮老师, 学习到好多理论基础,既深入又易懂

r1.value(function(err, data){
  if (err) throw err;
  var r2 = g.next(data);
  r2.value(function(err, data){
    if (err) throw err;
    g.next(data);
  });
});
没太明白,这里的 r1.value()是什么意思?value不是一个属性吗?可以当函数调用?

像这样的参数怎么Thunk化啊

function fetch(options) {
setTimeout(
function () {
options.callback(options.name);
},
1000
)
}

引用Jeff的发言:

r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});

没太明白,这里的 r1.value()是什么意思?value不是一个属性吗?可以当函数调用?

value是thunk

原来thunk的历史是这样的,最近在学习koa的时候,想看下co的发展过程,co从最初的thunk朝着promise发展,然后就想看下thunk的由来,直接在老师这里找到,太开心了

引用oyanglul.us的发言:

忍不住说一句, 柯里化不是thunk, 柯里化就是柯里化, 为什么要柯里化是为了部分配置,重用函数,提供更好地接口
http://blog.oyanglul.us/javascript/functional-javascript.html#sec-2-2

thunk 是柯里化的子集

 generator函数的yield必须是返回的异步函数才可以这么调用

引用Jeff的发言:

r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});

没太明白,这里的 r1.value()是什么意思?value不是一个属性吗?可以当函数调用?

网上没有找到介绍 trunk 和 curry 区别的文章, google: js trunk and curry ,诸神可否指个明路?

阮一峰你好,我是王大虎

引用zincing的发言:

网上没有找到介绍 trunk 和 curry 区别的文章, google: js trunk and curry ,诸神可否指个明路?

同问,阮老师,用Thunk确实可以控制Generator的流程,但感觉例子用柯里化也同样能实现: var readFileThunk = fs.readFile.bind(null, './sample.txt', 'utf8'); readFileThunk(someCallback); Thunk将多参的函数转为单参,和柯里化相比有什么优势吗?

我大 "Haskell" 被写成了 "Hskell"

引用题叶的发言:

我大 "Haskell" 被写成了 "Hskell"

确实, "Haskell" 被错误的拼成了 “Hskell”

co是如何知道每次的回调是否执行完成了呢? promise是知道的

不好意思阮大!在第二節'Thunk 函数的含义'中,範例代碼:

function f(thunk){
return thunk() * 2;
}

是不是要改成:

function f(thunk){
return thunk * 2;
}
才比較合理啊,這樣才可以在調用時呼叫thunk函數來賦值
f(thunk(1));

Thunkify 的那个源码 转换arguments 的时候 为什么不想简易版的一样 使用 Array.prototye.slice.call

引用Jeff的发言:

r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});

没太明白,这里的 r1.value()是什么意思?value不是一个属性吗?可以当函数调用?

这里返回的value 是一个函数。

var g = gen();

var r1 = g.next();
r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});

这种结构难道不算回调地狱吗?

var thunk = function () {
return x + 5;
};

function f(thunk){
return thunk() * 2;
}
这一段 如果 f(thunk)执行就会抛错 x 是未定义的代码就错误了如果改写成
var thunk = function(x){
return x + 5
}
那就和传值调用一样了。

var fs = require('fs');
var thunkify = require('thunkify');
var readFile = thunkify(fs.readFile);

var gen = function* (){
var r1 = yield readFile('/etc/fstab');
console.log(r1.toString());
var r2 = yield readFile('/etc/shells');
console.log(r2.toString());
};
这个代码有问题吧?thunkify包装过的函数只有在传递callback时才会调用原函数,上面的例子缺少最后一个callback参数,r1得到的只是一个中间的function。

thunk和carry有什么关系?

var Thunk = function(fn){
  return function (){
    var args = Array.prototype.slice.call(arguments);
    return function (callback){
      args.push(callback);
      return fn.apply(this, args);
    }
  };
};

这里的 return fn.apply(this, args) 里的 this 绑定有点迷,到底指向的是什么呢

这里的

阅读笔记:

chunk 的作用,将函数由按值传递改变为按名传递
即原来的调用是 sum(1 + 2) => sum (3)
f的函数原型为(a,b, callback)
变成了 f(1, 2)(sum) => 因此当执行sum的时候f(1, 2)才会被调用

chunkify 的作用是
将f包装成 f(a,b)并返回callback,并且对callback执行前做了处理:只执行一次

chunkify 在 文件读取中应用的例子:
函数原型: fs.readFile(fileName, callback);
在chunkify的包装下,变成了 readFile(filename) 并且返回 callback

在 Generator 中的应用:
readFile可以通过 Generator 发送值到 yield 中
yield中又可以将callback返回

“其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用递归来自动完成这个过程” , 想了很久也没明白,value只是迭代器对象的一个值,为什么可以传入一个next方法..这个是哪里的语法

我觉得的thunkify实现得不好,考虑几个case

const callback1 = (arg1) => {
console.log("callback1 is called", arg1)
}

const callback2 = (arg1) => {
console.log("callback2 is called", arg1)
}

const originalFoo = (arg1, callback) => {
console.log('originalFoo is called');
callback(arg1);
}

const thunkGen = thunkify(originalFoo);
const thunk = thunkGen('hello');

thunk(callback1);
thunk(callback2);


期望的结果是

originalFoo is called
callback1 is called hello
callback2 is called hello


实际出来的结果是

originalFoo is called
callback1 is called hello
originalFoo is called

感谢阮大分享,看完文章后感觉thunk与柯里化有点类似。 都是多参变单参。 但还是总觉得这两者有区别。 能都对比分析一下这两者的区别呢

老师您好,我将您的部分内容放入我的笔记(可能会发布到互联网)里边可以吗?

我要发表看法

«-必填

«-必填,不公开

«-我信任你,不会填写广告链接