- Published on
函数式编程
- Authors

- Name
- Jay
概念
函数式编程(Functional Programming)FP 是编程范式之一,我们常听说的的编程范式还有面向对象和面向过程编程(按照步骤实现),他们是并列关系。
面向对象编程 把现实世界的对象抽象成为程序世界的类和对象,通过封装继承多态演示事物之间的联系。
函数式编程的思维方式:把现实世界中事物和事物的联系抽象到程序世界(对运算过程进行抽象)
程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及到很多有输入和输出的函数
x ->(联系,映射)->y x->f(x)
函数式编程中的函数不是指的是程序中的函数(方法),而是数学中函数即映射的关系,例如 y = sin(x),是 x 和 y 的映射关系(x 的值确定了 y 的值也就确定了)
相同的输入始终要得到相同的输出(纯函数)
一句话总结 :函数式编程用来描述数据(函数)之间的映射
// 非函数式
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(sum)
// 函数式
function add(n1, n2) {
//运算过程的抽象 add
return n1 + n2
}
使用函数式编程的好处:
函数式编程可以抛弃 this
打包时可以更好的利用 tree shaking 过滤无用代码
方便测试,方便并行处理
有很多库可以帮助我们进行函数式开发,lodash underscore ramda
函数是一等公民
学习函数式编程首先要回顾以下知识
函数是一等公民
高阶函数
闭包
函数是一等公民 (First Class Function)
在 JavaScript 中,函数就是一个普通的对象(可以用 new function()),我们可以把函数存储到变量/数组中,它还可以作为另外一个函数的参数和返回值,甚至我们还可以在运行时构建函数。
new Function('alert(1)')
把函数赋值给变量
let fn = function () { console.log('Hello First class Function') } // 示例 const BlogController = { index(posts) { return Views.index(posts) }, show(posts) { return Views.show(posts) }, create(attrs) { return Db.create(attrs) }, update(post, attrs) { return Db.update(post, attrs) }, destory(post) { return Db.destory(post) }, } // 优化 // 由于函数名和返回值的函数名一致,我们可以做一定的优化 const BlogController = { index: Views.index, show: Views.show, create: Db.create, update: Db.update, destory: Db.destory, }函数是一等公民,是高阶函数和柯里化的基础。
高阶函数
什么是高阶函数?
高阶函数(Higher-order function)高阶组件(Higher-order component)
可以把函数作为参数传递给另外一个函数
可以把函数作为另一个函数的返回结果
函数作为参数
在 js 中,很多数组的方法都是以函数作为参数的,我们可以模拟 forEach 和 filter
forEach
// 每次使用 forEach的时候,调用的方法可能不一样, // 这时候,我们可以把函数的定义交给用户,让用户在使用时,把函数当作参数传入。 function forEach(array, fn) { for (let i = 0; i < array.length; i++) { fn(array[i]) } } // 测试 let arr = [1, 2, 3, 4] forEach(arr, (item) => { console.log(item) })filter (返回满足条件的数组元素)
function filter(arr, fn) { let res = [] for (let i = 0; i < arr.length; i++) { if (fn(arr[i])) { res.push(arr[i]) } } return res } // 测试 let arr = [1, 2, 3] let res = filter(arr, (item) => { return item > 2 }) console.log(res)函数作为参数的优点?可以不考虑内部的具体实现,可以自定义规则。
函数作为返回值
如果函数作为返回值,则是用一个函数生成一个函数。
function makeFun() { let msg = 'hello function' return function () { console.log(msg) } } const fn = makeFn() fn() makeFn()()再来尝试一下 once 函数,在 JQuery 中指的是为 Dom 元素绑定一个只执行一次的事件,而在 lodash 中则是对一个函数只执行一次。我们模拟一下 lodash 中的 once
只执行一次的函数有什么意义呢?函数不是为了重复调用而出现的吗?
想象一个场景,在做支付的时候,一个订单不管用户点多少次按钮,只执行一次。
function once(fn) { let done = false return function () { if (!done) { done = true return fn.apply(this, arguments) } } } let pay = once(function (money) { console.log(`支付了:${money} RMB`) }) pay(100) pay(100) pay(100) pay(100)在闭包和函数柯里化中,我们会不停的使用。
高阶函数的意义
抽象可以屏蔽细节,只需要关注我们的目标。高阶函数就是为了抽象通用的问题。
// 面向过程的方式
let arr = [1, 2, 3, 4]
for (let i = 0; i < arr.length; i++) {
console.log(arr[i])
}
// 高阶函数
arr.forEach((element) => {
console.log(element)
})
循环的过程可以不关注,只关注具体每次循环要做的事情。用 forEach 和 filter 等函数就可以解决通用的问题。高阶函数还可以使代码更加简洁。
常用的高阶函数
forEach
map
const map = (array, fn) => { let res = [] for (let value of array) { res.push(fn(value)) } return res } let arr = [1, 2, 3, 4] const arr1 = map(arr, (item) => { return item * item }) console.log(arr1)filter
every 判断数组中的每一个元素是否都匹配某一个条件
const every = (array, fn) => { // 假设全员匹配 let res = true for (let value of array) { // 通过条件进行判断 res = fn(value) // 如果有一个元素不匹配,则终止循环,返回res if (!res) { break } } return res } console.log(every(arr, (item) => item > 0))some 判断数组中的元素是否满足某个条件
const some = (array, fn) => { let res = false for (let value of array) { res = fn(value) if (res) { break } } return res }find/findIndex
reduce
sort
闭包
闭包(closure):函数和其周围的状态(词法环境)的引用捆绑在一起形成的闭包
- 可以在另一个作用域中调用一个函数的内部函数,并且访问到该函数作用域中的成员
function makeFun() {
let msg = 'hello function'
return function () {
console.log(msg)
}
}
const fn = makeFn()
fn()
makeFn()()
function once(fn) {
let done = false
return function () {
if (!done) {
done = true
return fn.apply(this, arguments)
}
}
}
let pay = once(function (money) {
console.log(`支付了:${money} RMB`)
})
pay(100)
pay(100)
pay(100)
pay(100)
当外部对内部有引用时,函数内部的成员并不会随着函数的销毁而销毁。
在另一个作用域中,调用函数内部的函数
内部函数可以访问到内部成员
闭包其实类似“封装”。外部永远无法直接更改内部成员的值。
闭包的本质:函数在执行的时候会被放在一个执行栈上,当函数执行完毕之后会从执行栈上删除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数已然可以访问外部的函数成员。
闭包案例
修改 pow 函数,让求一个数的平方或者三次方时,不去每次都传递第二个参数。
Math.pow(4, 2)
Math.pow(5, 2)
写一个函数生成器:
function makePower(power) {
return function (number) {
return Math.pow(number, power)
}
}
// 平方
let power2 = makePower(2)
let power3 = makePower(3)
console.log(power2(4))
console.log(power2(5))

在调试过程中,我们可以看到,调用 makePower 时,存在一个 Closuer 闭包。
纯函数
纯函数的概念
纯函数:相同的输入,永远会得到相同的输出,并且没有任何可观察的副作用。
纯函数类似数学中的函数(用来描述输入和输出的关系),y = f(x)

lodash 是一个纯函数功能库,提供了对数组,数字,对象,字符串,函数等的一些操作方法。
数组的 slice 和 splice 分别是纯函数和不纯的函数
slice 返回数组的指定部分,不会改变原有数组
let array = [1, 2, 3, 4, 6] console.log(array.slice(0, 3)) console.log(array.slice(0, 3)) console.log(array.slice(0, 3))多次调用并不会有其他结果,对相同的输入始终得到相同的输出
splice 对数组进行操作,并返回该数组,会改变原有数组。
let array = [1, 2, 3, 4, 6] console.log(array.splice(0, 3)) console.log(array.splice(0, 3)) console.log(array.splice(0, 3))
自己写一个纯函数其实很简单,对于纯函数来说,必须有输入(参数)和输出(返回值)。
function add(a, b) {
return a + b
}
函数式编程不会保留计算中间的结果,所以变量是不可变的(无状态的)
由于纯函数的特性,我们可以把一个函数的结果交给另外一个函数处理。(函数组合)
Lodash
使用:安装依赖
npm i lodash --save
演示方法
first / last / toUpper / reverse / each / includes / find / findIndex
const _ = require('lodash')
const array = ['zhangsan', 'lisi', 'wangwu', 'zhaoliu']
console.log(_.first(array)) // zhangsan
console.log(_.last(array)) // zhaoliu
console.log(_.toUpper(_.first(array))) // ZHANSAN
// console.log(array.reverse()) // 没有参数传递,不是纯函数
// console.log(_.reverse(array))
const res = _.each(array, (item, index) => {
console.log(item, index)
})
console.log(res) // 返回值为函数本身
// 在ES6 之前,可以使用 includes find 和 findIndex
除了工具函数,还提供了函数柯里化和函数组合的工具。
纯函数的好处
可缓存
因为对相同的输入总有相同的输出,对于大量运算过程的函数,可以直接把结果缓存起来。
记忆函数 memoize
// 记忆函数 const _ = require('lodash') function getArea(r) { console.log(r) return Math.PI * r * r } let getAreaWithMemory = _.memoize(getArea) // 高阶函数 console.log(getAreaWithMemory(10)) console.log(getAreaWithMemory(10)) console.log(getAreaWithMemory(10)) /* 10 314.1592653589793 314.1592653589793 314.1592653589793 打印结果可知,只调用了一次 */模拟记忆函数
function memoize(fn) { // 存储执行结果 let cache = {} return function () { let key = JSON.stringify(arguments) cache[key] = cache[key] || fn.apply(fn, arguments) return cache[key] } }可测试
让测试变得更为方便
单元测试其实在断言输出的结果 ,所有的纯函数都是有输入有输出,所以有利于测试。
并行处理
再多线程下,多个线程同时去修改一个变量可能会出现意外的情况
而纯函数是一个封闭的空间,它只依赖于参数,不会访问共享的内存数据,所以在并行环境下,可以任意的运行纯函数。(Web Worker)
副作用
// 不纯的
let mini = 18
function checkAge(age) {
return age >= mini
}
// 纯函的
function checkAge(age) {
let mini = 18 // 硬编码 可以通过柯里化解决
return age >= mini
}
如果函数依赖于外部的状态就无法保证输出相同。会带来副作用。
副作用的来源:
配置文件
数据库
用户的输入
所有的外部交互都有可能带来副作用,副作用让方法的通用性下降,不适合扩展和可重用。但是副作用不能完全禁止,尽可能控制他们在可控范围内发生。
柯里化(Haskell Brooks Curry)
柯里化演示
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) {
return function (age) {
return age >= min
}
}
let checkAge18 = checkAge(18)
checkAge18(29)
// ES6
let checkAge = (min) => (age) => age >= min
这其实就是函数的柯里化,
当一个函数有多个参数的时候 先传递一部分参数调用他(这部分参数以后永远不变)
然后返回一个新的函数接受剩余的参数,返回结果
但是刚刚的柯里化并不彻底,我们需要将任何函数转化为柯里化的函数。
lodash 里的柯里化
_curry(func)
功能: 创造一个函数,该函数接受一个或者多个 func 参数,如果 func 所需要的参数都被提供则执行 func,并返回执行的结果,否则继续返回该函数并等待接受剩余的参数
参数:需要柯里化的函数
返回值:柯里化后的函数
const _ = require('lodash')
function getSum(a, b, c) {
return a + b + c
}
let curried = _.curry(getSum)
console.log(curried(1, 2, 3))
console.log(curried(1)(2, 3))
console.log(curried(1)(2)(3))
curry 可以把一个多元函数转换为一个一元函数,对我们的函数组合很有帮助。
柯里化案例
判断字符串中的空白和数字,可以使用 match 方法。
''.match(/\s+/g) //
''.match(/\d+/g) //
更通用一些的方法,可以通过柯里化,根据不同的正则表达式,生成相应的匹配函数
const _ = require('lodash')
let match = _.curry(function (reg, str) {
return str.match(reg)
})
const haveSpace = match(/\s+/g)
console.log(haveSpace('hello world'))
const haveNumber = match(/\d+/g)
console.log(haveNumber('abc123'))
如果我们想要判断数组中每一个元素是否存在空字符串或者数字,可以再次进行改造。
const _ = require('lodash')
let match = _.curry(function (reg, str) {
return str.match(reg)
})
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
// 通过柯里化生成一个新的函数filter
const filter = _.curry(function (func, array) {
return array.filter(func)
})
// 通过 filter 生成一个函数 findSpace , 接受一个数组,返回拥有空白字符串的元素。
const findSpace = filter(haveSpace)
console.log(findSpace(['zhangsan', 'li si']))
这些函数只需要定义一次,以后都可以无数次的使用。
柯里化原理模拟
柯里化函数有两种调用方式,第一种是当传递的参数个数等于原来的函数参数个数时,立即执行该函数。
第二种是当传递的参数个数小于原来的参数个数时,返回一个新的函数,并等待剩余参数传递。
我们想要实现函数的柯里化,需要实现这两种形式的调用。
function curry(func) {
return function curried(...args) {
// 通过 rest 方式,接受全部实参,而形参可以通过函数的属性获得
// 判断形参和实参的个数
if (args.length < func.length) {
return function () {
// 可以通过闭包获得第一次传入的 args
// 第二次传入的参数可以使用 arguments 获得
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))
柯里化总结
柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住了某些固定参数的新函数。
这是一种对函数参数的缓存。
让函数变得更为灵活,让函数的粒度更小。
可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能。
函数组合
概念
我们用纯函数和柯里化很容易写出洋葱代码。h(g(f(x)))
- 获取数组的最后一个元素再转换成为大写字母:
_.toUpper(_.first(_.reverse(array)))
一层包裹这一层的代码,难以阅读和理解,我们把他们称为洋葱代码。
函数组合可以把细粒度的函数重新组合生成一个新的函数。
管道
在函数组合前,我们首先要了解“管道”的概念。
给 fn 函数输入参数 a,返回结果 b,可以把整个数据的处理过程看作一个黑盒管道

当 fn 函数比较复杂的时候,我们可以把 fn 拆分成为多个小的函数,此时运算过程中就多了 m , n 等中间值

组合的伪代码可以表达为:
fn = compose(f1, f2, f3)
b = fn(a)
我们不需要考虑 f1,f2,f3 产生的中间结果,而只需要关注输入值和输出值即可。
函数组合
函数组合(compose):如果一个函数要经过多个函数处理才能得到最终的值,这个时候可以把中间过程的函数合并成为一个函数
函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据通过多个管道形成最终的结果
函数组合默认是从右到左执行
案例: 求数组中的最后一个元素。
// 函数组合
// 我们的洋葱代码并没有减少,而是封装起来了。
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]))
虽然有很多种求数组中最后一个元素的方法,但是我们使用函数组合的方法,可以对函数进行任意的组合,并且代码可以重复使用。
lodash 中的函数组合
lodash 中的组合函数 flow 或者 flowRight ,它们都可以组合多个函数。
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(['one', 'two', 'three']))
模拟函数组合
flowRight 的参数是不固定的,可能是多个,并且均为纯函数的形式。执行后返回一个函数,该函数接受参数,并且从右到左的传递处理该数据并且进行处理。
// 将数组中的最后一个元素取出来并转换成为大写
function compose(...args) {
return function (val) {
// reduce 对数组的每一个元素,执行一个由我们提供的一个函数
// 并将其汇总为一个单个的集合
return args.reverse().reduce(function (acc, fn) {
return fn(acc)
}, val)
}
}
const reverse = (arr) => arr.reverse()
const first = (arr) => arr[0]
const toUpper = (str) => str.toUpperCase()
const f = compose(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))
// 我们可以使用箭头函数进行简写
// const compose = (...args) => val => args.reverse().reduce((acc, fn) => fn(acc), val)
结合率
函数的组合要满足结合律(associativity)
我们既可以把 g 和 h 组合,也可以把 f 和 g 组合,结果应该是一样的。
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) === compose(f, compose(g, h))
// true
示例:
const _ = require('lodash')
// const f = _.flowRight(_.toUpper, _.first, _.reverse)
const f = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse)
console.log(f(['one', 'two', 'three']))
调试
如何调试组合函数
const _ = require('lodash')
// 1. 准备函数
// _.split() 拥有多个参数,不宜组合,并且应该放在最右侧
const split = _.curry((sep, str) => _.split(str, sep))
// _.lowerCase()
// _.join() 拥有多个参数,应该继续进行柯里化
const join = _.curry((sep, array) => _.join(array, sep))
// 2. 组合函数
const f = _.flowRight(join('-'), _.toLower, split(' '))
console.log(f('NEVER SAY DIE'))
结果为 n-e-v-e-r-,-s-a-y-,-d-i-e
那么我们该如何调试呢?如何确定每一步的执行结果呢?很简单,我们可以使用函数组合的特性,插入一个函数,打印返回值并且将结果原样返回即可。
const log = (res) => {
console.log(res)
return res
}
// 2. 组合函数
const f = _.flowRight(join('-'), _.toLower, log, split(' '))
console.log(f('NEVER SAY DIE'))
这样我们就可以通过 log 查看 split 处理之后的数据了。
经过排查,我们可以得知,toLower 函数直接将数组转换成了字符串并且变为了小写,我们需要将数组中的每个元素进行小写转换 ,而不是整体转换。
const _ = require('lodash')
// 1. 准备函数
// _.split() 拥有多个参数,不宜组合,并且应该放在最右侧
const split = _.curry((sep, str) => _.split(str, sep))
// _.lowerCase()
const map = _.curry((fn, res) => _.map(res, fn))
// _.join() 拥有多个参数,应该继续进行柯里化
const join = _.curry((sep, array) => _.join(array, sep))
// 2. 组合函数
const f = _.flowRight(join('-'), map(_.toLower), split(' '))
console.log(f('NEVER SAY DIE'))
在调试时,我们可以使用辅助函数,对中间的结果进行排查。
如果调试点比较多,我们可以将 log 再做更改。
const trace = _.curry((tag, v) => {
console.log(tag)
return v
})
lodash 中的 FP 模块
上面的例子中,我们对 lodash 中的函数重新进行了柯里化的处理,比较麻烦。
lodash 的 fp 模块提供了实用的对函数式编程友好的方法,提供了不可变的方法(auto curried iteratee-first data-last),是已经柯里化的,函数优先,数据置后的。
const { head } = require('lodash')
const _ = require('lodash')
const fp = require('lodash/fp')
_.map(['a', 'b', 'c'], _.toUpper) // 数据优先,函数置后
_.split('he llo', ' ')
fp.map(fp.toUpper, ['a', 'b', 'c']) // fp模块,函数优先,数据置后,自动柯里化
console.log(fp.map(fp.toUpper)(['a', 'b', 'c']))
console.log(fp.split(' ')('he llo'))
所以我们上节的代码就可以修改成为:
const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '))
需要注意的是,lodash 和 lodash/fp 中的 map 函数有所不同
Point Free
Point Free 是一种代码风格,我们可以把数据处理的过程定义成与数据无关的合成运算,不需要关注代表数据的参数,只需要将简单的运算步骤聚合到一起,在使用这种模式之前,我们需要定义一些辅助的基本运算函数(函数组合)
不需要指明处理的数据
只需要合成运算过程
需要定义一些辅助的基本运算函数
// 非 Point Free
// Hello World => hello_world
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)
函数组合其实就是 Point Free 模式。
案例
把一个字符串中的首字母提取,并转换成为大写,使用 . 作为分隔符
// 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(' ')
)
函子
Functor(函子)
到目前位置,我们已经学习了函数式编程的一些基础,但是我们还没有做到如何把副作用控制在可控的范围内,包括异常处理,异步操作等。
在讲解函子之前,我们需要了解为什么要有函子。
先看下面的代码:
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 传了进去。这样我们就完美的实现了抽象,保证的灵活性。
到这里,我们的函子就该正式登场了。
什么是函子?
容器:包含值和值的变形关系(这个变形关系就是函数)
函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理(变形关系)
通过定义一个类来描述函子
// Functor
class Container {
constructor(value) {
// 初始化的时候创建一个盒子,盒子的内容是私有的,不对外暴露的
this._value = value
}
// map 接受一个函数,并对值进行处理,最后返回一个新的函子,对新的值进行保存
map(fn) {
return new Container(fn(this._value))
}
}
let r = new Container(5).map((x) => x + 1).map((x) => x * x)
console.log(r)
map 函数返回的是一个函子,我们始终不对外暴露具体的值,而是用函子对值进行维护。
为了让代码更加“函数式”,我们可以使用静态方法对其封装
// Functor
class Container {
static of(value) {
return new Container(value)
}
constructor(value) {
// 初始化的时候创建一个盒子,盒子的内容是私有的,不对外暴露的
this._value = value
}
// map 接受一个函数,并对值进行处理,最后返回一个新的函子,对新的值进行保存
map(fn) {
return Container.of(fn(this._value))
}
}
let r = Container.of(5)
.map((x) => x + 1)
.map((x) => x * x)
console.log(r)
我们永远不取出函子中保存的值。
函数式编程的运算不直接操作值,而是用函子来完成
函子就是一个实现了 map 契约的对象
我们可以把函子想象成一个盒子,这个盒子里封装了一个值
想要处理盒子中的值,我们要给盒子的 map 方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理。
最终 map 方法返回一个包含新值的函子。
但是我们的函子存在一些问题,如果在初始化的过程中传入的初始值为 null / undefined ,则会出现一些“副作用”,null 和 undefined ,并不能调用方法。
Container.of(null).map((x) => x.toUpperCase())
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())
let r = MayBe.of(null).map((x) => x.toUpperCase())
console.log(r)
MayBe 函子的问题
let r = MayBe.of('hello world')
.map((x) => x.toUpperCase())
.map((x) => null)
.map((x) => x.split(' '))
我们不清楚哪一步出现了 null
Either 函子
异常会让函数变得不纯,Either 函子可以用来做异常处理。Either 的处理类似于 if ... else
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, r2)
我们可以让 Left 中嵌入错误消息
当出现错误的时候,如果想让函数为纯函数(相同的输入有相同的输出),我们可以在 catch 中使用 Left
function parseJSON(str) {
try {
return Right.of(JSON.parse(str))
} catch (e) {
return Left.of({ error: e.message })
}
}
let r = parseJSON(`{name:zs}`)
//Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
IO 函子
IO 函子中的 _value 是一个函数,这里是把函数作为值来处理。
IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),包装当前的操作为纯函数。
把不纯的操作交给调用者来使用(把不纯的函数延迟到调用时)
const fp = require('lodash/fp')
class IO {
static of(value) {
// 将传递的值 x 通过函数包裹起来了,把求值延迟了。需要调用_value
// IO 函子最终还是想要一个结果 需要值的时候再取值
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._value())
IO 函子内部帮我们包装了一些函数,当然我们传递的函数有可能是不纯的操作,我们不管这个操作是不是纯的,IO 函子返回的结果始终是纯的操作,我们调用 map 的时候,始终会返回一个 IO 函子。
而 _value 属性保留的组合函数,有可能是不纯的,我们在执行时调用它,控制了副作用在可控的范围内发生。
folktale
函子可以帮助我们控制副作用,进行异常处理,还可以帮我们处理异步操作。
异步任务的实现过于复杂,我们可以使用 folktale 中的 Task 来演示。 Task 函子可以避免回调的嵌套。
forktale 是一个标准的函数式编程库
和 lodash ramda 不同,它没有提供很多功能的函数
只提供的了一些函数式的处理操作,例如 compose 和 curry 等,以及一些函子 Task ,Either,MayBe 等。
安装
npm install folktale --save
const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')
let f = curry(2, (x, y) => x + y)
// 为了避免出现一些问题,第一个参数为 形参个数。
// console.log(f(1, 2))
// console.log(f(1)(2))
const fn = compose(toUpper, first)
console.log(fn(['One', 'two']))
Task 函子
可以将 task 函子想象成 IO 函子的变形。
task 函数执行结束生成一个函子,这个函子是纯的,我们可以在调用(run)异步函数之前对函子进行操作。
// 读取package.json 的 version
const { task } = require('folktale/concurrency/task')
const fs = require('fs')
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')
// 在run之前可以用map进行监听
.map(split('\n'))
.map(find((x) => x.includes('version')))
.run()
.listen({
onReject: (err) => {
console.log(err)
},
onResolved: (value) => {
console.log(value)
},
})
Point 函子
Point 函子是实现了 of 静态方法的函子。
of 静态方法是为了避免使用 new 来创建对象,更深层次的含义是,of 方法用来把值放到上下文 Context (把值放到容器中使用 map 来处理值)
我们上面使用的函子,其实都是 Point 函子。
class Container {
static of(value) {
return new Container(value)
}
//...
}
of 方法的作用就是把值包裹在一个新的函子中并且返回,这个返回的结果就是一个上下文(Context)
调用 of 方法的时候我们获得上下文,之后我们可以在上下文中继续对函子进行操作。
Monad 函子(单子)
在使用 IO 函子的时候,我们可能会遇到一些问题。
const fp = require('lodash/fp')
const fs = require('fs')
class IO {
static of(value) {
// 将传递的值 x 通过函数包裹起来了,把求值延迟了。需要调用_value
// IO 函子最终还是想要一个结果 需要值的时候再取值
return new IO(function () {
return value
})
}
constructor(fn) {
this._value = fn
}
map(fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
// 模拟 cat 命令,读取文件并打印
// 读取文件
// 如果将读文件的操作封装成为一个函数,它依赖外部数据,是不纯的。
// 所以我们使用函子来进行操作 不直接进行操作。保证当前的操作是纯的:
// 输入一个函数,返回一个函子
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)
// 测试
let r = cat('package.json')
console.log(r)
在上面的代码中,我们拿到的 r 其实是一个嵌套的函子
IO(IO(x))
当我们执行 r._value()时,实际上是 print 函子在执行,打印出上一步的 IO 函子。
如果我们要读取文件,其实需要更长的调用
let r = cat('package.json')._value()._value()
但是这存在几个问题:
我们的 print 函子并没有起到应有的作用
调用嵌套函子时,调用链过长
Mobad 函子是可以变扁的 Point 函子。用以解决函子嵌套的问题。
一个函子如果具有 join 和 of 两个方法,并且遵守一些定律,就是一个 Monad
函数嵌套使用组合,函子嵌套使用 Monad
join 方法其实直接返回的是函数的调用。
当 IO 函子创建的时候,需要传入一个函数,如果传入的函数返回的是函子,则出现了函子嵌套。我们可以直接通过 join 调用 _value 获取这个函子
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 方法,使得 map 扁平化
flatMap(fn) {
// map 执行后会返回一个函子,调用 join, 获取这个函子的 _value 中的 函子
return this.map(fn).join()
}
}
// 模拟 cat 命令,读取文件并打印
// 读取文件
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)
// 测试
// IOreadFile(IOprint(x))
// 当我们传递的函数,返回的是值,则调用map,返回的是函子,调用 flatMap
let r = readFile('package.json').flatMap(print).join()
readFile 调用,生成了一个函子
调用 flatMap ,是由 readFile 产生的函子调用的
调用 map ,会将当前函子的 value(readFlie) 和 print 函数合并,返回一个新的函子
调用 join 将获取上一步合并的函子的 value 调用后生成的 新的函子(这个 value 中有 print 的部分),即 print 的函子
在实际开发的时候,只需要调用 API 即可
如果读完文件想要处理呢?
let r = readFile('package.json').map(fp.toUpper).flatMap(print).join()
更为简单的一个例子:
class Monad {
constructor(value) {
this.value = value
}
map(fn) {
return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null)
}
join() {
return this.value
}
}
Monad.of = function (val) {
return new Monad(val)
}
var a = Monad.of(Monad.of('str'))
console.log(a.join().map(toUpperCase))