vlambda博客
学习文章列表

从 Haskell 到 JavaScript 的翻译,我读过的最好的 Monad 介绍的部分内容

【译文】从 Haskell 到 JavaScript 的翻译,我读过的最好的 Monad 介绍的部分内容

BY 张聪(dancerphil@github)
原标题:Translation from Haskell to JavaScript of selected portions of the best introduction to monads I’ve ever read
这是一篇在原文基础上演绎的译文,与原文的表达会有出入。
我把文中的代码进行了现代化解释,把一部分 ES5 解释为 ES6,把一部分 Haskell 解释为 TypeScript。
除非另行注明,页面上所有内容采用 MIT 协议共享。

(对 John Gruber 和 A Neighborhood of Infinity 表示由衷的歉意)

I know, I know, 这个世界并不需要多出一篇 monad 介绍(或者多出一篇抱怨【这个世界并不需要多出一篇 monad 介绍】的文章)。所以你应该高兴,因为这篇文章确实不是上述之一,从某种意义上来说它不是新的。我认为我写这篇文章的原因在于,首先,monad 是一个值得了解的东西,其次,我想要了解它们是怎样与 asynchronous programming 联系起来,并且我希望得到一个 JavaScript 语言的 baseline,以帮助解释以后可能会写的其他文章。这也是一个用类型的思路来思考问题的宝贵练习。如果你可以阅读一些 Haskell,我非常推荐你阅读 Haskell 版本的原文,You Could Have Invented Monads (And Maybe You Already Have)。

首先,我需要讲一些背景故事。Monad 在 Haskell 中更为普遍,因为 Haskell 只允许 纯函数(pure function) ,即指那些没有 副作用(side effect)的函数。纯函数接受 参数(argument)作为输入,然后返回一些值作为输出,仅此而已。而我通常使用的语言(Ruby 和 JavaScript)并没有这种限制。但是事实证明,尽量使用纯函数,把它作为一个纪律来强制自己是非常好的。很多典型的 monad 介绍文章强调说 monad 是关于如何在这个纯函数的系统中潜入一些副作用,以便进行一些 I/O 的工作,但这其实只是 monad 的一个应用而已。Monad 实际上是关于 组合(compose)函数的,我们会看到。

注:接下来,作者讲解了 I/O monad 的例子。

让我们首先考虑一个例子。假设你有一个函数返回一个 number 的正弦值,在 JavaScript 里可以简单的包装 Math 库:

const sine = x => Math.sin(x);

然后你有另一个函数返回一个 number 的立方:

const cube = x => x ** 3;

这两个函数都以一个 number 作为输入并返回一个 number 作为输出,这使它们具备 可组合性(composable):你可以用一个函数的输出作为下一个函数的输入:

const sineCubed = cube(sine(x));

现在让我们创建一个函数来封装 组合(composition)过程。这个函数需要输入两个函数 f 和 g 并且返回一个用来计算 f(g(x)) 的新函数。

const compose = (f, g) => {
return x => f(g(x));
};

const sineOfCube = compose(sine, cube);
const y = sineOfCube(x);

接着,我们决定调试这些功能,当函数被调用的时候,我们希望打印一句日志。我们也许会这样做:

const cube = x => {
console.log('cube was called.');
return x ** 3;
};

但是注意,在一个仅允许纯函数的系统中,我们是不可以这样做的:console.log() 既不是函数的参数,也不是函数的返回值,它是一个副作用。如果我们想要获得日志信息,那么它必须是返回值的一部分。现在让我们修改函数的返回值,把它变成一对值:结果值,和一些调试信息:

const sine = x => [Math.sin(x), 'sine was called.'];

const cube = x => [x ** 3, 'cube was called.'];

但现在我们发现了一件可怕的事情,这些函数无法组合:

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

它在以下两个角度被破坏了:sine 试图计算一个数组的 sine 值,其结果为 NaN,同时,我们丢失了在 cube 调用中的调试信息。我们希望这个新函数返回 x 的立方的 sine 值,和一个字符串,告诉我们 cube 和 sine 都被调用了:

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

简单的组合不起作用,这是因为 cube 的返回值的类型(一个数组)和 sine 的参数的类型(一个数字)并不相同。我们需要写一些胶水代码使它们可以正确运行。我们可以写一个函数来组合这些 debuggable 函数:它会解开每个函数的返回值,并把它们以有意义的方式缝合到一起:

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

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

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

我们现在已经组合了两个函数,这些函数接受一个 number 作为参数并且返回类型为 [number, string] 的一对值,然后我们创建了一个拥有相同 签名(signature)的新函数,意味着这个新函数也可以和其他的 debuggable 函数进一步的组合。

为了简化起见,我会借用一些 TypeScript 的表示方法。以下的类型签名表示函数 cube 接受一个 number 作为参数并且返回包含了 number 和 string 的一对值:

type Cube = (x: number) => [number, string];

我们所有的 debuggable 函数和它们的组合都会拥有这个签名。我们原来的函数拥有一个更简单的签名 number => number;参数和返回值的类型具备 对称性(symmetry),这使得函数可组合。那么,与其我们为 debuggable 函数定制一个组合的逻辑,不如我们直接简单的把它们的签名也 转化(convert)成对称的形式:

type Cube = (tuple: [number, string]) => [number, string];

这时候,我们就可以使用之前定义的 compose 函数把它们粘合在一起了。我们可以手动的进行 转换(conversion),把 cube 和 sine 的源码进行重写,来接受 [number, string] 的参数,但是这不能 扩展(scale)到其他的函数上,这样你就必须手动修改所有的函数,添加相同的 样板(boilerplate)。更好的做法是让每个函数仅仅做自己的工作,然后提供一个工具函数,把这些函数强制的转化到我们所需要的样子。我们把这个工具函数称为 bind,它的工作就是接受一个形如 number => [number, string] 的函数并返回一个形如 [number, string] => [number, string] 的函数:

const bind = f => {
return tuple => {
const [x, s] = tuple;
const [y, t] = f(x);

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

我们可以使用 bind 函数来转化我们的函数,使它们拥有可组合的函数签名,然后我们可以组合出我们想要的结果:

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

但是现在,我们所有的函数有需要接受 [number, string] 作为其参数,而我们其实更想只传一个 number。所以除了转化函数之外,我们还需要一个函数,它可以把值转化成可接受的类型,也就是:

type Unit = (x: number) => [number, string];

unit 的作用就是接受一个值,然后将其包装于一个 基本容器(basic container)中,这个基本容器指的是我们正在使用的函数可以 消费(consume)的类型。以 debuggable 函数为例,我们只需要给这个 number 配对一个空字符串:

// type Unit = (x: number) => [number, string];
const unit = x => [x, ''];

const 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 函数还可以让我们把任何函数转化成 debuggable 函数。我们只要把它的返回值转化成 debuggable 函数的可接受的输入即可:

// type Round = (x: number) => number;
const round = x => Math.round(x);

// type RoundDebug = (x: number) => [number: string];
const roundDebug = x => unit(round(x));

进一步的,这种类型转换,从一个简单函数转化成一个 debuggable 函数,可以抽象为名为 lift 的函数。这个类型签名的意思是 lift 接受一个形如 number => number 的函数作为参数,并且返回一个形如 number => [number, string] 的函数:

// type F = (x: number) => number;
// type Lift = (f: F) => (x: number) => [number, string];
const lift = f => x => unit(f(x));

// or, more simply:
const lift = f => compose(unit, f);

现在让我们把它应用到现有的函数上,看看它是否有效:

const round = x => Math.round(x);

const roundDebug = lift(round);

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

到此,我们发现了三个用来粘合 debuggable 函数的重要抽象:

  • lift, 可以将简单函数转化为 debuggable 函数

  • bind, 可以一个 debuggable 函数转化为可组合的形式

  • unit, 可以把一个简单值放到一个容器中,以转化成 debuggable 函数所需的形式

注:思考把 number 放入 Promise 的容器中,从容器概念来解释 monad 可以参考 这篇文章

这些抽象(well,准确的说是 bind 和 unit),定义了一个 monad,在 Haskell 标准库中,它被称为 Writer monad。到现在为止,我们只是举了一个 I/O monad 的例子(还有一个 Promise 的例子的链接)。读者可能还是不清楚这个 模式(pattern)的通用的部分,所以让我们再举一个例子。

注:接下来,作者讲解了 List monad 的例子。

有种情形你可能已经遇到了很多次,你需要决定一个函数应该接受一个 item 作为参数还是接受 item 的数组作为参数。它们的区别通常会影响到是否需要在函数体的周围加上一个 for 循环。通常来说这是一个样板(你可能这样做了很多次)。但是这会对你如何组合这些函数带来很大的影响。例如,假设你有一个函数,它的功能是接受一个 DOM node 作为参数,并且以数组的形式返回它的 children。它的函数签名表示它接受一个 HTMLElement 并返回 HTMLElement 数组

// type Children = (node: HTMLElement) => HTMLElement[];
const children = node => [...node.childNodes];

const heading = document.getElementsByTagName('h3')[0];
children(heading);

现在假设我想要找到 heading 的 grandchildren,即它的 children 的 children。直觉上,以下是一个好的定义:

const grandchildren = compose(children, children);

然而 children 的输入输出并不对称,所以我们不能简单把它们 compose 起来。如果我们手写 grandchildren,它应该会长成这样:

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

我们只要简单的把所有找到的节点 concat 起来,就能得到一个 flat 的 grandchildren 数组。但是这并不是一个很方便的写法,事实上它包含了很多样板,这些样板仅仅是因为我们在处理数组,而不是因为我们在解决这个问题本身。我们更希望只是组合两个 list-handling 函数就可以完成这件事。

回头思考一下之前的例子,我们需要两个步骤来解决这个问题:提供一个 bind 函数,把 children 转化成可组合的形式,然后写一个 unit 函数把最初的输入值 heading 转化成可被接受的形式。

这个问题的核心就在于我们的函数接受一个 HTMLElement 而返回了数组,所以我们的转换应该考虑把一个 item 转化成数组的形式,反之亦然。item 是或者不是 HTMLElement 在此并不重要,在 TypeScript 和 Haskell 里我们会把这个具体的类型写成一个字母来占据位置即可(注:泛型)。unit 接受一个 item 并返回一个包含了这个 item 的数组,而 bind 接受一个 一对多 函数并返回一个 多对多 函数:

// type Unit = <T>(x: T) => T[];
const unit = x => [x];

// type Bind = <T>(f: (x: T) => T[]) => (list: T[]) => T[];
const bind = f => {
return list => {
let output = [];
for (let i = 0, n = list.length; i < n; i++) {
output = output.concat(f(list[i]));
}
return output;
};
};

现在我们可以如愿的组合 children 了:

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

grandchildren(unit(div));

我们刚刚实现了 Haskell 里的 List monad,它可以让你 compose 那些一对多的函数。

所以,什么是 monad?Well,这是一个 设计模式(design pattern)。它表明:

当你拥有一类函数 F,这类函数都接受一种类型的值 A 并返回另一种类型的值 B;我们就可以在这一类函数 F 上应用两个函数,使它们可组合:

  • 有一个 bind 函数可以转化 F 中的函数,使得它的输入和输出是一样的,这使它 可组合(composable)

  • 有一个 unit 函数可以把一个类型为 A 的值包装到一个容器中,使得这个值可以被一个 可组合函数(composable function)所接受。

注:我们在这里可以换一种说法,即 bind 可以把 F 类函数应用到 unit 给出的容器上

我需要强调的是,这是一个非常 不高雅(hand-waving)的不精确的定义,它忽略了 monad 的数学基础,我也并不打算要假装我理解。但对于像我这样的编程者,这是一个非常有用的设计模式,因为它可以帮你发现代码中不必要的复杂性:代码并没有直接的在解决问题,而是在把数据粘合在一起。它可以让你发现并且提取这类样板代码,并且从根本上提高代码的清晰度。