JavaScript 中实现不可变性的四种方法
介绍
什么是不可更改更新
不可变更新的困难
对原生JS行为的回顾
方法一:原生不可变更新
我想要一切
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
抽象的
本文介绍了四种在 JavaScript 中以不可变方式更新数据结构的不同技术:
许多代码片段都是可运行和可编辑的,我们鼓励您进行尝试。
介绍
函数式编程技术和设计模式在 JavaScript 应用中越来越受欢迎。 例如, RxJS 、 Redux 和 React等工具和框架都从 函数式响应式编程 中汲取了灵感。
函数式编程的众多特性中, 纯粹性 、 不可变性 和 持久化数据结构 这几个相互关联的方面带来了诸如 引用透明性 和 幂等性等 优势。简而言之,避免变异和副作用使得代码更易于理解和编写。
JavaScript 是一种 多范式 编程语言。它允许函数式编程,但缺乏某些专用函数式语言所具备的便利性和保证。因此,如果 JavaScript 开发者想要在不改变数据本身的情况下更新数据,就必须格外小心。例如, Redux 的 reducer “绝不 能改变 其参数、执行副作用……[或]调用非纯函数”,但 Redux 本身并没有提供任何工具来确保这些限制得到遵守。
本文不会着重讨论不可变性的好处,而是探讨如果需要的话,如何在 JS 中实现不可变性。
什么是不可更改更新
考虑这样一个函数,它会使 .counter对象的某个属性值递增:
const obj1 = { bools: [true, false], counter: 7 } const obj2 = { bools: [false, true], counter: 3 } function incrementObjCounter (obj) { // naive solution const newObj = { bools: obj.bools, // same reference counter: obj.counter + 1 // updated data } return newObj } const newObj1 = incrementObjCounter(obj1) const newObj2 = incrementObjCounter(obj2) console.log(obj1, newObj1) // different objects console.log(obj2, newObj2) // different objects console.log(obj1.bools === newObj1.bools) // shared reference console.log(obj2.bools === newObj2.bools) // shared reference
我们不会修改原始对象,而是生成一个包含已更改数据的新对象。需要注意的是,一种更简单的解决方案是递归地 复制 原始对象中的所有数据。这通常被称为 深度克隆 ,会使内存使用量翻倍。
然而,如果我们从未打算修改基础对象中的数据,那么就可以安全地将其 共享 给新对象,如 .bools上面的属性所示。这种 结构上的共享 意味着不可变更新通常可以重用现有内存。
遗憾的是,上述代码非常脆弱;即使对于未更新的数据,它也使用了硬编码的属性名称。该函数既不能复用于其他对象类型,也难以维护。
不可变更新的困难
假设我们有一些嵌套数据,如下所示:
const dino = {
name : ' Denver ' ,
type : ' dinosaur ' ,
friends : [
{
name : ' Wally ' ,
type : ' human ' ,
pets : [
{
name : ' Rocky ' ,
type : ' dog '
}
]
},
{
name : ' Casey ' ,
type : ' human '
}
]
}
Enter fullscreen mode
Exit fullscreen mode
或许我们可以给 Wally 添一只新的宠物兔,比如这样:[此处应填写兔子的品种名称] { name: 'Ears', type: 'rabbit' }。不妨试试看;该 dino值位于以下可编辑代码块中。与大多数实际应用不同,我们已将其 深度冻结, dino因此如果您不小心尝试修改它,将会收到错误提示。
const registerTests = require('@runkit/glebec/tape-dispenser/releases/1.0.0') const test = (actual) => registerTests(t => { t.deepEqual( actual, { name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' }, { name: 'Ears', type: 'rabbit' } ] }, { name: 'Casey', type: 'human' } ] } ) t.end() }) function deepFreeze(object) { var propNames = Object.getOwnPropertyNames(object); for (let name of propNames) { let value = object[name]; object[name] = value; if ((typeof value) === 'object') { object[name] = deepFreeze(value) } } return Object.freeze(object); } const dino = deepFreeze({ name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] }) await (function runTestInStrictMode () { 'use strict'; // using functional strict mode because of runkit limitations // dino is in scope const updatedDino = undefined; // give Wally a new pet rabbit! return test(updatedDino); })()
你是否成功生成了一个新的恐龙,并且没有抛出任何与变异相关的错误?在实际的代码库中,你有多大把握能够避免这些错误?请记住,像这样的对象通常并非完全冻结——它们会默默地接受变异。
本次挑战要求您添加一个对象。那么,如何更新一个嵌套很深的对象,或者删除一个对象呢?欢迎在下方进行尝试,然后继续阅读相关回顾。
function deepFreeze(object) { var propNames = Object.getOwnPropertyNames(object); for (let name of propNames) { let value = object[name]; object[name] = value; if ((typeof value) === 'object') { object[name] = deepFreeze(value) } } return Object.freeze(object); } const dino = deepFreeze({ name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] }) (function runTestInStrictMode () { 'use strict'; // using functional strict mode because of runkit limitations // dino is in scope const dinoRenamedRocky = undefined // change dino.friends[0].pets[0].name const dinoRemovedRocky = undefined // remove dino.friends[0].pets[0] console.log(dino, dinoRenamedRocky, dinoRemovedRocky) // three different values })()
对原生JS行为的回顾
基本类型是不可变的(但也可以重新赋值)
JavaScript 基本类型(undefined、null、boolean、number、string 和 symbol 值)默认是不可变的,因此您无法永久更改基本类型值本身。
const str = 'hello' str[0] = 'j' // silently ignores mutation attempt console.log(str) // "hello", not "jello"
当然,JavaScript 允许 重新赋值 共享 变量 ,这与修改变量不同,但会带来许多相同的问题。
let x = 5 badInc = () => { x = x + 1; return x } badDbl = () => { x = x * 2; return x } console.log(badInc(x) + badDbl(x)) // neither commutative nor idempotent
尝试将上述表达式中的项互换, badDbl(x) + badInc(x)然后重新运行代码。由于重新绑定共享 x变量固有的状态性,我们会得到不同的结果,这与我们对加法运算的预期不符。
对象是可变的(但也可被冻结)
在 JavaScript 中, 除了上面列出的基本类型之外的任何类型都被视为 对象 const,包括数组和函数。初学者对 JavaScript 的一个常见误解是,声明会阻止修改。并非如此;声明 const只会阻止 重新赋值 。
const obj = {} const arr = [] const fnc = () => {} obj.mutated = true arr.mutated = true fnc.mutated = true console.log(obj.mutated, arr.mutated, fnc.mutated) // true x 3
JavaScript 确实提供了 `static` 方法 Object.freeze(obj)来防止属性被重新赋值。在 严格模式 下,尝试在已冻结的对象上设置属性会抛出一个有用的错误信息。遗憾的是,在非严格模式下,这种尝试会被直接拒绝。(请注意,`static` freeze方法本身会改变对象。)
const obj = { example: 9000 } obj.example++ // mutates obj Object.freeze(obj) // mutates obj by freezing it obj.example++ // silently ignored in non-strict mode (error in strict mode) console.log(obj.example) // 9001, not 9002
但请注意—— freeze这只能阻止重新赋值顶级属性,并不能阻止修改这些属性引用的对象。要实现后者,你需要一个非原生的“ 深度冻结 ”算法,该算法会递归地冻结对象。
const obj = { o: { x: 50 } } Object.freeze(obj) obj.o.x++ // still allowed console.log(obj) // inner reference mutated, oops
冻结对象有助于及早发现和调试错误,因为它可以在尝试修改对象时就发出警告,而不是在意外状态导致错误行为时才发出警告,至少在严格模式下是如此。然而,在实践中,冻结对象并不常见,原因可能是:
作者需要预测某个特定物体是否值得花费额外的精力进行冷冻保存,这违背了 “你不需要它”(YAGNI) 原则。
深度冻结算法和深度克隆算法一样,在 JS 中存在一些特殊情况和陷阱(例如循环、正确检测对象)。
递归冻结深层嵌套对象可能会影响性能(无论这种做法是否合理)。
方法一:原生不可变更新
那么,当我们希望避免数据变更时,JavaScript 提供了哪些工具呢?由于基本类型本身是不可变的,而函数很少被用作可变数据容器,因此实际问题是如何生成新版本的 Object`s` 和 Array`s`。为此,我们提供了几种内置方法。
从 ES2015 开始,扩展运算 ...符允许将 可迭代 值展开为显式参数。当在新数组或对象字面量中使用时,可以将现有值的属性复制到新值中:
const obj1 = { type: 'data' } const arr1 = [1, 2, 3] const obj2 = { ...obj1, subtype: 'stuff' } // adds a key-val pair const arr2 = [ ...arr1, 'cheese' ] // adds an element // distinct old and new versions console.log(obj1, obj2) console.log(arr1, arr2)
尤其对于对象而言,这使得 更新 顶级键变得非常容易:
const obj1 = { type: 'data', age: 55 } const obj2 = { ...obj1, age: obj1.age + 1 } // modified age console.log(obj1, obj2)
在某些环境中,对象展开语法可能不受支持,但 Object.assign(以及 ES2015)可能可用。
本地方法的问题
展开运算符使得向数组添加新值、向对象添加新的键值对或修改对象中的键值对变得非常方便。然而,删除键值对却并非如此简单。虽然有一些利用 解构赋值的 技巧,但在处理嵌套对象时却不太方便。
对于数组,删除特定元素可以通过 filtering 或 sliceing 来实现:
const arr = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] const newArrA = [...arr.slice(0, 2), ...arr.slice(3)] const newArrB = arr.filter(color => color !== 'yellow') console.log(arr, newArrA, newArrB)
更新数组元素可以使用 slice以下方式 map:
const arr = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] const newArrA = [...arr.slice(0, 2), 'sunshine', ...arr.slice(3)] const newArrB = arr.map(color => color === 'yellow' ? 'sunshine' : color) console.log(arr, newArrA, newArrB)
这些方法虽然可行,但略显繁琐且容易出错。而且我们目前处理的只是浅层数据;如果数据嵌套更复杂,情况会更加棘手。让我们回到本文最初的例子,即向丹佛的朋友沃利的宠物列表中添加一只兔子。以下是使用原生 JavaScript 的一种可能解决方案:
const dino = { name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] } const updatedDino = { ...dino, friends: [ { ...dino.friends[0], pets: [...dino.friends[0].pets, { name: 'Ears', type: 'rabbit' }] }, ...dino.friends.slice(1) ] } console.log(dino, updatedDino)
这种方法可行,但也有一些明显的缺点:
它既复杂又冗长,以至于难以阅读、编写和维护。
数组和对象展开频繁交替使用,很容易让人搞不清楚发生了什么。
对对象研究得越深入,引用链增长的可能性就越高(例如 pets: [...dino.friends[0].pets ])
修改数组中的某个特定值需要添加 map或 slice操作,这会增加噪声。
dino除非代码被深度冻结 且 你处于严格模式, 否则任何实现错误在实践中都会表现为静默的变更错误。
性能并不理想
扩展语法调用迭代协议
pets创建已更改对象/数组(例如 ` and` friends数组) 的副本的 O(n)时间复杂度为 O(n^2)。
语境
透镜(Lenses) 是一种用于处理嵌套数据的方法,它起源于 Haskell ,一种惰性(因此必然是纯函数式)函数式语言,并采用 Hindley-Milner 类型推断。在 Haskell 中,透镜是非常灵活且通用的工具,可以无缝地处理各种数据类型。
透镜及其相关工具是一个相当深奥的话题,而且在 JavaScript 中,我们没有 Haskell 中透镜所特有的类型限制。然而,其核心思想足够简单实用,因此在流行的函数式 JavaScript 工具库 Ramda 中也有所体现。
高级别
从概念上讲,镜头是一对功能:
一个 用于获取“焦点”元素的 getter a,该元素可以从某些“源”数据中派生出来。 s
一个 可以更新的 setter a,它会不可变地生成一个新的 s
在实现方面,透镜实际上是一个 单独的函数 ,根据其使用方式的不同,它可以执行上述任一任务。给定的透镜函数并非直接在源上调用;而是由诸如 `getLens()`、`getLens()` view和 set`getLens()`之类的辅助函数 over负责正确地使用透镜。
const { view } = require ( ' ramda ' )
const extractedData = view ( someLens , sourceData ) // NOT someLens(wrappingData)
Enter fullscreen mode
Exit fullscreen mode
因为透镜是独立的功能,所以它们可以直接 组合 在一起形成新的透镜( 组合模式 的一个例子)。
const { compose } = require ( ' ramda ' )
const deepLens = compose ( outerLens , innerLens )
Enter fullscreen mode
Exit fullscreen mode
示范
Ramda 最常见的做法是,不会从头开始编写透镜,而是使用诸如 lensIndex和之类的方法 lensProp来生成正确的透镜。
const dino = { name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] } const R = require('ramda') // dino is in scope // making a lens for the "friends" property const friendsLens = R.lensProp('friends') // viewing data using a lens const friends = R.view(friendsLens, dino) console.log(friends) // immutably setting data using a lens const lonelyDino = R.set(friendsLens, [], dino) // aww, no more friends console.log(lonelyDino) // immutably mapping data using a lens const addEveryone = arr => [...arr, 'everyone'] const popularDino = R.over(friendsLens, addEveryone, dino) console.log(popularDino) console.log(dino) // unchanged
set`and` 方法 over会生成具有所需更新的新数据,但与对象/数组展开一样,新值也会与旧值共享任何未更新的引用。
这一切固然很好,但是嵌套数据呢?对于嵌套数据,标准的从右到左的函数组合可以将多个镜头拼接在一起。
const dino = { name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] } const R = require('ramda') // dino is in scope // making some lenses const friendsLens = R.lensProp('friends') const lens0 = R.lensIndex(0) const petsLens = R.lensProp('pets') const nameLens = R.lensProp('name') // stacking lenses together into a single lens const frns0pets0name = R.compose( friendsLens, // outermost lens first lens0, petsLens, lens0, nameLens // innermost lens last ) // viewing data using a lens console.log(R.view(frns0pets0name, dino)) // immutably setting data using a lens console.log(R.set(frns0pets0name, 'Spot', dino)) // immutably mapping data using a lens console.log(R.over(frns0pets0name, (s => s + '!'), dino)) console.log(dino) // unchanged
与我们之前使用的对象/数组展开 + 映射技术相比,上述方法更加简洁、声明式、易用且不易出错。而且它还不止于此;组合多个镜头来聚焦于嵌套属性链是很常见的,因此 Ramda 提供了一个便捷的 lensPath函数。
const dino = { name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] } const R = require('ramda') // dino is in scope const frns0pets0name = R.lensPath(['friends', 0, 'pets', 0, 'name']) console.log(R.set(frns0pets0name, 'Spot', dino)) console.log(dino) // unchanged
当然,你也可以将此透镜与 view`and`一起使用 over——不妨亲自尝试一下。最终,我们的最终解决方案被精简为两行易于阅读的代码——一行根据路径构建透镜,另一行使用该透镜以不可变的方式设置某些数据中深层嵌套的属性。
你只需要镜头吗?
镜头(Lenses)作为具有多种功能(读取/更新/设置)的原生可组合函数,是一种高度函数式的解决方案,并具备所有相关的技巧:局部应用、高阶调用、一流的可移植性、动态行为等等。然而,尽管 Ramda 的镜头函数可以轻松地查看、设置和映射嵌套属性,但删除操作则略显间接;您必须使用原生 JavaScript 删除技术来映射父属性:
const R = require('ramda') const obj = { items: ['a', 'b'] } const removeFirst = arr => arr.slice(1) const updated = R.over(R.lensProp('items'), removeFirst, obj)
通过 Ramda 等库实例化和使用透镜的基本方法可能很简单,但用户应该意识到透镜和其他“光学”*还有一些高级用法和重要的陷阱。例如,违反 透镜定律 的类透镜函数可能会导致一些奇怪的极端情况。
*( 透镜的一个子集是同构,它允许你像操作其他(等价的)数据结构一样操作数据结构。此外还有棱镜,它允许你与可能具有特定形状也可能不具有特定形状的数据进行交互;以及遍历,它可以一次关注多个子部分。 )
有些开发者可能更喜欢在 JavaScript 中使用更符合其惯用习惯的方式来实现不可变更新,这种方式无需导入一大堆工具或学习新的 API。Michael Westrate 开发的 Immer 库正是为此而生的。
Immer 的巧妙之处在于,它以元编程 的形式 改变了通常的修改操作的 含义 ——例如拦截赋值 =、不安全方法以及其他情况 。具体来说,它使用 构造函数创建一个原始对象的“草稿”版本。你可以随意修改这个草稿,甚至可以直接修改深层嵌套的引用。Immer 并不会允许这些修改实际发生,而是将它们转换为对原始对象的聚合不可变更新。 pushProxy
示范
Immer 的核心函数 produce具有以下签名:
produce(currentState, producer: (draftState) => void): nextState
Enter fullscreen mode
Exit fullscreen mode
const { produce } = require('immer') const original = { color: 'blue', items: [5, 6], thing: { x: true }, shared: {} } const updated = produce(original, (draft => { draft.color = 'red' draft.items.push(917) delete draft.thing.x draft.thing.y = false })) console.log(original, updated) // no mutation console.log(original.shared === updated.shared) // no naive copying
如上图 console.log所示,Immer 采用的结构共享方式与我们的手动扩展和 Ramda 镜头解决方案相同。其优势包括:
新属性在开发模式下会自动冻结
produce有一个用于部分应用的柯里化版本
immer是一个小型单一用途库
无需学习新的 API(除了一个函数包装器之外)。
大多数情况下无需特别注意,认知负担也较小。
你只需要沉浸式体验?
虽然 Immer 非常流畅,但它本质上执行的仍然是 Object.assign与我们之前的解决方案相同的扩展/映射类型的操作。因此,尽管 Immer 受益于结构共享,但对大型数据进行某些更新可能会很慢( O(n)例如复制数组)。此外, 默认情况下,Immer 仅适用于普通数组和对象(的某些属性) ,而不适用于其他内置函数,例如 `map` 和 ` Mapmap` Set。有关其他注意事项, 请参阅文档 。
到目前为止,我们所研究的三种方法都主要关注相同的数据结构——嵌套的 Objects 和 Arrays。原生方法、镜头等等 immer都“与其他库和代码库配合良好”,因为其他库和代码库通常会提供并期望这些数据类型。
然而,这些数据结构的一个缺点是,它们的设计初衷并非为了实现不可变更新。要向数组中添加元素,必须创建一个包含所有其他元素的数组副本——这是一个 O(n)复制操作。如果不加注意,可能会在不知情的情况下调用类似 `add` 这样的原生方法, sort从而导致原始数据结构发生改变。
另一种方法是改用 纯函数式数据结构 (PDF链接),或者至少是具有高效不可变更新操作的数据结构。这类数据结构只会公开纯粹的操作API , 从而降低出错的概率。
Facebook 的 Immutable 库正是利用了这种结构。虽然它们使用原生 JavaScript 实现,但细节对用户是隐藏的,用户现在直接与 `List<T>` List和 ` List<T>` 等类 Map(与同名的 ES2015 类不同)进行交互。对于大量数据,这些实体的修改速度大大提升;例如, Map`List<T>` 的 `set` 和 `get` 函数的时间复杂度为 O(n^2),而 `List<T>` 的 `set` 和 ` get` 方法的 O(log32 n)时间复杂度也为 O(n^2), 尽管它们返回的是更新后的列表。 ListpushpopO(1)
示范
MapImmutable 是一个包含多个类的庞大库,每个类又包含许多方法。我们将仅演示该库的一小部分功能,首先将一些典型的 JS 数据转换为嵌套的`<string>` 和 ` <string>` 序列 List。
const dino = { name: 'Denver', type: 'dinosaur', friends: [ { name: 'Wally', type: 'human', pets: [ { name: 'Rocky', type: 'dog' } ] }, { name: 'Casey', type: 'human' } ] } const Immutable = require('immutable') // dino is in scope const immDino = Immutable.fromJS(dino) // view nested (perhaps missing!) data const dogName = immDino.getIn(['friends', 0, 'pets', 0, 'name']) console.log(dogName) // change nested data (immutably), perhaps at a brand new path const immDino2 = immDino.setIn(['friends', 0, 'pets', 0, 'name'], 'Spot') console.log(immDino2)
有些优势虽然没有直接体现,但值得一提:
如果属性不存在, getIn则会直接返回 undefined而不是抛出 cannot read x of undefined错误。
如果某个属性不存在, setIn则会 Map在此过程中创建新对象。
Map我们还可以利用 Immutable.js 的`s` 和`s` 组件 做更多的事情 List,例如 .map`s`、 .push`s`、 .sort`s` 等等——所有这些操作都是以纯粹的、非破坏性的方式进行的。
最后,如果其他库或框架需要普通的 JS 对象或数组,我们可以使用 .toJS()它将对象或数组转换回来。
你只需要不变性吗?
性能和安全性是选择使用的主要原因 immutable,但一如既往,也存在一些需要考虑的权衡因素。
Immutable 要求开发者学习一个新的、相对较大的 API。
调试 immutable数值不如调试普通对象和数组方便。
使用不可变类的项目可能需要在不可变数据类型和更简单的数据类型之间进行转换,这可能会抵消性能优势。
我想要一切
每种方法都有其优缺点,这在编程中很常见。值得庆幸的是,它们并非互斥。例如,Brian Lonsdorf 在他的文章《 Lenses with Immutable.js》 over中演示了如何使用 Ramda lens 将原生对象/数组与 Immutable.js 的 map/list 混合使用。类似地,也可以在传递给 Ramda 的函数内部,或者在调用 Immer 的函数内部 使用数组展开 produce。了解多种方法可以让您找到最适合您用例的抽象层。
工具
学习曲线
安全
表现
概念
太长不看
本国的
JS开发者理应了解它,但精通它需要时间。
不良的、常见的错误来源
对于大量数据,速度较慢(线性)。
手动复制嵌套属性, map//以 filter进行 slice更新或删除
内置
Ramda(镜头) · (52 kB)
概念数量不多,但对FP新手来说可能比较陌生。
很好,但要注意路径。
与本地人相当
创建嵌套数据的透镜,使用 view/ set/ over进行操作,并通过 FP 技术(合成、局部应用)进行操控。
多功能图案
浸入式 · (13 kB)
一种 API 方法
不错,但有一些特殊情况。
与本地人相当
对草稿对象使用通常会改变对象的原生操作,操作将变为不可变的更新。
简易API
不可更改 · (63 kB)
包含许多 API 方法的大型库
很好,但要注意路径。
本身很出色,但转换速度慢了下来。
使用库中的数据结构和方法,而不是原生类型,并在必要时进行转换。
更快的算法
附录:通过函数编码实现不变性
Dierk König 教授提出了另一种存储不可变数据的技术:将数据存储为函数。
Vireo 组合子(正如雷蒙德·斯穆里安在其名著《嘲弄一只知更鸟》中所命名的那样 ) 在 JavaScript 中的定义如下:
const vireo = a => b => f => f ( a )( b )
Enter fullscreen mode
Exit fullscreen mode
当 vireo对两个不同的数据点应用两次时,它会返回一个最终函数,该函数已对这两个数据点进行了闭包处理。结果在概念上是一对数字, 以函数闭包的形式存储 。要访问存储的数字,需要提供一个最终函数参数:
const vireo = a => b => f => f(a)(b) const pairOfNums = vireo(5)(8) // f => f(5)(8) pairOfNums(a => b => console.log(a)) // logs 5 pairOfNums(a => b => console.log(b)) // logs 8
请注意,在这个例子中,一旦生成了键值对,就无法实际修改其值——闭包充当了一种特权数据。我们如何“更新”这样的键值对呢?我们创建一个新函数,该函数会调用旧函数:
const vireo = a => b => f => f(a)(b) const pairOfNums = vireo(5)(8) // f => f(5)(8) const fst = a => _ => a const snd = _ => b => b const newPairWithSecondNumDoubled = f => { const firstNum = pairOfNums(fst) const secondNum = pairOfNums(snd) return f(firstNum)(secondNum * 2) } console.log( newPairWithSecondNumDoubled(fst), // 5 newPairWithSecondNumDoubled(snd) // 16 )
为了简化操作,我们可以编写用于获取、设置和映射的辅助函数……
const vireo = a => b => f => f(a)(b) const getL = p => p(l => _ => l) const getR = p => p(_ => r => r) const getLR = p => p(l => r => [l, r]) const setL = newL => p => vireo(newL)(getR(p)) const setR = newR => p => vireo(getL(p))(newR) const mapL = transform => p => vireo(transform(getL(p)))(getR(p)) const mapR = transform => p => vireo(getL(p))(transform(getR(p))) const numPair = vireo(3)(7) const show = pair => console.log(getLR(pair)) show(numPair) // [3, 7] show(setL(0)(numPair)) // [0, 7] show(mapR(x => x * 2)(numPair)) // [3, 14]
通过闭包存储数据,并创建调用旧函数的新函数,可以扩展到更复杂的数据,并且更符合 JavaScript 的惯用写法;但这本身就是一个值得单独撰写一篇文章的庞大主题。如果您对这种方法感兴趣,Sandy Maquire 的 《Thinking with Types》 一书展示了一种巧妙的方法(使用 Haskell),将 井字棋 棋盘编码为从坐标到数据的函数。不妨在下面尝试一下 JavaScript 版本!
const emptyBoard = (x, y) => null const setPos = (atX, atY, newVal, oldBoard) => { const oldVal = oldBoard(atX, atY) const newBoard = (x, y) => { return (x === atX && y === atY) ? newVal : oldBoard(x, y) } return newBoard } const printBoard = (board, limitX, limitY) => { for (let x = 0; x < limitX; x++) { for (let y = 0; y < limitY; y++) { console.log('pos ' + x + ', ' + y + ' is ' + board(x, y)) } } } const b2 = setPos(0, 0, 'x', emptyBoard) const b3 = setPos(1, 1, 'o', b2) printBoard(b3, 2, 2) // NB: if you log a larger board, runkit paginates the results!
文章来源:https://dev.to/glebec/four-ways-to-immutability-in-javascript-3b3l