Copiando arrays y objetos en Javascript

15 Oct 2013

Introducción

Esta breve entrada viene motivada por un comportamiento especial del lenguaje Javascript del que no siempre nos percatamos hasta que no lo experimentamos a través de errores inesperados y es el que refiere a cuando tratamos de copiar objetos.

En Javascript, cuando asignamos a una variable un valor de tipo String o Boolean, estamos creando una copia de dicho objeto. Sin embargo, cuando asignamos un Array o un objeto, lo que estamos es creando una referencia a dicho valor. Veamos qué significa esto…

Copia vs Referencia

Una copia de un valor, como cabe esperar, no es otra cosa sino un nuevo valor idéntico al anterior, pero completamente independiente. Esto permite manipularlos uno a uno sin que alterar al resto:

var foo = "I'm Muzzy",
    bar = foo;
 
bar += ", big Muzzy!";
 
console.log( foo ); // I'm Muzzy
console.log( bar ); // I'm Muzzy, big Muzzy!

Bien; en el código anterior, la variable foo es de tipo String; al asignar este valor a una nueva variable bar, se crea una copia de dicho valor. Esto permite modificar bar de forma independiente sin que foo se vea afectada. Veamos ahora el tema de las referencias:

var foo = [ "Muzzy", "Bob", "Norman", "King", "Queen" ],
    bar = foo;
 
// Truncate the numbers of item to 2
bar.length = 2;
 
console.log( foo ); // [ "Muzzy", "Bob" ]
console.log( bar ); // [ "Muzzy", "Bob" ]

Una referencia es, como podemos apreciar en el ejemplo anterior, simplemente un puntero: cuando ahora se modifica el valor de la variable que apunta a la referencia, se está modificando al original y, por lo tanto, tanto foo como bar se ven afectados. Hemos perdido la independencia que teníamos en el caso anterior con la copia.

En el caso anterior, el array original tenía 5 elementos. Sin embargo, como bar ha creado una referencia, al truncar éste a solo dos elementos, en realidad se está actuando sobre el original. De ahí que finalmente ambos arrays muestren solo esos dos elementos.

Copiando arrays

Cuando lo que queremos es una copia exacta de un array para operar con él de forma independiente al original, tenemos varias formas de conseguirlo. La más sencilla sería iterar por los elementos del original e ir guardándolos en uno nuevo. Algo similar a:

for ( var i = 0, l = foo.length, bar = []; i < l; i++ ) {
   bar[ i ] = foo[ i ];
}

NOTA: He metido la inicialización de bar dentro del propio bucle, pero bien pódría sacarse fuera.

Con esto tendríamos en bar una copia independiente de foo… Pero es una solución muy lenta. Probemos con otra mejorada:

var bar = foo.slice();

Genial! Más corto de escribir y, sobre todo, más rápido para Javascript de evaluar. El método slice devuelve los elementos de un array que se le indiquen en sus argumentos. Al no poner ninguno, los devuelve todos, por lo que tenemos una copia perfecta del array.

¿Y esto funciona con un array multidimensional? Sin problemas:

var foo = [ {
    "name" : "Muzzy in Gondoland",
    "genre" : "Animation",
    "year" : "1986",
    "characters" : [ "Muzzy", "King", "Queen", "Pricess Sylvia" ]
}, { 
    "name" : "Muzzy Come Back",    
    "genre" : "Animation",
    "year" : "1989",
    "characters": ["Muzzy", "Bob", "Amanda", "King", "Queen" ]
} ];
 
var bar = foo.slice();
 
console.log( foo ); // [ Object { ... } ]
console.log( bar ); // [ Object { ... } ]

Copiando objetos

Como hemos visto en la introducción, los objetos también se referencian en lugar de copiarse:

var foo = {
    "name" : "Muzzy",
    "genre" : "Animation",
    "year" : "1986",
    "characters" : [ "Muzzy", "King", "Queen", "Pricess Sylvia" ]
};
 
var bar = foo;
bar[ 'name' ] = "Muzzy in Gondoland";
 
console.log( foo[ 'name' ] ); // Muzzy in Gondoland
console.log( bar[ 'name' ] ); // Muzzy in Gondoland

Para copiar un objeto no podemos hacer uso del método slice ya que éste, es único para los arrays. Tenemos que recurrir a soluciones más sucias…

La mayoría de frameworks tipo jQuery ya implementan un método propio para copiar objetos de forma limpia (al menos a nivel de API):

var bar = $.extend( {}, foo );

No resulta muy intuitivo, pero básicamente se trata de extender (extend) un objeto vacío (el primer argumento) con otro dado (el objeto original). El resultado, como cabe esperar, es un nuevo objeto idéntico al primero.

Para emular esto con Javascript puro, tenemos que iterar con algún tipo de función:

function clone( obj ) {
    if ( obj === null || typeof obj  !== 'object' ) {
        return obj;
    }
 
    var temp = obj.constructor();
    for ( var key in obj ) {
        temp[ key ] = clone( obj[ key ] );
    }
 
    return temp;
}

Con este ‘arreglo’, conseguiríamos también nuestro objetivo:

var bar = clone( foo );
bar[ 'name' ] = "Muzzy in Gondoland";
 
console.log( foo[ 'name' ] ); // Muzzy
console.log( bar[ 'name' ] ); // Muzzy in Gondoland

Muy aparatoso, pero funciona: se comprueba que realmente se le pasa un objeto y luego se itera por sus propiedades para asignarlas a un objeto temporal. Este ejemplo utiliza recursión para poder alcanzar tantos niveles de profundidad como se requiera.

El truco del JSON

Hay una forma quizá más sencilla de clonar objetos y es utilizando el truco del JSON: básicamente consiste en convertir un objeto en una cadena, copiar dicha cadena en la nueva variable, y reconvertir la cadena en objeto. Así, tendríamos nuestros dos elementos independientes:

var bar = JSON.parse( JSON.stringify( foo ) );
 
bar[ 'name' ] = "Muzzy in Gondoland";
 
console.log( foo[ 'name' ] ); // Muzzy
console.log( bar[ 'name' ] ); // Muzzy in Gondoland

Voilá! Funciona! Y con una única línea de código. Solo tiene una pega: la retrocompatibilidad. Si tenemos que dar soporte a entornos antiguos, la primera opción (la de la función) es la mejor. Sin embargo, si nuestro objetivo son los navegadores con menos de 10 años a cuestas, podemos optar por el JSON.

Estaría también el tema del rendimiento: JSON es mucho más lento que la función pero, a menos que estemos hablando de clonar objetos enormes, la diferencia no será crítica… Aún así, si existen muchos niveles de anidación en el objeto original, la función recursiva tampoco será especialmente rápida…

Conclusión

Copiar array y objetos en Javascript no es tan sencillo como asignar a una variable el contenido de otra. Tenemos que jugar un poco con el lenguaje para obligarlo a realizar una copia en lugar de únicamente asignar una referencia que puede provocar errores inesperados si el objetivo es manipular la copia pero preservando el original.

En este post, hemos visto algunas formas de hacerlo.

Más:

{12} Comentarios.

  1. epplestun

    Comentar, que el método del JSON, no copiaría métodos del objeto, si es para trabajar única y exclusivamente con estructuras de datos si funciona, y me parece una buena opción 🙂

  2. Leif

    Ahora si! Ya lo entiendo. He tenido varios problemas con todo esto y que entre unas cosas y otras había deducido una forma muy similar, pero nada como leerlo claramente!! Gracias.
    Por cierto, otra forma de hacer una copia es unir el array anterior a otro vacío con concat que tras leer he comprendido que se podría hacer y va un poco en la línea del extend.

    var a = [ "Muzzy", "Bob", "Norman", "King", "Queen" ,
        b = [].concat(a);
    b.length = 2;
    console.log(a);
    console.log(b);
    

    La solución del JSON.parse es la que utilizaba, antes de usar underscore :D. Este internamente usa más métodos suyos y podría asemejarse un poco a tu primera solución del recorrido de propiedades.

    • Carlos Benítez

      Interesante lo de concatenar el array a otro vacío. Ciertamente es muy similar al extend de jQuery.

      Gracias!

  3. Leif

    Por cierto, acabo de ver el comentario de @epplestun , muy buen apunte! no lo sabía!!

  4. Jacobo

    Como todos, un artículo genial. Nunca se me hubiese ocurrido el truco de JSON

  5. epplestun

    Otra buena solución podría ser la de extender mediante prototipo el objeto Object:

    Object.prototype.clone = function() {
      if(this.cloneNode) return this.cloneNode(true);
      var copy = this instanceof Array ? [] : {};
      for(var attr in this) {
        if(typeof this[attr] == "function" || this[attr]==null || !this[attr].clone)
          copy[attr] = this[attr];
        else if(this[attr]==this) copy[attr] = copy;
        else copy[attr] = this[attr].clone();
      }
      return copy;
    }
    
    var foo = {
        "name" : "Muzzy",
        "genre" : "Animation",
        "year" : "1986",
        "characters" : [ "Muzzy", "King", "Queen", "Pricess Sylvia" ],
        hola : function() {
            return this.name;
        }
        
    };
    
    var bar = foo.clone();
    
    bar.name = "ivan";
    
    console.log(foo.hola());
    console.log(bar.hola());
    

    Si clonamos un elemento DOM, usamos diretamente el método cloneNode 🙂

  6. Leif

    @epplestun El otro día me explicaron el por que no es bueno extender el objeto de algo. Si los frameworks los hicieran, como hace PrototypeJS, sería muy intrusivo. Cada uno extendería una propiedad a su antojo por que le parece bien, y luego tus objetos andarían contaminados de cosas no estandar. No creo que eso sea muy cómodo. A mi personalmente esa conversación me convenció para no hacer un extend a no ser que sea muy muy necesario, o sea para hacer algo que ofrezca retrocompatibilidad como podría ser la función trim que en ES5 si está soportada como prototype del String y antes no.

    • Carlos Benítez

      Completamente de acuerdo: extender el prototipo de una primitiva no es buena idea en JS salvo que todo el código esté bajo control y no vaya a ser utilizado junto al de terceros en otros proyectos.

      Yo limitaría esa opción a una aplicación personal cerrada en la que no voy a liberar, compartir o reutilizar el código.

  7. epplestun

    Estoy de acuerdo con los dos, simplemente quería dejar otra manera de poder hacerlo 😉

  8. A. Matías Quezada

    Muy bueno el artículo aunque matizaría un par de puntos:

    1. El `slice` no funciona con arrays multidimensionales, solo duplica la primera dimensión

        var foo = [ {
            "name" : "Muzzy in Gondoland",
            "genre" : "Animation",
            "year" : "1986",
            "characters" : [ "Muzzy", "King", "Queen", "Pricess Sylvia" ]
        }, {
            "name" : "Muzzy Come Back",
            "genre" : "Animation",
            "year" : "1989",
            "characters": ["Muzzy", "Bob", "Amanda", "King", "Queen" ]
        } ];
         
        var bar = foo.slice();
         
        console.log( foo ); // [ Object { ... } ]
        console.log( bar ); // [ Object { ... } ]
    
        foo[0].name = 'Hola';
        console.log(bar[0].name) // 'Hola';
    
        var a = [[ 1, 2 ], [ 3, 4 ]];
        var b = a;
        a[0][0] = 5;
        console.log(JSON.parse(b)) // [[5,2],[3,4]]
    

    2. Crear una copia exacta

    Para duplicar un objeto necesitas dos cosas, copiar sus propiedades propias y prototipar su prototipo, con la excepción del array y de las funciones que tienen comportamientos especiales.

    Cuando haces

        var temp = obj.constructor();
    

    Si `obj` es un objeto plano estarías invocando `Object`, que al ser nativa si es invocada sin new te devuelve un objeto. Pero si `obj` es un tipo no-nativo probablemente su constructor no devuelva nada

        function MyType(name) {
            this.name = name;
            // no hay return
        }
    
        clone(new MyType());
    

    En cuyo caso temp sería `undefined` rompiendo el código. Supongo que tendría más sentido invocar al constructor para crear una copia

        var temp = new obj.constructor();
    

    Pero en ese caso tienes otro problema, que pasa si el constructor tiene una lógica un poco compleja…

        function MyType(provider) {
            this.dependency = provider.get('other-module');
        }
    

    Entonces el código fallaría porque `undefined` (provider) no tiene el método `get`.

    En mi opinión no debería invocarse el constructor, sino que debería prototiparse el mismo prototipo y copiar las propiedades a mano

        function clone(obj) {
            // si retornamos `obj` el que invocó a esta función se creerá que tiene una copia cuando
            // en realidad tiene una referencia al mismo objeto
            if (typeof obj  !== 'object')
                throw new Error('this function can only clone objets');
    
            var tmp = obj.constructor === Array ? [] : Object.create(obj.constructor.prototype);
            for (var i in obj)
                if (obj.hasOwnProperty(i))
                    tmp[i] = obj[i];
    
            return tmp;
        }
    

    Esto sería válido si nos quedamos en ECMAScript3, porque en ECMA5 podemos cruzarnos con accessors, a mi me pasó

        var prototype = {
            get name() {
                return this.data.name;
            }
        };
    
        clone(prototype) // error! this.data is undefined
    
        var vector = {
            x: 1,
            y: 1,
            get isZero() {
                return this.x === 0 && this.y === 0;
            }
        };
    
        // this will do `copy.isZero = vector.isZero`
        var copy = clone(vector);
    
        copy.x = 10;
        console.log(copy.isZero) // true
    

    Entonces tendríamos que actualizar `clone()` para que soporte properties descriptors que deja el performance por los suelos…

        function clone(obj) {
            if (typeof obj  !== 'object')
                throw new Error('this function can only clone objets');
    
            var tmp = obj.constructor === Array ? [] : Object.create(obj.constructor.prototype);
            Object.keys(obj).forEach(function(i) {
                Object.defineProperty(tmp, i, Object.getOwnPropertyDescriptor(obj, i));
            });
    
            return tmp;
        }
    

    La conclusión es: si necesitas clonar un array usa `.slice()`, pero si necesitas copiar un objeto piensalo dos veces, probablemente hay una forma mejor de hacer lo que intentas hacer.

  9. Natalia

    Graciaaaaaas!, llevaba días tratando de entender porque no funcionaba al igual que con una variable normal

Deja un comentario

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