轻松理解 Monad(Lua 描述)
轻松理解 Monad(Lua 描述)
阿楠 2021-11-15
引子
这些年看过不少介绍 Monad 的文章,不过前段时间看到的一篇感觉是所有文章里面最容易理解的。然而最近我试图回想里面的内容,发现还是只记得一些残破的碎片。于是准备再温习一遍。想了想,干脆这次尝试一下根据自己的理解来转述一下,这样印象更深一些,毕竟教才是更好的学的方式~
顺便也改用我目前的主力开发语言 Lua 来进行描述。之前看过 Haskell、OCaml、Kotlin、Javascript 等版本的 Monad 介绍了,我也提供一个 Lua 版本吧。刚好我在知乎上开的专栏 “Lua 实验室” https://www.zhihu.com/column/lua-lab 也好久没有更新过了,可以发上去充个数~ :P
我第一次接触 Monad 这个概念,是在学习 Haskell 的时候。确实,Monad 在 Haskell 里更为常见,因为 Haskell 中仅允许纯函数。在纯函数的环境下,很多操作都要依赖 Monad 来实现。
大部分语言里并没有这种限制,但是自觉的尽量使用纯函数常常也是很有用的,有利于程序的优化和理解,使得分析程序查找问题更容易。
Monad 通常被介绍为用来将 I/O 操作等副作用引入这种纯函数模型。但其实这只是 Monad 各种应用中的一种。我发现通常以这种方式来介绍 Monad 只会让这个并不特别复杂的概念更难理解,其实我们很有可能已经在编程中以自己的方式“发明”过很多次 Monad 了,只是我们并没有自觉。
那么 Monad 到底是什么呢?Monad 其实是一个范畴论里的概念,但如果告诉你“一个 Monad 就是自函子范畴上的一个幺半群”,你多半会跟我一样一脸懵逼吧?
这篇文章会通过一个更容易理解的方式来介绍 Monad。
函数的组合 composition
很多时候,我们可以将程序理解为对数据进行的一系列操作和变换。而这些操作变换,通常以函数这种抽象方式和函数调用的形式来进行。对函数的组合,自然也就成了一个很强力的抽象工具。
我们可以实现这样的一个 compose
函数来将两个函数组合起来:
1 | local inc = function(v) return v + 1 end |
通过观察可以发现,要想成功组合两个函数 f
和 g
的话,需要满足这样的条件:函数 g
的返回值能够与函数 f
的输入参数匹配。
带调试信息的函数的组合
现在,如果我们要确认一个函数被调用的事实,有一个方式是在函数中加入打印信息。
1 | local inc = function(v) |
但这种打印是一种副作用,如果我们想要避免副作用,保持函数的纯函数性质的话,可以将这个信息作为函数返回值的一部分。
1 | local inc = function(v) |
但是你会发现,这样的话这两个函数就无法再通过 compose
来组合了。因为返回值变成了更复杂的类型,而函数要求一个简单的数字作为输入参数,二者不再匹配。
1 | dec(42) -- { 41, 'dec was called.' } |
当然,Lua 具有返回多个值,和自动调整传入参数数量与接受参数数量相匹配的能力。通过这两点我们也可以解决这个问题。不过,为了更通用的情况来考虑,我们暂时先忽律这一点。很多时候我们并不只是单纯的附加一个返回值,而是对返回值做更复杂的包装,只是为了便于展示和理解,我们以这种简单的情况来进行演示说明。
退一步说,假设我们通过返回多个值的方式来让程序不报错:
1 | local inc = function(v) |
但可以看到,这里仍然存在一个问题:dec
函数被调用的信息丢失了,只有 inc
函数被调用的信息留了下来。
我们的初衷是能够方便的确认各个函数是否被调用,所以这里更合理的结果是将所有过程中产生的信息都保留下来:例如
1 | compose(inc, dec)(42) -- { 42, 'dec wa called. inc was called.' } |
而我们无法通过之前实现的简单的 compose
函数来实现这一点。因为现在 inc
和 dec
函数返回一个包含了运算结果和调试信息的复合值,而只能接受和处理一个简单的数字。
我们需要再多用一点代码来粘合这些“可调试”的函数:将他们的返回值“打开”,再以一种有意义的方式将他们粘合回来。
1 | local composeDebuggable = function(f, g) |
这个 composeDebuggable
函数将两个 接受数字作为参数返回数字和调试信息 的函数组合起来,返回的函数同样是 接受数字作为参数返回数字和调试信息 的。也就是说这个返回的组合后的函数也可以被 composeDebuggable
函数所组合。
bind 函数
为了方便描述类似“接受数字作为参数返回数字和调试信息”的函数类型信息,我们可以借用 Haskell 里的语法。例如,我们后来实现的带调试信息的版本的 inc
函数类型为:
1 | inc :: Number -> (Number, String) |
对比一下,原始的 inc
函数的类型为:
1 | inc :: Number -> Number |
原始的 inc
函数的接受参数类型和返回值类型相同,这种对称性让它可以被轻松的通过 compose
进行组合,而不需要写特殊的 composeDebuggable
函数来处理。
所以如果我们将调试版本的 inc
函数也实现为一个参数和返回值类型相同的对称形式,就也可以轻松的通过原始的 compose
函数来进行组合了:
1 | inc :: (Number, String) -> (Number, String) |
我们可以手工实现一版这样的接受 (Number, String)
作为参数的 inc
和 dec
函数,这样他们就可以轻松的被组合。但是这并不是一个可扩展的好办法,这样你需要为每个参与组合的函数都写一个接受 (Number, String)
的版本。
还是让每个函数都只是做好自己的本职工作吧,我们可以实现一个工具函数 bind
来将一个普通函数转化成这样的一个“可调试”版本:
1 | -- bind :: (Number -> (Number, String)) -> ((Number, String) -> (Number, String)) |
bind
函数的作用是接受一个 Number -> (Number, String)
类型的函数,将它转化为一个 (Number, String) -> (Number, String)
类型的函数。
这样我们就可以轻松的通过 bind
和 compose
函数来组合“可调试”版本的 inc
和 dec
了:
1 | local f = compose(bind(inc), bind(dec)) |
unit 函数
如前面的例子所示,我们可以通过 bind
和 compose
函数来对“可调试”版本的函数进行组合,但是对组合后的 f
函数,我们需要传递 {42, ''}
作为参数。但其实我们在意的只是 42
这个参数而已,能不能只传这一个参数呢?
我们可以实现一个工具函数 unit
来将 42
这样的简单参数,封装打包成一个最终执行需要的 {42, ''}
形式:
1 | -- unit :: Number -> (Number, String) |
lift 函数
使用 unit
函数,我们也可以将一个返回简单值的函数,转换为一个“可调试”的函数:
1 | -- neg :: Number -> Number |
这种将一个普通函数转换为“可调试”版本函数的过程,可以被进一步的抽象为 lift
函数:
1 | -- lift :: (Number -> Number) -> (Number -> (Number, String)) |
或者,用一种更紧凑的写法:
1 | local lift = function(f) return compose(unit, f) end |
The writer monad
通过 lift
, bind
, unit
这些函数,我们就可以方便的将一个非“可调试”版本的函数 neg
加入到“可调试”函数的圈子里来,让他可以跟 inc
, dec
这些“可调试”的函数一起愉快的玩耍:
1 | local neg = function(x) |
通过 lift
, bind
, unit
这些函数(严格的说只是 bind
和 unit
),其实我们就定义了一个 monad:
lift
:将一个“简单”函数转换为一个“可调试”的函数。bind
:将一个“可调试”的函数转换为一个“可组合”的函数。unit
:将一个“简单”值封装打包为可以传入“可组合”函数的“复杂”值。
在 Haskell 中,刚实现的这个 monad 被称为 Writer monad:在“简单”值上附加一个用做日志记录的值。
通过与另一个例子进行类比,我们可以更清晰的看到这个模式中的通用逻辑:
另一个例子 The list monad
很多时候我们设计函数时会纠结这样一个问题:函数究竟是应该接受单个值作为参数,还是接受一系列的多个值的 list 作为参数,或者实现一个接受单个值的版本同时再提供一个接受一个 list 的版本。区别通常其实就只在于加入一个 for
循环而已。
当需要这样处理的函数多了之后,你会发现系统里存在很多个这样的 for
,好像也很啰嗦,如果能有一个抽象来减少这样的重复代码就好了。而且经常这样的区别也影响到函数是否可以被轻松的组合。
举个例子,我们现在有一个树结构,然后通过 getChildren
函数可以获得其中一个节点的子节点列表:
1 | -- getChildren :: Node -> [Node] |
现在我们想要获得一个节点的孙子节点,是不是可以通过这样组合一下来实现呢:
1 | local getGrandChildren = compose(getChildren, getChildren) |
但是 getChildren
函数的参数和返回值并不是对称的,没有办法通过 compose
进行简单的组合。是不是觉得哪里有点似曾相识的感觉了? :P
是的,我们可以实现一组 unit
和 bind
工具函数来帮助我们:
1 | -- unit :: a -> [a] |
这样我们就可以轻松的实现和使用 getGrandChildren 了:
1 | local getGrandChildren = compose(bind(getChildren), bind(getChildren)) |
是的,这样我们又实现了一种 monad,在 Haskell 中它被称为 List monad。
Monad
所以,Monad 到底是什么呢?它是由一个类型和定义在这个类型上的一些操作(unit
和 bind
)组合成的一个概念。在 Haskell 中它是一个 Type class,在很多面向对象的语言中它类似于一个 Interface。
我们也可以把它当成一种设计的模式来应用:当你有一系列函数,他们接受一种类型(a)作为参数,返回另一种类型(b)作为返回值,你可以定义对应的 bind
和 unit
函数来帮助他们进行组合:
bind
将函数转换为参数与返回值相同(都为b)的函数,让它可以被组合。unit
将简单值(a)打包为可以被组合函数接受的值(b)。
我自己也经常将 monad 理解为一种将简单类型打包为带有更多信息的复杂类型的方式,例如 Maybe monad, List monad, Writer monad 都是如此。
当然这并不是 monad 的严格定义,但是相信以这种方式会让 monad 更容易被理解和应用。
在 Haskell 中,unit
被称为 return
,bind
被实现为一个操作符 >>=
。
Haskell 中的 bind
也就是 >>=
的工作方式跟本文中的稍有不同,它并不是将函数转变为可组合的形式,而是接受一个打包后的值和一个“可调试”的函数,它将简单值从打包后的值中提取出来,并对它调用目标函数,最后再以一种有意义的方式将结果组合起来。例如:
1 | -- bind :: (Number, String) -> (Number -> (Number, String)) -> (Number, String) |
以这种方式定义,可以更容易将一连串的调用串联起来,例如可以定义一个 pipe
函数:
1 | -- pipe :: (Number, String) -> [Number -> (Number, String)] -> (Number, String) |
具体可以参考相关阅读中提到的这个作者的下一篇文章。
当理解了 monad 的作用后,或许你能从自己写过或将要写的代码中更容易的识别出潜藏其中的 monad 抽象,减少重复的粘合代码,让自己的代码变得更简洁更易于理解维护~
Monad laws (WIP)
只是实现 bind
和 unit
函数其实并不能称其为一个 Monad。Monad 首先必须是 Funtor 和 Applicative,此外 Monad 还必须满足以下3个性质。虽然编译器无法检查你定义的类型是否满足这些定律,只有靠设计者自己来保证,但是满足这些数学性质,让我们可以更好的对类型和它的行为进行推理预测。
这些定律是(暂时用 Haskell 的写法表达):
- Left Identity:
return x >>= f
与f x
等价。 - Right Identity:
m >>= return
与m
等价。 - 结合律 Associativity:
(m >>= f) >>= g
与m >>= (\x -> f x >>= g)
等价。
不同的 monad 定义方式 (WIP)
一般的 monad 定义是这样的:
1 | class Monad m where |
其实还存在另一种与其表达力相同的定义方式:
1 | class Monad m where |
这两种不同的定义方式都可以用来互相定义(有点烧脑)。
1 | ma >>= f = (\x -> ma) >=> f |
感觉第二种方式其实就是可以把一系列的形式为 a -> m b
的函数组合起来,形成一个新的形式为 a -> m b
的函数。
如果用第二种方式来定义 Monad laws 的话,看上去就清晰很多:
- Left Identity:
return >=> f
与f
等价 - Right Identity:
f >=> return
与f
等价 - 结合律 Associativity:
(f >=> g) >=> h
与f >=> (g >=> h)
等价
可以与 Monoid laws 类比:
mempty <> x
与x
等价x <> mempty
与x
等价(x <> y) <> z
与x <> (y <> z)
等价
后记
本来只是想温习一下的,然后自己想写点阅读笔记加强记忆,结果最后写成了这样一篇文字……
大部分内容是转写翻译的原文(原文又是对另一篇 Haskell 版本的转写 :D ),然后还加入了而很多我自己的理解和各种私货。如果有感觉还是难以理解的地方,建议结合原文食用。写得匆忙,如果有错漏,欢迎大家指出。
写文字的感觉还是很棒的,“Lua 实验室” https://www.zhihu.com/column/lua-lab 好久没有更新过了,一直想有机会写点什么,又一直懒得动。可能得益于最近开始每天写工作日志吧,感觉写东西的启动阻力小了很多,经常想到点什么就写了,写着写着或许就发展成了一整篇文字,挺好的~
相关阅读
读完这一篇之后,推荐继续阅读一下这个作者的更多文章:
- Monad syntax for JavaScript:很短小的一篇文章,里面提出了在类似 Javascript 这种没有 Haskell 里面方便的语法糖的情况下,怎么更好的使用 monad。
- Promises are the monad of asynchronous programming:介绍了 Promise 作为 monad 在 Javascript 中的使用。