关于 Monad 的学习笔记

1,168 阅读11分钟
原文链接: segmentfault.com

假期终于看明白了 Monad, 这个关卡卡了好几年了, 终于过了
我现在只能说初步了解到 Monad, 不够深入, 打算留一点笔记下来

现在回头看, 如果从前学习得法的话, 最快可能几天或者几周就搞定的
比如说有 Node.js 那样成熟的社区跟教程, 或者公司里有就有人教的话
此前在 Haskell 中文论坛问过, 知乎问过, 微博私信问过, 英文教程也看了
总体上 Monad 就成了越来越吸引我注意力的一个概念

Rich Hichey 的影响

我强烈推荐 Rich Hickey 的演讲, 因为我觉得他非常有智慧
github.com/matthiasn/t…
虽然很多是我听不懂的, 但让我能从更高的层次去理解函数式编程为什么好
比如说变量的问题, 他讲了好多例子, 讲清楚数据会发生改变是不可靠的
还有保持简单对于系统的可靠性会带来多大改善, 为什么面向对象有问题
好吧大部分是我听不懂, 但感觉很有启发

过程式编程是直观的, 但也是很存在问题的, 特别是学了函数式编程再回头看
比如说 null 值的问题, 看似自然而然, 实际却是考虑不够严谨
还有语句(或者说指令)按顺序执行的问题, 也很自然, 实际却考虑不足
这类问题导致我们在编写代码过程中不断发现有特殊的情况需要回头考虑
诚然迎合了新人学习编程所需的方便, 可代价却是对代码控制流的操作不够强大

我不否认有丰富经验跟能力的程序员能用过程式代码写出极为可靠的程序
然而引入函数式编程强大的复合能力, 有可能让程序变得更加简短清晰
而且如同 Haskell 这样搭配类型系统, 能让难以理解的过程稍微变得直观一些
当然, 函数式编程所需的抽象能力真的不是为新手准备的, 这带来巨大的门槛

纯函数

要理解 Monad 首先要对纯函数有足够的认识, 我假设读者有了解过 Haskell
相比过程式语言当中的函数(或者叫方法, procedure), Haskell 当中有很多不同:

  • Haskell 当中能定义, 但不能赋值, 不能修改已经定义好的数据
  • 纯函数传入参数相同, 返回值就一定相同, 不会有例外
  • 读写文件这类 IO 操作, 也是有返回值的, 比如 IO String, IO ()
  • Haskell 当中没有语句用于实现过程, 而是用函数模拟出来过程

最后一点跟流行编程语言区别尤其大, 即便跟 Lisp 的设计也差别很大
Lisp 虽然号称"一切皆表达式", 但在函数体, 在 begin 当中语句照样用:

racket(define (print-back)
  (define x (read))
  (print x))

比如这样的一段 Racket, 转化成 Haskell 看起来像是这样:

haskellprintBack :: IO ()
printBack = do
  x <- getline="" print="" x="" <="" code="">

然而 do 表达式并不是 Haskell 真实的代码, 这是一套语法糖
执行过程会被转化为 >>= 或者 >> 函数, 就像是下面这样:

haskellprintBack = getLine >>= (\x -> print x)

或者把函数放到前面来, 这样看得就更明确了:

haskellprintBack = (>>=) getLine (\x -> print x)

就是说 getLine 的执行结果, 还有后面的函数, 都是 >>= 这个函数的参数
后边的 (\x -> print x) 几乎就是个回调函数, 对, 类似 Callback Hell
所以 do 表达式完全就是个障眼法, Haskell 里大量使用回调的写法
同时因为回调, 所以 Haskell 不会暗地里并行执行参数里的操作, 而是有明确的先后顺序
只不过 Haskell 语法灵活, 大量嵌套函数, 看起来还能跟没事一样, 看文档:
en.wikibooks.org/wiki/Haskel…

总结一下就是纯函数编程, 过程式语言常用的招数都被废掉了
整个 Haskell 的函数都往数学函数逼近, 比如 f(x) = x^2 + 2*x + 1
另外, 加上了一套代数化的类型系统, 能够容纳编程需要的各种类型

IO 的特殊性

IO 要特别梳理一下, 因为相较于过程式语言, 这里的 IO 处理很奇怪
wiki.haskell.org/IO_inside
通常编程语言的做法, 比如说常用的读取文件吧, 调用, 返回字符串, 很好理解:

jscontent = fs.readFileSync('filename', 'utf8') // Node.js
juliacontent = readall("filename") # Julia
racket(define content (file->string "filename")) ; Racket

但在纯函数语言当中有个大问题, 不是说好了参数一样, 返回值一样吗?
所以在 Haskell 当中 readFile 返回值并不是 String, 而是加上了 IO:

haskellreadFile :: IO String

结果就是处理文件内容时, 必需引入 Monad 的写法才行:

haskellmain = do
  content <- readfile="" "filename"="" putstr="" content="" <="" code="">

这个地方的 IO StringString 做了一层封装, 后面会遇到更多封装

代数类型系统

关于这一点, 我理解不准确, 但是举一些例子大概可以明白一些,
比如这是类似加法的方式定义新的类型:

haskelldata MySumType = Foo Bool | Bar Char

这是类似乘法的方式定义新的类型:

haskelldata MyProductType = Baz (Bool, Char)

这是以递归的方式定义新的类型:

haskelldata List a = Nil | Cons a (List a)

相比 C 或者 Go 通过 struct 定义新的类型, Haskell 显得很数学化
因为, 如果用在 Go 里定义类型是 A 或者 B, 怎么定义? 还有递归?

Haskell 当中关于类型的概念, 整理在一起就是一些关键字:

  • data, type, newtype 用来定义类型或者类型的别名
  • instance, class 用来实现类型之间的关联, 或者说定义实现类型类

具体看这篇文章概括的, Haskell 当中类型, 类型类的一些操作
joelburget.com/data-newtyp…

这里的概念跟面向对象方面的, "类", "接口", "继承"有很多相似之处
但是看下例子, 这在 Haskell 当中是怎样使用的,
比如有一个叫做 Functor 的 Typeclass, 很多的 Type 都属于这个 Typeclass:

haskellclass Functor f where  
    fmap :: (a -> b) -> f a -> f b  

比如 Maybe Type 就是基于 Functor 实现, 首先用 data 定义 Maybe Type:

haskelldata Maybe a = Just a | Nothing
    deriving (Eq, Ord)

然后通过 instanceMaybe 上实现 Functor 约定的函数 fmap:

haskellinstance Functor Maybe where
    fmap f (Just x) = Just (f x)
    fmap f Nothing = Nothing

再比如 [] 也是, 那么首先 [] 大致可以这样定义
然后会有 [] 上实现的 Functor 约定的 fmap 方法:

haskelldata [a] = [] | a : [a] -- 演示代码, 可能有遗漏

instance Functor [] where
    fmap = map

还有一个例子比如说 Tree Type, 也可以同样实现 fmap 函数:

haskelldata Tree a = Node a [Tree a]

instance Functor Tree where
    fmap f (Leaf x) = Leaf (f x)
    fmap f (Branch left right) = Branch (fmap f left) (fmap f right)

就是说, Haskell 当中的类型, 是通过这样一套写法定义出来的
同样, Monad 也是个 Typeclass, 也就可以按上边这样理解
单看写法, Go 的 interface 定义看起来相似, 至少语法上可以理解

Functor, Applicative, Monad

Haskell 首先是我们熟悉的 Value 还有 Function 的世界
Functor, Applicative, Monad 在大谈封装的问题,
就是值会被装进一个盒子当中, 然后从盒子外边用这三种手法去操作,
adit.io/posts/2013-…

首先难以理解的是, 这层封装是什么? 为什么硬生生造出一个其他语言没有的概念?
考虑到 Haskell 当中大量的 Category Theory(范畴论)的术语, 好像高等代数学到过..
范畴论群论依然是我无法理解的数学语言, 所以这我依然不能解释, 究竟为什么有一层封装?
没有办法, 只能先看一下这一层封装在 Haskell 当中派上了什么用场?

首先 Maybe Type 实现了 Monad, 那么看下 Maybe 典型的场景
注意下 Haskell 里 1 / 0 结果是 Infinity,, 这个大概也不是我们想要的
下面是封装过的除法, 0 不能作为被除数, 所以有了个 Nothing:

haskelldivide :: (Fractional a) => a -> a -> Maybe a
divide a 0 = Nothing
divide a b = Just $ a / b

考虑一下这样一个四则运算, 上面提示了, 一个情况 b 可能是 0, 除法有问题
但是作为例子, 很多 x / 0 在实际的编程当中我们会当成报错来处理,
好, 先认为报错, 那么整个程序就退出了

haskell((a / b) * c) + d

不过, 引入 Maybe Type 给出了一套不同的方案, 对应有报错和没有报错的情况:

haskell(Just 0.5 * Just 3) + Just 4
Just 1.5 + Just 4
Just 4.5
haskell((Just 1 / Just 0) * Just 3) + Just 4
(Nothing * Just 3) + Just 4
Nothing + Just 4
Nothing

没有报错, 一切正常. 如果有报错后边的结果都是 Nothing
这个就像 Railway Oriented Programming 给的那样, 增加了一套可能的流程:
fsharpforfunandprofit.com/posts/recip…

然后, List 也实现了 Monad, 就来看下例子, 下面一段代码打印了什么结果

haskellexample :: [(Int, Int, Int)]
example = do
  a <- [1,2]="" b="" <-="" [10,20]="" c="" [100,200]="" return="" (a,b,c)="" --="" [(1,10,100),(1,10,200),(1,20,100),(1,20,200),(2,10,100),(2,10,200),(2,20,100),(2,20,200)]="" <="" code="">

其实是列表解析, 如果按花哨的写法写, 应该是这样:

haskell[(a, b, c) | a <- [1,2],="" b="" <-="" [10,20],="" c="" [100,200]]="" <="" code="">

后面的两个例子难以理解, 但是大概看一看, (->) r 也实现了 Functor Typeclass
(->) r 是什么? 是函数, 一个参数的函数. 注意 Haskell 里的函数参数都是一个...

haskellinstance Functor ((->) r) where
    fmap = (.)

函数作为 fmap 第二个参数, 最后效果居然是实现了函数复合! f . g

haskellghci> :t fmap (*3) (+100)
fmap (*3) (+100) :: (Num a) => a -> a
ghci> fmap (*3) (+100) 1
303

更复杂的是实现了 Applicative Typeclass 的 sequenceA 函数

haskellsequenceA :: (Applicative f) => [f a] -> f [a]  
sequenceA = foldr (liftA2 (:)) (pure [])  

这个函数能把别的函数组合在一起用, 还能把 IO 操作组合在一起用,
而且这么密集的抽象... 3 个 IO 操作被排在一起了...

haskellghci> sequenceA [(>4),(<10),odd] 7="" [true,true,true]="" ghci=""> and $ sequenceA [(>4),(<10),odd] 7="" true="" ghci=""> sequenceA [getLine, getLine, getLine]  
heyh  
ho  
woo  
["heyh","ho","woo"]  

好, 回到上面的问题, Functor, Applicative, Monad 为什么有?
之前说函数是语言一切都是函数, 一些过程式的写法写不了了,
现在借助几个抽象, 好像又回来了, 而且花样还很多.. 连复合函数都构造了一遍
在这样的认识之下, 再看下 IO Monad 做了什么, 加上 do 表达式:

haskellmain :: IO ()
main = do putStrLn "What is your name: "
          name <- getline="" putstrln="" name="" <="" code="">

完全就是在模仿面向过程的编程, 或者说把面向过程里的一些东西重新造了一遍
当然我个人学到这里依然没明白设计思路, 但我知道是为什么要设计了
按照教程上的说法, 我可以整理一下几个函数之间的关联的递进:

首先, Haskell 通常的代码可以看作是对基础类型进行操作
比如我们有个函数 f, 有个数据 x, 通过 call 来调用:

haskellPrelude> let call f x = f x
Prelude> :t call
call :: (a -> b) -> a -> b

那么 call 的类型声明就是 (a -> b) -> a -> b

haskellclass Functor f where  
    fmap :: (a -> b) -> f a -> f b  

接着是 Functor, 注意类型声明变成的改变, 多了一层封装:

haskell(a -> b) -> a -> b -- call
(a -> b) -> f a -> f b -- fmap
haskellclass (Functor f) => Applicative f where  
    pure :: a -> f a  
    (<*>) :: f (a -> b) -> f a -> f b  

到了 Applicative 呢, 又在前面加上了一层封装:

haskell(a -> b) -> a -> b -- call
(a -> b) -> f a -> f b -- fmap
f (a -> b) -> f a -> f b  -- <*>
haskellclass Monad m where  
    return :: a -> m a  

    (>>=) :: m a -> (a -> m b) -> m b  

    (>>) :: m a -> m b -> m b  
    x >> y = x >>= \_ -> y  

    fail :: String -> m a  
    fail msg = error msg  

到了 Monad, 参数顺序跟具体的封装又做了改进(m 写成 f 方便对比):

haskell(a -> b) -> a -> b -- call
(a -> b) -> f a -> f b -- fmap
f (a -> b) -> f a -> f b  -- (<*>)
f a -> (a -> f b) -> f b  -- (>>=)

大致上有个规律, 就是调用函数封装 f, 手段都是为了函数能超越封装使用
而且 f 会是什么? 有 Maybe [] ((->) r) IO, 还有其他很多
带来效果是什么? 有处理报错, 列表解析, 符合函数, 批量的 IO, 以及其他
Haskell 用纯函数补上了操作控制流和 IO 的功能, Monad 是其中一个手段

Monad 的写法

然后看下 Monad 去掉 do 表达式语法糖的时候怎么写, 原始的代码:
stackoverflow.com/q/16964732/…

haskelldo num <- numbernode="" x="" nt1="" <-="" numbertree="" t1="" nt2="" t2="" return="" (node="" num="" nt2)="" <="" code="">

去掉了语法糖, 是一串 >>= 函数连接在一起, 一层层的缩进:

haskellnumberNode x >>= \num ->
  numberTree t1 >>= \nt1 ->
    numberTree t2 >>= \nt2 ->
      return (Node num nt1 nt2)

还有一个 Applicative 的写法

haskellNode <$> numberNode x <*> numberTree t1 <*> numberTree t2

最后一个我得看老半天... 好吧, 总之, Haskell 就是提供了如此复杂的抽象
print("x") 在过程式语言中仅仅是指令, 在 Haskell 中却被处理为纯函数的调用
Haskell 将纯函数用于高阶的函数的转化以及操作, 变成很强大的控制流
前面说了, 实际上只是作为参数, 跟 Node.js 使用深度的回调很相似

不过还记得 Railway Oriented 那张图吗, 跟 Node.js 对比一下:

jsfs.readFile("filename", "utf8", function(err, content) {
  if (err) { throw err }
  console.log(content)
})

注意 err 的处理, Haskell 当中可没有写 err 而是在 >>= 内部处理掉了
而且 Haskell 也不会执行到这里就吐出返回值, 而是等全部执行完再返回
上边我用过 Callback Hell 打比方, 不过除了写法相似, 其他方面差别不小

总结

好了我不是在写 Monad 教程, 我也没全弄明白, 但是上边记录了我理解的思路:

  • 可变数据, 副作用, 种种不确定性是编程当中混乱的来源
  • 纯函数相对于过程式代码的特殊性, 决定了它不能简单使用语句或者指令直接写程序
  • Haskell 当中的 IO 做了封装, 使之融合到纯函数当中来
  • Monad 是 Haskell 当中的 Typeclass, 所以我先不去管数学中的定义
  • 什么是封装, 为什么 Haskell 中函数和数据会被封装
  • Monad 起到了怎样的作用, 怎样理解它的作用

我之前一直在想 Monad 会是数学结构当中某种强大的概念, 群论如何如何
但是回头看, 这更像是人为定义出来的方便编程语言使用的几个 Typeclass 而已
当新的数据类型被需要, 还可以自己定义, 用高阶函数玩转...
总之我不必为了弄懂 Monad 是什么回去把高等代数啃一遍...

不过呢, 过了这一关我还是不会写稍微复杂点的程序, 类型系统难点真挺多的