可是我的心,比整个宇宙,还要大了那麽一点点。
-- 费尔南多‧佩索亚, 诗选:A Little Larger Than the Entire Universe.
过了那座墙,脚下有条笔直的步道,向着偌大区域的中央延展过去。走着走着,视野的旁边总是会瞥到些闪烁着的什麽,但回过头去却又只剩下消失的微微的余光。
顺着路慢慢向前,迎来的是个广场般的地方,正中间像是喷泉,广场四周灌木丛若隐若现、外侧是一样黯淡的树稀疏围绕,而幽幽的影子如藤蔓般攀附於树上、悄悄伸展。
当我走到喷泉的前面,原本向下涓流的水改变了角度,在我面前张开一整片的水帘。不管是外观还是气氛,都神似之前那些建筑里,最後提出考验的平台。
流淌的水呼吸般闪烁着微微的光,像是等待着我回应它的提问。
但整个水幕,是一片纯然的空白。
所以,那个问题会是什麽?我向远处望去,透过点滴的水花,看见广袤的天空,与缓缓飘动的云。
知道了 Monad 其实只是个能坍缩成一层的容器这件事,似乎比不懂的时候还空虚。当然,这只是个比较好的起点而己。
你有没有发现,我们一路在 Haskell 里所示范的函式都很短,没有超过一行的。回想一下之前在其它的程序语言,例如在 Elixir 里,我们会写这样的函式:
# Elixir 语法
def foo(x) do
a = [x, x + 1, x + 2]
b = a |> Enum.map(fn i -> i * 2 end) # 注意这边用到了上面的 a
a ++ b ++ [10] # 再用到了上面的 a 跟 b
end
foo(1)
但我们到目前为止都没有看过在 Haskell 里有类似的写法:
-- 想像的 Haskell 语法
foo x =
a = [x, x + 1, x + 2]
b = fmap (*2) a -- 想用上面计算出来的 a
return a ++ b ++ [10] -- 再用到上面的 a 跟 b
因为办不到。
在 Haskell 里,函式的内容必须是一行连续的计算。不是两行、也不是三行。可以使用之前提过的 pattern matching 拆成多个实作,也可以用一些手段把中间过程暂存起来,但函式的本体,就跟数学的函式一样,只能是一行连续的计算。
IO
而刚刚那个还不是全部,更难搞 (或说纯粹,端看你的立场) 的一点,是 Hakell 的函式要将纯粹的计算与有副作用的部份区分开来。所谓的副作用是什麽呢?例如说取得使用者输入、输出到萤幕上、乱数、与资料库沟通等等,这种不是纯粹的计算,在 Haskell 中,都必须在特别的界限里处理。而这条界限,就叫 IO。
当我们想要在其它语言,例如 Elixir 中,读取使用者的输入,并印到画面上时,我们可以这样写:
# Elixir 语法
IO.gets(:stdin) |> IO.puts
但在 Haskell 中,是没办法这样做的。这件事从函式的型别上就看得出来,让我们一步一步来看。
-- 想像中的 Haskell 语法
putStrLn . getLine -- 会出错
putStrLn
是用来把结果输出到画面上的函式,而取得使用者输入,则是用 getLine
这个函式。让我们来看一下它们的型别:
-- Haskell 语法
putStrLn :: String -> IO ()
getLine :: IO String
putStrLn
是个接受一个字串,回传一个 IO
型别的函式。但 getLine
是回传一个包在 IO
容器里的 String
的函式。所以如果像上一小节那样直接用 .
来函式组合,会因为输出与输入的型别不符而编译失败。
既然 getLine
会拿到包在 IO
容器里的字串,而 putStrLn
需要的是一个字串,那我们可以用 fmap
吗?
-- Haskell 语法
putStrLn <$> getLine :: IO (IO ())
这麽一来是可以编译成功,但是没有办法顺利印出来。因为 putStrLn
拿到字串後,会回传 IO
,因此我们拿到的是包在 IO
容器里的 IO
容器。
这就是我们需要 monad 坍缩性质的时候了。
-- Haskell 语法
(>>=) :: Monad m => m a -> (a -> m b) -> m b
(>>=) @ IO :: IO a -> (a -> IO b) -> IO b
因此写成这样才能做到我们想做的事:
-- Haskell 语法
getLine >>= putStrLn
你也许还记得,之前提到 Applicative 时,我们的第一步就是将一个接受多个参数的函式,升格成容器里的函式。如果我们换一个角度来想,一旦这个计算过程被升格到容器里後,要怎麽计算接下来接收到的参数这件事就再也没有变动的空间了。如果我们再一次仔细看 liftA?系列的函式的型别,在接收到第一个函式之後,回传的就是 f a -> f b -> f c
(liftA2),接下来需要几个参数,每个的型别是什麽,都已经确定下来了。
但是在程序的运作过程中,我们常常会需要依照前面几步的计算结果不同,来决定接下来要采取哪些计算。而这个就是 Monad 那个坍缩成一层的概念得以发挥的地方。
是的。正如我们之前所示范的,串列是一种 Monad,其 >>=
就是 concat $ map
。不过就跟 functor 的情况一样,如果你只是想讨论串列的话,那可以单纯的用 concat $ map
(或其它语言的 flat_map
) 就足够了*。
注*:顺带一提,以 functional reactive 着名的 Rx 框架也是用 flatMap
的概念来谈坍缩两层的 observable 结构为一的。
来举个能展示 Monad 的用处的例子吧。假如我们想要知道丢两个骰子所有可能的结果,而且我们把 (1, 2)
与 (2, 1)
视为同一种时,我们可以这样写:
-- Haskell 语法
[1..6] >>= \x ->
[x..6] >>= \y -> -- 注意这行,我们用了 x..6 排除掉前大後小的结果
return (x, y) -- 这里用 return 函式,将结果装回串列里,才会符合型别要求
-- => [(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(2,2),(2,3),(2,4),(2,5),(2,6),(3,3),(3,4),(3,5),(3,6),(4,4),(4,5),(4,6),(5,5),(5,6),(6,6)]
注意在第一次 bind
中传入的函式 \x -> [x..6]
的部份,我们依赖了之前计算的结果,来进行接下来的计算。而在最後做成元组时,我们用 return
这个函式,将元组再包一层串列的容器外壳。
而这个是串列的 Monad 实作:
-- Haskell 语法
instance Monad [] where
return = []
xs >>= f = concat $ map f xs
do
语法糖在上面的 Monad 示范中,我们用了两个 >>=
函式来使用 Monad。而在 Haskell 中,有一种称为 do 语法糖的东西,是让这种连续的 Monad bind 更容易撰写,看起来也有一点点像其它程序语言里的指令式语法 (imperative),但请记得,它其实就是个 Monad:
-- Haskell 语法
rolls :: [(Integer, Integer)]
-- 把这个
rolls =
[1..6] >>= \x ->
[x..6] >>= \y ->
return (x, y)
-- 改写成这样
rolls = do
x <- [1..6]
y <- [x..6]
return (x, y)
这样子就能看得出来,在 monad 中,把多套上一层容器的壳的函式叫做 return
,更加巧妙的让这种语法写起来的手感像是其它程序语言的惯例了。
是的。如果是两层的 Just
的话,那就只留一层,除此之外都是 Nothing
。
-- Haskell 语法
instance Monad Maybe where
return x = Just x
Nothing >>= f = Nothing
Just x >>= f = f x
-- 示范
Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y))) -- => Just "3!"
-- do syntax
result = do
x <- Just 3
y <- Just "!"
return (show x ++ y)
是的。就跟 Applicative 一样,二元组要成为 Monad 的条件,前面的元素也要是个 Monoid。这麽一来,当两层的壳坍缩的时候,会用 mappend
将前面的元素并在一起:
-- Haskell 语法
instance Monoid a => Monad ((,) a) where
return b = (mempty, b)
(a, c) >>= f = let (b, c') = f c in (a <> b, c')
-- 示范
("hello", 2) >>= \x -> ("world", (x * 10)) -- => ("helloworld", 20)
Monad 在 Haskell 这麽受到重视,因为有许多问题的解答,就是利用 Monad 这个坍缩的性质来解决的。而这个性质,有非常多的用法,我们在这里也仅能列举一二。除了 IO 之外,还有 State,Reader,以及 Monad Transformer 等等。这些就需要更紮实的去理解 Haskell typeclass 的语法与实作,才有办法好好的向下探索的。
而之所以其它语言不太谈 Monad 这个词,是因为没有必要,在其它的语言里,底下的程序码是可以依赖上面的程序码的,副作用也不是严格隔离的。在许多语言里,变数就是可变的,程序的运作是大量依赖於那些变动的状态在运行的。但是计算与副作用愈混杂,在某些情况下非常方便,但是也会付出另外一些代价,例如稍有不慎,就会写出状态与计算纠结在一起的程序码,导致难以平行化,难以测试与除错,难以拆解组合修改扩充,为此只好规范出许多的原则、设计模式等等。除此之外,有很多抽象之後,本质上相同的计算,非得要依不同的情况(型别)一次次琐碎的重新实作......
但这不意味着这些函数式的观念在其它语言派不上用场。当你可以看到事物的抽象本质时,就能够在其它的语言里把纯粹计算的部份,与各种不同的副作用的部份各自分离,让他们用最小耦合的方式待在一起互相合作。而函数式的部份就可以用上各种组抽象与组合的技巧。当然,怎麽跟这个程序语言的天性互补配合,而不是与它博斗,又是另一个需要拿捏的地方了…
从我开始理解,阵列、 Maybe、IO、甚至函式本身也是一种 Monad 的那一刻起,有一种模糊的感觉出现在我的脑海里,而我用尽力气想要理解那究竟是什麽…於是我一次又一次的来回那个地方,探寻,阅读,试验,思索,而许多许多时日就那样一个个流过。
直到那天,我坐在窗边接着雨开始下了起来。不知道为什麽,我觉得这场雨,是熟悉的,曾经遇过的某一场雨。虽然景物,城市,一切的一切都已经不一样了,但却像是有个什麽,能够跨越漫长漫长的时间,把过往的那个雨天,带回到我面前……
我忽然意识到,这整座城市,由於其数学上连续计算、不可变动的、隔离副作用的本质,那麽为了要保持一个状态,其本身就是一个不断嵌叠的 Monad。
而我,站在这里的我,只是那个描述整个世界的状态,型别为 RealWorld
,万千事物层叠的参数里,非常非常小的一部份而己。
这个驱动世界运作的 Monad,一层接着一层,把整个世界的状态传递给里面那层的函式,接着进行计算、改变数值、解开外壳、然後再把结果传递给下一层的函式…如此不断反覆,永不停歇,向着时间的尽头运行…
………我曾经做过的梦,在这座城市里,是它的现实。
我走回中央的广场,用手指在水幕上面画出程序码……
要开始构造一个这样的世界,要从把多个函式 >>=
在一起开始,然後让一切运行起来的那个参数是……
随着字一个个画下,雨渐渐、渐渐的停了。天光如帘幕般柔和的、缓慢的降临到这座城市里,而万物的色彩开始回归。身旁原本透明的建筑上开始渲染出颜色跟质感跟光与水的折射,一座座质朴学院样貌的建筑错落开来。而在城市中央的区域里,壮丽的塔楼与教堂直入云雾…
「嘿。」
「蛮厉害的嘛。」
专注中、我听到一个很久很久没有听到,但却非常熟悉的声音。
「red... red panda?」
我转过头去。
花园旁的矮柱上,我瞥见了棕红色的毛,短短的胖爪子,膨膨的尾巴,那是过了许久许久,在记忆里已快要褪去的身影。
还有那个带着白线的眼角,跟总像是在思索着什麽的表情…
「你这次叫对了呢。」
~[FIN]~
啊呀,那来那麽多会好开啊,现在要跟外稽开会,开完後又要跟内稽开会,然後还要跟资讯部长官做会议结论.....
Fedora 34 内建 iBus 平台,直接安装行易有限公司释出的呒虾米表格档,使用完整度最高。 ...
Elasticsearch 的优化技巧 系列文章索引 (1/4) - Indexing 索引效能优化...
为什麽要压缩 CSS Utility CSS 整包 CSS 整包档案容量很大,会降低网页效能,也是...
环境 Windows 10 21H1 Visual Studio 2019 前情提要 在上一篇【Da...