什么是函数式编程?
是一种编程的思维方式
- 面向对象的编程思维方式:把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事物事件的联系。
- 函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界
函数式编程是范畴论,范畴论是数学分支的一门很复杂的学科,彼此之间存在关系概念。箭头表示范畴成员之间的关系,名字被成为“态射”,通过“态射”,一个成员能够变形成另一个成员。所有的成员是一个集合,变形关系就是函数。y=f(x)
//非函数式
let num1 = 2
let num2 = 3
let sum = num1 + num2
//函数式
function add(n1,n2){
return n1 + n2
}
let sum = add(2,3)
复制代码
注意:
- 函数式编程中的函数指的不是程序中的函数,而是数学中函数的映射关系。
- 函数式编程用来描述数据(函数)之间的一个关系
为什么要学习函数式编程
- 函数式编程随着React的流行受到越来越多的关注
- vue3也开始拥抱函数式编程
- 打包过程中可以使用tree shaking 过滤无用的代码
- 方便测试,方便并行处理
函数是一等公民
函数是一等公民指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他函数,可以作为参数,可以作为其他函数的返回值。
特性
- 函数可以存储在变量中
- 函数可以作为参数
- 函数可以作为返回值
//函数可以存储在变量中
let fn = function(){
console.log('hello')
}
//函数可以作为参数
function forEach(array,fn){
for(let i = 0 ; i < array.length;i++){
fn(array[i])
}
}
// 函数可以作为返回值
function once(fn){
let done = false
return function(){
if(!done){
done = true
return fn.apply(this,arguments)
}
}
}
let fn = once(function(){
consnole.log('我只会执行一次!!')
})
fn()
fn()
fn()
复制代码
高阶函数
具备特性2 或者 特性3 的函数被称之为高阶函数
- 使用高阶函数的意义
- 抽象可以帮我们屏蔽细节,只需要关注我们的目标
- 高阶函数是用来抽象通用的问题
- 常用的高阶函数
- map
- ervery
- some
- filter
- reduce
闭包
闭包的本质:函数在执行完毕后会从执行栈上移除,但是堆上作用域成员因为被外部引用不能释放,因此内部函数依然可以访问到外部函数的成员。
function makeFn(){
let msg = ''
return function(){
console.log(msg)
}
}
const fn = makeFn()
fn()
复制代码
纯函数
相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。
数组中的slice 和splice 分别是 : 纯函数和不纯的函数
- slice 返回的数组中的指定部分,不会改变原数组
- splice 对数组进行操作返回该数组,会改变原数组
let min = 18
function checkAge(age){
return age >= 18
}
checkAge(5)
复制代码
- 副作用
副作用的来源:
- 配置文件
- 数据库
- 获取用户的输入
- ….
所有的外部交互都有可能产生副作用,副作用也使得方法的通用性下降不适合扩展和可重用性。但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生
- 纯函数的好处
可缓存
const _ = require('lodash')
fuction getArea (r) {
return Math.PI * r * r
}
let getArea = _.memoize(getArea)
console.log(getArea(4))
console.log(getArea(4))
复制代码
模拟实现一下lodash中的memoize原理。大概思路就是将key值缓存下来,当下次同样的key出现时,直接拿cache中的值,而不再次调用函数。
function memoize(f){
let cache= {}
return function(){
let key = JSON.stringify(arguments)
cache[key] = cache[key] || f.apply(f,arguments)
return cache[key]
}
}
复制代码
柯里化
柯里化(Currying),又称部分求值(Partial Evaluation),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
- 柯里化可以让我们给一个函数传递较少的参数得到一个记住某些参数的新函数
- 这是一种对函数参数对缓存
- 让函数变得更灵活。让函数的粒度更小
function checkAge(min){
return function (age){
return age >= min
}
}
let checkAge18 = checkAge(18)
console.log(checkAge18(20))
let checkAge = min => age => age >= min
复制代码
- lodash 中的柯里化
const _ = require('lodash')
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))
复制代码
- 案例
const _ = require('lodash')
const match = _.curry(function(reg,str){
return str.match(reg)
})
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
console.log(haveSpace('hello world'))
const filter = _.curry(function (func,array){
return array.filter(func)
})
console.log(filter(haveSpace),['a b','ab'])
const findSpace = filter(haveSpace)
console.log(findSpace(['a b','ab']))
复制代码
- lodash 中的curry实现过程
function sum(a,b,c) {
return a + b + c
}
function curry(func) {
return function curriedFn(...args) {
if(args.length < func.length){
return function () {
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return func(...args)
}
}
const curriedSum = curry(sum)
console.log( curriedSum(1,2,3))
console.log( curriedSum(1,2)(3))
console.log( curriedSum(1)(2,3))
console.log( curriedSum(1)(2)(3))
复制代码
函数组合
- 纯函数和柯里化容易写出洋葱代码
h(g(f(x)))
比如获取数组最后一个元素转换成大写字母 _.toUpper(_.first(_.reverse(array)))
函数组合可以将我们把 细粒度的函数组合成一个新的函数
- 管道
给fn函数输入参数a,返回结果b。可以想像一下a数据通过一个管道得到了b数据
当fn函数比较复杂的时候,我们可以把函数fn拆分成多个小函数。数据a通过管道f3得到m,m在通过管道f2得到n,n在通过管道f1最终得到结果b
fn = compose(f1,f2,f3)
b = fn(a)
复制代码
函数组合
函数组合:如果一个函数要经过多个函数处理 才能得到最终的值,这个时候可以把中间过程的函数合并成一个函数
- 函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
- 函数组合默认是从右到左执行
function compose(f,g) {
return function (value) {
return f(g(value))
}
}
function reverse(array) {
return array.reverse()
}
function first(array) {
return array[0]
}
const last = compose(first,reverse)
console.log(last[1,2,3,4])
复制代码
- lodash 中的组合函数
- lodash 中的组合函数flow() 或者 flowRight() 他们都可以组合多个函数
- flow()是从左到右运行
- flowRight是从右到左运行, 使用更多一些
const _ = require('lodash');
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
const fn = _.flowRight(toUpper,first,reverse)
console.log(fn(['one','two','three']))
复制代码
- 组合函数实现原理
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
function compose(...args) {
return function (value) {
return args.reverse().reduce(function (acc,fn) {
return fn(acc)
},value)
}
}
const fn = compose(toUpper,first,reverse)
console.log(fn(['one','two','three']))
复制代码
- 函数组合结合律
//结合律
let f = compose (f,g,h)
let associative = compose(compose(f,g),h) === compose(f,compose(g,h))
// true
复制代码
- 函数组合调试
// 函数组合调试
// WANG YA HUI --> wang-ya-hui
const _ = require('lodash');
const split = _.curry((sep,str) => _.split(str,sep))
const join = _.curry((sep,str)=> _.join(str,sep))
const log = v = > {
console.log(v)
return v
}
const fn = _.flowRight(join('-'),_.toLower,split(' '))
console.log( fn('WANG YA HUI'))
复制代码
- lodash 中的fp模块(函数式编程的模块)
- lodash的fp模块提供了使用的对函数式编程友好的方法,并且都是柯里化过后的
- 提供了不可变的函数优先,数据之后的方法
pointFree
我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数
- 不需要指明处理的数据
- 只需要合成运算过程
- 需要定义一些辅助的基本运算函数
const fn = fp.flowRight(fp.join('-'),fp.map(_.toLower),fp.split(' '))
复制代码
函数式编程的核心就是把运算的过程抽象成函数,而pointFree就是把我们 抽象出来的函数在合成一个函数。在合成的过程中也是一种抽象的过程,并且在合成中,我们无需要关注数据的。
函子(functor)
函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
上图中,左侧的圆圈就是一个函子,表示人名的范畴。外部传入函数f,会转成右边表示早餐的范畴。
上图中,函数f完成值的转换(a到b),将它传入函子,就可以实现范畴的转换(Fa到Fb)。
容器:就是包含值和值的变形关系(函数)
函子:是一个特殊的容器,通过一个普通对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形)
class Container {
constructor(value) {
this._value = value
}
map(fn){
return new Container(fn(this._value))
}
}
let r = new Container(5)
.map(x => x + 1)
.map(x => x * x)
复制代码
但是有个缺陷,每次要创建一个函子的时候,都要去调用一个new来创建,这样很面向对象。
函数式编程一般有个约定,函子有一个of方法
class Container {
static of (value) {
return new Container(value)
}
constrauctor (value) {
this._value = value
}
map () {
return Contanier.of(fn(this._value))
}
}
let r = Container.of(5).map(x => x + 1).map(x => x * x)
复制代码
总结
函子是一个具有map方法的普通对象,我们在函子里面要维护一个值。就像这个值包裹在一个盒子里面,想要对这个值进行处理的话必须调用map方法,然后通过map方法传递一个处理值的函数。map方法执行完毕之后会返回一个新的函子,所以我们可以通过这种方式进行链式调用。
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了 map 契约的对象
- 我们可以把函子想像成一个盒子,这个盒子里面封装了一个值
- 如果想要处理盒子里的值,我们需要给盒子的map方法传递一个处理值的函数(纯函数),由这个函数对值进行处理
- 最终,map方法返回一个包含新值的盒子(函子)所以我们可以通过 .map 进行链式调用,因为 map方法始终返回的是一个函子,所有的函子都有 map 方法。因为我们可以把不同的运算方法封装到函子中,所以我们可以衍生出很多不同类型的函子。我们有多少运算就有多少函子,最终我们可以使用不同的函子来解决实际的问题。
Maybe函子
- 当函子执行出现异常时比如 null 、undefined 这类空值,会使函数变得不纯。所以我们要在执行的时候控制这个副作用。
class MayBe {
static of (value) {
return new MayBe (value)
}
constructor (value) {
this._value = value
}
isNothing () {
return this._value === null || typeof this._value === undefined
}
map (fn) {
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
}
}
const r = MayBe.of('hello world')
.map(x => x.toUpperCase())
.map(x => null)
.map(x => x.split(''))
复制代码
either函子
Either 两者中的任何一个,类似于 if ··· else ··· 的处理,也是我们处理异常的一种方式。
class Left {
static of (value) {
return new Left(value)
}
constructor (value) {
this._value = value
}
map () {
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 r = parseJSON('{name:zs}')
let r = parseJSON('{"name":"zs"}')
.map(x => x.name.toUpperCase())
复制代码
Left 和 Right 唯一的区别就在于 map 方法的实 现,Right.map 的行为和我们之前提到的 map 函数一 样。但是 Left.map 就很不同了:它不会对容器做任 何事情,只是很简单地把这个容器拿进来又扔出去。 这个特性意味着,Left 可以用来传递一个错误消息。
Left 可以让调用链中任意一环的错误立刻返回到调用链的尾部, 这给我们错误处理带来了很大的方便,再也不用一层又一层的 try/catch。
IO 函子
- IO函子中的_value 是一个函数,这里是把函数作为值来处理
- IO函子可以把不纯的动作存储到_value中,延迟执行这个不纯的操作
- 把不纯的操作交给调用者来处理
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))
}
}
const r = IO.of(process).map(p => p.execPath)
console.log(r._value())
复制代码
Pointed 函子
实现的 of 静态方法的函子,of 方法是为了避免使用 new 来创建对象,更深层次的含义是 of 方法用来把值放到上下文 Context (把值放在容器中,使用 map 来处理值)。
我们上文使用的一些函子都可以称之为Pointed函子。因为他们都有of静态方法
Monad 函子
Monad 函子是可以变扁的 Pointed 函子 IO(IO(x)),一个函子如果具有 join和of两个方法并遵守一些定律,就是一个 Monad。如果函数嵌套的话我们可以使用函数组合,函子嵌套可以 Monad。
const fp = require('lodash/fp')
const fs = require('fs');
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 (value) {
return new IO (function () {
console.log(value)
return value
})
}
let cat = fp.flowRight(print,readFile) // print(readFile(xxx))
// console.log(cat('package.json')._value()._value())
复制代码
如上列子可以发现了一个规律,存在IO(IO)的一个嵌套。如果想取出value值需要调用两次。这样非常影响我们的使用。我们可以考虑用Monad函子来解决这个问题。
- Monad 函子是可以变扁的pointed 函子
- 一个函子如果有join和of两个方法并遵循一些定律就是一个Monad
const fp = require('lodash/fp')
const fs = require('fs');
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 (value) {
return new IO (function () {
console.log(value)
return value
})
}
const r = readFile('package.json')
.flatMap(print)
.join()
console.log(`r`, r)
复制代码
Monad 函⼦子的作⽤用是,总是返回 ⼀一个单层的函⼦子。它有⼀个flatMap方法,与map方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。
如果函数fn返回的是一个函子,那么this.map(fn)就会生成一个嵌套的函子。所以,join方法保证了flatMap方法总是返回一个单层的函子。这意味这嵌套的函子会被铺平。