Come personalizzare l'uguaglianza degli oggetti per il set JavaScript


167

Il nuovo ES 6 (Harmony) introduce il nuovo oggetto Set . L'algoritmo di identità utilizzato da Set è simile ===all'operatore e quindi non molto adatto per confrontare oggetti:

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

Come personalizzare l'uguaglianza per gli oggetti Set per fare un confronto profondo degli oggetti? C'è qualcosa come Java equals(Object)?


3
Cosa intendi con "personalizza l'uguaglianza"? Javascript non consente il sovraccarico dell'operatore, pertanto non è possibile sovraccaricare l' ===operatore. L'oggetto set ES6 non ha alcun metodo di confronto. Il .has()metodo e il .add()metodo funzionano solo perché sono lo stesso oggetto reale o lo stesso valore per una primitiva.
jfriend00,

12
Per "personalizzare l'uguaglianza" intendo in ogni modo come lo sviluppatore può definire una certa coppia di oggetti da considerare uguali o meno.
czerny,

Risposte:


107

L' Setoggetto ES6 non ha alcun metodo di confronto o estensibilità di confronto personalizzata.

I .has(), .add()e .delete()metodi funzionano solo fuori essendo lo stesso oggetto reale o stesso valore per un primitivo e non hanno un mezzo a spina nella o sostituire solo quella logica.

Si potrebbe presumibilmente derivare il proprio oggetto da a Sete sostituire .has(), .add()e .delete()metodi con qualcosa che ha fatto prima un confronto approfondito di oggetti per scoprire se l'oggetto è già nel Set, ma le prestazioni probabilmente non sarebbero buone poiché l' Setoggetto sottostante non aiuterebbe affatto. Probabilmente dovresti semplicemente eseguire un'iterazione della forza bruta attraverso tutti gli oggetti esistenti per trovare una corrispondenza usando il tuo confronto personalizzato prima di chiamare l'originale .add().

Ecco alcune informazioni da questo articolo e la discussione delle funzionalità di ES6:

5.2 Perché non riesco a configurare il modo in cui mappe e set confrontano chiavi e valori?

Domanda: Sarebbe bello se ci fosse un modo per configurare quali chiavi della mappa e quali elementi del set sono considerati uguali. Perché non c'è?

Risposta: tale funzione è stata rinviata, poiché è difficile implementarla in modo corretto ed efficiente. Un'opzione è consegnare i callback alle raccolte che specificano l'uguaglianza.

Un'altra opzione, disponibile in Java, è quella di specificare l'uguaglianza tramite un metodo che l'oggetto implementa (equals () in Java). Tuttavia, questo approccio è problematico per gli oggetti mutabili: in generale, se un oggetto cambia, anche la sua "posizione" all'interno di una collezione deve cambiare. Ma non è quello che succede in Java. JavaScript probabilmente seguirà la strada più sicura abilitando il confronto solo per valore per oggetti speciali immutabili (i cosiddetti oggetti valore). Il confronto per valore significa che due valori sono considerati uguali se il loro contenuto è uguale. I valori primitivi vengono confrontati per valore in JavaScript.


4
Aggiunto articolo di riferimento su questo particolare problema. Sembra che la sfida sia come gestire un oggetto identico a un altro nel momento in cui è stato aggiunto al set, ma ora è stato modificato e non è più lo stesso di quell'oggetto. È nel Seto no?
jfriend00

3
Perché non implementare un GetHashCode semplice o simile?
Jamby,

@Jamby - Sarebbe un progetto interessante per creare un hash che gestisca tutti i tipi di proprietà e le proprietà degli hash nell'ordine giusto e si occupi di riferimenti circolari e così via.
jfriend00,

1
@Jamby Anche con una funzione hash devi ancora gestire le collisioni. Stai solo rinviando il problema dell'uguaglianza.
aprono il

5
@mpen Non è vero, sto permettendo allo sviluppatore di gestire la propria funzione hash per le sue classi specifiche che in quasi tutti i casi prevengono il problema di collisione poiché lo sviluppatore conosce la natura degli oggetti e può ricavare una buona chiave. In ogni altro caso, fallback al metodo di confronto corrente. Lotto di lingue già farlo, js no.
Jamby,

28

Come menzionato nella risposta di jfriend00, la personalizzazione della relazione di uguaglianza probabilmente non è possibile .

Il codice seguente presenta uno schema di soluzione alternativa computazionalmente efficiente (ma memoria costosa) :

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

    // ...
}

Ogni elemento inserito deve essere implementato toIdString() metodo che restituisce stringa. Due oggetti sono considerati uguali se e solo se i loro toIdStringmetodi restituiscono lo stesso valore.


Puoi anche fare in modo che il costruttore esegua una funzione che confronta gli elementi per l'uguaglianza. Questo va bene se vuoi che questa uguaglianza sia una caratteristica dell'insieme, piuttosto che degli oggetti usati in esso.
Ben J

1
@BenJ Il punto di generare una stringa e inserirla in una mappa è che in questo modo il tuo motore Javascript utilizzerà una ricerca ~ O (1) nel codice nativo per cercare il valore hash del tuo oggetto, mentre accettare una funzione di uguaglianza forzerebbe per eseguire una scansione lineare dell'insieme e controllare ogni elemento.
Jamby,

3
Una sfida con questo metodo è che penso che supponga che il valore di item.toIdString()sia invariante e non possa cambiare. Perché se può, allora GeneralSetpuò facilmente diventare non valido con elementi "duplicati" in esso. Pertanto, una soluzione del genere sarebbe limitata solo a determinate situazioni in cui gli oggetti stessi non vengono modificati durante l'utilizzo del set o in cui un set che diventa non valido non è di conseguenza. Tutti questi problemi probabilmente spiegano ulteriormente perché il set ES6 non espone questa funzionalità perché funziona davvero solo in determinate circostanze.
jfriend00,

È possibile aggiungere la corretta implementazione di .delete()a questa risposta?
jlewkovich,

1
@JLewkovich sicuro
czerny

6

Come menziona la risposta principale , la personalizzazione dell'uguaglianza è problematica per gli oggetti mutabili. La buona notizia è (e sono sorpreso che nessuno l'abbia ancora menzionato) c'è una libreria molto popolare chiamata immutable-js che fornisce un ricco set di tipi immutabili che forniscono la semantica di uguaglianza di valore profondo che stai cercando.

Ecco il tuo esempio 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}]

10
In che modo le prestazioni di Set / Map immutable-js si confrontano con Set / Map nativi?
Frank

5

Per aggiungere qui le risposte, sono andato avanti e ho implementato un wrapper Mappa che accetta una funzione hash personalizzata, una funzione di uguaglianza personalizzata e memorizza valori distinti che hanno hash equivalenti (personalizzati) nei bucket.

Com'era prevedibile, si rivelò più lento del metodo di concatenazione delle stringhe di czerny .

Fonte completa qui: https://github.com/makoConstruct/ValueMap


"Concatenazione di stringhe"? Il suo metodo non è più simile al "surrogante delle stringhe" (se hai intenzione di dargli un nome)? O c'è una ragione per cui usi la parola "concatenazione"? Sono curioso ;-)
binki il

@binki Questa è una buona domanda e penso che la risposta sollevi un buon punto che mi ci è voluto un po 'di tempo per capire. In genere, quando si calcola un codice hash, si fa qualcosa come HashCodeBuilder che moltiplica i codici hash dei singoli campi e non è garantito che sia unico (da qui la necessità di una funzione di uguaglianza personalizzata). Tuttavia, quando si genera una stringa ID, si concatenano le stringhe ID dei singoli campi che sono garantite come uniche (e quindi non è necessaria alcuna funzione di uguaglianza)
Pace

Quindi se hai Pointdefinito come { x: number, y: number }allora id stringprobabilmente lo è x.toString() + ',' + y.toString().
Pace

Rendere il tuo confronto di uguaglianza costruire un valore che è garantito variare solo quando le cose dovrebbero essere considerate non uguali è una strategia che ho usato prima. A volte è più facile pensare alle cose in questo modo. In tal caso, stai generando chiavi anziché hash . Finché hai un derivatore di chiavi che genera una chiave in una forma che gli strumenti esistenti supportano con l'uguaglianza di valore-stile, che quasi sempre finisce per essere String, allora puoi saltare l'intero passaggio di hashing e bucketing come hai detto e usare direttamente un Mapo anche oggetto semplice vecchio stile in termini di chiave derivata.
binki,

1
Una cosa a cui prestare attenzione se si utilizza effettivamente la concatenazione di stringhe nell'implementazione di un derivatore di chiavi è che le proprietà di stringa potrebbero dover essere trattate in modo speciale se è consentito loro di assumere qualsiasi valore. Ad esempio, se si dispone di {x: '1,2', y: '3'}e {x: '1', y: '2,3'}, quindi String(x) + ',' + String(y)verrà generato lo stesso valore per entrambi gli oggetti. Un'opzione più sicura, supponendo che tu possa contare JSON.stringify()sull'essere deterministico, è quella di trarre vantaggio dalla sua fuga di stringhe e usarla JSON.stringify([x, y])invece.
binki,

3

Il confronto diretto non sembra possibile, ma JSON.stringify funziona se le chiavi sono state ordinate. Come ho sottolineato in un commento

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

Ma possiamo aggirare questo con un metodo stringify personalizzato. Per prima cosa scriviamo il metodo

Stringify personalizzato

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

Il set

Ora usiamo un set. Ma usiamo un set di stringhe invece di oggetti

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

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

Ottieni tutti i valori

Dopo aver creato il set e aggiunto i valori, possiamo ottenere tutti i valori

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

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

Ecco un link con tutto in un unico file http://tpcg.io/FnJg2i


"se le chiavi sono ordinate" è un grande se, specialmente per oggetti complessi
Alexander Mills il

questo è esattamente il motivo per cui ho scelto questo approccio;)
relief.melone

2

Forse puoi provare a usare JSON.stringify()per fare un confronto profondo degli oggetti.

per esempio :

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


2
Questo può funzionare ma non deve essere come JSON.stringify ({a: 1, b: 2})! == JSON.stringify ({b: 2, a: 1}) Se tutti gli oggetti sono creati dal tuo programma nello stesso ordina di essere al sicuro. Ma non una soluzione davvero sicura in generale
relief.melone,

1
Ah sì, "convertilo in una stringa". La risposta di Javascript per tutto.
Timmmm,

2

Per gli utenti di Typescript, le risposte di altri (specialmente czerny ) possono essere generalizzate in una bella classe base sicura e riutilizzabile:

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

L'implementazione di esempio è quindi semplice: basta ignorare il stringifyKeymetodo. Nel mio caso stringo alcune uriproprietà.

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

L'uso di esempio è quindi come se fosse un normale 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

-1

Crea un nuovo set dalla combinazione di entrambi i set, quindi confronta la lunghezza.

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 è uguale a set2 = vero

set1 è uguale a set4 = false


2
Questa risposta è correlata alla domanda? La domanda riguarda l'uguaglianza degli elementi rispetto a un'istanza della classe Set. Questa domanda sembra discutere l'uguaglianza di due istanze Set.
czerny,

@czerny hai ragione - originariamente stavo visualizzando questa domanda di StackOverflow, in cui si poteva usare il metodo sopra: stackoverflow.com/questions/6229197/…
Stefan Musarra,

-2

A qualcuno che ha trovato questa domanda su Google (come me) che desidera ottenere un valore di una mappa utilizzando un oggetto come chiave:

Attenzione: questa risposta non funzionerà con tutti gli oggetti

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

Produzione:

Lavorato

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.