大前端课程学习体会与知识总结——01函数式编程
文章内容输出来源:拉勾教育大前端高薪训练营
开发是需要不停的学习,这是我工作以来得出的结论。但是学习途径确实多种多样的。
本篇文章干货内容太多,首先,来点感悟吧,毕竟是我注册CSDN以来第一次发表文章,而且还是那么长的文章。
先说一说我的经历吧,我的工作是前端开发,喜欢学习的那种(不喜欢也不行,毕竟年龄和技术不匹配会被淘汰的)。
平时也会通过各种途径学习,这些途径包括:
- 从某二手网站买前端课程合集,课程很多,但打开发现都有些老了,没学下去
- 也有去小破站找过学习的资源,emmm…大概是操作不当,反正找到的也都是一些零零散散的内容,不怎么系统
- 然后还有买过各种前端的课,都是那种比较基础的,课程加起来也很多,但总感觉学不到东西,听完课感觉都会了,用的时候却发现无从下手,学的东西似乎用不上。
虽然一直有这些问题在困扰我,但我从来没有放弃过学习以及寻找好的学习途径,然后,我遇到了拉勾教育。
关于拉勾,我最开始知道的是拉钩招聘,第一次知道有做教育是朋友圈看到的一元课程——《Flutter快学快用24讲》,虽然不是很清楚Flutter是个啥,但秉持着一块钱又不贵,不买白不买的心态,还是买了下来,然后就下载了app,发现有大前端课程,就点了一下了解课程,没想到很快就有人联系我了,嗯…由于我的防骗意识太好了,就当成了诈骗拒绝了学习邀请。
时隔一年多,我终于了解到拉勾教育是一个正经的专业的学习平台,又了解了在这个大前端高薪训练营中能学习到什么,所以我加入了进来。
开启我新一轮的学习之旅。
友情提醒:本文大约有三万字左右,阅读还请合理安排时间,当然也可以收藏一下呦~
函数式编程
- 什么是函数式编程
- 为什么要学函数式编程
什么是函数式编程
函数式编程(Functional Programming, FP),FP是编程范式之一,可以认为是一种编程的风格,我们常听说的编程范式还有面向过程编程、面向对象编程。函数式编程可以认为是一种思维模式,加上它的方法。
-
面向过程编程的思维方式: 按照步骤实现,一步一步实现想要的功能
-
面向对象编程的思维方式: 把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事件的联系
-
函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)
-
程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数
-
x -> f(联系、映射) -> y, y = f(x)
-
函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y = sin(x),x和y的关系
-
相同的输入始终要得到相同的输出(纯函数)
-
函数式编程用来描述数据(函数)之间的映射
// 非函数式 面向过程 let num1 = 2 let num2 = 3 let sum = num1 + num2 console.log(sum); // 函数式 函数式编程思想 function add (n1, n2) { return n1 + n2 } let sum = add(num1, num2) console.log(sum);
函数式编程中一定会有函数,提高代码的复用性;
函数式编程中抽象出来的函数都是细粒度的函数,这些函数可以重新组合成功能更强大的函数
为什么要学函数式编程
函数是编程是非常古老的一个概念,早于第一台计算机的诞生,函数式编程的历史
-
为什么现在还要学函数式编程?
函数式编程是随着React的流行收到越来越多的关注 React中的高阶组件使用了高阶函数来实现,高阶函数就是函数式编程的一种特性。虽然React中使用了一些函数式编程的特性,但它并不是纯函数式的,另外React中的一些生态,比如Redux,它使用了一些函数式编程的思想,所以,想要更好的学习React和Redux,就需要了解函数式编程。
-
Vue3也开始拥抱函数式编程
Vue3对Vue2做了很大的重构,而且越来越偏向函数式,在使用Vue3的CompositionAPI可以感受到,在Vue2的源码中,也大量使用了高阶函数,这些流行框架都在趋向于函数式编程,甚至可以说,可以不学这些框架,但是不能不了解函数式编程,因为这些才是永远不变的内容。
-
函数式编程可以抛弃this
很多人在学习之前,都了解过面向对象的语言,比如说java, c#,c++等等,所在在学习Js的时候,也都是从面向对象开始的,通过学习原型、原型链、以及模拟实现继承的机制来实现面向对象的一些特性。
在学习的过程中,还会遇到this,在使用this的时候可能遇到各种各样的问题,在使用JavaScript来模拟面向对象的一些特性的时候,非常痛苦。以后,可以拥抱函数式编程,如果拥抱函数式编程甚至可以抛弃掉this。 -
打包过程中可以更好的利用tree shaking过滤无用代码
-
方便测试、方便并行处理
-
有很多库可以帮助我们进行函数式开发: lodash、underscore、ramda
总结
- 函数式编程就是对运算过程进行抽象
- 函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系
- 相同的输入始终要得到相同的输出
-
前置知识
-
函数是一等公民
-
高阶函数
-
闭包
函数是一等公民
-
函数可以存储在变量中
-
函数作为参数
-
函数作为返回值
在JavaScript中函数就是一个普通的对象(可以通过new Function()),我们可以把函数存储到变量/数组中,它还可以作为另一个函数的参数和返回值,甚至我们可以在程序运行的时候通过new Function('alert(1)')
来构造一个新的函数
-
把函数赋值给变量
// 把函数赋值给变量 let fn = function () { console.log('Hello First-class Function'); } fn() // 一个示例 const constBlogController = { index (posts) { returnViews.index(posts) }, show (post) { returnViews.show(post) }, create (attrs) { returnDb.create(attrs) }, update (post, attrs) { returnDb.update(post, attrs) }, destroy (post) { returnDb.destroy(post) } } // 优化 const constBlogController = { index: returnViews.index, show: returnViews.show, create: returnDb.create, update: returnDb.update , destroy: returnDb.destroy, }
-
函数是一等公民是我们后面要学的高阶函数、柯里化等的基础。
高阶函数
什么是高阶函数
-
高阶函数(Higher-order function)
- 可以把函数作为参数传递给另一个函数
- 可以把函数作为另一个函数的返回结果(函数是一等公民的特性)
-
函数作为参数
function forEach (array, fn) { for (let i = 0; i< array.length; i++) { fn(array[i]) } } // 测试 let arr = [1, 2, 3, 4, 63] forEach(arr, function (item) { console.log(item); }) // 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 } // 测试 let arr = [1, 2, 3, 4, 63] const r = filter(arr, function (item) { return item % 2 === 0 }) console.log(r);
-
函数作为返回值
function makeFn() {
let msg = 'Hello function'
return function () {
console.log(msg);
}
}
const fn = makeFn()
fn()
// makeFn()()
// once
function once(fn) {
let done = false
return function (){
if (!done) {
done = true
fn.apply(this, arguments)
}
}
}
const pay = once(function (money) {
console.log(`支付:${money}RMB`);
})
pay(1)
pay(2)
pay(3)
使用高阶函数的意义
-
抽象可以帮我们屏蔽细节,只需要关注于我们的目标
-
高阶函数是用来抽象通用的问题
// 面向过程的方式 let array = [1, 2, 3, 4] for (let i = 0; i< array.length; i++) { console.log(array[i]); } // 高阶函数 let array = [1, 2, 3, 4] forEach(array, item => { console.log(item); }) let r = filter(array, item => { return item % 2 === 0 })
常用的高阶函数
- forEach
- map
- filter
- every
- some
- find/findIndex
- reduce
- sort
- …
// map
// 使用const希望不要改变这个函数
const map = (array, fn) => {
let results = []
for(let value of array) {
results.push(fn(value))
}
return results
}
// 测试
let arr = [1, 2, 3, 4]
arr = map(arr, v => v * v)
console.log(arr);
// every
const every = (array, fn) => {
let results = true
for(let value of array) {
results = fn(value)
if (!results) {
break
}
}
return results
}
// 测试
let arr = [1, 2, 3, 4]
// arr = every(arr, v => v < 10)
arr = every(arr, v => v < 2)
console.log(arr);
// some
const some = (array, fn) => {
let results = false
for(let value of array) {
results = fn(value)
if (results) {
break
}
}
return results
}
// 测试
let arr = [1, 2, 3, 4]
let r = some(arr, v => v % 2 === 0)
// let r = some(arr, v => v > 5)
console.log(r);
闭包
- 闭包(Closure)的概念:函数和其周围的状态(词法环境)的引用捆绑在一起行程闭包。
- 可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域的成员
function makeFn() {
let msg = 'Hello function'
return function () {
console.log(msg);
}
}
const fn = makeFn()
fn()
makeFn定义了一个函数,在这个函数中又返回了一个函数
调用makeFn的执行过程
-
先在内部定义一个变量msg,当这个函数执行完毕,内部的变量会被释放掉,如果这个函数又返回了一个函数,并且在这个返回的函数中,又访问了外部函数中的成员,这就是闭包。
-
当外部函数对内部的成员有引用的时候,内部函数的成员就不能被释放
-
当调用外部函数fn时,其实就是调用makeFn中的内部函数
-
调用这个内部函数的时候会访问到msg,也就是makeFn中的msg
// once
function once(fn) {
let done = false
return function () {
if (!done) {
done = true
fn.apply(this, arguments)
}
}
}
const pay = once(function (money) {
console.log(`支付:${money}RMB`);
})
pay(1)
pay(2)
- 闭包的核心作用:把函数内部的作用范围延长
- 闭包的本质:函数在执行的时候会放到一个执行栈上,当函数执行完毕之后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员
- 闭包案例
// 生成计算多少次幂的函数
// Math.pow(4, 2)
// Math.pow(5, 2)
function makePower (power) {
return function (num) {
return Math.pow(num, power)
}
}
// 求平方
let power2 = makePower(2)
let power3 = makePower(3)
console.log(power2(4));
console.log(power2(5));
console.log(power3(4));
console.log(power3(5));
// 为不同级别的员工计算工资
function makeSalary (base) {
return function (performance) {
return base + performance
}
}
let salaryLevel1 = makeSalary(12000)
let salaryLevel2 = makeSalary(15000)
console.log(salaryLevel1(2000));
console.log(salaryLevel2(3000));
总结
- 使用函数式编程可以使代码更简洁
- 函数作为参数会让函数操作更灵活
纯函数
纯函数的概念
-
纯函数:相同的输入永远会得到相同的输出,而且没有任何可观察的副作用
-
纯函数就类似于数学中的函数(用来描述输入和输出之间的关系),y = f(x)
-
lodash 是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法
// 演示lodash // first / last / toUpper / reverse / each / includes / find / findIndex const _ = require('lodash') const array = ['jack', 'tom', 'lucy', 'kate'] console.log(_.first(array)); // => jack console.log(_.last(array)); // => kate console.log(_.toUpper(_.first(array))); // => JACK console.log(_.reverse(array)); // => [ 'kate', 'lucy', 'tom', 'jack' ] const r = _.each(array, (item, index) => { console.log(item, index); }) console.log(r); // => [ 'kate', 'lucy', 'tom', 'jack' ] const r1 = _.includes(array, 'tom') const r2 = _.find(array, item => item === 'tom') const r3 = _.findIndex(array, item => item === 'tom') console.log(r1); // => true console.log(r2); // => tom console.log(r3); // => 2
-
数组的
slice
和splice
分别是:纯函数和不纯的函数slice
返回数组中的指定部分,不会改变原数组splice
对数组进行操作返回该数组,会改变原数组
// 纯函数和不纯的函数 // slice / splice let array = [1, 2, 3, 4, 5] // 纯函数 console.log(array.slice(0, 3)) // => [ 1, 2, 3 ] console.log(array.slice(0, 3)) // => [ 1, 2, 3 ] console.log(array.slice(0, 3)) // => [ 1, 2, 3 ] // 不纯的函数 console.log(array.splice(0, 3)); // => [ 1, 2, 3 ] console.log(array.splice(0, 3)); // => [ 4, 5 ] console.log(array.splice(0, 3)); // => []
function getSum(n1, n2) {
return n1 + n2
}
console.log(getSum(1, 2));
// => 3
console.log(getSum(1, 2));
// => 3
console.log(getSum(1, 2));
// => 3
- 函数式编程不会保留计算中间的结果,所以变量式不可变的(无状态的)
- 我们可以把一个函数的执行结果交给另一个函数去处理
- 对于纯函数来说,必须要有输入,必须要有输出
纯函数的好处
- 可缓存
- 因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来
// 记忆函数
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函数
// 模拟memoize方法的实现
// 调用的时候需要传入一个函数,并返回一个函数
function memoize (fn) {
// 将函数fn的执行结果缓存起来,由于函数fn是一个纯函数,纯函数有一个特点,即相同输入总是得到相同的输出,所以可以把这个函数的参数作为这个对象的键,把这个执行结果作为这个键的值
let cache = {}
return function () {
// 由于arguments是一个伪数组,所以需要转换为字符串来作为键
let key = JSON.stringify(arguments)
// 需要先判断cache[key]是否有值,如果有值则直接返回给cache[key],如果没有值,执行fn
// 调用fn的时候需要把参数arguments传给fn,但是arguments是一个伪数组,里面值的数量不确定,可以通过调用fn的apply方法
// apply方法可以改变函数内部的this,它的第二个参数可以把一个伪数组或者数组展开,这里的目的不是为了改变this,而是为了把arguments展开递给fn
// 所以它的第一个参数可以写成fn
cache[key] = cache[key] || fn.apply(fn, arguments)
return cache[key]
}
}
let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4));
console.log(getAreaWithMemory(4));
console.log(getAreaWithMemory(4));
-
可测试
- 纯函数让测试更方便
-
并行处理
- 在多线程环境下并行操作共享的内存数据很可能会出现意外情况
- 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数(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 (age) { let mini = 18 return age >= mini } // 普通的纯函数 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 } } const mini18 = checkAge (18) const mini20 = checkAge (20) console.log(mini18(20)); console.log(mini18(24)); console.log(mini20(24)); // ES6 let checkAge = min => (age => age >= min) const mini18 = checkAge (18) const mini20 = checkAge (20) console.log(mini18(20)); console.log(mini18(24)); console.log(mini20(24));
-
柯里化(Currying):
- 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变)
- 然后返回一个新的函数接收剩余的参数,返回结果
lodash 中的柯里化函数
- _.curry(func)
- 功能:创建一个函数,该函数接受一个或多个func的参数,如果func所需要的参数都被提供则执行func并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
- 参数:需要柯里化的函数
- 返回值:柯里化后的函数
// lodash 中的 curry 基本使用
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));
console.log(curried(1)(2)(3));
- 案例
// 柯里化案例
const _ = require('lodash')
// 匹配字符串中的空白
''.match(/\s+/g)
// 匹配字符串中的数字
''.match(/\d+/g)
const match = _.curry(function (reg, str) {
return str.match(reg)
})
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
console.log(haveSpace('helloworld'));
// => null
console.log(haveSpace('hello world'));
// => [ ' ' ]
console.log(haveNumber('123abc'));
// => [ '123' ]
console.log(haveNumber('abcd'));
// => null
const filter = _.curry(function (fn, array) {
return array.filter(fn)
})
console.log(filter(haveSpace, ['hello world', 'hello_world']));
// => [ 'hello world' ]
const findSpace = filter(haveSpace)
console.log(findSpace(['hello world', 'hello_world']));
// => [ 'hello world' ]
- 模拟
_.curry()
的实现
// 模拟实现 lodash 中的curry方法
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));
// 返回的函数如何处理:
// 1.调用返回的柯里化函数curried时,getSum需要几个参数,就传递几个参数。
// 如果传递的参数个数和getSum函数参数个数相同,那会立即调用getSum,并且返回它的执行结果。
// 2.调用返回的柯里化函数时,只传递getSum需要的部分参数。此时curried的函数会返回一个新的函数,并且等待接收getSum需要的其他参数。
// 接收参数个数不固定的参数,可以用es6的剩余参数来获取,...args
// 然后对比形参个数和实参个数,看形参个数和实参个数是否相同,
// 实参个数args是个数组,通过 args.length 可以获取
// 形参的个数,是传入的函数getSum的形参个数,可以用 函数名.length 来获取
function curry (func) {
return function curriedFn(...args) {
// 判断实参和形参的个数,如果实参个数小于形参个数,此时要返回一个新的函数
if (args.length < func.length) {
// 第一次调用的参数在args中,第二次调用的参数在arguments中,在返回函数时,需要把第一次和第二次的参数都传递过去,由于第二次的参数arguments是一个伪数组,所以需要通过Array.from(arguments)转换成数组后再合并,由于在调用函数时的参数并不是一个数组,所以需要...展开
return function () {
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return func(...args)
}
}
const curried = curry(getSum)
console.log(curried(1, 2, 3));
console.log(curried(1)(2, 3));
console.log(curried(1, 2)(3));
总结
- 柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住了某些固定参数的新函数
- 这是一种对函数参数的缓存
- 让函数变的更灵活,让函数的粒度更小
- 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能
函数组合
- 纯函数和柯里化很容易写出洋葱代码
h(g(f(x)))
- 获取数组的最后一个元素再转换成大写字母,
_.toUpper(_.first(_.reverse(array)))
- 获取数组的最后一个元素再转换成大写字母,
- 函数组合可以让我们把细粒度的函数重新组合生成一个新的函数
管道
下面这张图表示程序中使用函数处理数据的过程,给fn函数输入参数a,返回结果b。可以想象通过一个管道得到了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)
函数组合
- 函数组合(compose):如果一个函数要经过多个函数的处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。在处理的时候这些中间过程会生成多个中间结果,但是这个结果我们不需要关注。
- 函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
- 函数组合默认是从右到左执行
// 函数组合演示
// 这个函数能够接收多个函数并把他们组合成一个新的函数返回
// 返回的新的函数需要接受一个参数
// 因为函数组合默认是从右到左执行,所以先执行g,g的参数为输入的参数,再执行f的参数为执行g的结果
// 处理两个函数的组合函数
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, 23, 3, 4]));
- lodash中的组合函数
- lodash中组合函数flow()或者flowRight(),他们都可以组合多个函数
- 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(['1', '2', '3', '4', '5']));
- 模拟实现lodash的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(['1', '2', '3', '4', '5']));
// 分析 flowRight 的方法如何实现:
// 1.flowRight的参数是不固定的,并且这些参数都是纯函数的形式
// 2.调用flowRight后,会把这些函数组合成一个新的函数
// 3.返回的这个函数需要接收一个参数,用于开启数据的处理
function compose (...args) {
return function (value) {
// 最终需要一次调用传入的函数,并且返回执行结果,传入的函数组合是从右向左执行的,传入的纯函数都存储在args,需要先对数据进行反转
// reduce对数组中的每一个元素执行一个由我们提供的函数,并将其汇总成一个单个的结果,第一个参数是函数,其中acc为累计的结果,第二个参数为acc的初始值
return args.reverse().reduce(function (acc, fn) {
return fn(acc)
}, value)
}
}
const r = compose(toUpper, first, reverse)
console.log(r(['1', '2', '3', '4', '5']));
// ES6简写形式
const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)
- 函数的组合要满足结合律(associativity)
- 我们既可以把g和h组合,还可以把f和g组合,结果都是一样的
// 结合律 (associativity)
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(['1', '2', '3', '4', '5']));
调试
- 如何调试组合函数
// 函数组合 调试
// NEVER SAY DIE --> never-say-die
const _ = require('lodash')
// 函数组合的时候,我们需要的是只有一个参数的纯函数
// 第一个函数:使用数组把空格进行切割。对数组进行切割,lodash中有一个方法`_.split()`,这个方法需要三个参数,这里只关注前两个,第一个是要切割的字符串,第二个是分隔符,它有多个参数,所以在函数组合的时候没办法使用,所以要把它转换成只有一个参数的函数,当函数组合完毕之后,在需要的时候才会传入这个函数所需要的字符串,所以第一个参数应该在最后的位置。
// 第二个函数:转换大小写。转换大写是`_.toUpper()`,小写是`_.toLower()`,只需要一个参数,所以它可以在函数组合的过程中使用
// 第三个函数:join方法。lodash中有`_.join()`方法,这个方法需要两个参数,第一个是数组,第二个是分隔符,在函数组合的时候要使用一个参数的函数,并且这个方法中的数组参数需要最后给它传递,所以这个参数的位置也需要交换一下
// 使用函数的柯里化转换成只有一个参数的纯函数
const split = _.curry((sep, str) => _.split(str, sep))
// _.toLower()
const join = _.curry((sep, array) => _.join(array, sep))
// const log = v => {
// console.log(v)
// return v
// }
const trace = _.curry((tag, v) => {
console.log(tag, v)
return v
})
// 组合函数
// const f = _.flowRight(join('-'), _.toLower, split(' '))
// => n-e-v-e-r-,-s-a-y-,-d-i-e
// 得到的结果是非预期的,需要添加log函数排查问题
// const f = _.flowRight(join('-'), _.toLower, log, split(' '))
// => [ 'NEVER', 'SAY', 'DIE' ]
// split(' ')返回的结果是正常的,接着排查_.toLower
// const f = _.flowRight(join('-'), log, _.toLower, split(' '))
// => never,say,die
// split(' ')的结果传递给_.toLower后被转换成了小写字符串,但是当前log所在的位置需要的是一个数组
// 上面发现问题后,需要使用map函数。map函数可以对数组内的每一个元素进行处理,并返回处理后的数组,lodash中有`_.map()`方法,这个方法需要两个参数,第一个是数组,第二个是需要对数组进行处理的函数,这个方法也需要进行柯里化,柯里化的时候参数的位置也需要交换一下
const map = _.curry((fn, array) => _.map(array, fn))
// const f = _.flowRight(join('-'), log, map(_.toLower), log, split(' '))
const f = _.flowRight(join('-'), trace('map之后'), map(_.toLower), trace('split之后'), split(' '))
// => never-say-die
console.log(f('NEVER SAY DIE'))
lodash/fp
- lodash的fp模块提供了实用的对函数编程友好的方法
- 提供了不可变auto-curried iteratee-first data-last 的方法
- lodash模块中是数据优先,函数之后,未被柯里化
// lodash模块
const _ = require('lodash')
console.log(_.map(['a', 'b', 'c'], _.toUpper))
// => [ 'A', 'B', 'C' ]
console.log(_.map(['a', 'b', 'c']))
// => [ 'a', 'b', 'c' ]
console.log(_.split('Hello World', ' '))
// => [ 'Hello', 'World' ]
- lodash/fp模块中是函数优先,数据之后,已被柯里化
// lodash/fp模块
const fp = require('lodash/fp')
console.log(fp.map(fp.toUpper, ['a', 'b', 'c']))
// => [ 'A', 'B', 'C' ]
console.log(fp.map(fp.toUpper)(['a', 'b', 'c']))
// => [ 'A', 'B', 'C' ]
console.log(fp.split(' ', 'Hello World'))
// [ 'Hello', 'World' ]
console.log(fp.split(' ')('Hello World'))
// [ 'Hello', 'World' ]
- 案例
// 使用lodash时的方法
const _ = require('lodash')
// 这些都是数据优先,函数之后,而且没有被柯里化,所以在调用的时候需要我们自己进行柯里化处理,并且对参数进行改造,改造成函数优先,数据之后
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('-'), map(_.toLower), split(' '))
console.log(f('NEVER SAY DIE'))
// 使用lodash的fp方法
const 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中的map实现 const _ = require('lodash') console.log(_.map(['23', '8', '10'], parseInt)) // => [ 23, NaN, 2 ] // _.map() // 当我们调用map中传递的这个函数时,它需要接收三个参数value, index | key, collection // 执行过程: // 第一次: parseInt('23', 0, array) // 第二次: parseInt('8', 1, array) // 第三次: parseInt('10', 2, array) // parseInt的第二个参数取值范围是2-36,如果传0,就是10进制,如果是1,就返回NAN,因为1不支持,如果传2,就是转换成2进制的数据
// 用lodash/fp 中的map实现 const fp = require('lodash/fp') console.log(fp.map(parseInt, ['23', '8', '10'])) // => [ 23, 8, 10 ]
Point Free
**Point Free:**我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。
- 不需要指明处理的数据
- 只需要合成运算过程
- 需要定义一些辅助的基本运算函数
const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '))
- 案例演示
// 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'))
// => hello_world
- 使用Point Free的模式,把单词中的首字母提取并转换成大写
// point free 案例
// 把一个字符串中的首字母提取并转换成大写,使用. 作为分隔符
// 例如:world wild web => W. W. W
// 分析:
// 1、先对字符串进行切割,切割后得得一个数组
// 2、把数组中的每一项都转换成大写,并且把数组中每一个元素的第一个字母取出来
// 再使用. 把数组中的每一项合并起来
const fp = require('lodash/fp')
// const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '))
// 优化调用两次fp.map
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '))
console.log(firstLetterToUpper('world wild web'))
// => W. W. W
Functor(函子)
为什么要学函子
到目前为止已经学习了函数式编程的一些基础,但是我们还没有演示在函数式编程中如何把副作用控制在可控制的范围内、异常处理、异步操作等。
什么是Functor
- 容器:包含值和值的变形关系(这个变形关系就是函数)
- 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数,对值进行处理(变形关系)
Functor 函子
// Functor 函子
// 函子是一个普通的对象,这个对象里面应该维护一个值,并且要对外公布一个map方法,所以我们可以通过一个类来描述函子
// 先定义一个class
// 函子是一个容器,所以这个类的名字叫Container
class Container {
// 这个类中先定义一个构造函数
// 当我们创建函子的时候,函子内部要有一个值,所以在构造函数中,要把这个值传进来
constructor (value) {
// 函子内部要把这个值存储起来
// 注意:这个值是函子内部维护,只有它自己知道,这个值永远不对外公布
// 所以在定义这个属性的时候,可以通过this._value定义这个属性(约定所有以下划线开头的这些成员,都是私有的成员,也就是这个value不想让外部访问)
// 这个值就等于初始化的时候传递进来的值
this._value = value
}
// 还要对外公布一个map方法
// map方法的作用是接收一个处理值的函数,这个函数是一个纯函数,以为需要把value传递给这个函数,由这个函数真正处理这个值
map (fn) {
// map返回的不是值,而是一个新的函子对象,在这个新的函子对象里保存新的值,始终不把值对外公布,想要处理值的话,就要给map对象传递一个处理值的函数
// 在返回这个新的函子的时候,需要把处理的值传递给Container
return new Container(fn(this._value))
}
}
let r = new Container(5)
.map(x => x + 1)
.map(x => x * x)
console.log(r);
// 每次创建Container的时候都需要调用new实现,这种是面向对象编程,现在是使用函数式编程,所以可以进行修改一下
class Container {
// 创建一个静态的方法of,of的作用就是返回一个函子的对象,在创建函子对象的时候,of需要接收一个参数value, of放方法中直接返回new Container,其实of方法中就封装了new关键字
static of (value) {
return new Container(value)
}
constructor (value) {
this._value = value
}
map (fn) {
// 在map方法中,可以使用of方法,因为of是一个静态方法,可以直接通过类名调用
return Container.of(fn(this._value))
}
}
let r = Container.of(5)
.map(x => x + 2)
.map(x => x * x)
console.log(r);
-
注意:
- 获取的r并不是值,而是一个函子对象
- 值在这个函子对象里
- 我们永远不去取出来这个值
- 想要对这个值进行处理,就调用map方法
-
总结
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了map契约的对象,也就是所有的函子都有一个map对象
- 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
- 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理值的函数(纯函数),由这个函数来对质进行处理
- 最终map方法返回一个包含新值的盒子(函子)
-
在functor中如果我们传入null或undefined
// 演示传入null undefined 的问题
Container.of(null)
.map(x => x.toUpperCase())
// => TypeError: Cannot read property 'toUpperCase' of null
MayBe 函子
- 我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理
- MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)
// MayBe 函子
class MayBe {
static of (value) {
return new MayBe(value)
}
constructor (value) {
this._value = value
}
map(fn) {
// 传递的参数为 null | undefined 会出现异常
// return MayBe.of(fn(this._value))
// 在处理之前可以先判断value的值是否为 null | undefined
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);
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(' '))
console.log(r);
// MayBe函子虽然可以处理空值的问题,但是如果多次调用map方法的时候,哪一次出现空值,是不明确的
Either 函子
- Either 两者中的任何一个,类似于if…else…的处理
- 异常会让函数变的不纯,Either 函子可以用来做异常处理
// Either函子
class Left {
static of (value) {
return new Left(value)
}
constructor (value) {
this._value = value
}
// Left中的map方法比较特殊,直接返回当前对象this,这样做可以给Left函数中嵌入一个错误消息
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)
// Right函子返回了处理之后的数据,Left函子直接返回了传入的值,没有做任何处理
// console.log(r1);
// // => Right { _value: 14 }
// console.log(r2);
// // => Left { _value: 12 }
// 演示给Left函数中嵌入错误消息
function parseJSON (str) {
// 正确的时候走Right函子
try {
// 返回的是一个函子,我们会把转换后的结果交给这个函子处理,所以直接传入一个正确的值。对于这个返回出来的值,当我们调用map方法的时候,map方法传入的函数会处理现在要传入的值
return Right.of(JSON.parse(str))
} catch (e) {
// 出错的时候走Left函子
// 对于纯函数来说,相同的输入始终要有相同的输出,所以出错的时候也要返回一个函子,因为Either里面有Left和Right,Right处理正确的值,如果出现异常,可以返回Left函子,而Left函子中可以存储一些错误的信息
return Left.of({ error: e.message})
}
}
// let r = parseJSON('{name : zs}')
// console.log(r);
// // => Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
let r = parseJSON('{"name": "zs"}')
.map(x => x.name.toUpperCase())
console.log(r);
// => Right { _value: 'ZS' }
- Either 用来处理异常
IO 函子
- IO 函子中的_value是一个函数,因为函数是一等公民,所以这里把函数作为值来处理
- IO 函子可以把不纯的动作存储到
_value
中,_value
中存储的是函数,我们在函子内部并没有调用这个函数,所以通过IO 函子其实是延迟执行这个不纯的操作(惰性执行) - 通过IO 函子包装一些函数,当需要的时候再来执行这些函数,因为IO 函子中存储的这些函数有可能是不纯的,通过IO 函子包装之后,那么当前的操作是一个纯操作,把不纯的操作延迟到调用的时候
- 这些不纯的操作最终都要执行,可以把不纯的操作交给调用者来处理
// IO函子
// IO是Input / Output(输入 / 输出)的意思
const fp = require('lodash/fp')
// constructor创建一个构造函数,这个函数的参数是一个函数,_value把这个函数存储起来
// of方法接收的value不是函数而是数据,of方法里返回一个IO函子,调用IO的构造函数,传递的参数是一个函数,这个函数把传递过来的值包裹起来了
// IO函子最终是想把一个值返回出来,不过IO函子是通过一个函数把这个值包裹起来了
// IO函子的 _value 保存的是一个函数,而这个函数返回的是一个值,它把求值的过程做了延迟处理,当需要这个值的时候,再调用IO函子中的 _value 这个函数
// map方法,接收一个函数参数fn,在map方法中通过调用IO的构造函数(不是IO的of方法,因为在map方法里面要把当前函数的_value和传入的函数fn组合成一个新的函数,而不是调用函数处理值),创建一个IO的函子,参数里面调用了fp的flowRight,这里组合了两个函数,把当前函子的 _value(函数)组合上了传入的fn,返回了一个新的函数,作为新的IO函子的值返回出来
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))
}
}
// 调用
// IO的of方法
let r = IO.of(process).map(p => p.execPath)
// console.log(r);
// => IO { _value: [Function] }
console.log(r._value());
// => C:\Program Files\nodejs\node.exe
Task异步执行
- 异步任务的实现过于复杂,我们使用folktale中的Task来演示
- folktale一个标准的函数式编程库
- 和lodash、ramda不同的是,它没有提供很多功能的函数
- 只提供了一些函数式处理的操作,例如compose、curry等,还有一些函子Task、Either、MayBe等
// folktale 中的 compose、curry
const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')
// // curry有两个参数,第一个参数用来指明第二个参数(函数)的参数个数,传递第一个参数的目的是为了避免一些错误
// let f = curry(2, (x, y) => x + y)
// console.log(f(1, 2));
// // => 3
// console.log(f(1)(2));
// // => 3
// compose 在 lodash 中使用的是 flowRight
let f = compose(toUpper, first)
console.log(f(['one', 'two']));
// => ONE
- Task异步执行
- 当前使用的folktale版本问2.3.2,folktale 2.x和1.0中的Task区别很大,1.0中用法更接近现在演示的函子
- 这里以2.3.2版本进行演示,无非就是API的不同,可以查看文档使用
// Task 处理异步任务
const fs = require('fs')
// 导入前需要知道导入的方法在哪个模块中,可以查看folktale的官网
// task在2.x中提供的是一个函数形式,返回的是一个函子对象
// 在1.0中提供的是一个类
const { task } = require('folktale/concurrency/task')
const { split, find } = require('lodash/fp')
/**
* 读取文件的函数
* @param {*} filename 需要读取文件的路径
*/
function readFile (filename) {
// 返回一个Task函子。task返回的是一个函数,这个函数需要接收一个固定的参数resolver,resolver是一个对象,里面提供了两个方法resolve(成功后执行)和reject(失败时执行)
return task(resolver => {
// 读取文件需要三个参数,第一个是路径。第二个是编码格式,第三个是回调函数,node中错误优先,所以函数的第一个参数是err,第二个才是需要读取的数据
fs.readFile(filename, 'utf-8', (err, data) => {
if (err) resolver.reject(err)
resolver.resolve(data)
})
})
}
// 调用readFile的时候会返回一个Task函子
// 获取package.json文件中的version
readFile('package.json') // 执行完之后不会读取文件,而是返回了一个Task函子
.map(split('\n')) // 按行分隔文件
.map(find(x => x.includes('version'))) // 找到version所在的位置
.run() // 调用Task函子提供的run方法读取文件,读取完文件后没有传递resolver,会不知道如何处理数据,所以需要使用Task提供的listen方法监听当前的执行状态,listen是以事件的机制实现监听的
.listen({
onRejected: err => {
// 失败时执行的函数
console.log(err);
},
onResolved: value => {
// 成功时执行的函数
console.log(value);
}
})
// => "version": "1.0.0",
Pointed 函子
- Pointed 函子是实现了of静态方法的函子,之前所写的函子都都是实现了of方法的,所以他们都是Pointed 函子。
- of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context(把值放到容器中,使用map来处理值)
class Container {
static of (value) {
// 返回的结果即为上下文 context
return new Container(value)
}
......
}
// 调用of方法时获取到一个context(上下文),可以在context中处理数据
Container.of(2)
.map(x => x + 5)
Monad (单子)
在使用IO 函子的时候,如果我们写出如下代码:
const fs = require('fs')
const fp = require('lodash/fp')
class IO {
static of (value) {
return new IO(value)
}
constructor (fn) {
this._value = fn
}
map (fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
// 读取文件的内容并打印
// 读取文件需要依赖外部资源,会产生副作用,为了避免副作用,封装了一个函数,这个函数返回了一个函子,将读文件的操作封装到函子中,保证当前的操作是纯的
const 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
})
}
const cat = fp.flowRight(print, readFile)
// let r = cat('package.json') // => 推测结果为 IO(IO(x))
// console.log(r);
// // => IO { value: [Function] }
let r = cat('package.json')._value() // IO { _value: [Function] }
._value()
console.log(r);
- Monad 函子是可以变扁的Pointed函子,IO(IO(x)),如果函子出现嵌套关系,调用时很不方便,变扁就是解决函子嵌套的问题
- 函数嵌套可以使用组合的方式,函子嵌套可以使用Monad方法
- 一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad
const fs = require('fs')
const fp = require('lodash/fp')
class IO {
static of (value) {
return new IO(value)
}
constructor (fn) {
this._value = fn
}
// map的作用是把当前这个函数和函子内部的_value合起来,返回一个新的函子
map (fn) {
return new IO(fp.flowRight(fn, this._value))
}
// join方法不需要任何参数,join方法中就是调用._value,因为IO函子在创建的时候需要接收一个函数,当这个函数返回一个函子的时候,就需要把它变成IO monad,就是monad函子
join () {
return this._value()
}
// flatMap的作用就是同时调用map和join
flatMap (fn) {
return this.map(fn).join()
}
}
// 读取文件的内容并打印
const 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
})
}
// 当合并的函数返回的是一个值,就直接调用的map
// 如果返回的是一个函子,就调用flatMap
let r = readFile('package.json')
// .map(x => x.toUpperCase())
.map(fp.toUpper)
.flatMap(print)
.join()
console.log(r);
总结
- 认识函数式编程
- 函数相关复习
- 函数是一等公民
- 高阶函数
- 闭包
- 函数式变成基础
- lodash
- 纯函数
- 柯里化
- 管道
- 函数组合
- 函子
- Functor
- MayBe
- Either
- IO
- Task
- folktale
- Monad