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