之前看了修言的函数式小册,有些收益。顺便做了波笔记,高度浓缩,很干,把所有人物举例和我觉得没有必要的话全删除了。有些地方也做了些修改。大家如果对小册感兴趣,也可以支持。 我将分为上下两篇发出。 JS的三种编程范式
- 命令式编程
- 面向对象编程
- 函数式编程
对于编程范式来说,我们可以认为"特征即定义" 。就 JS 函数式编程而言,有以下三个特征:
- 拥抱纯函数,隔离副作用
- 函数是"一等公民"
- 避免对状态的改变(不可变值)
举个例子:
现在我们对年龄大于等于 24 岁的员工按照年龄升序排列并保存:
使用命令式如下:
javascript
// 这里我mock了一组员工信息作为原始数据,实际处理的数据信息量应该比这个大很多
const peopleList = [
{
name: 'John Lee',
age: 24,
career: 'engineer'
},
{
name: 'Bob Chen',
age: 22,
career: 'engineer'
},
{
name: 'Lucy Liu',
age: 28,
career: 'PM'
},
{
name: 'Jack Zhang',
age: 26,
career: 'PM'
},
{
name: 'Yan Xiu',
age: 30,
career: 'engineer'
}
]
const len = peopleList.length
// 对员工列表按照年龄【排序】
for (let i = 0; i < len; i++) {
// 内层循环用于完成每一轮遍历过程中的重复比较+交换
for (let j = 0; j < len - 1; j++) {
// 若相邻元素前面的数比后面的大
if (peopleList[j].age > peopleList[j + 1].age) {
// 交换两者
;[peopleList[j], peopleList[j + 1]] = [peopleList[j + 1], peopleList[j]]
}
}
}
let logText = ''
for (let i = 0; i < len; i++) {
const person = peopleList[i]
// 【筛选】出年龄符合条件的
if (person.age >= 24) {
// 从数组中【提取】目标信息到 logText
const perLogText = `${person.name}'s age is ${person.age}`
if (i !== len - 1) {
logText += `${perLogText},`
} else {
logText += perLogText
}
}
}
console.log(logText)
上面代码,我们逐个实现了排序、筛选、信息提取这三大块逻辑。
函数式写法:
javascript
// 定义筛选逻辑
const ageBiggerThan24 = person => person.age >= 24
// 定义排序逻辑
const smallAgeFirst = (a, b) => {
return a.age - b.age
}
// 定义信息提取逻辑
const generateLogText = person => {
const perLogText = `${person.name}'s age is ${person.age}`
return perLogText
}
const logText = peopleList
.filter(ageBiggerThan24)
.sort(smallAgeFirst)
.map(generateLogText)
.join(',')
console.log(logText)
可以看到,代码明显更简洁和可读。并且方法是高度可复用的。
每一个函数都代表一次逻辑变换,我们不必关心其内部实现,只需要关心输入输出,组合好这些函数即可。
什么是纯函数?
同时满足以下两个特征的函数,我们就认为是纯函数:
- 相同的输入,总会得到相同的输出
- 不会对外部环境产生任何影响,包括不会修改传入的参数,也不会修改全局变量或引起副作用。
什么是副作用?
函数副作用是指函数在执行过程中,对外部环境产生了影响,包括但不限于以下几种情况:
- 修改了传入的参数。
- 修改了全局变量。
- 发送了网络请求或修改了本地存储等操作。
- 控制台输出等操作。
纯函数纯在【显式数据流 】,这意味着函数除了入参和返回值之外,不以任何其它形式与外界进行数据交换。
"不纯"的元凶------隐式数据流
javascript
let a = 10
let b = 20
function add(a, b) {
return a+b
}
// 30
add(a, b)
a = 30
b = 40
// 70
add(a, b)
图中的横向数据流,表示函数自身显式数据流动,方向是从入参到出参。
纵向数据流则是隐式数据流,代表函数和外界的数据交换。
一个纯函数在执行过程中应该只有横向数据流,而不应该有纵向数据流。
数学世界的函数 VS 程序世界的函数
数学中的函数总是遵循着这样的原则:同一个输入,同一个输出。
对于一个给定的自变量 x,总是会有且仅有一个因变量 y 与它对应。
而计算机科学中的函数,灵活很多,比如一个获取时间的函数:
javascript
function getToday() {
return (new Date()).getDate()
}
今天是 22 号,我调用 getToday(),它自然会返回 22 给我。但是明天调用或者不同时区调用结果都会不一样。
JS 允许函数读写外部变量,允许函数引入副作用。这强化了程序的能力,却弱化了程序的数学性。
数学化的 JS 函数 === 纯函数
函数除了接受入参进行纯纯的计算,然后返回值,啥也不干 。解决了"不确定性 "和"副作用 "。
当然副作用不是毒药,我们只需要合理使用副作用即可。
一等函数的核心特征是"可以被当做变量一样用 "。
"可以被当做变量一样用"意味着什么?它意味着:
- 可以被当作参数传递给其他函数
- 可以作为另一个函数的返回值
- 可以被赋值给一个变量
"一等公民"的 JS 函数
我们来看看"一等公民"如何体现在函数上:
JS 函数可以被赋值给一个变量
javascript
// 将一个匿名函数赋值给变量 callMe
let callMe = () => {
console.log('Hello World!')
}
// 输出 callMe 的内容
console.log(callMe)
// 调用 callMe
callMe()
// 将一个新的匿名函数赋值给变量 callMe
callMe = () => {
console.log('Hello Yun~')
}
// 输出 callMe 的内容
console.log(callMe)
// 调用 callMe
callMe()
JS 函数可以作为参数传递
回调函数是 JS 异步编程的基础。在前端,我们常用的事件监听、发布订阅等操作都需要借助回调函数来实现。比如这样:
javascript
function consoleTrigger() {
console.log('spEvent 被触发')
}
jQuery.subscribe('spEvent', consoleTrigger)
consoleTrigger 函数就作为 subscribe 函数的第 2 个入参被传递。
在 Node 层,我们更是需要回调函数来帮我们完成与外部世界的一系列交互(也就是所谓的"副作用")。
举一个异步读取文件的例子:
javascript
function showData(err, data){
if(err) {
throw err
}
// 输出文件内容
console.log(data);
})
// -- 异步读取文件
fs.readFile(filePath, 'utf8', showData)
showData 函数作为 readFile 函数的第 3 个入参被传递。
JS 函数可以作为另一个函数的返回值
函数作为返回值传递,能很好利用到闭包的特性:
javascript
function baseAdd(a) {
return (b) => {
return a + b
};
};
const addWithOne = baseAdd(1)
// .... (也许在许多行业务逻辑执行完毕后)
const result = addWithOne(2)
"一等公民"的本质:JS 函数是可执行的对象
JS 函数的本质,就是可执行的对象 。它的类型是 Function,它具备 Function 原型上的一切属性和方法。
对象有的性质它也有:
- 能不能赋值给变量?能。
- 能不能作为函数参数传递?能。
- 能不能作为返回值返回?能。
JS 中的数据类型,整体上有两类:值类型(也称基本类型/原始值)和引用类型(也称复杂类型/引用值)。
其中值类型包括:String、Number、Boolean、null、undefined、Symbol、bigInt。这类型的数据最明显的特征是大小固定、体积轻量、相对简单
剩下的 Object 类型就是引用类型(复杂类型) 。这类数据相对复杂、占用空间较大、且大小不定。
保存值类型的变量是按值访问的, 保存引用类型的变量是按引用访问的。
这两类数据之间最大的区别,在于变量保存了数据之后,我们还能对这个数据做什么。
不可变的值,可变的引用内容
值类型的数据无法被修改,当我们修改值类型变量的时候,本质上会创建一个新的值:
javascript
let a = 1
let b = a
// true
a === b
b = 2
// false
a === b
"值类型"数据均为不可变数据。
引用数据,在引用本身不变的情况下,引用所指向的内容是可以发生改变的:
javascript
const a = {
name: 'xiaohong',
age: 30
}
const b = a
// true
a === b
b.name = 'xiaoming'
// true
a === b
b.name 被修改后,a、b 两个引用同一个对象,所以 a.name 也变了。
**像这种创建后仍然可以被修改的数据,我们称其为"可变数据"。
**
"不可变"不是要消灭变化,而是要控制变化
"状态"其实就是数据。
一个看似简单的 H5 营销游戏页面,背后可能就有几十上百个状态关系需要维护。
我们要做的是控制状态的变化的在预期范围内。
名不副实的 "constant"
const 只能够保证值类型数据的不变性,却不能够保证引用类型数据的不变性。
由于值类型数据天然存在不可变性,当我们讨论"JS 数据不可变性"时,更多的其实就是在讨论如何保证【引用类型】数据的不可变性。
不可变数据の实践原则:拷贝,而不是修改
浅拷贝和深拷贝可以解决传递给函数的对象共享引用的问题。
无论是什么样的编程范式,只读数据都必须和可写数据共存 。
对于函数式编程来说,函数的外部数据是只读的,函数的内部数据则是可写的 。
对于一个函数来说,"外部数据"可以包括全局变量、文件系统数据、数据库数据、网络层数据等。有且仅有这些外部数据,存在【只读】的必要。
拷贝不是万能解药
对于数据规模巨大、数据变化频繁的应用来说,拷贝意味着一场性能灾难。
回顾 Immutable.js
Immutable.js 提供了一系列的 Api,这些 Api 将帮助我们确保数据的不可变性。
从效率上来说,它在底层应用了持久化数据结构,解决了暴力拷贝带来的各种问题。
它的原理和 git commit 其实很像。
应对变化的艺术------Git "快照"是如何工作的
在创建 commit 时,git 会对整个项目的所有文件做一个"快照"。
但"快照"究竟是什么?
快照"记录的并不是文件的内容,而是文件的索引。
当 commit 发生时, git 会保存当前版本所有文件的索引。
对于那些没有发生变化的文件,git 保存他们原有的索引;对于那些已经发生变化的文件,git 会保存变化后的文件的索引。
总的说:变化的文件将拥有新的存储空间+新的索引,不变的文件将永远呆在原地。
DRY(Don't Repeat Yourself) 是一种软件设计原则,"不要重复你自己",代码要学会封装和复用。
HOF(High Order Function)指高阶函数。
比如以下代码:
javascript
// 迭代做加法
function arrAdd1(arr) {
const newArr = []
for (let i = 0; i < arr.length; i++) {
newArr.push(arr[i] + 1)
}
return newArr
}
// 迭代做乘法
function arrMult3(arr) {
const newArr = []
for (let i = 0; i < arr.length; i++) {
newArr.push(arr[i] * 3)
}
return newArr
}
// 迭代做除法
function arrDivide2(arr) {
const newArr = []
for (let i = 0; i < arr.length; i++) {
newArr.push(arr[i] / 2)
}
return newArr
}
// 输出 [2, 3, 4]
console.log(arrAdd1([1, 2, 3]))
// 输出 [3, 6, 9]
console.log(arrMult3([1, 2, 3]))
// 输出 [1, 2, 3]
console.log(arrDivide2([2, 4, 6]))
- 迭代做加法:函数入参为一个数字数组,对数组中每个元素做 +1 操作,并把计算结果输出到一个新数组 newArr。
fe:输入 [1,2,3],输出 [2,3,4] - 迭代做乘法:函数入参为一个数字数组,对数组中每个元素做 *3 操作,并把计算结果输出到一个新数组 newArr。
fe:输入 [1,2,3],输出 [3,6,9] - 迭代做除法:函数入参为一个数字数组,对数组中每个元素做 /2 操作,并把计算结果输出到一个新数组 newArr。
fe:输入 [2,4,6],输出 [1,2,3]
上面代码,不符合 DRY 原则。
DRY 原则的 JS 实践:HOF(高阶函数)
对于上面三个函数来说,迭代 loop、数组 push 动作都是一毛一样的,变化的仅仅是循环体里的数学算式而已:
javascript
// +1 函数
function add1(num) {
return num + 1
}
// *3函数
function mult3(num) {
return num * 3
}
// /2函数
function divide2(num) {
return num / 2
}
function arrCompute(arr, compute) {
const newArr = []
for (let i = 0; i < arr.length; i++) {
// 变化的算式以函数的形式传入
newArr.push(compute(arr[i]))
}
return newArr
}
// 输出 [2, 3, 4]
console.log(arrCompute([1, 2, 3], add1))
// 输出 [3, 6, 9]
console.log(arrCompute([1, 2, 3], mult3))
// 输出 [1, 2, 3]
console.log(arrCompute([2, 4, 6], divide2))
arrCompute() 函数,就是一个高阶函数。
高阶函数,指的就是接收函数作为入参,或者将函数作为出参返回的函数。
柯里化
柯里化其实就是一种函数转换,多元函数转换为一元函数(元:指的是函数参数的数量)
具体形式是把一个 fn(a, b, c) 转化为 fn(a)(b)(c) 的过程。
它持续的返回一个新的函数,直到所有的参数用尽为止,然后柯里化链中最后一个函数被返回并且执行时,才会全部执行。
例如我们有一个函数,可以将任意三个数相加:
javascript
function addThreeNum(a, b, c) {
return a + b + c
}
正常调用的话就是 addThreeNum(1, 2, 3) 这样的。
但是通过柯里化,我可以把调用姿势改造为 addThreeNum(1)(2)(3):
javascript
// 将原函数改造为三个嵌套的一元函数
function addThreeNum(a) {
// 第一个函数用于记住参数a
return function(b) {
// 第二个函数用于记住参数b
return function(c) {
// 第三个函数用于执行计算
return a + b + c
}
}
}
// 输出6,输出结果符合预期
addThreeNum(1)(2)(3)
直接修改现有函数,显然违背对外扩展开放,对内修改封闭的原则。
如何保留原有函数的基础上,单纯通过增量代码来实现柯里化 呢?
我们可以创建一个名为 curry 的高阶函数:
javascript
// 定义高阶函数 curry
function curry(addThreeNum) {
// 返回一个嵌套了三层的函数
return function addA(a) {
// 第一层"记住"参数a
return function addB(b) {
// 第二层"记住"参数b
return function addC(c) {
// 第三层直接调用现有函数 addThreeNum
return addThreeNum(a, b, c)
}
}
}
}
// 借助 curry 函数将 add
const curriedAddThreeNum = curry(addThreeNum)
// 输出6,输出结果符合预期
curriedAddThreeNum(1)(2)(3)
实现一个通用的柯里化函数:
javascript
function curry(func) {
if (typeof fn !== 'function') {
throw Error('No function provided')
}
return function curried(...args) {
if (args.length >= func.length) {
return func(...args)
}
return function (...args2) {
return curried(...args, ...args2)
}
}
}
接受 func 函数作为参数,返回 新的函数 curried。
curried 函数根据传入的参数数量判断是否执行 func,如果参数足够,则直接调用 func,否则返回一个新的函数,将已有的参数与新传入的参数合并。
以下是一个使用示例:
javascript
function log(logLevel, msg) {
console.log(`${logLevel}:${msg}:::${Date.now()}`)
}
// 柯里化 log 方法
const curryLog = curry(log)
const debugLog = curryLog('debug')
const errLog = curryLog('error')
// 复用参数 debug
debugLog('testDebug1')
debugLog('testDebug2')
// 复用参数 error
errLog('testError1')
errLog('testError2')
柯里化作用
- 参数复用,逻辑复用
- 延迟计算/执行
反柯里化
反柯里化将一个柯里化的函数转换为一个不再需要柯里化的函数:
javascript
const uncurry = fn => (...args) => {
let res = fn;
for (let arg of args) {
res = res(arg);
}
return res;
};
上面的 uncurry 函数接受一个柯里化的函数 fn,然后返回一个新的函数。这个新的函数接收一组参数 args,并逐一地将这些参数应用到 fn 上
使用这个 uncurry 函数的例子:
javascript
// 柯里化的加法函数
const add = x => y => z => x + y + z;
// 反柯里化
const uncurriedAdd = uncurry(add);
// 使用反柯里化的函数
console.log(uncurriedAdd(1, 2, 3)); // 输出:6
偏函数
偏函数就是固定一部分参数,然后产生更小单元的函数
简单理解就是:分为两次传递参数
javascript
function partial(fn, ...args) {
return function (...newArgs) {
const combinedArgs = [...args, ...newArgs];
return fn.apply(this, combinedArgs);
};
}
此时如果有一个函数,它需要三个参数,你可以使用 partial 函数预设其中的一些参数:
javascript
function add(a, b, c) {
return a + b + c
}
// 使用偏函数应用预设参数 a 和 b
const add5And3 = partial(add, 5, 3)
// 现在你只需要提供一个参数就可以调用这个函数了
console.log(add5And3(2)) // 输出 10
- 抽象:OOP 数据和行为抽象为一个个对象,对象是一等公民;而 FP 将行为抽象为函数,数据与行为是分离的,函数是一等公民。
- 代码重用:OOP 的核心在于继承,而 FP 的核心在于组合。
用 FP 解决业务问题
有这样一个需求:
javascript
用户 -> 喜欢课程 -> 注册课程 -> 检查是否 VIP -> 结束
代码如下:
javascript
// mock一个测试用户:李雷
const user = {
// 姓名
name: 'Li Lei',
// 喜欢列表
likedLessons: [],
// 注册列表
registeredLessons: [],
// VIP 标识
isVIP: false
}
// mock一套测试课程
const myLessons = [
{
teacher: 'John',
title: 'advanced English'
},
{
teacher: 'John',
title: 'advanced Spanish'
}
]
// "喜欢课程"功能函数
function likeLessons(user, lessons) {
const updatedLikedLessons = user.likedLessons.concat(lessons)
return Object.assign({}, user, { likedLessons: updatedLikedLessons })
}
// "注册课程"功能函数
function registerLessons(user) {
return {
...user,
registeredLessons: user.likedLessons
}
}
// "检查是否 VIP"功能函数
function isVIP(user) {
let isVIP = false
if (user.registeredLessons.length > 10) {
isVIP = true
}
return {
...user,
isVIP
}
}
const pipe = (...funcs) =>
funcs.reduce(
(f, g) =>
(...args) =>
g(f(...args))
)
const newUser = pipe(likeLessons, registerLessons, isVIP)(user, myLessons)
console.log(newUser)
打印结果如下:
在这个链条上我们可以随意组合,比如我们这里想在最后清空 likeLessons,只需要:
用 OOP 解决业务问题
有一款运动游戏。在这款游戏里,玩家可以选择成为任何一种类型的运动选手,并且有各种能力:
我们创造三个类:
- BasketballPlayer:篮球选手,会灌篮( slamdunk() ) ,会跳跃( jump() )
- FootballPlayer:足球选手,会射门( shot() ),会狂奔( runFast() )
- CrazyPlayer:疯狂号选手,会飞( fly() )
javascript
// Player 是一个最通用的基类
class Player {
// 每位玩家入场前,都需要给自己起个名字,并且选择游戏的类型
constructor(name, sport) {
this.name = name
this.sport = sport
}
// 每位玩家都有运动的能力
doSport() {
return 'play' + this.sport
}
}
// 篮球运动员类,是基于 Player 基类拓展出来的
class BasketballPlayer extends Player {
constructor(name) {
super(name, 'basketball')
}
slamDunk() {
return `${this.name} just dunked a basketball`
}
jump() {
return `${this.name} is jumping!`
}
}
// 足球运动员类,也基于 Player 基类拓展出来的
class FootballPlayer extends Player {
constructor(name) {
super(name, 'football')
}
shot() {
return `${this.name} just shot the goal`
}
runFast() {
return `${this.name} is running fast!`
}
}
// 疯狂号运动员,也是基于 Player 基类拓展出来的
class CrazyPlayer extends Player {
// 疯狂号运动员可定制的属性多出了 color 和 money
constructor(name, sport, color, money) {
super(name, sport)
this.color = color
this.money = money
}
fly() {
if (this.money > 0) {
// 飞之前,先扣钱
this.money--
return `${this.name} is flying!So handsome!`
}
return 'you need to give me money'
}
}
// 创建一个篮球运动员 Bob
const Bob = new BasketballPlayer('Bob')
Bob.slamDunk()
const John = new FootballPlayer('John')
John.shot()
// 创建一个红色皮肤的疯狂号选手xiuyan,并充了1块钱
const xiuyan = new CrazyPlayer('xiuyan', 'basketball', 'red', 1)
xiuyan.fly()
xiuyan.money
xiuyan.fly()
在网课的案例中,我之所以倾向于使用 FP 求解,是因为这是一个重行为、轻数据结构 的场景;
在游戏的案例中,我之所以倾向使用 OOP 求解,是因为这是一个重数据结构、轻行为 的场景。
现在我们想让一个选手只需要篮球选手的"灌篮"能力,不需要"跳跃"能力;它只需要足球选手的"射门"能力,不需要"狂奔"能力。
如果借助继承:
javascript
SuperPlayer
extends BasketballPlayer
extends FootballPlayer
extends CrazyPlayer
SuperPlayer 需要同时继承 3 个 Class,被迫拥有了它并不需要也并不想要的的"射门"和"狂奔"能力。
今后篮球/足球/疯狂号选手新增的任何属性和方法,都很可能是和我 SuperPlayer 是没有关系的。
而且任何一种选手的 Class 发生变更,都可能影响到我们这位 SuperPlayer 明星选手,谁还敢再动那些父类。
我们不妨引入组合来解决下:
javascript
const getSlamDunk = player => ({
slamDunk: () => {
return `${player.name} just dunked a basketball`
}
})
const getShot = player => ({
shot: () => {
return `${player.name} just shot the goal`
}
})
const getFly = player => ({
fly: () => {
if (player.money > 0) {
// 飞之前,先扣钱
player.money--
return `${player.name} is flying!So handsome!`
}
return 'you need to give me money'
}
})
const SuperPlayer = (name, money) => {
// 创建 SuperPlayer 对象
const player = {
name,
sport: 'super',
money
}
// 组合多个函数到 player 中
return Object.assign({}, getSlamDunk(player), getShot(player), getFly(player))
}
const superPlayer = SuperPlayer('yunmu', 20)
superPlayer.slamDunk()
superPlayer.shot()
superPlayer.fly()
这样一来,我们就用组合的方法,改造了原有的继承链,一举端掉了继承所带来的各种问题。
FP:函数是一等公民
FP 构造出的程序,就像一条长长的管道。管道的这头是源数据,管道的那头是目标数据。
我们只需要关注如何把一节一节简单的小管道(函数)组合起来即可。
OOP:对象是一等公民
OOP 思想起源于对自然界的观察和抽象,旨在寻找事物之间的共性,来抽象出对一类事物的描述 。
在 OOP 关注的更多是一系列有联系的属性和方法。我们把相互联系的属性和方法打包,抽象为一个"类"数据结构
我们关注的不是行为本身,而是谁做了这个行为,谁和谁之间有着怎样的联系 。
此时,摆在我们面前的不再是一个个平行的数据管道,而是一张复杂交错的实体关系网。
代码重用:组合 vs 继承
面向对象(OOP)的核心在于继承,而函数式编程(FP)的核心在于组合。
FP 案例借助 pipe 函数实现了函数组合,OOP 案例借助 extends 关键字实现了类的继承。
组合的过程是一个两两结合、聚沙成塔的过程;
而继承则意味着子类在父类的基础上重写/增加一些内容,通过创造一个新的数据结构来满足的新的需求。
继承当然可以帮我们达到重用的目的,但它称不上"好"。
子类和父类之间的关系,是一种紧耦合的关系。父类的任何变化,都将直接地影响到子类。
但我们定义父类的时候,无法预测这个父类未来会变成什么样子。我们修改任何一个类的时候,都要考虑它是否会对其它的类带来意料之外的影响
在 OOP 的语境下,我们解决"继承滥用"问题的一个重要方法,就是引入"组合"思想。
小结
能用组合就不要用继承 。
即便我们用 OOP 去抽象整体的程序框架,也应该在局部使用"组合"来解决代码重用的问题。
所以 OOP 和 FP 之间并不是互斥/对立的关系,而是正交/协作的关系。
理解"数据共享":从"快照"到"安全帽"
和 git "快照"一样,持久化数据结构的精髓同样在于"数据共享 "。
数据共享意味着将"变与不变"分离,确保只有变化的部分被处理,而不变的部分则将继续留在原地、被新的数据结构所复用。
不同的是,在 git 世界里,这个"变与不变"的区分是文件级别的;而在 Immutable.js 的世界里,这个"变与不变"可以细化到数组的某一个元素、对象的某一个字段。
假如我借助 Immutable.js 基于 A 对象创建出了 B 对象:
javascript
const dataA = Map({
do: 'coding',
age: 666,
from: 'a',
to: 'b'
})
B 对象在 A 对象的基础上修改了其中的某一个字段(age):
javascript
// 使用 immutable 暴露的 Api 来修改 baseMap 的内容
const dataB = dataA.set({
age: 66.6
})
Immutable.js 仅仅会创建变化的那部分(也就是创建一个新的 age 给 B),并且为 B 对象生成一套指回 A 对象的指针,从而复用 A 对象中不变的那 3 个字段。
如何实现数据共享
为了达到这种"数据共享"的效果,持久化数据结构在底层依赖了一种经典的基础数据结构,那就是 Trie(字典树)。
在 Trie 的加持下,我们存储一个对象的姿势可以是这样的:
当我们创建对象 B 的时候,我们可以只针对发生变化的 age 字段创建一条新的数据,并将对象 B 剩余的指针指回 A 去,如下图:
在图示中,B 显然已经区别 于A,是一个新的对象、具备一个新的索引。B 通过和 A 共享不变的那部分数据,成功地提升了管理数据的效率。