【问题标题】:How to customize object equality for JavaScript Set如何为 JavaScript Set 自定义对象相等性
【发布时间】:2015-06-27 20:42:36
【问题描述】:

新的 ES 6 (Harmony) 引入了新的 Set 对象。 Set 使用的身份算法类似于=== 运算符,因此不太适合比较对象:

var set = new Set();
set.add({a:1});
set.add({a:1});
console.log([...set.values()]); // Array [ Object, Object ]

如何自定义 Set 对象的相等性以便进行深度对象比较?有没有类似Javaequals(Object)的东西?

【问题讨论】:

标签: javascript set ecmascript-harmony


【解决方案1】:

ES6 Set 对象没有任何比较方法或自定义比较可扩展性。

.has().add().delete() 方法仅适用于相同的实际对象或原语的相同值,并且无法插入或替换该逻辑。

您大概可以从 Set 派生出您自己的对象,并用首先进行深度对象比较以查找该项目是否已经在 Set 中的方法替换 .has().add().delete() 方法,但是性能可能不会很好,因为底层的Set 对象根本没有帮助。在调用原始 .add() 之前,您可能需要对所有现有对象进行暴力迭代,以使用您自己的自定义比较找到匹配项。

以下是来自this article and discussion 的一些关于 ES6 功能的信息:

5.2 为什么我不能配置 map 和 set 如何比较键和值?

问题:如果有办法配置什么地图就好了 键和什么集合元素被认为是相等的。为什么没有?

回答:该功能已被推迟,因为它很难 正确有效地实施。一种选择是将回调传递给 指定相等性的集合。

Java 中可用的另一个选项是通过方法指定相等性 该对象实现(Java 中的 equals())。然而,这种方法是 可变对象的问题:一般来说,如果一个对象发生变化,它的 集合中的“位置”也必须改变。但这不是 Java中发生了什么。 JavaScript 可能会走更安全的路线 仅对特殊的不可变对象启用按值比较 (所谓的值对象)。按值比较意味着两个值 如果它们的内容相等,则认为它们相等。原始值是 在 JavaScript 中按值进行比较。

【讨论】:

  • 为什么不实现一个简单的 GetHashCode 或类似的?
  • @Jamby 即使使用哈希函数,您仍然必须处理冲突。你只是在推迟平等问题。
  • @mpen 不对,我允许开发人员为他的特定类管理自己的哈希函数,这几乎在所有情况下都可以防止冲突问题,因为开发人员知道对象的性质并且可以导出一个好的密钥。在任何其他情况下,回退到当前的比较方法。Lotoflanguagesalready 这样做,js 不这样做。
  • @Jamby 因为 js 程序员不知道哈希码 :(
  • @Jamby,他说每当您修改任何键时,都需要刷新集合。但我不明白为什么合同需要如此,实际上,没有人在 Java 中这样试探过。
【解决方案2】:

正如jfriend00's answer 中提到的,自定义相等关系可能不可能

以下代码概述了计算效率高(但内存昂贵)解决方法

class GeneralSet {

    constructor() {
        this.map = new Map();
        this[Symbol.iterator] = this.values;
    }

    add(item) {
        this.map.set(item.toIdString(), item);
    }

    values() {
        return this.map.values();
    }

    delete(item) {
        return this.map.delete(item.toIdString());
    }

    // ...
}

每个插入的元素都必须实现返回字符串的toIdString() 方法。当且仅当它们的toIdString 方法返回相同的值时,两个对象才被认为是相等的。

【讨论】:

  • 您还可以让构造函数采用一个比较项目是否相等的函数。如果您希望这种相等性成为集合的一个特征,而不是其中使用的对象,这很好。
  • @BenJ 生成字符串并将其放入 Map 的要点是,您的 Javascript 引擎将在本机代码中使用 ~O(1) 搜索来搜索对象的哈希值,而接受相等函数将强制对集合进行线性扫描并检查每个元素。
  • 这种方法的一个挑战是我认为它假定item.toIdString() 的值是不变的并且不能改变。因为如果可以,那么GeneralSet 很容易因为其中的“重复”项目而变得无效。因此,这样的解决方案将仅限于某些情况,即在使用集合时对象本身没有改变,或者集合变得无效并不重要。所有这些问题可能进一步解释了为什么 ES6 Set 没有公开这个功能,因为它实际上只在某些情况下有效。
  • 是否可以将.delete() 的正确实现添加到这个答案中?
  • @JLewkovich 当然
【解决方案3】:

正如top answer 所提到的,自定义相等对于可变对象是有问题的。好消息是(我很惊讶还没有人提到这一点)有一个非常受欢迎的库,名为 immutable-js,它提供了一组丰富的不可变类型,这些类型提供了您正在寻找的 deep value equality semantics

这是您使用 immutable-js 的示例:

const { Map, Set } = require('immutable');
var set = new Set();
set = set.add(Map({a:1}));
set = set.add(Map({a:1}));
console.log([...set.values()]); // [Map {"a" => 1}]

【讨论】:

  • immutable-js Set/Map的性能与原生Set/Map相比如何?
  • 感谢您的推荐!
  • 似乎是相同的列表值,仍然不等于 Map 键
【解决方案4】:

为了补充这里的答案,我继续实现了一个 Map 包装器,它采用自定义散列函数、自定义相等函数,并将具有等效(自定义)散列的不同值存储在存储桶中。

可以预见的是,turned out to be slowerczerny's string concatenation method

完整来源:https://github.com/makoConstruct/ValueMap

【讨论】:

  • “字符串连接”?他的方法不是更像“字符串代理”(如果你要给它起个名字的话)吗?或者你使用“串联”这个词有什么原因?我很好奇;-)
  • @binki 这是一个很好的问题,我认为答案提出了一个很好的观点,我花了一段时间才掌握。通常,在计算哈希码时,会执行HashCodeBuilder 之类的操作,它将单个字段的哈希码相乘,并且不能保证唯一(因此需要自定义相等函数)。但是,在生成 id 字符串时,您连接各个字段的 id 字符串,这些字段保证是唯一的(因此不需要相等函数)
  • 因此,如果您将Point 定义为{ x: number, y: number },那么您的id string 可能是x.toString() + ',' + y.toString()
  • 让你的平等比较建立一些价值,只有当事情应该被认为是不平等的时候才会改变,这是我以前使用过的一种策略。有时以这种方式思考事情更容易。在这种情况下,您生成的是 keys 而不是 hashes。只要你有一个密钥派生器,它以现有工具支持的值样式相等的形式输出一个密钥,几乎总是以String 结尾,那么你可以跳过整个散列和分桶步骤,就像你说的那样就派生键而言,直接使用Map 甚至旧式普通对象。
  • 如果您在密钥派生程序的实现中实际使用字符串连接,需要注意的一点是,如果允许字符串属性具有任何值,则可能需要对其进行特殊处理。例如,如果您有{x: '1,2', y: '3'}{x: '1', y: '2,3'},那么String(x) + ',' + String(y) 将为这两个对象输出相同的值。假设您可以指望JSON.stringify() 具有确定性,一个更安全的选择是利用其字符串转义并改用JSON.stringify([x, y])
【解决方案5】:

也许您可以尝试使用JSON.stringify() 进行深度对象比较。

例如:

const arr = [
  {name:'a', value:10},
  {name:'a', value:20},
  {name:'a', value:20},
  {name:'b', value:30},
  {name:'b', value:40},
  {name:'b', value:40}
];

const names = new Set();
const result = arr.filter(item => !names.has(JSON.stringify(item)) ? names.add(JSON.stringify(item)) : false);

console.log(result);

【讨论】:

  • 这可以工作,但不必像 JSON.stringify({a:1,b:2}) !== JSON.stringify({b:2,a:1}) 如果所有对象由您的程序按照您安全的顺序创建。但总的来说不是一个真正安全的解决方案
  • 啊,是的,“将其转换为字符串”。 Javascript 解决一切问题。
【解决方案6】:

直接比较它们似乎是不可能的,但 JSON.stringify 如果键被排序就可以工作。正如我在评论中指出的那样

JSON.stringify({a:1, b:2}) !== JSON.stringify({b:2, a:1});

但是我们可以使用自定义的 stringify 方法来解决这个问题。首先我们写方法

自定义字符串化

Object.prototype.stringifySorted = function(){
    let oldObj = this;
    let obj = (oldObj.length || oldObj.length === 0) ? [] : {};
    for (let key of Object.keys(this).sort((a, b) => a.localeCompare(b))) {
        let type = typeof (oldObj[key])
        if (type === 'object') {
            obj[key] = oldObj[key].stringifySorted();
        } else {
            obj[key] = oldObj[key];
        }
    }
    return JSON.stringify(obj);
}

套装

现在我们使用一个集合。但是我们使用一组字符串而不是对象

let set = new Set()
set.add({a:1, b:2}.stringifySorted());

set.has({b:2, a:1}.stringifySorted());
// returns true

获取所有值

创建集合并添加值后,我们可以通过

let iterator = set.values();
let done = false;
while (!done) {
  let val = iterator.next();

  if (!done) {
    console.log(val.value);
  }
  done = val.done;
}

这是一个文件的链接 http://tpcg.io/FnJg2i

【讨论】:

  • “如果键被排序”是一个很大的 if,尤其是对于复杂的对象
  • 这正是我选择这种方法的原因;)
  • 请记住,JSON.stringify() 对通用对象的唯一且完全描述性的表示形式存在各种挑战,而不仅仅是键顺序。它只支持基本类型,不支持 Set、Map、Symbol 或 Class 实例。它也不处理循环引用,例如引用其子代的父代和引用其父代的子代。
【解决方案7】:

对于 Typescript 用户,其他人(尤其是 czerny)的答案可以概括为一个很好的类型安全且可重用的基类:

/**
 * Map that stringifies the key objects in order to leverage
 * the javascript native Map and preserve key uniqueness.
 */
abstract class StringifyingMap<K, V> {
    private map = new Map<string, V>();
    private keyMap = new Map<string, K>();

    has(key: K): boolean {
        let keyString = this.stringifyKey(key);
        return this.map.has(keyString);
    }
    get(key: K): V {
        let keyString = this.stringifyKey(key);
        return this.map.get(keyString);
    }
    set(key: K, value: V): StringifyingMap<K, V> {
        let keyString = this.stringifyKey(key);
        this.map.set(keyString, value);
        this.keyMap.set(keyString, key);
        return this;
    }

    /**
     * Puts new key/value if key is absent.
     * @param key key
     * @param defaultValue default value factory
     */
    putIfAbsent(key: K, defaultValue: () => V): boolean {
        if (!this.has(key)) {
            let value = defaultValue();
            this.set(key, value);
            return true;
        }
        return false;
    }

    keys(): IterableIterator<K> {
        return this.keyMap.values();
    }

    keyList(): K[] {
        return [...this.keys()];
    }

    delete(key: K): boolean {
        let keyString = this.stringifyKey(key);
        let flag = this.map.delete(keyString);
        this.keyMap.delete(keyString);
        return flag;
    }

    clear(): void {
        this.map.clear();
        this.keyMap.clear();
    }

    size(): number {
        return this.map.size;
    }

    /**
     * Turns the `key` object to a primitive `string` for the underlying `Map`
     * @param key key to be stringified
     */
    protected abstract stringifyKey(key: K): string;
}

示例实现就这么简单:只需覆盖stringifyKey 方法。就我而言,我将一些 uri 属性字符串化。

class MyMap extends StringifyingMap<MyKey, MyValue> {
    protected stringifyKey(key: MyKey): string {
        return key.uri.toString();
    }
}

示例用法就像这是一个常规的Map&lt;K, V&gt;

const key1 = new MyKey(1);
const value1 = new MyValue(1);
const value2 = new MyValue(2);

const myMap = new MyMap();
myMap.set(key1, value1);
myMap.set(key1, value2); // native Map would put another key/value pair

myMap.size(); // returns 1, not 2

【讨论】:

    【解决方案8】:

    TypedArray 作为 Set/Map 键使用的特殊但常见情况的良好字符串化方法

    const key = String.fromCharCode(...new Uint16Array(myArray.buffer));
    

    它生成最短的唯一字符串,可以很容易地转换回来。然而,这并不总是一个有效的 UTF-16 字符串,用于显示低和高代理。 Set 和 Map 似乎忽略了代理有效性。 正如在 Firefox 和 Chrome 中测量的那样,展开运算符的执行速度很慢。如果您的 myArray 具有固定大小,则在您编写时它会执行得更快:

    const a = new Uint16Array(myArray.buffer);  // here: myArray = Uint32Array(2) = 8 bytes
    const key = String.fromCharCode(a[0],a[1],a[2],a[3]);  // 8 bytes too
    

    可能是这种密钥构建方法最有价值的优势:它适用于 Float32Array 和 Float64Array,没有任何舍入副作用。请注意,+0 和 -0 是不同的。无穷大是一样的。沉默的 NaN 是一样的。信号 NaN 因信号而异(在原生 JavaScript 中从未见过)。

    【讨论】:

      【解决方案9】:

      致在谷歌上发现这个问题的人(就像我一样)想要使用对象作为 Key 来获取 Map 的值:

      警告:此答案不适用于所有对象

      var map = new Map<string,string>();
      
      map.set(JSON.stringify({"A":2} /*string of object as key*/), "Worked");
      
      console.log(map.get(JSON.stringify({"A":2}))||"Not worked");
      

      输出:

      工作

      【讨论】:

        猜你喜欢
        • 2010-09-17
        • 1970-01-01
        • 2013-12-20
        • 1970-01-01
        相关资源
        最近更新 更多