函数式编程是一种编程范式。相对命令式编程的过程思维,函数式编程更侧重于结果思维。对于每个函数调用,不必关注内部的执行细节,而只需要关注函数的输入与输出。
纯函数与副作用
实践纯函数的目的不是消灭副作用,而是将逻辑与副作用做合理的分层解耦,从而提升编码质量与执行效率。
纯函数
纯函数是输入输出数据流全是显式的函数。
数据以入参的形式传入,即为显式输入数据流;数据以返回值的形式输出,即为显式输出数据流。
显式数据流意味着函数除了入参和返回值之外,不以任何其他形式与外界进行数据交换。
因此,当函数的入参相同时,得到的结果总是一致的。
纯函数的实践,实际上是将程序的外部影响和内部计算进行了解耦,间接促成了程序逻辑的分层,使得模块功能更加内聚。
副作用
副作用是指,如果函数除了计算之外,还对其执行上下文、执行宿主等外部环境造成了一些其他影响,则这些影响就是副作用。
纯函数具有显式的输入输出流,而导致函数具有副作用的则是隐式数据流。以下是两个隐式数据流的 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);
参考