轻松理解 Monad(Lua 描述)

语言: CN / TW / HK

阿楠 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)

通过观察可以发现,要想成功组合两个函数 fg 的话,需要满足这样的条件:函数 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 函数来实现这一点。因为现在 incdec 函数返回一个包含了运算结果和调试信息的复合值,而只能接受和处理一个简单的数字。

我们需要再多用一点代码来粘合这些“可调试”的函数:将他们的返回值“打开”,再以一种有意义的方式将他们粘合回来。

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) 作为参数的 incdec 函数,这样他们就可以轻松的被组合。但是这并不是一个可扩展的好办法,这样你需要为每个参与组合的函数都写一个接受 (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) 类型的函数。

这样我们就可以轻松的通过 bindcompose 函数来组合“可调试”版本的 incdec 了:

local f = compose(bind(inc), bind(dec))
f({42, ''})  -- { 42, 'dec was called. inc was called.' }

unit 函数

如前面的例子所示,我们可以通过 bindcompose 函数来对“可调试”版本的函数进行组合,但是对组合后的 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 这些函数(严格的说只是 bindunit ),其实我们就定义了一个 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

是的,我们可以实现一组 unitbind 工具函数来帮助我们:

-- 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 到底是什么呢?它是由一个类型和定义在这个类型上的一些操作( unitbind )组合成的一个概念。在 Haskell 中它是一个 Type class,在很多面向对象的语言中它类似于一个 Interface。

我们也可以把它当成一种设计的模式来应用:当你有一系列函数,他们接受一种类型(a)作为参数,返回另一种类型(b)作为返回值,你可以定义对应的 bindunit 函数来帮助他们进行组合:

bind
unit

我自己也经常将 monad 理解为一种将简单类型打包为带有更多信息的复杂类型的方式,例如 Maybe monad, List monad, Writer monad 都是如此。

当然这并不是 monad 的严格定义,但是相信以这种方式会让 monad 更容易被理解和应用。

在 Haskell 中, unit 被称为 returnbind 被实现为一个操作符 >>=

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 中的使用。