在 JavaScript 中构建富有表现力的 Monad:简介
GenAI LIVE! | 2025年6月4日
Monad 是一种强大的设计模式,如果使用得当,它可以彻底改变你对 JavaScript (JS) 中值处理的看法。本入门教程适合任何级别的 JS 熟悉程度,甚至(或许尤其)初学者。
对于已经熟悉 Monad 的读者,本介绍仅涵盖有效使用 Monad 的基本知识,并仅在必要时涉及其起源和更专业的术语以提供背景信息。本介绍不会尝试解释范畴论或函数式编程的深层概念。
“monad”是什么意思?
为了本次介绍的目的,我想引用字典中的定义,该定义早于其在数学和编程中的使用:一个单位。
这个定义类似于二元组和三元组——分别表示两个或三个单位。
“monad” 一词在数学和范畴论中的用法有所不同。在编程领域,monad 一词因 Haskell 而流行,并被移植到包括 JS 在内的多种语言中。它被用来保存值并控制变量的改变。
不过,我觉得记住“单个单位”的定义还是有好处的。至少它对我有帮助。
单子解决了什么问题?
任何时候你必须尝试跟踪值的变化,任何兼容类型的 monad 都会帮助你解决值如何变化的问题。
如果您遇到无法处理它们的函数null
并因此造成严重破坏,那么 monad 可以解决该问题。undefined
Maybe
对我来说,它有助于将改变值的过程分解成小步骤,让我可以一次思考一个部分,而不必担心值会以意想不到的方式发生变化。这样可以更轻松地专注于单个函数。结果更加可预测,流程中的步骤也更加易于测试。
Monad 甚至可以处理异步的过程,但出于本介绍的目的,我们只关注同步情况。
它在 Javascript 中如何使用?
最好将 monad 视为值的容器:就像容器类型可以容纳值的集合一样Array
,Object
monad 也可以执行相同的操作。
你构建的每个 monad 都像是构建一种新的类似容器的类型。正如和等Array
方法一样,monad 既有标准方法,也有你可以根据具体情况添加的方法。forEach
Object
keys
如果您已经使用过Array
和Object
,那么您已经获得了一些对 monad 有用的经验。
最基本的 monad:Identity
我们将从最基本的 monad(Identity
monad)开始我们的第一个例子。
首先,简要介绍一下 monad 的命名和样式约定……
在开始构建Identity
monad 之前,我想先解释一下本介绍中你将看到的命名和样式。刚开始学习 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,但我觉得亲自动手的方法实际上会让您更容易理解这个概念。
综上所述,这个Identity
monad 总共只有 5 行,包含了很多内容。让我们来分解一下。
const Identity = x => ({ ... });
最简单的部分:我们将使用 ,const
因为我们不希望我们的定义发生改变。你可能知道或听说过,const
并不能完美地锁定突变:如果你使用const
来定义Array
或Object
,那么它们随后可能会发生突变。
值得庆幸的是,我们为赋值了一个函数表达式const
,我喜欢称之为常量函数表达式(CFE)。相比标准function
定义,我更喜欢这种表达式,因为它可以防止任何人篡改函数原型。
如果您经常查找 JS 中的 monad 库,您会发现它们基于function
或class
,这使得它们容易受到干扰。
我们要传递给Identity
monad 的值是x
,CFE 的优点在于传递给它的参数永远不能被改变或更改:它是绝对不可变的,而无需使用任何特殊的 API。
这就是我喜欢这种 monad 模式的原因:只需几行代码,无需任何高级语法,它就能创建一个绝对不可变的值!
一旦我们将其1
作为值传递进去,任何东西都不能改变1
传递的值。如果我们使用一个类并将该值存储在访问器中,那么无需使用某些特殊的 API,我们就可以做类似的事情myIdentity.__value = 2
并直接更改该值。
虽然我还没有验证过这个假设,但我认为这是占用内存最少的 JS monad 模式。
让我们开始研究核心方法。
方法:emit
别名 :join
,,value
valueOf
代码
emit: () => x,
使用示例
console.log(one.emit());
// > 1
这是最简单的方法,它只返回其包含的值。通常被称为join
,然而我发现它在学习 JavaScript 时不太直观。我喜欢emit
用动词来解释它的作用:发出其包含的值。
不过需要注意的是,emit
除了调试之外,你不必依赖它。事实上,在主要的例子中,你根本不会看到我使用它。
方法:chain
别名: flatMap
,bind
代码
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
,而它本身可以受到map
、chain
等emit
的约束,对于您想要应用到它上面的任意数量的函数。
这是我在 monad 中最常用的方法。
我有时喜欢把它想象成一个银行账本。所有值都必须被记录:它们的起始位置(.of
),以及它们如何随时间变化(map
&chain
方法)。Monad 的初始值就像一个新开的银行账户,账户中有一笔初始存款,每个map
或chain
都是其上的一笔交易。初始存款的价值永远不会改变,但我们有方法可以计算出账户中当前剩余的金额。
还有一种方法: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`;
我举的例子已经够多了。是时候让你尝试一下了。
让我们创建另一个 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
使用某个特定函数,为什么不添加方法呢?map
chain
根据我的经验,您可以添加两种方法:
map
-like:返回相同类型的 Monad 的方法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 模式,因此在使用此类方法时请注意,继续链接map
、emit
、inspect
等操作将无法正常工作。
const myNumbers = List.of([1, 3, 4, 7, 10]);
myNumbers.head().inspect();
// > ERROR! We unwrapped from the monad at `.head()`!
更多方法
如果你了解的Array
话,你就会知道它有很多方法。你可以List
用各种各样的东西来构建一个。
所以这是一个很好的练习——采用这个基本的List
monad 并编写一些你自己的方法!
尝试一下:列表 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
一个数字。由于reading2
是null
(也许是个坏掉的温度计?),JavaScript 会将其转换null
为0
,从而持续给出错误的读数-17.7792
。
然而,由于我们已经封装在一个Maybe
单子中,所以我们只有两种可能性:一个实数(Just
,如“只是一个值”),以及根本没有值(Nothing
)。
解释
这是怎么发生的?
我们的Maybe.of
构造函数做到了:
const MaybeOf = x =>
x === null ||
x === undefined ||
x.isNothing ? Nothing() : Just(x);
如果封装在 monad 中的值不是undefined
、null
,或者已经是Nothing
,那么它将被保存在 中Just
。虽然这个名字Just
对你来说可能很陌生,但它的概念几乎与 相同Identity
!所以你基本上已经知道它是如何Just
工作的了。
Nothing
与大多数 monad 截然不同:它不接受值,并且你使用的每种方法都会返回Nothing()
。Maybe
将值转换为 之后Nothing
,就无法再返回 —— 所有尝试map
或chain
的结果都只是Nothing
,因此你不必担心函数会出现意外行为,因为它们实际上从未运行过。
即使emit
在这里也返回Nothing()
,而不是null
或undefined
。这是因为我之前撒了个谎,我们的emit
方法有时实际上无法发出值,尤其是在有 的情况下Nothing()
!
但我们最终需要处理我们所做的事情Nothing
……
方法:fork
Maybe
这里输入的是上面给出的 monad的珍贵方法: fork
。
补充一点:并非所有Maybe
monad 实现都会有fork
,但可以用其他方式处理Nothing
。不过在本教程中,我们将使用它,因为我们可以!
fork
这里有两个地方有方法:在Just
和Nothing
// 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