为什么我们需要单子(monads)?

19 浏览
0 Comments

为什么我们需要单子(monads)?

在我不才的观点中,对于这个著名问题“什么是monad?”,特别是得票最高的回答,试图解释什么是monad却没有明确解释为什么monad是真正必要的。它们能被解释为问题的解决方案吗?

0
0 Comments

为什么我们需要monads?

在Benjamin Pierce的《TAPL》中,他说:

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

这就是为什么具备强大类型系统的语言比弱类型语言更具表现力的原因。你可以把monads看作是同样的道理。

正如sigfpe所指出的,你可以通过为数据类型添加所需的所有操作来实现,而不需要使用monads、typeclasses或其他抽象概念。然而,monads不仅允许你编写可重用的代码,还可以将所有冗余细节抽象化。

举个例子,假设我们想要过滤一个列表。最简单的方法是使用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 <= 10, sum [1..i] > 4, sum [1..i] < 25的i,我们可以这样写:

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]。这里使用了一个列表作为累加器。代码之所以能正常工作,是因为可以离开列表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? -- 输出

True -- 输入

return 2?

False

return 4?

False

return 5?

True

[1,5] -- 输出

最后,filterAccum可以用filterM来定义:

filterAccum f a xs = evalState (filterM (state . flip f) xs) a

在这个例子中,monads不仅允许你抽象计算上下文和编写干净的可重用代码(正如解释的那样),还可以统一处理用户定义的数据类型和内置的原始类型。

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

0
0 Comments

为什么我们需要monads?

答案是:“我们不需要”。就像所有的抽象一样,它并不是必需的。

Haskell并不需要一个monad的抽象。在一个纯语言中执行IO并不需要它。IO类型可以很好地处理这个问题。现有的do块的monadic desugaring可以用GHC.Base模块中定义的bindIOreturnIOfailIO来替代。(这个模块在hackage上没有文档,所以我得指向它的源代码来查看文档。)所以,monad的抽象是不需要的。

那么如果不需要,为什么它存在呢?因为人们发现许多计算模式形成了monadic结构。通过抽象一个结构,可以编写适用于该结构的所有实例的代码。简而言之,代码重用。

在函数式语言中,最强大的代码重用工具是函数的组合。老旧的(.) :: (b -> c) -> (a -> b) -> (a -> c)运算符非常强大。它使得可以轻松地编写小函数,并用最小的语法和语义开销将它们粘合在一起。

但是有些情况下,类型无法完全匹配。当你有foo :: (b -> Maybe c)bar :: (a -> Maybe b)时,你该怎么办?foo . bar无法通过类型检查,因为bMaybe b不是相同的类型。

但是...它几乎是正确的。你只是想要一点灵活性。你希望能够将Maybe b视为基本上是b。然而,将它们完全视为相同的类型是个坏主意,这基本上相当于空指针,Tony Hoare曾经称之为十亿美元的错误。所以,如果你不能将它们视为相同的类型,也许你可以找到一种扩展组合机制(.)的方法。

在这种情况下,重要的是真正地检查(.)的理论基础。幸运的是,有人已经为我们做过这件事。事实证明,(.)id的组合形成了一个称为category的数学结构。但是还有其他方法可以形成category。比如,Kleisli category允许被组合的对象略微扩展。对于Maybe的Kleisli category将包含(.) :: (b -> Maybe c) -> (a -> Maybe b) -> (a -> Maybe c)id :: a -> Maybe a。也就是说,这个category中的对象用一个Maybe扩展了(->),所以(a -> b)变成了(a -> Maybe b)

突然之间,我们扩展了组合的能力,使其适用于传统(.)操作无法处理的事物。这就是新的抽象能力的来源。Kleisli categories适用于不仅仅是Maybe的更多类型。它们适用于每一种可以组装一个适当的category、遵守category laws的类型。

  1. 左单位元: id . f = f
  2. 右单位元: f . id = f
  3. 结合律: f . (g . h) = (f . g) . h

只要你能证明你的类型遵守这三个法则,你就可以将其转化为Kleisli category。那么这有什么大不了的?嗯,事实证明,monad和Kleisli category其实是一样的东西。Monad的return就是Kleisli category的id。Monad的(>>=)虽然不同于Kleisli category的(.),但它们之间很容易相互转换。并且当你将它们从(>>=)转换为(.)时,category laws和monad laws是相同的。

那么为什么要费这么大劲呢?为什么要在语言中引入Monad的抽象?正如我上面所提到的,它可以实现代码重用。它甚至可以实现两个不同维度的代码重用。

代码重用的第一个维度直接来自于抽象的存在。你可以编写适用于抽象的所有实例的代码。整个monad-loops包就是一个例子,它包含适用于任何Monad实例的循环代码。

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

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

你能解释一下一般情况下和Kleisli category之间的关系吗?你描述的三个法则在任何category中都成立。

哦。用代码来说,newtype Kleisli m a b = Kleisli (a -> m b)。Kleisli category是函数,其中范畴返回类型(在这种情况下是b)是类型构造函数m的参数。当且仅当Kleisli m形成一个category时,m是一个Monad。

范畴返回类型到底是什么?Kleisli m似乎形成了一个范畴,其中对象是Haskell类型,从ab的箭头是从am b的函数,id = return(.) = (<=<)。是这样的吗,还是我搞混了不同的层次?

没错。对象都是类型,箭头是从类型ab的箭头,但它们不是简单的函数。它们的返回值在函数的返回值中带有额外的m

范畴论的术语真的有必要吗?也许,如果你将类型转换为图片,其中类型将是绘制图片的DNA(虽然是依赖类型*),然后你可以使用图片来编写程序,名字是在图标上方的小的Ruby字符。

0
0 Comments

为什么我们需要monads?

我们想要只使用函数进行编程。然后,我们遇到了一个问题。这是一个程序:

f(x) = 2 * x

g(x,y) = x / y

我们怎么知道要先执行哪个函数?我们怎么用只有函数的方式形成一个有序的函数序列(也就是一个程序)?

解决方法:组合函数。如果你想先执行g再执行f,只需写成f(g(x,y))。这样,“程序”也是一个函数:main = f(g(x,y))。但是...

更多问题:有些函数可能会失败(例如g(2,0),除以0)。函数式编程中没有“异常”(异常不是函数)。我们如何解决这个问题?

解决方法:让函数能够返回两种不同的值:而不是只有g : 实数,实数 -> 实数(从两个实数到一个实数的函数),我们可以允许g : 实数,实数 -> 实数 | 无(从两个实数到(实数或无)的函数)。

但是函数应该(为了更简单)只返回一个值。

解决方法:让我们创建一种新的数据类型来返回一个“装箱类型”,这个类型可以包含一个实数或者什么都没有。因此,我们可以有g : 实数,实数 -> Maybe 实数。但是...

现在f(g(x,y))会发生什么?f不准备处理Maybe 实数。而且,我们不想改变我们连接到g的每个函数来处理Maybe 实数。

解决方法:让我们有一个特殊的函数来“连接”/“组合”/“链接”函数。这样,我们可以在幕后适应一个函数的输出以供下一个函数使用。

在我们的例子中:g >>= f(将g连接/组合到f)。我们希望 >>= 获取g的输出,检查它,并在它是Nothing的情况下不调用f并返回Nothing;或者相反,提取封装的实数并将其输入f。(这个算法只是Maybe类型的 >>= 的实现)。还要注意, >>= 只需要针对每个“装箱类型”编写一次(不同的装箱,不同的适配算法)。

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

可以使用这种技术解决的一些重要问题是:

- 有一个每个函数都可以共享的全局状态的问题:解决方法是StateMonad。

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

总之,monads提供了一种解决纯函数编程中的一些“困难部分”的方法,而State Monad解决了其他所有问题。通过一组语法糖,我们获得了许多好处。实际上,它是以一种易接受的方式将有状态的东西添加到Haskell中。

关于monads的一些误解:monads与状态相关;monads与异常处理相关;在纯函数式编程语言中没有办法实现IO(使用monads);monads是明确的(反例是Either)。大部分答案都是关于“为什么我们需要functors?”。

“6. 2. 有一个组合器/链接器g >>= f来帮助连接g的输出到f的输入,这样我们根本不需要改变任何f。”这完全是错误的。在f(g(x,y))中,f可以产生任何东西。它可以是f:: 实数 -> 字符串。使用“monadic composition”,必须将其更改为产生Maybe 字符串,否则类型不匹配。此外, >>= 本身也不适合!!这种组合是由 => 进行的,而不是 >>=。参见与Carl的回答下面的讨论。

你的回答是正确的,因为在我看来,monads确实最好描述为关于“函数”的组合性(实际上是Kleisli箭头),但是具体的细节是什么类型放在哪里才使它们成为“monads”。你可以以各种方式连接这些“盒子”(例如Functor等)。这种特定的连接方式定义了“monad”。

对于学习Scala的人来说,Scala中的compose函数(>>=)的等效物通常是map方法。例如,Option.map将给定的函数应用于封装的数据(如果类型为Some),如果类型为None,则不进行任何操作。因此,我们可以使用g map f来代替g >>= f。Try monad的工作方式类似,但Failure会存储一个异常(在Failure上调用map会返回未修改的Failure)。

我希望几年前就看到这个答案。它比我读过的任何其他东西都要清楚得多。明确指出了问题,而且是与语言无关的。非常感谢!

很高兴能够帮到你。祝你好运。

让那些具有副作用的函数/操作符返回一个有标记/装箱的值(IO monad),而不是在语言中标记那些具有副作用的函数/操作符,让程序员知道并决定何时何地以及如何使用它们,比如a_dangerous_function_with_side_effect。那么我们是否仍然需要整个monad故事来实现具有副作用的可能性?

0