为啥我们需要单子?

Posted

技术标签:

【中文标题】为啥我们需要单子?【英文标题】:Why do we need monads?为什么我们需要单子? 【发布时间】:2015-03-24 05:23:49 【问题描述】:

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

【问题讨论】:

cs.coloradocollege.edu/~bylvisaker/MonadMotivation blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html 你已经做了哪些研究?你在哪里看过?你找到了哪些资源? We expect you to do a significant amount of research before asking, and show us in the question what research you've done。有许多资源试图解释资源的动机——如果你根本没有找到任何资源,你可能需要做更多的研究。如果您找到了一些,但它们对您没有帮助,那么如果您解释一下您的发现以及为什么它们对您不起作用,这将是一个更好的问题。 这绝对更适合 Programmers.StackExchange,但不适合 ***。如果可以的话,我会投票支持移民,但我不能。 =( @jpmc26 它很可能会因为“主要基于意见”而被关闭;在这里至少有机会(如大量的赞成票、昨天迅速重新开放以及还没有更多的接近投票所示) 【参考方案1】:

为什么我们需要单子?

    我们希望仅使用函数进行编程。 (毕竟是“函数式编程 (FP)”)。

    那么,我们遇到了第一个大问题。这是一个程序:

    f(x) = 2 * x

    g(x,y) = x / y

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

    解决方案:组合函数。如果你想先g 然后f,只需写f(g(x,y))。这样,“程序”也是一个函数:main = f(g(x,y))。好的,但是...

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

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

    但函数应该(更简单)只返回一件事

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

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

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

    在我们的例子中:g >>= f(连接/组合gf)。我们希望>>= 得到g 的输出,检查它,如果是Nothing,就不要调用f 并返回Nothing;或者相反,提取装箱的Real 并用它喂f。 (这个算法只是对Maybe 类型的>>= 的实现)。另请注意,>>= 对于每个“装箱类型”(不同的盒子,不同的适应算法)必须只写一次

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

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

    具有函数序列中的每个函数(“程序”)可以共享的全局状态:解决方案StateMonad

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

幸福快乐!

【讨论】:

@Carl 请写出更好的答案来启发我们 @Carl 我认为答案很清楚,有很多问题可以从这种模式中受益(第 6 点),IO monad 只是列表中的另一个问题@987654357 @(第 7 点)。另一方面,IO 只出现一次并且出现在最后,所以,不要理解你的“大部分时间都在谈论......关于 IO”。 函数组合并不能确定先执行哪个函数(即写\x y -> f (g x y)并不意味着g会在f之前执行)。这是由评估策略决定的。为了证明这一点,请在 GHCi 中评估以下内容,let assert = const True . errorassert "Function composition doesn't determine evaluation order." 结果是 True,而不是错误。在严格的语言中,它会抛出一个错误。但是,因为 Haskell 默认情况下是非严格的,所以它不会这样做。相反,它首先评估 const 而根本不评估 error 关于 monad 的大误解:monad 关于状态;关于异常处理的单子;没有 monad 就无法在纯 FPL 中实现 IO;monad 是明确的(相反是 Either)。大部分答案是关于“为什么我们需要仿函数?”。 "6. 2. 有一个作曲家/链接器g >>= f 来帮助将g 的输出连接到f 的输入,所以我们不必更改任何内容f 一点也不。” 这是不对的。之前,f(g(x,y))f 可以产生任何东西。可能是f:: Real -> String。使用“一元组合”必须更改以生成Maybe String,否则类型将不适合。此外,>>= 本身不适合!!是>=> 做这个组合,而不是>>=。请参阅 Carl 的回答下与 dfeuer 的讨论。【参考方案2】:

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

Haskell 不需要单子抽象。不需要以纯语言执行 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 不是同一类型。

但是……这几乎是对的。你只是想要一点余地。您希望能够将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-loops 包由循环组成,可与Monad 的任何实例一起使用。

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

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

【讨论】:

你能解释一下类别和Kleisli类别之间的关系吗?您描述的三个定律适用于任何类别。 @dfeuer 哦。把它放在代码中,newtype Kleisli m a b = Kleisli (a -> m b)。 Kleisli 类别是其中类别返回类型(在本例中为b)是类型构造函数m 的参数的函数。如果 Kleisli m 形成一个类别,m 是一个 Monad。 什么是分类返回类型? Kleisli m 似乎形成了一个类别,其对象是 Haskell 类型,并且从 ab 的箭头是从 am b 的函数,具有 id = return(.) = (<=<)。这是对的,还是我把不同层次的东西混在一起了? @dfeuer 没错。对象都是类型,态射在类型ab之间,但它们不是简单的函数。它们在函数的返回值中被额外的m 修饰。 真的需要范畴论术语吗?也许,如果您将类型转换为图片,Haskell 会更容易,其中类型将是图片绘制方式的 DNA(虽然是依赖类型*),然后您使用图片编写程序,名称为小红宝石字符图标上方。【参考方案3】:

本杰明·皮尔斯在TAPL中说

一个类型系统可以看成是计算一种静态的 程序中术语的运行时行为的近似值。

这就是为什么配备强大类型系统的语言比类型差的语言更具有严格的表达能力。你可以用同样的方式考虑单子。

正如@Carl 和sigfpe 所指出的,您可以为数据类型配备您想要的所有操作,而无需求助于单子、类型类或任何其他抽象的东西。然而,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 &lt;= 10, sum [1..i] &gt; 4, sum [1..i] &lt; 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 实际上不使用&gt;&gt;=,但它可以)。但是,不可能安全地离开 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 所解释的那样),而且还可以统一处理用户定义的数据类型和内置原语.

【讨论】:

这个答案解释了为什么我们需要 Monad 类型类。理解为什么我们需要 monad 而不是其他东西的最好方法是阅读 monad 和 applicative functors 之间的区别:one, two。【参考方案4】:

我不认为IO 应该被视为一个特别出色的 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-&gt;[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

还不错!

那么这一切与 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 定律,尽管我不确定如果没有标准的绑定/连接运算符,我们能否真正制定它们。

无论如何,我们现在已经达到了具有适当 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。但我的意思更多是对那些已经知道 continuation monad 的人表示赞同,因为它并没有完全适合初学者的声誉。【参考方案5】:

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

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

关于 monad 的困惑在于,它是如此通用,即一种组合函数的机制,它们可以用于很多事情,从而导致人们认为 monad 是关于状态、关于 IO 等的,而实际上它们只是关于“组合函数”。

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

【讨论】:

【参考方案6】:

Monads 只是解决一类反复出现的问题的便捷框架。首先,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 绝不是一个副作用计算。一个数学结构是一个单子(即符合上述接口和定律)并不意味着副作用,尽管副作用现象通常很适合单子框架。

【讨论】:

【参考方案7】:

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

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

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

注意Maybe 是一个类型构造函数。它接受一个类型,如Int,并返回一个新类型Maybe Int。首先要记住,没有类型构造函数,没有 monad。

当然,你想在你的代码中使用你的类型构造函数,很快你就会得到像Int -&gt; Maybe StringString -&gt; Maybe Float这样的函数。现在,您无法轻松组合您的功能。生活不再美好。

这时 monad 就派上用场了。它们允许您再次组合这种功能。您只需更改 .>== 的组合。

【讨论】:

这与类型族无关。你到底在说什么?【参考方案8】:

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

总是需要一元类型 - 来自 Philip Wadler 的 How to Declare an Imperative:

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

地点:

(* pages 25-26 *)
fun putcML c  = TextIO.output1(TextIO.stdOut,c);
fun getcML () = valOf(TextIO.input1(TextIO.stdIn));

是的,好吧 - 您可能正在尝试学习 Haskell,这就是您最终来到这里的原因。碰巧的是,像 Haskell 这样的非严格语言中的 I/O 困境使单子接口如此突出——这就是为什么我选择 I/O 作为运行示例的原因。

现在,您可以像这样在 Haskell 中编写 echo

echoH :: IO ()
echoH =  do c <- getChar
            if c == '\n' then
              return ()
            else
              putChar c >> echoH

或者这个:

echoH' :: IO ()
echoH' =  getChar   >>= \c ->
          if c == '\n' then return () else
          putChar c >> echoH'

但你不能这样写:

errcho    :: () -> ()
errcho () =  let c = getc () in
             if c == '\n' then
               ()
             else
               putc c ; errcho ()

 -- fake primitives!
(;)  :: a -> b -> b
putc :: Char -> ()
getc :: ()   -> Char

这不是合法的 Haskell...但是这几乎是:

echo   :: OI -> ()
echo u =  let !u1:u2:u3:_ = parts u in
          let !c          = getchar u1 in
          if c == '\n' then () else putchar c u2 `seq` echo u3

地点:

data OI             -- abstract
parts :: OI -> [OI] -- primitive

 -- I'll leave these definitions to you ;-)
putchar :: Char -> OI -> ()
getchar :: OI -> Char

Bang-patterns 是 Haskell 2010 的扩展;

Prelude.seq isn't actually sequential - 你需要seq 的替代定义,例如:

   -- for GHC 8.6.5
  -# LANGUAGE CPP #-
  #define during seq
  import qualified Prelude(during)

  -# NOINLINE seq #-
  infixr  0 `seq`
  seq     :: a -> b -> b
  seq x y = Prelude.during x (case x of _ -> y)

或:

   -- for GHC 8.6.5
  -# LANGUAGE CPP #-
  #define during seq
  import qualified Prelude(during)
  import GHC.Base(lazy)

  infixr 0 `seq`
  seq     :: a -> b -> b
  seq x y = Prelude.during x (lazy y)

(是的 - 正在使用更多扩展,但它们与每个定义保持一致。)

它比较笨重,但这是常规的 Haskell:

echo   :: OI -> ()
echo u =  case parts u of
            u1:u2:u3:_ -> case getchar u1 of
                            c -> if c == '\n' then () else
                                 case putchar c u2 of () -> echo u3

是的,就是这样:没有将结果传递给延续,没有将结果与一些抽象状态捆绑在一起——只是一个普通的结果。您需要做的就是提供一个全新的OI 值 - 多么新颖的概念!

这似乎好得令人难以置信……参照透明性如何:它是否被保留?

嗯,我们使用这些OI 值的方式类似于 F. Warren Burton 在Nondeterminism with Referential Transparency in Functional Programming Languages 中描述的 时间戳 - 主要区别在于时间戳必须首先可以从树中检索,而OI 值可以直接使用。

Burton 的时间戳通过以下方式保持引用透明度:

确定时间戳所涉及的效果只发生一次 - 在最初使用时;

一旦确定,时间戳就会保持不变 - 即使被重复使用,它也不会改变。

如果OI 值也以这种方式工作,则保留引用透明度。

是的,它有点神秘,但与 suitable definition of seqparts 和 those curious OI 值一起可以让你做这样的简洁的事情:

runDialogue :: Dialogue -> OI -> ()    
runDialogue d =
    \u -> foldr seq () (yet (\l -> zipWith respond (d l) (parts u)))

respond :: Request -> OI -> Response
respond Getq     = getchar `bind` (unit . Getp)
respond (Putq c) = putchar c `bind` \_ -> unit Putp

地点:

 -- types from page 14 of Wadler's paper
type Dialogue = [Response] -> [Request]

data Request  = Getq | Putq Char
data Response = Getp Char | Putp

yet      :: (a -> a) -> a
yet f    =  f (yet f)

unit     :: a -> (OI -> a)
unit x   =  \u -> part u `seq` x

bind     :: (OI -> a) -> (a -> (OI -> b)) -> (OI -> b)
bind m k =  \u -> case part u of (u1, u2) -> (\x -> x `seq` k x u2) (m u1)

part     :: OI -> (OI, OI)
part u   =  case parts u of u1:u2:_ -> (u1, u2)

它不工作?试试这个:

yet      :: (a -> a) -> a
yet f    =  y where y = f y

是的,不断地输入OI -&gt; 会很烦人,如果这种 I/O 方法能够奏效,它就必须在任何地方都能奏效。最简单的解决方案是:

type IO a = OI -> a

避免使用构造函数所涉及的包装和展开的麻烦。类型的改变也为main提供了一个替代的类型签名:

main :: OI -> ()

总结 - 对于 Haskell 来说,一元类型是一种方便

echo' :: OI -> ()
echo' =  getchar   `bind` \c ->
         if c == '\n' then unit () else
         putchar c `bind` \_ -> echo'

而不是绝对的必需

【讨论】:

以上是关于为啥我们需要单子?的主要内容,如果未能解决你的问题,请参考以下文章

函数式编程-将Monad(单子)融入Swift

状态单子:从一种状态类型转换到另一种状态类型

Haskell 中的单子——洪峰老师讲创客道(三十五)

SharePoint Online 创建我的审批列表

自学编程在家接单有可能吗?

为啥我们需要回调事件?