Monad 说什么?(第一部分)
最近,函数式编程、组合编程之类的热词层出不穷,像Functor
和 这样的术语Monad
可能已经出现在你的信息流中,让你不禁疑惑:“这些人到底在说什么?” 这些名称如此陌生,解释起来可能更加晦涩难懂,需要对代数(抽象的代数)和范畴论有深入的理解,因此,我们可能很难理解这些类型的结构如何融入我们日常使用的 JavaScript 代码中。
在本系列中,我们将从 JavaScript 程序员的角度探索什么Monad
是 a ,以及如何在日常代码中有效地使用它们。我们将主要关注这些类型的用法,而不是其背后的理论。
例如,我们不必费力去理解以下定义:
A
Monad
是aMonoid
在Category
Endofunctors
我们将努力理解一个更实际的定义:
A
Monad
是一种数据类型,允许在映射其底层数据时顺序应用其“效果”或“修饰”。
现在,虽然第二个定义现在可能仍然不清楚,但我认为我们可以同意,努力理解这些词语以及它们如何组合在一起而得出的含义似乎更容易理解。
当我们大胆尝试创建自己的类型时,理解第一个定义至关重要。不过,如果你和我一样,我喜欢亲自动手,先尝试一下,等我对如何使用它们有了很好的直觉后再运用理论来建立理解。市面上已经有很多现成的类型,我们可以尽情地玩转它们……而无需理解它们背后的数学原理。
这些文章不仅要求你理解 JavaScript 语言,还要求你了解 JavaScript 中“柯里化”、“偏应用”和“函数组合”的具体实现。如果你对这些概念还不太了解,网上有很多资源可以帮助你理清思路。
事不宜迟,让我们开始吧。
第一部分:代数数据类型(ADT)
很多时候,当人们说“我用这个做了Monad
这个,用那个做Monad
了那个”时,他们真正的意思是:“我用这个代数数据类型(ADT)做了这个,用那个代数数据类型 (ADT) 做了那个”。当你查看他们提供的代码时,你会发现他们从未触及Monad
类型的部分,或者在某些情况下,类型甚至不是Monad
。
首先,我想澄清一下我之前提到的这个争论点。这看起来似乎无关紧要,但我发现,当我们开始围绕 s 和其他数据类型方面Monad
建立初步的直觉时,如果将某个 ADT 的真正含义称为 a,往往会造成混淆。Monad
在开始理解什么是代数数据类型 (ADT Monad
) 之前,我们需要先了解一下什么是 ADT。我能想到的最好的切入点是给出一个简单的定义。然后演示 JavaScript 中 ADT 的使用方法,并与(希望)更熟悉的命令式实现进行对比。
让我们通过示例来看一下我们将要处理的数据:
// data :: [ * ]
const data = [
{ id: '9CYolEKK', learner: 'Molly' },
null,
{ id: 'gbdCC8Ui', learner: 'Thomas' },
undefined,
{ id: '1AceDkK_', learner: 'Lisa' },
{ id: 3, learner: 'Chad' },
{ gid: 11232, learner: 'Mitch' },
]
数据是混合的Array
,可以包含任何类型的值。在这个特定的例子中,我们使用了三种类型:POJO
不同形状的 s(普通 JavaScript 对象)、一个Null
实例和一个Undefined
实例。
我们的示例将根据以下要求列表进行定义:
- 在其输入处接受任何类型的任何值。
- 除非数据至少包含一条有效记录,否则将返回
Array
空。Object
- 返回
Object
以包含的记录中的有效值作为键的有效记录id
,从而有效地过滤掉任何无效记录。 - 我们将有效记录定义为
Object
以String
为键的记录id
。 - 无论输入是什么,此函数都不会抛出任何错误,并在
Object
返回的空值中提供合理的默认值。
根据这些要求,我们可以实现一个执行以下操作的命令式函数:
- 验证输入是否为,如果不是则
Array
返回空。Object
- 声明一个
result
累加器来构建我们的最终结果,并将其默认为空Object
。 - 迭代所提供的内容
Array
并对每个项目执行以下操作:- 根据我们的记录标准验证该项目
- 如果通过,则将记录添加到结果中,并以
id
记录上的值作为键。否则不执行任何操作。
- 返回
result
。
通过一些帮助程序来帮助我们进行一些类型检查,我们可以提供如下实现:
// isArray :: a -> Boolean
const isArray =
Array.isArray
// isString :: a -> Boolean
const isString = x =>
typeof x === 'string'
// isObject :: a -> Boolean
const isObject = x =>
!!x && Object.prototype.toString.call(x) === '[object Object]'
// indexById :: [ * ] -> Object
function indexById(records) {
if (!isArray(records)) {
return {}
}
let result = {}
for (let i = 0; i < records.length; i++) {
const rec = records[i]
if (isObject(rec) && isString(rec.id)) {
result[rec.id] = rec
}
}
return result
}
indexById(null)
//=> {}
indexById([])
//=> {}
indexById([ 1, 2, 3 ])
//=> {}
indexById(data)
//=> {
// 9CYolEKK: { id: '9CYolEKK', learner: 'Molly' },
// gbdCC8Ui: { id: 'gbdCC8Ui', learner: 'Thomas' },
// 1AceDkK_: { id: '1AceDkK_', learner: 'Lisa' }
// }
如我们所见,我们有一个强大的实现,它可以满足我们的要求并按预期响应我们给出的任何输入。
至于我们的 ADT 实现,我们将主要依赖crocks
库。尽管 JavaScript 是一种功能齐全的编程语言,但它缺少一些在其他非通用语言(但严格来说是函数式的)中常见的结构。因此,像 这样的库crocks
通常用于处理 ADT。
以下是使用 ADT 实现要求的实现:
const {
Assign, Maybe, composeK, converge, isArray,
isObject, isString, liftA2, mreduceMap, objOf,
prop, safe
} = require('crocks')
// wrapRecord :: Object -> Maybe Object
const wrapRecord = converge(
liftA2(objOf),
composeK(safe(isString), prop('id')),
Maybe.of
)
// mapRecord :: a -> Object
const mapRecord = record =>
safe(isObject, record)
.chain(wrapRecord)
.option({})
// indexById :: [ * ] -> Object
const indexById = records =>
safe(isArray, records)
.map(mreduceMap(Assign, mapRecord))
.option({})
indexById(null)
//=> {}
indexById([])
//=> {}
indexById([ 1, 2, 3 ])
//=> {}
indexById(data)
//=> {
// 9CYolEKK: { id: '9CYolEKK', learner: 'Molly' },
// gbdCC8Ui: { id: 'gbdCC8Ui', learner: 'Thomas' },
// 1AceDkK_: { id: '1AceDkK_', learner: 'Lisa' }
// }
我希望大家注意到这两个实现之间的一个区别是,ADT 实现中缺少我们熟悉的流程控制和逻辑模式。for
循环和if
语句之类的东西在第二个实现中一次也没有出现。它们仍然存在,当然它们仍然存在,但在使用 ADT 时,我们会将这些流程/逻辑编码到特定的类型中。
例如,注意到safe
在几个地方使用的那个函数了吗?看一下传递给这些调用的第一个参数的谓词函数。注意,那里进行了相同的检查,但if
我们使用的不是 ,而是safe
返回名为 的 ADT 的函数Maybe
。
您可能注意到的另一件事是,第二个实现中任何地方都缺少状态。每个声明的变量都是一个函数,而不是一个 JavaScript 值。在原始实现中,我们使用了两种状态,一种result
是组合最终结果,另一种是一个名为 的小助手,rec
它只是清理了代码,并使我们不必从Array
.
通过使用函数将每条记录折叠到类型上,我们能够摆脱for
循环和变量的需要。让我们像在原生 JavaScript 中那样组合s ,从而无需跟踪 之类的累加器。既然有了累加方法,我们就可以依靠 来消除循环。result
mreduceMap
Assign
Assign
Object
Object.assign
result
Object
for
mreduceMap
现在不需要理解 、 、 fold 等等。我之所以提到它们,是因为我想表达的是,原始实现中的每个模式在 ADT 版本中都存在,这里没有任何魔法。当我们使用 ADT 进行编码时,我们会通过将它们编码到 ADT 中来移除许多机械部分,例如累积、逻辑、控制流和状态切换,并让类型为我们处理所有“管道”工作Maybe
。Assign
我最不希望被注意到的是,我们如何使用看似流畅的 API来在函数mapRecord
和中将操作链接在一起indexById
。看到这样的代码可能会让我们误以为我们正在Object
像典型的面向对象程序员一样使用传统的 s 和类。当你听到这些操作被称为方法时,这种感觉会更加强烈(所有crocks文档都这样称呼)。这些直觉和误导性的特征可能会妨碍我们理解 ADT 在日常代码中的使用方式。
下次,我们将通过探索 ADT 为何不像Object
面向对象程序员所认为的那样,更深入地研究 ADT 的使用Object
。
趣味练习
- 取第一个 POJ(纯 JavaScript)函数,
for
使用reduce
上的方法移除循环Array.prototype
。记录变量 的变化result
,以及 的默认值{}
是如何应用的。 - 取第一个 POJ 函数,不使用计时器(
setTimeout
或setInterval
),将其重构为您能想到的最低效的实现。重构时,思考一下您选择的最低效实现是什么。 - 使用第一个
POJ
函数或练习 1 中的重构函数,确定哪些离散操作/转换可以独立于它们自己的函数中。然后创建这些函数,并重构主函数以使用它们。
附加练习(也是为了好玩)
- 我们使用了第三方库的类型检查谓词函数来进行类型检查。选择我们使用的谓词之一,并实现您自己的版本,在您的实现中抛出不同类型的值,看看它是否按预期运行。
- 如果你恰好熟悉ramda或lodash-fp之类的库,可以只使用你熟悉的库在函数中实现相同的行为。将你的函数结果与上述 ADT 版本的 pointfree 版本进行比较:
// wrapRecord :: Object -> Maybe Object
const wrapRecord = converge(
liftA2(objOf),
composeK(safe(isString), prop('id')),
Maybe.of
)
// mapRecord :: a -> Object
const mapRecord = compose(
option({}),
chain(wrapRecord),
safe(isObject)
)
// indexById :: [ * ] -> Object
const indexById = records => compose(
option({ error: true }),
map(mreduceMap(Assign, mapRecord)),
safe(isArray),
)