Clonando objetos en JavaScript

Valores primitivos y Objetos

Cuando programamos, es usual invocar funciones y pasarle argumentos, así mismo es usual que no conozcamos el manejo que le da JavaScript a esos parámetros, motivo por el cual a veces obtenemos resultados inesperados.

Al pasar una variable con un valor primitivo (string, number, boolean, null, undefined), el parámetro de la función que recibe el valor crea una copia (by value).
Esto significa que cualquier cambio que hagamos sobre ese parámetro, sólo será válido dentro del ámbito (scope) de la función; veamos el siguiente ejemplo:

    function fnMessage(msg) {
        console.log("Original:", msg);
        msg = msg || "nada que decir";
        msg = "Mensaje: " + msg;
        return msg;
    }

    var msg1 = "JavaScript rules!",
        msg2 = fnMessage(msg1);

    console.log("------");
    console.log("msg1 = ", msg1);
    console.log("msg2 = ", msg2);

Lo mismo ocurre cuando asignamos un valor primitivo a una variable; el valor es copiado a la variable.

    var msg1 = "JavaScript rules!",
        msg2 = msg1;

    msg1 = "jQuery rules!";

    console.log("msg1 = ", msg1);
    console.log("msg2 = ", msg2);

Sin embargo cuando trabajamos con objetos (Object, Array, RegExp, Date...), ya sea que lo pasemos como argumento de una función o que lo asignemos a una variable, estamos pasando su referencia (by reference). Esto significa que cualquier cambio que hagamos sobre la referencia del objeto, se verá reflejado en el objeto original. Veamos el siguiente caso:

    function fnSetCenter(x) {
        console.log("Original:", x);
        x = x || {};
        x.top = "50%";
        x.left = "50%";
        return x;
    }

    var obj1 = { top: 1, left: 1 };

    //obtenemos la referencia de obj1
    //a través del valor retornado por la función
    var obj2 = fnSetCenter(obj1);

    console.log("-----");
    console.log("obj1: ", obj1);
    console.log("obj2: ", obj2);

    //creamos una nueva propiedad en obj2
    obj2.zIndex = 2;
    console.log("-----");
    console.log("obj1: ", obj1);
    console.log("obj2: ", obj2);

Las variables obj1 y obj2 son iguales!!
¿Qué ocurrió entonces?

  • Asignamos un objeto a la variable obj1
  • Ejecutamos fnSetCenter() enviándole como parámetro la variable obj1
  • El parámetro X de la función fnSetCenter() recibe una referencia al objeto asignado en la variable obj1
  • Cambiamos las propiedades del objeto X (recuerden que es una referencia)
  • Retornamos el objeto X (se cierra el scope de la función)
  • Ahora asignamos el valor retornado por la función fnSetCenter a la variable obj2
  • Lo más natural es pensar que obj2 contiene una copia del objeto retornado por fnSetCenter, sin embargo obtenemos una nueva referencia de obj1
  • Ahora creamos la propiedad zIndex en la variable obj2
  • Y finalmente observamos que esa propiedad también existe en obj1

Veamos un ejemplo más:

    (function() {
        function View (name) {
            this.name = name || "Vista";
        }

        function fnCounter(obj) {
            obj = obj || {};
            obj.count = 1 + (obj.count || 0);
        }

        var view = new View();
        console.log(view);

        //agregamos la propiedad [count]
        fnCounter(view);
        console.log(view);
    }());

¿Y qué dice el standard?

A primitive value is a member of one of the following built-in types: Undefined, Null, Boolean, Number, and String; an object is member of the remaining built-in type: Object; and a method is a function associated with an object via a property. — ECMAScript

Un valor primitivo es miembro de uno de los siguientes tipos integrados: Undefined, Null, Boolean, Number y String; un objeto es miembro del tipo integrado restante: Object; y un método es una función asociada a un objeto mediante una propiedad

De lo anterior podemos asumir que una función también está asociada con un objeto (by reference) — Lectura recomendada: Data Types In JavaScript

    (function() {
        function fn(name) {
            var n = name || "fn",
                p = arguments.callee; //referencia a la función actual
            p.x = p.x || 1; //referencia a una propiedad de la función
            console.log(n + ".x = ", p.x);
        }

        var a = fn, b = a;
        fn(); //ejecutamos la función original

        //accedemos al apuntador que referencia a fn
        console.log("referencia a.x = ", a.x);

        //modificamos fn mediante su referencia en b
        b.x = 31;
        b("b");
        a("a");
    }());

¿Porqué clonar objetos?

Como vimos anteriormente, cuando asignamos un objeto a una variable o cuando enviamos un objeto no primitivo como parámetro de una función, lo que realmente hacemos es establecer una referencia a ese objeto.

Si no queremos que una referencia modifique el objeto original, entonces debemos crear una copia de ese objeto, a este procedimiento lo llamamos: clonar objetos.

Clonando un Array

Para clonar el contenido de un array, todo lo que se necesita es utilizar el método slice(), enviando el índice [0] como primer argumento:

    var list = ["A",1,2,3,5,8,13];

    //clonamos el array original (shallow copy)
    var clone = list.slice(0);

    //modificamos el primer elemento del array original
    list[0] = 1;

    //agregamos un elemento al inicio del array original
    list.unshift(0);

    //agregamos un elemento al final del array clonado
    clone.push(21);

    console.log("list: ", list);
    console.log("clone: ", clone);

En el código anterior clonamos el array original copiando los valores primitivos, por tal motivo podemos alterar los dos arrays independientemente. Sin embargo, si el array contiene elementos de tipo Object, entonces el elemento “copiado” será una nueva referencia del objeto original, es decir, se creará una copia de su referencia y no de su valor.

    var list = [{id: 1, age: 30}, {id: 2, age: 42}];

    //clonamos el array original (shallow copy)
    var clone = list.slice(0);

    console.log("list[0]: ", list[0]);
    console.log("clone[0]: ", clone[0]);

    //modificamos el primer elemento del array original
    list[0].age = 21;

    //tanto el original como el clon fueron modificados
    console.log("list: ", list);
    console.log("clone: ", clone);

Para hacer una copia profunda de los elementos del array, debemos crear el objeto en vez de copiar su referencia (deep copy), para ello podemos crear una función genérica que clone objetos. (Ver la implementación Clonando objetos complejos y Deep copy)

Shallow copy vs Deep copy

  • Una copia superficial o shallow copy se logra copiando la referencia de los objetos originales. Sin embargo este enfoque puede conducir a resultados desagradables ya que el valor de los elementos del objeto original puede ser alterado desde otra referencia.
  • Una copia profunda o deep copy se logra creando nuevamente los objetos que se desea clonar. Es un proceso recursivo que itera los elementos del objeto a copiar. La ventaja de este enfoque, es que al crear un nuevo objeto para cada elemento copiado, nos aseguramos que será asignado en un compartimento de memoria independiente, por tal motivo al modificar los valores del objeto clonado, no se afectará el objeto original.

Shallow copy vs Deep copy

Nota: Los siguientes métodos de clonación son efectivos para copiar objetos planos (que sigan el estándar JSON), no objetos que posean estructuras circulares o con apuntadores a funciones, en cuyo caso, siempre es mejor crear un clonador que se ajuste a esos requerimientos específicos. (Vea la sección Clonando objetos complejos)

Deep copy usando JSON.parse

    /* @throws TypeError: Converting circular structure to JSON
       No copia funciones ni objetos con estructuras circulares.
       Debe respetar estrictamente los valores permitidos por JSON.
       Lea la especificación del formato JSON: http://json.org/
    */

    function fnClone (obj) {
        return JSON.parse(JSON.stringify(obj));
    }

Deep copy usando jQuery.extend

    /* @throws RangeError: Maximum call stack size exceeded
       Copia funciones, pero no copia estructuras circulares
    */

    //clonar arrays con jQuery
    function fnCloneArray (arr) {
        return jQuery.extend(true, [], arr);
    }

    //clonar objetos con jQuery
    function fnCloneObject (obj) {
        return jQuery.extend(true, {}, obj);
    }
Poniendo todo junto…
    (function ($) {

        //clonar usando JSON
        function fnCloneJSON (obj) {
            return JSON.parse(JSON.stringify(obj));
        }

        //clonar usando jQuery
        function fnClonejQuery (arr) {
            return $.extend(true, [], arr);
        }

        //modifica el valor del elemento[0] en el array a
        function fnModify(a, b, value) {
            a[0].y = value;
            console.log("a[0]", a[0]);
            console.log("b[0]", b[0]);
        }

        var list = [{x:2, y:3}, {x:5, y:8}],
            clone = list.slice(0); //shallow copy

        //agregamos un elemento al final del array original
        list.push({x:13, y:21});

        console.log("\n> Shallow copy (slice)");
        console.log("original:", list); //3 elementos
        console.log("clonado:", clone); //2 elementos

        ///modificamos el valor del item[0] en el array original
        fnModify(list, clone, "0");

        //clonamos con deep copy
        var clone1 = fnCloneJSON(list),
            clone2 = fnClonejQuery(list);

        //modificamos el valor del item[0] en el array original
        console.log("\n> Deep copy (JSON)");
        fnModify(list, clone1, 777);

        console.log("\n> Deep copy (jQuery)");
        fnModify(list, clone2, 999);

    })(jQuery);

Clonando DOM Nodes

Para clonar nodos DOM (elementos HTML) se debe tener en cuenta que algunas de las propiedades del nodo hacen referencia hacia sí mismo. Veamos el siguiente ejemplo para dar mayor claridad: tenemos el siguiente elemento HTML <p id="test">texto</p>

    (function() {
        var p1 = document.getElementById("test"),
            p2 = p1, //p2 referencia a p1
            p3, doc;

        p2.innerHTML = "texto modificado";
        //el texto en la variable p1 también fue modificado

        doc = p1.ownerDocument;
        //referencia al documento que contiene el elemento p1

        p3 = doc.getElementById("test");
        //nuevamente obtenemos la referencia hacia p1
    }());

En el ejemplo anterior tenemos una referencia circular cuando accedemos a la propiedad .ownerDocument, ya que ésta apunta al documento raíz del objeto referenciado por p1 y a su vez ownerDocument posee una referencia hacia el objeto p1

Clonar nodos DOM que poseen estructuras circulares no es posible con los métodos vistos anteriormente. Para ello podemos utilizar el método cloneNode() de JavaScript, o el método clone() de jQuery.

    (function ($) {
        var p1 = document.getElementById("test"),
            p2 = p1.cloneNode(true); //true: deep copy, false: shallow copy

        var p3 = $("#test"),
            p4 = p3.clone(); //default: deep copy
    }(jQuery));

Nota: Los elementos clonados crean un duplicado del nodo, incluyendo todos los atributos y sus valores (el atributo id debería ser modificado antes de insertar el nodo en el documento). El elemento duplicado no tiene padre (.parentNode = null) hasta que sea agregado en el documento, bien sea usando appendChild(), append() o alguno de los mecanismos para la inserción de nodos.

Métodos jQuery para la inserción de nodos:

Add elements using jQuery
DOM Insertion, Outside
DOM Insertion, Inside

Clonando objetos complejos

Clonar objetos que poseen estructuras circulares es una tarea que requiere un poco más de esfuerzo; en la sección anterior se hizo énfasis en la clonación de nodos DOM, pero ¿que pasa si el objeto que queremos clonar no es un nodo DOM, sino un objeto plano con propiedades que se referencian entre sí?… veamos el siguiente caso:

    var freeman = {
        name: "Gordon Freeman",
        character: "Freeman",
        game: "Half-Life",
        friends: []
    };

    var david = {
        name: "David Rivera",
        character: "jherax",
        friends: []
    };

    freeman.friends = [david, "Barney Calhoun"];
    david.friends = [freeman, "John Carmack"];

Acá tenemos dos objetos cuya propiedad friends contiene la lista de amigos de una persona. Podemos observar que el objeto freeman tiene como amigo a david, y a su vez david tiene como amigo a freeman, lo cual significa que la propiedad friends posee referencias cruzadas entre los objetos david y freeman. Por lo tanto podemos decir que ambos objetos tienen una estructura circular.

Como en los casos anteriores no podíamos clonar objetos con estructuras circulares, vamos a idear una forma de crear una función genérica que clone diferentes tipos de objetos; nuevamente les recuerdo que si tenemos un requerimiento específico, como clonar “instancias” de “clases propias” preservando prototipos, herencia, etc, la mejor solución es desarrollar un clonador que responda a esas necesidades concretas.

Creando el clonador

  1. Será una función recursiva que retorna una copia (deep copy) del objeto que recibe como primer argumento.
  2. Si es un objeto primitivo, retornamos su valor (creamos una copia)
  3. Si el objeto es una instancia de alguno de los prototipos:   Date, RegExp, Function, String, Number, Boolean, entonces creamos una nueva instancia a partir de su constructor.
  4. Iteramos recursivamente cada una de las propiedades navegables del objeto (no se recomienda utilizar arguments.callee para tener acceso a la función en ejecución, ya que no es admitido por ECMAScript 5 en strict mode, ver nota — MDN)
    /* Retorna deep copy del objeto @from */
    function fnClone (from) {
        var prop, clone;

        // determina si @from es un valor primitivo o una función
        if (from == null || typeof from != "object") return from;

        // determina si @from es una instancia de alguno de los siguientes prototipos
        if (from.constructor == Date || from.constructor == RegExp || from.constructor == Function ||
            from.constructor == String || from.constructor == Number || from.constructor == Boolean) {
            return new from.constructor(from);
        }

        // si el constructor del objeto no es ninguno de los anteriores
        if (from.constructor != Object && from.constructor != Array) return from;

        // itera recursivamente las propiedades del objeto
        clone = new from.constructor();
        for (prop in from) {
            //no se recomienda arguments.callee
            //clone[prop] = arguments.callee(from[prop]);
            clone[prop] = fnClone(from[prop]);
        }
        return clone;
    }
Ahora podemos hacer algo más interesante, modifiquemos nuestro clonador para que pueda extender las propiedades de un objeto dentro de otro, de tal manera que si pasamos sólo un argumento, retornará el objeto clonado, pero si pasamos dos argumentos, extenderá las propiedades del primer objeto dentro del segundo.
    // Extiende las propiedades del objeto @from en el objeto @to
    // Si no se proporciona el objeto @to, entonces retorna deep copy de @from
    function fnClone (from, to) {
        var prop;

        // determina si @from es un valor primitivo o una función
        if (from == null || typeof from != "object") return from;

        // determina si @from es una instancia de alguno de los siguientes prototipos
        if (from.constructor == Date || from.constructor == RegExp || from.constructor == Function ||
            from.constructor == String || from.constructor == Number || from.constructor == Boolean) {
            return new from.constructor(from);
        }

        // si el constructor del objeto no es ninguno de los anteriores
        if (from.constructor != Object && from.constructor != Array) return from;

        // itera recursivamente las propiedades del objeto
        to = to || new from.constructor();
        for (prop in from) {
            to[prop] = typeof to[prop] == "undefined" ? fnClone(from[prop], null) : to[prop];
        }
        return to;
    }

Nota: Como se mencionó anteriormente, ésta es una función genérica que clona efectivamente algunos objetos, no se tiene en cuenta la cadena de prototipos, la herencia, o el estado interno si lo posee.

Ahora bien, nuestro clonador crea una copia nueva del objeto enviado a la función; podemos clonar valores primitivos, Object literal, Array, Function, Date, RegExp, y otros más. Hasta ahora, nuestro clonador es muy similar a otras soluciones que pueden encontrar en la web, sin embargo vamos a darle un valor agregado, y es que sea capaz de clonar objetos con estructuras circulares.

Objetos con estructura circular

Veamos el siguiente caso usando nuestra función fnClone:

    var freeman = {
        name: "Gordon Freeman",
        friends: ["Barney Calhoun"]
    };

    var david = {
        name: "David Rivera",
        friends: ["John Carmack"]
    };

    //clonamos el objeto @david
    var cloned = fnClone(david);

    //observamos los objetos
    console.log("david:", david);
    console.log("cloned:", cloned);

    //hasta el momento todo va perfecto,
    //ahora creamos la referencia circular
    freeman.friends.push(david);
    david.friends.push(freeman);

    //observamos los objetos
    console.log("david:", david);
    console.log("cloned:", cloned);

    //tratamos de clonar un objeto con estructura circular,
    //sin embargo obtenemos la siguiente excepción:
    //RangeError: Maximum call stack size exceeded
    cloned = fnClone(freeman);

En el ejemplo anterior, la función fnClone arroja la excepción "RangeError: Maximum call stack size exceeded" al momento de clonar un objeto con estructura circular.

Lo que este mensaje dice, es que hay una iteración infinita y se alcanzó el máximo de llamados en el stack para la función recursiva cuando intentaba crear el objeto de la propiedad friends. Para entender mejor lo que estoy diciendo, me gustaría que exploren el objeto david en la consola del navegador, verán algo similar a esto:

estructura circular

¿Y cómo clonar objetos con estructura circular?

Una forma de solucionar este problema, es mantener las referencias de los objetos creados, de tal manera que si el objeto ya se creó, entonces pasamos la referencia de ese objeto y así evitamos que se cree infinitamente. Para ello vamos a crear un array como propiedad de la función, para que guarde las referencias de los Object que posee el elemento que clonaremos. Añadimos el siguiente snippet a la función:

    //...
    // si @from es un Object
    if (Object.prototype.toString.call(from) == "[object Object]") {

        // creamos la propiedad [objects] en la función
        fnClone.objects = fnClone.objects || [];

        // verificamos si @from hace referencia a un objeto ya creado
        if (fnClone.objects.filter(function(item) {
            return item === from;
        }).length) return from;

        // guarda la referencia de los objetos creados
        fnClone.objects.push(from);
    }
    //...
Así quedaría la función, integrando el snippet anterior.
    // Extiende las propiedades del objeto @from en el objeto @to
    // Si no se proporciona el objeto @to, entonces retorna deep copy de @from
    function fnClone (from, to) {
        var prop;

        // si @from es un Object
        if (Object.prototype.toString.call(from) == "[object Object]") {

            // creamos la propiedad [objects] en la función
            fnClone.objects = fnClone.objects || [];

            // verificamos si @from hace referencia a un objeto ya creado
            if (fnClone.objects.filter(function(item) {
                return item === from;
            }).length) return from;

            // guarda la referencia de los objetos creados
            fnClone.objects.push(from);
        }

        // determina si @from es un valor primitivo o una función
        if (from == null || typeof from != "object") return from;

        // determina si @from es una instancia de alguno de los siguientes prototipos
        if (from.constructor == Date || from.constructor == RegExp || from.constructor == Function ||
            from.constructor == String || from.constructor == Number || from.constructor == Boolean) {
            return new from.constructor(from);
        }

        // si el constructor del objeto no es ninguno de los anteriores
        if (from.constructor != Object && from.constructor != Array) return from;

        // itera recursivamente las propiedades del objeto
        to = to || new from.constructor();
        for (prop in from) {
            to[prop] = typeof to[prop] == "undefined" ? fnClone(from[prop], null) : to[prop];
        }
        return to;
    }

Buenas prácticas

Nuestro clonador está terminado, sin embargo existe un “problema” que no salta a la vista, y es que la propiedad fnClone.objects irá guardando los objetos creados con cada llamado a la función, lo que incrementará el tamaño del array, y además esa propiedad es pública, pero por la naturaleza de su misión debería ser privada. Para dar solución a este problema emplearemos el concepto de closure (encapsulamiento) en el cual crearemos el entorno de ejecución proveyendo un estado interno privado para los objetos (variables libres).

/**
 * Clona un objeto (deep-copy)
 * @param  {Any}    from: el objeto a clonar
 * @param  {Object} dest: (opcional) objeto a extender
 * @return {Any} retorna el nuevo objeto clonado
 */
var fnClone = (function() {
  // @Private
  var _toString = Object.prototype.toString;

  // @Private
  function _clone (from, dest, objectsCache) {
    var prop;
    // determina si @from es un valor primitivo o una funcion
    if (from === null || typeof from !== "object") return from;
    // revisa si @from es un objeto ya guardado en cache
    if (_toString.call(from) === "[object Object]") {
      if (objectsCache.filter(function (item) {
        return item === from;
      }).length) return from;
      // guarda la referencia de los objetos creados
      objectsCache.push(from);
    }
    // determina si @from es una instancia de alguno de los siguientes constructores
    if (from.constructor === Date || from.constructor === RegExp || from.constructor === Function ||
      from.constructor === String || from.constructor === Number || from.constructor === Boolean) {
      return new from.constructor(from);
    }
    if (from.constructor !== Object && from.constructor !== Array) return from;
    // crea un nuevo objeto y recursivamente itera sus propiedades
    dest = dest || new from.constructor();
    for (prop in from) {
      // TODO: allow overwrite existing properties
      dest[prop] = (typeof dest[prop] === "undefined" ?
          _clone(from[prop], null, objectsCache) :
          dest[prop]);
    }
    return dest;
  }

  // función retornada en el closure
  return function (from, dest) {
    var objectsCache = [];
    return _clone(from, dest, objectsCache);
  };

}());
Ahora utilizemos nuestro super clonador fnClone
    var freeman, david;

    function Freeman() {
        this.name = "Gordon Freeman";
        this.character = "Freeman";
        this.game = "Half-Life";
        this.friends = [];
    }

    david = {
        name: "David Rivera",
        character: "jherax",
        friends: [],
        languages: new RegExp(/javascript|jquery|c#|sql|java|vb/i),
        greeting: function() { return "Hi, I am " + this.name },
        info: {
            job: "programmer",
            birth: new Date()
        }
    };

    freeman = new Freeman();

    //creamos la referencia circular
    freeman.friends = [david, "Barney Calhoun"];
    david.friends = [freeman, "John Carmack"];

    //clonamos el objeto @david
    var cloned = fnClone(david);

    //modificamos propiedades del objeto original
    david.name = david.name + " (jherax)";
    david.friends.push("Jim Rynor");

    //vemos que @cloned no fue modificado
    console.log("original:", david.name, david.friends);
    console.log("clonado:", cloned.name, cloned.friends);

Conclusión

Es importante conocer la forma en que JavaScript le da manejo a los valores de las variables, teniendo en cuenta que para los valores primitivos se copia su contenido, y para los objetos, se crea un apuntador a su referencia; entender que las funciones son tratadas como propiedades de un objeto en el contexto de ejecución, es crucial para comprender algunos patrones de diseño y el funcionamiento de los métodos call(), apply(), bind()

La intención de éste artículo fue demostrar la importancia que tiene la clonación de objetos para mejorar la calidad de nuestro código, evitando errores comunes como el hecho de que una función pueda modificar el objeto original que fue pasado como argumento.

Recomiendo que lean los artículos “Deep copying of Objects and Arrays” de James Padolsey, “Deep copy in JavaScript” de Oran Looney y Lazy Function Definition Pattern por David Rivera.

Happy coding!

10 thoughts on “Clonando objetos en JavaScript

  1. Francamente, muy didáctico. Es muy fácil cometer errores pensando que estamos haciendo lo correcto pero cuando analizamos en profundidad el problema vemos lo equivocados que estamos.
    Muy brillante tu artículo.

  2. Martín Alejandro says:

    Articulos como este deberiamos hacer todos, muchas felicidades, me encanto leer tu articulo, muy bien explicado, y muy buenos ejemplos. Mis admiraciones, sigue así!!

    • Gracias Martín. Este tipo de artículos explicando utilerías me ha gustado, escribiré otros del mismo estilo.

  3. Se corrigió un bug que surgió en la pasada edición (fnExtend: lazy-function). El articulo fue re-escrito en algunas secciones, y se agregaron nuevos segmentos para aclarar algunos temas.

  4. Mi mas sincera enhorabuena…
    Excepcional nivel de JavaScript…
    Da gusto leer determinados fragmentos de código.
    Todo un recital!

Comentarios

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s