【问题标题】:How to work with javascript Map without mutations如何在没有突变的情况下使用 javascript Map
【发布时间】:2021-04-12 03:25:08
【问题描述】:

我在我的 JS 项目中以函数式的方式工作。

这也意味着我不会改变 objectarray 实体。相反,我总是创建一个新实例并替换一个旧实例。

例如

let obj = {a: 'aa', b: 'bb'}
obj = {...obj, b: 'some_new_value'}

问题是:

如何以函数式(不可变)方式使用 javascript 地图?

我想我可以使用以下代码来添加值:

let map = new Map()
...
map = new Map(map).set(something)

但是删除项目呢?

我不能做new Map(map).delete(something),因为.delete 的结果是一个布尔值。

PS 我知道 ImmutableJS 的存在,但我不想使用它,因为你永远无法 100% 确定你现在是在使用普通 JS 对象还是使用 immutablejs ' 对象(尤其是嵌套结构)。而且由于对 TypeScript 的支持不好,顺便说一句。

【问题讨论】:

  • 我认为您可以创建一个新的地图,过滤掉已删除的项目?地图就像一个普通的对象,但你创建新地图而不是对象创建符号
  • 关于一般主题的可能有趣的阅读可能是how-does-one-implement-hash-tables-in-a-functional-language
  • @sergei 确实,类似于let newMap = new Map(Array.from(oldMap.entries()).filter(([key, value]) => k !== someKey))
  • @JaredSmith 我明白你的意思。老实说,我使用了一些 eslint-extension 来确定我想要的功能。我不想要太多,尽可能只想要纯函数,不要对函数参数和其他一些东西进行突变。
  • 公平地说,不要使用像 immutable 这样的库,因为 “你永远不能 100% 确定你现在是否正在使用一个普通的 JS 对象” 是一个不好的理由。明确定义的抽象障碍(又称“模块”)是成功编程的关键。

标签: javascript dictionary functional-programming immutability


【解决方案1】:

如果您不想使用持久化的 map 数据结构,那么您就无法绕过突变,或者不得不进行效率极低的浅拷贝。请注意,突变本身并无害,但只能与共享底层可变值一起使用。

如果我们能够限制访问可变值的方式,我们就可以获得安全的可变数据类型。不过,它们是有代价的。你不能像往常一样使用它们。事实上,使用它们需要一些时间来熟悉。这是一个权衡。

这里是一个原生Map的例子:

// MUTABLE

const Mutable = clone => refType => // strict variant
  record(Mutable, app(([o, initialCall, refType]) => {
    o.mutable = {
      run: k => {
        o.mutable.run = _ => {
          throw new TypeError("illegal subsequent inspection");
        };

        o.mutable.set = _ => {
          throw new TypeError("illegal subsequent mutation");
        };

        return k(refType);
      },

      set: k => {
        if (initialCall) {
          initialCall = false;
          refType = clone(refType);
        }

        k(refType);
        return o;
      }
    }

    return o;
  }) ([{}, true, refType]));
  
const mutRun = k => o =>
  o.mutable.run(k);

const mutSet = k => o =>
  o.mutable.set(k);

// MAP

const mapClone = m => new Map(m);

const mapDelx = k => m => // safe-in-place-update variant
  mutSet(m_ =>
    m_.has(k)
      ? m_.delete(k)
      : m_) (m);
      
const mapGet = k => m =>
  m.get(k);

const mapSetx = k => v => // safe-in-place-update variant
  mutSet(m_ => m_.set(k, v));

const mapUpdx = k => f => // safe-in-place-update variant
  mutSet(m_ => m_.set(k, f(m_.get(k))));

const MutableMap = Mutable(mapClone);

// auxiliary functions

const record = (type, o) => (
  o[Symbol.toStringTag] = type.name || type, o);

const app = f => x => f(x);

const id = x => x;

// MAIN

const m = MutableMap(new Map([[1, "foo"], [2, "bar"], [3, "baz"]]));

mapDelx(2) (m);
mapUpdx(3) (s => s.toUpperCase()) (m);

const m_ = mutRun(Array.from) (m);
console.log(m_); // [[1, "foo"], [3, "BAZ"]]

try {mapSetx(4) ("bat") (m)} // illegal subsequent mutation
catch (e) {console.log(e.message)}

try {mutRun(mapGet(1)) (m)} // illegal subsequent inspection
catch (e) {console.log(e.message)}

如果您仔细查看Mutable,您会发现它也会创建一个浅表副本,但最初只会创建一次。在您第一次检查可变值之前,您可以进行任意数量的突变。

您可以在我的scriptum library 中找到具有多个实例的实现。这是post,其中包含有关该概念的更多背景信息。

我从 Rust 借用了这个概念,它被称为所有权。类型理论背景是仿射类型,如果你有兴趣,可以归入线性类型。

【讨论】:

  • @您是指错字还是完全错误?我不确定我知道我在说什么,b/c 我没有 PLT 背景..
  • 只是错字。 _(我也不是)
  • 我不同意你的观点,即不可能避免突变。好吧,实际上你是对的,但实际上你可以指定一些不可变的 eslint 规则(这就是我所做的)。
  • 我(我想我)只知道has no observable effects except for producing its result 允许在内部使用突变的函数。如果你将它限制为每个突变延伸只有一个观察者,你保证它是这样的。 (我还听说过类似“唯一类型”和“线性类型”这样的术语)
【解决方案2】:

我不能做 new Map(map).delete(something);,因为 .delete 的结果是一个布尔值。

您可以使用插页式变量。如果你愿意,你可以把它移植到一个函数中:

function map_delete(old_map, key_to_delete) {
    const new_map = new Map(old_map);
    new_map.delete(key_to_delete);
    return new_map;
} 

或者您可以创建获取地图中的条目,过滤它们并从结果中创建一个新条目:

const new_map = new Map( Array.from(old_map.entries).filter( ([key, value]) => key !== something ) );

【讨论】:

  • 我猜 map.entries 返回 MapIterator,而不是数组,所以正如@nbokmans 提到的,你必须使用 Array.from 或其他东西。
  • @SergeiPanfilov — 你是对的;这将教你只浏览文档而不进行测试。
【解决方案3】:

滚动你自己的数据结构

另一种选择是编写自己的map 模块,该模块依赖于 JavaScript 的原生 Map。这完全将我们从其可变行为中解放出来,并防止每次我们希望 setupdatedel 时都制作完整副本。该解决方案让您可以完全控制并有效地演示如何实现您想象中的任何数据结构 -

// main.js

import { fromEntries, set, del, toString } from "./map.js"

const a =
  [["d",3],["e",4],["g",6],["j",9],["b",1],["a",0],["i",8],["c",2],["h",7],["f",5]]

const m =
  fromEntries(a)

console.log(1, toString(m))
console.log(2, toString(del(m, "b")))
console.log(3, toString(set(m, "c", "#")))
console.log(4, toString(m))

我们希望得到预期的输出 -

  1. 映射mfromEntries(a)的结果
  2. 映射的导数 m 与键 "b" 已删除
  3. 映射m 的导数与键"c" 更新为"#"
  4. 映射m,未经上述操作修改
1 (a, 0)->(b, 1)->(c, 2)->(d, 3)->(e, 4)->(f, 5)->(g, 6)->(h, 7)->(i, 8)->(j, 9)
2 (a, 0)->(c, 2)->(d, 3)->(e, 4)->(f, 5)->(g, 6)->(h, 7)->(i, 8)->(j, 9)
3 (a, 0)->(b, 1)->(c, #)->(d, 3)->(e, 4)->(f, 5)->(g, 6)->(h, 7)->(i, 8)->(j, 9)
4 (a, 0)->(b, 1)->(c, 2)->(d, 3)->(e, 4)->(f, 5)->(g, 6)->(h, 7)->(i, 8)->(j, 9)

是时候实现我们的愿望并实施我们的map 模块了。我们将从定义 empty 映射的含义开始 -

// map.js

const empty =
  Symbol("map.empty")

const isEmpty = t =>
  t === empty

接下来我们需要一种方法将我们的条目插入map。这调用存在,fromEntriessetupdatenode -

// map.js (continued)

const fromEntries = a =>
  a.reduce((t, [k, v]) => set(t, k, v), empty)

const set = (t, k, v) =>
  update(t, k, _ => v)

const update = (t, k, f) =>
  isEmpty(t)
    ? node(k, f())
: k < t.key
    ? node(t.key, t.value, update(t.left, k, f), t.right)
: k > t.key
    ? node(t.key, t.value, t.left, update(t.right, k, f))
: node(k, f(t.value), t.left, t.right)

const node = (key, value, left = empty, right = empty) =>
  ({ key, value, left, right })

接下来,我们将定义一种方法来get 映射中的值 -

// main.js (continued)

const get = (t, k) =>
  isEmpty(t)
    ? undefined
: k < t.key
    ? get(t.left, k)
: k > t.key
    ? get(t.right, k)
: t.value

现在我们将定义一种方法来delete 从我们的地图中获取一个条目,这也调用存在 concat -

// map.js (continued)

const del = (t, k) =>
  isEmpty(t)
    ? t
: k < t.key
    ? node(t.key, t.value, del(t.left, k), t.right)
: k > t.key
    ? node(t.key, t.value, t.left, del(t.right, k))
: concat(t.left, t.right)

const concat = (l, r) =>
  isEmpty(l)
    ? r
: isEmpty(r)
    ? l
: r.key < l.key
    ? node(l.key, l.value, concat(l.left, r), l.right)
: r.key > l.key
    ? node(l.key, l.value, l.left, concat(l.right, r))
: r

最后,我们提供了一种使用toString 可视化地图的方法,它调用了inorder。作为奖励,我们将提供toArray -

const toString = (t) =>
  Array.from(inorder(t), ([ k, v ]) => `(${k}, ${v})`).join("->")

function* inorder(t)
{ if (isEmpty(t)) return
  yield* inorder(t.left)
  yield [ t.key, t.value ]
  yield* inorder(t.right)
}

const toArray = (t) =>
  Array.from(inorder(t))

导出模块的功能 -

// map.js (continued)

export { empty, isEmpty, fromEntries, get, set, update, del, append, inorder, toArray, toString }

唾手可得的果实

您的地图模块已完成,但我们可以添加一些有价值的功能,而无需付出太多努力。下面我们实现preorderpostorder 映射遍历。此外,我们向toStringtoArray 添加了第二个参数,允许您选择要使用的遍历。默认使用inorder -

// map.js (continued)

function* preorder(t)
{ if (isEmpty(t)) return
  yield [ t.key, t.value ]
  yield* preorder(t.left)
  yield* preorder(t.right)
}

function* postorder(t)
{ if (isEmpty(t)) return
  yield* postorder(t.left)
  yield* postorder(t.right)
  yield [ t.key, t.value ]
}

const toArray = (t, f = inorder) =>
  Array.from(f(t))

const toString = (t, f = inorder) =>
  Array.from(f(t), ([ k, v ]) => `(${k}, ${v})`).join("->")

export { ..., preorder, postorder }

我们可以扩展fromEntries 以接受任何可迭代的,而不仅仅是数组。这与Object.fromEntriesArray.from 的功能相匹配-

// map.js (continued)

function fromEntries(it)
{ let r = empty
  for (const [k, v] of it)
    r = set(r, k, v)
  return r
}

就像我们上面做的那样,我们可以添加第二个参数,它允许我们指定如何将条目添加到地图中。现在它就像Array.from 一样工作。为什么Object.fromEntries 没有这种行为让我很困惑。 Array.from 很聪明。就像Array.from -

// map.js (continued)

function fromEntries(it, f = v => v)
{ let r = empty
  let k, v
  for (const e of it)
    ( [k, v] = f(e)
    , r = set(r, k, v)
    )
  return r
}
// main.js

import { fromEntries, toString } from "./map.js"

const a =
  [["d",3],["e",4],["g",6],["j",9],["b",1],["a",0],["i",8],["c",2],["h",7],["f",5]]

const z =
  fromEntries(a, ([ k, v ]) => [ k.toUpperCase(), v * v ])

console.log(toString(z))
(A, 0)->(B, 1)->(C, 4)->(D, 9)->(E, 16)->(F, 25)->(G, 36)->(H, 49)->(I, 64)->(J, 81)

演示

展开下面的sn-p,在你自己的浏览器中验证我们的Map模块的结果-

// map.js
const empty =
  Symbol("map.empty")

const isEmpty = t =>
  t === empty

const node = (key, value, left = empty, right = empty) =>
  ({ key, value, left, right })

const fromEntries = a =>
  a.reduce((t, [k, v]) => set(t, k, v), empty)

const get = (t, k) =>
  isEmpty(t)
    ? undefined
: k < t.key
    ? get(t.left, k)
: k > t.key
    ? get(t.right, k)
: t.value

const set = (t, k, v) =>
  update(t, k, _ => v)

const update = (t, k, f) =>
  isEmpty(t)
    ? node(k, f())
: k < t.key
    ? node(t.key, t.value, update(t.left, k, f), t.right)
: k > t.key
    ? node(t.key, t.value, t.left, update(t.right, k, f))
: node(k, f(t.value), t.left, t.right)

const del = (t, k) =>
  isEmpty(t)
    ? t
: k < t.key
    ? node(t.key, t.value, del(t.left, k), t.right)
: k > t.key
    ? node(t.key, t.value, t.left, del(t.right, k))
: concat(t.left, t.right)

const concat = (l, r) =>
  isEmpty(l)
    ? r
: isEmpty(r)
    ? l
: r.key < l.key
    ? node(l.key, l.value, concat(l.left, r), l.right)
: r.key > l.key
    ? node(l.key, l.value, l.left, concat(l.right, r))
: r

function* inorder(t)
{ if (isEmpty(t)) return
  yield* inorder(t.left)
  yield [ t.key, t.value ]
  yield* inorder(t.right)
}

const toArray = (t) =>
  Array.from(inorder(t))

const toString = (t) =>
  Array.from(inorder(t), ([ k, v ]) => `(${k}, ${v})`).join("->")
  
// main.js
const a =
  [["d",3],["e",4],["g",6],["j",9],["b",1],["a",0],["i",8],["c",2],["h",7],["f",5]]

const m =
  fromEntries(a)

console.log(1, toString(m))
console.log(2, toString(del(m, "b")))
console.log(3, toString(set(m, "c", "#")))
console.log(4, toString(m))
console.log(5, get(set(m, "z", "!"), "z"))

【讨论】:

  • WillNess,我确实想进行平衡,但觉得这个话题可能太大而无法突破。我会花时间阅读链接的文章。感谢分享:D
  • 精彩的答案!一个问题:使用k &lt; t.key 是否意味着键需要是字符串或数字? IE。原生 Map 真的是您可以使用 Objects/Arrays/Functions 作为键而不必在查找时迭代所有元素的唯一方法吗?有混合解决方案吗? (就像使用 Wea​​kMap 仅用于存储对象之间的链接和一些生成的 ID 以用作此自定义实现中的可比较键)
  • 我所做的另一个有趣的事情是关于订单。如果您根本没有订单或插入订单,您可以使用自平衡且非常高效的哈希数组映射的 Trie 实现持久的Map。但是,如果顺序由键/值确定,则需要自平衡 BST,例如一棵手指树。
  • @user3297291 这是一个很好的问题。这个答案使用简单的keyvalue 属性实现了二叉树,但是我们经常想要存储和检索更复杂的值。在this Q&A 中,我们检查了一个更复杂的 btree,它允许用户精确定义节点的比较方式。这棵树足够灵活,你甚至可以提供一个散列函数,比如this Q&A,这样树就可以处理任意形状的节点。
【解决方案4】:

功能模块

这是我对持久性map 模块的小实现 -

// map.js

import { effect } from "./func.js"

const empty = _ =>
  new Map

const update = (t, k, f) =>
  fromEntries(t).set(k, f(get(t, k)))

const set = (t, k, v) =>
  update(t, k, _ => v)

const get = (t, k) =>
  t.get(k)

const del = (t, k) =>
  effect(t => t.delete(k))(fromEntries(t))

const fromEntries = a =>
  new Map(a)

export { del, empty, fromEntries, get, set, update }
// func.js
const effect = f => x =>
  (f(x), x)

// ...

export { effect, ... }
// main.js
import { fromEntries, del, set } from "./map.js"

const q =
  fromEntries([["a",1], ["b",2]])

console.log(1, q)
console.log(2, del(q, "b"))
console.log(3, set(q, "c", 3))
console.log(4, q)

展开下面的sn-p,在自己的浏览器中验证结果-

const effect = f => x =>
  (f(x), x)

const empty = _ =>
  new Map

const update = (t, k, f) =>
  fromEntries(t).set(k, f(get(t, k)))

const set = (t, k, v) =>
  update(t, k, _ => v)

const get = (t, k) =>
  t.get(k)

const del = (t, k) =>
  effect(t => t.delete(k))(fromEntries(t))

const fromEntries = a =>
  new Map(a)

const q =
  fromEntries([["a", 1], ["b", 2]])

console.log(1, q)
console.log(2, del(q, "b"))
console.log(3, set(q, "c", 3))
console.log(4, q)
1 Map(2) {a => 1, b => 2}
2 Map(1) {a => 1}
3 Map(3) {a => 1, b => 2, c => 3}
4 Map(2) {a => 1, b => 2}

面向对象的界面

如果你想以面向对象的方式使用它,你可以在我们的普通函数周围添加一个类包装器。这里我们称之为Mapping,因为我们不想破坏原生Map -

// map.js (continued)

class Mapping
{ constructor(t) { this.t = t }
  update(k,f) { return new Mapping(update(this.t, k, f)) }
  set(k,v) { return new Mapping(set(this.t, k, v)) }
  get(k) { return get(this.t, k) }
  del(k) { return new Mapping(del(this.t, k)) }
  static empty () { return new Mapping(empty()) }
  static fromEntries(a) { return new Mapping(fromEntries(a))
  }
}

export default Mapping
// main.js

import Mapping from "./map"

const q =
  Mapping.fromEntries([["a", 1], ["b", 2]]) // <- OOP class method

console.log(1, q)
console.log(2, q.del("b"))      // <- OOP instance method
console.log(3, q.set("c", 3))   // <- OOP instance method
console.log(4, q)

即使我们通过 OOP 接口进行调用,我们的数据结构仍然会持续运行。不使用可变状态 -

1 Mapping { t: Map(2) {a => 1, b => 2} }
2 Mapping { t: Map(1) {a => 1} }
3 Mapping { t: Map(3) {a => 1, b => 2, c => 3} }
4 Mapping { t: Map(2) {a => 1, b => 2} }

【讨论】:

    猜你喜欢
    • 2020-09-24
    • 1970-01-01
    • 2020-08-23
    • 2011-02-13
    • 1970-01-01
    • 2018-10-15
    • 2020-04-09
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多