纯函数

462 阅读14分钟

纯函数

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,我们只是调整了数字 12 和变量 b 之间的映射关系! 而非修改数字本身!

也就是说1、1、2 这三个数字从创建开始就不会再发生任何改变

像数字类型这样,自创建起就无法再被修改的数据,我们称其为“不可变数据”。

image.png

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 两个引用的指向没有发生任何变化——坑还是那个坑,但是坑里存的对象的内容却不一样了

对于引用类型来说,我们总是可以像楼上这样,在数据被创建后,随时修改数据的内容。
像这种创建后仍然可以被修改的数据,我们称其为“可变数据”。

image.png

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))

一行代码版本适用于日常开发中深拷贝一些简单的对象! 对于较为复杂的对象则问题频发

undefinedsymbol函数会被忽略

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
}

此版本能满足日常使用, 但是对正则Datedom节点三者处理仍有问题,当作对象处理

实现一个相对完美的深拷贝
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)

只要写操作执行,拷贝动作才会发生

本文借鉴与 修言大大 的函数式编程