JavaScript函数式编程
一、为什么要学习函数式编程?
首先,函数式编程是一个很古老的概念它早于第一台计算机的诞生。
那我们为什么现在还要学习函数式编程呢?大概有以下几点。
- 函数式编程随着React的流行受到越来越多的关注
- Vue3也开始拥抱函数式编程
- 函数式编程可以抛弃this
- 打包过程中可以更好的利用tree shaking过滤无用代码
- 方便测试、方便并行处理
- 有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda等
二、什么是函数式编程
函数式编程(Functional Programming, FP)。FP是一种编程范式。同样的还有其他的编程范式,例如:面向对象编程、面向过程编程。
-
面向对象编程的思维方式:把现实世界中的事物抽象成程序世界中的类、对象,通过封装、继承、多态来展示事物之间的联系。
-
函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)
- 这里程序的本质:有输入就会根据运算获得相应的输出;
- 和数学的函数思想一致 x->y, x=f(y);
- 函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系。例如:y= sin(x);
- 相同的输入始终要得到相同的输出(纯函数)
- 函数式编程用来描述数据(函数)之间的映射关系
通过下面的代码体会非函数式编程和函数式编程(示例):
//非函数式的
let num1 = 5
let num2 = 6
console.log(num1 + num2)
//函数式编程
function add(num1, num2) {
return num1 + num2
}
let sum = add(5, 6)
console.log(sum)
三、学习函数式编程
学习函数式编程之前,我们需要先学习以下知识:
- 函数是一等公民
- 高阶函数
- 闭包
1. 函数是一等公民
为什么说函数是一等公民(First-class Function) 呢?MDN中给出如下三个原因:
- 函数可以存储在变量中
- 函数可以作为参数
- 函数可以作为返回值
函数是一等公民是学习后面的高阶函数、柯里化等的基础
2. 高阶函数
2.1 什么是高阶函数
什么是高阶函数?以下情况的函数都可以称为高阶函数。
- 把函数作为参数传递给另一个函数
- 把函数作为另一个函数的返回值
//一、函数作为参数
//1. forEach 循环
function forEach (array, fn){
for (let i = 0; i < array.length; i++) {
fn(array[i])
}
}
//2. filter 过滤
function filter (array, fn){
let result = []
for (let i = 0; i < array.length; i++) {
if(fn(array[i])) {
result.push(array[i])
}
}
return result
}
//二、函数作为另一个函数的返回值
//1.
function createFn () {
let msg = "hello world"
return function () {
console.log(msg)
}
}
//2.workOnce 使得传进来的函数只能执行一次
function workOnce (fn) {
let done = false;
return function (...args) {
if(!done) {
done = true
return fn(...args)
//return fn.apply(this, arguments)
}
}
}
//测试 workOnce
let pay = workOnce(function(money) {
console.log(`支付了${money}`)
})
pay(100)
pay(100)
pay(100)
// 支付了100
2.2 高阶函数的意义
- 抽象可以帮助我们屏蔽细节,我们只需要关注我们的目标
- 高阶函数是用来抽象通用的问题
//面向过程的方式
let arr = [1, 2, 3, 4]
for (let i = 0; i < arr.length; i++) {
console.log(arr[i])
}
//高阶函数
let arr = [1, 2, 3, 4]
forEach(arr, item => {
console.log(item)
})
let r = filter(arr, item => {
return item % 2 === 0
})
2.3 常用的高阶函数
模拟常用的高阶函数 map、every、some
//map 遍历数组每一项,对每个元素执行传入的fn,将结果放入新的数组返回
const map = function (array, fn) {
let result = [];
for (let item of array) {
result.push(fn(item))
}
return result
}
//every 遍历数组每一项,判断是否都符合传入的fn,返回true 或者 false
const every = function (array, fn) {
let result = true
for (let item of array) {
result = fn(item)
if (!result) {
break
}
}
return result
}
//some 遍历数组每一项,判断是否有符合传入fn的项, 返回 true 或者 false
const some = function (array, fn) {
let result = false
for (let item of array) {
result = fn(item)
if (result) {
break
}
}
return result
}
3. 闭包
闭包(closure):函数和其周围的状态(此法环境)的引用捆绑在一起形成闭包。即可以在另一个作用域中调用一个函数内部的函数并访问到该函数的作用域中的成员。
//
function makePower (power) {
return function (num) {
return Math.pow(num, power)
}
}
const power2 = makePower(2)
const power3 = makePower(3)
console.log(power2(2))
console.log(power2(5))
Chrome浏览器中控制台断点的作用域Scope处,可以看到执行过程中,有闭包产生。如图:
闭包的本质:函数在执行的时候被会放到一个执行栈上,执行栈上的函数执行完毕后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员
三、纯函数
1 纯函数的概念
纯函数:相同的输入永远会得到相同的输出,而且没有任何可观察到的副作用。举个例子,数组中的方法slice和splice分别是纯函数和不纯的函数。
let arr = [1, 2, 3, 4]
//纯函数
console.log(arr.slice(0, 2))
console.log(arr.slice(0, 2))
console.log(arr.slice(0, 2))
//[ 1, 2 ]
//[ 1, 2 ]
//[ 1, 2 ]
//不纯函数
console.log(arr.splice(0, 2))
console.log(arr.splice(0, 2))
console.log(arr.splice(0, 2))
//[ 1, 2 ]
//[ 3, 4 ]
//[]
2 纯函数的好处
- 可缓存
- 可测试。纯函数让测试更加方便
- 并行处理。
- 在多线程的环境下并行操作共享内存数据很可能会出现意外情况
- 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数
//1、可缓存
//举例:记忆函数,利用纯函数相同的输入返回限购听的输出,可以缓存之前执行的结果,这样避免频繁的执行相同的输入的过程
const _ = require('lodash')
function getArea (r) {
console.log(r)
return Math.PI * r * r
}
let getAreaWithMemory = _.memoize(getArea)
console.log(getAreaWithMemory(5))
//5
//78.53981633974483
console.log(getAreaWithMemory(5))
//78.53981633974483
console.log(getAreaWithMemory(5))
//78.53981633974483
//相同的输入下,方法getArea只执行了一次
//模拟 memoize 方法
function memoize (fn){
let cache = {}
return function(){
let key = JSON.stringify(arguments)
cache[key] = cache[key] || fn.apply(fn, arguments)
return cache[key]
}
}
let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(5))
//5
//78.53981633974483
console.log(getAreaWithMemory(5))
//78.53981633974483
console.log(getAreaWithMemory(5))
//78.53981633974483
3 纯函数副作用
纯函数:对于相同的输入永远得到相同的输出,而且没有任何可以观察到的副作用
//不纯的,外部的min值如果变化,函数的结果也会变化
let min = 18
function checkAge (age) {
return age >= min
}
//纯的(有硬编码,后续可以通过柯里化解决)
function checkAge(age) {
let min = 18
return age >= min
}
副作用让一个函数变的不纯,如第一个例子,纯函数的根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。
副作用的来源:
- 配置文件
- 数据库
- 获取用户的输入
- …
所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降,不适合扩展和可重用。同时副作用会给程序中带来安全隐患,给程序带来不确定性,但是副作用不可能完全禁止,我们只能尽可能的将它们控制在可控范围内发生。
四、lodash库
lodash是一个纯函数的功能库,提供了数组、数字、对象、字符串、函数等操作的一些方法,这些都是纯函数方法。
1 基本使用
lodash官网 www.lodash.com
/*
* npm init -y初始化package-lock.json
* npm i lodash下载lodash依赖包
*/
const _ = require('lodash')
//lodash 提供一些数组字符串方法,例如 first、last、toUpper、reverse、each、includes、find、findIndex
let arr = ['one', 'two', 'three', 'four']
console.log(_.first(arr))
//one
console.log(_.last(arr))
//four
console.log(_.toUpper(arr))
//ONE,TWO,THREE,FOUR
console.log(_.reverse(arr))
//['four', 'three', 'two', 'one']
五、柯里化
柯里化(Haskell Brooks Curry)
将含有多个参数的函数,经过"包装",变为只有一个参数的函数,将其他参数的值都固定住之后,返回新的函数,这个函数等待传入最后一个参数,然后执行返回结果。
1. 柯里化演示
// 柯里化演示
function checkAge (age) {
let min = 18
return age >= min
}
// 普通的纯函数
function checkAge (min, age) {
return age >= min
}
console.log(checkAge(18, 20))
console.log(checkAge(18, 24))
console.log(checkAge(22, 24))
// 函数的柯里化
function checkAge (min) {
return function (age) {
return age >= min
}
}
// ES6
let checkAge = min => (age => age >= min)
let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)
console.log(checkAge18(20))
console.log(checkAge18(24))
2. lodash中的柯里化函数
_.curry(func)
功能: 创建一个函数,该函数接收一个或多个func的参数,如果func所需要的的参数都被提供则执行func并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
参数: 需要柯里化的函数
返回值: 柯里化后的函数
//举例说明
//案例一
//引入lodash库
const _ = require('lodash')
//需要柯里化的函数,穿插概念:含有一个参数的函数称为一元函数、两个参数的称为二元函数、三个参数的称为三元函数...
function getSum(a, b, c) {
return a + b + c
}
//柯里化后的函数
let curried = _.curry(getSum)
//测试
console.log(curried(1, 2, 3))
//6
console.log(curried(1)(2, 3))
//6
console.log(curried(1, 2)(3))
//6
console.log(curried(1)(2)(3))
//6
//案例二
// ''.match(/\s+/g) 匹配字符串中所有的空格
// ''.match(/\d+/g) 匹配字符串中所有的数字
const _ = require('lodash')
//利用柯里化函数将函数柯里化
const match = _.curry(function (reg, str) {
return str.match(reg)
})
//得到专门匹配空格的函数 haveSpace
const haveSpace = match(/\s+/g)
//得到专门匹配数字的函数 haveNumber
const haveNumber = match(/\d+/g)
//测试
console.log(haveSpace('helloworld'))
//null
console.log(haveNumber('abc'))
//null
//对数组进行过滤 filter进行封装成纯函数
const filter = _.curry(function (func, array) {
return array.filter(func)
})
//寻找含有空格元素
console.log(filter(haveSpace, ['John Connor', 'John_Donne']))
//['John Connor']
//经过柯里化可以得到可以过滤数组具有空白字符的元素的方法 findSpace
const findSpace = filter(haveSpace)
console.log(findSpace(['John Connor', 'John_Donne']))
//['John Connor']
总结:
- 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数
- 这是一种对函数参数的"缓存"
- 让函数变的更灵活、让函数的粒度更小
- 可以把多元函数转换成一元函数、可以组合使用函数产生强大的功能
//模拟curry 方法实现
function curry (func) {
return function curriedFn(...args) {
// 判断实参和形参的个数
if (args.length < func.length) {
return function () {
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return func(...args)
}
}
六、 函数组合
1.函数组合的概念
纯函数和柯里化很容易写出洋葱代码 。
例如:像 h(g(f(x)))、获取数组最后一个元素再转换为大写字母 .toUpper(.first(_.reverse(array)))
函数组合可以让我们把细粒度的函数重新组合生成一个新的函数
"管道"概念
下面这张图表示在程序中使用函数处理数据的过程。给fn函数输入数据a,经过"管道"处理,得到结果b
当fn函数比较复杂的时候,我们可以把管道拆成多个小的函数,此时也会多个中间过程产生的一些数据m和n
上述过程描述成代码可以理解为以下形式:
fn = compose(f1, f2, f3)
b = fn(a)
函数组合: 如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。函数的组合默认是从右向左执行
2.lodash中的组合函数
lodash中的组合函数有两个。flow()和flowRight()。flow是从左向右运行。flowRight是从右向左运行,使用的多一些。
//使用组合函数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(['one', 'two', 'three']))
//THREE
//模拟lodash中flowRight 命名为compose
//函数的逻辑是 可以返回一个新的函数。并且函数只有一个形参,该形参会在传入的函数中从右向左依次执行,每次执行的输入值为上一个的执行结果,然后最终输出最后的值
function compose (...args) {
return function (value) {
return args.reverse().reduce(function (acc, fn) {
return fn(acc)
}, value)
}
}
//改写为ES6语法
const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)
const f = compose(toUpper, first, reverse)
//测试
console.log(f(['one', 'two', 'three']))
//THREE
3.组合函数要满足结合律
// 函数组合要满足结合律
const _ = require('lodash')
// const f = _.flowRight(_.toUpper, _.first, _.reverse)
// const f = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse)
const f = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse))
console.log(f(['one', 'two', 'three']))
4.组合函数-调试
// 函数组合 调试
// 组合函数的逻辑需求是将字符串 NEVER SAY DIE 转为 never-say-die
const _ = require('lodash')
//简单的调试方法
// const log = v => {
// console.log(v)
// return v
// }
//升级后的调试方法,可以打印一些提示信息,将调试方法放到想要调试的方法后面
const trace = _.curry((tag, v) => {
console.log(tag, v)
return v
})
// _.split()
const split = _.curry((sep, str) => _.split(str, sep))
// _.toLower()
const join = _.curry((sep, array) => _.join(array, sep))
const map = _.curry((fn, array) => _.map(array, fn))
const f = _.flowRight(join('-'), trace('map 之后'), map(_.toLower), trace('split 之后'), split(' '))
//测试
console.log(f('NEVER SAY DIE'))
//split 之后 [ 'NEVER', 'SAY', 'DIE' ]
//map 之后 [ 'never', 'say', 'die' ]
//never-say-die
七、 lodash-fp模块
1.lodash/fp模块
从上面的例子中我们可以感受到,在使用函数组合的过程中,对组合的函数的要求还是有的,而且形参的顺序也有所需求,比如:.join、.split。在组合过程中,我们更希望它的形参是数据在后更方便组合使用。
lodash/fp模块就提供了实用的对函数编程友好的方法。提供了不可变的柯里化后的、函数优先,数据置后的方法。对比如下:
//lodash模块
const _ = reqire('lodash')
_.map(['a', 'b', 'c'], _.toUpper)
//['A', 'B', 'C']
_.map(['a', 'b', 'c'])
//['a', 'b', 'c']
_.split('hello world', ' ')
//['hello', 'world']
//lodash/fp模块
const fp = require('lodash/fp')
fp.map(fp.toUpper, ['a', 'b', 'c'])
//['A', 'B', 'C']
fp.map(fp.toUpper)(['a', 'b', 'c'])
//['A', 'B', 'C']
fp.split(' ', 'hello world')
//['hello', 'world']
fp.split('')('hello world')
//['hello', 'world']
lodash中的map方法和lodash/fp中的map有点区别,在于传递的函数形参个数
//lodash map
_.map(['10', '25', '55'], (value, index, array)=>{})
//lodash/fp map
fp.map(value => {}, ['10', '25', '55'])
2.PointFree
PointFree: 它是一种编程风格。我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。
// point free
// Hello World => hello_world
const fp = require('lodash/fp')
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
console.log(f('Hello World'))
// 把一个字符串中的首字母提取并转换成大写, 使用. 作为分隔符
// world wild web ==> W. W. W
const fp = require('lodash/fp')
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '))
//简化后
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '))
console.log(firstLetterToUpper('world wild web'))
3.Functor(函子)
- 为什么要学习函子?
处理在函数式编程中,如何把副作用控制在可控的范围内、异常处理、异步操作等
- 什么是Functor(函子)
容器:包含值和值的变形关系(这个变形关系就是函数)
函子:是一个特殊的容器,通过普通的对象来实现,该对象具有map方法,map方法可以运行一个函数,对值进行处理(变形关系)
//函子
class Container {
static of (value) {
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 + 2)
.map(x => x * x)
console.log(r)
//Container { _value: 49 }
总结
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了map契约的对象
- 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
- 想要处理盒子中的值, 我们需要给盒子的map方法传递一个处理值的函数(纯函数),由这个函数对值进行处理
- 最终map方法返回一个包含新值的盒子(函子)
4.MayBe函子
- 我们在编程过程中可能会遇到很多错误,需要对这些错误做相应的处理
- MayBe函子的作用就是可以对外部的控制情况做处理(控制副作用在可控的范围内)
// MayBe 函子
class MayBe {
static of (value) {
return new MayBe(value)
}
constructor (value) {
this._value = value
}
map (fn) {
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
}
isNothing () {
return this._value === null || this._value === undefined
}
}
let r = MayBe.of('Hello World')
.map(x => x.toUpperCase())
console.log(r)
//MayBe { _value: 'HELLO WORLD' }
let r = MayBe.of(null)
.map(x => x.toUpperCase())
console.log(r)
//MayBe { _value: null }
let r = MayBe.of('hello world')
.map(x => x.toUpperCase())
.map(x => null)
.map(x => x.split(' '))
console.log(r)
//MayBe { _value: null }
5.Either函子
- Either 两者中的任何一个,类似于if…else…的处理
- 异常会让函数变的不纯,Either函子可以用来做异常处理
// Either 函子
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 (fn) {
return Right.of(fn(this._value))
}
}
let r1 = Right.of(12).map(x => x + 2)
let r2 = Left.of(12).map(x => x + 2)
console.log(r1)
//Right { _value: 14 }
console.log(r2)
//Left { _value: 12 }
function parseJSON (str) {
try {
return Right.of(JSON.parse(str))
} catch (e) {
return Left.of({ error: e.message })
}
}
let r = parseJSON('{ name: zs }')
console.log(r)
//Left { _value: { error: 'Unexpected token n in JSON at position 2' } }
let r = parseJSON('{ "name": "zs" }')
.map(x => x.name.toUpperCase())
console.log(r)
//Right { _value: 'ZS' }
6.IO函子
- IO函子中的_value是一个函数,这里是把函数作为值来处理
- IO函子可以把不纯的动作存储到_value中,延迟执行这个不纯的操作(惰性执行),包装当前的操作是一个纯操作
- 把不纯的操作交给调用者来处理
// IO 函子
const fp = require('lodash/fp')
class IO {
static of (value) {
return new IO(function () {
return value
})
}
constructor (fn) {
this._value = fn
}
map (fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
// 调用
let r = IO.of(process).map(p => p.execPath)
console.log(r)
//IO { _value: [Function (anonymous)] }
console.log(r._value())
//C:\Program Files\nodejs\node.exe
7.folktale 库
//npm i -y
//npm i folktale
//folktale 中的compose 和 curry
const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')
let f = curry(2, (x, y) => {
return x + y
})
console.log(f(1, 2))
//3
console.log(f(1)(2))
//3
let f = compose(toUpper, first)
console.log(f(['one', 'two']))
//ONE
8.folktale库中的Task异步执行
- folktale 2.x版本中的Task 和 1.0版本中的Task区别很大
- 下面是2.3.2版本的演示
// Task 处理异步任务
const fs = require('fs')
const { task } = require('folktale/concurrency/task')
const { split, find } = require('lodash/fp')
//读取文件的函数
function readFile (filename) {
return task(resolver => {
fs.readFile(filename, 'utf-8', (err, data) => {
if (err) resolver.reject(err)
resolver.resolve(data)
})
})
}
readFile('package.json')
.map(split('\n'))
.map(find(x => x.includes('version')))
.run()
.listen({
onRejected: err => {
console.log(err)
},
onResolved: value => {
console.log(value)
}
})
9.Pointed函子
- Pointed 函子是实现了of静态方法的函子
- of方法是为了避免使用new 来创建对象,更深层的含义是of方法用来把值放到上下文Context(把值放到容器中,使用map来处理值)
class Container {
static of(value){
return new Container(value)
}
......
}
Container.of(2)
.map(x => x +1)
10.Monad (单子)
// 先说一下 IO 函子的问题
const fs = require('fs')
const fp = require('lodash/fp')
class IO {
static of (value) {
return new IO(function () {
return value
})
}
constructor (fn) {
this._value = fn
}
map (fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
let readFile = function (filename) {
return new IO(function () {
return fs.readFileSync(filename, 'utf-8')
})
}
let print = function (x) {
return new IO(function () {
console.log(x)
return x
})
}
let cat = fp.flowRight(print, readFile)
// IO(IO(x))
//想要执行的时候需要嵌套的去执行 不太友好
let r = cat('package.json')._value()._value()
console.log(r)
- Monad函子是可以变扁的Pointed 函子, IO(IO(x))
- 一个函子如果具有join 和of两个方法并遵守一些定律就是一个Monad
// IO Monad
const fs = require('fs')
const fp = require('lodash/fp')
class IO {
static of (value) {
return new IO(function () {
return value
})
}
constructor (fn) {
this._value = fn
}
map (fn) {
return new IO(fp.flowRight(fn, this._value))
}
join () {
return this._value()
}
flatMap (fn) {
return this.map(fn).join()
}
}
let readFile = function (filename) {
return new IO(function () {
return fs.readFileSync(filename, 'utf-8')
})
}
let print = function (x) {
return new IO(function () {
console.log(x)
return x
})
}
let r = readFile('package.json')
// .map(x => x.toUpperCase())
.map(fp.toUpper)
.flatMap(print)
.join()
console.log(r)