从JavaScript纯函数解析最深刻的函子 Monad实例

目录
  • 序言
  • 纯函数
    • 输入 & 输出
    • 副作用
  • “纯”的好处
    • 自文档化
    • 组合函数
    • 引用透明性
    • 其它
  • 无形参风格
  • Monad
  • 结语

序言

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

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

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

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

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

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

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

  • 函数的输入和输出
  • 函数的副作用
  • 组合函数
  • 无形参风格编程
  • 以及最后将一窥较难理解的函子 Monad 概念

话不多说,赶紧冲了~

纯函数

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

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

  • 如果函数的调用参数相同,则永远返回相同的结果。它不依赖于程序执行期间函数外部任何状态或数据的变化,必须只依赖于其输入参数。
  • 该函数不会产生任何可观察的副作用,例如网络请求,输入和输出设备或数据突变(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.9706010566439833
Math.random() // 0.26820889412263416
Math.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 = 0
function 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 封装不存的函数让它看起来“纯”~

更多关于JavaScript纯函数Monad的资料请关注我们其它相关文章!

(0)

相关推荐

  • JavaScript函数式编程实现介绍

    目录 为什么要学习函数式编程 什么是函数式编程 前置知识 函数是一等公民 函数可以储存在变量中 函数作为参数 函数作为返回值 高阶函数 什么是高阶函数 使用高阶函数的意义 常用高阶函数 闭包 纯函数 纯函数概念 纯函数的好处 副作用 柯里化 函数组合 Functor(函子) MayBe 函子 Either函子 为什么要学习函数式编程 Vue进入3.*(One Piece 海贼王)世代后,引入的setup语法,颇有向老大哥React看齐的意思,说不定前端以后还真是一个框架的天下.话归正传,框架的趋

  • JavaScript函数柯里化

    目录 1 什么是函数柯里化 2 柯里化的作用和特点 2.1 参数复用 2.2 提前返回 2.3 延迟执行 3 封装通用柯里化工具函数# 4 总结和补充 1 什么是函数柯里化 在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术.这个技术以逻辑学家 Haskell Curry 命名的. 什么意思?简单来说,柯里化是一项技术,它用来改造多参数的函数. 比如: // 这是一个接受3个参数的函

  • JavaScript前端面试组合函数

    经历过一些列的函数式编程思想的学习总结,一些重要的高阶函数的学习,以及前一段时间关于 RxJS 的学习. 我们再回看一次 —— 组合函数 compose 本瓜越来越觉得,[易读]的代码应该是将声明和调用分开来的.根据不同的流程,用函数组合的方式.也可以说它是管道.或者说是链式调用,将声明的函数组合起来,再等待时机进行调用. 如果没有组合函数 compose,函数连续调用将会是嵌套的: const multi10 = function(x) { return x * 10; } const toS

  • JavaScript函数式编程(Functional Programming)纯函数用法分析

    本文实例讲述了JavaScript函数式编程(Functional Programming)纯函数用法.分享给大家供大家参考,具体如下: 函数式编程鼓励我们多创建纯函数(pure functions),纯函数只依赖你交给它的东西,不使用任何函数以外的东西,也不会影响到函数以外的东西.跟纯函数对应的就是不纯函数(impure functions),也就是不纯函数可能会使用函数以外的东西,比如使用了一个全局变量.也可能会影响到函数以外的东西,比如改变了一个全局变量的值. 多使用纯属函数是因为它更可靠

  • JavaScript函数式编程示例分析

    目录 函数式编程 函数柯理化(Curring) Compose 场景案例 总结 函数式编程 1.函数式编程指的是函数的映射关系 2.vue3.react16.8的函数组件推动了前端函数编程 3.必须是纯函数(幂等):同样的输入有同样的输出 //非纯函数 function getFirst1(arr){ return arr.splice(0,1); }; //纯函数 function getFirst2(arr){ return arr.slice(0,1); }; const arr = [1

  • JS函数式编程之纯函数、柯里化以及组合函数

    目录 前言 纯函数 纯函数的概念 副作用 纯函数案例 柯里化 柯里化的概念 函数柯里化的过程 函数柯里化的特点及应用 自动柯里化函数的实现 组合函数 前言 函数式编程(Functional Programming),又称为泛函编程,是一种编程范式. 早在很久以前就提出了函数式编程这个概念了,而后面一直长期被面向对象编程所统治着,最近几年函数式编程又回到了大家的视野中,JavaScript是一门以函数为第一公民的语言,必定是支持这一种编程范式的. 下面就来谈谈JavaScript函数式编程中的核心

  • 从柯里化分析JavaScript重要的高阶函数实例

    目录 前情回顾 百变柯里化 缓存传参 缓存判断 缓存计算 缓存函数 防抖与节流 lodash 高阶函数 结语 前情回顾 我们在前篇 <从历史讲起,JavaScript 基因里写着函数式编程> 讲到了 JavaScript 的函数式基因最早可追溯到 1930 年的 lambda 运算,这个时间比第一台计算机诞生的时间都还要早十几年.JavaScript 闭包的概念也来源于 lambda 运算中变量的被绑定关系. 因为在 lambda 演算的设定中,参数只能是一个,所以通过柯里化的天才想法来实现接

  • 从JavaScript纯函数解析最深刻的函子 Monad实例

    目录 序言 纯函数 输入 & 输出 副作用 “纯”的好处 自文档化 组合函数 引用透明性 其它 无形参风格 Monad 结语 序言 转眼间,来到专栏第 3 篇,前两篇分别是: 从历史讲起,JavaScript 基因里写着函数式编程 从柯里化讲起,一网打尽 JavaScript 重要的高阶函数 建议按顺序“食用”.饮水知其源,由 lambda 演算演化而来的闭包思想是 JavaScript 写在基因里的东西,闭包的“孪生子”柯里化,是封装高阶函数的利器. 当我们频繁使用高阶函数.甚至自己不断在封装

  • Javascript变量函数声明提升深刻理解

    目录 前言: 变量提升 函数提升 为什么要提升? 最佳实践 总结 前言: Javascript变量函数声明提升(Hoisting)是在 Javascript 中执行上下文工作方式的一种认识(也可以说是一种预编译),从字面意义上看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,在代码里的位置是不会动的,而是在编译阶段被放入内存中会和代码顺序不一样.变量函数声明提升虽然对于实际编码影响不大,特别是现在ES6的普及,但作为前端算是一个基础知识,必须掌握的,是很多大厂的前端面试必问的

  • JavaScript中函数声明优先于变量声明的实例分析

    复制代码 代码如下: var a; // 声明一个变量,标识符为a function a() { // 声明一个函数,标示符也为a } alert(typeof a); 显示的是"function",即function的优先级高于var. 有人觉得这是代码顺序执行的原因,即a被后执行的funcion覆盖了.好,将它们调换下. 复制代码 代码如下: function a() { } var a; alert(typeof a); 结果仍然显示的是"function"而

  • JavaScript回调函数callback用法解析

    这篇文章主要介绍了JavaScript回调函数callback用法解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 JavaScript回调函数的使用是很常见的,引用官方回调函数的定义: A callback is a function that is passed as an argument to another function and is executed after its parent function has complete

  • JavaScript ECMA-262-3 深入解析.第三章.this

    介绍 在这篇文章里,我们将讨论跟执行上下文直接相关的更多细节.讨论的主题就是this关键字. 实践证明,这个主题很难,在不同执行上下文中确定this的值经常会发生问题. 许多程序员习惯的认为,在程序语言中,this关键字与面向对象程序开发紧密相关,其完全指向由构造器新创建的对象.在ECMAScript规范中也是这样实现的,但正如我们将看到那样,在ECMAScript中,this并不限于只用来指向新创建的对象. 下面让我们更详细的了解一下,在ECMAScript中this的值到底是什么? 定义 t

  • 浅析JavaScript 箭头函数 generator Date JSON

    ES6 标准新增了一种新的函数: Arrow Function(箭头函数). x => x *x 上面的箭头相当于: function (x){ return x*x; } 箭头函数相当于匿名函数,并且简化了函数定义.一种像上面的,只包含一个表达式, 连{ ... }和return都省略掉了.还有一种可以包含多条语句,这时候就不能省略{ ... }和return: x =>{ if(x > 0){ return x * x; }else{ return -x *x; } } 如果参数不是

  • JavaScript转换与解析JSON方法实例详解

    本文实例讲述了JavaScript转换与解析JSON方法.分享给大家供大家参考,具体如下: json格式数据如下: var json = { 'jquery': [{ "id": "1", "type": "ASP.NET", "title": "JSON全解析"}] } alert(json.jquery[0].id); alert(json.jquery[0].type); aler

  • 浅谈JavaScript的函数及作用域

    函数和作用域是JavaScript的重要组成部分,我们在使用JavaScript编写程序的过程中经常要用到这两部分内容,作为初学者,我经常有困惑,借助写此博文来巩固下之前学习的内容. (一)JavaScript函数 JavaScript函数是指一个特定代码块,可能包含多条语句,可以通过名字来供其他语句调用以执行函数包含的代码语句. 1.JavaScript创建函数的方法有两种: 函数声明: function funcDeclaration(){ return 'A is a function';

  • 谈谈JavaScript异步函数发展历程

    <The Evolution of Asynchronous JavaScript>外文梳理了JavaScript异步函数的发展历程,首先通过回调函数实现异步,之后又经历了Promise/A+.生成器函数,而未来将是async函数的.感谢景庄对该文章的翻译,内容如下: 现在让我们一起来回顾这些年来JavaScript异步函数的发展历程吧. 回调函数Callbacks 似乎一切应该从回调函数开始谈起. 异步JavaScript 正如我们所知道的那样,在JavaScript中,异步编程方式只能通过

随机推荐