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

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

相关推荐
ThisIsClark1 分钟前
【后端面试总结】MySQL主从复制逻辑的技术介绍
mysql·面试·职场和发展
m0_748254883 分钟前
DataX3.0+DataX-Web部署分布式可视化ETL系统
前端·分布式·etl
ZJ_.14 分钟前
WPSJS:让 WPS 办公与 JavaScript 完美联动
开发语言·前端·javascript·vscode·ecmascript·wps
GIS开发特训营19 分钟前
Vue零基础教程|从前端框架到GIS开发系列课程(七)响应式系统介绍
前端·vue.js·前端框架·gis开发·webgis·三维gis
Cachel wood1 小时前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
学代码的小前端1 小时前
0基础学前端-----CSS DAY9
前端·css
joan_851 小时前
layui表格templet图片渲染--模板字符串和字符串拼接
前端·javascript·layui
程序猿进阶1 小时前
深入解析 Spring WebFlux:原理与应用
java·开发语言·后端·spring·面试·架构·springboot
还是大剑师兰特1 小时前
什么是尾调用,使用尾调用有什么好处?
javascript·大剑师·尾调用
m0_748236111 小时前
Calcite Web 项目常见问题解决方案
开发语言·前端·rust