在我看来,对于“什么是单子?”这个著名问题的答案,尤其是那些投票最多的答案,试图解释什么是单子,却没有清楚地解释为什么单子是必要的。它们能被解释为问题的解决方案吗?


当前回答

为什么我们需要单一类型?

由于I/O的困境和它在非严格语言(如Haskell)中可观察到的影响,使得单元接口如此突出:

[...] 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). (emphasis by me.) [...] 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 Wadler的这种变化:

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 -> ()
                      ⋮

and:

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

这是如何运作的呢?在运行时,Main。main接收一个初始OI伪数据值作为参数:

module Main(main) where

main            :: OI -> ()
          ⋮

使用parttoi或partsOI从其中产生其他OI值。您所要做的就是确保每个新的OI值在每次调用基于OI的定义(外部或其他)时最多使用一次。作为回报,你会得到一个普通的结果——它并没有与一些奇怪的抽象状态配对,或者需要使用回调延续等等。

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

一旦你进入IO单子,你就永远被困在那里,并被简化为algolstyle命令式编程。 罗伯特·哈珀。

但如果你真的需要它:

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

                      ⋮

所以,单体类型并不总是需要的-有其他的接口:

LML早在1989年就有了一个完整的oracle多处理器(sequence Symmetry)实现。Fudgets论文中的描述引用了这个实现。和它一起工作很愉快,也很实用。 […] 现在所有的事情都是用单子完成的,所以其他的解决方案有时会被遗忘。 Lennart Augustsson(2006)。


等一下:既然它与标准ML直接使用效果非常相似,那么这种方法及其使用的伪数据引用是透明的吗?

当然,只要找到一个合适的“参考透明度”的定义;有很多选择…

其他回答

如果你有一个类型构造函数和返回该类型族值的函数,你就需要单子。最终,你会想把这些函数组合在一起。这是回答为什么的三个关键要素。

让我详细说明一下。你有Int, String和Real和函数类型Int -> String, String -> Real等等。你可以很容易地组合这些函数,以Int -> Real结尾。生活是美好的。

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

注意Maybe是一个类型构造函数。它接受一个类型,比如Int,然后返回一个新的类型,可能是Int。首先要记住,没有类型构造函数,没有单子。

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

这时单子就来拯救我们了。它们允许你再次组合这类功能。你只需要改变成分。> = =。

我不认为IO应该被视为一个特别出色的单子,但它肯定是一个更令人震惊的初学者,所以我将用它来解释。

Naïvely为Haskell构建IO系统

对于纯函数式语言来说,最简单的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]列表确实给出了一个很好的输出顺序,但我们没有得到输入何时完成的顺序。

解决方案:让输入事件也成为要做的事情列表中的项目。

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₃可以分解出一系列的动作。为什么我们不简单地使用签名::IO₁,它有一个特例? 这些列表不再真正给出程序流程的可靠概述:大多数后续计算只会作为某些输入操作的结果被“宣布”。因此,我们不妨放弃列表结构,并简单地为每个输出操作添加一个“and then do”。

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

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

还不错!

那么这一切与单子有什么关系呢?

在实践中,您不希望使用普通构造函数来定义所有程序。需要有几个这样的基本构造函数,但对于大多数更高级别的东西,我们希望编写一个具有一些不错的高级签名的函数。事实证明,其中大多数看起来非常相似:接受某种有意义类型的值,并产生一个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)

Now that starts to look familiar, but we're still only dealing with thinly-disguised plain functions under the hood, and that's risky: each “value-action” has the responsibility of actually passing on the resulting action of any contained function (else the control flow of the entire program is easily disrupted by one ill-behaved action in the middle). We'd better make that requirement explicit. Well, it turns out those are the monad laws, though I'm not sure we can really formulate them without the standard bind/join operators.

无论如何,我们现在已经达到了一个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实现,但原则上是可用的。

为什么我们需要单一类型?

由于I/O的困境和它在非严格语言(如Haskell)中可观察到的影响,使得单元接口如此突出:

[...] 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). (emphasis by me.) [...] 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 Wadler的这种变化:

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 -> ()
                      ⋮

and:

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

这是如何运作的呢?在运行时,Main。main接收一个初始OI伪数据值作为参数:

module Main(main) where

main            :: OI -> ()
          ⋮

使用parttoi或partsOI从其中产生其他OI值。您所要做的就是确保每个新的OI值在每次调用基于OI的定义(外部或其他)时最多使用一次。作为回报,你会得到一个普通的结果——它并没有与一些奇怪的抽象状态配对,或者需要使用回调延续等等。

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

一旦你进入IO单子,你就永远被困在那里,并被简化为algolstyle命令式编程。 罗伯特·哈珀。

但如果你真的需要它:

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

                      ⋮

所以,单体类型并不总是需要的-有其他的接口:

LML早在1989年就有了一个完整的oracle多处理器(sequence Symmetry)实现。Fudgets论文中的描述引用了这个实现。和它一起工作很愉快,也很实用。 […] 现在所有的事情都是用单子完成的,所以其他的解决方案有时会被遗忘。 Lennart Augustsson(2006)。


等一下:既然它与标准ML直接使用效果非常相似,那么这种方法及其使用的伪数据引用是透明的吗?

当然,只要找到一个合适的“参考透明度”的定义;有很多选择…

单子的作用基本上是将函数组合在一个链中。时期。

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

关于单子的困惑是,它是一种组合函数的机制,可以用于很多事情,因此导致人们相信单子是关于状态,关于IO等,而实际上它们只是关于“组合函数”。

Now, one interesting thing about monads, is that the result of the composition is always of type "M a", that is, a value inside an envelope tagged with "M". This feature happens to be really nice to implement, for example, a clear separation between pure from impure code: declare all impure actions as functions of type "IO a" and provide no function, when defining the IO monad, to take out the "a" value from inside the "IO a". The result is that no function can be pure and at the same time take out a value from an "IO a", because there is no way to take such value while staying pure (the function must be inside the "IO" monad to use such value). (NOTE: well, nothing is perfect, so the "IO straitjacket" can be broken using "unsafePerformIO : IO a -> a" thus polluting what was supposed to be a pure function, but this should be used very sparingly and when you really know to be not introducing any impure code with side-effects.

Monads are just a convenient framework for solving a class of recurring problems. First, monads must be functors (i.e. must support mapping without looking at the elements (or their type)), they must also bring a binding (or chaining) operation and a way to create a monadic value from an element type (return). Finally, bind and return must satisfy two equations (left and right identities), also called the monad laws. (Alternatively one could define monads to have a flattening operation instead of binding.)

列表单子通常用于处理不确定性。绑定操作选择列表中的一个元素(直观地说,它们都在并行世界中),让程序员对它们进行一些计算,然后将所有世界中的结果组合到一个列表中(通过连接或平铺嵌套列表)。下面是如何在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"]

需要注意的是,列表单子绝不是计算的副作用。一个数学结构是一个单子(即符合上面提到的接口和规律)并不意味着副作用,尽管副作用现象通常很好地适合单子框架。