JS 中的函数式编程:Functor——Monad 的小兄弟

2025-06-07

JS 中的函数式编程:Functor——Monad 的小兄弟

你已经听说过 monad 了。你也读过一些教程,看过一些例子,但仍然无法完全理解它们。

这很有意思,因为对我来说,monad 的概念从来就没那么难。我想这是因为在学习 monad 之前,我先学习了 functor。

问题是,学习 monad 而不了解和理解 functor,就像学习 Redux 而不了解和理解 React 一样。这根本说不通。

函子比单子简单得多。最重要的是,所有单子实例实际上也是函子。而且,函子本身就很有趣。学习函子之后,你会发现函子无处不在,这将使你能够创建优雅的编程模式和 API。

所以,如果你在使用 Monad 编程时遇到困难,请阅读我关于函子的文章。我们将从一些简单的理论开始,然后通过一个实际的例子来展示如何利用函子编写更简洁、更易读的代码。

如果您在阅读本文后认为它对您有帮助,请在Twitter上订阅我,以获取有关 JavaScript、React 和函数式编程的更多内容。

让我们开始吧!

什么是函子?

在函数式编程中,我们遇到了各种名字奇特、怪异、深奥的概念。函子、单子、应用函子、半群、幺半群。

它们听起来很抽象,很数学化(这并非毫无道理),这会吓跑新来者。

那么这些到底是什么呢?

你可以把它们想象成面向对象编程中设计模式或接口的补充。它们只是一种让我们注意到代码中某些共性,并使其明确的方式。

举个例子,面向对象语言中一个非常流行的模式接口就是可迭代对象。它只是一个可以被迭代的东西。更简单的说——一个可以在 for 循环中使用的对象。

当程序员开始使用 for 循环编写程序时,他们注意到可以迭代许多不同的数据结构。数组、链表、各种类型的字典、图、生成器等等。不胜枚举。

这些数据结构在本质上通常有很大差异并且服务于完全不同的目的,但它们有一个共同点——我们可以编写一个 for 循环以某种方式迭代它们的元素。

所以函数式编程中那些奇特的东西,比如函子和单子,都是基于类似的视角而创建的。我们注意到代码中存在一些共通之处,所以我们实际上会以一种显式的方式将它们引入到代码库中。

这使得编程更容易。如果各种数据结构拥有相似的 API,操作起来会更简单。想象一下,每辆车都有完全不同的转向机制。在不同的车之间切换会非常困难!但无论汽车多么不同——从微型车到大型卡车——转向方式都非常相似,这使得驾驶它们变得容易得多。

同样,使用遵循通用接口的数据结构也更加容易。

除此之外,如果我们定义了一个通用接口,现在就可以尝试编写适用于该接口所有实例的代码。例如,应该可以将每个可迭代对象的实例转换为指定长度的列表。毕竟,我们可以简单地用 for 循环遍历一个数据结构,然后逐步将其元素放入一个新列表中。

我们只需编写一次这样的函数,而不必每次都为可迭代接口的每个实例编写一次。函子和单子也具备这样的能力。例如,Haskell 的标准库中充满了适用于各种抽象接口所有实例的函数。这使得代码重用变得非常容易,无需多次编写外观相似的函数。

JS 示例中函子的概念。

有了这些介绍,我们现在可以介绍函子到底是什么了。

函子只是可以映射的东西。

这句话可能看起来非常抽象,所以让我们用几个例子来解释一下。

当你听到“映射事物”这个词时,你可能会立即想到mapJavaScript 数组中可用的方法。此方法允许你获取一个函数并将其应用于数组的每个元素。这样就会创建一个新数组,其元素就是连续调用该函数的结果。

假设我们想将一个数字数组转换为一个字符串数组。map方法可以让我们轻松地做到这一点:

const arrayOfNumbers = [1, 2, 3];

const arrayOfStrings = arrayOfNumbers.map(num => num + '');
Enter fullscreen mode Exit fullscreen mode

箭头函数num => num + ''以直接的方式将数字转换为字符串。

因此,当我们通过 map 将此函数应用到数组时,我们得到结果["1", "2", "3"]。很简单。

值得注意的是,如果数组为空,它map仍然可以正常工作。由于没有要映射的元素,它只会再次返回一个空数组。

这听起来可能没什么,但请注意,这里为我们处理了一个特殊情况 - 一个空数组,而无需手动检查数组中是否真的有任何元素。

因此 - 根据我们的定义 - 因为我们可以映射数组,所以数组确实是函子的一个实例。

原生 JavaScript 中还有其他函子吗?

你可能会惊讶地发现,Promises 也是函子。你可能会问:“但是为什么呢?Promises 不像数组那样有 map 方法!”

确实如此。但请注意,thenPromise 上的方法也允许你映射存储在 Promise 中的值。假设现在我们有一个存储数字的 Promise,而不是数字数组。我们可以使用与数组相同的函数将该数字转换为字符串:

const promiseWithNumber = Promise.resolve(5);

const promiseWithString = promiseWithNumber.then(num => num + '');
Enter fullscreen mode Exit fullscreen mode

结果我们得到一个解析为值的 Promise "5"

将代码与 Promises 和数组进行比较,注意它在语法和行为上有多么相似:

const arrayOfStrings = arrayOfNumbers.map(num => num + '');

const promiseWithString = primiseWithNumber.then(num => num + '');
Enter fullscreen mode Exit fullscreen mode

混淆这种相似性的原因在于,Promisethen方法是一种万能方法。它用于映射、副作用以及类似 monad 的行为。

从功能的角度来看,如果 Promises 仅具有map遵循一些更严格规则的专用方法,那么它将是一个更清晰的设计:

  • 你不能(或者至少不应该)在其中产生任何副作用,
  • 您不能(或至少不应该)在该函数内再次返回 Promise。

那么相似性就会更加明显:

const arrayOfStrings = arrayOfNumbers.map(num => num + '');

// now it's a map!
const promiseWithString = promiseWithNumber.map(num => num + '');
Enter fullscreen mode Exit fullscreen mode

但这并不能改变then你仍然可以实现类似函子的行为。所以,无论出于何种目的,将 Promise 视为函子接口的另一个实例都是完全可以的。

提出我们自己的函子。

说实话,我还没发现原生 JavaScript 中其他优秀的函子示例。如果你知道,请在评论区告诉我!

但这并不意味着我们已经完成了。我们可以在自己的自定义代码中引入函子。事实上,这将是你了解函子的最大实际优势。将函子行为引入数据结构将使你能够编写更简洁、更可复用的代码,就像map你如何对数组进行这样的操作一样。

第一种方法可能是引入到其他一些本机 JavaScript 数据结构的映射。

例如,JavaScript 对象没有原生map方法。这是因为编写这样的方法时,你必须做出一些不太明显的设计决策。但因为我们map在这里编写自己的方法,所以我们可以随心所欲地处理。

那么对象的映射应该是什么样的呢?最好还是举个例子。假设我们仍然想使用num => num + ''将数字映射到字符串的函数。

如果我们得到一个值为数字的对象:

const objectWithNumbers = {
    a: 1,
    b: 2,
    c: 3
};
Enter fullscreen mode Exit fullscreen mode

我们希望返回一个相同形状的对象,但使用字符串而不是数字:

const objectWithStrings = {
    a: "1",
    b: "2",
    c: "3",
};
Enter fullscreen mode Exit fullscreen mode

我们可以做的是,使用一个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;
};
Enter fullscreen mode Exit fullscreen mode

然后,如果我们运行这个例子:

const objectWithNumbers = {
    a: 1,
    b: 2,
    c: 3
};

const objectWithStrings = mapObject(objectWithNumbers, num => num + '');
Enter fullscreen mode Exit fullscreen mode

我们确实会得到我们期望的结果。

所以我们的函子集合变得更大了。我们可以映射数组、promise 和对象:

const arrayOfStrings = arrayOfNumbers.map(num => num + '');

const promiseWithString = promiseWithNumber.then(num => num + '');

const objectWithStrings = mapObject(objectWithNumbers, num => num + '');
Enter fullscreen mode Exit fullscreen mode

本着可重用性的精神,让我们为函数命名num => num + ''并在示例中使用该名称:

const numberToString = num => num + '';

const arrayOfStrings = arrayOfNumbers.map(numberToString);

const promiseWithString = promiseWithNumber.then(numberToString);

const objectWithStrings = mapObject(objectWithNumbers, numberToString);
Enter fullscreen mode Exit fullscreen mode

这样你就能看到我们的代码现在是多么的可复用和可组合。我们numberToString不仅可以直接对数字使用函数,还可以对任何包含数字的函子使用函数——数字数组、包含数字的 Promise、包含数字的对象等等。

让我们再创建另一个函子的实例。

这次,我们不会为已经存在的数据结构创建映射函数,而是创建自己的数据结构,并通过为其提供方法确保它是一个函子map

我们将编写一个 Maybe 数据结构,它在函数式编程中非常流行。你或许听说过它被称为“Maybe monad”。事实上,Maybe 是一个 monad,但它同时也是一个函子,而这正是我们在本文中要重点讨论的 Maybe 的一个方面。

nullMaybe 是一种数据结构,它表示一个值可能存在也可能不存在。它本质上是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);
    }
}
Enter fullscreen mode Exit fullscreen mode

正如您所见,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));
    }
}
Enter fullscreen mode Exit fullscreen mode

请注意,这里的 map 方法是不可变的 - 它不会修改调用它的实例,而是创建一个新的 Maybe 实例或仅返回之前未修改的值。

如果 Maybenull内部有一个,它只会返回相同的值——带有的 Maybe null

但是,如果 Maybe 包含一些实际值,则对该值map调用mapper 并创建一个带有映射值的新 Maybe。fn

这看起来可能很多,所以让我们来玩一下我们新创建的 Maybe 数据结构:

const maybeNumber = Maybe.just(5);

const maybeString = maybeNumber.map(numberToString);
Enter fullscreen mode Exit fullscreen mode

这里我们创建了一个 Maybe,里面有一个实际值——数字 5。然后我们可以用numberToString它映射到一个"5"里面有一个字符串的 Maybe。

但在实际代码中,我们的 Maybe 中可能会出现 a 的情况null。有趣的是,我们不需要手动检查这种情况。它map会帮我们处理:

const numberMaybe = Maybe.just(null);

const stringMaybe = numberMaybe.map(numberToString); // this does not crash!
Enter fullscreen mode Exit fullscreen mode

因为 null 值是在map方法本身中处理的,所以我们实际上不需要再考虑 Maybe 中是否真的存在一个值。我们可以直接对这个“可能值”进行操作,而无需任何检查和判断。

将此与值的典型用法进行比较null,在任何操作之前,我们必须检查值是否真的存在:

const numberOrNull = /* this is either a number or null, we don't know */;

const stringOrNull = numberOrNull === null ? 
    null : 
    numberToString(numberOrNull);
Enter fullscreen mode Exit fullscreen mode

这些检查非常尴尬,尤其是当代码库中很多地方都用到这个值的时候。也许可以让你只做一次这样的检查——在 map 方法中——之后就不用再考虑它了。

再次注意这个 API 与我们之前的函子实例有多么相似:

const arrayOfStrings = arrayOfNumbers.map(numberToString);

const promiseWithString = promiseWithNumber.then(numberToString);

const objectWithStrings = mapObject(objectWithNumbers, numberToString);

const maybeString = maybeNumber.map(numberToString);
Enter fullscreen mode Exit fullscreen mode

尽管 Maybe 的工作方式与数组或 Promise 完全不同,但我们可以使用相同的思维模型对所有这些数据结构进行编程。

还要注意,我们所有的函子实例都内置了某种特殊情况处理:

map对于数组,处理空数组的情况。mapObject处理空对象的情况。Promise.then处理被拒绝的承诺的情况。Maybe.map处理一个null值的情况。

这样,我们不仅获得了适用于多种数据结构的通用 API,还能处理各种特殊情况,这样我们就不用再考虑它们了。是不是很酷?

令人惊讶的是,我们用“可映射的事物”这样一个简单的概念实现了如此多的功能。函数式编程中更复杂的接口(例如 monad)更加强大,带来更多好处,这也不足为奇。

但那是另一篇文章的故事……

函子定律

如果你之前读过关于函子或单子的内容,你可能会注意到我们省略了一些东西。单子(以及函子)都遵循着一些著名的“定律”。

它们类似于数学定律,也成功地吓跑了人们学习函数式编程。毕竟我们只想写代码,不想做数学!

但重要的是要理解,这些定律只不过相当于说“这个数据结构是用合理的方式编写的”。或者换句话说,“这个数据结构并不愚蠢”。

让我们看一个例子。

函子的第一定律(一共有两条)指出,如果我们采用一个恒等函数(它只是一个返回其参数的函数):

const identity = a => a;
Enter fullscreen mode Exit fullscreen mode

然后我们把它放在一个map方法中,这个方法就会返回我们之前的数据结构,并且不会改变。或者说,它会返回一个新的数据结构,但其结构与之前的数据结构完全相同。

事实上,如果我们用身份调用数组的映射,我们将再次得到相同的数组:

[1, 2, 3].map(identity) // this returns [1, 2, 3] again
Enter fullscreen mode Exit fullscreen mode

但是,如果 JavaScript 的创建者想要让语言更有趣一点,并决定map以相反的顺序返回值,那该怎么办?

例如此代码:

[1, 2, 3].map(numberToString)
Enter fullscreen mode Exit fullscreen mode

将返回["3", "2", "1"]数组。

那么显然:

[1, 2, 3].map(identity)
Enter fullscreen mode Exit fullscreen mode

会返回一个[3, 2, 1]数组。但这已经不是同一个数组了!我们违反了第一函子定律!

所以你可以看到,这条法律根本不允许人们编写愚蠢的map函数!

第二定律也是如此,它指出将两个函数一个接一个地映射:

someFunctor
    .map(firstFunction)
    .map(secondFunction)
Enter fullscreen mode Exit fullscreen mode

应该产生与在映射中运行这两个函数相同的值:

someFunctor.map(value => {
    const x = firstFunction(value);
    return secondFunction(x);
});
Enter fullscreen mode Exit fullscreen mode

作为练习,尝试检查我们的逆是否map满足这个条件。

不要过多考虑法律

我看过很多文章,例如“Promise 实际上不是一个 monad”等等。

这些文章确实有其价值,但我认为你不应该过多地考虑函子律或单子律。毕竟,正如我所展示的,它们的作用仅仅是确保数据结构不会以荒谬的方式编写。

但是如果数据结构不完全满足函子或单子定律,我仍然认为将其视为函子或单子是有价值的。

这是因为在日常编程中,函子最有价值的是作为一种设计模式(或接口),而不是作为一个数学概念。我们并不是想在这里写一些学术代码,然后用数学方法证明它的正确性。我们只是想写一些更健壮、更易读的代码。仅此而已。

因此,即使 - 例如 - Promise 可能实际上不是一个 monad,我仍然认为它是 monad 的一个很好的例子,因为它展示了如何使用“monad”风格以优雅的方式处理异步性。

所以,别当数学怪咖,务实点儿。:)

结论

我希望此时函子对你来说不再是一个神秘的概念。

这意味着你已经准备好学习 monad 了!理解了函子之后,学习 monad 其实就是对函子的设计做一些修改。

如果您想看到与本文风格类似的 monad 教程,请给我留言。

此外,如果您喜欢阅读这篇文章,请在Twitter上订阅我,以获取有关 JavaScript、React 和函数式编程的更多内容。

感谢您的阅读并祝您有美好的一天!

(封面照片由Nikola Johnny MirkovicUnsplash上拍摄)

文章来源:https://dev.to/mpodlasin/functional-programming-in-js-functor-monad-s-little-brother-3053
PREV
如何创建和发布 npm 模块
NEXT
CI/CD tutorial using GitHub Actions