为什么我们需要单子?

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

58032 次浏览

为什么我们需要单子?

  1. 我们想要编程只使用函数。(毕竟是“函数式编程”)。
  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(函数从两个实数变成(实数或无))。< / p >

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

    解决方案:让我们创建一个新的数据类型来返回,一个“拳击类型”,它可能包含一个实数,也可能什么都没有。因此,我们可以有g : Real,Real -> Maybe Real。好的,但是…

  5. f(g(x,y))现在发生了什么?f还没有准备好使用Maybe Real。并且,我们不想改变每个可以与g连接的函数来使用Maybe Real

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

    在我们的例子中:g >>= f(连接/组合gf)。我们希望>>=获取g的输出,检查它,如果它是Nothing,就不要调用f并返回Nothing;或者相反,提取盒装的Real并将其输入f。(此算法只是g1类型的>>=的实现)。还要注意,>>=必须为每个“装箱类型”(不同的箱子,不同的自适应算法)编写g3

  6. 出现的许多其他问题都可以用同样的模式来解决:使用“盒子”来编纂/存储不同的含义/值,并使用g这样的函数返回这些“盒子值”。2. 有一个编写器/链接器g >>= f来帮助将g的输出连接到f的输入,所以我们根本不需要改变任何f

  7. 使用这种技术可以解决的显著问题有:

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

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

    • 李< / ul > < / >

总幸福!

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

Haskell不需要单子抽象。在纯语言中执行IO并不是必需的。IO类型本身就很好地处理了这一点。现有的do块的一元糖可以用GHC.Base模块中定义的bindIOreturnIOfailIO的糖替换。(它不是一个关于hackage的文档模块,所以我必须指向其来源来获取文档。)所以不,没有必要抽象单子。

如果不需要它,为什么它会存在?因为人们发现许多计算模式形成了单一结构。结构的抽象允许编写跨该结构的所有实例的代码。更简单地说——代码重用。

在函数式语言中,最强大的代码重用工具是函数的组合。老的(.) :: (b -> c) -> (a -> b) -> (a -> c)操作符非常强大。它可以很容易地编写小函数,并以最小的语法或语义开销将它们粘合在一起。

但在某些情况下,这些类型并不完全正确。当你有foo :: (b -> Maybe c)bar :: (a -> Maybe b)时你会做什么?foo . bar不进行类型检查,因为bMaybe b不是同一类型。

但是…几乎是对的。你只是需要一点回旋的余地。你希望能够将Maybe b作为b来处理。不过,直接把他们当作同一类型的人并不是一个好主意。这或多或少与空指针相同,Tony Hoare将其称为十亿美元的错误。因此,如果你不能将它们视为同一类型,也许你可以找到一种方法来扩展(.)提供的组合机制。

在这种情况下,真正检查(.)背后的理论是很重要的。幸运的是,有人已经为我们做了这件事。事实证明,(.)id的组合形成了一个称为(.)0的数学结构。但还有其他方法来形成分类。例如,一个Kleisli类别允许被组合的对象被增强一点。Maybe的Kleisli类别由(.) :: (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. Associativity: f . (g . h) = (f . g) . h

只要你能证明你的类型符合这三条定律,你就可以把它变成克莱斯利类别。这有什么大不了的?其实单子和Kleisli分类是一样的。Monadreturn与Kleisli id相同。Monad(>>=)与Kleisli (.)不完全相同,但事实证明,用另一个来表示它们非常容易。类别法则和单子法则是一样的,当你在(>>=)(.)之间的差异上翻译它们。

那么为什么要这么麻烦呢?为什么在语言中有Monad抽象?如上所述,它支持代码重用。它甚至可以在两个不同的维度上实现代码重用。

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

第二个维度是间接的,但它源于构图的存在。当组合很容易时,很自然地编写小的、可重用的代码块。同样地,为函数使用(.)操作符鼓励编写小型、可重用的函数。

那么为什么抽象存在呢?因为它被证明是一种工具,可以在代码中实现更多的组合,从而创建可重用的代码,并鼓励创建更多可重用的代码。代码重用是编程的终极目标之一。单子抽象的存在是因为它将我们推向了圣杯。

本杰明·皮尔斯在TAPL

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

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

正如@Carl和我试所指出的那样,你可以为一个数据类型配备你想要的所有操作,而无需求助于单子、类型类或任何其他抽象的东西。然而,单子不仅允许你编写可重用的代码,还可以抽象出所有冗余的细节。

举个例子,假设我们想过滤一个列表。最简单的方法是使用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]。在这里,列表作为累加器传递。代码可以工作,因为它可以离开列表单子,所以整个计算保持纯净(notElem实际上不使用>>=,但它可以)。然而,安全地离开IO单子是不可能的(即你不能执行一个IO动作并返回一个纯值-这个值总是会被包装在IO单子中)。另一个例子是可变数组:当你离开可变数组所在的ST单子后,你就不能在常数时间内更新数组了。因此,我们需要从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单子的版本,它询问要返回哪些元素:

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单子,它是在引子下使用的,只是一个普通的数据类型。

这个例子说明,单子不仅允许您抽象计算上下文和编写干净的可重用代码(由于单子的可组合性,正如@Carl解释的那样),而且还可以统一对待用户定义的数据类型和内置原语。

我不认为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)

现在这看起来很熟悉了,但我们仍然只处理隐藏在底层的简单函数,这是有风险的:每个“值-操作”都有责任实际传递任何包含函数的结果操作(否则整个程序的控制流很容易被中间的一个行为不良的操作中断)。我们最好把那个要求说清楚。好吧,事实证明这些是单细胞生物法,尽管我不确定我们是否可以在没有标准绑定/连接操作符的情况下真正地表述它们。

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

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

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

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

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

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

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

单体只是一个方便的框架,用于解决一类重复出现的问题。首先,单子必须是(即必须在不查看元素(或其类型)的情况下支持映射),它们还必须带来绑定(或链接)操作和一种从元素类型(return)创建单子值的方法。最后,bindreturn必须满足两个等式(左等式和右等式),也称为单子定律。(或者也可以定义单子有flattening operation而不是绑定。)

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

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

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

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

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

现在,关于单子的一个有趣的事情是,组合的结果总是类型为“M a”,也就是说,一个标记为“M”的信封中的值。这个特性实现起来真的很不错,例如,纯代码和不纯代码之间的清晰分离:将所有不纯操作声明为类型为“IO a”的函数,并且在定义IO单子时,不提供从“IO a”中取出“a”值的函数。结果是,没有一个函数可以是纯的,同时从“IO a”中取出一个值,因为没有办法在保持纯的同时取出这样的值(函数必须在“IO”单子中使用这样的值)。(注意:好吧,没有什么是完美的,所以“IO束缚”可以使用“unsafePerformIO: IO a -> a”来打破,从而污染了原本应该是纯函数的东西,但这应该非常谨慎地使用,当你真的知道不要引入任何带有副作用的不纯代码时。

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

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

  • […monads用于解决更一般的计算问题(包括状态,输入/输出,回溯,…)返回值:它们不直接解决任何输入/输出问题,而是提供了许多相关问题解决方案的优雅而灵活的抽象。[…例如,在命令式函数式编程中至少使用了三种不同的输入/输出方案来解决这些基本问题,这篇论文最初提出了'一个新的模型,基于单子,执行输入/输出在一个非严格,纯函数语言'。[…]

    [这样的]输入/输出方案只是提供了一个框架,在这个框架中,副作用操作可以在保证执行顺序的情况下安全地使用,而不会影响语言纯函数部分的属性。

    老人Reinke(210页中的96-97页)。

    (由我强调)

  • […当我们编写有效的代码时——不管是不是单子——我们必须时刻牢记我们传递的表达式的上下文。

    事实上,单元代码“desugars”(是可实现的)无副作用的代码是无关紧要的。当我们使用单一符号时,我们在这个符号中编程,而不考虑这个符号被蔗糖成什么。考虑糖化代码打破了单一抽象。无副作用的应用程序代码通常被编译为C或机器代码(也就是说,编译为机器代码)。如果去糖化的论点有任何力量,它也可以同样适用于应用程序代码,从而得出结论,这一切都归结为机器代码,因此所有的编程都是必要的。

    […从个人经验来看,我已经注意到我在编写单变量代码时所犯的错误与我在c语言中编程时所犯的错误完全相同。实际上,单变量错误往往更糟,因为单变量符号(与典型的命令式语言相比)笨拙而模糊。

    奥列格•凯瑟列夫(26页中的第21页)。

  • 对于学生来说,最难理解的结构是单子。我在介绍IO时没有提到单子。

    奥拉夫·奇蒂尔

更普遍的:

如果有另一种方法来指定“有保证的执行顺序"在Haskell中,同时保持将常规Haskell定义与那些涉及I/O(及其可观察的影响)的定义分开的能力-将Philip Wadler定义echo的这种变体:

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 < em >虚假数据< / em >作为参数:

module Main(main) where


main            :: OI -> ()
⋮

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

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

一旦你在IO单子中,你就永远被困在那里,并被简化为algolstyle命令式编程。

罗伯特·哈珀

但是如果你真的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


⋮

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

LML早在1989年就有了一个完整的oracle多处理器(sequence Symmetry)实现。福吉特论点中的描述引用了这个实现。和它一起工作很愉快,也很实用。

[…]

现在所有的事情都是用单子完成的,所以其他的解决方案有时会被遗忘。

Lennart Augustsson(2006)。


等一下:因为它非常类似于标准ML直接使用的效果,这种方法及其虚假数据的使用是引用透明的吗?

当然-只要找到一个合适的定义&;__abc0 &;;有很多选择…