如何为 JavaScript Set 自定义对象相等性
How to customize object equality for JavaScript Set
新的 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)
?
2022 年 3 月更新
目前有一个 proposal 可以将记录和元组(基本上是不可变的对象和数组)添加到 Javascript。在该提案中,它使用 ===
或 !==
提供记录和元组的直接比较,其中它比较值,而不仅仅是对象引用并且与此答案相关 Set
和 Map
对象将使用键 comparisons/lookups 中 Record 或 Tuple 的 value,这将解决此处要求的内容。
由于 Records 和 Tuples 是不可变的(无法修改)并且因为它们很容易按值进行比较(通过它们的内容,而不仅仅是它们的对象引用),它允许 Maps 和 Sets 使用对象内容作为键并且提议的规范明确命名了 Sets 和 Maps 的此功能。
这个原始问题要求 Set 比较的可定制性,以支持深度对象比较。这并没有提出 Set 比较的可定制性,但如果您使用新的 Record 或 Tuple 而不是 Object 或 Array,它直接支持深度对象比较,从而解决这里的原始问题。
请注意,该提案已于 2021 年年中进入第 2 阶段。最近一直在推进,但肯定没有完成。
可以跟踪 Mozilla 在这个新提案上的工作 here。
原答案
ES6 Set
对象没有任何比较方法或自定义比较扩展性。
.has()
、.add()
和 .delete()
方法仅在成为相同的实际对象或相同的原语值时起作用,并且没有插入或插入的方法只替换那个逻辑。
您大概可以从 Set
派生您自己的对象,并用首先进行深度对象比较的方法替换 .has()
、.add()
和 .delete()
方法以找到如果该项目已经在 Set 中,但性能可能不会很好,因为底层 Set
对象根本没有帮助。在调用原始 .add()
.
之前,您可能只需要对所有现有对象进行强力迭代以使用您自己的自定义比较找到匹配项
以下是来自 this article and discussion ES6 特性的一些信息:
5.2 Why can’t I configure how maps and sets compare keys and values?
Question: It would be nice if there were a way to configure what map
keys and what set elements are considered equal. Why isn’t there?
Answer: That feature has been postponed, as it is difficult to
implement properly and efficiently. One option is to hand callbacks to
collections that specify equality.
Another option, available in Java, is to specify equality via a method
that object implement (equals() in Java). However, this approach is
problematic for mutable objects: In general, if an object changes, its
“location” inside a collection has to change, as well. But that’s not
what happens in Java. JavaScript will probably go the safer route of
only enabling comparison by value for special immutable objects
(so-called value objects). Comparison by value means that two values
are considered equal if their contents are equal. Primitive values are
compared by value in JavaScript.
如所述,自定义等式关系可能不可能。
以下代码概述了计算效率高(但内存昂贵)解决方法:
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()
方法 returns 字符串。当且仅当它们的 toIdString
方法 returns 相同值时,两个对象才被认为是相等的。
为了补充此处的答案,我继续实施了一个 Map 包装器,它采用自定义哈希函数、自定义相等函数,并在存储桶中存储具有等效(自定义)哈希值的不同值。
不出所料,turned out to be slower than 。
也许你可以尝试使用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 是可行的。正如我在评论中指出的
JSON.stringify({a:1, b:2}) !== JSON.stringify({b:2, a:1});
但我们可以使用自定义字符串化方法来解决这个问题。首先我们写方法
自定义字符串化
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;
}
这是一个 link 文件,所有内容都在一个文件中
http://tpcg.io/FnJg2i
致在 Google 上发现此问题的人(如我)想要使用对象作为键来获取 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");
输出:
Worked
正如您正在寻找的 mentions, customizing equality is problematic for mutable objects. The good news is (and I'm surprised no one has mentioned this yet) there's a very popular library called immutable-js that provides a rich set of immutable types which provide the 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}]
对于 Typescript 用户,其他人(尤其是 czerny)的答案可以概括为一个很好的 type-safe 和可重用的基础 class:
/**
* 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<K, V>
。
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
对于 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
key-building 这种方法最有价值的优点可能是:它适用于 Float32Array 和 Float64Array,无需任何舍入 side-effect。请注意,+0 和 -0 是不同的。无穷大是一样的。沉默的 NaN 是相同的。信号 NaN 根据它们的信号而不同(在香草 JavaScript 中从未见过)。
正如其他人所说,到目前为止还没有本地方法可以做到这一点。
但是如果你想用自定义比较器来区分数组,你可以尝试使用 reduce
方法来实现。
function distinct(array, equal) {
// No need to convert it to a Set object since it may give you a wrong signal that the set can work with your objects.
return array.reduce((p, c) => {
p.findIndex((element) => equal(element, c)) > -1 || p.push(c);
return p;
}, []);
}
// You can call this method like below,
const users = distinct(
[
{id: 1, name: "kevin"},
{id: 2, name: "sean"},
{id: 1, name: "jerry"}
],
(a, b) => a.id === b.id
);
...
新的 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)
?
2022 年 3 月更新
目前有一个 proposal 可以将记录和元组(基本上是不可变的对象和数组)添加到 Javascript。在该提案中,它使用 ===
或 !==
提供记录和元组的直接比较,其中它比较值,而不仅仅是对象引用并且与此答案相关 Set
和 Map
对象将使用键 comparisons/lookups 中 Record 或 Tuple 的 value,这将解决此处要求的内容。
由于 Records 和 Tuples 是不可变的(无法修改)并且因为它们很容易按值进行比较(通过它们的内容,而不仅仅是它们的对象引用),它允许 Maps 和 Sets 使用对象内容作为键并且提议的规范明确命名了 Sets 和 Maps 的此功能。
这个原始问题要求 Set 比较的可定制性,以支持深度对象比较。这并没有提出 Set 比较的可定制性,但如果您使用新的 Record 或 Tuple 而不是 Object 或 Array,它直接支持深度对象比较,从而解决这里的原始问题。
请注意,该提案已于 2021 年年中进入第 2 阶段。最近一直在推进,但肯定没有完成。
可以跟踪 Mozilla 在这个新提案上的工作 here。
原答案
ES6 Set
对象没有任何比较方法或自定义比较扩展性。
.has()
、.add()
和 .delete()
方法仅在成为相同的实际对象或相同的原语值时起作用,并且没有插入或插入的方法只替换那个逻辑。
您大概可以从 Set
派生您自己的对象,并用首先进行深度对象比较的方法替换 .has()
、.add()
和 .delete()
方法以找到如果该项目已经在 Set 中,但性能可能不会很好,因为底层 Set
对象根本没有帮助。在调用原始 .add()
.
以下是来自 this article and discussion ES6 特性的一些信息:
5.2 Why can’t I configure how maps and sets compare keys and values?
Question: It would be nice if there were a way to configure what map keys and what set elements are considered equal. Why isn’t there?
Answer: That feature has been postponed, as it is difficult to implement properly and efficiently. One option is to hand callbacks to collections that specify equality.
Another option, available in Java, is to specify equality via a method that object implement (equals() in Java). However, this approach is problematic for mutable objects: In general, if an object changes, its “location” inside a collection has to change, as well. But that’s not what happens in Java. JavaScript will probably go the safer route of only enabling comparison by value for special immutable objects (so-called value objects). Comparison by value means that two values are considered equal if their contents are equal. Primitive values are compared by value in JavaScript.
如
以下代码概述了计算效率高(但内存昂贵)解决方法:
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()
方法 returns 字符串。当且仅当它们的 toIdString
方法 returns 相同值时,两个对象才被认为是相等的。
为了补充此处的答案,我继续实施了一个 Map 包装器,它采用自定义哈希函数、自定义相等函数,并在存储桶中存储具有等效(自定义)哈希值的不同值。
不出所料,turned out to be slower than
也许你可以尝试使用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 是可行的。正如我在评论中指出的
JSON.stringify({a:1, b:2}) !== JSON.stringify({b:2, a:1});
但我们可以使用自定义字符串化方法来解决这个问题。首先我们写方法
自定义字符串化
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;
}
这是一个 link 文件,所有内容都在一个文件中 http://tpcg.io/FnJg2i
致在 Google 上发现此问题的人(如我)想要使用对象作为键来获取 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");
输出:
Worked
正如您正在寻找的
这是您使用 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}]
对于 Typescript 用户,其他人(尤其是 czerny)的答案可以概括为一个很好的 type-safe 和可重用的基础 class:
/**
* 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<K, V>
。
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
对于 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
key-building 这种方法最有价值的优点可能是:它适用于 Float32Array 和 Float64Array,无需任何舍入 side-effect。请注意,+0 和 -0 是不同的。无穷大是一样的。沉默的 NaN 是相同的。信号 NaN 根据它们的信号而不同(在香草 JavaScript 中从未见过)。
正如其他人所说,到目前为止还没有本地方法可以做到这一点。
但是如果你想用自定义比较器来区分数组,你可以尝试使用 reduce
方法来实现。
function distinct(array, equal) {
// No need to convert it to a Set object since it may give you a wrong signal that the set can work with your objects.
return array.reduce((p, c) => {
p.findIndex((element) => equal(element, c)) > -1 || p.push(c);
return p;
}, []);
}
// You can call this method like below,
const users = distinct(
[
{id: 1, name: "kevin"},
{id: 2, name: "sean"},
{id: 1, name: "jerry"}
],
(a, b) => a.id === b.id
);
...