在 Javascript 中构建富有表现力的 Monad:简介 GenAI LIVE!| 2025 年 6 月 4 日

2025-06-10

在 JavaScript 中构建富有表现力的 Monad:简介

GenAI LIVE! | 2025年6月4日

Monad 是一种强大的设计模式,如果使用得当,它可以彻底改变你对 JavaScript (JS) 中值处理的看法。本入门教程适合任何级别的 JS 熟悉程度,甚至(或许尤其)初学者。

对于已经熟悉 Monad 的读者,本介绍仅涵盖有效使用 Monad 的基本知识,并仅在必要时涉及其起源和更专业的术语以提供背景信息。本介绍不会尝试解释范畴论或函数式编程的深层概念。

“monad”是什么意思?

为了本次介绍的目的,我想引用字典中的定义,该定义早于其在数学和编程中的使用:一个单位

这个定义类似于二元组三元组——分别表示两个或三个单位。

“monad” 一词在数学和范畴论中的用法有所不同。在编程领域,monad 一词因 Haskell 而流行,并被移植到包括 JS 在内的多种语言中。它被用来保存值并控制变量的改变。

不过,我觉得记住“单个单位”的定义还是有好处的。至少它对我有帮助。

单子解决了什么问题?

任何时候你必须尝试跟踪值的变化,任何兼容类型的 monad 都会帮助你解决值如何变化的问题。

如果您遇到无法处理它们的函数null并因此造成严重破坏,那么 monad 可以解决该问题。undefinedMaybe

对我来说,它有助于将改变值的过程分解成小步骤,让我可以一次思考一个部分,而不必担心值会以意想不到的方式发生变化。这样可以更轻松地专注于单个函数。结果更加可预测,流程中的步骤也更加易于测试。

Monad 甚至可以处理异步的过程,但出于本介绍的目的,我们只关注同步情况。

它在 Javascript 中如何使用?

最好将 monad 视为值的容器:就像容器类型可以容纳值的集合一样ArrayObjectmonad 也可以执行相同的操作。

你构建的每个 monad 都像是构建一种新的类似容器的类型。正如Array方法一样,monad 既有标准方法,也有你可以根据具体情况添加的方法。forEachObjectkeys

如果您已经使用过ArrayObject,那么您已经获得了一些对 monad 有用的经验。

最基本的 monad:Identity

我们将从最基本的 monad(Identitymonad)开始我们的第一个例子。

首先,简要介绍一下 monad 的命名和样式约定……

在开始构建Identitymonad 之前,我想先解释一下本介绍中你将看到的命名和样式。刚开始学习 monad 时,我很快就被这些名称和样式难住了。这严重拖慢了我的学习进度。

您会看到以大写字母命名的 monad,通常具有相当抽象的名称。

不要太在意命名;如果一个抽象的名称让你感到困惑,请记住“命名是计算机科学中最难的事情之一”。通常,这些名称指向一个特定的既定设计模式,而这个模式可能有多个听起来很别扭的名称。

单子名称将大写——这是一个既定的惯例,我的假设是为了表明它们是一种特殊类型,就像一个class名字一样。

Monad 方法名也一样,最常见的方法有很多既定的名称。每次介绍一个方法名时,我会提到你可能发现的用于同一方法的其他名称。我会尽量关注我认为对 Monad 新手来说最有表现力的方法名,尽管大家的意见可能会有所不同。

其次,简要说明一下“身份”一词……

再简单补充一点:monad 的名称Identity源于一个术语“ identity”,它指的是一个只返回给定值的函数。monadIdentity实际上也会做同样的事情。这看起来可能是一个几乎没用的函数(它什么都不做!),但它非常适合用来举出最基本的例子,而且在函数式编程中也有一些实际的用例。

例如,如果您需要传递一个函数作为参数来潜在地改变一个值,但想要确保该函数在某些情况下实际上不会改变该值,那么身份就是一个很好的方法。

Identity:代码

const Identity = x => ({
    emit: () => x,
    chain: f => f(x),
    map: f => Identity(f(x))
});

// example use:
const one = Identity(1);

是的,就这些。像上面这样写,你就写出了一个 monad。这三个是必需的方法。

许多教程都会向您提供一个库并仅展示如何使用 monad,但我觉得亲自动手的方法实际上会让您更容易理解这个概念。

综上所述,这个Identitymonad 总共只有 5 行,包含了很多内容。让我们来分解一下。

const Identity = x => ({ ... });

最简单的部分:我们将使用 ,const因为我们不希望我们的定义发生改变。你可能知道或听说过,const并不能完美地锁定突变:如果你使用const来定义ArrayObject,那么它们随后可能会发生突变。

值得庆幸的是,我们为赋值了一个函数表达式const,我喜欢称之为常量函数表达式(CFE)。相比标准function定义,我更喜欢这种表达式,因为它可以防止任何人篡改函数原型。

如果您经常查找 JS 中的 monad 库,您会发现它们基于functionclass,这使得它们容易受到干扰。

我们要传递给Identitymonad 的值是x,CFE 的优点在于传递给它的参数永远不能被改变或更改:它是绝对不可变的,而无需使用任何特殊的 API。

这就是我喜欢这种 monad 模式的原因:只需几行代码,无需任何高级语法,它就能创建一个绝对不可变的值!

一旦我们将其1作为值传递进去,任何东西都不能改变1传递的值。如果我们使用一个类并将该值存储在访问器中,那么无需使用某些特殊的 API,我们就可以做类似的事情myIdentity.__value = 2并直接更改该值。

虽然我还没有验证过这个假设,但我认为这是占用内存最少的 JS monad 模式。

让我们开始研究核心方法。

方法:emit

别名 join,,valuevalueOf

代码
emit: () => x,
使用示例
console.log(one.emit());
// > 1

这是最简单的方法,它只返回其包含的值。通常被称为join,然而我发现它在学习 JavaScript 时不太直观。我喜欢emit用动词来解释它的作用:发出其包含的值。

不过需要注意的是,emit除了调试之外,你不必依赖它。事实上,在主要的例子中,你根本不会看到我使用它。

方法:chain

别名: flatMapbind

代码
chain: f => f(x),
使用示例
console.log(one.chain(a => a + 1));
// > 2

下一个最简单的方法是chain,其目的是将各种 monad链接在一起,但可以按照上面演示的方式进行操作。

f => f(x)f表示接受一个函数,并将值x传递给该函数。在本例中,a => a + 1接受该值,并返回该值加 1。

更典型的用法可能是:

one.chain(a => SomeMonad(a + 1));

其中SomeMonad是一个单子。在这里chain,我们将 转换Identity(1)SomeMonad(2)。当你使用 时chain,通常表示你传入的函数要么本身会返回一个单子(防止递归式的“单子套单子套单子”……),要么你希望结果为非单子。

现在不用太担心为什么,因为我发现与下一种方法相比,这种方法不太常用map。但在我们研究之前,首先理解这一点很重要map

方法:map

别名:( fmap “功能图”)

代码
map: f => Identity(f(x))
使用示例
console.log(one.map(a => a + 1));
// > [not pretty: outputs monad defintion... at least until we implement .inspect() below]

map是最重要的方法。这正是 monad 如此有用的原因:我们可以取一个已建立的 monad Identity(1),并通过一个函数生成一个常量,Identity(2)而无需对示例常量进行任何修改one

简而言之,它是chain一个内置函数,将结果值重新包装为新的Identity,而它本身可以受到mapchainemit的约束,对于您想要应用到它上面的任意数量的函数。

这是我在 monad 中最常用的方法。

我有时喜欢把它想象成一个银行账本。所有值都必须被记录:它们的起始位置(.of),以及它们如何随时间变化(map&chain方法)。Monad 的初始值就像一个新开的银行账户,账户中有一笔初始存款,每个mapchain都是其上的一笔交易。初始存款的价值永远不会改变,但我们有方法可以计算出账户中当前剩余的金额。

还有一种方法:inspect

你可能已经注意到,在 map 之后在控制台输出值看起来不太美观。虽然这并非 monad 正常工作的严格要求,但inspect它可以帮助我们通过控制台了解 monad 中到底包含什么,以及它是什么类型的 monad。

const Identity = (x) => ({
    chain: f => f(x),
    emit: () => x,
    map: f => Identity(f(x)),
    inspect: () => `Identity(${x})`
});

const one = Identity(1);
const two = one.map(a => a + 1);

console.log(two.inspect());
// > Identity(2)

此方法在调试中很重要,因为 simpleemit不会返回类型Identity,只会返回包含的值2。这在使用多种 monad 类型时非常重要。

最后,添加一个构造函数

在上面的所有例子中,我都是直接调用的Identity。然而,通常情况下,会有一个构造函数。在 JS 中,惯例是添加一个of构造函数。如下所示:

const one = Identity.of(1);

这在几个方面有所帮助。首先,of()它强烈地暗示了我们正在处理一个 monad,因为你可能在其他地方看不到它。

其次,如果您的 monad 对传递给它的内容有限制,它将允许您进行类型检查行为。

通常我使用导入/导出来处理这个问题,如下所示:

const Identity = x => ({
    emit: () => x,
    chain: f => f(x),
    map: f => IdentityOf(f(x)),
    inspect: () => `Identity(${x})`
});

// you might do type-checking here
const IdentityOf = x => Identity(x);

const exportIdentity = {
    of: IdentityOf
}

// or module.exports
export {
    exportIdentity as Identity
}
// or require()
import { Identity } from './Identity.js`;

我举的例子已经够多了。是时候让你尝试一下了。

尝试一下:Identity Monad 示例 REPL

让我们创建另一个 monad:List

List是 -like monad 的典型名称Array

我们将从我们的开始Identity,但将其重命名。

const List = x => ({
    emit: () => x,
    chain: f => f(x),
    map: f => List.of(f(x)),
    inspect: () => `List(${x})`
});

为了本例的目的,我们假设of已经为 this 添加了构造函数。实际创建构造函数时,我们还会在of构造函数中进行类型检查,以确保传递的值是Array

添加更多方法

正如您所见,通过添加,添加新方法非常容易。如果您编写自己的 monad,并且经常inspect使用某个特定函数,为什么不添加方法呢mapchain

根据我的经验,您可以添加两种方法:

  1. map-like:返回相同类型的 Monad 的方法
  2. chain-like:返回不同类型的 monad 或非 monad 值的方法;它可能会或可能不会“退出” monad 模式,我喜欢将其称为“解开” monad 值

方法:concat

连接是一个相当简单的概念Array:取一个数组,然后将其添加到另一个数组的末尾。这似乎是一个非常有用的方法。

concat: a => List.of(x.concat(a)),

// e.g.

const myNumbers = List.of([1, 3, 4, 7, 10]);

myNumbers.concat([12]).inspect();
// > List(1,3,4,7,10,12);

功能很简单:List使用Array.concat包含的值和传入的值创建一个新的。

请注意,这是map类似的;它返回一个新的List

方法:head

假设我们只想知道 中的第一个项List是什么。它不是,Array所以使用索引访问器[0]是行不通的。

head: () => x[0],

// e.g.

const myNumbers = List.of([1, 3, 4, 7, 10]);

myNumbers.head()
// > 1

此方法chain类似于 ,因为它返回一个非 monad 值——在本例中,是解包该值的一部分。此方法会退出 monad 模式,因此在使用此类方法时请注意,继续链接mapemitinspect等操作将无法正常工作。

const myNumbers = List.of([1, 3, 4, 7, 10]);

myNumbers.head().inspect();
// > ERROR! We unwrapped from the monad at `.head()`!

更多方法

如果你了解的Array话,你就会知道它有很多方法。你可以List用各种各样的东西来构建一个。

所以这是一个很好的练习——采用这个基本的Listmonad 并编写一些你自己的方法!

尝试一下:列表 Monad 示例 REPL

Maybe:最强大的 monad

您可能听说过Maybe(也称为Option):这个名字很奇怪,但非常有用且功能强大的 monad 模式。

“可能”这个名字指的是“也许有价值……但也许没有”的想法。

在 JS 中,有些值放在错误的地方undefined可能null会造成混乱。如果在当前所有需要放置尴尬if (x === undefined || x === null)语句的情况下,我们都能直接在值的容器内处理这些情况,并且永远不会暴露那些难看又麻烦的空值,那会怎么样?

代码

这里有很多代码。别担心,我们会全部讲完。

const Just = (x) => ({
  chain: f => f(x),
  emit: () => x,
  map: f => MaybeOf(f(x)),
  fork: (_, g) => g(x),
  isJust: true,
  isNothing: false,
  inspect: () => `Just(${x})`,
});

const Nothing = (x) => ({
  chain: _ => Nothing(),
  emit: () => Nothing(),
  map: _ => Nothing(),
  fork: (f, _) => f(),
  isJust: false,
  isNothing: true,
  inspect: () => `Nothing`,
});

const MaybeOf = x => x === null || x === undefined || x.isNothing ? Nothing() : Just(x);

const exportMaybe = {
  of: MaybeOf
};

export { 
    exportMaybe as Maybe
}

用例

举一个很有用的例子,让我们有一个系统读取华氏温度并以摄氏度显示。

const fahrenheitToCelsius = a => (a - 32) * 0.5556;

const reading1 = 15;
const reading2 = null;

const temp1C = Maybe.of(reading1)
                    .map(fahrenheitToCelsius);

console.log(temp1C.inspect());
// > Just(-9.4444)

const temp2C = Maybe.of(reading2)
                    .map(fahrenheitToCelsius);

console.log(temp2C.inspect());
// > Nothing()

我们马上就遇到一个问题:要使函数fahrenheitToCelsius正常工作,我们需要a一个数字。由于reading2null(也许是个坏掉的温度计?),JavaScript 会将其转换null0,从而持续给出错误的读数-17.7792

然而,由于我们已经封装在一个Maybe单子中,所以我们只有两种可能性:一个实数(Just,如“只是一个值”),以及根本没有值(Nothing)。

解释

这是怎么发生的?

我们的Maybe.of构造函数做到了:

const MaybeOf = x =>
    x === null ||
    x === undefined ||
    x.isNothing ? Nothing() : Just(x);

如果封装在 monad 中的值不是undefinednull,或者已经是Nothing,那么它将被保存在 中Just。虽然这个名字Just对你来说可能很陌生,但它的概念几乎与 相同Identity!所以你基本上已经知道它是如何Just工作的了。

Nothing与大多数 monad 截然不同:它不接受值,并且你使用的每种方法都会返回Nothing()Maybe将值转换为 之后Nothing,就无法再返回 —— 所有尝试mapchain的结果都只是Nothing,因此你不必担心函数会出现意外行为,因为它们实际上从未运行过

即使emit在这里也返回Nothing(),而不是nullundefined。这是因为我之前撒了个谎,我们的emit方法有时实际上无法发出值,尤其是在有 的情况下Nothing()

但我们最终需要处理我们所做的事情Nothing……

方法:fork

Maybe这里输入的是上面给出的 monad的珍贵方法: fork

补充一点:并非所有Maybemonad 实现都会有fork,但可以用其他方式处理Nothing。不过在本教程中,我们将使用它,因为我们可以!

fork这里有两个地方有方法:在JustNothing

// Just
fork: (_, g) => g(x),

// Nothing
fork: (f, _) => f(x),

你可能马上就会发现一些奇怪的东西。_这是一种在函数式编程中常用的风格选择,用来表示我们知道会传递值的地方,但我们计划不使用它。它就像占位符的反义词。

现在我们用它来显示温度:

// assume a `display` function to display the temperature reading, and act like console.log

const fahrenheitToCelsius = a => (a - 32) * 0.5556;

const reading1 = 15;
const reading2 = null;

Maybe.of(reading1)
    .map(fahrenheitToCelsius)
    .fork(
        _ => display('ERR!'),
        t => display(`${t}°C`) // will read `-9.4452°C`
    );

Maybe.of(reading2)
    .map(fahrenheitToCelsius)
    .fork(
        _ => display('ERR!'), // will read `ERR!`
        t => display(`${t}°C`)
    );

请注意,在这个用例中,我们甚至没有将结果分配Maybe给一个const值,因为在这个例子中我们只需要显示它。

但如果我们确实需要该字符串值来做其他事情……

const display = a => {
    console.log(a);
    return a;
};

const fahrenheitToCelsius = a => (a - 32) * 0.5556;

const reading1 = 15;

const temp3C = Maybe.of(reading1)
    .map(fahrenheitToCelsius)
    .fork(
        _ => display('ERR!'),
        t => display(`${t}°C`)
    );

console.log(temp3C)
// > "-9.4452°C"

这应该足够你开始使用了Maybe。这是一种与 JS 中通常教授的价值观截然不同的思考方式,可能需要一些时间才能完全掌握。

实践对理解 monad 的用法大有裨益!在你的下一个小项目中,尝试添加一个Maybe模块(见下方建议),或者自己编写一个。如果时间充裕,你可能无法想象没有它,你该如何用 JS 写代码!

现在,您可以Maybe在以下 REPL 中进行尝试。

尝试一下:也许 Monad 示例 REPL

关于emit

你可能已经注意到这里实际上没有任何使用emit(aka join) 的例子。这是因为“解包”一个 monad 实际上是你应该尽可能避免的事情,除非需要移除多层容器 monad。

它非常适合调试,但是当你emit需要意识到你正在离开安全的 monad 世界时,又会陷入副作用和可变性。

许多 monad 库甚至没有这种方法,因为从技术上讲这不是必需的——毕竟,chain如果输入一个简单的身份函数就可以达到相同的效果。

如果可能的话,您可以做的最好的事情不是使用 monad 来计算要返回的值,而是构建 monad 并向其传递完成所需操作所需的所有函数。

不过,如果你现在还无法弄清楚如何在所有情况下做到这一点,也不用担心。即使只是开始使用 monad,也可能是一次令人兴奋的学习经历。

快速回顾

这些年来对我帮助最大的,就是把 monad 看作一个容器。这或许对你有帮助,或者回到字典里对“单个单元”的定义也或许会有帮助。

与银行分类账非常相似,monad 保持其值不变,但允许方法在其上应用函数来生成新的 monad,从而生成新的值。

但请注意,在网上搜索你可能遇到的 monad 问题的解决方案可能会有点困难。很多文档充斥着你可能不熟悉的技术术语。其中很多甚至连我都不熟悉。希望随着这种强大的模式被更广泛地采用,这种情况会有所改变。

您现在可以使用的 Monad 模块

我自己的模块与本介绍中演示的模块没有太大区别,可以在 npm 上以 simple-maybe 的形式使用。

下一步是什么?

一旦您完全掌握了本介绍中概述的概念,其他 monad 将大多只是此处显示的 monad 类型的细微变化。

在不久的将来,我将发布一些我正在构建的其他 monad 模块,以及它们的使用和构造方式。

我希望本文的方法即使是 JavaScript 新手也能轻松理解,并且代码足够清晰,不会成为学习的障碍。欢迎留下改进建议,或分享其他有助于您更好地理解 Monad 用法的方法。

鏂囩珷鏉ユ簮锛�https://dev.to/rgeraldporter/building-expressive-monads-in-javascript-introduction-23b
PREV
为开发人员和设计师精心挑选的预设和盒子阴影编辑器
NEXT
VS Code:搜索并替换正则表达式