为什么是monads?它如何解决副作用?

时间:2011-10-20 17:59:46

标签: haskell monads

我正在学习Haskell并试图理解Monads。我有两个问题:

  1. 根据我的理解,Monad只是另一个类型类,它声明了与“容器”内部数据交互的方式,包括MaybeListIO。用一个概念实现这三个东西似乎很聪明和干净,但实际上,关键在于在一系列函数,容器和副作用中可以进行干净的错误处理。这是正确的解释吗?

  2. 副作用问题究竟是如何解决的?使用容器的这个概念,语言基本上说容器内的任何东西都是非确定性的(例如i / o)。因为列表和IO都是容器,所以列表与IO等价,尽管列表中的值对我来说似乎非常确定。那么什么是确定性的,有什么副作用?我无法理解基本值是确定性的想法,直到你将它粘贴在一个容器中(这与其旁边的其他值相同的值相同,例如Nothing)并且它现在可以随意了。

  3. 有人可以直观地解释Haskell如何通过输入和输出来改变状态?我没有看到这里的魔力。

8 个答案:

答案 0 :(得分:33)

  

关键在于,在一系列功能,容器和副作用中可以进行干净的错误处理。这是正确的解释吗?

不是真的。你已经提到了很多人们在试图解释monad时所引用的概念,包括副作用,错误处理和非确定性,但听起来你已经错误地认为所有这些概念都适用于所有monad。但是你提到的一个概念是:链接

这有两种不同的风格,所以我将用两种不同的方式解释它:一种没有副作用,一种有副作用。

无副作用:

采用以下示例:

addM :: (Monad m, Num a) => m a -> m a -> m a
addM ma mb = do
    a <- ma
    b <- mb
    return (a + b)

这个函数添加两个数字,扭曲它们包含在一些monad中。哪个monad?没关系!在所有情况下,这种特殊的do语法都会消除以下内容:

addM ma mb =
    ma >>= \a ->
    mb >>= \b ->
    return (a + b)

...或者,运算符优先级明确:

ma >>= (\a -> mb >>= (\b -> return (a + b)))

现在你可以真正看到这是一系列小函数,它们全部组合在一起,其行为将取决于为每个monad定义>>=return的方式。如果您熟悉面向对象语言中的多态性,那么这基本上是相同的:一个具有多个实现的通用接口。它比一般的OOP界面略微弯曲,因为界面代表计算策略而不是动物或形状等。

好的,让我们看一些addM如何在不同的monad中表现的例子。 Identity monad是一个不错的起点,因为它的定义很简单:

instance Monad Identity where
    return a = Identity a  -- create an Identity value
    (Identity a) >>= f = f a  -- apply f to a

当我们说:

时会发生什么
addM (Identity 1) (Identity 2)

逐步扩展这个:

(Identity 1) >>= (\a -> (Identity 2) >>= (\b -> return (a + b)))
(\a -> (Identity 2) >>= (\b -> return (a + b)) 1
(Identity 2) >>= (\b -> return (1 + b))
(\b -> return (1 + b)) 2
return (1 + 2)
Identity 3

大。现在,既然你提到了干净的错误处理,让我们来看看Maybe monad。它的定义仅比Identity稍微复杂一些:

instance Monad Maybe where
    return a = Just a  -- same as Identity monad!
    (Just a) >>= f = f a  -- same as Identity monad again!
    Nothing >>= _ = Nothing  -- the only real difference from Identity

所以你可以想象,如果我们说addM (Just 1) (Just 2)我们会得到Just 3。但是对于笑容,让我们扩展addM Nothing (Just 1)

Nothing >>= (\a -> (Just 1) >>= (\b -> return (a + b)))
Nothing

或者相反,addM (Just 1) Nothing

(Just 1) >>= (\a -> Nothing >>= (\b -> return (a + b)))
(\a -> Nothing >>= (\b -> return (a + b)) 1
Nothing >>= (\b -> return (1 + b))
Nothing

因此Maybe monad对>>=的定义进行了调整以解释失败。当使用Maybe将函数应用于>>=值时,您会得到您期望的结果。

好的,所以你提到了非决定论。是的,列表monad可以被认为是某种意义上的非确定性建模...这有点奇怪,但是将列表视为代表替代可能的值:[1, 2, 3]不是集合,它是单个非-deterministic数字可以是一个,两个或三个。这听起来很愚蠢,但是当你考虑如何为列表定义>>=时,它开始有意义:它将给定的函数应用于每个可能的值。因此,addM [1, 2] [3, 4]实际上将计算这两个非确定性值的所有可能总和:[4, 5, 5, 6]

好的,现在要解决你的第二个问题......

副作用:

假设您将addM应用于IO monad中的两个值,例如:

addM (return 1 :: IO Int) (return 2 :: IO Int)

你没有得到任何特别的东西,只有3 IO monad。 addM不会读取或写入任何可变状态,因此它并不好玩。 StateST monad也是如此。没有什么好玩的。所以让我们使用不同的功能:

fireTheMissiles :: IO Int  -- returns the number of casualties

显然,每次发射导弹时世界都会有所不同。显然。现在让我们假设您正在尝试编写一些完全无害,无副作用的非导弹射击代码。也许你再次尝试添加两个数字,但这次没有任何monad飞来飞去:

add :: Num a => a -> a -> a
add a b = a + b

突然间你的手滑了,你不小心错了:

add a b = a + b + fireTheMissiles

一个诚实的错误,真的。钥匙是如此紧密。幸运的是,由于fireTheMissiles的类型为IO Int,而不仅仅是Int,因此编译器能够避免灾难。

好吧,完全做作的例子,但关键是在IOST和朋友的情况下,类型系统将效果与某些特定的上下文隔离开来。它并没有神奇地消除副作用,使得代码在引用时不应该是透明的,但它确实在编译时清楚了解效果的范围。

回到原点:这与链接或功能组合有什么关系?那么,在这种情况下,它只是表达一系列效果的便捷方式:

fireTheMissilesTwice :: IO ()
fireTheMissilesTwice = do
    a <- fireTheMissiles
    print a
    b <- fireTheMissiles
    print b

要点:

monad表示链接计算的一些策略。 Identity的策略是纯函数组合,Maybe的策略是具有失败传播的函数组合,IO的策略是不纯的函数组成等等

答案 1 :(得分:11)

答案 2 :(得分:10)

首先,我要指出优秀的“You could have invented monads”文章。它说明了Monad结构在编写程序时如何自然地显现出来。但是教程没有提到IO,所以我会在这里扩展方法。

让我们从您可能已经看到的东西开始 - 容器monad。假设我们有:

f, g :: Int -> [Int]

一种看待这种情况的方法是,它为每个可能的输入提供了许多可能的输出。如果我们想要两个函数的组合的所有可能输出怎么办?通过一个接一个地应用这些功能,我们可以获得所有可能性吗?

嗯,有一个功能:

fg x = concatMap g $ f x

如果我们更普遍,我们得到

fg x     = f x >>= g
xs >>= f = concatMap f xs
return x = [x]

为什么我们要像这样包装它?好吧,主要使用>>=return编写我们的程序给了我们一些不错的属性 - 例如,我们可以肯定,“忘记”解决方案相对困难。我们明确地必须重新引入它,比如添加另一个函数skip。而且我们现在有一个monad,可以使用monad库中的所有组合器!

现在,让我们跳到你棘手的例子。假设这两个函数是“副作用”。这不是非确定性的,它只是意味着理论上整个世界既是他们的输入(因为它可以影响他们)以及他们的输出(因为功能可以影响它)。所以我们得到类似的东西:

f, g :: Int -> RealWorld# -> (Int, RealWorld#)

如果我们现在希望f获得g留下的世界,我们会写:

fg x rw = let (y, rw')  = f x rw
              (r, rw'') = g y rw'
           in (r, rw'')

或概括:

fg x     = f x >>= g
x >>= f  = \rw -> let (y, rw')  = x   rw
                      (r, rw'') = f y rw'
                   in (r, rw'')
return x = \rw -> (x, rw)

现在,如果用户只能使用>>=return和一些预先定义的IO值,我们会再次获得一个不错的属性:用户实际上永远不会看到< / em> RealWorld#传递过来!这是一件非常好的事情,因为您对getLine从哪里获取数据的细节不感兴趣。再次 我们从monad库中获得了所有不错的高级函数。

所以要带走的重要事情是:

  1. monad捕获代码中的常见模式,例如“始终将容器A的所有元素传递给容器B”或“传递此真实世界标记”。通常,一旦你意识到你的程序中有一个monad,复杂的东西就变成了正确的monad组合器的应用程序。

  2. monad允许您完全隐藏用户的实现。它是一种出色的封装机制,无论是针对您自己的内部状态,还是IO如何设法以相对安全的方式将非纯度压缩为纯粹的程序。


  3. <强>附录

    如果有人在我开始的时候仍然在RealWorld#上scrat::::在 所有monad抽象被移除后,显然会有更多的魔法。然后编译器将利用只有一个“真实世界”的事实。这是好消息和坏消息:

    1. 因此,编译器必须保证函数之间的执行顺序(这就是我们之后的事情!)

    2. 但这也意味着实际传递现实世界是没有必要的,因为只有一个我们可能意味着:当函数执行时是最新的那个!

    3. 一句话是,一旦执行订单得到修复,RealWorld#就会被优化掉。因此,使用IO monad的程序实际上没有运行时开销。另请注意,使用RealWorld#显然只有一种可能放置IO的方式 - 但它恰好是GHC内部使用的方式。关于monad的好处是,用户真的不需要知道。

答案 3 :(得分:4)

通常有助于我理解某事的本质的一件事是以最微不足道的方式检查它。这样,我就不会被可能不相关的概念分心。考虑到这一点,我认为理解Identity Monad的本质可能会有所帮助,因为它是Monad最可能实现的(我认为)。

Identity Monad有什么有趣的地方?我认为它允许我表达在其他表达式定义的上下文中计算表达式的想法。对我而言,这是我遇到过的每一个Monad的精髓(到目前为止)。

如果你在学习Haskell之前已经接触过很多“主流”编程语言(就像我一样),那么这似乎并不是很有趣。毕竟,在主流编程语言中,语句依次执行,一个接一个地执行(当然,除了控制流构造之外)。当然,我们可以假设每个语句都在所有先前执行的语句的上下文中进行评估,并且那些先前执行的语句可能会改变环境和当前正在执行的语句的行为。

所有这些都是像Haskell这样功能性,懒惰的语言中的外来概念。在Haskell中计算计算的顺序是明确定义的,但有时很难预测,甚至更难控制。对于很多种问题,这很好。但是,如果没有一些方便的方法在程序中的计算之间建立隐式顺序和上下文,其他类型的问题(例如IO)很难解决。

就副作用而言,具体而言,通常可以将它们(通过Monad)转换为简单的状态传递,这在纯函数语言中是完全合法的。然而,有些Monads似乎不具备这种性质。诸如IO Monad或ST monad之类的Monad实际上执行副作用动作。有很多方法可以考虑这个,但我想到的一种方式是,因为我的计算必须存在于没有副作用的世界中,Monad可能不会。因此,Monad可以自由地为我的计算执行建立一个基于其他计算定义的副作用的上下文。

最后,我必须否认我绝对不是Haskell专家。因此,请理解我所说的一切都是我自己对这个主题的想法,当我更充分地理解Monads时,我可能会很好地否定它们。

答案 4 :(得分:4)

关于IO monad有三个主要观察结果:

1)你无法从中获得价值。其他类型如Maybe可能允许提取值,但monad类接口本身和IO数据类型都不允许它。

2)“内部”IO不仅是真正的价值,也是“真实世界”的事物。此虚拟值用于强制类型系统对操作进行链接:如果您有两个独立的计算,则使用>>=会使第二个计算依赖于第一个计算。

3)假设一个非确定性的东西,如random :: () -> Int,这在Haskell中是不允许的。如果您将签名更改为random :: Blubb -> (Blubb, Int),则允许 ,如果您确保没有人可以使用Blubb两次:因为在这种情况下所有输入都是“不同的” ,输出也不同也没问题。

现在我们可以使用以下事实:1):没有人可以从IO中获取某些内容,因此我们可以使用隐藏在RealWord中的IO假人作为Blubb }。整个应用程序中只有一个IO(我们从main获得的那个),它负责正确的顺序,正如我们在2)中看到的那样。问题解决了。

答案 5 :(得分:2)

  

重点是在一系列函数,容器和副作用中可以进行干净的错误处理

或多或少。

  

副作用问题究竟是如何解决的?

I / O monad中的值,即类型IO a之一,应该被解释为程序。 p >> q上的IO值可以解释为将两个程序合并为首先执行p,然后q的程序的运算符。其他单子操作员也有类似的解释。通过将程序分配给名称main,您向编译器声明该程序必须由其输出对象代码执行。

对于列表monad,除了非常抽象的数学意义外,它与I / O monad并不真正相关。 IO monad给出了具有副作用的确定性计算,而list monad给出了非确定性(但不是随机!)的回溯搜索,有点类似于Prolog的运作方式。

答案 6 :(得分:2)

  

使用容器的这个概念,语言基本上说容器内的任何东西都是非确定性的

没有。 Haskell是确定性的。如果你要求整数加2 + 2,你将总是得到4。

“不确定性”只是一种比喻,一种思维方式。一切都是确定性的。如果你有这个代码:

do x <- [4,5]
   y <- [0,1]
   return (x+y)

它大致相当于Python代码

 l = []
 for x in [4,5]:
     for y in [0,1]:
         l.append(x+y)

你认为这里有不确定性吗?不,这是列表的确定性构造。运行两次,你会得到相同顺序的相同数字。

您可以这样描述:从[4,5]中选择任意x。从[0,1]中选择任意y。返回x + y。收集所有可能的结果。

这种方式似乎涉及非确定性,但它只是一个嵌套循环(列表理解)。这里没有“真正的”非确定性,它是通过检查所有可能性来模拟的。不确定性是一种幻觉。代码似乎只是不确定的。

此代码使用State monad:

do put 0
   x <- get
   put (x+2)
   y <- get
   return (y+3)

给出了5并且似乎涉及改变状态。与列表一样,这是一种幻觉。没有变化的“变量”(如命令式语言)。一切都是不可改变的。

您可以这样描述代码:将0添加到变量中。将变量的值读取为x。将(x + 2)放入变量。将变量读取到y,并返回y + 3。

这种方式似乎涉及状态,但它只是通过附加参数组成函数。这里没有“真正的”可变性,它是通过组合模拟的。可变性是一种幻觉。代码似乎只是在使用它。

Haskell这样做:你有功能

   a -> s -> (b,s)

此函数获取状态的旧值并返回新值。它不涉及可变性或变更变量。这是数学意义上的一种功能。

例如,函数“put”接受状态的新值,忽略当前状态并返回新状态:

   put x _ = ((), x)

就像你可以编写两个普通函数一样

  a -> b
  b -> c

  a -> c

使用(。)运算符可以组成“状态”变换器

  a -> s -> (b,s)
  b -> s -> (c,s)

成单一功能

  a -> s -> (c,s)

尝试自己编写合成算子。这才是真正发生的事情,没有“副作用”只将参数传递给函数。

答案 7 :(得分:0)

<块引用>

据我所知,Monad 只是另一个声明与数据交互方式的类型类 [...]

...提供所有具有实例的类型通用的接口。然后,这可用于提供适用于所有 monadic 类型的通用定义。

<块引用>

用一个概念来实现这三件事似乎既聪明又干净[...]

...唯一实现的三件事是这三种类型(列表、MaybeIO)的 实例 - 类型本身在别处独立定义.

<块引用>

[...] 但实际上,重点是可以在一系列函数、容器和副作用中进行干净的错误处理。

不仅仅是错误处理,例如考虑ST - 如果没有 monadic 接口,将不得不直接正确地传递封装状态......这是一项令人厌烦的任务。


<块引用>

副作用问题究竟是如何解决的?

简短回答:Haskell 解决通过使用类型来指示它们的存在来管理它们。

<块引用>

有人能直观地解释 Haskell 如何通过输入和输出改变状态吗?

“直观地”……比如有什么可用的over here?让我们尝试进行简单的直接比较:

  • 来自菲利普·瓦德勒的 How to Declare an Imperative

    (* page 26 *)
    type 'a io  = unit -> 'a
    
    infix >>=
    val >>=     : 'a io * ('a -> 'b io) -> 'b io
    fun m >>= k = fn () => let
                             val x = m ()
                             val y = k x ()
                           in
                             y
                           end
    
    val return  : 'a -> 'a io
    fun return x = fn () => x
    
    val putc    : char -> unit io
    fun putc c = fn () => putcML c
    
    val getc    : char io
    val getc  = fn () => getcML ()
    
    fun getcML () =
       valOf(TextIO.input1(TextIO.stdIn))
    
    (* page 25 *)
    fun putcML c =
      TextIO.output1(TextIO.stdOut,c)
    
  • 基于我的 these 两个 answers,这是我的 Haskell 翻译:

    type IO a  =  OI -> a
    
    (>>=)      :: IO a -> (a -> IO b) -> IO b
    m >>= k    =  \ u -> let !(u1, u2) = part u in
                         let !x = m u1 in
                         let !y = k x u2 in
                         y
    
    return     :: a -> IO a
    return x   =  \ u -> let !_ = part u in x
    
    putc       :: Char -> IO ()
    putc c     =  \ u -> putcOI c u
    
    getc       :: IO Char
    getc       =  \ u -> getcOI u
    
     -- primitives
    data OI
    partOI :: OI -> (OI, OI) 
    putcOI :: Char -> OI -> ()
    getcOI :: OI -> Char
    

现在还记得关于副作用的简短回答吗?

Haskell 通过使用类型来指示它们的存在来管理它们。

Data.Char.chr :: Int -> Char    -- no side effects

getChar       :: IO Char        -- side effects at
           {- :: OI -> Char -}  -- work: beware!