浓缩《函数式编程》小册(上)

之前看了修言的函数式小册,有些收益。顺便做了波笔记,高度浓缩,很干,把所有人物举例和我觉得没有必要的话全删除了。有些地方也做了些修改。大家如果对小册感兴趣,也可以支持。 我将分为上下两篇发出。 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 函数 === 纯函数

函数除了接受入参进行纯纯的计算,然后返回值,啥也不干 。解决了"不确定性 "和"副作用 "。
当然副作用不是毒药,我们只需要合理使用副作用即可。

一等函数的核心特征是"可以被当做变量一样用 "。

"可以被当做变量一样用"意味着什么?它意味着:

  1. 可以被当作参数传递给其他函数
  2. 可以作为另一个函数的返回值
  3. 可以被赋值给一个变量

"一等公民"的 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 原型上的一切属性和方法。

对象有的性质它也有:

  1. 能不能赋值给变量?能。
  2. 能不能作为函数参数传递?能。
  3. 能不能作为返回值返回?能。

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. 迭代做加法:函数入参为一个数字数组,对数组中每个元素做 +1 操作,并把计算结果输出到一个新数组 newArr。
    fe:输入 [1,2,3],输出 [2,3,4]
  2. 迭代做乘法:函数入参为一个数字数组,对数组中每个元素做 *3 操作,并把计算结果输出到一个新数组 newArr。
    fe:输入 [1,2,3],输出 [3,6,9]
  3. 迭代做除法:函数入参为一个数字数组,对数组中每个元素做 /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 共享不变的那部分数据,成功地提升了管理数据的效率。

相关推荐
DogDaoDao3 小时前
leetcode 面试经典 150 题:有效的括号
c++·算法·leetcode·面试··stack·有效的括号
桂月二二4 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
CodeClimb5 小时前
【华为OD-E卷 - 第k个排列 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
hunter2062065 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb6 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角6 小时前
CSS 颜色
前端·css
九酒6 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae
浪浪山小白兔7 小时前
HTML5 新表单属性详解
前端·html·html5
lee5767 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579657 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter