JS 中的函数式编程:Functor——Monad 的小兄弟
你已经听说过 monad 了。你也读过一些教程,看过一些例子,但仍然无法完全理解它们。
这很有意思,因为对我来说,monad 的概念从来就没那么难。我想这是因为在学习 monad 之前,我先学习了 functor。
问题是,学习 monad 而不了解和理解 functor,就像学习 Redux 而不了解和理解 React 一样。这根本说不通。
函子比单子简单得多。最重要的是,所有单子实例实际上也是函子。而且,函子本身就很有趣。学习函子之后,你会发现函子无处不在,这将使你能够创建优雅的编程模式和 API。
所以,如果你在使用 Monad 编程时遇到困难,请阅读我关于函子的文章。我们将从一些简单的理论开始,然后通过一个实际的例子来展示如何利用函子编写更简洁、更易读的代码。
如果您在阅读本文后认为它对您有帮助,请在Twitter上订阅我,以获取有关 JavaScript、React 和函数式编程的更多内容。
让我们开始吧!
什么是函子?
在函数式编程中,我们遇到了各种名字奇特、怪异、深奥的概念。函子、单子、应用函子、半群、幺半群。
它们听起来很抽象,很数学化(这并非毫无道理),这会吓跑新来者。
那么这些到底是什么呢?
你可以把它们想象成面向对象编程中设计模式或接口的补充。它们只是一种让我们注意到代码中某些共性,并使其明确的方式。
举个例子,面向对象语言中一个非常流行的模式和接口就是可迭代对象。它只是一个可以被迭代的东西。更简单的说——一个可以在 for 循环中使用的对象。
当程序员开始使用 for 循环编写程序时,他们注意到可以迭代许多不同的数据结构。数组、链表、各种类型的字典、图、生成器等等。不胜枚举。
这些数据结构在本质上通常有很大差异并且服务于完全不同的目的,但它们有一个共同点——我们可以编写一个 for 循环以某种方式迭代它们的元素。
所以函数式编程中那些奇特的东西,比如函子和单子,都是基于类似的视角而创建的。我们注意到代码中存在一些共通之处,所以我们实际上会以一种显式的方式将它们引入到代码库中。
这使得编程更容易。如果各种数据结构拥有相似的 API,操作起来会更简单。想象一下,每辆车都有完全不同的转向机制。在不同的车之间切换会非常困难!但无论汽车多么不同——从微型车到大型卡车——转向方式都非常相似,这使得驾驶它们变得容易得多。
同样,使用遵循通用接口的数据结构也更加容易。
除此之外,如果我们定义了一个通用接口,现在就可以尝试编写适用于该接口所有实例的代码。例如,应该可以将每个可迭代对象的实例转换为指定长度的列表。毕竟,我们可以简单地用 for 循环遍历一个数据结构,然后逐步将其元素放入一个新列表中。
我们只需编写一次这样的函数,而不必每次都为可迭代接口的每个实例编写一次。函子和单子也具备这样的能力。例如,Haskell 的标准库中充满了适用于各种抽象接口所有实例的函数。这使得代码重用变得非常容易,无需多次编写外观相似的函数。
JS 示例中函子的概念。
有了这些介绍,我们现在可以介绍函子到底是什么了。
函子只是可以映射的东西。
这句话可能看起来非常抽象,所以让我们用几个例子来解释一下。
当你听到“映射事物”这个词时,你可能会立即想到map
JavaScript 数组中可用的方法。此方法允许你获取一个函数并将其应用于数组的每个元素。这样就会创建一个新数组,其元素就是连续调用该函数的结果。
假设我们想将一个数字数组转换为一个字符串数组。map
方法可以让我们轻松地做到这一点:
const arrayOfNumbers = [1, 2, 3];
const arrayOfStrings = arrayOfNumbers.map(num => num + '');
箭头函数num => num + ''
以直接的方式将数字转换为字符串。
因此,当我们通过 map 将此函数应用到数组时,我们得到结果["1", "2", "3"]
。很简单。
值得注意的是,如果数组为空,它map
仍然可以正常工作。由于没有要映射的元素,它只会再次返回一个空数组。
这听起来可能没什么,但请注意,这里为我们处理了一个特殊情况 - 一个空数组,而无需手动检查数组中是否真的有任何元素。
因此 - 根据我们的定义 - 因为我们可以映射数组,所以数组确实是函子的一个实例。
原生 JavaScript 中还有其他函子吗?
你可能会惊讶地发现,Promises 也是函子。你可能会问:“但是为什么呢?Promises 不像数组那样有 map 方法!”
确实如此。但请注意,then
Promise 上的方法也允许你映射存储在 Promise 中的值。假设现在我们有一个存储数字的 Promise,而不是数字数组。我们可以使用与数组相同的函数将该数字转换为字符串:
const promiseWithNumber = Promise.resolve(5);
const promiseWithString = promiseWithNumber.then(num => num + '');
结果我们得到一个解析为值的 Promise "5"
。
将代码与 Promises 和数组进行比较,注意它在语法和行为上有多么相似:
const arrayOfStrings = arrayOfNumbers.map(num => num + '');
const promiseWithString = primiseWithNumber.then(num => num + '');
混淆这种相似性的原因在于,Promisethen
方法是一种万能方法。它用于映射、副作用以及类似 monad 的行为。
从功能的角度来看,如果 Promises 仅具有map
遵循一些更严格规则的专用方法,那么它将是一个更清晰的设计:
- 你不能(或者至少不应该)在其中产生任何副作用,
- 您不能(或至少不应该)在该函数内再次返回 Promise。
那么相似性就会更加明显:
const arrayOfStrings = arrayOfNumbers.map(num => num + '');
// now it's a map!
const promiseWithString = promiseWithNumber.map(num => num + '');
但这并不能改变then
你仍然可以实现类似函子的行为。所以,无论出于何种目的,将 Promise 视为函子接口的另一个实例都是完全可以的。
提出我们自己的函子。
说实话,我还没发现原生 JavaScript 中其他优秀的函子示例。如果你知道,请在评论区告诉我!
但这并不意味着我们已经完成了。我们可以在自己的自定义代码中引入函子。事实上,这将是你了解函子的最大实际优势。将函子行为引入数据结构将使你能够编写更简洁、更可复用的代码,就像map
你如何对数组进行这样的操作一样。
第一种方法可能是引入到其他一些本机 JavaScript 数据结构的映射。
例如,JavaScript 对象没有原生map
方法。这是因为编写这样的方法时,你必须做出一些不太明显的设计决策。但因为我们map
在这里编写自己的方法,所以我们可以随心所欲地处理。
那么对象的映射应该是什么样的呢?最好还是举个例子。假设我们仍然想使用num => num + ''
将数字映射到字符串的函数。
如果我们得到一个值为数字的对象:
const objectWithNumbers = {
a: 1,
b: 2,
c: 3
};
我们希望返回一个相同形状的对象,但使用字符串而不是数字:
const objectWithStrings = {
a: "1",
b: "2",
c: "3",
};
我们可以做的是,使用一个Object.entries
方法来获取 的键和值numbersObject
。然后,基于这些值,我们将创建一个新的对象,并通过num => num + ''
函数映射这些值。
因为向原生 JS 原型添加新方法是一种不好的做法,所以我们只需创建一个mapObject
函数,它将接受两个参数——一个我们想要映射的对象和一个执行实际映射的函数:
const mapObject = (object, fn) => {
const entries = Object.entries(object);
const mappedObject = {};
entries.forEach(([key, value]) => {
// here is where the mapping is happening!
mappedObject[key] = fn(value);
});
return mappedObject;
};
然后,如果我们运行这个例子:
const objectWithNumbers = {
a: 1,
b: 2,
c: 3
};
const objectWithStrings = mapObject(objectWithNumbers, num => num + '');
我们确实会得到我们期望的结果。
所以我们的函子集合变得更大了。我们可以映射数组、promise 和对象:
const arrayOfStrings = arrayOfNumbers.map(num => num + '');
const promiseWithString = promiseWithNumber.then(num => num + '');
const objectWithStrings = mapObject(objectWithNumbers, num => num + '');
本着可重用性的精神,让我们为函数命名num => num + ''
并在示例中使用该名称:
const numberToString = num => num + '';
const arrayOfStrings = arrayOfNumbers.map(numberToString);
const promiseWithString = promiseWithNumber.then(numberToString);
const objectWithStrings = mapObject(objectWithNumbers, numberToString);
这样你就能看到我们的代码现在是多么的可复用和可组合。我们numberToString
不仅可以直接对数字使用函数,还可以对任何包含数字的函子使用函数——数字数组、包含数字的 Promise、包含数字的对象等等。
让我们再创建另一个函子的实例。
这次,我们不会为已经存在的数据结构创建映射函数,而是创建自己的数据结构,并通过为其提供方法确保它是一个函子map
。
我们将编写一个 Maybe 数据结构,它在函数式编程中非常流行。你或许听说过它被称为“Maybe monad”。事实上,Maybe 是一个 monad,但它同时也是一个函子,而这正是我们在本文中要重点讨论的 Maybe 的一个方面。
null
Maybe 是一种数据结构,它表示一个值可能存在也可能不存在。它本质上是or 的替代品undefined
。如果某个值可以是 eithernull
或 or undefined
,我们就会用 Maybe 来代替。
事实上,在我们的 Maybe 实现中,我们将简单地使用它null
来表示一个不存在的值:
class Maybe {
constructor(value) {
this.value = value;
}
static just(value) {
if (value === null || value === undefined) {
throw new Error("Can't construct a value from null/undefined");
}
return new Maybe(value);
}
static nothing() {
return new Maybe(null);
}
}
正如您所见,Maybe 只是一个值的包装器,具有两个静态方法。
Maybe.just
允许您创建一个包含实际值的 Maybe 数据结构(这就是我们检查null
和 的原因undefined
)。
另一方面,Maybe.nothing
只需创建一个内部带有空值(我们将其解释为“无值”)的 Maybe。
到目前为止,这样的数据结构可能看起来没什么用。正是因为它还不是一个函子!所以,让我们通过编写一个 map 方法,将它变成一个函子:
class Maybe {
// nothing changes here
map(fn) {
if (this.value === null) {
return this;
}
return new Maybe(fn(value));
}
}
请注意,这里的 map 方法是不可变的 - 它不会修改调用它的实例,而是创建一个新的 Maybe 实例或仅返回之前未修改的值。
如果 Maybenull
内部有一个,它只会返回相同的值——带有的 Maybe null
。
但是,如果 Maybe 包含一些实际值,则对该值map
调用mapper 并创建一个带有映射值的新 Maybe。fn
这看起来可能很多,所以让我们来玩一下我们新创建的 Maybe 数据结构:
const maybeNumber = Maybe.just(5);
const maybeString = maybeNumber.map(numberToString);
这里我们创建了一个 Maybe,里面有一个实际值——数字 5。然后我们可以用numberToString
它映射到一个"5"
里面有一个字符串的 Maybe。
但在实际代码中,我们的 Maybe 中可能会出现 a 的情况null
。有趣的是,我们不需要手动检查这种情况。它map
会帮我们处理:
const numberMaybe = Maybe.just(null);
const stringMaybe = numberMaybe.map(numberToString); // this does not crash!
因为 null 值是在map
方法本身中处理的,所以我们实际上不需要再考虑 Maybe 中是否真的存在一个值。我们可以直接对这个“可能值”进行操作,而无需任何检查和判断。
将此与值的典型用法进行比较null
,在任何操作之前,我们必须检查值是否真的存在:
const numberOrNull = /* this is either a number or null, we don't know */;
const stringOrNull = numberOrNull === null ?
null :
numberToString(numberOrNull);
这些检查非常尴尬,尤其是当代码库中很多地方都用到这个值的时候。也许可以让你只做一次这样的检查——在 map 方法中——之后就不用再考虑它了。
再次注意这个 API 与我们之前的函子实例有多么相似:
const arrayOfStrings = arrayOfNumbers.map(numberToString);
const promiseWithString = promiseWithNumber.then(numberToString);
const objectWithStrings = mapObject(objectWithNumbers, numberToString);
const maybeString = maybeNumber.map(numberToString);
尽管 Maybe 的工作方式与数组或 Promise 完全不同,但我们可以使用相同的思维模型对所有这些数据结构进行编程。
还要注意,我们所有的函子实例都内置了某种特殊情况处理:
map
对于数组,处理空数组的情况。mapObject
处理空对象的情况。Promise.then
处理被拒绝的承诺的情况。Maybe.map
处理一个null
值的情况。
这样,我们不仅获得了适用于多种数据结构的通用 API,还能处理各种特殊情况,这样我们就不用再考虑它们了。是不是很酷?
令人惊讶的是,我们用“可映射的事物”这样一个简单的概念实现了如此多的功能。函数式编程中更复杂的接口(例如 monad)更加强大,带来更多好处,这也不足为奇。
但那是另一篇文章的故事……
函子定律
如果你之前读过关于函子或单子的内容,你可能会注意到我们省略了一些东西。单子(以及函子)都遵循着一些著名的“定律”。
它们类似于数学定律,也成功地吓跑了人们学习函数式编程。毕竟我们只想写代码,不想做数学!
但重要的是要理解,这些定律只不过相当于说“这个数据结构是用合理的方式编写的”。或者换句话说,“这个数据结构并不愚蠢”。
让我们看一个例子。
函子的第一定律(一共有两条)指出,如果我们采用一个恒等函数(它只是一个返回其参数的函数):
const identity = a => a;
然后我们把它放在一个map
方法中,这个方法就会返回我们之前的数据结构,并且不会改变。或者说,它会返回一个新的数据结构,但其结构与之前的数据结构完全相同。
事实上,如果我们用身份调用数组的映射,我们将再次得到相同的数组:
[1, 2, 3].map(identity) // this returns [1, 2, 3] again
但是,如果 JavaScript 的创建者想要让语言更有趣一点,并决定map
以相反的顺序返回值,那该怎么办?
例如此代码:
[1, 2, 3].map(numberToString)
将返回["3", "2", "1"]
数组。
那么显然:
[1, 2, 3].map(identity)
会返回一个[3, 2, 1]
数组。但这已经不是同一个数组了!我们违反了第一函子定律!
所以你可以看到,这条法律根本不允许人们编写愚蠢的map
函数!
第二定律也是如此,它指出将两个函数一个接一个地映射:
someFunctor
.map(firstFunction)
.map(secondFunction)
应该产生与在映射中运行这两个函数相同的值:
someFunctor.map(value => {
const x = firstFunction(value);
return secondFunction(x);
});
作为练习,尝试检查我们的逆是否map
满足这个条件。
不要过多考虑法律
我看过很多文章,例如“Promise 实际上不是一个 monad”等等。
这些文章确实有其价值,但我认为你不应该过多地考虑函子律或单子律。毕竟,正如我所展示的,它们的作用仅仅是确保数据结构不会以荒谬的方式编写。
但是如果数据结构不完全满足函子或单子定律,我仍然认为将其视为函子或单子是有价值的。
这是因为在日常编程中,函子最有价值的是作为一种设计模式(或接口),而不是作为一个数学概念。我们并不是想在这里写一些学术代码,然后用数学方法证明它的正确性。我们只是想写一些更健壮、更易读的代码。仅此而已。
因此,即使 - 例如 - Promise 可能实际上不是一个 monad,我仍然认为它是 monad 的一个很好的例子,因为它展示了如何使用“monad”风格以优雅的方式处理异步性。
所以,别当数学怪咖,务实点儿。:)
结论
我希望此时函子对你来说不再是一个神秘的概念。
这意味着你已经准备好学习 monad 了!理解了函子之后,学习 monad 其实就是对函子的设计做一些修改。
如果您想看到与本文风格类似的 monad 教程,请给我留言。
此外,如果您喜欢阅读这篇文章,请在Twitter上订阅我,以获取有关 JavaScript、React 和函数式编程的更多内容。
感谢您的阅读并祝您有美好的一天!
(封面照片由Nikola Johnny Mirkovic在Unsplash上拍摄)
文章来源:https://dev.to/mpodlasin/functional-programming-in-js-functor-monad-s-little-brother-3053