Cómo personalizar la igualdad de objetos para JavaScript Set

167

El nuevo ES 6 (Harmony) presenta un nuevo objeto Set . El algoritmo de identidad utilizado por Set es similar al ===operador y, por lo tanto, no es muy adecuado para comparar objetos:

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

¿Cómo personalizar la igualdad para establecer objetos para hacer una comparación profunda de objetos? ¿Hay algo como Java equals(Object)?

Czerny
fuente
3
¿Qué quiere decir con "personalizar la igualdad"? Javascript no permite la sobrecarga del operador, por lo que no hay forma de sobrecargarlo ===. El objeto de conjunto ES6 no tiene ningún método de comparación. El .has()método y el .add()método solo funcionan si es el mismo objeto real o el mismo valor para una primitiva.
jfriend00
12
Por "personalizar la igualdad" me refiero a cualquier forma en que el desarrollador puede definir cierto par de objetos para ser considerados iguales o no.
czerny

Respuestas:

107

El Setobjeto ES6 no tiene ningún método de comparación ni extensibilidad de comparación personalizada.

Los métodos .has(), .add()y .delete()solo funcionan si es el mismo objeto real o el mismo valor para una primitiva y no tienen un medio para conectarse o reemplazar solo esa lógica.

Probablemente podría derivar su propio objeto de ay Setreemplazar .has(), .add()y .delete()métodos con algo que hizo una comparación profunda de objetos primero para encontrar si el elemento ya está en el Conjunto, pero el rendimiento probablemente no sería bueno ya que el Setobjeto subyacente no estaría ayudando en absoluto. Probablemente tendría que hacer una iteración de fuerza bruta a través de todos los objetos existentes para encontrar una coincidencia utilizando su propia comparación personalizada antes de llamar al original .add().

Aquí hay información de este artículo y discusión sobre las características de ES6:

5.2 ¿Por qué no puedo configurar cómo los mapas y conjuntos comparan claves y valores?

Pregunta: Sería bueno si hubiera una manera de configurar qué teclas del mapa y qué elementos del conjunto se consideran iguales. ¿Por qué no está ahí?

Respuesta: Esa característica se ha pospuesto, ya que es difícil de implementar de manera adecuada y eficiente. Una opción es entregar devoluciones de llamada a colecciones que especifiquen igualdad.

Otra opción, disponible en Java, es especificar la igualdad a través de un método que implemente el objeto (equals () en Java). Sin embargo, este enfoque es problemático para los objetos mutables: en general, si un objeto cambia, su "ubicación" dentro de una colección también tiene que cambiar. Pero eso no es lo que sucede en Java. JavaScript probablemente irá por la ruta más segura de habilitar solo la comparación por valor para objetos inmutables especiales (los llamados objetos de valor). La comparación por valor significa que dos valores se consideran iguales si su contenido es igual. Los valores primitivos se comparan por valor en JavaScript.

jfriend00
fuente
44
Se agregó una referencia de artículo sobre este tema en particular. Parece que el desafío es cómo lidiar con un objeto que era exactamente igual a otro en el momento en que se agregó al conjunto, pero ahora se ha cambiado y ya no es el mismo que ese objeto. ¿Está en el Seto no?
jfriend00
3
¿Por qué no implementar un GetHashCode simple o similar?
Jamby
@Jamby: sería un proyecto interesante para hacer un hash que maneje todos los tipos de propiedades y propiedades de hashes en el orden correcto y trate con referencias circulares, etc.
jfriend00
1
@Jamby Incluso con una función hash, aún tiene que lidiar con colisiones. Solo estás aplazando el problema de la igualdad.
mpen
55
@mpen Eso no está bien, estoy permitiendo que el desarrollador administre su propia función hash para sus clases específicas que en casi todos los casos previenen el problema de colisión ya que el desarrollador conoce la naturaleza de los objetos y puede derivar una buena clave. En cualquier otro caso, recurrir al método de comparación actual. Lote de idiomas ya hacer eso, no js.
Jamby
28

Como se menciona en la respuesta de jfriend00, la personalización de la relación de igualdad probablemente no sea posible .

El siguiente código presenta un esquema de solución computacionalmente eficiente (pero costosa en memoria) :

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());
    }

    // ...
}

Cada elemento insertado tiene que implementar un toIdString()método que devuelva una cadena. Dos objetos se consideran iguales si y solo si sus toIdStringmétodos devuelven el mismo valor.

Czerny
fuente
También puede hacer que el constructor tome una función que compare elementos para la igualdad. Esto es bueno si desea que esta igualdad sea una característica del conjunto, en lugar de los objetos utilizados en él.
Ben J
1
@BenJ El punto de generar una cadena y ponerla en un Mapa es que de esa manera su motor Javascript usará una búsqueda ~ O (1) en código nativo para buscar el valor hash de su objeto, mientras que aceptar una función de igualdad obligaría para hacer un escaneo lineal del conjunto y verificar cada elemento.
Jamby
3
Un desafío con este método es que creo que supone que el valor de item.toIdString()es invariante y no puede cambiar. Porque si puede, GeneralSetpuede volverse inválido fácilmente con elementos "duplicados". Por lo tanto, una solución como esa estaría restringida a ciertas situaciones probables en las que los objetos mismos no cambian mientras se usa el conjunto o donde un conjunto que se vuelve inválido no es una consecuencia. Es probable que todos estos problemas expliquen por qué el ES6 Set no expone esta funcionalidad porque realmente solo funciona en ciertas circunstancias.
jfriend00
¿Es posible agregar la implementación correcta de .delete()esta respuesta?
jlewkovich
1
@JLewkovich seguro
czerny
6

Como se menciona en la respuesta principal , personalizar la igualdad es problemático para los objetos mutables. La buena noticia es que (y me sorprende que nadie haya mencionado esto aún) hay una biblioteca muy popular llamada immutable-js que proporciona un rico conjunto de tipos inmutables que proporcionan la semántica de igualdad de valor profundo que está buscando.

Aquí está su ejemplo usando 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}]
Russell Davis
fuente
10
¿Cómo se compara el rendimiento de Set / Map inmutable-js con Set / Map nativo?
frankster
5

Para agregar a las respuestas aquí, seguí adelante e implementé un contenedor de mapas que toma una función hash personalizada, una función de igualdad personalizada y almacena valores distintos que tienen hashes equivalentes (personalizados) en cubos.

Como era de esperar, resultó ser más lento que el método de concatenación de cadenas de Czerny .

Fuente completa aquí: https://github.com/makoConstruct/ValueMap

mako
fuente
¿"Concatenación de cuerdas"? ¿No es su método más parecido a "cadena de sustitución" (si va a darle un nombre)? ¿O hay alguna razón por la que usas la palabra "concatenación"? Tengo curiosidad ;-)
binki
@binki Esta es una buena pregunta y creo que la respuesta trae a colación un buen punto que me tomó un tiempo comprender. Normalmente, cuando se calcula un código hash, uno hace algo como HashCodeBuilder que multiplica los códigos hash de los campos individuales y no se garantiza que sea único (de ahí la necesidad de una función de igualdad personalizada). Sin embargo, cuando se genera una cadena de ID concatenar las cadenas de ID de campos individuales que se garantiza que sea único (y por lo tanto ninguna función de la igualdad es necesario)
Pace
Entonces, si tiene un Pointdefinido como { x: number, y: number }entonces, id stringprobablemente sea su x.toString() + ',' + y.toString().
Paso
Hacer su comparación de igualdad crea un valor que se garantiza que variará solo cuando las cosas se consideren no iguales es una estrategia que he usado antes. Es más fácil pensar las cosas de esa manera a veces. En ese caso, estás generando claves en lugar de hashes . Siempre que tenga un derivador de clave que genere una clave en una forma que las herramientas existentes admitan con igualdad de estilo de valor, que casi siempre termina siendo String, entonces puede omitir todo el paso de hashing y bucketing como dijo y simplemente usar directamente un Mapo incluso un objeto plano de estilo antiguo en términos de la clave derivada.
binki
1
Una cosa a tener en cuenta si realmente utiliza la concatenación de cadenas en su implementación de un derivador de clave es que las propiedades de cadena pueden necesitar un tratamiento especial si se les permite tomar algún valor. Por ejemplo, si tiene {x: '1,2', y: '3'}y {x: '1', y: '2,3'}, entonces String(x) + ',' + String(y)generará el mismo valor para ambos objetos. Una opción más segura, suponiendo que pueda contar con JSON.stringify()ser determinista, es aprovechar su escape de cadena y usar JSON.stringify([x, y])en su lugar.
binki
3

No es posible compararlos directamente, pero JSON.stringify funciona si las claves se acaban de ordenar. Como señalé en un comentario

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

Pero podemos evitar eso con un método de stringify personalizado. Primero escribimos el método

Stringify personalizado

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);
}

El conjunto

Ahora usamos un conjunto. Pero usamos un conjunto de cadenas en lugar de objetos

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

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

Obtén todos los valores

Después de crear el conjunto y agregar los valores, podemos obtener todos los valores al

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

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

Aquí hay un enlace con todo en un archivo http://tpcg.io/FnJg2i

alivio de melone
fuente
"si las claves están ordenadas" es un gran if, especialmente para objetos complejos
Alexander Mills
es exactamente por eso que elegí este enfoque;)
relief.melone
2

Tal vez pueda intentar usar JSON.stringify()para hacer una comparación profunda de objetos.

por ejemplo :

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);

Guahsu
fuente
2
Esto puede funcionar, pero no tiene que ser como JSON.stringify ({a: 1, b: 2})! == JSON.stringify ({b: 2, a: 1}) Si su programa crea todos los objetos en el mismo orden estás a salvo. Pero no es una solución realmente segura en general
relief.melone
1
Ah sí, "conviértelo en una cadena". La respuesta de Javascript para todo.
Timmmm
2

Para los usuarios de Typecript, las respuestas de otros (especialmente czerny ) se pueden generalizar a una clase base agradable, segura y reutilizable:

/**
 * 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;
}

La implementación de ejemplo es así de simple: simplemente anule el stringifyKeymétodo. En mi caso, stringifico alguna uripropiedad.

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

El uso de ejemplo es entonces como si fuera un habitual 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
Jan Dolejsi
fuente
-1

Cree un nuevo conjunto a partir de la combinación de ambos conjuntos, luego compare la longitud.

let set1 = new Set([1, 2, 'a', 'b'])
let set2 = new Set([1, 'a', 'a', 2, 'b'])
let set4 = new Set([1, 2, 'a'])

function areSetsEqual(set1, set2) {
  const set3 = new Set([...set1], [...set2])
  return set3.size === set1.size && set3.size === set2.size
}

console.log('set1 equals set2 =', areSetsEqual(set1, set2))
console.log('set1 equals set4 =', areSetsEqual(set1, set4))

set1 es igual a set2 = verdadero

set1 es igual a set4 = falso

Stefan Musarra
fuente
2
¿Esta respuesta está relacionada con la pregunta? La pregunta es sobre la igualdad de elementos con respecto a una instancia de la clase Set. Esta pregunta parece discutir la igualdad de dos instancias de Set.
czerny el
@czerny tiene razón: originalmente estaba viendo esta pregunta de stackoverflow, donde se podría usar el método anterior: stackoverflow.com/questions/6229197/…
Stefan Musarra
-2

Para alguien que encontró esta pregunta en Google (como yo) que desea obtener un valor de un Mapa usando un objeto como Clave:

Advertencia: esta respuesta no funcionará con todos los objetos

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");

Salida:

Trabajó

DigaoParceiro
fuente