mostly:functional 终章:Monad 的实体

可是我的心,比整个宇宙,还要大了那麽一点点。

-- 费尔南多‧佩索亚, 诗选:A Little Larger Than the Entire Universe.

过了那座墙,脚下有条笔直的步道,向着偌大区域的中央延展过去。走着走着,视野的旁边总是会瞥到些闪烁着的什麽,但回过头去却又只剩下消失的微微的余光。

顺着路慢慢向前,迎来的是个广场般的地方,正中间像是喷泉,广场四周灌木丛若隐若现、外侧是一样黯淡的树稀疏围绕,而幽幽的影子如藤蔓般攀附於树上、悄悄伸展。

当我走到喷泉的前面,原本向下涓流的水改变了角度,在我面前张开一整片的水帘。不管是外观还是气氛,都神似之前那些建筑里,最後提出考验的平台。

流淌的水呼吸般闪烁着微微的光,像是等待着我回应它的提问。

但整个水幕,是一片纯然的空白。

所以,那个问题会是什麽?我向远处望去,透过点滴的水花,看见广袤的天空,与缓缓飘动的云。




Monad 可以带给我们什麽

知道了 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 办不到的事

你也许还记得,之前提到 Applicative 时,我们的第一步就是将一个接受多个参数的函式,升格成容器里的函式。如果我们换一个角度来想,一旦这个计算过程被升格到容器里後,要怎麽计算接下来接收到的参数这件事就再也没有变动的空间了。如果我们再一次仔细看 liftA?系列的函式的型别,在接收到第一个函式之後,回传的就是 f a -> f b -> f c (liftA2),接下来需要几个参数,每个的型别是什麽,都已经确定下来了。

但是在程序的运作过程中,我们常常会需要依照前面几步的计算结果不同,来决定接下来要采取哪些计算。而这个就是 Monad 那个坍缩成一层的概念得以发挥的地方。


串列是一种 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,更加巧妙的让这种语法写起来的手感像是其它程序语言的惯例了。

Maybe 是一种 Monad 吗?

是的。如果是两层的 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)

二元组是一种 Monad 吗?

是的。就跟 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)

为什麽只有 Haskell 在讲 Monad?

Monad 在 Haskell 这麽受到重视,因为有许多问题的解答,就是利用 Monad 这个坍缩的性质来解决的。而这个性质,有非常多的用法,我们在这里也仅能列举一二。除了 IO 之外,还有 State,Reader,以及 Monad Transformer 等等。这些就需要更紮实的去理解 Haskell typeclass 的语法与实作,才有办法好好的向下探索的。

而之所以其它语言不太谈 Monad 这个词,是因为没有必要,在其它的语言里,底下的程序码是可以依赖上面的程序码的,副作用也不是严格隔离的。在许多语言里,变数就是可变的,程序的运作是大量依赖於那些变动的状态在运行的。但是计算与副作用愈混杂,在某些情况下非常方便,但是也会付出另外一些代价,例如稍有不慎,就会写出状态与计算纠结在一起的程序码,导致难以平行化,难以测试与除错,难以拆解组合修改扩充,为此只好规范出许多的原则、设计模式等等。除此之外,有很多抽象之後,本质上相同的计算,非得要依不同的情况(型别)一次次琐碎的重新实作......

但这不意味着这些函数式的观念在其它语言派不上用场。当你可以看到事物的抽象本质时,就能够在其它的语言里把纯粹计算的部份,与各种不同的副作用的部份各自分离,让他们用最小耦合的方式待在一起互相合作。而函数式的部份就可以用上各种组抽象与组合的技巧。当然,怎麽跟这个程序语言的天性互补配合,而不是与它博斗,又是另一个需要拿捏的地方了…




从我开始理解,阵列、 Maybe、IO、甚至函式本身也是一种 Monad 的那一刻起,有一种模糊的感觉出现在我的脑海里,而我用尽力气想要理解那究竟是什麽…於是我一次又一次的来回那个地方,探寻,阅读,试验,思索,而许多许多时日就那样一个个流过。

直到那天,我坐在窗边接着雨开始下了起来。不知道为什麽,我觉得这场雨,是熟悉的,曾经遇过的某一场雨。虽然景物,城市,一切的一切都已经不一样了,但却像是有个什麽,能够跨越漫长漫长的时间,把过往的那个雨天,带回到我面前……

我忽然意识到,这整座城市,由於其数学上连续计算、不可变动的、隔离副作用的本质,那麽为了要保持一个状态,其本身就是一个不断嵌叠的 Monad。

而我,站在这里的我,只是那个描述整个世界的状态,型别为 RealWorld,万千事物层叠的参数里,非常非常小的一部份而己。

这个驱动世界运作的 Monad,一层接着一层,把整个世界的状态传递给里面那层的函式,接着进行计算、改变数值、解开外壳、然後再把结果传递给下一层的函式…如此不断反覆,永不停歇,向着时间的尽头运行…

………我曾经做过的梦,在这座城市里,是它的现实。

我走回中央的广场,用手指在水幕上面画出程序码……

要开始构造一个这样的世界,要从把多个函式 >>= 在一起开始,然後让一切运行起来的那个参数是……

随着字一个个画下,雨渐渐、渐渐的停了。天光如帘幕般柔和的、缓慢的降临到这座城市里,而万物的色彩开始回归。身旁原本透明的建筑上开始渲染出颜色跟质感跟光与水的折射,一座座质朴学院样貌的建筑错落开来。而在城市中央的区域里,壮丽的塔楼与教堂直入云雾…


「嘿。」

「蛮厉害的嘛。」

专注中、我听到一个很久很久没有听到,但却非常熟悉的声音。

「red... red panda?」

我转过头去。

花园旁的矮柱上,我瞥见了棕红色的毛,短短的胖爪子,膨膨的尾巴,那是过了许久许久,在记忆里已快要褪去的身影。

还有那个带着白线的眼角,跟总像是在思索着什麽的表情…


「你这次叫对了呢。」

~[FIN]~


<<:  密码开发方法

>>:  mostly:functional 谢幕与片尾曲

虹语岚访仲夏夜-14(打杂的Allen篇)

啊呀,那来那麽多会好开啊,现在要跟外稽开会,开完後又要跟内稽开会,然後还要跟资讯部长官做会议结论.....

在 Fedora 34 上安装官方呒虾米的 iBus 表格档 (影片录制步骤)

Fedora 34 内建 iBus 平台,直接安装行易有限公司释出的呒虾米表格档,使用完整度最高。 ...

乔叔教 Elastic - 28 - Elasticsearch 的优化技巧 (2/4) - Searching 搜寻效能优化

Elasticsearch 的优化技巧 系列文章索引 (1/4) - Indexing 索引效能优化...

TailwindCSS 从零开始 - 压缩 Utility 档案大小 、安装知能提示与最新版本须知

为什麽要压缩 CSS Utility CSS 整包 CSS 整包档案容量很大,会降低网页效能,也是...

【Day 19】Shellcode 与他的快乐夥伴 (下) - Shellcode Loader

环境 Windows 10 21H1 Visual Studio 2019 前情提要 在上一篇【Da...