发布于 2026-01-06 5 阅读
0

函子、应用式和单子图解(ReasonML)

函子、应用式和单子图解(ReasonML)

这是Haskell 中的Functors, Applicatives, And Monads In Pictures到 ReasonML的翻译。

我并未将此作品的功劳据为己有,如果您喜欢这篇文章,请务必感谢原作者Aditya Bhargava ( @_egonschiele )。


这是一个简单的数值:

我们知道如何将函数应用于这个值:

很简单。我们再进一步,假设任何值都可以存在于某个上下文中。现在,你可以把上下文想象成一个盒子,你可以把值放进去:

现在,当你将一个函数应用于这个值时,你会根据上下文得到不同的结果。这就是函子、应用式、单子、箭头函数等概念的基础。

让我们创建一个Maybe数据类型来定义两个相关的上下文:


module Maybe = {
  type t('a) =
    | Nothing
    | Just('a);
}
Enter fullscreen mode Exit fullscreen mode

稍后我们将看到,当对象是函数(a)Just(a)而不是函子(a)时,函数应用有何不同Nothing。首先,让我们来谈谈函子!

函子

当一个值被包裹在上下文中时,你不能对其应用普通函数:

这就是它的用武之地fmapfmap它来自街头,fmap了解语境。fmap它知道如何将函数应用于包含在语境中的值。

假设你想申请(+3)Just(2)我们可以实现fmap

Maybe.fmap((+)(3), Just(2));
// Just(5)
Enter fullscreen mode Exit fullscreen mode

砰! fmap给我们展示了怎么做到的!

函子究竟是什么?

Functor是定义函数的函数类fmap

在 Haskell 中,它们被定义为类型类

ReasonML 目前还没有类型类,但OCaml 正在开发类型类,因此 ReasonML 也在开发类型类。

以下是定义:

AFunctor是任何定义了如何fmap应用于它的数据类型。以下是它fmap的工作原理:

所以我们可以这样做:

Maybe.fmap((+)(3), Just(2));
// Just(5)
Enter fullscreen mode Exit fullscreen mode

这具体说明了如何fmap应用于Justs 和Nothings:

让我们fmapMaybe模块中添加以下内容:

let fmap = (f, m) => {
  switch (m) {
  | Nothing => Nothing
  | Just(a) => Just(f(a))
  };
};
Enter fullscreen mode Exit fullscreen mode

以下是我们写作时幕后发生的事情Maybe.fmap((+)(3), Just(2));

所以你就会想,好吧fmap,请申请(+3)一个Nothing


Maybe.fmap((+)(3), Nothing)
// Nothing
Enter fullscreen mode Exit fullscreen mode

就像《黑客帝国》里的墨菲斯一样,fmap他知道该怎么做;你从 `\n` 开始Nothing,最终得到 ` Nothing\n`!fmap这就是禅意。现在你明白为什么这种Maybe数据类型存在了。例如,以下是在没有 `\n` 的语言中操作数据库记录的方法Maybe

post = Post.find_by_id(1)
if post
  return post.title
else
  return nil
end
Enter fullscreen mode Exit fullscreen mode

让我们Post用这些函数在 ReasonML 中创建一个简单的模块。

module Post = {
  type t = {
    id: int,
    title: "string,"
  };
  let make = (id, title) => {id, title};
  let fmap = (f, post) => f(post);
  let getPostTitle = post => post.title;
  let findPost = id => make(id, "Post #" ++ string_of_int(id));
};
Enter fullscreen mode Exit fullscreen mode

现在我们可以这样写:

Post.(fmap(getPostTitle, findPost(1)));
Enter fullscreen mode Exit fullscreen mode

如果findPost返回一个帖子,我们将获取该帖子的标题getPostTitle。如果返回空值Nothing,我们将返回空值Nothing!很巧妙吧?

在 Haskell 中,<$>是常见的中缀版本fmap

在 ReasonML 中,我们可以创建等效的别名。添加到我们的Post模块中:

let (<$>) = fmap;
Enter fullscreen mode Exit fullscreen mode

所以我们现在可以这样写:

Post.(getPostTitle <$> findPost(1));
Enter fullscreen mode Exit fullscreen mode

再举一个例子:将函数应用于列表会发生什么?

列表也可以作为函子使用!

在 ReasonML 中,我们可以使用列表映射函数:

List.map
Enter fullscreen mode Exit fullscreen mode

好吧,好吧,最后一个例子:当一个函数应用于另一个函数时会发生什么?

对于这种情况,我们可以定义一个Function包含以下内容的模块fmap

module Function = {
  let fmap = (f, g, x) => f(g(x));
};
Enter fullscreen mode Exit fullscreen mode

所以我们现在可以这样写:

Function.fmap((+)(3), (+)(1));
Enter fullscreen mode Exit fullscreen mode

这是一个函数:

以下是一个函数应用于另一个函数的示例:

结果只是另一个函数!

let foo = Function.fmap((+)(3), (+)(2));
foo(10);
// 15
Enter fullscreen mode Exit fullscreen mode

所以函数也可以是函子!

当你fmap在函数上使用它时,你实际上就是在进行函数组合!

应用

应用式将这种特性提升到了一个新的层次。使用应用式,我们的值会被封装在一个上下文中,就像函子一样:

但是我们的函数也被封装在一个上下文中!

没错。好好想想。应用式可不是闹着玩的。它们知道如何将一个包裹在上下文中的函数应用到一个包裹在上下文中的值上:

应用程序定义一个apply函数(在 Haskell 中也写作<*>),我们可以在 ReasonML 中为其创建一个别名。

让我们把应用程序函数添加到我们的Maybe模块中:

let apply = (mf, mv) => {
  switch (mv) {
  | Nothing => Nothing
  | Just(v) =>
    switch (mf) {
    | Nothing => Nothing
    | Just(f) => Just(f(v))
    }
  };
};

let (<*>) = apply;
Enter fullscreen mode Exit fullscreen mode

以下是一个使用它们的例子:

Maybe.(Just((+)(3)) <*> Just(2));
// Just(5)
Enter fullscreen mode Exit fullscreen mode

我们还要为列表定义应用函数。MyList为了避免与内置List模块名称冲突,我们将创建一个新模块:

module MyList = {
  type apply('a, 'b) = (list('a => 'b), list('a)) => list('b);

  let apply: apply('a, 'b) =
    (fs, xs) => List.flatten(List.map(f => List.map(f, xs), fs));

  let (<*>) = apply;
};
Enter fullscreen mode Exit fullscreen mode

使用这种方法<*>可能会产生一些有趣的情况。例如:

let funList = [(*)(2), (+)(3)];
let valList = [1, 2, 3];
MyList.(funList <*> valList);
// [2, 4, 6, 4, 5, 6]
Enter fullscreen mode Exit fullscreen mode

以下是使用 Applicatives 可以做到而使用 Functors 无法做到的事情:如何将一个接受两个参数的函数应用于两个包装后的值?

Maybe.((+) <$> Just(5));
// Just((+)(5))

Maybe.(Just((+)(5)) <$> Just(4));
// ERROR ??? WHAT DOES THIS EVEN MEAN WHY IS THE FUNCTION WRAPPED IN A JUST
Enter fullscreen mode Exit fullscreen mode

应用:

Maybe.((+) <$> Just(5));
// Just((+)(5))

Maybe.(Just((+)(5)) <*> Just(3));
// Just(8)
Enter fullscreen mode Exit fullscreen mode

ApplicativeFunctor一把推开。“大人物可以使用任意数量参数的函数,”它说道。“有了 `and`<$>和 ` <*>,我可以接受任何需要任意数量未包装值的函数。然后我把所有包装值都传递给它,就能得到一个包装值!哈哈哈哈!”

Maybe.((*) <$> Just(5) <*> Just(3));
Enter fullscreen mode Exit fullscreen mode

单子

如何学习 Monad:

  1. 攻读计算机科学博士学位。
  2. 把它扔掉吧,这部分用不到!

单子带来了一种新的变化。

函子将函数应用于包装后的值:

应用函数将包装后的函数应用于包装后的值:

Monad将返回包装值的函数应用于包装值。

Monad 有一个函数bind或运算符别名>>=可以实现此功能。

我们来看一个例子。

首先,我们需要将绑定添加到我们熟悉的Maybe模块中:

let bind = (mv, f) => {
  switch (mv) {
  | Nothing => Nothing
  | Just(v) => f(v)
  };
};

let (>>=) = bind;
Enter fullscreen mode Exit fullscreen mode

假设half有一个函数只对偶数有效:

让我们half用 ReasonML 编写代码(我们还需要定义even一些odd函数):

/*
 Mutually recursive function
 https://ocaml.org/learn/tutorials/labels.html
 */
let rec even = x =>
  if (x <= 0) {
    true;
  } else {
    odd(x - 1);
  }
and odd = x =>
  if (x <= 0) {
    false;
  } else {
    even(x - 1);
  };

let half = x =>
  if (even(x)) {
    Maybe.Just(x / 2);
  } else {
    Nothing;
  };
Enter fullscreen mode Exit fullscreen mode

如果我们输入一个包装后的值呢?

我们需要用它>>=来将包装后的值传递给函数。下图是>>=

它的运作方式如下:

Maybe.(Just(3) >>= half);
// Nothing

Maybe.(Just(4) >>= half);
// Just(2)

Maybe.(Nothing >>= half);
// Nothing
Enter fullscreen mode Exit fullscreen mode

内部发生了什么?Monad定义了一个bind(或>>=)函数:

让我们Maybe通过添加函数将我们的代码变成一个单子bind

let bind = (mv, f) => {
  switch (mv) {
  | Nothing => Nothing
  | Just(v) => f(v)
  };
};

let (>>=) = bind;
Enter fullscreen mode Exit fullscreen mode

这里展示的是它的运行效果Just(3)

如果你通过了审核,Nothing那就更简单了:

您还可以将这些调用串联起来:

Maybe.(Just(20) >>= half >>= half >>= half);
Enter fullscreen mode Exit fullscreen mode

太棒了!现在我们已经实现了Maybe成为Functor,一个Applicative,一个Monad

现在让我们来看另一个例子,创建一个IO单子:

IOHaskell 中存在 monad,但我们将在 ReasonML 中声明我们自己的 monad

具体来说,有三个函数。getLine它们不接受任何参数,而是接收用户输入:

readFile接受一个字符串(文件名),并返回该文件的内容:

putStrLn接收一个字符串并将其打印出来:

我们的IO模块可能看起来像这样(省略了辅助函数的实现细节):

module IO = {
  type t = Js.Promise.t(string);

  type bind('a, 'b) = (t, string => t) => t;
  let bind: bind('a, 'b) = (pa, f) => pa |> Js.Promise.then_(a => f(a));

  let (>>=) = bind;

  type getLine = unit => t;
  let getLine = ...

  type readFile = string => t;
  let readFile = ...

  type putStrLn = string => t;
  let putStrLn = ...
};
Enter fullscreen mode Exit fullscreen mode

如果您感兴趣,可以获取完整的源代码

这三个函数都接受一个常规值(或空值),并返回一个包装后的值。我们可以使用!Promise将它们串联起来。>>=


IO.(getLine() >>= readFile >>= putStrLn);
Enter fullscreen mode Exit fullscreen mode

哇哦!Monad 表演的前排座位!

结论

  1. 函子是一种实现了fmap函数的数据类型。
  2. 应用类型是一种实现apply函数的数据类型。
  3. 单子是一种实现该bind函数的数据类型。

我们示例中的模块Maybe实现了这三种特性,因此它是一个函子、一个应用器和一个单子。

这三者之间有什么区别?

函子fmap:您可以使用或将函数应用于包装值<$>

应用函数apply:您可以使用`or`将包装函数应用于包装值<*>

Monadbind :你可以使用`or`将返回包装值的函数应用于包装值>>=

所以,亲爱的朋友(我想我们现在应该算是朋友了),我想我们都同意,单子很容易理解,而且是个很棒的主意(tm)。既然你已经通过这篇指南初步了解了单子,何不效仿梅尔·吉布森,把整瓶酒都喝光呢?去看看LYAH关于单子的章节。我省略了很多内容,因为Miran在这方面做得非常出色,讲解得非常深入。


如果您觉得我可以在翻译成 ReasonML 时做出一些改进,请告诉我。

再次感谢Aditya Bhargava撰写了这篇文章的原文❤️

文章来源:https://dev.to/kevanstannard/functors-applicatives-and-monads-in-pictures-in-reasonml-3p44