思维方式
把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)
程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数;
函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y = sin(x),x和y的关系;
x -> f(联系、映射) -> y,y=f(x) ;
相同的输入始终要得到相同的输出;
函数式编程用来描述数据(函数)之间的映射;
基础认知
函数
函数是一等公民
高阶函数
定义
可以把函数作为参数传递给另一个函数
可以把函数作为另一个函数的返回结果
例
- 函数作为参数 ```javascript // map const map = (array, fn) => { let results = [] / NOTE 新语法 for of 遍历一切可迭代对象(包括 Array,Map,Set,String,TypedArray,arguments 对象等等) / for (let value of array) { results.push(fn(value)) } return results }
// filter function filter (array, fn) { let results = [] for (let i = 0; i < array.length; i++) { if (fn(array[i])) { results.push(array[i]) } } return results }
- 函数作为返回值```javascript// 函数作为返回值function makeFn(){let msg = 'hello'return function(){console.log(msg)}}const fn = makeFn()fn()
// oncefunction once (fn) {let done = falsereturn function () {if (!done) {done = truereturn fn.apply(this, arguments)}}}let pay = once(function (money) { console.log(`支付:${money} RMB`)})// 只会支付一次pay(5)pay(5)
意义
抽象可以帮我们屏蔽细节,只需要关注与我们的目标
高阶函数是用来抽象通用的问题
闭包
定义
函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。
可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员。
观察闭包
控制台观察调用栈、作用域
在控制台观察调用栈(Call Stack)的变化,作用域Scope的变化
Scope包括四个:
- 局部作用域Local:有当前执行的函数中的变量;
- 闭包Closure:有闭包中被保护的私有变量;
- 块级作用域Script:有let声明的变量等;
- 顶级作用域Global:有window,var声明的变量等;
例
上面函数作为返回值的两个例子也存在闭包 ```javascript // 生成计算数字的多少次幂的函数 function makePower (power) { return function (x) { return Math.pow(x, power) } } let power2 = makePower(2) let power3 = makePower(3) console.log(power2(4)) console.log(power3(4))
// 第一个数是基本工资,第二个数是绩效工资 function makeSalary (x) { return function (y) { return x + y } } let salaryLevel1 = makeSalary(1500) let salaryLevel2 = makeSalary(2500) console.log(salaryLevel1(2000)) console.log(salaryLevel1(3000))
<a name="feIMz"></a>#### 本质函数在执行的时候会放到一个执行栈上当函数执行完毕之后会从执行栈上移除,但是 堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员<a name="DbVA3"></a>## 纯函数<a name="2lByi"></a>### 概念相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。<br />纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)。<br />lodash 是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法<a name="Z7KIq"></a>### 例数组的 slice 和 splice 分别是:纯函数和不纯的函数<br />slice 返回数组中的指定部分,不会改变原数组splice 对数组进行操作返回该数组,会改变原数组```javascriptlet numbers = [1, 2, 3, 4, 5]// 纯函数numbers.slice(0, 3)// => [1, 2, 3]numbers.slice(0, 3)// => [1, 2, 3]numbers.slice(0, 3) // => [1, 2, 3]// 不纯的函数numbers.splice(0, 3) // => [1, 2, 3]numbers.splice(0, 3) // => [4, 5]numbers.splice(0, 3) // => []
函数式编程不会保留计算中间的结果,所以变量是不可变的(无状态的)
我们可以把一个函数的执行结果交给另一个函数去处理
纯函数库lodash
// 演示 lodash// first / last / toUpper / reverse / each / includes / find / findIndexconst _ = require('lodash')const array = ['jack', 'tom', 'lucy', 'kate']console.log(_.first(array))console.log(_.last(array))console.log(_.toUpper(_.first(array)))console.log(_.reverse(array))/* NOTE 会改变原数组的函数(副作用)不是纯函数lodash中的fp(functional programming)模块中的方法才是纯函数;lodash的reverse方法内部就是调用的数组的reverse方法,数组的reverse方法用法是[1,2].reverse(),没有输入,不符合纯函数的定义;两个reverse都会改变原数组,有副作用,都不符合纯函数的定义;*/const r = _.each(array, (item, index) => {console.log(item, index)})console.log(r)
优点
- 可缓存:因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来 ```javascript // 记忆函数 const _ = require(‘lodash’)
function getArea(r) { console.log(r) return Math.PI r r }
let getAreaWithMemory = _.memoize(getArea) console.log(getAreaWithMemory(4)) console.log(getAreaWithMemory(4)) console.log(getAreaWithMemory(4))
// 模拟 memoize 方法的实现 function memoize(f) { let cache = {} return function () { / NOTE 控制台浅色属性表示不可枚举 不可枚举的属性用for..in,JSON.stringfy,Object.keys遍历都无法找到 / let key = JSON.stringify(arguments) console.log(key)//{“0”:4}
// WHY applycache[key] = cache[key] || f.apply(f, arguments)console.log(cache)//{ '{"0":4}': 50.26548245743669 }return cache[key]
} }
let getAreaWithMemory = memoize(getArea) console.log(getAreaWithMemory(4)) console.log(getAreaWithMemory(4)) console.log(getAreaWithMemory(4))
- 可测试:纯函数让测试更方便- 并行处理:在多线程环境下并行操作共享的内存数据很可能会出现意外情况 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数 (Web Worker)<a name="iQJ7m"></a>### 副作用```javascript// 不纯的let mini = 18function checkAge (age) {return age >= mini}// 纯的(有硬编码,后续可以通过柯里化解决)function checkAge (age) {let mini = 18return age >= mini}
让一个函数变的不纯,纯函数的根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。
来源
影响
副作用使方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生。
柯里化
使用柯里化解决上一个案例中硬编码的问题
function checkAge (age) {let min = 18return age >= min}// 普通纯函数function checkAge (min, age) {return age >= min}checkAge(18, 24)checkAge(18, 20)checkAge(20, 30)// 柯里化function checkAge (min) {return function (age) {return age >= min}}// ES6 写法let checkAge = min => (age => age >= min)let checkAge18 = checkAge(18)let checkAge20 = checkAge(20)checkAge18(24)checkAge18(20)
当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变),然后返回一个新的函数接收剩余的参数,返回结果
柯里化的原理就是利用了闭包,缓存部分参数,返回一个新函数接收其余参数,返回结果。
lodash中的柯里化函数
_.curry(func)
- 功能:创建一个函数,该函数接收一个或多个 func 的参数,如果 func 所需要的参数都被提 供则执行 func 并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
- 参数:需要柯里化的函数
- 返回值:柯里化后的函数
```javascript const _ = require(‘lodash’)
// 要柯里化的函数 function getSum (a, b, c) { return a + b + c }
// 柯里化后的函数 let curried = _.curry(getSum)
// 测试 curried(1, 2, 3) curried(1)(2)(3) curried(1, 2)(3)
- 例```javascriptconst _ = 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'))console.log(haveNumber('25$'))const filter = _.curry(function (func, array) {return array.filter(func)})console.log(filter(haveSpace, ['John Connor', 'John_Donne']))const findSpace = filter(haveSpace)console.log(findSpace(['John Connor', 'John_Donne']))
模拟实现
function curry (func) {return function curriedFn (...args) {// 判断实参和形参的个数if (args.length < func.length) {/* NOTE 形参的个数使用函数对象的 length 属性可以获取函数的形参个数。该属性为只读属性,在函数体内、体外都可以使用。在匿名函数中,使用 arguments.callee.length 获取函数形参的个数。callee 是 arguments 对象的一个属性。它可以用于引用该函数的函数体内当前正在执行的函数。这在函数的名称是未知时很有用,例如在没有名称的函数表达式 (也称为“匿名函数”)内。警告:在严格模式下,第5版 ECMAScript (ES5) 禁止使用 arguments.callee()。当一个函数必须调用自身的时候, 避免使用 arguments.callee(), 通过要么给函数表达式一个名字,要么使用一个函数声明.*/return function () {return curriedFn(...args.concat(Array.from(arguments)))}}// 实参和形参个数相同,调用 func,返回结果return func(...args)}}
意义
柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数,这是一种对函数参数的’缓存’,让函数变的更灵活,让函数的粒度更小,可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能。
函数组合
背景
纯函数和柯里化很容易写出洋葱代码 h(g(f(x)))
例如:获取数组的最后一个元素再转换成大写字母, .toUpper(.first(_.reverse(array)))
函数组合可以让我们把细粒度的函数重新组合生成一个新的函数
使用函数组合可以避免洋葱代码
下面这张图表示程序中使用函数处理数据的过程,给 fn 函数输入参数 a,返回结果 b。可以想想 a 数据 通过一个管道得到了 b 数据。
当 fn 函数比较复杂的时候,我们可以把函数 fn 拆分成多个小函数,此时多了中间运算过程产生的 m 和n。
下面这张图中可以想象成把 fn 这个管道拆分成了3个管道 f1, f2, f3,数据 a 通过管道 f3 得到结果 m,m再通过管道 f2 得到结果 n,n 通过管道 f1 得到最终结果 b
fn = compose(f1,f2,f3)b = fn(a)
定义
如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数
函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
函数组合默认是从右到左执行
// 组合函数function compose (f, g) {return function (x) {return f(g(x))}}function first (arr) {return arr[0]}function reverse (arr) {return arr.reverse()}// 从右到左运行let last = compose(first, reverse)console.log(last([1, 2, 3, 4]))
lodash 中的组合函数
lodash 中组合函数 flow() 或者 flowRight(),他们都可以组合多个函数
flow()
flowRight()
是从右到左运行,使用的更多一些
const _ = require('lodash')const toUpper = s => s.toUpperCase()const reverse = arr => arr.reverse()const first = arr => arr[0]const f = _.flowRight(toUpper, first, reverse)console.log(f(['one', 'two', 'three']))
模拟实现 lodash 的 flowRight 方法
function compose (...fns) {return function (value) {return fns.reverse().reduce(function (acc, fn) {return fn(acc)}, value)}}//es6const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), 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']))
结合律
函数的组合要满足结合律 (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)const f = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse))console.log(f(['one', 'two', 'three'])) // => THREE
如何调试组合函数
// 函数组合 调试// NEVER SAY DIE --> never-say-dieconst _ = require('lodash')// const log = v => {// console.log(v)// return v// }const trace = _.curry((tag, v) => {console.log(tag, v)return v})const split = _.curry((sep, str) => _.split(str, sep))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(str, sep)、.join(array, sep)、_.map(array, fn)?
因为函数组合是把多个函数串联到一起,后面的函数依赖前面函数的返回值作为参数,串联时,需要先传入除这个参数之外的所有参数,组合后会得到一个通道函数,调用者调用这个通道函数,传入参数,得到最终结果。
所以函数组合的参数需要是一个柯里化后的只等待传入最后一个参数的函数。
但是每次这样进行函数组合会很麻烦,所以lodash提供了fp模块帮我们把方法群斗包装成了柯里化后的形式,可以直接组合,非常方便。
lodash中的fp模块
lodash 的 fp 模块提供了实用的对函数式编程友好的方法
提供了不可变 auto-curried自动柯里化 iteratee-first函数优先 data-last数据之后 的方法
// lodash 的 fp 模块// NEVER SAY DIE --> never-say-dieconst fp = require('lodash/fp')const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '))console.log(f('NEVER SAY DIE'))
lodash 和 lodash/fp 模块中 map 方法的区别
// lodash 和 lodash/fp 模块中 map 方法的区别const _ = require('lodash')console.log(_.map(['23', '8', '10'], parseInt))// _.map会传递三个参数给parseInt:value index|key collection// parseInt('23', 0, array)//23// parseInt('8', 1, array)//NaN// parseInt('10', 2, array)//2const fp = require('lodash/fp')console.log(fp.map(parseInt, ['23', '8', '10']))//[ 23, 8, 10 ]// fp.map会传递一个参数给parseInt:value
Point Free
我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。
- 不需要指明处理的数据
- 只需要合成运算过程
- 需要定义一些辅助的基本运算函数 ```javascript // 非 Point Free 模式 // Hello World => helloworld 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) 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’))
<a name="hnt1T"></a>## 函子<a name="3gTVQ"></a>### 背景在函数式编程中,如何把副作用控制在可控的范围内?如何异常处理?怎么进行异步操作?<br />可以使用各种函子来解决。<a name="tT7zp"></a>### 定义容器:包含值和值的变形关系(这个变形关系就是函数)<br />函子:是一个特殊的容器,通过一个普通的对象来实现,该对象把传入的值存放在_value属性中,使用map 方法可以运行一个函数对值进行处理(变形关系),把处理后的结果放到_value属性中,然后返回_value是新值的函子的实例,实例可以继续调用map方法运行一个函数对新值进行处理,再次更新_value,依次往后。<br />函子就是一个实现了 map 契约的对象。<a name="obfaV"></a>### Functor 函子```javascript// 一个容器,包裹一个值class Container {// of 静态方法,可以省略 new 关键字创建对象static of(value) {return new Container(value)}constructor(value) {this._value = value}// map 方法,传入变形关系,将容器里的每一个值映射到另一个容器map(fn) {return Container.of(fn(this._value))}}// 测试Container.of(3).map(x => x + 2).map(x => x * x)._value
函数式编程的运算不直接操作值,而是由函子完成。
我们可以把函子想象成一个盒子,这个盒子里封装了一个值。
想要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理。
最终 map 方法返回一个包含新值的盒子(函子)。
在 Functor 中如果我们传入 null 或 undefined?
// 值如果不小心传入了空值(副作用)Container.of(null).map(x => x.toUpperCase())//TypeError:Cannot read property 'toUpperCase' of null
MayBe 函子
我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理
MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)
class MayBe {static of(value) {return new MayBe(value)}constructor(value) {this._value = value}// 如果对空值变形的话直接返回 值为 null 的函子map(fn) {return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))}isNothing() {return this._value === null || this._value === undefined}}// 传入具体值MayBe.of('Hello World').map(x => x.toUpperCase()) // 传入 null 的情况MayBe.of(null).map(x => x.toUpperCase()) // => MayBe { _value: null }
在 MayBe 函子中,我们很难确认是哪一步产生的空值问题,如下例:
MayBe.of('hello world').map(x => x.toUpperCase()).map(x => null).map(x => x.split(' '))// => MayBe { _value: null }
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)// console.log(r2)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)let r = parseJSON('{ "name": "zs" }').map(x => x.name.toUpperCase())console.log(r)
IO 函子
IO 函子中的 _value 是一个函数,这里是把函数作为值来处理。
IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),包装当前的操作纯,把不纯的操作交给调用者来处理。
其实就是使用函数组合把不纯的操作组合得到一个通道函数,让调用者自己去调用这个函数得到有副作用结果。
Container.of(null).map(x => x.toUpperCase())const fp = require('lodash/fp') class IO {static of(x) {return new IO(function () {return x})}constructor(fn) {this._value = fn}map(fn) {// 把当前的 value(非纯函数)和 传入的 fn(非纯函数) 组合成一个新的函数return new IO(fp.flowRight(fn, this._value))}}let io = IO.of(process).map(p => p.execPath)console.log(io._value())//IO 函子的_value是个组合后的通道函数,传入参数执行即可拿到有副作用的结果
Task 函子
Task 函子可以用来处理异步任务。
异步任务的实现过于复杂,我们使用 folktale 中的 Task 来演示。
folktale 是一个标准的函数式编程库。和 lodash、ramda 不同的是,他没有提供很多功能函数,只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、 MayBe等。
// folktaleconst { compose, curry } = require('folktale/core/lambda')const { toUpper, first } = require('lodash/fp')// 第一个参数是传入函数的参数个数let f = curry(2, function (x, y) { console.log(x + y) })f(3, 4)f(3)(4)// 函数组合let f = compose(toUpper, first)f(['one', 'two'])// Task 函子const { task } = require('folktale/concurrency/task')function readFile(filename) {return task(resolver => {fs.readFile(filename, 'utf-8', (err, data) => {if (err) resolver.reject(err)resolver.resolve(data)})})}// 调用 run 执行readFile('package.json').map(split('\n'))// 这个函子的map方法会拿到读取后的结果.map(find(x => x.includes('version')))// 这个函子的map方法会拿到变更后的结果.run().listen({onRejected: err => { console.log(err) },onResolved: value => { console.log(value) }})
Pointed 函子
Pointed 函子是实现了 of 静态方法的函子
of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文 Context(把值放到容器中,使用 map 来处理值)
class Container {static of(value) {return new Container(value)}......}Contanier.of(2).map(x => x + 5)
Monad 单子
背景
在使用 IO 函子的时候,很容易写出洋葱代码IO(IO(x)):
// 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)// NOTE fs模块读取文件的相对路径是以启动node的位置为基准的
函数嵌套的洋葱式代码可以使用函数组合解决;函子嵌套的洋葱式代码可以使用Monad单子解决;
Monad 函子是可以变扁的 Pointed 函子,IO(IO(x))
一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
// IO Monadconst 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)/* 执行过程分析 */// 1readFile('package.json')// 结果this._value = function () {return fs.readFileSync('package.json', 'utf-8')}// 2.map(fp.toUpper)// 结果this._value = fp.flowRight(fp.toUpper, function () {return fs.readFileSync('package.json', 'utf-8')})// 3.flatMap(print)// 过程this.map(print).join()new IO(fp.flowRight(print, fp.flowRight(fp.toUpper, function () {return fs.readFileSync('package.json', 'utf-8')})).join()// 结果fp.flowRight(print, fp.flowRight(fp.toUpper, function () {return fs.readFileSync('package.json', 'utf-8')})()// 4.join()// 结果fp.flowRight(print, fp.flowRight(fp.toUpper, function () {return fs.readFileSync('package.json', 'utf-8')})()() // 大写的文件内容let r = 大写的文件内容
