前言
当你点开这篇文章,说明你想要了解,或者你已经了解了什么是函数式编程。那你又是通过什么地方知道的函数式编程呢?也许是在课堂上听过这一编程范式?又或者是通过React了解到的这一块?
大家的理由可能各种各样,但是不可否认的是函数式对于今天前端的影响已经越来越深了。既然大家来到这篇文章,那我们就一起进入函数式的世界吧。
作者水平有限,文中疏漏在所难免,欢迎大家指正,也希望大家可以发表自己的想法和意见。
命令式 VS 声明式
假设有这么一个需求,查找出地图中所有的动物,按照编号写在图鉴上。
命令式代码如下
js
// 地图数据
const map = [
{
type: 'animal',
name: 'duck',
index: 1
},
{
type: 'rock'
},
{
type: 'animal',
name: 'monkey',
index: 4
},
{
type: 'rock'
},
{
type: 'tree'
},
{
type: 'animal',
name: 'fish',
index: 2
},
{
type: 'animal',
name: 'bird',
index: 3
}
]
const illustrated = []
// 筛选出类型是动物的选项
const animals = []
for (let key = 0; key < map.length; key++) {
if (map[key].type === 'animal') {
animals.push(map[key])
}
}
// 给动物按照编号升序排序
for (let i = 0; i < animals.length - 1; i++) {
for (let j = 0; j < animals.length - 1 - i; j++) {
if (animals[j].index > animals[j + 1].index) {
[animals[j], animals[j + 1]] = [animals[j + 1], animals[j]]
}
}
}
// 把动物映射到图鉴上
for (let animal = 0; animal < animals.length; animal++) {
illustrated.push(`动物:${animals[animal].name} 编号:${animals[animal].index}`)
}
而声明式代码如下
js
const filterAnimals = (map) => {
// 过滤出类型是动物的选项
return map.filter(option => option['type'] === 'animal')
}
const sortAnimals = (illustrated) => {
// 给动物按照编号升序排序
return illustrated.sort((a, b) => a.index - b.index)
}
const mapToIllustrated = (animals) => {
// 把动物映射到图鉴上
return animals.map(animal => `动物:${animal.name} 编号:${animal.index}`)
}
// 组合函数,后面我们会一起讨论
const pipe = (...args) => args.reduce((g, f) => (...rest) => f(g(...rest)))
const illustrated = pipe(
filterAnimals,
sortAnimals,
mapToIllustrated
)
illustrated(map)
你会发现声明式的代码的简洁,可读性明显要高于命令式,声明式我们最后只关注pipe里那三个函数,只要你的函数名取得有语义化,那函数结果将一目了然,如果程序出现错误,声明式我们可以直接挨个定位到相关函数排查,而命令式我们将难以快速定位到错误。
那声明式和函数式有什么关系吗?函数式是声明式的子集,命令式是关注过程的,而声明式是关注结果的,函数式也是一样关注结果的,你只需要告诉我怎么做,而不用教我怎么做。
命令式就是关注过程,声明式就是关注结果。
函数式的三要素
想要了解函数式,那我们就不得不了解函数式的三要素纯函数 、第一公民 、不可变值。
纯函数
什么是纯函数?
纯函数是指给定相同的参数一定返回相同的结果。
js
let a = 1
let b = 2
const add1 = () => a + b
add1()
const add2 = (a, b) => a + b
add2(1, 2)
上面两个add函数的结果都是3,不过add1不是纯函数,add2是纯函数。add1函数是没有参数,内部依赖了外部的变量,外部的a和b有可能变成任何值。而add2函数接收两个参数a和b,调用的时候传入相同的参数一定会返回相同的值。
js
const list = [1, 2, 3, 4, 5]
list.splice(1, 1)
// toSpliced()是在node v20.10.0,浏览器兼容性可以查看MDN
list.toSpliced(1, 1)
splice这个函数修改了原数组,除了执行函数本身的功能外,还对外部状态造成了影响,这就是函数的副作用,而toSpliced就是一个纯函数,它会返回一个新数组,而不会去修改原数组,传入相同的参数一定返回相同的结果。
从上面两个例子,我们能够看出来纯函数就是,除了入参和返回值,函数不会再依赖外部状态,也不会影响外部状态。由此,相同的输入一定是返回相同的输出。
怎么写纯函数?
根据上面的结论我们已经知道什么是纯函数,什么是副作用,我们看看下面的几段程序。
js
const resize = (w, h) => {
const box = document.querySelector('.box')
box.style.width = w
box.style.height = h
}
const getData = async (url) => {
try {
const { data } = await fetch(url)
return data
} catch (error) {
console.error('请求错误', error)
}
}
const fs = require('fs')
const readFile = (url) => {
try {
const data = fs.readFileSync(url, 'utf-8')
return data
} catch (error) {
console.error('读取错误', error)
}
}
上面的三段程序都不是纯函数,他们都对程序外部产生了影响,DOM操作、网络请求、文件读取。
写纯函数有3个要求
- 数据引用透明 函数的参数应该是明确安全的,像外部的网络请求和文件可能会被篡改
- 功能职责单一 一个函数只做它自身应该做的事
- 除了入参和返回值,不应该对外部产生影响
为什么要是纯函数?
我们发现,如果要写纯函数,不能有副作用,那我们的程序可能什么都干不了,既然纯函数这么多约束,为什么函数式一定要是纯函数呢?
纯函数有下面两大好处
- 函数引用透明,函数的返回值依赖函数的入参,而不是外部,保证程序的安全
- 函数职责单一,结构清晰,方便维护
- 易于测试,因为相同的结果一定返回相同结果
js
const toSum = (a, b) => a + b
test('1+2等于3', () => {
expect(toSum(1, 2)).toBe(3)
})
但是为了这些好处,就不能写副作用了吗?答案是否定的,程序一定会有副作用,后面我们会学习怎么把副作用降低到最小。
没有副作用的函数就是纯函数,副作用是指函数对外部世界的影响,由此,纯函数是指函数只完成自身的功能,不会对外部程序,也不会被外部程序所影响返回的结果,相同的输入一定返回相同的输出。
一等公民
函数在JS中是一等公民,是指函数可以被当作一个变量,被赋值,被传入函数参数,被函数返回到外部。
js
// 函数赋值给变量
const add = (a, b) => a + b
const copyAdd = add
// 函数被当作参数传入另一个函数内部
const map = (arr, cb) => arr.map((...args) => cb(...args))
// 函数被一个函数当作函数值返回
const memoize = (fn) => {
const cache = {}
return (...args) => {
const key = JSON.stringify(args)
!cache[key] && (cache[key] = fn(...args))
return cache[key]
}
}
接受一个函数作为参数,或者返回一个函数的函数也被叫做高阶函数,JS中的高阶函数有map、filter、sort、forEach、reduce...
那函数作为一等公民的好处是什么呢?好处就是,函数有最大的自由度,这也是函数式的基础,函数可以被存入数组和对象。
在一个语言中,函数可以被当作一个变量,被赋值,被传入函数参数,被函数返回到外部 ,就可以说函数在这门语言中是一等公民。接受一个函数作为参数,或者返回一个函数的函数也被叫做高阶函数。
不可变值
函数式对原数据有着非常严格的要求,不允许函数对原数据有修改。因为原数据可变,对于结果我们是难以预测的。
对于不可变值,可以使用const,但是const只能保证原始值不会被修改,对于数组和对象,const也无能为力。或者使用Object.freeze()冻结对象。
js
const obj = {
name: 'zhang san',
age: 18
}
Object.freeze(obj)
const arr = [1, 2, 3]
Object.freeze(arr)
对于原数据,在函数内部不会直接操作它,而是复制一个副本,来操作副本,对于复制一个对象,可以使用深度拷贝。
js
const deepClone = (obj) => {
if (Object.prototype.toString.call(obj) !== '[object Object]') {
return obj
}
const newObj = {}
for (const key in obj) {
newObj[key] = deepClone(obj[key])
}
return newObj
}
const obj = {
val: 1,
left: {
val: 2,
left: {
val: 4
}
},
right: {
val: 3
}
}
console.log(obj === deepClone(obj)) // false
但是又出现了一个问题,函数内部操作的数据也许只有几个,但是我们却复制了全部的数据,这消耗了大量的内存。业界对此的解决方案是immutable.js,immutable修改数据会创建一块新的内存地址,把修改的数据指向新地址,而其他没有修改的数据,则指向原数据的地址。
js
const { Map } = require('immutable')
const person1 = Map({
name: '张三',
age: 18
})
const person2 = person1.set('age', 19)
for(const [key, value] of person1.entries()) {
console.log(key, value) // name 张三 age 18
}
for(const [key, value] of person2.entries()) {
console.log(key, value) // name 张三 age 19
}
console.log(person1 === person2) // false
至此,我们已经了解了函数式的三要素。不过还是不能够写函数式程序,函数式的精髓是组合,在此之前,我们先来看看柯里化这一概念。
柯里化
什么是柯里化?
在讨论柯里化之前,我们先来看一看函数的元
js
const add = (a, b, c) => a + b + c
这个add函数就是一个三元函数,它的元分别是a, b, c,那和柯里化有什么关系吗?柯里化就是把一个多元函数变成一个单元函数,一次只接收一个参数,上面的三元add函数变成单元add函数就是:
js
const add = a => b => c => a + b + c
console.log(add(1)(2)(3))
设计一个可以把一个n元函数转成n个一元函数的柯里化函数:
js
const curry = (fn) => {
const args_L = fn.length
const curried = (...args) => {
return args.length < args_L
? (...rest) => curried(...args, ...rest)
: fn(...args)
}
return curried
}
const add = (a, b, c) => a + b + c
const add1 = curry(add)
console.log(add1(1, 2, 3)) // 6
console.log(add1(1)(2, 3)) // 6
console.log(add1(1, 2)(3)) // 6
console.log(add1(1)(2)(3)) // 6
为什么要柯里化?
可是这么写函数的好处是什么呢?柯里化的好处是
- 不完全调用函数,可以让函数惰性求值
- 减少函数的参数,让关注集中到函数本身上面
- 为了满足函数式的精髓,组合。
组合
函数式的本质是函数,函数式的精髓是组合,那么函数式就是把一个个的函数组合起来的过程。
我们可以把每个函数都当作是一个黑盒,我们不必关注这个黑盒内部是怎么实现的,我们只需要关注数据是怎么在这一个个的黑盒里面流动的,那我们的程序就是靠这一个个的黑盒搭建而来。
而要把我们的函数组合在一起,就需要我们组合函数compose和pipe,compose是从右到左的执行函数,而pipe是从左到右的执行函数。
js
const compose = (...args) => {
return args.reduceRight((g, f) => {
return (...rest) => {
return f(g(...rest))
}
})
}
const pipe = (...args) => {
return args.reduce((g, f) => {
return (...rest) => {
return f(g(...rest))
}
})
}
reduce累加器,就像一个加工厂,把你一个个函数依次拿进来执行,并把结果给下一个函数。
Point-Free
Point-Free是函数式的一种代码风格,是指没有可见参数的一种代码风格,这样做的好处是,我们可以把关注集中在函数本身,这和柯里化的好处是一样的,就像最开始介绍声明式那个时候的代码,pipe里面的函数都没有参数,只在它返回的函数调用时传入了一个参数。
我想你已经想到了怎么做了,就是结合我们的柯里化和组合,把个个函数减少到最小元(函数本身的代码规则,参数也不应该太多),最把这些函数组合成一个新函数,如果你愿意,你也可以把这个组合的函数再和其他函数再组合,你发现了吗?这个一个个的函数的组合,像不像我们搭建房子,一块一块的砌砖,是的,我们就是把一个个职责单一,最小副作用和最小元的纯函数组合起来,听起来是不是感觉很酷。
总结
函数式编程的优点
- 函数解耦度高
- 易测试
- 方便维护
函数式编程的缺点
- 由于不可变值,代码会存在很多副本,导致内存消耗变大
- 学习难度比面向对象要大,因为现在主流的还是面向对象
第二篇我们会讲一些函子和如何处理副作用