起初本瓜看到【单子】说白了不过就是【自函子范畴】上的一个【幺半群】而已?
这句话的时候,还以为自己在看量子力学的量子纠缠相关内容,单子、函子、粒子、玻色子、费米子、绝绝子。。。
正好最近又看到一篇《怎样理解“范畴”?》,解释 “范畴” 都这么费劲?表示脑细胞已经不够用了。。。
至于 “幺半群”?是打麻将吗。。。
好家伙~ 最后,你告诉我这句话是关于函数式编程 Monad 的解释,牛你是真滴牛!
- 怕生词概念的同学先别慌,先告诉你 Monad 和 Promise 很像,增点亲切感;😁
浅尝 Monad
在函数式编程中我们一直强调:纯函数、纯函数、纯函数!无副作用,无副作用,无副作用!
但是,要求总写没有任何副作用的纯函数是几乎不可能的;
HTTP 请求、修改函数外的数据、输出数据到屏幕或控制台、DOM查询/操作、Math.random()、获取当前时间等,这些操作都会使函数产生副作用,导致我们跟踪数据状态困难、代码不易读;
又但是!我们即使不能一直写纯纯的纯函数,不过,尽可能把这些副作用操作放在最后去执行(延迟处理、惰性处理),这也是函数式编程书写纯函数原则之一!
而实现这种做法靠的就是 Monad!
直接上代码,看看 Monad 在实际应用中是怎么写的:
var fs = require("fs"); // 纯函数,传入 filename,返回 Monad 对象 var readFile = function (filename) { // 副作用函数:读取文件 const readFileFn = () => { return fs.readFileSync(filename, "utf-8"); }; return new Monad(readFileFn); }; // 纯函数,传入 x,返回 Monad 对象 var print = function (x) { // 副作用函数:打印日志 const logFn = () => { console.log(x); return x; }; return new Monad(logFn); }; // 纯函数,传入 x,返回 Monad 对象 var tail = function (x) { // 副作用函数:返回最后一行的数据 const tailFn = () => { return x[x.length - 1]; }; return new Monad(tailFn); }; // 链式操作文件 const monad = readFile("./xxx.txt").bind(tail).bind(print); // 执行到这里,整个操作都是纯的,因为副作用函数一直被包裹在 Monad 里,并没有执行 monad.value(); // 执行副作用函数
我们用 Monad 将包含副作用函数得操作进行封装,到绑定链式操作的时候,都并没有执行任何副作用操作;
直到最后,调用 monad.value()
才执行了这些副作用操作;
在外界看来,被 Monad 函数包裹住含副作用的函数,根本就和纯函数是一样一样的,因为:
你无法知道一间黑色的房间里面有没有一只黑色的猫;
在编程开发中,尤其是多人协作中,一个数据要经过各种计算、加入各种逻辑、进行不同线路的变异,最后呈现给消费方;
这个数据的链路越长(多计算)、越多(多分支)、越复杂(多异步),数据的元信息越容易丢失,就像一句话,经过不同人的不同方式转述后,会变得和初始意义相差甚远;
我们试图将计算(函子)和业务输出(链式操作)剥离开来,会让这个“转述”过程更准确、清晰;
wiki 中 Monad
没错,上一小节中的 Monad 只说了它的应用示例,此小 bar 来看看它在 wiki 中的【超干】定义:
单子由 3 个部分组成:
- 类型构造子
M
,建造一个单子类型M T
- 类型转换子,经常叫做unit或return,将一个对象
x
嵌入到单子中:unit(x) :: T -> M T
- 组合子,典型的叫做bind(约束变量的那个bind),并表示为中缀算子
>>=
,去包装一个单体变量,接着把它插入到一个单体函数/表达式之中,结果为一个新的单体值:(mx >>= f) :: (M T, T -> M U) -> M U
同时,这 3 个组成部分还需遵循 3 个定律:
unit
是bind的左单比特:unit(a) >>= λx -> f(x) ↔ f(a)
unit
也是bind的右单比特:ma >>= λx -> unit(x) ↔ ma
- bind本质上符合结合律:
ma >>= λx -> (f(x) >>= λy -> g(y)) ↔ (ma >>= λx -> f(x)) >>= λy -> g(y)
没看懂?确实难懂!本瓜好奇:当我不懂 A 时,有人用 A` 来解释 A,但我又不懂 A`,然后再用 A_ 来解释 A`,还是没懂,之后,再用 A/ 、A·、A+ ......来一层套一层解释,当这个解释线拉的足够长的时候,是否还能做到:有效解释?🐶
可以直接这样理解:Monad 是一种特殊的数据结构,它能把值进行包装,然后链接执行;王垠在《对函数式语言的误解》中准确了描述了 Monad 本质:
Monad 本质是使用类型系统的“重载”(overloading),把这些多出来的参数和返回值,掩盖在类型里面。这就像把乱七八糟的电线塞进了接线盒似的,虽然表面上看起来清爽了一些,底下的复杂性却是不可能消除的。
所以,底下的复杂性是自然。
Promise 和 Monad
我们尝试用 JS 来模拟最基本的 Monad:
class Monad { value = ""; // 构造函数 constructor(value) { this.value = value; } // unit,把值装入 Monad 构造函数中 unit(value) { this.value = value; } // bind,把值转换成一个新的 Monad bind(fn) { return fn(this.value); } } // 满足 x-> M(x) 格式的函数 function add1(x) { return new Monad(x + 1); } // 满足 x-> M(x) 格式的函数 function square(x) { return new Monad(x * x); } // 接下来,我们就能进行链式调用了 const a = new Monad(2) .bind(square) .bind(add1); //... console.log(a.value === 5); // true
那为什么我们最开始说 Monad 和 Promise 很像呢?
可以看到,确实很像:
- Promise 也是构造函数;
- Promise.Resolve ,相当于 Monad unit,用于包装返回值;
- Promise.prototype.then 相当于 Monad bind,用于链接执行;
Promise 等效于把函数进行包装,Promise.resolve 等效于把这个包装进行拆开,将为一个普通的值;
不过,Promise 不都是 Monad,示例🌰
Promise.resolve
传入的是 Promise.resovle(1)
这个 Promise 对象时,已经做了计算,p.then
失效;
关于 Promise 和 Monad 再引用一个很棒的解释(建议重点阅读):
纯函数不能有副作用,所以无法与外部进行 IO 操作,不能存在 a -> IO 或 IO -> a 这种操作,必须为 IO -> IO(Promise -> Promise),也就是必须为「自函子」,async 函数中都是自函子映射,也就是一个「自函子范畴」,那么相对的「幺半群」就是Promise了。
阶段小结
函数式编程中,处处都是惰性思维的体现; Monad 也是惰性计算的实践之一;至于标题中的这句话:【单子】说白了不过就是【自函子范畴】上的一个【幺半群】而已?
咱们也用惰性思维去思考:现在很难理解,那我是必须要现在去理解吗?如果不是,那就放到后面需要去理解的时候再去理解吧~~ 不过至少,也要勾勒一下 Monad 和 Promise 关系的大致轮廓;Promise 是 JS 人的浪漫!Monad 是函数式编程的浪漫!
后续还会带来各类单子介绍,建议结合专栏内容,联系前后食用~
以上。
撰文不易,点赞鼓励👍👍👍
我是掘金安东尼,公众号同名,输出暴露输入,技术洞见生活,再会!