Cómo comparar objetos y arrays en Javascript

05 Oct 2011

Introducción

Es muy frecuente que, durante la etapa de debug de un código, tengamos que realizar diversas comparaciones entre objetos. Por ejemplo, uno de los métodos más recurrentes cuando realizamos TDD es el assertEquals donde pasamos dos términos y esperamos comprobar si ambos son iguales.

Sin embargo, pese a que cuando comparamos cadenas y números no hay ningún problema, no ucurre lo mismo cuando nos enfrentamos a objetos o arrays. Un ejemplo de resultados poco intuitivos son los siguientes:

console.log( [1, 2] == [1, 2] ); // false
console.log( [1, 2] === [1, 2] ); // false
 
console.log( { foo : 'Hello World'} == { foo : 'Hello World'} ); // false
console.log( { foo : 'Hello World'} === { foo : 'Hello World'} ); // false

Como podemos ver, ni la comparación simple ni la estricta, resultan útiles a la hora de trabajar con objetos y arrays. Como cabe esperar, si la estructura fuera más complicada, con más elementos anidados, el resultado sería siendo el mismo.

Preparando el terreno

Para conseguir una comparación estricta (o como algunos frameworks lo llaman profunda), tenemos que crear una función personalizada. Y para ello, no existe ningún otro método que no se base en la fuerza bruta: hay que iterar por cada método o valor de nuestros objetos para poder realizar la comparación.

Para facilitar el trabajo, vamos a recurrir a uan vieja conocida: la función toType() que vimos en un artículo anterior. Gracias a este pequeño snippet, podemos determinar de forma precisa el tipo de dato de de un objeto superando así las capacidades del operador nativo typeof.

El código de dicha función es el siguiente:

var toType = function (obj) {
  if (typeof obj === "undefined") {
    return "undefined";
 
    // consider: typeof null === object
  }
  if (obj === null) {
    return "null";
  }
 
  var type = Object.prototype.toString.call(obj).match(/^\[object\s(.*)\]$/)[1] || '';
 
  switch (type) {
  case 'Number':
    if (isNaN(obj)) {
      return "nan";
    } else {
      return "number";
    }
  case 'String':
  case 'Boolean':
  case 'Array':
  case 'Date':
  case 'RegExp':
  case 'Function':
    return type.toLowerCase();
  }
  if (typeof obj === "object") {
    return "object";
  }
  return undefined;
};

Si prestamos atención, este código es ligeramente diferente al que aparece en el citado artículo: se ha mejorado la detección para los casos NaN y Number; por lo demás, su funcionamiento no ha variado:

console.log( toType( [ 1, 2 ] ) ); // array
console.log( toType( { foo: '1' } ) ); // object
console.log( toType( undefined ) ); // undefined
console.log( toType( NaN ) ); // nan
console.log( toType( / \s+/ ) ); // regexp

Creando la función “equals”

Existen básicamente dos formas de implementar nuestra función: la primera es como tal, una función que acepte dos parámetros y nos devuelva un valor booleano con el resultado de la comparación. La segunda es como una extensión del Object mediante su prototipo para que esté siempre disponible independientemente del scope en el que nos encontremos.

Según lo anterior, invocariamos nuestra función de las siguientes formas:

// Normal function
equals( a, b );
 
// Prototype extend
a.equals( b );

Personalmente, siempre prefiero la primera opción ya que, aunque más lenta (dependiendo de dónde se invoque hay que remontar los scopes necesarios hasta alcanzar su definición), es más segura: no hay problemas con posibles colisiones ya sean en el intérprete o con bibliotecas de terceros.

La función completa se muestra a continuación y ha sido extraída del framework QUnit que ya estudiamos en su día; la autoría corresponde a Philippe Rathé. El código está lo suficientemente comentado para ser autoexplicativo:

var equals = function () {
 
  var innerEquiv; // the real equiv function
  var callers = []; // stack to decide between skip/abort functions
  var parents = []; // stack to avoiding loops from circular referencing
  // Call the o related callback with the given arguments.
 
  function bindCallbacks(o, callbacks, args) {
    var prop = toType(o);
    if (prop) {
      if (toType(callbacks[prop]) === "function") {
        return callbacks[prop].apply(callbacks, args);
      } else {
        return callbacks[prop]; // or undefined
      }
    }
  }
 
  var callbacks = function () {
 
    // for string, boolean, number and null
 
    function useStrictEquality(b, a) {
      if (b instanceof a.constructor || a instanceof b.constructor) {
        // to catch short annotaion VS 'new' annotation of a
        // declaration
        // e.g. var i = 1;
        // var j = new Number(1);
        return a == b;
      } else {
        return a === b;
      }
    }
 
    return {
      "string": useStrictEquality,
      "boolean": useStrictEquality,
      "number": useStrictEquality,
      "null": useStrictEquality,
      "undefined": useStrictEquality,
 
      "nan": function (b) {
        return isNaN(b);
      },
 
      "date": function (b, a) {
        return QUnit.toType(b) === "date" && a.valueOf() === b.valueOf();
      },
 
      "regexp": function (b, a) {
        return QUnit.toType(b) === "regexp" && a.source === b.source && // the regex itself
        a.global === b.global && // and its modifers
        // (gmi) ...
        a.ignoreCase === b.ignoreCase && a.multiline === b.multiline;
      },
 
      // - skip when the property is a method of an instance (OOP)
      // - abort otherwise,
      // initial === would have catch identical references anyway
      "function": function () {
        var caller = callers[callers.length - 1];
        return caller !== Object && typeof caller !== "undefined";
      },
 
      "array": function (b, a) {
        var i, j, loop;
        var len;
 
        // b could be an object literal here
        if (!(toType(b) === "array")) {
          return false;
        }
 
        len = a.length;
        if (len !== b.length) { // safe and faster
          return false;
        }
 
        // track reference to avoid circular references
        parents.push(a);
        for (i = 0; i < len; i++) {
          loop = false;
          for (j = 0; j < parents.length; j++) {
            if (parents[j] === a[i]) {
              loop = true; // dont rewalk array
            }
          }
          if (!loop && !innerEquiv(a[i], b[i])) {
            parents.pop();
            return false;
          }
        }
        parents.pop();
        return true;
      },
 
      "object": function (b, a) {
        var i, j, loop;
        var eq = true; // unless we can proove it
        var aProperties = [],
          bProperties = []; // collection of
        // strings
        // comparing constructors is more strict than using
        // instanceof
        if (a.constructor !== b.constructor) {
          return false;
        }
 
        // stack constructor before traversing properties
        callers.push(a.constructor);
        // track reference to avoid circular references
        parents.push(a);
 
        for (i in a) { // be strict: don't ensures hasOwnProperty
          // and go deep
          loop = false;
          for (j = 0; j < parents.length; j++) {
            if (parents[j] === a[i]) loop = true; // don't go down the same path
            // twice
          }
          aProperties.push(i); // collect a's properties
          if (!loop && !innerEquiv(a[i], b[i])) {
            eq = false;
            break;
          }
        }
 
        callers.pop(); // unstack, we are done
        parents.pop();
 
        for (i in b) {
          bProperties.push(i); // collect b's properties
        }
 
        // Ensures identical properties name
        return eq && innerEquiv(aProperties.sort(), bProperties.sort());
      }
    };
  }();
 
  innerEquiv = function () { // can take multiple arguments
    var args = Array.prototype.slice.apply(arguments);
    if (args.length < 2) {
      return true; // end transition
    }
 
    return (function (a, b) {
      if (a === b) {
        return true; // catch the most you can
      } else if (a === null || b === null || typeof a === "undefined" || typeof b === "undefined" || toType(a) !== toType(b)) {
        return false; // don't lose time with error prone cases
      } else {
        return bindCallbacks(a, callbacks, [b, a]);
      }
 
      // apply transition with (1..n) arguments
    })(args[0], args[1]) && arguments.callee.apply(this, args.splice(1, args.length - 1));
  };
 
  return innerEquiv;
 
}();

Un rápido vistazo por el código nos muestra que se basa una comprobación sistemática de los argumentos aplicando a cada uno el algoritmo de comparación más adecuado.

Para el caso de los objetos y los arrays, el proceso pasa por iterarlos comparando si en cada paso los valores se correspondan con los esperados.

La función, al basarse en la fuerza bruta, puede resultar lenta cuando comparamos objetos muy complejos y anidados. Afortunadamente, el recorrido de circuito corto característico de Javascript permite salir inmediatamente de la función tras la primera comparación fallida evitando realizar las restantes.

Conclusión

A veces, los métodos y operadores nativos de Javascript no permiten efectuar algunas operaciones que podríamos considerar básicas. En este caso concreto, nos encontramos con la inconsistencia del lenguaje a la hora de comparar objetos y arrays en lo que sería algo trivial durante un desarrollo dirigido por tests.

Gracias al ejemplo anterior, podemos contar con una pieza de código reutilizable que puede sacarnos de un apuro en más de una ocasión.

Más:

{7} Comentarios.

  1. FcoDiaz

    .equal() no es lo mismo que “==” o “===”, y contrario a la tu conclución no considero esto una incosistencia de JS por que los operadores estan fucionando como debieran ejemplifico haciendo lo mismo pero en Java que se considerá tener muchas mas incositencias que JS

    package javaapplication1;
    public class Humano {
    String nombre;
    int edad;
    public Humano(String nombre, int edad) {
    this.nombre = nombre;
    this.edad = edad;
    }
    @Override
    public boolean equals(Object obj) {
    Humano h=(Humano)obj;
    return h.nombre.equals(this.nombre) && h.edad == this.edad;
    }
    @Override
    public String toString() {
    return “Soy “+this.nombre+” y tengo “+this.edad;
    }
    }

    ————————————————
    package javaapplication1;
    public class JavaApplication1 {
    public static void main(String[] args) {
    Humano h1 = new Humano(“pako”, 27);
    Humano h2 = new Humano(“pako”, 27);
    Humano h3 = h1;
    System.out.println( h1 == h2 );//false
    System.out.println( h1 == h3 );//true
    System.out.println( h2 == h3 );//false
    System.out.println(h1.equals(h2));//true
    System.out.println(h1.equals(h3));//true
    System.out.println(h2.equals(h3));//true
    }
    }

    si observamos pasa exactamente lo mismo que en JS, el operador “===” o “==” en el caso de Objetos(inclyendo arrays por que por ende son Objetos y exeptuando String en el caso de JS) lo que se compara es la localización del Objeto, en los ejemplos iniciales lo que tenemos son 2 Objetos distintos en memorias con sus atributos con el mimo valor del otro pero distintos objetos al fin por esta razon es correcto decir

    console.log( { foo : ‘Hello World’} === { foo : ‘Hello World’} ); // false

    vamos el operador === verá si se esta tratando del mismo objeto y en si es lo que se esperá, y si lo que buscamos es comparar dos objetos distintos y ver que siertas caracteristicas sean similares y considerarlo como un “igual” al otro objeto es donde entra en accion el .equal

    en mi objservacion la incosistencia si estaria en

    console.log( “hola”===”hola”)//true

    por que al final los string tambien son objetos, pero en este caso son tratados comos si fueran valores nativos, creo que es una abstración ya que internamente se supondria que al hacer esto se intentará comparár el contenido de la cadena que es el unico valor que se supone puede conener un string y JS toma esto como un default, pero pudiera no ser siempre así por la fexibilidad del lenguaje, haciendo esto:

    a=”hola”;
    a.n=10;
    console.log( “hola” === a); //true #fail

    y ya entrado en el analicis me salio la duda si hacia esto:
    console.log( new String(“Hola”) === new String(“Hola”)); //false
    inicializando las cadenas de esta forma si toma el comportamiento que debiese considerando que String son objetos.. de alli me sale la duda que diferencia hay y por que se coporta diferente el operador “===” al inicializarse una cadena con “” o con new String()

  2. FcoDiaz

    perdon digo javascript tine mas incosistencias que Java (me hise bolas) :$

  3. yeikos

    Demasiado exhaustivo a la vez que costoso, quizás útil en situaciones puntuales, a la vez que escasas.

    console.log( JSON.stringify([{ x: ‘s’, y: 9.8 },1]) == JSON.stringify([{ x: ‘s’, y: 9.8 },1]) ); // true

    • Carlos Benítez

      Si; quizá es demasiado exhaustivo pero es una función diseñada para entornos críticos donde se precisa una comparación fiable.
      Funciones como JSON.stringify presentan muchos inconvenientes a la hora de enfrentar objetos. El más inmediato es que, como apunta la documentación, no se manejan bien cuando éstos presentan métodos.

      Por ejemplo:

      var a = { foo : ( function(){ a } )(), bar : function(){ b } };
      var b = { foo : ( function(){ b } )(), bar : function(){ a } };
      
      console.log( JSON.stringify(a) == JSON.stringify(b) ); // true
      console.log( equals(a, b) ); // false
      

      Como podemos comprobar, la utilidad JSON falla. Y, personalmente, lo que encuentro más alarmante es que no hay un mensaje de error o similar: únicamente se nos devuelve true, algo que puede romper la integridad de nuestra lógica si damos por hecho que es un resultado es correcto.

      Saludos!

  4. Javi

    equals(Number.NaN, Number.NaN); // true?

    Esto rompe IEEE-754.

    • Carlos Benítez

      Si que lo rompe desde un punto de vista matemático, pero quizá no desde lo que se espera de un TDD: estamos evaluando el tipo de objeto, no su valor… Pero si que es interesante al menos plantearse qué se busca exactamente para este caso.

      Si preferimos que la comparación no sea verdadera, podemos manipular el código en consecuencia: si lo leemos con atención, se está determinando explícitamente que NaN es, comparativamente, igual a NaN. Tan solo tendríamos que modificar la función useStrictEquality para corregir ese comportamiento.

      Saludos!

  5. yeikos

    Esos muchos inconvenientes quedan reducidos infimamente, cuando en verdad, y como bien muestras en los ejemplos, la mayoría de las comparaciones serán objetos, cadenas, arrays…

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *