Thunk 函式的含義和用法

阮一峰發表於2015-05-01

本文是《深入掌握 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 的自動執行器。

(完)

相關文章