状态 monads / monad 转换器如何在 do 表示法中脱糖?

How are state monads / monad transformers desugared inside do notation?

例子

sumArray :: Array Int -> State Int Unit
sumArray = traverse_ \n -> modify \sum -> sum + n

t1 :: Int
t1 = execState (do
  sumArray [1, 2, 3]
  sumArray [4, 5]
  sumArray [6]) 0
-- returns 21

module Main where

import Prelude
import Effect (Effect)
import Data.Foldable (fold, traverse_)
import Control.Monad.State (State, execState)
import Control.Monad.State.Class (modify)
import Data.Maybe (Maybe(..))
import Effect.Console (log)

main :: Effect Unit
main = log $ show t1

sumArray :: Array Int -> State Int Unit
sumArray = traverse_ \n -> modify \sum -> sum + n

t1 :: Int
t1 = execState (do
  sumArray [1, 2, 3]
  sumArray [4, 5]
  sumArray [6]) 0

{-
execState :: forall s a. State s a -> s -> s
execState (StateT m) s = case m s of Identity (Tuple _ s') -> s'

type State s = StateT s Identity
-}

描述

我如何理解表达式的求值 t1:

  1. 每个 sumArray 调用 returns 状态 monad 保存给定 Int 数组值的总和。
  2. 所有三个状态 monad(以某种方式)统一为一个,同时累积中间总和。
  3. execState returns 总体 Int 总和,给定 State Int Unit 和初始值作为输入。

问题

特别是第 2 步我不太理解。根据 do notation,像 sumArray [1, 2, 3] 这样的表达式会脱糖为 bind x \_ -> ...,因此忽略之前的输入。如果我用不同的 monad 类型写 do,比如

t2 :: Maybe Int
t2 = do
  Just 3
  Just 4

,编译器抱怨:

A result of type Int was implicitly discarded in a do notation block. You can use _ <- ... to explicitly discard the result.

,所以规则好像和t1有点不一样。

问题

这三个独立的状态单子究竟是如何组合成一个单子的?更具体地说:为什么运行时计算所有中间状态 monad 总和结果的总体 sum,而不是像 (1+2+3) * (4+5) * 6 这样的东西?换句话说:隐式 + 累加器在哪里?

我觉得我错过了 Chapter 11: Monadic Adventures 中的一些概念。

Each sumArray invocation returns a state monad holding the sum of given Int array values.

不,它不是 return“状态 monad”,它不保存数组的总和。

它 return 是一个 State Int Unit 值,表示“ Unit 的状态计算”(使用 Int 为国家)。要获得总和,您实际上必须 运行 该计算:

t :: State Int Unit
t = sumArray [1, 2, 3]

x = runState t 0 // ((), 6)
y = runState t 5 // ((), 11)

请注意,对于值 y,永远不会计算数组元素的总和 - 它会将 1 加到 5,然后将 2 加到 6,然后将 3 加到 8。

How exactly are the three separate state monads combined into a single one?

理解的关键是它们不是状态值,而是状态计算。它们可以通过简单地将这些计算一个接一个地排序,将结果和状态传递给下一个来组合。

@Bergi 的回答已经给出了基本的解释 - 但我认为扩展他所说的某些部分并更直接地回答您的一些问题可能会有用。

According to do notation, an expression like sumArray [1, 2, 3] desugars to bind x \_ -> ..., so former input is ignored.

这在某种意义上是完全正确的,但也暴露了一些误解。

一方面,我发现该引用的措辞具有误导性——尽管它在原始来源的上下文中是完全可以接受的。它不是在谈论像 sumArray [1, 2, 3] 这样的表达式本身如何“脱糖”,而是关于 do 块的连续行(“语句”)如何脱糖成一个“组合”它们的表达式- 这似乎是你整个问题的本质。所以是的,这是真的 - 基本上 do 符号的定义 - 像

这样的表达式
do a <- x
   y

脱糖为 bind x \a -> y(我们认为 y 是一些更复杂的表达式,可能涉及 a)。同样

do x
   y

脱糖为 bind x \_ -> y。但后一种情况不是“忽略输入”——它忽略了 output。让我再解释一下。

通常认为 m a 类型的一般单子值是某种“产生”a 类型值的“计算”。这必然是一个相当抽象的表述——因为 Monad 是一个如此笼统的概念,一些特定的 Monad 比其他的更适合这种心理图景。但这是理解 monad 基础知识的好方法,尤其是 do 表示法——每一行都可以被认为是某种命令式语言中的一个“语句”,它可能有一些“副作用”(某种程度上严格受您正在使用的特定 monad 的限制)并且还会产生一个值作为“结果”。

从这个意义上说,上面第一种类型的 do 块 - 我们使用“左箭头”符号“绑定”结果 - 使用计算值(用 a 表示)来决定下一步做什么。 (顺便说一下,这就是 monads 和 applicatives 的区别——如果你只是有一系列的计算并且只是想结合它们的“效果”,而不让“中间结果”影响你正在做的事情,你实际上并不需要 monads或 bind.) 而第二个不使用第一个计算的结果(该计算是 x)——这正是我所说的“忽略输出”时的意思。它忽略了 x 的结果。 并不(必然)意味着x虽然没用。它仍因其“副作用”而被使用。

为了使其更具体,我将更详细地查看您的两个示例,从 Maybe monad 中的简单示例开始(我将根据编译器的建议进行更改为了让它开心 - 请注意,我个人对 Haskell 比对 Purescript 更熟悉,所以我可能会弄错像这样的 Purescript 特定的东西,因为 Haskell 与你的原版完全没问题代码):

t2 :: Maybe Int
t2 = do
  _ <- Just 3
  Just 4

在这种情况下,t2 将简单地等于 Just 4,并且看起来 - 正确地 - do 块的第一行是多余的。但这只是 Maybe monad 如何工作的结果,以及我们在那里获得的特定值。我可以很容易地向您证明第一行 确实 仍然很重要,通过进行此更改

t2 :: Maybe Int
t2 = do
  _ <- Nothing
  Just 4

现在你会发现t2不等于Just 4,不等于Nothing

那是因为 Maybe monad 中的每个“计算”——即每个类型 Maybe a 的值——要么“成功”并获得类型 a 的“结果”(由 Just 值表示)或“失败”(由 Nothing 表示)。而且,重要的是,Maybe monad 的定义方式——即 bind 的定义——故意传播失败。也就是说,在任何时候遇到的任何 Nothing 值都会立即以 Nothing 结果终止计算。

所以即使在这里,第一次计算的“副作用”——它成功或失败的事实——确实对整体发生的事情产生重大影响。我们只是忽略“结果”(计算成功时的实际值)。

如果我们现在转到 State monad - 这是一个比 Maybe 更复杂的 monad,但实际上可能因此使上述几点更容易理解。因为这是一个 monad,所以谈论每个 monadic 值的“副作用”和“结果”确实有直接的意义——在 Maybe 的情况下,这可能让人觉得有点强迫,甚至是愚蠢的。

State s a 类型的值表示计算结果为 a 类型的值,同时“保持某种状态”为 s 类型。也就是说,计算可以使用当前状态来计算其结果,and/or 它可以更新状态作为计算的一部分。具体来说,这与 s -> (a, s) 类型的函数相同 - 它需要一些状态,并且 returns 更新状态(可能相同)以及计算值。事实上,State s a 类型本质上是这种函数类型的简单 newtype 包装器。

bind 在其 Monad 实例中的实现做了最明显和自然的事情——用文字解释比从实际的实现细节中“看到”要容易得多。两个这样的“有状态函数”通过将原始状态提供给第一个函数,然后从中获取更新后的状态并将其提供给第二个函数来组合。 (实际上,bind 需要做的并不止于此,因为正如我之前提到的,它需要能够使用来自第一次计算的“结果”——a 来决定如何处理第二个。但我们现在不需要深入探讨,因为在这个例子中我们不使用结果值——实际上不能,因为它总是微不足道的 Unit 类型. 其实并不复杂,但我不会详细说明,因为我不想让这个答案更长!)

所以当我们这样做时

do
  sumArray [1, 2, 3]
  sumArray [4, 5]
  sumArray [6]

我们正在构建 State Int Unit 类型的有状态计算 - 即 Int -> (Unit, Int) 类型的函数。由于 Unit 是一个无趣的类型,并且在这里基本上用作“我们不关心任何结果”的占位符,我们基本上是从其他三个这样的类型构建一个 Int -> Int 类型的函数职能。这很容易做到——我们只需组合这三个函数即可!在这个简单的例子中,State monad 的 bind 的实现最终就是这样做的。

希望这能回答您的主要问题:

where is the implicit + accumulator?

通过证明除了函数组合之外没有“隐式累加器”。事实上,这些单独的函数恰好将(在这种情况下分别)6、9 和 6 添加到输入中,导致最终结果是这 3 个数字的总和(由于两个总和的组合是本身是一个和,最终来自于加法的结合性。

但更重要的是,我希望这让您对 Monad 和 do 符号有了更全面的解释,您可以将其应用于许多其他情况。