首页 文章

为什么我们需要monad?

提问于
浏览
329

在我的拙见中,着名问题"What is a monad?"的答案,特别是投票最多的问题,试图解释什么是monad而没有明确解释为什么monad真的是必要的 . 他们可以解释为问题的解决方案吗?

7 回答

  • 526

    为什么我们需要monad?

    • 我们要编制 only using functions . (毕竟是"functional programming (FP)") .

    • 然后,我们遇到了第一个大问题 . 这是一个程序:

    f(x) = 2 * x

    g(x,y) = x / y

    怎么说 what is to be executed first ?我们如何使用不多于函数形成有序的函数序列(即 a program )?

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

    • 更多问题:某些函数 might fail (即 g(2,0) ,除以0) . 我们在FP中有 no "exceptions" (异常不是函数) . 我们如何解决它?

    解答:让我们 allow functions to return two kind of things :而不是让 g : Real,Real -> Real (从两个实数到实数的函数),让我们允许 g : Real,Real -> Real | Nothing (从两个实数到(真或无)) .

    • 但函数应该(更简单)只返回 one thing .

    解决方案:让我们创建一个要返回的新类型的数据,一个“ boxing type ”,它可能包含一个真实的或者根本就没有 . 因此,我们可以 g : Real,Real -> Maybe Real . 好的但是 ...

    • 现在 f(g(x,y)) 会发生什么? f 尚未准备好使用 Maybe Real . 并且,我们不希望更改我们可以与 g 连接的每个函数来使用 Maybe Real .

    解决方案:让我们 have a special function to "connect"/"compose"/"link" functions . 这样,我们可以在幕后调整一个函数的输出来提供下一个函数 .

    在我们的例子中: g >>= f (连接/撰写 gf ) . 我们希望 >>= 得到 g 的输出,检查它,如果它是 Nothing 只是不要调用 f 并返回 Nothing ;或者相反,提取盒装的 Real 并用它来提供 f . (此算法只是 Maybe 类型的 >>= 的实现) . 另请注意, >>= 必须写成 only once 每"boxing type"(不同的框,不同的自适应算法) .

    • 可以使用相同的模式解决许多其他问题:1 . 使用"box"来编纂/存储不同的含义/值,并使用 g 这样的函数返回那些"boxed values" . 2.让作曲家/链接器 g >>= f 帮助将 g 的输出连接到 f 's input, so we don' t,根本不需要改变任何 f .

    • 使用这种技术可以解决的显着问题是:

    • 具有全局状态,函数序列中的每个函数("the program")都可以共享:solution StateMonad .

    • 我们不喜欢"impure functions":为同一输入产生不同输出的函数 . 因此,让我们标记这些函数,使它们返回一个标记/盒装值: IO monad .

    总幸福!

  • 3

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

    Haskell不需要monad抽象 . 没有必要以纯语言执行IO . IO 类型本身就可以解决这个问题 . 现有的monadic desugaring do 块可以替换为 bindIOreturnIOfailIO ,如 GHC.Base 模块中定义的那样 . (它必须指向its source以获取文档 . )所以不,不需要monad抽象 .

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

    在函数式语言中,代码重用最强大的工具是函数的组合 . 好老的 (.) :: (b -> c) -> (a -> b) -> (a -> c) 运营商非常强大 . 它可以轻松编写微小的函数并将它们粘合在一起,只需最少的语法或语义开销 .

    但是有些情况下类型不能正常运行 . 当你有 foo :: (b -> Maybe c)bar :: (a -> Maybe b) 时你会怎么做? foo . bar 没有进行类型检查,因为 bMaybe b 的类型不同 .

    但是......这几乎是正确的 . 你只想要一点余地 . 您希望能够将 Maybe b 视为基本上 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 . 它们适用于可以组装适当类别的每种类型,遵守类别法 .

    • 左侧身份: id . f = f

    • 右侧身份: f . id = f

    • 相关性: f . (g . h) = (f . g) . h

    只要您能证明您的类型符合这三个法则,您就可以将其转换为Kleisli类别 . 那有什么大不了的?嗯,事实证明monad与Kleisli类别完全相同 . Monadreturn 与Kleisli id 相同 . Monad(>>=) 与Kleisli (.) 不完全相同,但事实证明,就另一个而言,每个人都很容易 . 当你在 (>>=)(.) 之间的差异中翻译它们时,类别法则与monad法则相同 .

    那么为什么要经历这一切呢?为什么语言中有 Monad 抽象?正如我在上面提到的,它使代码重用成为可能 . 它甚至可以沿两个不同的维度重用代码 .

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

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

    那为什么抽象存在呢?因为它被证明是一种工具,可以在代码中实现更多的组合,从而创建可重用的代码并鼓励创建更多可重用的代码 . 代码重用是编程的圣杯之一 . monad抽象之所以存在,是因为它让我们向这个圣杯移动了一点点 .

  • 3

    本杰明·皮尔斯在TAPL说道

    类型系统可以被视为计算程序中术语的运行时行为的一种静态近似 .

    这就是为什么配备强大类型系统的语言比表达不良的语言更具表现力 . 你可以用同样的方式思考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操作并返回纯值 - 该值始终将包含在IO monad中) . 另一个例子是可变数组:在你离开ST monad之后,一个可变数组存在,你不能再在恒定时间内更新数组 . 所以我们需要从 Control.Monad 进行monadic过滤模块:

    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 对列表中的所有元素执行monadic操作,产生元素,monadic操作返回 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所解释的),而且还可以统一处理用户定义的数据类型和内置基元 .

  • 24

    我不认为 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] ,将其从文件中获取一个字符串,并生成一个简单执行给定程序的非交互式程序 .

    有's one problem here: we don' t确实有一个读取文件的概念 . [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"]
              ]
    

    好的,现在你可能发现了一个不 balancer :你可以读取一个文件并根据它来输出,但你不能使用文件内容来决定例如还读了另一个文件 . 明显的解决方案:使输入事件的结果也是 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
    

    还不错!

    那么所有与monads有什么关系呢?

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

    现在看起来很熟悉了,但我们有风险:每个“ Value 行动”都有责任实际传递任何包含函数的结果动作(否则整个程序的控制流很容易被一个不良行为破坏)中间的行动) . 我们最好明确要求这个要求 . 好吧,事实证明这些是monad定律,但我不确定如果没有标准的绑定/连接运算符,我们可以真正制定它们 .

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

  • 205

    Monads只是解决一类反复出现问题的便捷框架 . 首先,monad必须是仿函数(即必须支持映射在不查看元素(或它们的类型)的情况下,它们还必须带来绑定(或链接)操作以及从元素类型( return )创建monadic值的方法 . 最后, bindreturn 必须满足两个方程(左右身份),也称为monad定律 . (或者,可以将monads定义为 flattening operation 而不是绑定 . )

    列表monad通常用于处理非确定性 . 绑定操作选择列表中的一个元素(直观地将它们全部放在并行世界中),让程序员用它们进行一些计算,然后将所有世界中的结果组合到单个列表中(通过连接或展平嵌套列表) ) . 以下是如何在Haskell的monadic框架中定义置换函数:

    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"]
    

    应该注意,列表monad绝不是副作用计算 . 作为monad的数学结构(即符合上述界面和定律)并不意味着副作用,尽管副作用现象通常很好地适合于monadic框架 .

  • 2

    Monads基本上用于在链中组合功能 . 期 .

    现在它们组成的方式在现有的单子组中不同,因此导致不同的行为(例如,模拟状态monad中的可变状态) .

    关于monad的困惑在于它是如此通用,即构成函数的机制,它们可以用于很多事情,从而引导人们相信monad是关于状态,关于IO等,当它们只是关于“组合函数”时” .

    现在,关于monad的一个有趣的事情是,合成的结果总是类型为“M a”,即标记为“M”的信封内的值 . 这个特性实现起来非常好,例如,纯粹与不纯的代码之间的明确分离:将所有不纯的动作声明为“IO a”类型的函数,并在定义IO monad时不提供任何功能,以取出“ “IO a”中的“值” . 结果是没有函数可以是纯粹的并且同时从“IO a”中取出一个值,因为在保持纯粹的时候没有办法获取这样的值(该函数必须在“IO”monad中使用这样的 Value ) . (注意:嗯,没有什么是完美的,所以“IO straitjacket”可以使用“unsafePerformIO:IO a - > a”打破,从而污染了本应该是纯粹的功能,但这应该非常谨慎地使用,当你真的知道不会引入任何带有副作用的不纯代码 .

  • 18

    如果你有一个 type constructorfunctions that returns values of that type family ,你需要monad . 最后,你想 combine these kind of functions together . 这些是回答原因的三个关键要素 .

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

    然后,有一天,你需要创建一个 new family of types . 这可能是因为您需要考虑不返回任何值( Maybe ),返回错误( Either ),多个结果( List )等的可能性 .

    请注意 Maybe 是一个类型构造函数 . 它需要一个类型,如 Int 并返回一个新类型 Maybe Int . 首先要记住的是, no type constructor, no monad.

    当然,您的代码中的 you want to use your type constructor 很快就会以 Int -> Maybe StringString -> Maybe Float 等函数结束 . 现在,您无法轻松组合您的功能 . 生活不再好 .

    而且这是monad来救援的时候 . 它们允许您再次组合这种功能 . 您只需要为 >== 更改组合 . .

相关问题