2

我对数学概念属性符号掌握得不好, 所以理解比较慢,
这篇文章是从概念性的内容去理解 Monad, 不精确, 可能具体到数学概念也不准确.
但是希望提供一个比较直观的方式去了解, Monad 是怎么来的?

概念上简单说 Monad 是"自函子范畴上的一个幺半群".
新概念很多, 函子, 自函子, 范畴, 半群, 幺半群.
模糊地讲, 这些就是数学的概念, 集合啦, 集合元素间的映射啦, 单位元啦,

Monad 概念

模糊理解的话, 函子可以当做是函数, a -> b 的映射, 当然也可以比函数更抽象点的东西,
然后"自函子", 涉及到类型的概念, 函子从一个集合 A 到另一个集合 B,
但我们把程序所有东西都放在一起的话, 函子可以认为是从 A 到 A 自己了, 所以是"自函子".
"范畴"我解释不来, 大致是这些函子的构成和关系, 具体看何幻的文章.
从前面这部分看, Haskell 把程序当成各种集合还有映射来看待了,
程序中, 无论值的变化, 甚至副作用的变化, 全都纳入到范畴里边来理解.

然后幺半群呢? 要理解这些概念, 就要知道相关的几个概念,

  • 原群(Magma)

一个集合, 然后集合上的元素的二元操作, 操作结果都在这个集合内部,
一个例子, 比如 { true false }, 还有二元操作 and or,
任何二元操作的结果都在集合内.

  • 半群(Semigroup)

半群在原群的基础上增加了一个条件, 满足结合律:
比如所有非空的字符串的集合, 以及 concat 操作.
"a" `concat` "b" 得到 "ab" 还在集合内,
然后 ("a" `concat` "b") `concat` "c" 得到 "abc",
然后 "a" `concat` ("b" `concat` "c") 得到 "abc",
两者是等价的, 满足结合律.

  • 幺半群(Monoid)

幺半群, 在半群的基础上增加一个条件, 存在幺元,
幺元跟任何元素 a 结合得到的都是 a 本身,
例子的话, 在上面这个非空字符串的例子当中再加上空字符串,
那么 "" `concat` "a" 得到 "a",
然后 "a" `concat` "" 得到 "a",
两者等价的, "" 就是这个集合的幺元.

  • 群(Group)

群在幺半群的基础上在加上了一个条件, 存在逆元,
一个例子比如整数的集合, 其中
(x + y) + z = x + (y + z) 满足结合律,
x + 0 = 0 + x 存在幺元,
x + (-x) = 0 存在逆元,
所以整数的集合就是一个幺半群了.

当然这个叙述就不精确了, 但你大致应该知道幺半群对应什么了,
集合, 二元操作闭合, 交换律, 幺元.
特别是字符串这个例子, 可以看到程序当中明显会有很多这样的对应,
我们大量使用数组, 数组也是满足这几个条件的,
还是那个 concat 操作, 闭合, 交换律, 幺元([]), 都是成立,
然后数值计算, +, * 这两个操作, 闭合, 交换律, 幺元, 也是存在的.

然后需要绕过来理解一下了, 对于函数, 对于副作用, 是不是也是幺半群?

函数吧, 有 f g h 三个函数, 然后有个复合函数的操作 compose,
(f `compose` g) `compose` h 是一个,
f `compose` (g `compose` h) 是另一个,
compose 是右边函数先计算的, 所以总是现在 h(x) 先计算, 最终是 f(g(h(x))).
这个是结合律.
可以直观理解一下, 函数结合知识结合的函数, 最终调用还是固定的顺序的.
另外幺元就是 function(x){ return x } 这个函数了. 左右都一样.

然后副作用呢, 我们把副作用拿函数包一下, function(){ someEffect() },
然后按照函数 compose 的操作, 还是有结合律的,
然后我们定义一个副作用为空的 Unit 操作, 可以作为幺元,
因为是空的副作用, 所以放在左边放在右边都是不影响的.

所以函数, 副作用, 这些也还是能按照幺半群来理解和约束的.
这样想下去, 程序里边大量的内容都是可以套用幺半群去理解了.
而关键位置就是结合律还有幺元, 对于函数来说就是函数复合还有 (x) => x 函数.

既然幺半群是程序当中普遍存在的结构, 也就能直接地接受这个概念了.
然后"自函子范畴上的幺半群", 也就是说限定在"自函子"而不是"普通集合元素"的幺半群了.
这个不能准确描述, 但是应该足够提供一些观念上的理解了, Monad 是怎么出来的...

Monad class

在 Haskell 里定义又要再另外理解了, 首先对幺半群来说还是清晰的,
而交换律作为 law 没有写在 class 的定义当中了,

class Monoid m where  
    -- 定义元素
    mempty :: m
    
    -- 定义闭合二元操作
    mappend :: m -> m -> m
    
    -- 定义多个元素的结合, 默认是用的 List 表示
    -- 满足结合律, 所以 foldr 是从右边开始结合的, 跟左边结合效果一致
    mconcat :: [m] -> m
    mconcat = foldr mappend mempty

对应的 Monad 版本, 也有着跟 Monoid 对应的一些函数的结构,

class Monad m where
    -- 定义幺元
    return :: a -> m a
    
    -- 对应上边的二元操作 mappend
    (>>=) :: m a -> (a -> m b) -> m b
    
    (>>) :: m a -> m b -> m b
    x >> y = x >>= \_ -> y
    
    -- 对应上边的多个元素的组合 mconcat
    join :: m => m (m a) -> m a

这边容易分歧的用法出现了, 首先幺元的定义:

-- 在 Monoid 当中是
mappend :: m

-- 在 Monad 当中是
return :: a -> m a
-- 或者有的地方用是 pure 的名称
pure :: a -> m a
-- 那么, Monoid 中的二元操作
mappend :: m -> m -> m

-- 到了 Monad, 应该是
mappend :: m a -> m a -> m a

不过我们实际看到的是两个类型变量, a b:

-- 包含从 a 到 m b 态射的一个过程
(>>=) :: m a -> (a -> m b) -> m b
    
-- 包含 a 也包含 b 但是 a 被丢弃的过程, 比较可能是通过副作用丢弃了
(>>) :: m a -> m b -> m b
x >> y = x >>= \_ -> y

这个仔细想想也可以理解, 比如 List Int, 整数的数组,
经过映射之后, 可能还是 List Int, 也可能通过 length 得到 Int,
不过在 m a 这个类型约束当中, 不会是纯的 Int 了,
可能是 List String, 比如 [1,2,3,4].map(JSON.stringify),
可能是 List Boolean, 比如 [1,2,3,4].map(x => x > 0),
总之这个地方由于态射的存在而变化了.

至于为什么要这样定义, 如果说 a -> m b 这个过程不需要跟 m 发生作用,
那么我们用态射, 直接用 Functor 就能达成了,

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

但是存在情况, 就是需要跟 m 发生作用的, 比如 IO, 就必然会,
然后是 flatMap 的情况, 计算过程要跟 List 这个构造器发生作用:

Prelude> (>>=) [1,2,3,4] (\x -> [0..x])
[0,1,0,1,2,0,1,2,3,0,1,2,3,4]

IO Monad 的特殊性在于主流语言习惯性不去管 IO,
但是按照 Monoid 这套理解下来, IO 确实用是这样的结构.

其他

里边的概念都太抽象了, 特别是范畴相关的, 这个写得不太能自圆其说.


题叶
17.3k 声望2.6k 粉丝

Calcit 语言作者


引用和评论

0 条评论