函数式编程范式
为什么学习函数式编程
- 函数式编程是随着react的流行受到了越来越多的关注
- vue 3也开始拥抱函数式编程
- 函数式编程可以抛弃this
- 打包过程中可以更好的利用tree shaking过滤无用代码
- 方便测试和并行处理
当前也有很多库可以帮助我们进行函数式开发,eg:lodash,underscore,ramda
函数式编程概念
函数式编程(Functional Programming,FP),FP是编程范式之一。
编程范式还有:
- 面向过程编程: 按照步骤,一步一步来实现。
- 面向对象编程:把现实世界中的事物抽象成程序世界中的类和对象,通过封装,继承和多态来演示事物事件的联系
- 函数式编程:把现实世界的事物和事物之间的联系抽象到程序世界。
程序的本质:根据输入,经过运算,获得输出。执行这个过程的就是函数。而函数式编程就是对这个运算过程进行抽象,但是面向对象编程是对事物进行抽象。
函数式编程中的函数指的不是程序中的函数(方法),指的是数学中的函数,就是一个映射关系,eg: y = sin(x)。此时,相同的输入始终要得到相同的输出(纯函数)。
//非函数式
let num1 = 1
let add = num1 + 1
//函数式
function add1(num){
return num + 1
}
let add = add1(2)
函数是一等公民
- 函数可以存储在变量中
- 函数可以作为参数
- 函数可以作为返回值
eg:
//函数表达式就是把函数赋值给变量
let fun = function (){
console.log('hello')
}
//改造成赋值给变量
const obj = {
show(params){ return otherObj.show(params) }
}
//修改后
const obj = {
show: otherObj.show
}
高阶函数(Higher-order function)
高阶函数就是把函数作为参数传递给另一个函数,或者把函数作为另一个函数的返回结果。
// 利用高阶函数-将函数作为参数写一个forEach函数
function forEach(arr, fn) {
for (let i = 0; i < arr.length; i++) {
fn(arr[i], i)
}
}
const arr = [1, 2, 3, 4, 5]
forEach(arr, (item, index) => {
console.log(item, index)
})
// 利用函数可以作为返回值,实现一个once函数-只执行一次
function once(fn) {
let mark = false
return function () {
if (!mark) {
mark = true
fn.apply(this, arguments)
}
}
}
let pay = once((money) => {
console.log(`${money} RMB`)
})
pay(100)
pay(100)
pay(100)
pay(100)
高阶函数的意义:
- 抽象通用的问题
- 屏蔽细节,只关注我们的目标
常用的高阶函数:
forEach, filter, map, every, some...
闭包
在把函数当作返回值返回时,内部函数可以访问外部函数中的变量,就形成了闭包。
function outter() {
let num = 0
return function inner() {
console.log(num++)
}
}
let o = outter()
o()
o()
o()
以上例子,如果outter内部没有闭包,outter调用完毕后,outter从执行栈上移除,同时它内部的变量num也会从内存中移除,但是内部有了inner引用了变量num,outter从执行栈移除后,num因为还被inner引用,不会被移除,即-堆上的作用域成员因为被外部引用不能释放。
纯函数
纯函数:相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。
lodash是要给纯函数的功能库,提供了对数组,数字,对象,字符串,函数等操作的一些方法。
eg:数组的slice方法是纯函数,splice是不纯的函数。
- slice:返回数组中的指定部分,不会改变原数组。
- splice:对数组进行操作返回该数组,会改变原数组。
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]
// slice 没有修改原数组,每次返回结果一样,是纯函数
console.log(arr.slice(1, 3)) //[ 2, 3 ]
console.log(arr.slice(1, 3)) //[ 2, 3 ]
console.log(arr.slice(1, 3)) //[ 2, 3 ]
// splice 修改了原数组,每次返回结果不一样,是不纯的函数
console.log(arr.splice(1, 3)) //[ 2, 3, 4 ]
console.log(arr.splice(1, 3)) //[ 5, 6, 7 ]
console.log(arr.splice(1, 3)) //[ 8, 9 ]
纯函数的好处:
- 可缓存。因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来。
// 自己实现一个类似lodash中的 memoize
function getArea(r) {
console.log(r)
return Math.PI * r * r
}
function memoize(f) {
let cache = {}
return function () {
let key = JSON.stringify(arguments)
cache[key] = cache[key] || f.apply(f, arguments)
return cache[key]
}
}
let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4)) // 4 50.26548245743669
console.log(getAreaWithMemory(4)) // 50.26548245743669
console.log(getAreaWithMemory(5)) // 5 78.53981633974483
- 可测试
- 并行处理。在多线程环境(eg:Web Worker)下并行操作共享的内存数据很可能出现意外,纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数。
纯函数的副作用:
eg:
// 不纯
let mini = 18
function checkAge(age) {
return age >= mini
}
// 纯的,有硬编码
function checkAge(age) {
let mini = 18
return age >= mini
}
如果函数依赖于外部状态,就无法保证输出相同,就会带来副作用,变得不纯。
在函数内部定义一个变量的值,又会出现硬编码。不过这个可以通过柯里化解决。
副作用不可完全禁止,尽可能控制他们在可控范围内发生。
柯里化
使用柯里化可以解决硬编码的问题。
柯里化: 当一个函数有多个参数的时候,先传递一部分参数调用它,这部分参数以后永远不变,然后返回一个新的函数接受剩余的参数,返回结果。
eg:
let checkAge = min => (age => age >= min)
let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)
console.log(checkAge18(20))
console.log(checkAge20(24))
lodash中的柯里化函数
const _ = require('lodash')
function getSum(a, b, c) {
return a + b + c
}
const curried = _.curry(getSum)
console.log(curried(1, 2, 3)) //6
console.log(curried(1)(2, 3)) //6
console.log(curried(1)(2)(3)) //6
lodash中的curry方法可以将一个多元函数,转换为任意多元函数。
自己实现一个lodash.curry
function curry(fn){
return function a(...args){
if(args.length < fn.length){
return function(){
return a(...args.concat(Array.from(arguments)))
}
}
return fn(...args)
}
}
function getSum(a, b, c) {
return a + b + c
}
const curried = curry(getSum)
console.log(curried(1, 2, 3))
console.log(curried(1)(2, 3))
console.log(curried(1)(2)(3))
总结
- 柯里化可以返回一个新函数,这个新函数已经记住了某些固定参数
- 这是一种对函数参数的‘缓存’
- 让函数的粒度更小,更灵活
- 可以把多元函数转换成一元函数,组合使用函数产生强大的功能
函数组合
纯函数和柯里化很容易写出洋葱代码,eg:_.toUpper(_.first(_.reverse(arr)))
,而以函数组合的形式 fn = compose(f1, f2, f3) 把中间过程的函数合并成一个函数,看起来更简洁。函数组合默认是从右到左执行,所以执行顺序是 f3, f2, f1。
// 函数组合演示
// 洋葱代码并没有被省略,而是被封装起来了
function compose(f, g) {
return function (value) {
return f(g(value))
}
}
// 实现将数组先反转在取第一个
function reverse(arr) {
return arr.reverse()
}
function first(arr) {
return arr[0]
}
const last = compose(first, reverse)
console.log(last([1, 2, 3, 4, 5])) //5
lodash中的组合函数
- flow() 从左到右执行
- flowRight() 从右到左执行,使用的更多
// 使用lodash 中的函数组合的方法: _.flowRight()
const _ = require('lodash')
// 实现先翻转,再去第一个,再大写
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
const f = _.flowRight(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))
自己实现flowRight
function compose(...args) {
return function (value) {
return args.reverse().reduce((pre, pro) => {
return pro(pre)
}, value)
}
}
// 改为箭头函数
const compose = (...args) => value => args.reverse().reduce((pre, pro) => pro(pre), value)
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
const f = compose(toUpper, first, reverse)
console.log(f(['one', 'two', 'three'])) // THREE
函数组合-结合律
函数组合要满足结合律,即:
let associative = comose(compose(f,g),h) == compose(f,compose(g,h)) //true
函数组合-调试
函数组合的方式,如果最终结果不是预期,很难找出是哪一步出了错,eg:
// NEVER SAY DIE --> never-say-die
//将左侧字符串转换成右侧的字符串,要经过分割,变小写,再用-隔开
const log = _.curry((tag, v) => {
console.log(tag, v)
return v
})
//为了满足函数组合参数都是一个,将split和join柯里化
// _.toLower()
// _.split()
const split = _.curry((sep, str) => _.split(str, sep))
// _.join()
const join = _.curry((sep, arr) => _.join(arr, sep))
const f = _.flowRight(join('-'), _.toLower, split(' '))
console.log(f('NEVER SAY DIE')) //n-e-v-e-r-,-s-a-y-,-d-i-e
打印后发现结果和预期不一样,但是不好定位错误位置,我们可以给函数组合的每个函数中间加个log来打印每次结果是否正确。
const log = (v) => {
console.log(v)
return v
}
const f = _.flowRight(join('-'), _.toLower, log, split(' ')) //[ 'NEVER', 'SAY', 'DIE' ]
const f = _.flowRight(join('-'), log, _.toLower, split(' ')) //never,say,die
可以看到,在_.toLower后,数组变成了字符串格式,和预期不一样。但是这样打log,如果同时放入多个log,分不清打印的是哪一步,可以通过改造:
// tag 用来标注位置,v是结果,用_.curry柯里化
const log = _.curry((tag, v) => {
console.log(tag, v)
return v
})
const f = _.flowRight(join('-'), log('lower 后'), _.toLower, log('split 后'), split(' '))
// split 后 [ 'NEVER', 'SAY', 'DIE' ]
// lower 后 never,say,die
lodash-fp模块
lodash中的fp模块提供了实用的对函数式编程友好的方法,并且函数优先,数据滞后,自动柯里化。
// lodash 模块, 都是数据优先,函数滞后
_.map(['a', 'b', 'c'], _.toUpper) // ['A','B','C']
_.map(['a', 'b', 'c']) // ['a', 'b', 'c']
_.split('Hello World', ' ')
// lodash/fp 模块, 都是函数优先,数组滞后
const fp = require('lodash/fp')
fp.map(fp.toUpper, ['a', 'b', 'c'])
fp.map(fp.toUpper)(['a', 'b', 'c'])
fp.split(' ', 'Hello World')
fp.split(' ')('Hello World')
将前面的例子做修改
const f = fp.flowRight(fp.join('-'), fp.split(' '), fp.toLower)
console.log(f('NEVER SAY DIE')) // never-say-die
lodash 和 lodash/fp 模块中的map方法的区别
首先执行console.log(_.map(['23','8','10'], parseInt)) // [ 23, NaN, 2 ]
,想将数组中的字符串转为数字,但是最终拿到的结果和预期不一样,让我们看一下_.map的解释:
函数接收到的是三个参数,value,index,array,所以此时parseInt是这样执行的:
parseInt('23', 0, array)
parseInt('8', 1, array)
parseInt('10', 2, array)
parseInt的第二个参数是几进制,所以此时结果不符合预期。但是看一下fp.map方法:
可以看到fp.map方法中的函数只接受一个参数,
console.log(fp.map(parseInt, ['23', '8', '10'])) //[ 23, 8, 10 ]
,parseInt接受一个参数,就不会出现刚才的问题。
Point Free
Point Free是一种编程的风格,具体形式就是函数组合。
- 不需要指明处理的数据
- 只需要合成运算规则
- 需要定义一些辅助的基本运算函数
eg:const f = fp.flowRight(fp.join('-'), fp.split(' '), fp.toLower)
这个例子中将运算过程合成,没有指明要处理的数据。
// Hello Word => hello_world
// fp 中的方法都是柯里化的
// fp.replace可以接受3个参数,1 匹配的正则, 2 替换成什么 3 要修改的字符串
// fp.replace是柯里化所以接收两个参数可以返回一个函数,这个函数可以接受一个字符串
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
console.log(f('Hello Word')) // hello_word
案例:
// 把一个字符串中的首字母提取,转换成大写,使用. 作为分隔符
// world wild web ==> W. W. W
const f = fp.flowRight(fp.toUpper, fp.join('. '), fp.map(fp.first), fp.split(' '), fp.replace(/\s+/g, ' '))
// const f = fp.flow(fp.replace(/\s+/g, ' '), fp.split(' '), fp.map(fp.first), fp.join('. '), fp.toUpper)
console.log(f('world wild web')) // W. W. W
Functor(函子)
- 容器:包含值和值的变形关系(函数)
- 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理。
// Functor 函子
class Container {
constructor(value) {
// 维护一个不对外公布的静态变量
this._value = value
}
// map方法通过fn处理值,并通过新的函子返回
map(fn) {
return new Container(fn(this._value))
}
}
// map方法返回的是新的函子,所以还可以调用map方法
const lr = new Container(5)
.map(v => v + 3)
.map(v => v * 2)
console.log(lr) // Container { _value: 16 }
上边的new Container可以通过封装:
class Container {
static of(value) {
return new Container(value)
}
constructor(value) {
// 维护一个不对外公布的静态变量
this._value = value
}
// map方法通过fn处理值,并通过新的函子返回
map(fn) {
return Container.of(fn(this._value))
}
}
// map方法返回的是新的函子,所以还可以调用map方法
const lr = Container.of(5)
.map(v => v + 3)
.map(v => v * 2)
console.log(lr) // Container { _value: 16 }
总结:
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了map契约的对象
- 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
- 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理
- 最终map方法返回一个包含新值的盒子(函子)
MayBe函子
- 对编程过程中可能遇到的错误做相应的处理
- 对外部的空值情况做处理(控制副作用在允许的范围)
class MayBe {
static of(value) {
return new MayBe(value)
}
constructor(value) {
this._value = value
}
// 通过 isNothing 判断是否为空值
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(null)
.map(x => x.toUpperCase())
console.log(r) // MayBe { _value: null }
但是,以下这种情况,不会出错,但是什么时候出现null不知道
let r = MayBe.of('hello world')
.map(x => x.toUpperCase())
.map(x => null)
.map(x=> x.toUpperCase())
console.log(r) // MayBe { _value: null }
可以通过Either函子来解决
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))
}
}
function parseJson(str) {
try {
return Right.of(JSON.parse(str))
} catch (e) {
return Left.of({ error: e.message })
}
}
let r1 = parseJson('{name:zs}')
console.log(r1) // Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
let r2 = parseJson('{"name":"zs"}')
.map(x => x.name.toUpperCase())
console.log(r2) // Right { _value: 'ZS' }
使用Left来处理异常,Right来做正确的操作。
IO函子
- IO是input,output的意思。IO函子中的_value 是一个函数,整理把函数作为值来处理。
- IO函子可以把不纯的动作存储到_value中,延迟执行,包装它的操作是纯的。
- 把不纯的操作交给调用者来处理。
// IO 函子
class IO {
static of(value) {
// this._value 就是这里的function
return new IO(function () {
return value
})
}
constructor(fn) {
this._value = fn
}
map(fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
// process - node 进程
let r = IO.of(process).map(p => p.execPath)
console.log(r) // IO { _value: [Function (anonymous)] }
console.log(r._value()) // D:\node.js\node.exe
IO函子的问题
const fs = require('fs')
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() // IO { _value: [Function (anonymous)] }
let r = cat('../../../package.json')._value()._value()
console.log(r)
如果出现函子嵌套IO(IO(x)),需要拿多层_value才能拿到值。
Monad函子
- Monad函子是可以变扁的Pointed函子IO(IO(x))
- 一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad
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()
}
}
const fs = require('fs')
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(fp.toUpper)
.flatMap(print)
.join()
console.log(r)
Pointed函子
- Pointed函子是时下你了of静态方法的函子
- of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context。
Task函子
Folktale
folktale是一个标准的函数是编程库,但是它和lodash,ramda不同的是,他没有提供很多功能函数,而是提供了一些函数是处理的操作,eg:compose, curry等,一些函子Task,Either,MayBe等。
基本使用:
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 n = compose(toUpper, first)
console.log(n(['one', 'two'])) // ONE
folktale 2.x 中的Task和 1.0中的Task区别很大,1.0用法接近前边的例子,下边一2.3.2来演示Task的使用:
Folktale中Task函子的使用
// 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(er)
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)
}
})