持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第21天,点击查看活动详情
Hi~,我是一碗周,如果写的文章有幸可以得到你的青睐,万分有幸~
写在前面
函数式编程是一种很古老的概念,早于第一台计算机的诞生。早于第一台计算机的诞生。
函数式编程是随着React的流行受到越来越多的关注(就跟Google地图和Gmail带火了Ajax一样)React的高级组件使用了高阶函数,高阶函数就是函数式编程的一个特性。
-
Vue3也开始拥抱函数式编程了。
-
函数式编程可以抛弃this。
-
打包时可以更好的利用tree shaking 过滤无用代码。
-
方便测试,方便并行处理。
-
有很多库可以帮助我们进行函数式开发,例如
lodash
、underscore
、ramda
。
概念
函数是编程(Functional Programming) FP是编程范式之一,我们常说的编程范式还有面向对象编程(面向对象编程就是把现实世界的对象抽象成为程序世界的类和对象,通过封装继承多态来演示事物之间的联系)和面向过程编程(所谓的面向对象编程就是按照步骤实现),它们是并列关系。
函数式编程的思维方式就是把现实世界中事物和事物联系抽象到程序世界(这里说的抽象是对运算过程进行首抽象)。
程序的本质就是根据输入的内容,通过某种运算获得相应的输出。程序开发过程中会涉及到很多输入和输出和函数,假如我们有个x
想要通过某种运算得到结果y
,写成函数就是y=f(x)
。
函数式编程中的函数指的并不是程序中的函数或者方法,而是数学中的函数,也就是映射关系。例如y=sin(x)
,是x
和y
的映射关系(x
的值确定乐y
的值也就确定了)。
简单的说函数式编程就是用来描述数据(函数)之间的映射关系。在下面的例子中,就存在面型过程编程以及函数式编程两种编程范式,示例代码如下:
// 面向过程编程 非函数式
var a = 1
var b = 2
var sum = a + b
console.log(sum); // 3
// 函数式编程
function add (a, b) {
return a + b
}
var sum = add(1, 2)
console.log(sum); // 3
在上面的代码中,我们需要计算两个值得和,我们需要先抽象出一个用于计算两个值功能的函数。
函数式编程的主要特性是代码的复用。
前置知识
在学习函数式编程之前,我们需要先回顾一下如下知识:
-
头等函数
-
闭包
头等函数
头等函数First-class Function,即可以把函数当做变量一样使用,主要体现在以下三个方面:
-
将一个函数赋值给一个变量,示例代码如下:
// 1. 将函数作为变量使用 function fn () { console.log('this is function'); } var fun = fn fun() // this is function
-
将函数作为参数传递给另一个函数,示例代码如下:
// 定义一个函数,将要作为函数的参数使用 function getHello () { return 'Hello' } // 定义另一个函数,该函数接受一个函数,并将该函数的返回值做处理后并返回 function fun (fn, str) { console.log(`${fn()} ${str}`); } // 执行函数fun() 并将 getHello() 作为参数传入 fun(getHello, 'JavaScript') // Hello JavaScript
使用好这一特性可以封装很多高阶函数,例如如下函数:
-
forEach
遍历数组// forEach const forEach = (array, fun) => { for (let i = 0; i < array.length; i++) { fun(array[i]) } } // 测试 forEach([1, 2, 3], (item) => { console.log(item); })
-
filter
返回满足条件的数组// 2. filter const filter = (array, fun) => { // 定义一个空数组,用于存放符合条件的数组项 let res = [] for (let i = 0; i < array.length; i++) { // 将数组中的每一项都调用传入的函数,如果返回结果为true,则将结果push进数组,最后返回 if (fun(array[i])) { res.push(array[i]) } } return res } // 测试 let res = filter([1, 2, 3], (item) => { return item > 2 }) console.log(res); // [ 3 ]
-
map
根据回调函数处理我们的数组,并将处理后的结果返回// 3. map const map = (array, fun) => { // 定义一个空数组,用于存放修改后的数据 let res = [] for (let i = 0; i < array.length; i++) { res.push(fun(array[i])) } return res } // 测试 let res = map([1, 2, 3], (item) => { return item * 2 }) console.log(res); // [ 2, 4, 6 ]
-
every
判断数组中的每一项,如果都满足回调函数中条件则返回true否则返回false// 4. every const every = (array, fun) => { // 假设全员通过 let flag = true for (let i = 0; i < array.length; i++) { // 遍历数组,如果有一个数组项不合符规则,重新赋值标志位 if (!fun(array[i])) { flag = false break } } return flag } // 测试 let res = every([1, 2, 3], (item) => { return item < 4 }) console.log(res); // true
-
some
判断数组中的每一项,如果有一项满足回调函数中条件就返回true都不满足则返回false// 5. some const some = (array, fun) => { let flag = false for (let i = 0; i < array.length; i++) { if (fun(array[i])) { flag = true break } } return flag } // 测试 let res = some([1, 2, 3], (item) => { return item > 4 }) console.log(res); // false
这么写的作用是在编写过程中屏蔽细节(例如这里的遍历),我们只需要关注我们目标就可以(例如遍历数组中的每一项都进行对2取余)。
-
-
函数作为返回值使用,示例代码如下:
// 3. 作为函数返回值 function sayHello () { return function () { console.log("Hello!"); } } // 调用该函数 // 方式一,定义一个变量接受该返回值,然后将变量以函数的形式调用 const say = sayHello() say() // Hello! // 方法二 通过 ()()的方式调用 sayHello()() // Hello!
demo:封装一个只执行一次的函数
once
。function once (fn) { // 第一次执行的时候,flag 默认为 false let flag = false return function () { // 只有当 flag 为 false 传递的函数才执行,执行之后立马将 flag 重置为true if (!flag) { flag = true // 通过 apply 的方式调用该 fn 函数,并通过 arguments 传递参数 return fn.apply(this, arguments) } } }
闭包
作用域链
在了解闭包之前, 我们先来了解一下作用域链。所谓的作用域,就是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。
在ECMAScript5版本中不存在块级作用域,也就是说在代码块中的定义的变量是存在全局作用域的,示例代码如下:
if (true) {
var a = 100;
}
console.log(a); // 100
在ECMAScript5版本中也没有跨级作用域,但它有函数作用域,也就是说,在某函数内定义的所有变量在该函数外是不可见的。示例代码如下:
function fun() {
var b = 200;
}
console.log(b); // ReferenceError: b is not defined
具体什么是作用域链,看一下下面的代码:
// 作用域链
var a = 'a'; // 全局变量
function fun() {
var b = 'b'; // 相对于fn函数作用域的话,b相当于全局变量
function fn() {
var c = 'c'; // 相对于f函数作用域的话,c相当于全局变量
function f() { // 函数作用域
var d = 'd';
console.log(a);
console.log(b);
console.log(c);
console.log(d);
}
f();
}
fn();
}
fun();
在f()
函数中输出变量a
,父级没有,再往父级找,如果还没有就一直往父级寻找,直到找到全局作用域。这种一层一层关系就是作用域链。
闭包
在函数中提出的概念,简单来说就是一个函数定义中引用了函数外定义的变量,并且该函数可以在其定义环境外被执行。当内部函数以某一种方式被任何一个外部函数作用域访问时,一个闭包就产生了。
实际上闭包可以看做一种更加广义的函数概念。因为其已经不再是传统意义上定义的函数。
-
闭包的条件:
-
外部函数中定义了内部函数。
-
外部函数是具有返回值,且返回值为内部函数。
-
内部函数还引用了外部函数的变量。
-
-
闭包的缺点:
-
作用域没有那么直观。
-
因为变量不会被垃圾回收所以有一定的内存占用问题。
-
-
闭包的作用:
-
可以使用同级的作用域。
-
读取其他元素的内部变量。
-
延长作用域。
-
-
闭包的实现的demo:
// 1. 通过返回的内部函数来操作函数中的局部变量 function fun () { var v = 100; // 局部变量 // 通过返回一个对象的方式访问局部变量v 来完成闭包 return { set: function (x) { v = x; }, get: function () { return v } } } var result = fun(); result.set(200) console.log(result.get()); // 200
// 2. 定义一个局部变量,计算该函数一共调用几次 var generate_count = function () { var container = 0; return function () { container++ console.log(`这是第${container}次调用`); } } var result = generate_count(); result(); // 这是第1次调用 result(); // 这是第2次调用 result(); // 这是第3次调用
// 3.修改 Math.pow() 函数,让求一个数的平方或者立方时,不需要每次传递第二个参数 /* Math.pow(4, 2) // 求4的平方 Math.pow(4, 3) // 求4的立方 */ // 写一个函数生成器 function makePower (power) { return (number) => { return Math.pow(number, power) } } // 平方 let power2 = makePower(2) // 立方 let power3 = makePower(3) // 求4的平方 console.log(power2(4)) // 16 // 求4的立方 console.log(power3(4)) // 62
纯函数
概念
纯函数指的是相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。纯函数类似数学中的函数,用来描述输入和输出的关系,y=f(x)
。如下图所示:
在上图中,函数
f
描述了左边值到右边值的对应关系。
下面我们看一下JavaScript提供的两个函数slice
和splice
,这两个函数一个是纯函数,一个不是纯函数。示例代码如下:
-
slice
返回数组的指定部分,不会改变原有数组,这是一个纯函数。// Array.slice() 函数,返回数组的指定部分,并不会改变原数组 let array = [1, 2, 3, 4, 5] // 多次调用 slice() 方法,传入相同的参数 console.log(array.slice(0, 3)) // [1, 2, 3] console.log(array.slice(0, 3)) // [1, 2, 3] console.log(array.slice(0, 3)) // [1, 2, 3] // 多次调用返回的结果相同,该函数为纯函数
-
splice
对数组进行操作,并返回操作后的数组,该函数会改变原有数组,并不是一个纯函数。let array = [1, 2, 3, 4, 5] // Array.splice() 函数,该函数对指定数组进行操作,并返回操作后得数组,该函数会改变原有数组 console.log(array.splice(0, 3)) // [1, 2, 3] console.log(array.splice(0, 3)) // [ 4, 5 ] console.log(array.splice(0, 3)) // [] // 多次调用返回的结果并不相同,所以该函数并不是一个纯函数
如果自己写一个纯函数也非常简单,只要有输入(也就是参数)和输出(也就是返回值),并且相同的输入可以得到相同的输入,说明该函数就是一个纯函数。实现纯函数的代码如下:
function add (a, b) {
return a + b
}
函数式编程是不会保留计算的中间结果的,所以说变量是不可变的,也就是无状态的。
由于纯函数的特性,我们可以把一个函数的结果交给另一个函数来处理,也就是函数组合。
Lodash库为我们提供了一些函数柯里化和函数组合的一些工具函数。
纯函数的好处
-
可缓存:
纯函数是可以根据输入来做缓存的,如果每次输入的结果都是一样的,就可以将第一次的结果缓存起来,在下次调用的时候直接返回结果即可。
实现缓存技术的一种经典的方式就是
memoize
技术,Lodash中实现了该方法,示例代码如下:const _ = require('lodash') // 定义一个计算圆面积的函数 function getArea (r) { console.log(r) return Math.PI * r * r } // 调用 Lodash 中提供的memoize方法生成一个可缓存的函数 let getAreaWithMemory = _.memoize(getArea) // 多次调用 getAreaWithMemory 函数 console.log(getAreaWithMemory(10)) console.log(getAreaWithMemory(10)) console.log(getAreaWithMemory(10)) console.log(getAreaWithMemory(10)) /* 结果如下 10 314.1592653589793 314.1592653589793 314.1592653589793 314.1592653589793 */
由结果可以看出,我们调用了4次函数,但是最终只打印了一次
r
,所以说最终只执行了一次getArea
函数,剩下的每次都是从缓存中拿的结果。我们还可以自己简单手写一个
memoize
函数,虽然不具备健壮性。实现代码如下:function memoize (fn) { // 定义一个对象,用于存储执行结果 let cache = {} return function () { // 将当前的参数用作 key 保证同一参数可以不用重复执行函数 let key = JSON.stringify(arguments) // 如果在当前对象中具有 key 的值,就直接返回该值,否则调用传入的fn方法,并将结果存入这个对象中 cache[key] = cache[key] || fn.apply(fn, arguments) return cache[key] } }
执行结果与上面相同。
-
可移植性/自文档化:
纯函数是完全自给自足的,它需要的所有东西都能轻易获得。这样的好处是函数的依赖很明确,因此更易于观察和理解。
-
可测试:
纯函数让测试变得更加的方便,单元测试其实在断言输出结果,所有的纯函数都是有输入有输出,所有说有利于测试。
-
并处处理:
在多线程下,多个线程去同时修改一个变量可能会出现意外的情况。而纯函数是一个封闭的空间,它只依赖于参数,不会访问共享的内存数据,所以在并行环境下,可以随意的执行纯函数。
并处可以通过Web Worker技术实现,但是现在主流的还是只用单线程。
纯函数的副作用
我们先来看一下如下代码:
// 不纯的函数
let mini = 18
function checkAge (age) {
return age >= mini
}
// 由于函数中的判断条件的变量定义在全局,只要全局变量mini发生了改变,就导致我们每次的输入可能得不到相同的输入,这就是一个不纯的函数
// 纯函数
function checkAge (age) {
let mini = 18
return age >= mini
}
// 上面的函数是一个纯函数,但是存在硬编码,导致这个函数不灵活,可以通过函数柯里化来解决
如果函数依赖于外部的状态就无法保证输出相同。就会带来副作用,副作用的来源可能是:
-
配置文件
-
数据库
-
用户的输入
-
发送一个http请求
-
可变数据
-
打印/log
-
获取用户输入
-
DOM查询
-
访问系统状态
-
...
所有的外部交互都可能带来副作用,副作用让方法的通用性下降,不适合扩展和可重用。但是副作用是不能完全禁止的,所以我们要尽可能的将副作用控制在可控范围内发生。
柯里化
柯里化可以把接收多个参数的函数转换可以具有任意参数的函数,并且返回接收剩余参数且返回结果的新函数。柯里化可以给函数组合提供细粒度的函数。
入门
下面一段代码来演示一下函数的柯里化:
// 下面这个函数 存在硬编码
/*
function checkAge (age) {
let mini = 18
return age >= mini
}
*/
// 将其修改为不具有硬编码的纯函数
function checkAge (min, age) {
return age >= min
}
我们优化后的函数不具有硬编码,且不受外部变量影响的一个纯函数。
如果我们经常使用某个基准值,则可以将代码进行复用。
console.log(checkAge(18, 20))
console.log(checkAge(18, 22))
console.log(checkAge(18, 24))
我们将函数优化为如下:
function checkAge (min) {
// 通过闭包,将 min 进行缓存
return function (age) {
return age >= min
}
}
let checkAge18 = checkAge(18)
console.log(checkAge18(20))
console.log(checkAge18(22))
console.log(checkAge18(24))
用ES6改写函数:
const checkAge = min => age => age >= min
// 完整写法如下:
const checkAge = (min) => {
return (age) => {
return age >= min
}
}
因为在ES6中,如果只有一个参数()
可以省略,如果只有return
的一句话,{}
和return
都可以省略。
其实上面的代码就是函数的柯里化:
-
当一个函数有多个参数的时候可以先传递一部分参数调用它(这部分参数以后永远不变)
-
然后返回一个新的函数接受剩余的参数,返回结果。
但是上面的例子柯里化并不彻底,我们需要将任何函数转换为柯里化函数。
Lodash里的柯里化
Lodash提供了一个curry()
函数,该函数的语法结构如下:
_.curry(fun)
-
功能:该函数创造一个柯里化函数,接受一个或者多个
fun
参数,如果fun
所需要的参数都被提供则执行fun
,并返回执行结果,否则继续返回该函数并等待接受剩余的参数。 -
参数:需要被柯里化的函数。
-
返回值:柯里化后的函数。
示例代码如下:
const _ = require('lodash')
// 定义一个多元函数,所谓的多元函数,就是具有多个参数的函数,一个参数叫一元函数
function getSum (a, b, c) {
return a + b + c
}
// 通过 _.curry() 函数将其转换为一个柯里化函数
let curried = _.curry(getSum)
// 说明getSum1还需要接收两个参数才可以执行
const getSum1 = curried(1)
console.log(getSum1(2, 3)) // 6
// 说明getSum2 还需要接收一参数才可以执行
// const getSum2 = curried(1, 2)
// 或者
// const getSum2 = getSum1(2)
// 或者
const getSum2 = curried(1)(2)
console.log(getSum2(3)) // 6
demo
判断字符串中的空白和数字,可以使用match
方法实现。示例代码如下:
// 判断字符串中空格和数字
console.log('Hello JavaScript'.match(/\s+/g))
console.log('Hello 123 JavaScript'.match(/\d+/g))
我们可以将上面的代码通过柯里化的方式重新定义,最终可以根据不同的正则表达式,生成对应的匹配函数。
const _ = require('lodash')
// 调用_.curry() 传递一个匿名箭头函数生成一个柯里化函数
const match = _.curry((reg, str) => {
return str.match(reg)
})
// 生成一个匹配空格的函数
const haveSpace = match(/\s+/g)
console.log(haveSpace('Hello JavaScript'))
// 同理 生成一个匹配数字的函数
const haveNumber = match(/\d+/g)
console.log(haveNumber('Hello 123 JavaScript'))
如果我们想要判断数组中的每一项元素是否存在空格或者数字,还可以继续改造。
const _ = require('lodash')
const match = _.curry((reg, str) => str.match(reg))
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
// 如果我们想要判断数组中的每一项元素是否存在空格或者数字,还可以继续改造
// 生成一个新的函数filter,它是一个柯里化函数
const filter = _.curry((fun, array) => {
return array.filter(fun)
})
// 通过 filter 生成一个函数 findSpace, 该函数可以接受一个数组,返回拥有空白字符串的元素
const findSpace = filter(haveSpace)
console.log(findSpace(['一碗粥', '一碗周']));
这些函数只需要定义一次,以后就可以无数次的进行使用。
_.curry()函数模拟
调用_.curry()
函数返回的函数有两种调用方法方式,第一种是当传递的参数个数定等于原来函数参数个数时,立刻执行该函数;另一种就是当传递的参数个数小于原来参数个数时,返回一个新的函数并等待剩余的参数传递。
我们想要模拟这个函数,只需要实现这两种形式的调用即可。实现代码如下:
// 该函数接受一个函数作为参数
function curry (func) {
// ...args 接收所有参数
return function curried (...args) {
// 判断实参个数与形参个数,args.length表示实参个数,func.length可以获取形参个数
if (args.length < func.length) {
// 如果实参个数小于形参个数,则说明需要继续等待参数传递,则继续返回一个函数
return function () {
// 通过闭包可以获取第一次传入的参数 args
// 第二次传递的参数可以通过 arguments 获取,通过Array.from() 将其转换为一个数组,并通过 Array.concat() 方法将其将两个数组合并
// 最后通过 ... 运算符展开数组,作用参数递归调用函数
return curried(...args.concat(Array.from(arguments)))
}
}
// 如果不小于则直接调用该函数,并将结果返回
return func(...args)
}
}
function getSum (a, b, c) {
return a + b + c
}
let curried = curry(getSum)
console.log(curried(1)(2)(3));
现在我们就实现了这个_.curry()
函数
总结
柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住了某些固定参数的新函数。
这是一种对参数缓存的办法。
这样做可以让函数变得更加灵活,让函数的颗粒度更小,可以将多元函数转换为一元函数,可以组合使用函数产生强大的功能。
函数组合
概念
我们用纯函数和柯里化很容易写出洋葱代码,所谓的洋葱代码就是指的是一个函数的参数是另一个函数结果,另一个函数的参数又是另一个的另一个函数的结果,简单的伪代码就是h(g(f(x)))
。
我们来写一个洋葱代码,例如获取数组的最后一个元素在转换为大写字母,示例代码如下:
_.toUpper(_.first(_.reverse(array)))
这一层包裹着另一层的代码,难以阅读和理解,我们把他们称为洋葱代码。
函数组合可以帮助我们解决这个问题,它可以将细粒度的函数重新组合生成一个新的函数。
管道
在学习函数组合之前,我们先来学习一下管道的概念。
如果我们给fn
函数传递参数a
,返回结果b
,我们可以把整个数据的处理过程看做一个黑盒管道,如下图所示:
参数a
经过了管道的处理最终得到了b
,但是如果我们中间出现了问题,导致最终的结果并不是结果b
,我们定位问题的话并不是很方便,因为这个fn
管道太长了,并不知道具体的问题出现了了哪里。
这个时候我们可以将fn
函数拆分为多个小函数,在运算的过程中就多了需要中间值,然而我们并不需要考虑这些中间结果,只需要关注输入的值和最终输出的值即可。如下图所示:
在上面图中,我们就将一个大的管道(函数)拆分成为了多个小的函数,执行每个函数都会得到一个结果,这些结果并不是我们最终想要的一个结果,而是每次的中间值,这些中间值具体是什么不用做考虑。
最后我们将每次执行的结果用作下一次函数执行的参数,然后组合成一个大的函数,伪代码如下:
fn = compose(fn1, fn2, fn3)
b = fn(a)
函数组合
函数组合(compose)指的是如果一个函数要经历多个函数处理才可以得到最终的值,这个时候可以把中间的过程的函数合并称为一个函数。
函数组合有着如下的特点:
-
函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据通过多个管道形成最终的结果。
-
函数组合默认是从右到左执行(更加能够反映数学上的含义)。
现在我们就利用函数组合的特性,来编写一个demo:编写一个函数求出数组的最后一个元素,示例代码如下:
// 定义函数组合函数的函数
function compose (f, g) {
// 接受多个函数,返回一个函数,这个函数接收一个输入。
return function (value) {
// 这么写的化洋葱代码并没有减少,而是封装了起来
return f(g(value))
}
}
// demo
// 反转数组的函数
function reverse (array) {
return array.reverse()
}
// 返回数组第一项的函数
function first (array) {
return array[0]
}
// 使用组合函数
const last = compose(first, reverse)
console.log(last([1, 2, 3, 4, 5, 6])) // 6
虽然有很多种求数组中最后一个元素的方法,但是我们使用函数组合的方法,可以对函数进行任意的组合,且我们封装的函数可以复用。
Lodash中的函数组合
Loadsh中提供了两个函数,一个是flow
另一个是flowRight
,该函数的功能是创建一个函数。 参数是任意个函数,返回的结果是调用提供函数的结果,this
会绑定到创建函数。 每一个连续调用,传入的参数都是前一个函数返回的结果。
两个函数区别就是一个是从左到右运行的(flow
),另一个是从右到左运行的(更加能够反映数学上的含义),所以说flowRight
用的更多一些。
示例代码如下如下:
const _ = require('lodash')
// 需求:将数组最后一个元素取出,并转换为大写
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = str => str.toUpperCase()
const f = _.flowRight(toUpper, first, reverse)
console.log(f(['bi', 'an', 'fan', 'hua'])) // HUA
模拟Lodash中的函数组合
接下来我们就模拟一下Lodash中的flowRight
函数。
需求分析:该函数的可以接受多个参数,且均为纯函数的形式。执行后返回一个函数,该函数就接收参数,并且从右到左的传递处理该数据并且进行处理。
实现该功能主要是通过Array.prototype.reduce()
实现,该方法的语法请参考
developer.mozilla.org/zh-CN/docs/…
实现代码如下:
// 通过 ...args 来接受任意和函数参数
function compose (...args) {
// 返回一个函数,用于接受第一个传递的值
return function (val) {
// 因为是从右往左调用,需要将数组进行翻转之后再调用 reduce 方法。或者直接调用 reduceRight()
/*
reduce 作用是对数组中的每个值调用指定的callback,并将结果汇总后返回 接收两个参数,callback, value
* callback 中包含4个参数,这里只用到了两个,第一个是累计的值,第二个是当前值
* value 表示第一次调用时的值
*/
return args.reverse().reduce(function (acc, fn) {
// 返回一个函数调用的结果,将次结果做累计,为下次调用做基础
return fn(acc)
}, val)
}
}
// 用箭头函数改写
// const compose = (...args) => val => args.reduceRight((acc, fn) => fn(acc), val)
结合律
函数的组合需要满足结合律(associativity),我们看一下下面的代码:
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) === compose(f, compose(g, h))
上面的代码就是结合律特性。
示例:
const _ = require('lodash')
const f1 = _.flowRight(_.toUpper, _.first, _.reverse)
const f2 = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse)
const f3 = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse))
console.log(f1(['bi', 'an', 'fan', 'hua'])) // HUA
console.log(f2(['bi', 'an', 'fan', 'hua'])) // HUA
console.log(f3(['bi', 'an', 'fan', 'hua'])) // HUA
如何调试组合函数
我们的组合函数,每次只有一句话,但是出现问题的时候,并不知道是哪个函数出现了问题,调试的话是非常的不容易的,那组合函数应该怎调试呢?先看一段代码:
const _ = require('lodash')
/*
需求 将字符串 BI AN FAN HUA 转换为 bi_an_fan_hua
*/
/*
Lodash 中的 split 函数接受两个参数,第一个是 需要被分割的字符串,第二个是分隔符
但是如果是这样的话,并不适合写组合函数,因为组合函数只能接受一个参数
所以我们需要对该函数进行一下改造,将其改造为柯里化
*/
const split = _.curry((separator, str) => _.split(str, separator))
// 以空格拆分字符串
const splitSpace = split(' ')
// 大小转换小写采用 _.toLower 函数
/*
将一个数组拼接为一个字符串可以使用 _.join 函数,该函数接受两个参数,第一个是数组,第二个是拼接符号
这个也不适合写组合函数,所以也需要改造并柯里化
*/
const join = _.curry((separator, array) => _.join(array, separator))
// 以_拼接
const join_ = join('_')
const str = 'BI AN FAN HUA'
// 组合函数
const f = _.flowRight(join_, _.toLower, splitSpace)
// 测试结果
console.log(f(str));
最终的执行结果如下:
b_i_,_a_n_,_f_a_n_,_h_u_a
这个结果并不是我们想要的那个结果,那问题出现在哪里了呢?
想要调试组合函数,我们可以编写一个纯函数用于调试,该纯函数接受一个值,并不做任何处理直接将该值返回,但是在返回之前需要做一些事情,例如打印上次执行的结果。该测试函数的定义如下:
// 定义一个测试函数,因为该函数需要两个参数,需要将其柯里化
const log = _.curry((name, value) => {
console.log(`${name}的结果为: `, value)
return value
})
我们的函数组合定义修改如下:
const f = _.flowRight(join_, log('toLower'), _.toLower, log('splitSpace'), splitSpace)
最终的执行结果为:
splitSpace的结果为: [ 'BI', 'AN', 'FAN', 'HUA' ]
toLower的结果为: bi,an,fan,hua
b_i_,_a_n_,_f_a_n_,_h_u_a
我们根据结果得知,join
的参数应该是一个数组,但是传递的却是一个字符串,所以需要对toLower
函数做一下改造,改造代码如下:
// 大小转换小写采用 _.toLower 函数
/*
_.map函数对数组内的值通过回调函数进行修改,但是map并不是只接受一个参数,所以也需要将map进行柯里化
*/
const map = _.curry((callback, array) => _.map(array, callback))
// 使用 _.toLower 作为 callback
const mapToLower = map(_.toLower)
我们的函数组合定义修改如下:
const f = _.flowRight(join_, log('toLower'), mapToLower, log('splitSpace'), splitSpace)
到此为止,就实现了我们的需求。
完整代码如下:
const _ = require('lodash')
const log = _.curry((name, value) => {
console.log(`${name}的结果为: `, value)
return value
})
const split = _.curry((separator, str) => _.split(str, separator))
const splitSpace = split(' ')
const map = _.curry((callback, array) => _.map(array, callback))
const mapToLower = map(_.toLower)
const join = _.curry((separator, array) => _.join(array, separator))
const join_ = join('_')
const str = 'BI AN FAN HUA'
const f = _.flowRight(join_, log('toLower'), mapToLower, log('splitSpace'), splitSpace)
console.log(f(str));
Lodash 中的FP模块
在实现上面的需求是,需要反复对Loadsh中的函数进行柯里化处理,这显然是比较麻烦的。
Lodash中包含一个FP模块,该模块中提供了一些比较实用的对函数式编程友好的函数,提供了不可变的函数,这些函数都是已经柯里化的,函数优先,数据滞后的。
示例代码如下:
const _ = require('lodash')
const fp = require('lodash/fp')
// 对比一下 _.split() 与 fp.split() 的区别
const r1 = _.split('一 碗 周', ' ') // 数据优先 函数滞后,未柯里化
console.log(r1) // [ '一', '碗', '周' ]
const r2 = fp.split(' ')('一 碗 周') // 自动柯里化,函数优先,数据滞后
console.log(r2) // [ '一', '碗', '周' ]
根据这个特性我们改写一下上面那个案例:
const fp = require('lodash/fp')
const str = 'BI AN FAN HUA'
const f = fp.flowRight(fp.join('_'), fp.map(fp.toLower), fp.split(' '))
console.log(f(str)) // bi_an_fan_hua
可以看到,代码量巨减。(牛啊牛啊)
值得注意的是,
lodash
和lodash/fp
中的map
函数有所不同。
Pointfree编程风格
Pointfree是一种编程风格,这种风格要求我们把数据处理的过程定义成与数据无关的合成运算,不需要关注代表数据的参数,只需要将简单的运算步骤聚合到一起,在使用这种模式之前,我们需要定义一些辅助的基本运算函数(函数组合)。
我们可以将上面概括为以下三点:
-
不需要指明处理的数据。
-
只需要合成运算过程。
-
需要定义一些辅助的基本运算函数。
示例代码如下:
/*
需求:将 Hello World 转换为 hello_world
*/
// 非 Point Free 编程风格
function f (word) {
// 先将字母转换为全部小写,在匹配所有的空格替换为_
return word.toLowerCase().replace(/\s+/g, '_')
}
// Point Free 风格代码
const fp = require('lodash/fp')
// 过程中,不关心处理的数据
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
函数组合其实就是Pointfree编程风格的代码。
demo:把一个字符串中的首字母提取,并转换成为大写,使用.
作为分隔符。示例代码如下:
/*
需求:将 world wild web 转换为 W. W. W
*/
const fp = require('lodash/fp')
/*
分析需求
1. 将字符串以空格进行拆分
2. 提取拆分后的第一个字母,并将其转换为大写
3. 以. 进行拼接
*/
// * 函数式编程时,函数名尽量要具有语义
const firstLetterToUpper = fp.flowRight(
fp.join('. '),
/* 在函数组合中嵌套一个函数组合 */
fp.map(fp.flowRight(fp.first, fp.toUpper)),
fp.split(' ')
)
console.log(firstLetterToUpper('world wild web')) // W. W. W
函子
什么是函子
在函数式编程中,函子(functor)是受到范畴论函子启发的一种设计模式,它允许泛化类型在内部应用一个函数而不改变泛化类型的结构。
学习好函子这个概念,对以后的函数式编程很重要,那到底什么是函子的?举一个例子来解释一下什么是函子。
假如我要给我住在美国的二姑快递一些保定的驴肉,很明显我不可以买好驴肉直接送过去,需要将驴肉包装,那么这个驴肉就是被容器化的肉,然后将这个包装好的驴肉交给快递员,我会告诉快递员这个驴肉的打开方法,因为通过海关时候需要打开进行检疫、盖上邮戳、重新包装,最后在送往美国。
以上例子并不是肉自己走进包装盒里面的,而是容器化之后的肉,有包装的方法,比如阴冷保存、速食、向上打开等注意事项、防止海关检查的时候不小心损坏。
我们用代码实现如下:
// 存放肉的盒子
class MeatBox {
constructor(meat) {
this._value = meat
}
// map 表示打开包装的方法
map (fn) {
return new MeatBox((fn(this._value)))
}
}
海关打开之后检疫完成,他又会根据盒子的规范重新打包成新的肉容器,方便在美国海关检疫,然后送到二姑手上。
实现流程代码如下:
// 将肉容器化
let meatBox1 = new MeatBox('驴肉')
// 海关检疫,然后将驴肉进行包装快递。
let meatBox2 = meatBox1.map(meat => check(meat))
// 二姑吃到的保定的驴肉,然后将驴肉包装起来进行存放
let meatBox3 = meatBox2.map(meat => eat(meat))
用链式写法
new MeatBox('驴肉').map(meat => check(meat)).map(meat => eat(meat))
我们总结一下上面的几个特点:
-
肉从一个单体或者一个值被容器化了,变成了一个具有数据类型的容器。
-
每一次对肉操作都会将肉拿出来进行操作之后又重新根据规则或者某种协议进行容器化。
-
容器具有
map
这个方法,用于取值,并且返回的也具有map方法。 -
可以进行链式调用。
所谓的函子就是值被容器化之后具有一条标准协议规范的数据类型或者数据容器。
函子的概念如下:
-
函数遵守一些特定规则的类型容器获取数据编程协议。
-
具有一个通用
map
方法,该方法返回新实例,这个实例和之前实例有相同的规则。 -
具有与结婚外部运算能力。
为什么使用函子
在讲解函子之前,我们需要了解为什么要有函子。
先看下面的代码:
function double (x) {
return x * 2
}
function add5 (x) {
return x + 5
}
var a = add5(5)
double(a)
// 或者
double(add5(5))
我们想要以数据为中心,串行的方式去执行
(5).add5().double()
很明显,这样的串行调用就清晰多了。但是要实现这样的串行调用,需要(5)
必须是一个引用类型,因为需要挂载方法。同时,引用类型上要有可以调用的方法也必须返回一个引用类型,保证后面的串行调用。
class Num {
constructor (value) {
this.value = value ;
}
add5 () {
return new Num( this.value + 5)
}
double () {
return new Num( this.value * 2)
}
}
var num = new Num(5);
num.add5 ().double ()
我们通过new Num(5)
,创建了一个num
类型的实例。把处理的值作为参数传了进去,从而改变了 this.value
的值。我们把这个对象返会出去,可以继续调用方法去处理数据。
通过上面的做法,我们已经实现了串行调用。但是,这样的调用很不灵活。如果我想再实现个减一的函数,还要再写到这个 Num 构造函数里。所以,我们需要思考如何把对数据处理这一层抽象出来,暴露到外面,让我们可以灵活传入任意函数。来看下面的做法:
class Num {
constructor (value) {
this.value = value ;
}
map (fn) {
return new Num( fn(this.value) )
}
}
var num = new Num(5);
num.map(add5).map(double)
我们创建了一个map
方法,把处理数据的函数fn
传了进去。这样我们就完美的实现了抽象,保证的灵活性。
理解函子
现在编写一个简单的函子,实例代码如下:
class Container {
constructor(value) {
// 函子中的值是保存在内部的,不对外部公布
// 定义私有成员使用_开头作用约束
this._value = value
}
// 有一个对外的方法map,接受一个函数(纯函数),来处理私有的值
map (fn) {
// 返回一个新的函子,把fn处理的值传递给函子,有新的函子来保存
return new Container(fn(this._value))
}
}
// 创建一个函子对象
let r = new Container(5)
.map(x => x + 1) // 6
.map(x => x * x) // 36
// 返回了一个Container对象,其具有一个_Value的值,不对外部公布
console.log(r) // Container { _value: 36 }
Pointed函子
Pointed函子是实现了of
静态方法的函子。因为上面的代码中使用的是面向对象的编程方式,在函数式编程中,应该避免使用new
关键字,于是Pointed函子就出现了。
of
静态方法是为了避免使用new
关键字来关键对象,创建对象的实现在of
静态方法中实现。
这里更深层的含义就是of
方法用来将值放到上下文中。实现代码如下:
class Container {
static of (value) {
//使用类的静态方法,of替代了new Container的作用
return new Container(value)
}
constructor(value) {
this._value = value
}
map (fn) {
return Container.of(fn(this._value))
}
}
let r = Container.of(5)
.map(x => x + 1) // 6
.map(x => x * x) // 36
console.log(r) // Container { _value: 36 }
但是上面的代码有一个问题,如果我们传递null
或者undefined
就会出现意想不到的结果,导致我们的函数变得不纯,代码如下:
class Container {
static of (value) {
//使用类的静态方法,of替代了new Container的作用
return new Container(value)
}
constructor(value) {
this._value = value
}
map (fn) {
return Container.of(fn(this._value))
}
}
let r = Container.of(null)
.map(x => x + 1)
.map(x => x * x)
console.log(r)
想要解决这个问题,我们接着往下看
MayBe函子
MayBe函子可以解决上面出现的问题,并作出相应的处理,其作用就是可以对外部空值得情况做处理(控制副作用在允许的范围)实现代码如下:
class MayBe {
static of (value) {
return new MayBe(value)
}
constructor(value) {
this._value = value
}
map (fn) {
// 如果当期值为 null 或者 undefined 将 null 传递作为值传递给当前类
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
}
// 定义一个辅助方法,用于判断当前是否存在问题
isNothing () {
return this._value === null || this._value === undefined
}
}
// 正常传递
const r1 = MayBe.of('hello world').map(val => val.toUpperCase())
console.log(r1) // MayBe { _value: 'HELLO WORLD' }
// 传递一个null
const r2 = MayBe.of(null).map(val => val.toUpperCase())
console.log(r2) // MayBe { _value: null }
MayBe函子也存在一个问题,就是链式调用次数过多时,我们并不知道那个环节出现了问题。如下代码:
const r3 = MayBe.of('hello world')
.map(val => val.toUpperCase())
.map(x => undefined)
.map(val => val.toUpperCase())
console.log(r3) // MayBe { _value: null }
在上面的代码中,我们并不知道那个环节出了问题。
Either函子
条件运算if...else
是最常见的运算之一,函数式编程里面,使用 Either 函子表达。
Either 函子内部有两个值或者是具有两个类:左值(Left
)和右值(Right
)。右值是正常情况下使用的值,左值是右值不存在时(出现异常时)使用的默认值。示例代码如下:
// 一个类定义两个值的写法
class Either {
static of (left, right) {
return new Either(left, right)
}
constructor(left, right) {
this._left = left
this._right = right
}
map (fn) {
// 如果 right 的值存在,left 的值原封不动的返回,否则反之
return this._right
?
Either.of(this._left, fn(this._right))
:
Either.of(fn(this._left), this._right)
}
}
// * 定义两个类的写法的写法
// 出现异常时调用
class Left {
static of (value) {
return new Left(value)
}
constructor(value) {
this._value = value
}
map (fn) {
return this
}
}
// 正常时调用
class Right {
static of (value) {
return new Right(value)
}
constructor(value) {
this._value = value
}
map (value) {
return Right.of(fn(this._value))
}
}
// 有一个可能会出现异常的需求,将字符串转换为JSON对象
// 这里通过 try 来捕获异常,如果出现异常,则将值保存为left,否则正常处理
// * 一个类的写法
function parseJSON1 () {
try {
return Either.of(null, JSON.parse('{name: "一碗粥"}'))
} catch (e) {
console.log(e.message);
return Either.of('{name: “一碗周”}', null)
}
}
// * 两个类的写法
function parseJSON2 () {
try {
return Right.of(JSON.parse('{name: 一碗粥}'))
} catch (e) {
console.log('报错了');
return Left.of('{name: 一碗粥}')
}
}
console.log(parseJSON1()) // Either { _left: null, _right: { name: '一碗粥' } }
console.log(parseJSON2()) // Unexpected token n in JSON at position 1 Right { _value: '[name: ‘一碗粥’]' }
IO函子
IO就是Input and Output,即输入输出,IO函子中的_value
是一个函数,这里是把函数作为值来处理。
IO函子可以把不纯的函数存储到_value
中,延迟执行这个不纯的操作(惰性执行),包装当前的操作为纯函数。
把不纯的操作交给调用者来使用(把不纯的函数延迟执行到调用时),示例代码如下:
// 这里需要将函数组合
const fp = require('lodash/fp')
class IO {
static of (value) {
// 将传递的值 value 通过函数包裹起来了,把求值延迟了。需要调用_value
// IO 函子最终还是想要一个结果 需要值的时候再取值
return new IO(() => {
return value
})
}
constructor(fn) {
this._value = fn
}
map (fn) {
// map 返回一个函数组合,第一个函数为调用of传递的函数,所以调用map时只需要传递一个fn函数即可
return new IO(fp.flowRight(fn, this._value))
}
}
// 获取当前进程执行的路径
let r = IO.of(process).map(process => process.execPath)
console.log(r._value()) // C:\Program Files\nodejs\node.exe
IO函子内部帮我们包装了一些函数,当然我们传递的函数有可能是不纯的操作,我们不管这个操作是不是纯的,IO函子返回的结果始终是纯的操作,我们调用map的时候,始终会返回一个IO函子。
而_value
属性保留的组合函数,有可能是不纯的,我们在执行时调用它,控制了副作用在可控的范围内发生。
Task函子
-
函子可以控制副作用,还可以处理异步任务,为了避免地狱之门。
-
异步任务的实现过于复杂,我们使用
folktale
中的Task
来演示。 -
folktale一个标准的函数式编程库。和
lodash
、ramda
不同的是,他没有提供很多功能函数。只提供了一些函数式处理的操作,例如:compose
、curry
等,一些函子Task
、Either
、MayBe
等。
安装:
npm i folktale --save
folktale中的curry函数
const { compose, curry } = require('folktale/core/lambda')
// curry中的第一个参数是函数有几个参数,为了避免一些错误
const f = curry(2, (x, y) => x + y)
console.log(f(1, 2)) // 3
console.log(f(1)(2)) // 3
folktale中的compose函数
const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')
// compose 组合函数在lodash里面是flowRight
const r = compose(toUpper, first)
console.log(r(['one', 'two'])) // ONE
Task函子异步执行
const { task } = require('folktale/concurrency/task')
const fs = require('fs')
// 2.0中是一个函数,函数返回一个函子对象
// 1.0中是一个类
//读取文件
function readFile (filename) {
// task传递一个函数,参数是resolver
// resolver里面有两个参数,一个是reject失败的时候执行的,一个是resolve成功的时候执行的
return task(resolver => {
//node中读取文件,第一个参数是路径,第二个是编码,第三个是回调,错误在先
fs.readFile(filename, 'utf-8', (err, data) => {
if(err) resolver.reject(err)
resolver.resolve(data)
})
})
}
//演示一下调用
// readFile调用返回的是Task函子,调用要用run方法
readFile('package.json')
.run()
// 现在没有对resolve进行处理,可以使用task的listen去监听获取的结果
// listen传一个对象,onRejected是监听错误结果,onResolved是监听正确结果
.listen({
onRejected: (err) => {
console.log(err)
},
onResolved: (value) => {
console.log(value)
}
})
/** {
"name": "Functor",
"version": "1.0.0",
"description": "",
"main": "either.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"folktale": "^2.3.2",
"lodash": "^4.17.20"
}
}
*/
Demo:在package.json
文件中提取一下version
字段
const { task } = require('folktale/concurrency/task')
const fs = require('fs')
const { split, find } = require('lodash/fp')
// 2.0中是一个函数,函数返回一个函子对象
// 1.0中是一个类
//读取文件
function readFile (filename) {
// task传递一个函数,参数是resolver
// resolver里面有两个参数,一个是reject失败的时候执行的,一个是resolve成功的时候执行的
return task(resolver => {
//node中读取文件,第一个参数是路径,第二个是编码,第三个是回调,错误在先
fs.readFile(filename, 'utf-8', (err, data) => {
if(err) resolver.reject(err)
resolver.resolve(data)
})
})
}
//演示一下调用
// readFile调用返回的是Task函子,调用要用run方法
readFile('package.json')
//在run之前调用map方法,在map方法中会处理的拿到文件返回结果
// 在使用函子的时候就没有必要想的实现机制
.map(split('\n'))
.map(find(x => x.includes('version')))
.run()
// 现在没有对resolve进行处理,可以使用task的listen去监听获取的结果
// listen传一个对象,onRejected是监听错误结果,onResolved是监听正确结果
.listen({
onRejected: (err) => {
console.log(err)
},
onResolved: (value) => {
console.log(value) // "version": "1.0.0",
}
})
Monad函子
IO函子的嵌套问题
Monad函子可以解决IO函子的嵌套问题,IO函子的嵌套问题如下:
const fp = require('lodash/fp')
const fs = require('fs')
class IO {
static of (value) {
return new IO(() => {
return value
})
}
constructor (fn) {
this._value = fn
}
map(fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
//读取文件函数
let readFile = (filename) => {
return new IO(() => {
//同步获取文件
return fs.readFileSync(filename, 'utf-8')
})
}
//打印函数
// x是上一步的IO函子
let print = (x) => {
return new IO(()=> {
console.log(x)
return x
})
}
// 组合函数,先读文件再打印
let cat = fp.flowRight(print, readFile)
// 调用
// 拿到的结果是嵌套的IO函子 IO(IO(x))
let r = cat('package.json')
console.log(r)
// IO { _value: [Function] }
console.log(cat('package.json')._value())
// IO { _value: [Function] }
// IO { _value: [Function] }
console.log(cat('package.json')._value()._value())
// IO { _value: [Function] }
/**
* {
"name": "Functor",
"version": "1.0.0",
"description": "",
"main": "either.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"folktale": "^2.3.2",
"lodash": "^4.17.20"
}
}
*/
上面遇到多个IO函子嵌套的时候,那么_value就会调用很多次,这样的调用体验很不好。所以进行优化。
什么是Monad函子
-
Monad函子是可以变扁的
Pointed
函子,用来解决IO函子嵌套问题,IO(IO(x))
-
一个函子如果具有
join
和of
两个方法并遵守一些定律就是一个Monad
实现一个Monad函子
const fp = require('lodash/fp')
const fs = require('fs')
class IO {
static of (value) {
return new IO(() => {
return value
})
}
constructor (fn) {
this._value = fn
}
map(fn) {
return new IO(fp.flowRight(fn, this._value))
}
join () {
return this._value()
}
// 同时调用map和join方法
flatMap (fn) {
return this.map(fn).join()
}
}
let readFile = (filename) => {
return new IO(() => {
return fs.readFileSync(filename, 'utf-8')
})
}
let print = (x) => {
return new IO(()=> {
console.log(x)
return x
})
}
let r = readFile('package.json')
.flatMap(print)
.join()
// 执行顺序
/**
* readFile读取了文件,然后返回了一个IO函子
* 调用flatMap是用readFile返回的IO函子调用的
* 并且传入了一个print函数参数
* 调用flatMap的时候,内部先调用map,当前的print和this._value进行合并,合并之后返回了一个新的函子
* (this._value就是readFile返回IO函子的函数:
* () => {
return fs.readFileSync(filename, 'utf-8')
}
* )
* flatMap中的map函数执行完,print函数返回的一个IO函子,里面包裹的还是一个IO函子
* 下面调用join函数,join函数就是调用返回的新函子内部的this._value()函数
* 这个this._value就是之前print和this._value的组合函数,调用之后返回的就是print的返回结果
* 所以flatMap执行完毕之后,返回的就是print函数返回的IO函子
* */
r = readFile('package.json')
// 处理数据,直接在读取文件之后,使用map进行处理即可
.map(fp.toUpper)
.flatMap(print)
.join()
// 读完文件之后想要处理数据,怎么办?
// 直接在读取文件之后调用map方法即可
/**
* {
"NAME": "FUNCTOR",
"VERSION": "1.0.0",
"DESCRIPTION": "",
"MAIN": "EITHER.JS",
"SCRIPTS": {
"TEST": "ECHO \"ERROR: NO TEST SPECIFIED\" && EXIT 1"
},
"KEYWORDS": [],
"AUTHOR": "",
"LICENSE": "ISC",
"DEPENDENCIES": {
"FOLKTALE": "^2.3.2",
"LODASH": "^4.17.20"
}
}
*/
Monad函子小结
什么是Monad函子?
答:具有静态的IO
方法和join
方法的函子。
什么时候使用Monad?
答:
-
当一个函数返回一个函子的时候,我们就要想到monad,monad可以帮我们解决函子嵌套的问题。
-
当我们想要返回一个函数,这个函数返回一个值,这个时候可以调用****方法
-
当我们想要去合并一个函数,但是这个函数返回一个函子,这个时候我们要用
flatMap
方法
{完}