Gatsby Starter Blog

工程化: promise is monad

January 12, 2020

本文译自 https://blog.jcoglan.com/2011/03/11/promises-are-the-monad-of- asynchronous-programming/

上文中我们看到了几个 monads 的例子并且研究了其共通之处来阐明其底层的设计模式。在我讲述如何将其运用到异步编程之前我们需要先讨论下泛型。

看一下我们之前实现的 list monad:

var compose = function(f, g) {
  return function(x) { return f(g(x)) };
};

// unit :: a -> [a]
var unit = function(x) { return [x] };

// bind :: (a -> [a]) -> ([a] -> [a])
var bind = function(f) {
  return function(list) {
    var output = [];
    for (var i = 0, n = list.length; i < n; i++) {
      output = output.concat(f(list[i]));
    }
    return output;
  };
}

在我们之前的例子中,我们实现了一个函数,其接受一个 HTMLElement 元素,并返回 HTMLElement 的数组。注意到上面 bind 的函数签名为(a -> [a]) -> ([a] -> [a])。那个‘a’表示我们可以在那里放入任何类型,但是类型 a -> [a] 表示函数必须返回一个数组且数组元素的类型必须与输入参数的类型一致。实际上并不是这样,正确的签名如下

bind :: (a -> [b]) -> ([a] -> [b])

例如 bind 可以接受类型为 String ->[HTMLElements]的函数,返回一个类型为[String] -> [HTMLElements]的函数。考虑如下两个函数:第一个函数接受一个 tagName 字符串返回所有 tag 类型为 tagName 的节点,第二个函数接受一个节点返回该节点的所有 class 名。

// byTagName :: String -> [HTMLElement]
var byTagName = function(name) {
  var nodes = document.getElementsByTagName(name);
  return Array.prototype.slice.call(nodes);
};

// classNames :: HTMLElement -> [String]
var classNames = function(node) {
  return node.className.split(/\s+/);
};

如果我们忽略掉其返回列表这一方面,那么我们可以符合这两函数得到一个函数其接受一个 tagName 返回所有 tag 为 tagName 的节点的 className 列表。

var classNamesByTag = compose(classNames, byTagName);

当然我们必须考虑列表,但是 monad 仅仅关心列表,并不关心列表里存了什么,我们可以利用它进行函数复合:

// classNamesByTag :: [String] -> [String]
var classNamesByTag = compose(bind(classNames), bind(byTagName));

classNamesByTag(unit('a'))
// -> ['profile-links', 'signout-button', ...]

monad 仅关心’…的列表‘,并不关心列表的内容,我们同样可以使用管道语法。

// bind :: [a] -> (a -> [b]) -> [b]
var bind = function(list, f) {
  var result = [];
  for (var i = 0, n = list.length; i < n; i++) {
    result = result.concat(f(list[i]));
  }
  return result;
};

// pipe :: [a] -> [a -> [b]] -> [b]
var pipe = function(x, functions) {
  for (var i = 0, n = functions.length; i < n; i++) {
    x = bind(x, functions[i]);
  }
  return x;
};

// for example
pipe(unit('a'), [byTagName, classNames])
// -> ['profile-links', 'signout-button', ...]

pipe 函数并不关心 bind 是否与列表相关,我们可以得到 pipe 的更宽泛的类型签名:

pipe :: m a -> [a -> m b] -> m b

该记法中,m a 表示一个 monad 的包装器值。例如如果输入是一个字符串列表,对于 List monad 而言 m 表示’…的列表’而 a 表示字符串。当我们考虑到将这些方法应用到异步编程时更宽泛的容器概念显得更加重要。

对于 Node.js 众所周知的指责就是其很容易落入回调地狱(callback hell),即回调一层嵌一层使得代码难以维护。存在很多解决此问题的方法,但是我认为 monads 提供了一个更加有趣的方法,其强调需要把业务逻辑与粘结数据的代码相分离。

我们考虑一个人造的案例,如果我们有一个文件为 urls.json,其包含了含有一个 URL 的 json。我们需要读取该文件,获取 URL 值,使用该 URL 值发起 HTTP 请求,打印返回的内容。Monads 鼓励我们从数据类型的思路思考,如何控制数据在管道中流动。我们可以使用下面的管道语法进行建模:

pipe(unit(__dirname + '/urls.json'),
        [ readFile,
          getUrl,
          httpGet,
          responseBody,
          print         ]);

从文件路径名(一个字符串)开始,我们可以追踪数据在管道中的流动:

  • readFile 接收一个 String(路径名)返回一个字符串(文件内容)
  • getUrl 接收一个 String(一个 JSON 文档)返回一个 URI 对象
  • httpGet 接收一个 URI 对象,返回一个 Response
  • responseBody 接收一个 Response·返回一个 String
  • print 接收一个 String 返回空

所以队列中的每一环都为下一位生成数据。但是在 Node 中,这里的许多操作都是异步的。与其使用 CPS(continuation-passing- style)和内嵌回调,我们如何修改这些函数来表示其返回值得结果可能还是未知的?通过把其包装进 Promise 即可:

readFile      :: String   -> Promise String
getUrl        :: String   -> Promise URI
httpGet       :: URI      -> Promise Response
responseBody  :: Response -> Promise String
print         :: String   -> Promise null

这个 Promise monad 需要三样东西:一个包装对象,一个 unit 函数将一个值包装进包装对象里,一个 bind 函数帮助复合这些函数。我们可以使用 JS.Class 里的 Deferrable 实现 promise:

var Promise = new JS.Class({
  include: JS.Deferrable,

  initialize: function(value) {
    // if value is already known, succeed immediately
    if (value !== undefined) this.succeed(value);
  }
});

有了这个类我们就可以解决我们的问题了。立即返回值得那些函数可以返回一个包装的 Promise 对象。

// readFile :: String -> Promise String
var readFile = function(path) {
  var promise = new Promise();
  fs.readFile(path, function(err, content) {
    promise.succeed(content);
  });
  return promise;
};

// getUrl :: String -> Promise URI
var getUrl = function(json) {
  var uri = url.parse(JSON.parse(json).url);
  return new Promise(uri);
};

// httpGet :: URI -> Promise Response
var httpGet = function(uri) {
  var client  = http.createClient(80, uri.hostname),
      request = client.request('GET', uri.pathname, {'Host': uri.hostname}),
      promise = new Promise();

  request.addListener('response', function(response) {
    promise.succeed(response);
  });
  request.end();
  return promise;
};

// responseBody :: Response -> Promise String
var responseBody = function(response) {
  var promise = new Promise(),
      body    = '';

  response.addListener('data', function(c) { body += c });
  response.addListener('end', function() {
    promise.succeed(body);
  });
  return promise;
};

// print :: String -> Promise null
var print = function(string) {
  return new Promise(sys.puts(string));
};

至此我们可以使用数据类型来表示一个延迟的值,不再需要回调了。下面就是实现 unit 和 bind 使的 callback 包含在这些函数里。unit 实现很简单,仅需要使用 Promise 包装值即可。bind 略显复杂:其接受一个 Promise 对象和一个函数,其必须提取出 Promise 里的值,将该值传递给函数,其返回另一个 Promise 对象,并等待最终的 Promise 对象完成。

// unit :: a -> Promise a
var unit = function(x) {
  return new Promise(x);
};

// bind :: Promise a -> (a -> Promise b) -> Promise b
var bind = function(input, f) {
  var output = new Promise();
  input.callback(function(x) {
    f(x).callback(function(y) {
      output.succeed(y);
    });
  });
  return output;
};

利用上述定义,我们可以发现之前的管道可以正常工作,例如我已经把我的 GitHub API URL 放在 url.json 文件里:

pipe(unit(__dirname + '/urls.json'),
        [ readFile,
          getUrl,
          httpGet,
          responseBody,
          print         ]);

// prints:
// {"user":{"name":"James Coglan","location":"London, UK", ...

我希望这可以说明 Promise(在 JQuery 和 Dojo 被称为 Deferred)为什么重要,其可以怎样帮助你控制数据在管道中流动和管道如何巧妙的粘结。对于客户端,我们发现该技巧同样适用于 Ajax 和动画;事实上 MethodChain (我作为异步技巧已使用多年)就是 Promise monad 其适用于方法调用而非函数调用。

上述所有的代码都在我的 GitHub 上。至此你可以深入学习 jQuery’s Deferred API 。Node 中同样也设计 promise,但是其被 CPS 所取代,你会发现你未来无须如此麻烦就可实现这些功能。