Gatsby Starter Blog

工程化:Javascript中的Monad

January 22, 2020

本文翻译自 https://blog.jcoglan.com/2011/03/05/translation-from-haskell-to- javascript-of-selected-portions-of-the-best-introduction-to-monads-ive-ever- read/ ,主要介绍 Monad 在 Javascript 中的应用

我知道世界上不需要再来一篇介绍 monads 的文章(或者另外一篇抱怨世界上不需要再来一篇介绍 monads 文章的文章)。我写这篇文章的初衷在于,首先 monads 值得了解,其次我想解释其如何与异步编程相关联,另外我也想为我下篇文章做铺垫。这也是关于类型一个很好的练习。如果你了解一点 Haskell,我强烈推荐你看这篇原文 You Could Have Invented Monads (And Maybe You Already Have)

首先介绍点背景知识,Monads 在 Haskell 中如此流行在于其仅仅允许纯函数存在,即不存在副作用的函数。纯函数接收输入作为参数吐出输出作为返回值,就是这么简单。我使用的其他语言(Ruby 和 JavaScript)并不包含这些限制,但是我们仍然可以人为施加这些限制。典型的 monad 介绍会告诉你 monad 完全是关于如何在你的模型中注入副作用使得你可以做 I/O,但仅是一部分功能。Monads 正如我们即将看到,实际上是关于如何组合函数的。

来看一个例子,假如你有一个计算一个数的 sine 值的函数,用 Javascript 可以表述如下:

var sine = function(x) { return Math.sin(x) }

另外你还有一个计算数的立方的函数

var cube = function(x) { return x * x * x };

这些函数输入一个数输出一个数,使得其可以很容易的做复合,你可以把前者的输出作为后者的输入:

var sineCubed = cube(sine(x));

我们可以定义一个函数来封装复合操作,其接受两个函数 f 和 g,返回一个函数完成 f(g(x))运算

var compose = function(f, g) {
  return function(x) {
    return f(g(x));
  };
};
var sineOfCube = compose(sine, cube);
var y = sineOfCube(x);

接下来如果我们打算调试该函数,那么我们需要记录它们曾被调用,我们可能向下面这样做

var cube = function(x) {
  console.log('cube was called.');
  return x * x * x;
};

但是在一个仅允许存在纯函数的系统里我们没法这样做:console.log()带有副作用。如果我们想要捕捉日志信息,其必须作为返回值的一部分。我们修改我们的函数时期返回一个序对:计算结果和调试信息:

var sine = function(x) {
  return [Math.sin(x), 'sine was called.'];
};

var cube = function(x) {
  return [x * x * x, 'cube was called.'];
};

但是我们现在发现这两个函数无法做复合操作了

cube(3) // -> [27, 'cube was called.']
compose(sine, cube)(3) // -> [NaN, 'sine was called.']

这由两个原因导致:sine 尝试计算一个数组,其结果为 NaN 而且我们丢失了调用 cube 返回的调试信息。我们希望两个函数的复合返回 sine(cube(x))的结果和一个包含 cube 和 sine 被调用的调试信息:

compose(sine, cube)(3)
// -> [0.956, 'cube was called.sine was called.']

一个简单的复合函数明显不起作用,因为 cube 的返回类型(数组)和 sine 的输入类型(数字)不匹配。我们需要一些额外的工作。我们可以写一个函数来复合这两个“包含调试信息”的函数,其可以拆分它们的返回值然后再按有意义的方式拼接回去:

var composeDebuggable = function(f, g) {
  return function(x) {
    var gx = g(x),      // e.g. cube(3) -> [27, 'cube was called.']
        y  = gx[0],     //                 27
        s  = gx[1],     //                 'cube was called.'
        fy = f(y),      //     sine(27) -> [0.956, 'sine was called.']
        z  = fy[0],     //                 0.956
        t  = fy[1];     //                 'sine was called.'

    return [z, s + t];
  };
};

composeDebuggable(sine, cube)(3)
// -> [0.956, 'cube was called.sine was called.']

我们复合了两个输入为数字返回为数字+字符串序对的函数,然后生成了一个同样类型签名的函数,意味着我们可以进一步的组合这些函数。

为了简化说明,我这里需要借用 Haskell 的记法。下面的类型说明 cube 接受一个数作为输入并且返回一个包含数字和字符串的序对:

cube :: Number -> (Number,String)

这是我们所有的调试函数和其复合函数的类型签名。我们原始的函数有一个更简单的签名 Number -> Number;参数和返回值的对称性使其很容易进行复合操作。与其为我们的调试函数自定义复合函数,我们不如简化我们函数的类型签名:

cube :: (Number,String) -> (Number,String)

这样我们就可以使用原始的 compose 函数来进行复合操作了。我们需要重写 cube 和 sine 使其接受参数为(Number,String)而非 Number,但是这样明显没有伸缩性,你可能需要重写你所有的函数。与其重写每个函数,我们提供了一个工具将自动将函数类型转换为我们需要的类型。我们把这个操作称为 bind,其主要负责将一个类型为 Number -> (Number,String)的函数转换为一个类型为(Number,String) -> (Number,String)的函数。

var bind = function(f) {
  return function(tuple) {
    var x  = tuple[0],
        s  = tuple[1],
        fx = f(x),
        y  = fx[0],
        t  = fx[1];

    return [y, s + t];
  };
};

我们可以使用该函数对我们的函数进行变换,使其类型更容易复合。

var f = compose(bind(sine), bind(cube));
f([3, '']) // -> [0.956, 'cube was called.sine was called.']

但是现在所有的函数的参数类型都为(Number,String),但是我们有时仅需要传递一个 Number 作为参数。如同变换函数,我们需要一个方法将一个值变为我们可接受的类型,所以我们需要如下函数

unit :: Number -> (Number,String)

unit 的作用接受一个值然后将其包装进一个容器内,使其可以作为我们函数的参数。对于我们的调试函数而言,我们仅需要将其与一个字符串包装进一个序列对里

// unit :: Number -> (Number,String)
var unit = function(x) { return [x, ''] };

var f = compose(bind(sine), bind(cube));
f(unit(3)) // -> [0.956, 'cube was called.sine was called.']

// or ...
compose(f, unit)(3) // -> [0.956, 'cube was called.sine was called.']

这个 unit 函数使得我们可以将任何函数转换为调试函数,只需要将其输出参数转换为调试函数的参数类型即可

// round :: Number -> Number
var round = function(x) { return Math.round(x) };

// roundDebug :: Number -> (Number,String)
var roundDebug = function(x) { return unit(round(x)) };

我们同样将这种变换抽象为一个函数,称为 lift。类型签名表明 lift 接受一个 Number -> Number 的函数,返回一个 Number -> (Number,String)的函数

// lift :: (Number -> Number) -> (Number -> (Number,String))
var lift = function(f) {
  return function(x) {
    return unit(f(x));
  };
};

// or, more simply:
var lift = function(f) { return compose(unit, f) };

让我们尝试一下现有的函数看能否正常工作

var round = function(x) { return Math.round(x) };

var roundDebug = lift(round);

var f = compose(bind(roundDebug), bind(sine));
f(unit(27)) // -> [1, 'sine was called.']

迄今我们已经发现三种重要的抽象可以用来帮助我们复合调试函数

  • lift,其可以将一个’简单’的函数转换为调试函数
  • bind,其可以将一个调试函数转换为一个可复合函数
  • unit,其可以将一个简单的值通过将其放置于一个容器内将其转换为需要的类型

这些抽象(实际上只有 bind 和 unit)定义了一个 monad。在 Haskell 标准库中这被称为 Writer monad。可能现在还难以看出这种模式的组成,我们看下一例子

一个你常见的问题可能是判断一个函数的参数是一个元素还是一组元素。区别在于一组元素的话需要将操作包含在一个 for 循环里,这都是一些样式代码。但是这对你是否能对你的函数进行复合操作很有影响。举例来说,如果你的函数接受一个 DOM 节点作为参数并将其所有的孩子节点作为一个数组返回;那么该函数的签名为 HTMLElement -> [HtmlElement].

// children :: HTMLElement -> [HTMLElement]
var children = function(node) {
  var children = node.childNodes, ary = [];
  for (var i = 0, n = children.length; i < n; i++) {
    ary[i] = children[i];
  }
  return ary;
};

// e.g.
var heading = document.getElementsByTagName('h3')[0];
children(heading)
// -> [
//      "Translation from Haskell to JavaScript...",
//      <span class=​"edit">​…​</span>​
//    ]

加入我们现在需要查找 heading 的所有孙子节点,直觉上我们可能这么做

var grandchildren = compose(children, children)

但是 children 函数的输入输出并没有对称性,所以我们不能像上面一样组合。如果我们需要手动定义 grandchildren 函数,则可能如下所示:

// grandchildren :: HTMLElement -> [HTMLElement]
var grandchildren = function(node) {
  var output = [], childs = children(node);
  for (var i = 0, n = childs.length; i < n; i++) {
    output = output.concat(children(childs[i]));
  }
  return output;
};

我们仅需要查找所有孩子的孩子节点然后将结果进行拼接即可得到所有的孙子节点。但是这样写很不方便,实际上这里包含了许多与问题无关的但是处理列表所需的模板代码。我们(todo)

考虑之前的例子,我们需要两个步骤来解决这个问题:提供一个 bind 函数将 children 类型转换为一个可复合的类型,写一个 unit 函数将初始输入 heading,转换为一个可接受的类型。

问题的核心在于我们的函数接受一个 HTMLElement 输入但是返回一组 HTMLElement,因此我们需要考虑如何将一个元素转换为一组和如何将一组元素转换为一个元素。事实上这些元素的类型是 HTMLElement 无关紧要,在 Haskell 中当某个具体的类型可能发生变化时我们用一个字母去占位。unit 接受一个元素并返回一组元素,bind 接受一个一到多(one- to-many)的函数,返回一个多到多(many-to-many)的函数。

// 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;
  };
};

我们现在可以如下复合 children 函数

ar div = document.getElementsByTagName('div')[0];
var grandchildren = compose(bind(children), bind(children));

grandchildren(unit(div))
// -> [<h1>…</h1>, <p>…</p>, ...]

我们实际上实现了 Haskell 中所谓的 List monad,其可以使你对“一到多”的函数进行复合。

那么什么叫 monad 呢?它是一种设计模式。当你有一系列函数,这些函数接受一个类型且返回另外一个类型时,我们可以在其上应用两个函数使其可以进行复合操作:

  • bind 函数可以将任意的函数转换为输入和输出类型相同的函数(注:对于更宽泛的应用并不要求 bind 返回的函数的输入和输出类型一致),使其更易复合
  • unit 函数将一个值包裹进一个容器内使其被可复合函数接受

我必须强调这是 monad 非常不严谨的定义,其忽视了我不曾试图理解的数学定义。但是对于像我一样编程的人来说这是一个很有用的设计模式,因为其帮助你避免了偶然的复杂性(accidental complexity),这些复杂并非来源于问题本身,而来自于如何将不同的数据类型进行粘合。能够识别出这些粘合代码将显著的提高你的代码可读性。

如果你喜欢本文,你也应该喜欢我最近出版的 JavaScript Testing Recipes 一书。其充满了如何在浏览器和服务端编写模块化,可维护性 JavaScript 代码的技巧。