函数式编程是一种编程范式。相对命令式编程的过程思维,函数式编程更侧重于结果思维。对于每个函数调用,不必关注内部的执行细节,而只需要关注函数的输入与输出。
纯函数与副作用
实践纯函数的目的不是消灭副作用,而是将逻辑与副作用做合理的分层解耦,从而提升编码质量与执行效率。
纯函数
纯函数是输入输出数据流全是显式的函数。
数据以入参的形式传入,即为显式输入数据流;数据以返回值的形式输出,即为显式输出数据流。
显式数据流意味着函数除了入参和返回值之外,不以任何其他形式与外界进行数据交换
。
因此,当函数的入参相同时,得到的结果总是一致的。
纯函数的实践,实际上是将程序的外部影响
和内部计算
进行了解耦,间接促成了程序逻辑的分层,使得模块功能更加内聚。
副作用
副作用是指,如果函数除了计算之外,还对其执行上下文、执行宿主等外部环境造成了一些其他影响,则这些影响就是副作用。
纯函数具有显式的输入输出流,而导致函数具有副作用的则是隐式数据流。以下是两个隐式数据流的 case:
- 隐式数据输入
a
和b
两个变量并没有以入参的形式传入add()
,而是被定义在全局作用域下。因此,当a
和b
发生变化时,即使add()
的入参没有变化(为空),输出结果也会被改变。
const a = (b = 1);
const add = () => a + b;
- 隐式数据输出
在add
函数中,除了完成了计算的工作外,还进行了 console 输出,这种和函数本身执行目的无关的行为属于隐式数据输出。
const add = (a, b) => {
console.log("add");
return a + b;
};
函数是一等公民
当一门编程语言的函数可以被当作变量一样使用时,则称该门语言拥有头等函数
(被当作一等公民对待的函数)。
能被当作变量使用,意味着:
- 可以被当作参数传递给其他函数(回调函数)
- 可以作为另一个函数的返回值(闭包)
- 可以被赋值给一个变量
JS 中,函数可以被当作变量使用,本质上是因为它是一个可执行的对象。
数据可变性
值类型数据均为不可变数据。包括 6 个基本数据类型(String
、Number
、Boolean
、null
、undefined
、Symbol
)
而对于引用类型,创建后仍可以修改数据,为可变数据。
可变数据对于函数式编程而言是危险的,因为函数可能会改变入参的可变数据,数据的变化变得隐蔽,从而使函数行为变得难以预测。
此外,可变数据的存在,使函数复用成本变高。它要求我们需要先了解函数的逻辑细节,定位它对外部数据的依赖和影响,确保调用动作的安全性。但实际上我们在使用函数时是默认该黑盒是可靠的,并不会关注实现细节,所以我们有必要确保该黑盒是可靠的、受控的。
所以,如果想写出好的函数式代码,需要确保数据的不可变性。这要求我们将变化控制在函数的内部,确保所有变化都在可预期的范围内发生,而不去改变函数外的任何东西。
拷贝,而非修改
值类型数据天然存在不可变性,此处讨论 JS 的数据不可变性,也就是讨论如何保证引用类型
数据的不可变性。
JS 的关键字const
用于定义一个不能被 reassign 的变量,但由于修改引用类型变量时,并没有将新的对象 reassign 给引用类型变量,而是用.运算符来访问并修改既有对象的其中一个属性,所以不能保证引用数据类型的不变性。
const people = {
name: "xxx",
age: 18,
};
people.age = 19; // 并不会报错
虽然const
无法保证引用类型的不可变性,但是我们可以通过拷贝该引用类型、修改其拷贝的副本,达到不影响入参的引用类型变量的目的。
而在 JS 中实现引用类型数据的拷贝,一般有如下手段:
- 拓展运算符
- Object.assign
- slice / concat / map (针对数组)
- lodash 的 deepClone
- …
持久化数据结构
若数据规模庞大、数据变化频繁,拷贝行为将对性能造成较大影响。而持久化数据结构避开了拷贝的弊端。
git
在创建commit
时,会对整个项目的所有文件做一个快照
。此处快照并非为当前文件创建一个完整的拷贝,而是记录当前文件的索引。
当commit
发生时,git
会保存当前版本所有文件的索引。对于未发生变化的文件,git
将沿用其原有的索引;对于已经发生变化的文件,git
会记录变化后的文件索引。
而持久化数据结构的核心思想和快照是一致的:
- 快照保存文件索引,而不保存文件本身
- 变化的文件将拥有新的存储空间和新的索引,不变的文件将永远呆在原地。
持久化数据结构的精髓在于数据共享
,意味着将变与不变
分离,确保只有变化的部分被处理,而不变的部分被复用。
Immutable.js
被开发团队 FB 定位为实现持久化数据结构的库
。在Immutable.js
的世界里,这种变与不变
可以细化到数组的某一元素、对象的某一字段。
假如现在借助Immutable.js
,在data
对象的基础上修改了age
创建出了cloneData
,则Immutable.js
仅会创建变化的那部分,并为cloneData
对象生成一套指回data
的指针,从而复用data
对象中不变的剩余字段。
为了达到这种数据共享
的效果,持久化数据结构在底层依赖了字典树。
当创建cloneData
时,可以只针对发生变化的字段创建一条新的数据,再将cloneData
剩余字段的指针指回data
。如此,cloneData
已经区分于data
成为一个新的对象、具备一个新的索引。但是相对于直接拷贝源数据,cloneData
通过和data
共享不变的那部分数据,成功提升了管理数据的效率。
Proxy 代理模式
Immutability
的解决方案并非只有持久化数据结构,譬如Immer.js
对Immutability
的实践便是使用了 Proxy,对目标对象的行为进行元编程
,实现了数据的不可变性。
Proxy
对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义。如属性查找、复制、枚举、函数调用等。其使用范例如下:
// 定义一个 programmer 对象
const programmer = {
name: "xiuyan",
age: 30,
};
// 定义这个对象的拦截逻辑
const proxyHandler = {
// obj 是目标对象, key 是被访问的键名
get(obj, key) {
if (key === "age") return 100;
return obj[key];
},
};
// 借助 Proxy,将这个对象使用拦截逻辑包起来
const wrappedProgrammer = new Proxy(programmer, proxyHandler);
// 'xiuyan'
console.log(wrappedProgrammer.name);
// 100
console.log(wrappedProgrammer.age);
而使用Immer.js
,只需要 import 一个名为produce
的 api。它的工作原理是将拷贝操作精准化,严格控制了拷贝的时机:produce
借助Proxy
,将拷贝动作发生的时机和setter
函数的触发时机牢牢绑定。仅当写操作发生时,拷贝动作才会进行,setter
方法就会被逐层触发,呈现逐层浅拷贝
的效果,间接地实现了数据在新老对象间的共享。
produce
不仅会拦截setter
,也会拦截getter
。通过对getter
的拦截,produce
可以按需地对被访问到的属性进行懒代理
:访问得有多深,代理逻辑就能走多深;而所有被代理的属性,都会具备新的setter
方法。
对于 Immutable.js
来说,它通过构建一套原生 JS 无法支持的 Trie 数据结构,最终实现了树节点的按需创建。
对于 Immer.js
来说,它借助 Proxy
的 getter
函数实现了按需代理,借助 Proxy
的 setter
函数实现了对象属性的按需拷贝。
Immer.js 的简易实现
Immer.js
的核心 api——produce
的一个简易版的复现 demo 如下(完整版Immer.js
的浅拷贝是可递归的):
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;
return true;
},
};
// 被 proxy 包装后的 base 记为 draft
const draft = new Proxy(base, baseHandler);
// 将 draft 作为入参传入 recipe
recipe(draft);
// 返回一个被“冻结”的 copy,如果 copy 不存在,表示没有执行写操作,返回 base 即可
// “冻结”是为了避免意外的修改发生,进一步保证数据的纯度
return Object.freeze(copy || base);
}
// 调用
// 这是我的源对象
const baseObj = {
a: 1,
b: {
name: "修言",
},
};
// 这是一个执行写操作的 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);
参考