为什么我们需要单子?


366

以我的拙见,对著名问题“什么是单子”的答案,尤其是投票最多的人,试图解释什么是单子,而没有清楚地说明为什么单子是真正必要的。可以将它们解释为解决问题的方法吗?




4
您已经完成了哪些研究?你去哪了 您找到了哪些资源? 我们希望您在提出问题之前先进行大量研究,然后向我们展示您所进行的研究中的问题。有很多资源试图解释资源的动机-如果您根本没有发现资源,则可能需要做更多的研究。如果您找到了一些但他们没有帮助您,那么如果您解释了所发现的内容以及为什么它们对您没有帮助,这将是一个更好的问题。
DW

8
这绝对适合Programmers.StackExchange,而不适合StackOverflow。如果可以的话,我会投票决定是否迁移,但我不能。=(
jpmc26,2015年

3
@ jpmc26最有可能以“主要基于意见”的形式在此处关闭;这至少是一个机会(如大量的赞成票,昨天迅速重启并没有更多封闭票显示)
Izkata

Answers:


580

为什么我们需要单子?

  1. 我们只想使用函数进行编程。(毕竟是“功能性编程(FP)”)。
  2. 然后,我们有第一个大问题。这是一个程序:

    f(x) = 2 * x

    g(x,y) = x / y

    我们如何说 要先执行什么?我们如何仅使用函数就可以形成有序的函数序列(即程序)?

    解决方案:编写函数。如果先要g然后f再写就可以f(g(x,y))。这样,“程序”也是一个功能:main = f(g(x,y))。好的但是 ...

  3. 更多问题:某些功能可能会失败(即g(2,0),除以0)。我们有没有“例外”在FP(一个例外是不是一个函数)。我们该如何解决?

    解决方案:让函数让函数返回两种东西g : Real,Real -> Real让我们让g : Real,Real -> Real | Nothing(函数从两个实数转换为实数或无),而不是让函数从两个实数转换为实数。

  4. 但是函数(为了简化)应该只返回一件事

    解决方案:让我们创建一种要返回的新型数据,即“ 装箱类型 ”,其中可能包含实数,也可能只是空东西。因此,我们可以拥有g : Real,Real -> Maybe Real。好的但是 ...

  5. 现在会发生什么f(g(x,y))f还没准备好消费Maybe Real。而且,我们不想更改可以连接的每个函数g来消耗Maybe Real

    解决方案:让我们有一个特殊的功能来“连接” /“组成” /“链接”功能。这样,我们可以在幕后调整一项功能的输出以提供下一项功能。

    在我们的例子中:( g >>= f连接/组成gf)。我们想要>>=获取g的输出,对其进行检查,以防万一它Nothing不调用f并返回Nothing;或者相反,提取盒装Realf用它喂食。(此算法只是>>=针对该Maybe类型的实现)。另外请注意,每个“装箱类型”(不同的装箱箱,不同的适应算法)只能>>=写入一次

  6. 使用此相同的模式可以解决许多其他问题:1.使用“框”来整理/存储不同的含义/值,并具有g返回这些“框值”的函数。2.拥有一个作曲家/链接程序g >>= f来帮助将g的输出连接到f的输入,因此我们根本不需要更改任何内容f

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

    • 具有全局状态,函数序列中的每个函数(“程序”)可以共享:solution StateMonad

    • 我们不喜欢“不纯函数”:对于相同输入产生不同输出的函数。因此,让我们标记这些函数,使它们返回标记/装箱的值:monad。IO

完全幸福!


64
@Carl请写一个更好的答案让我们认识
XrXr

15
@Carl我认为答案很明显,许多问题都可以从这种模式中受益(第6点),而IOmonad只是列表中的另一个问题IO(第7点)。另一方面,IO它只出现一次并在末尾出现,因此,不理解您的“大部分时间都在谈论...关于IO”。
cibercitizen1 2015年

4
关于单子的巨大误解:关于国家的单子;关于国家的单子。有关异常处理的monad;没有monad,就无法在纯FPL中实现IO; monad是明确的(矛盾是Either)。答案最多的是“为什么我们需要函子?”。
vlastachu

4
“ 6. 2.有一个作曲者/链接器g >>= f可以帮助将g'的输出连接到f'的输入,因此我们根本不需要进行任何更改f。” 这根本不对。之前,在f(g(x,y))f可以生产任何东西。可能是f:: Real -> String。对于“单声道成分” ,必须将其更改为production Maybe String,否则类型将不合适。而且,>>=它本身不合适!这是>=>做这个成分,不>>=。请参阅卡尔的回答与dfeuer的讨论。
尼斯

3
您的答案是正确的,因为对monads IMO的描述最好是关于“功能”的组成/性质(实际上是Kleisli箭头),但是确切类型的详细信息是什么使它们成为“ monads”。您可以采用各种方式(例如Functor等)为盒子接线。将它们连接在一起的这种特定方式就是“ monad”的定义。
尼斯

219

答案当然是“我们不”。与所有抽象一样,这不是必需的。

Haskell不需要monad抽象。无需使用纯语言执行IO。该IO类型本身可以很好地解决这一问题。现有一元脱糖do块可以与脱糖被替换bindIOreturnIOfailIO作为限定GHC.Base模块。(它不是关于黑客的文档化模块,因此,我必须指出文档来源。)因此,不需要,不需要monad抽象。

因此,如果不需要它,为什么它存在?因为发现许多计算模式形成单子结构。结构的抽象允许编写可在该结构的所有实例中工作的代码。简而言之-代码重用。

在功能语言中,发现用于代码重用的最强大的工具就是功能的组合。好的老(.) :: (b -> c) -> (a -> b) -> (a -> c)操作员功能强大。它使编写微小的函数和将它们粘合在一起变得容易,并且语法或语义开销最小。

但是在某些情况下,这些类型无法正确解决。当你有你做什么foo :: (b -> Maybe c)bar :: (a -> Maybe b)foo . bar不进行类型检查,因为bMaybe b类型不相同。

但是...几乎是正确的。您只需要一点余地。您希望能够将其Maybe b视为基本b。但是,仅仅将它们视为相同类型是一个糟糕的主意。这几乎与null指针相同,Tony Hoare曾将其称为十亿美元的错误。因此,如果您不能将它们视为同一类型,也许您可​​以找到一种方法来扩展(.)提供的合成机制。

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

突然之间,我们将合成的功能扩展到了传统(.)操作无法完成的事情。这是新的抽象能力的来源。Kleisli类别不仅可以使用更多类型,还可以使用更多类型Maybe。它们适用于可以组合适当类别的每种类型,并遵守类别法则。

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

只要您可以证明自己的类型符合这三个定律,就可以将其转换为Kleisli类别。那有什么大不了的?好吧,事实证明,单子与Kleisli类别完全相同。Monadreturn是相同的Kleisli idMonad(>>=)是不相同的Kleisli (.),但它原来是很容易写在每个其它方面。当您跨(>>=)和之间的区别翻译类别法则时,类别法则与单子法则相同(.)

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

代码重用的第一个维度直接来自抽象的存在。您可以编写适用于所有抽象实例的代码。整个monad-loops程序包均包含可与的任何实例一起使用的循环Monad

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

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


2
您能否解释一般类别与Kleisli类别之间的关系?您描述的三个定律适用于任何类别。
dfeuer 2015年

1
@dfeuer哦。放入代码中newtype Kleisli m a b = Kleisli (a -> m b)。Kleisli类别是函数,其中归类返回类型(b在这种情况下)是类型构造函数的参数m。Iff Kleisli m构成一个类别,m是Monad。
卡尔

1
确切的返回类型是什么?Kleisli m似乎形成了一个类别,其对象是Haskell类型,并且从a到的箭头b是从am b,带有id = return和的函数(.) = (<=<)。那是对的吗,还是我混淆了不同层次的东西?
dfeuer

1
@dfeuer是的。对象是所有类型,并且态射在类型a和之间b,但是它们不是简单的函数。它们在m函数的返回值中附加了一个修饰符。
卡尔

1
确实需要类别理论术语吗?也许,如果您将类型转换为图片,而该类型将成为图片绘制方式的DNA(尽管是依赖类型*),然后使用图片编写名称为小红宝石字符的程序,Haskell会更容易。图标上方。
aoeu256

24

本杰明·皮尔斯在TAPL中

类型系统可以看作是一种对程序中各项的运行时行为的静态近似计算。

这就是为什么配备功能强大的类型系统的语言要比类型不好的语言严格地表现力强的原因。您可以以相同的方式考虑monad。

正如@Carl和sigfpe所指出的那样,您可以为数据类型配备所需的所有操作,而无需求助于monad,typeclass或任何其他抽象的东西。但是,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操作并返回纯值-该值始终会包装在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对列表中的所有元素执行单子动作,产生单子动作为其返回的元素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的版本,该版本要求返回哪些元素:

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所述),而且还可以统一地处理用户定义的数据类型和内置基元。


1
这个答案解释了为什么我们需要Monad类型类。理解为什么我们需要monad而不是其他东西的最好方法是阅读monad和应用函子之间的区别:
user3237465'2

20

我认为IO不应将其视为特别出色的单子,但对于初学者来说,它肯定是更令人惊讶的单子,因此我将用它来解释。

天真的为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]列表肯定为输出提供了一个很好的顺序,但是对于何时完成输入,我们没有任何顺序。

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

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₁(作为特殊情况)呢?

  • 这些列表实际上不再提供可靠的程序流程概览:大多数后续计算仅会由于某些输入操作而被“宣布”。因此,我们不妨放弃列表结构,仅对每个输出操作进行“先做然后做”。

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)

现在开始看起来很熟悉,但是我们仍然只处理底层隐藏的简单函数,这很冒险:每个“值动作”都有责任实际传递任何包含函数的结果动作(否则中间的一个不良行为很容易破坏整个程序的控制流)。我们最好明确要求。好吧,事实证明这些就是单子法则,尽管我不确定我们是否可以在没有标准绑定/联接运算符的情况下真正制定它们。

无论如何,我们现在已经得出了具有适当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的有效实现,但原则上是可用的。


@jdlugosz: IO3 a ≡ Cont IO2 a。但我的意思是,此评论更多地向那些已经知道延续单子的人致敬,因为它并不以初学者友好而闻名。
左转

4

Monad只是解决一类重复出现问题的便捷框架。首先,单子必须是函子(即,必须在不查看元素(或其类型)的情况下支持映射),它们还必须进行绑定(或链接)操作以及从元素类型(return)创建单子值的方法。最后,bind并且return必须满足两个方程(左右恒等式),也称为单子定律。(或者,可以将monad定义为具有flattening operation而不是绑定。)

列表单子是通常用来对付非决定。绑定操作选择列表中的一个元素(直观上所有元素都在并行世界中),让程序员对其进行一些计算,然后将所有世界中的结果组合到单个列表中(通过串联或展平嵌套列表) )。这是在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"]

应该注意的是,列表单子绝不影响计算。尽管单反现象通常很好地适合于单子框架,但数学结构为单子(即符合上述接口和定律)并不意味着有副作用。


3

Monad基本上起着将功能组合在一起的作用。期。

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

关于monad的困惑在于它是如此的笼统,即一种功能组合的机制,它们可以用于很多事情,因此使人们相信monad仅仅涉及“组成函数”时是关于状态,关于IO等的。 ”。

现在,有关monads的一件有趣的事情是,合成的结果始终是“ M a”类型,即信封中标有“ M”的值。恰好可以很好地实现此功能,例如,将纯代码与不纯代码之间清楚地分开:将所有不纯行为声明为“ IO a”类型的函数,并且在定义IO monad时不提供任何函数来取出“ “ IO a”内部的“ a”值。结果是没有函数可以是纯函数,而不能同时从“ IO a”中取出一个值,因为在保持纯函数的同时无法获取该值(函数必须在“ IO” monad中才能使用)这样的值)。(注意:嗯,没有什么是完美的,因此可以使用“ unsafePerformIO:IO a-> a”来破坏“ IO straitjacket”


2

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

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

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

注意,这Maybe是一个类型构造函数。它需要一个类型,例如,Int然后返回一个新类型Maybe Int。首先要记住的是,没有类型构造函数,也没有monad。

当然,您希望在代码中使用类型构造函数,很快您将以Int -> Maybe String和结束函数String -> Maybe Float。现在,您无法轻松地组合功能。生活不再美好。

这是单子救援的时候。它们使您可以再次组合这种功能。您只需要更改组成即可对于> ==


2
这与类型族无关。你到底在说什么
dfeuer
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.