假期终于看明白了 Monad, 这个关卡卡了好几年了, 终于过了
我现在只能说初步了解到 Monad, 不够深入, 打算留一点笔记下来
现在回头看, 如果从前学习得法的话, 最快可能几天或者几周就搞定的
比如说有 Node.js 那样成熟的社区跟教程, 或者公司里有就有人教的话
此前在 Haskell 中文论坛问过, 知乎问过, 微博私信问过, 英文教程也看了
总体上 Monad 就成了越来越吸引我注意力的一个概念
Rich Hichey 的影响
我强烈推荐 Rich Hickey 的演讲, 因为我觉得他非常有智慧
https://github.com/matthiasn/talk-transcripts/tree/master/Hickey_Rich
虽然很多是我听不懂的, 但让我能从更高的层次去理解函数式编程为什么好
比如说变量的问题, 他讲了好多例子, 讲清楚数据会发生改变是不可靠的
还有保持简单对于系统的可靠性会带来多大改善, 为什么面向对象有问题
好吧大部分是我听不懂, 但感觉很有启发
过程式编程是直观的, 但也是很存在问题的, 特别是学了函数式编程再回头看
比如说 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 看起来像是这样:
haskell
printBack :: IO () printBack = do x <- getLine print x
然而 do
表达式并不是 Haskell 真实的代码, 这是一套语法糖
执行过程会被转化为 >>=
或者 >>
函数, 就像是下面这样:
haskell
printBack = getLine >>= (\x -> print x)
或者把函数放到前面来, 这样看得就更明确了:
haskell
printBack = (>>=) getLine (\x -> print x)
就是说 getLine
的执行结果, 还有后面的函数, 都是 >>=
这个函数的参数
后边的 (\x -> print x)
几乎就是个回调函数, 对, 类似 Callback Hell
所以 do
表达式完全就是个障眼法, Haskell 里大量使用回调的写法
同时因为回调, 所以 Haskell 不会暗地里并行执行参数里的操作, 而是有明确的先后顺序
只不过 Haskell 语法灵活, 大量嵌套函数, 看起来还能跟没事一样, 看文档:
http://en.wikibooks.org/wiki/Haskell/do_notation
总结一下就是纯函数编程, 过程式语言常用的招数都被废掉了
整个 Haskell 的函数都往数学函数逼近, 比如 f(x) = x^2 + 2*x + 1
另外, 加上了一套代数化的类型系统, 能够容纳编程需要的各种类型
IO 的特殊性
IO 要特别梳理一下, 因为相较于过程式语言, 这里的 IO 处理很奇怪
https://wiki.haskell.org/IO_inside
通常编程语言的做法, 比如说常用的读取文件吧, 调用, 返回字符串, 很好理解:
js
content = fs.readFileSync('filename', 'utf8') // Node.js
julia
content = readall("filename") # Julia
racket
(define content (file->string "filename")) ; Racket
但在纯函数语言当中有个大问题, 不是说好了参数一样, 返回值一样吗?
所以在 Haskell 当中 readFile
返回值并不是 String
, 而是加上了 IO
:
haskell
readFile :: IO String
结果就是处理文件内容时, 必需引入 Monad 的写法才行:
haskell
main = do content <- readFile "filename" putStr content
这个地方的 IO String
对 String
做了一层封装, 后面会遇到更多封装
代数类型系统
关于这一点, 我理解不准确, 但是举一些例子大概可以明白一些,
比如这是类似加法的方式定义新的类型:
haskell
data MySumType = Foo Bool | Bar Char
这是类似乘法的方式定义新的类型:
haskell
data MyProductType = Baz (Bool, Char)
这是以递归的方式定义新的类型:
haskell
data List a = Nil | Cons a (List a)
相比 C 或者 Go 通过 struct
定义新的类型, Haskell 显得很数学化
因为, 如果用在 Go 里定义类型是 A
或者 B
, 怎么定义? 还有递归?
Haskell 当中关于类型的概念, 整理在一起就是一些关键字:
-
data
,type
,newtype
用来定义类型或者类型的别名 -
instance
,class
用来实现类型之间的关联, 或者说定义实现类型类
具体看这篇文章概括的, Haskell 当中类型, 类型类的一些操作
http://joelburget.com/data-newtype-instance-class/
这里的概念跟面向对象方面的, "类", "接口", "继承"有很多相似之处
但是看下例子, 这在 Haskell 当中是怎样使用的,
比如有一个叫做 Functor
的 Typeclass, 很多的 Type 都属于这个 Typeclass:
haskell
class Functor f where fmap :: (a -> b) -> f a -> f b
比如 Maybe
Type 就是基于 Functor
实现, 首先用 data
定义 Maybe
Type:
haskell
data Maybe a = Just a | Nothing deriving (Eq, Ord)
然后通过 instance
在 Maybe
上实现 Functor
约定的函数 fmap
:
haskell
instance Functor Maybe where fmap f (Just x) = Just (f x) fmap f Nothing = Nothing
再比如 []
也是, 那么首先 []
大致可以这样定义
然后会有 []
上实现的 Functor
约定的 fmap
方法:
haskell
data [a] = [] | a : [a] -- 演示代码, 可能有遗漏 instance Functor [] where fmap = map
还有一个例子比如说 Tree
Type, 也可以同样实现 fmap
函数:
haskell
data 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
在大谈封装的问题,
就是值会被装进一个盒子当中, 然后从盒子外边用这三种手法去操作,
http://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_...
首先难以理解的是, 这层封装是什么? 为什么硬生生造出一个其他语言没有的概念?
考虑到 Haskell 当中大量的 Category Theory(范畴论)的术语, 好像高等代数学到过..
范畴论群论依然是我无法理解的数学语言, 所以这我依然不能解释, 究竟为什么有一层封装?
没有办法, 只能先看一下这一层封装在 Haskell 当中派上了什么用场?
Maybe
首先 Maybe
Type 实现了 Monad
, 那么看下 Maybe
典型的场景
注意下 Haskell 里 1 / 0
结果是 Infinity
,, 这个大概也不是我们想要的
下面是封装过的除法, 0
不能作为被除数, 所以有了个 Nothing
:
haskell
divide :: (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 给的那样, 增加了一套可能的流程:
http://fsharpforfunandprofit.com/posts/recipe-part2/
List
然后, List 也实现了 Monad
, 就来看下例子, 下面一段代码打印了什么结果
haskell
example :: [(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)]
其实是列表解析, 如果按花哨的写法写, 应该是这样:
haskell
[(a, b, c) | a <- [1,2], b <- [10,20], c <- [100,200]]
(->) r
后面的两个例子难以理解, 但是大概看一看, (->) r
也实现了 Functor
Typeclass(->) r
是什么? 是函数, 一个参数的函数. 注意 Haskell 里的函数参数都是一个...
haskell
instance Functor ((->) r) where fmap = (.)
函数作为 fmap
第二个参数, 最后效果居然是实现了函数复合! f . g
haskell
ghci> :t fmap (*3) (+100) fmap (*3) (+100) :: (Num a) => a -> a ghci> fmap (*3) (+100) 1 303
sequenceA
更复杂的是实现了 Applicative
Typeclass 的 sequenceA
函数
haskell
sequenceA :: (Applicative f) => [f a] -> f [a] sequenceA = foldr (liftA2 (:)) (pure [])
这个函数能把别的函数组合在一起用, 还能把 IO 操作组合在一起用,
而且这么密集的抽象... 3 个 IO 操作被排在一起了...
haskell
ghci> 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
表达式:
haskell
main :: IO () main = do putStrLn "What is your name: " name <- getLine putStrLn name
完全就是在模仿面向过程的编程, 或者说把面向过程里的一些东西重新造了一遍
当然我个人学到这里依然没明白设计思路, 但我知道是为什么要设计了
按照教程上的说法, 我可以整理一下几个函数之间的关联的递进:
首先, Haskell 通常的代码可以看作是对基础类型进行操作
比如我们有个函数 f
, 有个数据 x
, 通过 call
来调用:
haskell
Prelude> let call f x = f x Prelude> :t call call :: (a -> b) -> a -> b
那么 call
的类型声明就是 (a -> b) -> a -> b
Functor
haskell
class Functor f where fmap :: (a -> b) -> f a -> f b
接着是 Functor
, 注意类型声明变成的改变, 多了一层封装:
haskell
(a -> b) -> a -> b -- call (a -> b) -> f a -> f b -- fmap
Applicative
haskell
class (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 -- <*>
Monad
haskell
class 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
表达式语法糖的时候怎么写, 原始的代码:
http://stackoverflow.com/q/16964732/883571
haskell
do num <- numberNode x nt1 <- numberTree t1 nt2 <- numberTree t2 return (Node num nt1 nt2)
去掉了语法糖, 是一串 >>=
函数连接在一起, 一层层的缩进:
haskell
numberNode x >>= \num -> numberTree t1 >>= \nt1 -> numberTree t2 >>= \nt2 -> return (Node num nt1 nt2)
还有一个 Applicative
的写法
haskell
Node <$> numberNode x <*> numberTree t1 <*> numberTree t2
最后一个我得看老半天... 好吧, 总之, Haskell 就是提供了如此复杂的抽象print("x")
在过程式语言中仅仅是指令, 在 Haskell 中却被处理为纯函数的调用
Haskell 将纯函数用于高阶的函数的转化以及操作, 变成很强大的控制流
前面说了, 实际上只是作为参数, 跟 Node.js 使用深度的回调很相似
不过还记得 Railway Oriented 那张图吗, 跟 Node.js 对比一下:
js
fs.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 是什么回去把高等代数啃一遍...
不过呢, 过了这一关我还是不会写稍微复杂点的程序, 类型系统难点真挺多的