ChatGPT解决这个技术问题 Extra ChatGPT

为什么我们需要单子?

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

你已经做了哪些研究?你在哪里看过?你找到了哪些资源? 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,而不适合 StackOverflow。如果可以的话,我会投票支持移民,但我不能。 =(
@jpmc26 它很可能会因为“主要基于意见”而被关闭;在这里它至少有机会(如大量的赞成票,昨天迅速重新开放,并且还没有更多的接近投票所示)

1
11 revs, 4 users 95%

为什么我们需要单子?

我们只想使用函数进行编程。 (毕竟是“函数式编程(FP)”)。然后,我们遇到了第一个大问题。这是一个程序: f(x) = 2 * xg(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 |什么都没有(函数从两个实数变为(实数或虚无))。但是函数应该(更简单)只返回一件事。解决方案:让我们创建一种要返回的新数据类型,一种“装箱类型”,它可能包含一个真实的或只是什么都没有。因此,我们可以有 g : Real,Real -> Maybe Real。好的,但是......现在 f(g(x,y)) 会发生什么? f 还没有准备好使用 Maybe Real。而且,我们不想更改我们可以与 g 连接的每个函数来使用 Maybe Real。解决方案:让我们有一个特殊的功能来“连接”/“撰写”/“链接”功能。这样,我们可以在幕后调整一个函数的输出来提供下一个函数。在我们的例子中:g >>= f(将 g 连接/组合到 f)。我们希望 >>= 得到 g 的输出,检查它,如果它是 Nothing,就不要调用 f 并返回 Nothing;或者相反,提取装箱的 Real 并用它输入 f。 (这个算法只是对 Maybe 类型的 >>= 的实现)。另请注意, >>= 每个“装箱类型”(不同的盒子,不同的适应算法)只能写一次。出现了许多其他问题,可以使用相同的模式解决: 1. 使用“框”来编码/存储不同的含义/值,并具有像 g 这样的函数来返回那些“装箱值”。 2. 有一个作曲家/链接器 g >>= f 来帮助将 g 的输出连接到 f 的输入,所以我们根本不需要更改任何 f。可以使用这种技术解决的显着问题是:具有函数序列中的每个函数(“程序”)可以共享的全局状态:解决方案 StateMonad。我们不喜欢“不纯函数”:对相同输入产生不同输出的函数。因此,让我们标记这些函数,使它们返回一个标记/装箱的值:IO monad。

总幸福!


@Carl 请写一个更好的答案来启发我们
@Carl 我认为答案很清楚,有很多问题可以从这种模式中受益(第 6 点),而 IO monad 只是列表 IO 中的另一个问题(第 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 就无法在纯 FPL 中实现 IO;monad 是明确的(相反的参数是 Either)。最多的答案是关于“为什么我们需要仿函数?”。
"6. 2. 有一个作曲家/链接器 g >>= f 来帮助将 g 的输出连接到 f 的输入,因此我们根本不需要更改任何 f。"< /i> 这是不对的。之前,在 f(g(x,y)) 中,f 可以产生任何东西。它可能是 f:: Real -> String。使用“一元组合”必须更改以生成 Maybe String,否则类型将不适合。此外,>>= 本身不适合!!是 >=> 做这个组合,而不是 >>=。请参阅 Carl 的回答下与 dfeuer 的讨论。
c
cmaher

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

Haskell 不需要单子抽象。不需要以纯语言执行 IO。 IO 类型本身就可以很好地解决这个问题。 do 块的现有一元脱糖可以替换为 GHC.Base 模块中定义的对 bindIOreturnIOfailIO 的脱糖。 (这不是关于 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(虽然是依赖类型*),然后您使用图片编写程序,名称为小红宝石字符图标上方。
u
user3237465

本杰明皮尔斯在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 <= 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]

或者我们可以根据 filterAccum 重新定义 nub 函数,从列表中删除重复元素:

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]。一个列表在这里作为累加器传递。该代码有效,因为可以离开 list 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 所解释的那样),而且还可以统一处理用户定义的数据类型和内置原语。


这个答案解释了为什么我们需要 Monad 类型类。理解为什么我们需要 monad 而不是其他东西的最好方法是阅读 monad 和 applicative functors 之间的区别:onetwo
l
leftaroundabout

我不认为 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->[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 实例的 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 的人表示赞同,因为它并不完全以对初学者友好而闻名。
m
mljrg

单子基本上用于将功能组合在一起形成一个链。时期。

现在,它们的组合方式在现有 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”,从而污染本应是纯函数的内容,但这应该非常谨慎地使用,当你真的知道不会引入任何具有副作用的不纯代码。


h
heisenbug

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


j
jdinunzio

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

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

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

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

当然,您想在代码中使用类型构造函数,很快您就会使用 Int -> Maybe StringString -> Maybe Float 之类的函数。现在,您无法轻松组合您的功能。生活不再美好。

这就是monads来救援的时候了。它们允许您再次组合这种功能。你只需要改变构图。对于 >==。


这与类型族无关。你到底在说什么?
a
atravers

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

由于 I/O 的困境及其在 Haskell 等非严格语言中的可观察效果使单子接口如此突出:

[...] monad 用于解决更一般的计算问题(涉及状态、输入/输出、回溯,...)返回值:它们不直接解决任何输入/输出问题,而是提供优雅且对相关问题的许多解决方案的灵活抽象。 [...] 例如,不少于三种不同的输入/输出方案用于解决命令式函数式编程中的这些基本问题,该论文最初提出了“基于单子的新模型,用于在一种非严格的、纯函数式的语言”。 [...] [此类] 输入/输出方案仅提供框架,其中可以安全地使用副作用操作并保证执行顺序,并且不会影响语言的纯功能部分的属性。克劳斯·雷因克(第 96-97 页,共 210 页)。 (我强调。)

[...] 当我们编写有效的代码时——单子或无单子——我们必须时刻牢记我们传递的表达式的上下文。单子代码“去糖”(可根据)无副作用代码这一事实是无关紧要的。当我们使用一元符号时,我们在该符号中进行编程——而不考虑该符号脱糖成什么。考虑脱糖代码打破了单子抽象。无副作用的应用代码通常被编译为(即脱糖成)C 或机器代码。如果去糖论证有任何作用,它也可以应用于应用代码,从而得出结论,这一切都归结为机器代码,因此所有编程都是必要的。 [...] 从个人经验来看,我注意到我在编写 monadic 代码时所犯的错误正是我在 C 编程时所犯的错误。实际上,monadic 错误往往更严重,因为 monadic notation (与一种典型的命令式语言)笨拙且晦涩难懂。 Oleg Kiselyov(第 21 页,共 26 页)。

学生最难理解的结构是单子。我介绍 IO 时没有提到 monad。奥拉夫·奇蒂尔。

更普遍:

尽管如此,在将 monad 的概念引入函数式编程世界 25 年后的今天,初学的函数式程序员仍难以掌握 monad 的概念。大量关于努力学习 monad 的博客文章就是这种斗争的例证。根据我们自己的经验,我们注意到即使在大学阶段,学士学位学生也经常难以理解 monad,并且在与 monad 相关的考试问题上得分一直很低。考虑到 monad 的概念不太可能很快从函数式编程领域消失,作为函数式编程社区,我们必须以某种方式克服新手在第一次学习 monad 时遇到的问题。 Tim Steenvoorden、Jurriën Stutterheim、Erik Barendsen 和 Rinus Plasmeijer。

如果只有另一种方法可以在 Haskell 中指定“保证的执行顺序”,同时保持将常规 Haskell 定义与 I/O 中涉及的定义(及其可观察的效果)分开的能力 - 翻译这个Philip Wadlerecho 的变体:

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

和:

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

这将如何工作?在运行时,Main.main 接收初始 OI pseudo-data 值作为参数:

module Main(main) where

main            :: OI -> ()
          ⋮

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

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

一旦你进入了 IO monad,你就永远被困在那里,并且沦为 Algol 风格的命令式编程。罗伯特哈珀。

但如果您真的do需要它:

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

                      ⋮

因此,并不总是需要单子类型 - 还有其他接口:

早在 1989 年,LML 就有一个完全成熟的运行多处理器(顺序对称)的预言机实现。Fudgets 论文中的描述指的是这个实现。使用起来相当愉快,而且非常实用。 [...] 如今,所有事情都由 monad 完成,因此有时会忘记其他解决方案。伦纳特·奥古斯特森 (2006)。

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

绝对 - 只需找到“参照透明度”的合适定义; there's plenty to choose from...