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

理解 Monad:F# 小入门 场景 我们的第一个实现 重构以求更简洁的实现 即将发现 🗺 你刚刚发现了 Monad 👏 嘿,我认出你了! 🕵️‍♀️ Monad 就是“then-able”容器 📦 在实际应用中发现 Monad 我们学到了什么? 👨‍🎓 下次见

理解单子

小F#引物

场景

我们的首次实施

重构以实现更简洁的实现

即将发现🗺

你刚刚发现了单子👏

嘿,我认出你了!🕵️‍♀️

Monad 只是“可以执行”的容器📦

在野外发现单子

我们学到了什么?👨‍🎓

下次

在这篇文章中,我们将通过一个实际的例子来独立“发现”monad,从而理解monad。

小F#引物

我们将使用 F#,但即使您以前没用过,也应该很容易上手。您只需要理解以下几点即可。

  • F# 中有一个option类型。它通过一个值来表示值的存在Some或缺失None。它通常用于代替 `int`null来表示缺失值。
  • 类型匹配option如下所示:
match anOptionalValue with
| Some x -> // expression when the value exists
| None -> // expression when the value doesn't exist.
Enter fullscreen mode Exit fullscreen mode
  • F# 有一个管道运算符,记为 `&&` |>。它是一个中缀运算符,将左侧的值应用于右侧的函数。例如,如果toLower一个函数接受一个字符串并将其转换为小写,那么 `&&`"ABC |> toLower将输出 `"" "abc"`。

场景

假设我们正在编写一段需要从用户信用卡扣款的代码。如果用户存在且其个人资料中保存了信用卡信息,我们可以进行扣款;否则,我们需要发出信号表明没有发生任何交易。

F# 中的数据模型

type CreditCard =
    { Number: string
      Expiry: string
      Cvv: string }

type User = 
    { Id: UserId
      CreditCard: CreditCard option }
Enter fullscreen mode Exit fullscreen mode

请注意,记录CreditCard中的字段Useroption,因为它可能缺失。

我们想要编写一个chargeUserCard具有以下签名的函数。

double -> UserId -> TransactionId option
Enter fullscreen mode Exit fullscreen mode

它应该接受一个类型为 的金额doubleUserId并返回Some TransactionId用户卡是否成功扣款,否则None返回卡未扣款。

我们的首次实施

让我们来尝试实现一下chargeUserCard。首先,我们将定义几个辅助函数,用于查找用户和实际刷卡。

let chargeCard (amount: double) (card: CreditCard): TransactionId option =
    // synchronously charges the card and returns 
    // Some TransactionId if successful, otherwise None

let lookupUser (userId: UserId): User option =
    // synchronously lookup a user that might not exist

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    let user = lookupUser userId
    match user with
    | Some u ->
        match u.CreditCard with
        | Some cc -> chargeCard amount cc
        | None -> None
    | None -> None
Enter fullscreen mode Exit fullscreen mode

完成了,但代码有点乱。双重模式匹配的代码读起来不太清晰。在这个简单的例子中或许还能勉强应付,但如果出现第三个或第四个嵌套匹配,那就麻烦了。我们可以通过提取一些函数来解决这个问题,但还有另一个问题。注意,在这两种None情况下我们都返回了None`null`。这看起来没什么问题,因为默认值很简单,而且只重复了两次,但我们应该能做得更好。

我们真正想要的是能够说,“如果在任何时候因为缺少某些数据而无法继续,那么就停止并返回None”。

我们期望的实现

暂且假设数据始终存在,且我们无需option处理任何变量。我们称之为chargeUserCardSafe,它看起来会是这样。

let chargeUserCardSafe (amount: double) (userId: UserId): TransactionId =
    let user = lookupUser userId
    let creditCard = u.CreditCard
    chargeCard amount creditCard
Enter fullscreen mode Exit fullscreen mode

注意它现在返回的是一个TransactionId值而不是一个空值,TransactionId option因为它永远不会失败。

如果我们能编写出类似这样的代码,即使存在数据缺失的情况,那就太好了。但要实现这一点,我们需要在每行代码之间添加一些内容,以确保类型对齐并将它们连接起来。

重构以实现更简洁的实现

这段代码应该如何运行?如果上一步的值为空None,它应该终止计算;否则,它应该从中取出该值Some并将其传递给下一行。实际上,它执行的就是我们之前编写的模式匹配。

我们来看看能不能把模式匹配的部分提取出来。首先,我们要以流水线的方式重写那个不会失败的函数,这样之后就能更方便地把新函数插入到各个步骤之间。

// This helper is just here so we can easily chain all the steps
let getCreditCard (user: User): CreditCard option =
    u.CreditCard

let chargeUserCardSafe (amount: double) (userId: UserId): TransactionId = 
    userId 
    |> lookupUser 
    |> getCreditCard 
    |> chargeCard amount
Enter fullscreen mode Exit fullscreen mode

我们在这里所做的,就是将其转换为一系列由管道运算符组成的步骤。

现在,如果我们允许lookupUser并再次lookupCreditCard返回,option那么它将无法编译。问题在于我们不能这样写。

userId |> lookupUser |> getCreditCard
Enter fullscreen mode Exit fullscreen mode

因为lookupUser返回值User option,而我们正试图将其传递给一个期望接收普通值的函数User

所以,我们有两种方法可以解决这个问题。

  1. 编写一个函数,User option -> User该函数会解包选项以便进行管道传输。这意味着会忽略大小写而丢弃一些信息None。命令式程序员可能会通过抛出异常来解决这个问题。但函数式编程旨在提供安全性,因此我们在这里不应该这样做。

  2. 转换管道右侧的函数,使其能够接受一个值User option而不是一个整数User。因此,我们需要编写一个高阶函数。也就是说,一个以一个函数作为输入并将其转换为另一个函数的函数。

我们知道这个高阶函数应该具有类型(User -> CreditCard option) -> (User option -> CreditCard option)
所以,让我们按照类型来编写它。我们将其命名为liftGetCreditCard,因为它“提升”了getCreditCard函数,使其能够处理option输入而不是普通的输入。

let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
    match user with
    | Some u -> u |> getCreditCard
    | None -> None
Enter fullscreen mode Exit fullscreen mode

很好,现在我们离chargeUserCard想要的功能越来越近了。它现在变成了……

let chargeUserCard (amount: double) (userId: UserId): TransactionId option = 
    userId 
    |> lookupUser 
    |> liftGetCreditCard getCreditCard 
    |> chargeCard double
Enter fullscreen mode Exit fullscreen mode

通过部分应用getCreditCardliftGetCreditCard我们创建了一个函数,其签名是,User option -> CreditCard option这正是我们想要的。

不完全是这样,我们现在遇到了同样的问题,只是位置更靠后了。`is`chargeCard期望的是一个 `a` CreditCard,但我们试图传递给它的是一个 `b` CreditCard option。没问题,我们再用同样的技巧处理一下。

let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
    match user with
    | Some u -> u |> getCreditCard
    | None -> None

let liftChargeCard chargeCard (card: CreditCard option): TransactionId option =
    match card with
    | Some cc -> cc |> chargeCard
    | None -> None

let chargeUserCard (amount: double) (userId: UserId): TransactionId option = 
    userId 
    |> lookupUser 
    |> liftGetCreditCard getCreditCard 
    |> liftChargeCard (chargeCard amount)
Enter fullscreen mode Exit fullscreen mode

即将发现🗺

注意这两个lift...函数非常相似。还要注意它们对第一个参数的类型依赖性不高。只要它是一个从选项中包含的值到另一个可选值的函数即可。那么,我们来看看能否编写一个版本来同时满足这两个需求。我们可以通过将第一个参数重命名为f(代表函数)并移除大部分类型提示来实现这一点,因为 F# 会自动推断泛型。

let lift f x =
    match x with
    | Some y -> y |> f
    | None -> None
Enter fullscreen mode Exit fullscreen mode

F# 推断的类型lift是`(T, ('a -> 'b option) -> ('a option -> 'b optionT)`,其中 `T`'a'b`T` 是泛型类型。这听起来有点拗口和抽象,但让我们把它和上面更具体的 `(T, T)` 类型签名放在一起比较一下liftGetCreditCard

(User -> CreditCard option) -> (User option -> CreditCard option)

('a -> 'b option) -> ('a option -> 'b option`)
Enter fullscreen mode Exit fullscreen mode

具体User类型已被替换为泛型类型'a,具体CreditCard类型也被替换为泛型类型'b。这是因为函数lift并不关心option盒子里是什么,它只是说“给我一个函数'f',如果'x'的值存在,我就把它应用到'x'中包含的值上。”唯一的限制是该函数f接受的类型与盒子内的类型相同option

好了,现在我们可以chargeUserCard继续清理了。

let chargeUserCard (amount: double) (userId: UserId): TransactionId option = 
    userId 
    |> lookupUser 
    |> lift getCreditCard 
    |> lift (chargeCard amount)
Enter fullscreen mode Exit fullscreen mode

现在它看起来真的非常接近不带可选数据的版本了。不过还有最后一点,我们把它重命名lift为 `as`,andThen因为直观上我们可以把这个函数理解为在数据存在时继续计算。所以我们可以说,“先做一件事如果成功则做另一件事”。

let chargeUserCard (amount: double) (userId: UserId): TransactionId option = 
    userId 
    |> lookupUser 
    |> andThen getCreditCard 
    |> andThen (chargeCard amount)
Enter fullscreen mode Exit fullscreen mode

这段描述很清晰,也很好地契合了我们对这个功能的设想。我们先查找用户,如果用户存在,就获取他们的信用卡信息,最后如果信用卡有效,就从他们的卡中扣款。

你刚刚发现了单子👏

我们编写的那个 ` lift/`函数正是使值成为单子(monad)的关键。通常谈到单子时,它被称为 `then-able` ,但这对于理解单子并不重要。重要的是你能明白我们为什么定义它以及它是如何工作的。单子只是一类具有这种“then-able”类型功能的事物¹andThenoptionbind

嘿,我认出你了!🕵️‍♀️

lift我将其重命名为 `A`还有另一个原因andThen。如果您是 JavaScript 开发人员,那么它应该看起来很像Promise带有 `a`then方法的 `B`。在这种情况下,您可能已经理解了 monad。`A`Promise也是一个 monad。与 `B` 完全相同option,它有一个 `a` ,该 `a` 接受另一个函数作为输入,并在 `B` 成功时then调用该函数来处理 `C` 的结果。Promise

Monad 只是“可以执行”的容器📦

理解 monad 的另一种好方法是将其视为值容器。一个option容器要么包含一个值,要么为空。而Promise一个容器则“承诺”如果异步计算成功返回,则会保存该异步计算的值。

当然还有其他容器,例如List(用于保存多个计算结果的)以及Result包含计算成功值或失败错误的容器。对于每个容器,我们都可以定义一个andThen函数,该函数定义了如何将需要容器内部对象的函数应用于被容器包裹的对象。

在野外发现单子

如果你经常使用一些接受普通输入(例如 `int` 或 `int`)并执行一些副作用(例如返回 `int` 或 `int`)的函数intstring那么User可能option存在一个单子(monad)。尤其当你需要按顺序调用多个这样的函数时,更是如此。PromiseResult

我们学到了什么?👨‍🎓

我们了解到,monad 只是一种容器类型,它定义了一个名为 `then-able` 的函数bind。我们可以使用此函数将操作链接在一起,这些操作本质上是从一个未包装的值转换到另一个类型的包装值。

这很有用,因为这是一种常见的模式,适用于许多不同类型的对象。通过提取这个bind函数,我们可以消除处理这些类型时大量的样板代码。Monad 只是遵循这种模式的事物的一个名称,正如理查德·费曼所说,名称并不等同于知识

下次

如果你还记得我们最初开始这次重构之旅时给自己设定的目标,你就会知道我们想要编写类似这样的代码。

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    let user = lookupUser userId
    let creditCard = u.CreditCard
    chargeCard amount creditCard
Enter fullscreen mode Exit fullscreen mode

但即便处理可选值,它仍然能够正常工作。我们在这里并没有完全实现这个目标。在下一篇文章中,我们将探讨如何使用 F# 的计算表达式,即使在使用 monad 时也能恢复这种更“命令式”的编程风格。


脚注

  1. 范畴论专家们,请原谅我。
文章来源:https://dev.to/choc13/grokking-monads-in-f-3j7f