为什么我们需要单子?

Why do we need monads?

以我愚见,著名问题"What is a monad?"的答案,尤其是投票最多的问题,试图解释什么是monad,但没有清楚地解释为什么monad是真正必要的.它们可以解释为问题的解决方案吗?

为什么我们需要 monad?

  1. 我们想只使用函数编程。 (毕竟"functional programming (FP)")。
  2. 那么,我们就有了第一个大问题。这是一个程序:

    f(x) = 2 * x

    g(x,y) = x / y

    怎么说先执行什么?我们如何才能形成有序的函数序列(即 一个程序只使用函数

    解决方案:组合函数。如果你想先g然后f,就写f(g(x,y))。这样,"the program" 也是一个函数:main = f(g(x,y))。好的,但是...

  3. 更多问题:一些函数可能会失败(即g(2,0),除以0)。我们在 FP 中没有"exceptions"(异常不是函数)。我们如何解决它?

    解决方案:让我们允许函数return两种东西:而不是g : Real,Real -> Real(从两个实数到一个实数的函数),让我们允许 g : Real,Real -> Real | Nothing(从两个实数到(实数或无)的函数)。

  4. 但是函数应该(为了更简单)return只有一件事

    解决方案:让我们创建一种新的数据类型returned,一种“拳击类型”,它可能包含一个真实的或什么都不是。因此,我们可以有 g : Real,Real -> Maybe Real。好的,但是...

  5. f(g(x,y))现在怎么样了? f 尚未准备好使用 Maybe Real。而且,我们不想更改可以与 g 连接的每个函数来使用 Maybe Real.

    解决方案:让我们有一个特殊的函数来"connect"/"compose"/"link"函数。这样,我们就可以在幕后调整一个函数的输出来满足下一个函数的需求。

    在我们的例子中:g >>= f(connect/compose gf)。我们希望 >>= 得到 g 的输出,检查它,如果它是 Nothing 就不要调用 f 和 return Nothing;或者相反,提取盒装 Real 并用它喂 f。 (该算法只是 >>=Maybe 类型的实现)。另请注意,>>=必须写成每个"boxing type"只写一次(不同的盒子,不同的适配算法)。

  6. 许多其他问题可以使用相同的模式解决: 1. 使用 "box" 到 codify/store 不同的 meanings/values,并具有类似 g 那 return 那些 "boxed values"。 2. 有一个 composer/linker g >>= f 来帮助将 g 的输出连接到 f 的输入,所以我们不必更改任何 f全部.

  7. 可以使用此技术解决的显着问题是:

    • 具有函数序列 ("the program") 中的每个函数都可以共享的全局状态:解决方案 StateMonad

    • 我们不喜欢 "impure functions":对于 相同 输入产生 不同 输出的函数。因此,让我们标记这些函数,使它们成为 return 一个 tagged/boxed 值: IO monad.

太幸福了!

答案当然是"We don't"。与所有抽象一样,没有必要。

Haskell 不需要 monad 抽象。没有必要用纯语言执行 IO。 IO 类型本身就可以很好地处理这个问题。 do 块的现有单子脱糖可以替换为 bindIOreturnIOfailIO 的脱糖,如 GHC.Base 模块中定义的那样。 (它不是关于 hackage 的文档模块,所以我必须指向 its source 以获得文档。)所以不,不需要 monad 抽象。

所以如果不需要它,为什么它存在?因为发现许多计算模式形成单子结构。结构的抽象允许编写适用于该结构的所有实例的代码。更简洁地说——代码重用。

在函数式语言中,最强大的代码重用工具是函数组合。古老的 (.) :: (b -> c) -> (a -> b) -> (a -> c) 运算符非常强大。它使得编写微小的函数并将它们粘合在一起变得容易,并且语法或语义开销最小。

但在某些情况下,类型不能完全正确地工作。当你有 foo :: (b -> Maybe c)bar :: (a -> Maybe b) 时你会做什么? foo . bar 不进行类型检查,因为 bMaybe b 不是同一类型。

不过……差不多就对了。你只是想要一点回旋余地。您希望能够像对待 b 一样对待 Maybe b。但是,将它们完全视为同一类型是一个糟糕的主意。这或多或少与空指针相同,Tony Hoare 将其称为 the billion-dollar mistake。所以如果你不能把它们当作同一类型,也许你可以找到一种方法来扩展 (.) 提供的组合机制。

在那种情况下,真正检查 (.) 背后的理论很重要。幸运的是,已经有人为我们做到了这一点。事实证明,(.)id 的组合形成了一个称为 category 的数学结构。但是还有其他方法来形成类别。例如,Kleisli 类别允许对正在组合的对象进行一些扩充。 Maybe 的 Kleisli 类别将由 (.) :: (b -> Maybe c) -> (a -> Maybe b) -> (a -> Maybe c)id :: a -> Maybe a 组成。也就是说,类别中的对象用 Maybe 扩充 (->),因此 (a -> b) 变为 (a -> Maybe b)

突然之间,我们将组合的力量扩展到传统 (.) 操作无法处理的事物。这是新的抽象能力的来源。 Kleisli 类别适用于更多类型,而不仅仅是 Maybe。他们与每一种可以 assemble 适当分类的类型一起工作,遵守分类法则。

  1. 左身份:id . f = f
  2. 正确身份:f . id = f
  3. 关联性:f . (g . h) = (f . g) . h

只要你能证明你的类型遵守这三个定律,你就可以把它变成一个 Kleisli 类。这有什么大不了的?好吧,事实证明 monad 与 Kleisli 范畴完全一样。 Monadreturn与Kleisliid相同。 Monad(>>=) 与 Kleisli (.) 并不完全相同,但事实证明它们很容易相互转换。类别法则与单子法则相同,当您将它们翻译成 (>>=)(.) 之间的差异时。

那么为什么要经历这些麻烦呢?为什么要在语言中进行 Monad 抽象?正如我在上面提到的,它支持代码重用。它甚至可以沿两个不同的维度实现代码重用。

代码重用的第一个维度直接来自抽象的存在。您可以编写适用于所有抽象实例的代码。整个 monad-loops 包由可与 Monad.

的任何实例一起使用的循环组成

第二个维度是间接的,但它来自于组合的存在。当组合很容易时,很自然地以小的、可重用的块编写代码。这与函数的 (.) 运算符鼓励编写可重用的小函数的方式相同。

那么抽象为什么存在?因为它已被证明是一种工具,可以在代码中实现更多组合,从而创建可重用代码并鼓励创建更多可重用代码。代码重用是编程的圣杯之一。 monad 抽象的存在是因为它让我们朝着圣杯前进了一点点。

本杰明皮尔斯在 TAPL

中说

A type system can be regarded as calculating a kind of static approximation to the run-time behaviours of the terms in a program.

这就是为什么配备了强大类型系统的语言比类型较差的语言更具表现力的原因。你可以用同样的方式思考 monad。

正如@Carl 和sigfpe 指出的那样,您可以为数据类型配备您想要的所有操作,而无需求助于 monad、类型类或任何其他抽象的东西。然而,monad 不仅允许您编写可重用的代码,而且还可以抽象掉所有冗余的细节。

举个例子,假设我们要过滤一个列表。最简单的方法是使用filter函数:filter (> 3) [1..10],等于[4,5,6,7,8,9,10].

稍微复杂一点的 filter,也是从左到右传递一个累加器,

swap (x, y) = (y, x)
(.*) = (.) . (.)

filterAccum :: (a -> b -> (Bool, a)) -> a -> [b] -> [b]
filterAccum f a xs = [x | (x, True) <- zip xs $ snd $ mapAccumL (swap .* f) a xs]

要得到所有i,使得i <= 10, sum [1..i] > 4, sum [1..i] < 25,我们可以写

filterAccum (\a x -> let a' = a + x in (a' > 4 && a' < 25, a')) 0 [1..10]

等于[3,4,5,6].

或者我们可以重新定义 nub 函数,根据 filterAccum:

从列表中删除重复元素
nub' = filterAccum (\a x -> (x `notElem` a, x:a)) []

nub' [1,2,4,5,4,3,1,8,9,4] 等于 [1,2,4,5,3,8,9]。列表作为累加器在这里传递。代码有效,因为可以离开列表 monad,所以整个计算保持纯粹(notElem 实际上不使用 >>=,但它可以)。然而,安全地离开 IO monad 是不可能的(即你不能执行一个 IO 动作和 return 一个纯值——这个值总是被包裹在 IO monad 中)。另一个例子是可变数组:在你离开 ST monad 之后,可变数组所在的位置,你不能再在常数时间内更新数组。所以我们需要来自 Control.Monad 模块的单子过滤:

filterM          :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
filterM _ []     =  return []
filterM p (x:xs) =  do
   flg <- p x
   ys  <- filterM p xs
   return (if flg then x:ys else ys)

filterM 对列表中的所有元素执行单子操作,产生元素,为此单子操作 returns True.

带数组的过滤示例:

nub' xs = runST $ do
        arr <- newArray (1, 9) True :: ST s (STUArray s Int Bool)
        let p i = readArray arr i <* writeArray arr i False
        filterM p xs

main = print $ nub' [1,2,4,5,4,3,1,8,9,4]

按预期打印 [1,2,4,5,3,8,9]

还有一个带有 IO monad 的版本,它询问哪些元素要 return:

main = filterM p [1,2,4,5] >>= print where
    p i = putStrLn ("return " ++ show i ++ "?") *> readLn

例如

return 1? -- output
True      -- input
return 2?
False
return 4?
False
return 5?
True
[1,5]     -- output

作为最后的例子,filterAccum 可以用 filterM:

来定义
filterAccum f a xs = evalState (filterM (state . flip f) xs) a

使用 StateT monad,在幕后使用,只是一个普通的数据类型。

此示例说明,monad 不仅允许您抽象计算上下文并编写干净的可重用代码(由于 monad 的可组合性,正如@Carl 解释的那样),而且还可以处理用户定义的数据类型和内置图元统一。

我不认为IO是一个特别出色的monad,但对于初学者来说肯定是比较惊艳的monad之一,所以我会用它来解释.

天真地为 Haskell

构建一个 IO 系统

对于纯函数式语言(实际上是 Haskell 开始的)最简单的 IO 系统是这样的:

main₀ :: String -> String
main₀ _ = "Hello World"

由于懒惰,这个简单的签名就足以实际构建交互式终端程序——不过 非常 有限。最令人沮丧的是我们只能输出文本。如果我们添加一些更令人兴奋的输出可能性会怎样?

data Output = TxtOutput String
            | Beep Frequency

main₁ :: String -> [Output]
main₁ _ = [ TxtOutput "Hello World"
          -- , Beep 440  -- for debugging
          ]

可爱,但当然更现实的“替代输出”是写入文件。但是您还需要一些方法来 文件中读取。有机会吗?

好吧,当我们使用 main₁ 程序并简单地 将文件通过管道传输到进程 (使用操作系统工具)时,我们实际上已经实现了文件读取。如果我们可以从 Haskell 语言中触发文件读取...

readFile :: Filepath -> (String -> [Output]) -> [Output]

这将使用“交互式程序”String->[Output],向其提供从文件中获取的字符串,并生成一个仅执行给定程序的非交互式程序。

这里有一个问题:我们真的不知道何时 文件被读取。 [Output] 列表确实为 outputs 提供了很好的顺序,但是我们没有得到 inputs 何时会出现的顺序完成。

解决方案:使输入事件也成为待办事项列表中的项目。

data IO₀ = TxtOut String
         | TxtIn (String -> [Output])
         | FileWrite FilePath String
         | FileRead FilePath (String -> [Output])
         | Beep Double

main₂ :: String -> [IO₀]
main₂ _ = [ FileRead "/dev/null" $ \_ ->
             [TxtOutput "Hello World"]
          ]

好的,现在你可能会发现一个不平衡:你可以读取一个文件并根据它进行输出,但你不能使用文件内容来决定,例如。还读了另一个文件。显而易见的解决方案:使输入事件的结果也成为 IO 类型的东西,而不仅仅是 Output。这肯定包括简单的文本输出,但也允许读取其他文件等。

data IO₁ = TxtOut String
         | TxtIn (String -> [IO₁])
         | FileWrite FilePath String
         | FileRead FilePath (String -> [IO₁])
         | Beep Double

main₃ :: String -> [IO₁]
main₃ _ = [ TxtIn $ \_ ->
             [TxtOut "Hello World"]
          ]

这实际上允许您在程序中表达您可能想要的任何文件操作(尽管可能性能不佳),但它有点过于复杂:

  • main₃ 产生整个 list 动作。为什么我们不简单地使用签名 :: IO₁,它有一个特殊情况?

  • 这些列表不再真正提供程序流程的可靠概览:大多数后续计算只会作为某些输入操作的结果“公布”。所以我们不妨抛弃列表结构,对每个输出操作简单地cons一个“然后做”。

data IO₂ = TxtOut String IO₂
         | TxtIn (String -> IO₂)
         | Terminate

main₄ :: IO₂
main₄ = TxtIn $ \_ ->
         TxtOut "Hello World"
          Terminate

还不错!

那么所有这些与 monad 有什么关系呢?

实际上,您不会希望使用普通构造函数来定义所有程序。需要有几个这样的基本构造函数,但对于大多数更高级别的东西,我们希望编写一个具有一些不错的高级签名的函数。事实证明,其中大部分看起来非常相似:接受某种类型有意义的值,并产生一个 IO 操作作为结果。

getTime :: (UTCTime -> IO₂) -> IO₂
randomRIO :: Random r => (r,r) -> (r -> IO₂) -> IO₂
findFile :: RegEx -> (Maybe FilePath -> IO₂) -> IO₂

这里明显有规律,最好写成

type IO₃ a = (a -> IO₂) -> IO₂    -- If this reminds you of continuation-passing
                                  -- style, you're right.

getTime :: IO₃ UTCTime
randomRIO :: Random r => (r,r) -> IO₃ r
findFile :: RegEx -> IO₃ (Maybe FilePath)

现在开始看起来很熟悉,但我们仍然只是在处理幕后的伪装简单的函数,这是有风险的:每个“价值行动”都有责任实际传递结果行动任何包含的函数(否则整个程序的控制流很容易被中间的一个行为不当的动作打乱)。我们最好明确要求。好吧,事实证明这些是 monad 法则,但我不确定我们是否真的可以在没有标准 bind/join 运算符的情况下制定它们。

无论如何,我们现在已经得出了一个具有适当 monad 实例的 IO 公式:

data IO₄ a = TxtOut String (IO₄ a)
           | TxtIn (String -> IO₄ a)
           | TerminateWith a

txtOut :: String -> IO₄ ()
txtOut s = TxtOut s $ TerminateWith ()

txtIn :: IO₄ String
txtIn = TxtIn $ TerminateWith

instance Functor IO₄ where
  fmap f (TerminateWith a) = TerminateWith $ f a
  fmap f (TxtIn g) = TxtIn $ fmap f . g
  fmap f (TxtOut s c) = TxtOut s $ fmap f c

instance Applicative IO₄ where
  pure = TerminateWith
  (<*>) = ap

instance Monad IO₄ where
  TerminateWith x >>= f = f x
  TxtOut s c >>= f = TxtOut s $ c >>= f
  TxtIn g >>= f = TxtIn $ (>>=f) . g

显然这不是 IO 的有效实现,但原则上是可用的。

如果你有一个 类型的构造函数 函数 returns 该类型族 的值,你就需要 monad。最终,您希望将这些功能组合在一起。这些是回答 为什么 .

的三个关键要素

让我详细说明。您有 IntStringReal 以及 Int -> StringString -> Real 等类型的函数。您可以轻松组合这些函数,以 Int -> Real 结尾。生活很好。

然后,有一天,您需要创建一个新的家族 类型。可能是因为你需要考虑没有返回值(Maybe)、返回错误(Either)、多个结果(List)等的可能性。

注意 Maybe 是一个类型构造器。它需要一个类型,例如 Int 和 returns 一个新类型 Maybe Int。首先要记住,没有类型构造函数,没有 monad。

当然,你想在你的代码中使用你的类型构造函数,很快你就会以 Int -> Maybe StringString -> Maybe Float 这样的函数结束。现在,您不能轻易组合您的功能。生活不美好了。

这就是 monad 来拯救的时候了。它们允许您再次组合那种功能。您只需要将组合 . 更改为 >==

Monads 只是一个方便的框架,用于解决 class 反复出现的问题。首先,monad 必须是 functors(即必须支持不查看元素(或它们的类型)的映射),它们还必须带有 binding(或链接)操作以及从元素类型(return)创建单子值的方法。最后,bindreturn 必须满足两个方程(左右恒等式),也称为单子法则。 (或者,可以将 monad 定义为具有 flattening operation 而不是绑定。)

list monad通常用于处理非确定性。绑定操作选择列表中的一个元素(直觉上是 平行世界 中的所有元素),让程序员对它们进行一些计算,然后将所有世界中的结果组合到一个列表中(通过连接或展平嵌套列表)。下面是如何在 Haskell:

的单子框架中定义置换函数
perm [e] = [[e]]
perm l = do (leader, index) <- zip l [0 :: Int ..]
            let shortened = take index l ++ drop (index + 1) l
            trailer <- perm shortened
            return (leader : trailer)

这是一个示例 repl 会话:

*Main> perm "a"
["a"]
*Main> perm "ab"
["ab","ba"]
*Main> perm ""
[]
*Main> perm "abc"
["abc","acb","bac","bca","cab","cba"]

需要注意的是,list monad 绝不是一种副作用计算。作为 monad 的数学结构(即符合上述接口和法则)并不意味着副作用,尽管副作用现象通常很适合 monadic 框架。

Monads 基本上用于将函数组合成一个链。期间.

现在它们的组合方式在现有的 monad 中有所不同,从而导致不同的行为(例如,在 state monad 中模拟可变状态)。

关于 monad 的困惑在于它是如此通用,即一种组合函数的机制,它们可以用于很多事情,从而导致人们相信 monad 是关于状态、关于 IO 等的,而实际上它们是只有大约 "composing functions".

现在,关于 monad 的一件有趣的事情是,组合的结果始终是 "M a" 类型,也就是说,一个值在用 "M" 标记的信封内。这个特性恰好非常好实现,例如,纯代码和不纯代码之间的明确分离:将所有不纯操作声明为 "IO a" 类型的函数,并且在定义 IO monad 时不提供任何函数来取出"a" 中的值来自 "IO a"。结果是没有函数可以是纯的,同时从"IO a"中取出一个值,因为没有办法在保持纯的情况下取这样的值(函数必须在"IO"中monad 使用这样的值)。 (注意:好吧,没有什么是完美的,所以 "IO straitjacket" 可以使用 "unsafePerformIO : IO a -> a" 破坏,从而污染应该是纯函数的东西,但是应该非常谨慎地使用它,当你真的知道不引入任何有副作用的不纯代码。

Why do we need monadic types?

由于 I/O 的窘境及其在 Haskell 等非严格语言中的可观察到的影响,使 monadic 接口如此突出:

  • [...] monads are used to address the more general problem of computations (involving state, input/output, backtracking, ...) returning values: they do not solve any input/output-problems directly but rather provide an elegant and flexible abstraction of many solutions to related problems. [...] For instance, no less than three different input/output-schemes are used to solve these basic problems in Imperative functional programming, the paper which originally proposed `a new model, based on monads, for performing input/output in a non-strict, purely functional language'. [...]

    [Such] input/output-schemes merely provide frameworks in which side-effecting operations can safely be used with a guaranteed order of execution and without affecting the properties of the purely functional parts of the language.

    Claus Reinke (pages 96-97 of 210).

    (我强调的。)

  • [...] When we write effectful code – monads or no monads – we have to constantly keep in mind the context of expressions we pass around.

    The fact that monadic code ‘desugars’ (is implementable in terms of) side-effect-free code is irrelevant. When we use monadic notation, we program within that notation – without considering what this notation desugars into. Thinking of the desugared code breaks the monadic abstraction. A side-effect-free, applicative code is normally compiled to (that is, desugars into) C or machine code. If the desugaring argument has any force, it may be applied just as well to the applicative code, leading to the conclusion that it all boils down to the machine code and hence all programming is imperative.

    [...] From the personal experience, I have noticed that the mistakes I make when writing monadic code are exactly the mistakes I made when programming in C. Actually, monadic mistakes tend to be worse, because monadic notation (compared to that of a typical imperative language) is ungainly and obscuring.

    Oleg Kiselyov (page 21 of 26).

  • The most difficult construct for students to understand is the monad. I introduce IO without mentioning monads.

    Olaf Chitil.

更一般地说:

  • Still, today, over 25 years after the introduction of the concept of monads to the world of functional programming, beginning functional programmers struggle to grasp the concept of monads. This struggle is exemplified by the numerous blog posts about the effort of trying to learn about monads. From our own experience we notice that even at university level, bachelor level students often struggle to comprehend monads and consistently score poorly on monad-related exam questions.

    Considering that the concept of monads is not likely to disappear from the functional programming landscape any time soon, it is vital that we, as the functional programming community, somehow overcome the problems novices encounter when first studying monads.

    Tim Steenvoorden, Jurriën Stutterheim, Erik Barendsen and Rinus Plasmeijer.

如果有另一种方法可以在 Haskell 中指定“保证执行顺序”,同时保持分隔常规 Haskell 定义的能力来自那些参与 I/O(及其可观察到的影响)的人 - 翻译 Philip Wadlerecho 的这个变体:

val echoML    : unit -> unit
fun echoML () = let val c = getcML () in
                if c = #"\n" then
                  ()
                else
                  let val _ = putcML c in
                  echoML ()
                end

fun putcML c  = TextIO.output1(TextIO.stdOut,c);
fun getcML () = valOf(TextIO.input1(TextIO.stdIn));

...然后可以像这样简单:

echo :: OI -> ()                         
echo u = let !(u1:u2:u3:_) = partsOI u in
         let !c = getChar u1 in          
         if c == '\n' then               
           ()                            
         else                            
           let !_ = putChar c u2 in      
           echo u3                       

其中:

data OI  -- abstract

foreign import ccall "primPartOI" partOI :: OI -> (OI, OI)
                      ⋮

foreign import ccall "primGetCharOI" getChar :: OI -> Char
foreign import ccall "primPutCharOI" putChar :: Char -> OI -> ()
                      ⋮

和:

partsOI         :: OI -> [OI]
partsOI u       =  let !(u1, u2) = partOI u in u1 : partsOI u2 

这将如何运作?在 run-time,Main.main 收到初始 OI pseudo-data 值作为参数:

module Main(main) where

main            :: OI -> ()
          ⋮

...使用 partOIpartsOI 从中生成其他 OI 值。您所要做的就是确保每个新的 OI 值在每次调用基于 OI 的定义时最多使用 一次 ,无论是外来的还是其他的。在 return 中,您会得到一个普通的普通结果 - 它不是例如与一些奇怪的抽象状态配对,或者需要使用 回调 延续等

使用 OI,而不是像标准 ML 那样使用单位类型 (),这意味着我们可以避免 总是 必须使用 monadic 接口:

Once you're in the IO monad, you're stuck there forever, and are reduced to Algol-style imperative programming.

Robert Harper.

但是如果你真的do需要它:

type IO a       =  OI -> a

unitIO          :: a -> IO a
unitIO x        =  \ u -> let !_ = partOI u in x

bindIO          :: IO a -> (a -> IO b) -> IO b
bindIO m k      =  \ u -> let !(u1, u2) = partOI u in
                          let !x        = m u1 in
                          let !y        = k x u2 in
                          y

                      ⋮

所以,monadic 类型并不总是 需要 - 还有其他接口:

LML had a fully fledged implementation of oracles running of a multi-processor (a Sequent Symmetry) back in ca 1989. The description in the Fudgets thesis refers to this implementation. It was fairly pleasant to work with and quite practical.

[...]

These days everything is done with monads so other solutions are sometimes forgotten.

Lennart Augustsson (2006).


等一下:既然它与标准 ML 直接使用效果非常相似,那么这种方法及其对 pseudo-data 的使用是参照透明的吗?

绝对 - 只需找到“引用透明度”的合适定义即可; there's plenty to choose from...