写点什么

从纯函数讲起,一窥最深刻的函子 Monad

作者:掘金安东尼
  • 2022-11-04
    广东
  • 本文字数:5250 字

    阅读完需:约 17 分钟

专栏简介

作为一名 5 年经验的 JavaScript 技能拥有者,笔者时常在想,它的核心是什么?后来我确信答案是:闭包和异步。而函数式编程能完美串联了这两大核心,从高阶函数到函数组合;从无副作用到延迟处理;从函数响应式到事件流,从命令式风格到代码重用。所以,本专栏将从函数式编程角度来再看 JavaScript 精要,欢迎关注!传送门

序言

转眼间,来到专栏第 3 篇,前两篇分别是:


✨从历史讲起,JavaScript 基因里写着函数式编程


✨从柯里化讲起,一网打尽 JavaScript 重要的高阶函数


建议按顺序“食用”。饮水知其源,由 lambda 演算演化而来的闭包思想是 JavaScript 写在基因里的东西,闭包的“孪生子”柯里化,是封装高阶函数的利器。


当我们频繁使用高阶函数、甚至自己不断在封装高阶函数的时候,其实就已经把“函数是一等公民”这个最核心的函数式编程思想根植在心里面了。


函数可以作为参数、可以作为返回值、可以赋值给变量......


本篇带来 JavaScript 函数式编程思想中最重要的概念之一 —— 纯函数,它定义了:写出怎样的函数才是优雅的! 由纯函数概念衍生,我们将进一步探讨:


  • 函数的输入和输出

  • 函数的副作用

  • 组合函数

  • 无形参风格编程

  • 以及最后将一窥较难理解的函子 Monad 概念


话不多说,赶紧冲了~


点赞 + 收藏 + 关注 === 学会

纯函数

什么样的函数才算“纯”?


紧扣定义,满足以下两个条件的函数可以称作纯函数:


  1. 如果函数的调用参数相同,则永远返回相同的结果。它不依赖于程序执行期间函数外部任何状态或数据的变化,必须只依赖于其输入参数。

  2. 该函数不会产生任何可观察的副作用,例如网络请求,输入和输出设备或数据突变(mutation)

输入 & 输出

在纯函数中,约定:相同的输入总能得到相同的输出。而在日常 JavaScript 编程中,我们并没有刻意保持这一点,这会导致很多“意外”。


🌰 比如:分不清 slice 和 splice 的区别


var arr = [1,2,3,4,5];
arr.slice(0,3); // [1,2,3]
arr.slice(0,3); // [1,2,3]
arr.slice(0,3); // [1,2,3]
复制代码


var arr = [1,2,3,4,5];
arr.splice(0,3); // [1,2,3]
arr.splice(0,3); // [4,5]
arr.splice(0,3); // []
复制代码


使用 slice 无论多少次,相同的输入参数,都会有相同的结果;而 splice 则不会,splice 会修改原数组,导致即使参数完全相同,结果竟然完全不同。


在数组中,类似的、会对原数组修改的方法还有不少:pop()、push()、shift()、unshift()、reverse()、sort()、splice() 等,阅读代码时,想要得到原数组最终的值,必须追踪到每一次修改,这会大幅降低代码的可读性。


🌰 比如: random 函数的不确定


Math.random() // 0.9706010566439833Math.random() // 0.26820889412263416Math.random() // 0.6144693062318409
复制代码


Math.random() 每次运行,都会产生一个介于 0 和 1 之间的新随机数,你无法预测它,相同的输入、不通的输出,意外 + 1;


相似的还有 new Date() 函数,每次相同的调用,结果不一致;


new Date().toLocaleTimeString() // '11:43:44'
new Date().toLocaleTimeString() // '11:44:16'
复制代码


🌰 比如:有隐式输出的函数


var tax = 20;
function calculateTax(productPrice) { tax = tax/100 return (productPrice * tax) + productPrice;}
calculateTax(100) // 120
calculateTax(100) // 100.2
复制代码


上面 calculateTax 函数是一个比较隐蔽的非纯函数,输入相同的参数,得到不同的结果。


究其原因是因为函数输出依赖外部变量 tax,并在无意中修改了外部变量。


所以,综上,纯函数必须要是:有相同的输入就必须有相同输出的这样的函数,运行一次是这样,运行一万次也应该是这样。

副作用

除了保障相同的输入得到相同的输出这一点外,纯函数还要求:不会产生任何可观察的副作用


副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。


副作用主要包含:


  • 可变数据

  • 打印/log

  • 获取用户输入

  • DOM 查询

  • 发送一个 http 请求

  • Math.random()

  • 获取的当前时间

  • 访问系统状态

  • 更改文件系统

  • 往数据库插入记录


🌰 举一些常见的有副作用的函数例子:


// 修改函数外部数据


let num = 0function sum(x,y){    num = x + y    return num}
复制代码


// 调用 I/O


function sum(x,y){    console.log(x,y)    return x+y}
复制代码


// 引用函数外检索值


function of(){    return this._value}
复制代码


// 调用磁盘方法


function getRadom(){    return Math.random()}
复制代码


// 抛出异常


function sum(x,y){    throw new Error()    return x + y}
复制代码


我们不喜欢副作用,它充满了不确定性,我们的函数不是一个稳定的黑盒,假设 function handleA() 函数,我们只期望它的功能是 A 操作,不希望它意外的又操作了 B 或 C。


所以,我们在纯函数内几乎不去引用、修改函数外部的任何变量,仅仅通过最初的形参输入,经过一系列计算后再 return 返回给外部。


但副作用真的太常见了,有时候难以避免使用带副作用的非纯函数。在 JavaScript 函数式编程中,我们并不是倡导严格控制函数不带一点副作用,而是要尽量把这个“危险的玩意”控制在可控的范围内。后面会讲到如何控制非纯函数的副作用。

“纯”的好处

说了这么多关于“纯函数”概念,肯定有人会问:写纯函数有什么好处?我为什么要写纯函数?

自文档化

函数越纯,它的功能越明确,不需要你阅读它的时候还翻前找后,代码本身就是文档,甚至读一下方法名就能放心的使用它,而不用担心它还会不会有其它的影响。这就是代码的自文档化。


🌰举个例子:


实现一个登录功能:


// 非纯函数


var signUp = function(attrs) {  var user = saveUser(attrs);  welcomeUser(user);};
var saveUser = function(attrs) { var user = Db.save(attrs); ...};
var welcomeUser = function(user) { Email(user, ...); ...};
复制代码


// 纯函数


var signUp = function(Db, Email, attrs) {  return function() {    let user = saveUser(Db, attrs);    welcomeUser(Email, user);  };};
var saveUser = function(Db, attrs) { ...};
var welcomeUser = function(Email, user) { ...};
复制代码


在纯函数表达中,每个函数需要用到的参数更明确、调用关系更明确,为我们提供了更多的基础信息,代码信息自成文档。

组合函数

本瓜常提的“组合函数”就是纯函数衍生出来的一种函数。把一个纯函数的结果作为另一个纯函数的输入,最终得到一个新的函数,就是组合函数。


const componse = (...fns) => fns.reduceRight((pFn, cFn) => (...args) => cFn(pFn(...args)))
复制代码


function hello(name) { return `HELLO ${name}` }
function connect(firstName, lastName) { return firstName + lastName; }
function toUpperCase(name) { return name.toUpperCase() }
复制代码


const sayHello = componse(hello, toUpperCase, connect)
console.log(sayHello('juejin', 'anthony')) // HELLO JUEJINANTHONY
复制代码


多个纯函数组合起来的函数也一定是纯函数。

引用透明性

引用透明性是指一个函数调用可以被它的输出值所代替,并且整个程序的行为不会改变。


我们可以利用这个特性对纯函数进行“加和乘”的运算,这是重构代码的绝妙手段之一~


🌰比如:


优化以下代码:


var Immutable = require('immutable');
var decrementHP = function(player) { return player.set("hp", player.hp-1);};
var isSameTeam = function(player1, player2) { return player1.team === player2.team;};
var punch = function(player, target) { if(isSameTeam(player, target)) { return target; } else { return decrementHP(target); }};
var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});
punch(jobe, michael);
复制代码


因为 decrementHPisSameTeam 都是纯函数,我们可以用等式推导、手动执行、值的替换来简化代码:


因为数据不可变,所以 isSameTeam(player, target) 替换成 "red" === "green",在 puch 函数内,if(false){...} 则直接删掉,然后将 decrementHP 函数内联,最终简化为:


var punch = function(player, target) {  return target.set("hp", target.hp-1);};
var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});
punch(jobe, michael);
复制代码


纯函数的引用透明性让纯函数能做简单运算及替换,在重构中能大大减少代码量。

其它

  • 纯函数不需要访问共享的内存,这也是它的决定性好处之一。这样一来,它无需处于竞争态,使得 JS 在服务端的并行能力极大提高。

  • 纯函数还能让测试更加容易。我们不需要模拟一个真实的场景,只需要简单模拟函数的输入、然后断言输出即可。

  • 纯函数与运行环境无关,只要愿意吗,可以在任何地方移植它、运行它,其本身已经撇除了函数所携带的的各种隐式环境,这是命令式编程的弊病之一。


言而总之,函数尽量写“纯”一点,好处真的有很多~ 写着写着就知道了


无形参风格

纯函数的引用透明性可以等式推导演算,在函数式编程中,有一种流行的代码风格和它很相似,如出一辙。


这种风格就是无形参风格,其目的是通过移除不必要的形参-实参映射来减少视觉上的干扰。


🌰举例说明:


function double(x) {    return x * 2;}
[1,2,3,4,5].map( function mapper(v){ return double( v );} );
复制代码


double 函数和 mapper 函数有着相同的形参,mapper 的参数 v 可以直接映射到 double 函数里的实参里,所以 mapper(..) 函数包装是非必需的。我们可以将其简化为无形参风格:


function double(x) {    return x * 2;}
[1,2,3,4,5].map( double );// [2,4,6,8,10]
复制代码


无形参可以提高代码的可读性和可理解性。


其实我们也能看出只有纯函数的组合才能更利于写出无形参风格的代码,看起来更优雅~

Monad

前面一直强调:纯函数!无副作用!


谈何容易?HTTP 请求、修改函数外的数据、输出数据到屏幕或控制台、DOM 查询/操作、Math.random()、获取当前时间等等这些操作都是我们经常需要做的,根本不可能摈弃它们,不然连最基础功能都实现不了。。。


解决上述矛盾,这里要抛出一个哲学问题:


你是否能知道一间黑色的房间里面有没有一只黑色的猫?



明显是不能的,直到开灯那一刻之前,把一只猫藏在一间黑色的屋子里,和一间干净的黑屋子都是等效的。


所以,对了!我们可以把不纯的函数用一间间黑色屋子装起来,最后一刻再亮灯,这样能保证在亮灯前一刻,一直都是“纯”的。


这些屋子就是单子 —— “Monad”!


🌰举个例子,用 JavaScript 模拟这个过程:


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(); // 执行副作用函数
复制代码


readFile、print、tail 函数最开始并非是纯函数,都有副作用操作,比如读文件、打印日志、修改数据,然而经过用 Monad 封装之后,它们可以等效为一个个纯函数,然后通过链式绑定,最后调用执行,也就是开灯。


在执行 monad.value() 这句之前,整段函数都是“纯”的,都没有对外部环境做任何影响,也就意味着我们最大程度的保证了“纯”这一特性。


王垠在《对函数式语言的误解》中准确了描述了 Monad 本质:


Monad 本质是使用类型系统的“重载”(overloading),把这些多出来的参数和返回值,掩盖在类型里面。这就像把乱七八糟的电线塞进了接线盒似的,虽然表面上看起来清爽了一些,底下的复杂性却是不可能消除的。


上述的 Monad 只是最通俗的理解,实际上 Monad 还有很多分类,比如:Maybe 单子、List 单子、IO 单子、Writer 单子等,后面再讨论~

结语

本篇从纯函数出发,JavaScript 函数要写的优雅,一定要“纯”!写纯函数、组合纯函数、简化运算纯函数、无形参风格、纯函数的链式调用、Monad 封装不存的函数让它看起来“纯”~


纯,就是这个味儿!



OK,以上便是本篇分享,专栏第 3 篇,希望各位工友喜欢~ 欢迎点赞、收藏、评论 🤟


后文会重点讲 延迟处理的思想、JavaScript 迭代器、函数式编程中的异步等,敬请期待~


关注专栏 # JavaScript 函数式编程精要 —— 签约作者安东尼


我是掘金安东尼 🤠 100 万人气前端技术博主 💥 INFP 写作人格坚持 1000 日更文 ✍ 关注我,安东尼陪你一起度过漫长编程岁月 🌏

发布于: 刚刚阅读数: 3
用户头像

安东尼陪你度过漫长编程岁月~ 2022-07-14 加入

真正的大师,永远怀着一颗学徒的心(易)

评论

发布
暂无评论
从纯函数讲起,一窥最深刻的函子 Monad_前端_掘金安东尼_InfoQ写作社区