纯函数
1.1什么是纯函数
同时满足以下两个特征的函数,我们就认为是纯函数:
-
1.对于相同的输入,总是会得到相同的输出 -
2.在执行过程中没有语义上可观察的副作用
故纯函数的核心在于 确定性 与 副作用!
纯函数是指在相同输入的情况下总是返回相同输出,并且不产生任何副作用的函数。具体来说,它不会修改传递给它的参数,也不会修改程序的状态或与外部系统交互。这使得纯函数更容易测试、并发执行和推理。—— chatGPT
1.2如何理解副作用
相比于“函数副作用”来说,更为大家所熟知的一个概念或许是“药物副作用”:我们为了治疗A疾病服用某种药物,药物在缓解A疾病的症状之余,可能会导致B疾病。那么“引发B疾病”就是这个药物的副作用。
在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。 ——维基百科
简单地讲:对函数来说,它的正常工作任务就是【计算】,除了计算之外,它不应该搞别的。
如果一个函数除了计算之外,还对它的执行上下文、执行宿主等外部环境造成了一些其它的影响,那么这些影响就是所谓的”副作用”。
看几个例子,更好理解
案例一
let a = 10
let b = 20
function add() {
return a+b
}
案例二
function processName(firstName, secondName) {
const fullName = `${firstName}·${secondName}`
console.log(`I am ${fullName}`)
return fullName
}
processName('鲁班', '七号')
案例三
function getData(url) {
const response = await fetch(url)
const { data } = response
return data
}
只要是跟函数外部环境发生交互的都是副作用
- 更改文件系统
- 往数据库插入记录
- 发送一个http请求
- 可变数据
- 打印/Log
- 获取用户输入
- DOM查询
- 访问系统状态
- 等等
以上三个案例 是否为纯函数?
案例1: 给定相同的输入,并不能保证给出相同的输出,受到了函数外变量a与b的影响
案例2:不纯,函数内部产生了副作用,对控制台输出了一段数据,除了计算外对控制台又有了影响
案例3:不可能纯,既然是网络请求,就有可能会失败,并不能保证同样的输入,给定相同的输出
1.3深入理解
纯函数(Pure Function)——输入输出数据流全是显式(Explicit)的函数。
—— 维基百科
定义中的“显式”这个概念,其实非常好理解,它约束的是数据的来源(入参)和出口(返回值)。
数据以入参形式传入,这叫【显式输入数据流】。
数据以返回值形式输出,这叫【显式输出数据流】
纯函数——输入只能够以参数形式传入,输出只能够以返回值形式传递,除了入参和返回值之外,不以任何其它形式和外界进行数据交换的函数。
1.4不纯的元凶
隐式数据流
案例1: 存在隐式的数据输入
a 和 b 两个变量并没有以入参的形式传入,而是在函数执行过程中直接被从全局作用域“抓进来”参与计算的,这就是典型的隐式数据交换。
案例2:存在隐式的数据输出
会偷偷地向控制台输出一行字符串,这个字符串脱离了显式数据流的流向,进而导致函数不纯
案例3:同样存在隐式的数据输入
1.5函数为何非纯不可
说到函数那肯定想到数学中的函数
数学中的函数总是遵循着这样的原则:同一个输入,同一个输出。
但是在js中不同的,影响 JS 函数输出的因素太多了,函数入参只是众多因素中的一个!
js的函数犹如花花世界,允许函数读取外部变量,允许函数有副作用,这样虽然强化了程序的能力,但是也让程序充满不确定性,不确定性意味着风险,而风险是bug!
纯函数 高度的确定性(同一个输入,同一个输出)
纯函数 不存在副作用(计算完全在内部,不会对外部资源产生任何影响)
纯函数 更加灵活(不依赖上下文,输入全靠入参,它的计算逻辑在任何上下文里都是成立的!如案例1 则依赖上下文提供的a、b)
故纯函数高度确定下,没有副作用,更加的灵活不依赖上下文!选择靠拢纯函数,yyds
不可变数据
2.1 js中的八种数据类型
String
Number
Boolean
null
undefined
Symbol
es6新增的一种基本数据类型,用于创建一个独一无二的值!
背景: 为何需要Symbol
在 ES6 之前,对象的属性名都是字符串,容易造成重名的问题,比如有个从别人那获得的对象 obj ,原本有个属性 name,在使用时没注意,或者不知道该对象已经有了 name 属性,而给 obj 重复定义了 name,原本的 name 的值就会被覆盖。ES6 开始,对象的属性名还可以使用由 Symbol() 函数返回得到的 Symbol 类型的值,它们是独一无二的,从而解决了属性名命名冲突问题。
// 定义
const sym1 = Symbol()
const sym2 = Symbol()
console.log(sym1 === sym2) // false
// 描述
const sym3 = Symbol('我是 sym3')
const sym33 = Symbol('我是 sym3')
console.log(Symbol.keyFor(sym3)) // 我是 sym3
console.log(sym3 === sym33) // false
// 使用
const sym4 = Symbol('name')
const sym5 = Symbol('age')
const obj = {
[sym4]: 'Jay',
[sym5]: 40
}
console.log(obj[sym4]) // 'Jay'
console.log(obj[sym5]) // '40'
BigInit
es2019(es10) 提出的一个新的基本数据类型,可以用来操作超出 Number 最大安全范围的整数! 在javaScript中,number可以准确表达的最大数字是2^53,没有比这更大的数字 BigInt类型出现就可以解决这一问题,比2^53大的数可以使用BigInt类型表示
Object
其中 前七种为简单的 值类型/基本类型
Object为复杂的 引用类型/复杂类型, 其中 Array 以及 Function 为特殊的Object类型
值类型特点: 体积轻量,大小固定,相对简单
引用类型特点:复杂,重用空间大,体积不固定
2.2 值类型
值类型的数据无法被修改,当我们修改值类型变量的时候,本质上会创建一个新的值
let a = 1
let b = a
a === b // true
b = 2
a === b // false
定义 变量a 会在内存中开辟一个坑位, 并把 值1 放进去
将a赋值给b,会在内存中开辟新的坑位非b,并把a的 值1 copy一份 放进去
此时 a和b 虽然值相同 但是开辟的内存并不是同一个!
那为何 此时 a被b 相等呢?
对于值类型是否相等,不判断所在的内存是否相等(因为值类型的内存必然不相等),只需要判断引用的值是否相等
当我们修改b的时候,实际上是解除了 变量b与值1的关联,并建立了与值2的关联!
在这个过程中,你可以把1修改为2么,实际上是不可以的,1就是1, 2就是2,我们只是调整了数字 1、2 和变量 b 之间的映射关系! 而非修改数字本身!
也就是说1、1、2 这三个数字从创建开始就不会再发生任何改变
像数字类型这样,自创建起就无法再被修改的数据,我们称其为“不可变数据”。
let a = 1
b = a++
// a, b的值为多少, 这个过程怎么理解
let a = 1
a = a++
// a的值为多少, 这个过程怎么理解
案例1: a = 2 b =1 b = a++ 的时候先赋值, 会把a的值1 copy一份赋值给b, 然后对a 进行加加
案例1: a = 1 a = a++ 的时候先赋值, 会把a的值1 copy一份赋值给a, 然后对a的原始数据 进行加加
2.3 引用类型
引用类型是否相同需要考虑 1.引用的值是否相等 2.所处的内存是否相等
const a = { name: 'xiuyan', age: 30 }
const b = a
a === b // true
b.name = 'youhu'
a === b // true
对于引用类型来说,当我们把 a 对象赋值给 b 时,并不会发生“开辟一个新的 b 对象坑位、放入一份 a 对象的副本”这种事——JS 会直接把 a 的引用赋值给 b! 也就是此时 变量a和变量b指向同一个内存
故: 我们对变量b修改 即使修改了内存中的值! 被修改后,a、b 两个引用的指向没有发生任何变化——坑还是那个坑,但是坑里存的对象的内容却不一样了
对于引用类型来说,我们总是可以像楼上这样,在数据被创建后,随时修改数据的内容。
像这种创建后仍然可以被修改的数据,我们称其为“可变数据”。
2.4 为何函数变成 不喜欢可变数据 案例分析
案例: 假设我们要招聘,leval高的出费用给猎头招聘,否则公司内部处理
// 定义 基础信息
const JOB_INFO = {level: 0, city:'New York'};
// 修改leval的函数
const changeJobLevel = (jobInfo, level,) => {
const newLevelJob = jobInfo;
newLevelJob.level = level;
return newLevelJob
};
const JOB_INFO1 = changeJobLevel(JOB_INFO, 5);
const JOB_INFO2 = changeJobLevel(JOB_INFO, 10);
// 组装两条数据为一个发布数组
const releaseList = [JOB_INFO_001, JOB_INFO_002]
// 发布两条数据
releaseJobs(releaseList,)
发布数据,我们判断, 如果这个人是level为高级大于9,则又猎头招聘,否则公司内处理
此时 因为JOB_INFO, jobInfo, JOB_INFO1, JOB_INFO2 其实都指向同一个坑的数据, 相互影响,最终JOB_INFO2 末次修改,将该坑里的level都修改为了10, 费用翻倍
2.5 控制变化---copy
不可变数据の实践原则:拷贝,而不是修改
拷贝的目的:确保外部数据的只读性
对于函数式编程来说,函数的外部数据是只读的,函数的内部数据则是可写的。
我们有什么办法来确保引用类型数据的不可变性呢?
答案也很简单,大家只需要记住一个原则:不要修改,要拷贝。
说起copy 方案千千万,深拷贝呀,浅拷贝
const changeJobLevel = (jobInfo, level,) => {
// 扩展运算符
const newLevelJob = {...jobInfo};
// JSON.parse JSON.stringify
const newLevelJob = JSON.parse(JSON.stringify(jobInfo));
newLevelJob.level = level;
return newLevelJob
};
浅拷贝
Object.assign
const obj = { name: 'lin' }
const newObj = Object.assign({}, obj)
obj.name = 'xxx' // 改变原来的对象
console.log(newObj) // { name: 'lin' } 新对象不变
console.log(obj == newObj) // false 两者指向不同地址
扩展运算符
const arr = ['lin', 'is', 'handsome']
const newArr = [...arr]
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变
console.log(arr == newArr) // false 两者指向不同地址, 值也不同
数组的 slice 和 concat 方法,数组静态方法 Array.from 等等也都是浅拷贝
深拷贝
常见的一行代码深拷贝
JSON.parse(JSON.stringify(obj))
一行代码版本适用于日常开发中深拷贝一些简单的对象! 对于较为复杂的对象则问题频发
undefined、symbol和函数会被忽略
Date会从标准时间,转为字符串
正则会转换为空对象
dom节点会转换成空对象
NaN会被转为null
const myobj = {
a: true,
b: 100,
c: 'str',
d: undefined,
e: null,
f: Symbol('f'),
g: {
g1: {}, // 深层对象
},
h: [], // 数组
i: new Date(), // Date
j: /abc/, // 正则
k: function () {}, // 函数
l: [document.getElementById('foo')],
m: NaN
};
const sym = Symbol('哈喽');
myobj[sym] = '哈喽';
const newObj = JSON.parse(JSON.stringify(myobj))
console.log(newObj)
实现一个简单版本的深拷贝
const deepClone = (obj) => {
// 原始类型则直接返回
if (typeof obj !== 'object' || obj === null) {
return obj
}
let cloneObj = Array.isArray(obj) ? [] : {};
// Reflect.ownKeys方法返回对象的所有属性 Reflect.ownKeys方法不仅返回对象的自有属性,还返回继承属性
// 此外 也用来处理 Symbol 类型作为 key的情况
// 使用 常规的 for in 则会忽视 Symbol作为 key的情况
Reflect.ownKeys(obj).forEach(key => {
// 来判断一个属性是否为对象自身的属性,避免了继承属性被误判为对象自身的属性的情况
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deep(obj[key])
}
})
return cloneObj
}
此版本能满足日常使用, 但是对正则,Date,dom节点三者处理仍有问题,当作对象处理
实现一个相对完美的深拷贝
const deepClone = (obj) => {
if (obj === null) return obj // 如果是 null 就不进行拷贝操作
if (obj instanceof Date) return new Date(obj) // 处理日期
if (obj instanceof RegExp) return new RegExp(obj) // 处理正则
if (obj instanceof HTMLElement) return obj // 处理 DOM元素
if (typeof obj !== 'object') return obj // 处理原始类型和函数 不需要深拷贝,直接返回
let cloneObj = Array.isArray(obj) ? [] : {}
Reflect.ownKeys(obj).forEach(key => {
// 来判断一个属性是否为对象自身的属性,避免了继承属性被误判为对象自身的属性的情况
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deep(obj[key])
}
})
return cloneObj
}
用拷贝代替修改,是确保引用类型数据不可变性的一剂良药! 然而,拷贝并非一个万能的解法。拷贝意味着重复,而重复往往伴随着冗余。
比如:某一个函数的入参是一个极为庞大的对象,假设里面有10000个字段,一般我们也著需要修改其中的几个字段! 但是我们却把整个对象都copy了下来,冗余率接近100%,且开辟了新的内存空间存放这些冗余数据
故对于轻量,简单的数据,拷贝确实是一剂维持数据不可变性的良药
对于数据量复杂且巨大的数据,拷贝则是性能灾难
试想 如果我们可以仅对需要进行修改的数据进行拷贝,其他数据不变,那岂不是美滋滋了
2.6 深入控制变化---数据持久化
Immer.js,一个傻瓜式的 Immutability 解决方案
import produce from "immer"
// 这是我的源数据
const baseState = [ { name: "小明", age: 99 }, { name: "小红", age: 100 } ]
// 定义数据的写逻辑
const recipe = draft => {
draft.push({
name: "小黑",
age: 101
})
draft[1].age = 102
}
// 借助 produce,执行数据的写逻辑
const nextState = produce(baseState, recipe)
手动实现
Proxy对象用于创建一个对象的代理,是用于监听一个对象的相关操作。代理对象可以监听我们对原对象的操作。
Proxy对象需要传入两个参数,分别是需要被Proxy代理的对象和一系列的捕获器
let a = {
a1: 1,
a2: {
name: a2
}
}
let b = new Proxy(a, {})
console.log(a === b) // ?
console.log(a.a2 === b.a2) // ?
// 这是我的源对象
const baseObj = {
a: 1,
b: {
name: "鲁班七号"
}
}
function produce(base, recipe) {
// 预定义一个 copy 副本
let copy
// 定义 base 对象的 proxy handler
const baseHandler = {
set(obj, key, value) {
// 先检查 copy 是否存在,如果不存在,创建 copy
if (!copy) {
copy = { ...base }
}
// 如果 copy 存在,修改 copy,而不是 base
copy[key] = value
}
}
// 被 proxy 包装后的 base 记为 draft
const draft = new Proxy(base, baseHandler)
// 将 draft 作为入参传入 recipe! 触发点,此操作修改代理数据draft 会触发 set
recipe(draft)
// 返回一个被“冻结”的 copy,如果 copy 不存在,表示没有执行写操作,返回 base 即可
// “冻结”是为了避免意外的修改发生,进一步保证数据的纯度
return Object.freeze(copy || base)
}
// 这是一个执行写操作的 recipe
const changeA = (draft) => { draft.a = 2 }
// 这是一个不执行写操作、只执行读操作的 recipe
const doNothing = (draft) => {
console.log("doNothing function is called, and draft is", draft)
}
// 借助 produce,对源对象应用写操作,修改源对象里的 a 属性
const changedObjA = produce(baseObj, changeA)
// 借助 produce,对源对象应用读操作
const doNothingObj = produce(baseObj, doNothing)
// 顺序输出3个对象,确认写操作确实生效了
console.log(baseObj)
console.log(changedObjA)
console.log(doNothingObj)
// 【源对象】 和 【借助 produce 对源对象执行过读操作后的对象】 还是同一个对象吗?
// 答案为 true
console.log(baseObj === doNothingObj)
// 【源对象】 和 【借助 produce 对源对象执行过写操作后的对象】 还是同一个对象吗?
// 答案为 false
console.log(baseObj === changedObjA)
// 源对象里没有被执行写操作的 b 属性,在 produce 执行前后是否会发生变化?
// 输出为 true,说明不会发生变化
console.log(baseObj.b === changedObjA.b)
只要写操作执行,拷贝动作才会发生。
本文借鉴与 修言大大 的函数式编程