Artículos de Tecnología > Programación

Vamos a implementar una función de clonación profunda con inmutabilidad en JS

Caique Moraes
Caique Moraes

Este artículo fue escrito por el estudiante Caique Moraes.

Hace un tiempo, estuve entusiasmado estudiando sobre el paradigma funcional, ampliamente utilizado en frameworks como React. Uno de sus principios, la inmutabilidad, sostiene que ningún dato/estado debe ser modificado, sino evolucionado y transformado. Este principio garantiza la consistencia de una información, la cual puede ser accedida y leída desde varios puntos de un software, pero ninguno de estos puntos puede modificarla, causando una violación. Solo un único punto aislado puede evolucionar esta información y ponerla a disposición del resto del software para consumirla.

Esto despertó en mí una gran curiosidad sobre cómo construir una información inmutable, y aunque fuera replicada, sus clones no podrían impactar en la información original, ya que internamente estos clones corresponden a nuevas direcciones de memoria.

Existen bibliotecas como lodash, que tienen funciones capaces de crear clones en profundidad de estructuras complejas, pero para mí no era suficiente agregar una biblioteca que hiciera eso en mis proyectos. Lo que yo quería era ver lo que realmente sucedía detrás de escena dentro de una función capaz de clonar grandes estructuras de datos. El gran físico Richard Feynman decía: "Aquello que no puedo crear, no puedo entender".

Esta búsqueda me llevó a algunos conceptos clave que nos llevarán a un nuevo nivel como programadores. Estos conceptos se presentarán a lo largo de este artículo: cómo JavaScript almacena datos en la memoria y cómo construir una función capaz de identificar el tipo de un dato; y por último, construiremos una función utilizando JavaScript puro capaz de producir clones inmutables.

¡Vamos a los primeros conceptos clave!

Para empezar: hay varias formas de clonar una estructura de datos como arrays y objetos. Sin embargo, hay dos tipos de clonajes:

  1. Un clon superficial, también conocido como clon superficial (Shallow Clone), que copia solo la superficie de una estructura objetivo a otra dirección de memoria y mantiene sus propiedades apuntando a la misma dirección de memoria de la estructura original. Cualquier cambio en las propiedades internas de un clon superficial afectará a la estructura original y viceversa.
  2. También tenemos la clonación en profundidad, llamada clonación profunda (Deep Clone), que realiza una copia de la estructura objetivo y todas sus propiedades internas en nuevas direcciones de memoria. Es decir, el clon en profundidad genera una nueva estructura idéntica y desconectada de la estructura original, donde cualquier cambio en esta estructura no se reflejará en la estructura original.

Cada una de estas técnicas de clonación "brilla" en diferentes contextos. Cuando trabajamos con estructuras compuestas por tipos primitivos, no es necesario crear mecanismos complejos y costosos para la clonación, podemos seguir con la técnica de clonación superficial. Sin embargo, al trabajar con estructuras de datos complejas y anidadas, la clonación en profundidad es la mejor alternativa.

A lo largo de este artículo se presentarán las motivaciones para utilizar la técnica de clonación en profundidad y, como bonificación, implementaremos una técnica de inmutabilidad para garantizar que nuestros datos no puedan ser modificados. ¡Así que vamos allá!

Cómo se almacenan en memoria los tipos de datos de JavaScript

Sabemos que JavaScript tiene dos grupos de tipos de datos: los tipos primitivos y los tipos no primitivos, compuestos por Boolean, Null, Undefined, BigInt, String, Number y Symbol.

Los tipos primitivos son inmutables por naturaleza. Al realizar cualquier cambio en un tipo primitivo, el propio intérprete de JavaScript, en tiempo de ejecución, se encargará de asignar una nueva dirección de memoria para el resultado transformado:

let num1 = 0 
let num2 = num1 
num2++

console.log(num1) // 0 
console.log(num2) // 1

Cuando se creó la variable num1, el intérprete de JavaScript creó un identificador único para ella. Asignó una dirección en memoria (por ejemplo, EJ0001) y almacenó el valor "1" en la dirección asignada.

Cuando definimos que num2 es igual a num1, lo que JavaScript hizo en segundo plano fue:

  1. Crear un identificador único para la variable num2.
  2. Apuntar el identificador a la misma dirección de memoria que la variable num1 (por ejemplo, EJ0001).

Pero en la línea 3, cuando incrementamos el valor de la variable num2, JavaScript asignó una nueva unidad de memoria (por ejemplo: XJ0501), almacenó el valor de la expresión "num2++" y apuntó el identificador de la variable num2 a esta nueva dirección de memoria. Por lo tanto, tendremos dos variables apuntando a dos direcciones de memoria distintas.

Este comportamiento es diferente cuando se trabaja con objetos y arrays, que son considerados tipos no primitivos. Estas estructuras de datos se almacenan en otra región dentro de la arquitectura del lenguaje. Esta región se llama Heap y es capaz de almacenar datos no ordenados que pueden crecer y disminuir dinámicamente, como objetos y arrays.

Cuando declaramos una variable "person" y le asignamos un tipo no primitivo como un objeto vacío:

const person = {}

Esto es lo que sucede en segundo plano:

  1. Se crea un identificador con el nombre "person".
  2. Se asigna una dirección de memoria en tiempo de ejecución en la Stack (por ejemplo, CM9323).
  3. Se almacena en esta dirección creada en la Stack una referencia a una dirección de memoria asignada en la Heap.
  4. La dirección de memoria en la Heap almacenará el valor que se asignó a "person", en este caso, un objeto vacío.

A partir de este punto, cualquier cambio que hagamos en el objeto person ocurrirá en la Heap y todas las variables que apunten a la misma dirección de memoria de person se verán afectadas por el cambio.

const person = {} 
const clonedPerson = person 
person.name = 'John'

console.log(person.name) // 'John' 
console.log(person.clonedPerson) // 'John'

Este es el comportamiento nativo de JavaScript al manejar la memoria. ¿Pero por qué se comporta así? Precisamente para ahorrar memoria y obtener rendimiento.

Imagina un escenario hipotético en el que tenemos un simple array con 1 millón de elementos. Si copiáramos este array 10 veces, tendríamos 10 arrays independientes con 1 millón de elementos cada uno, lo que sumaría un total de 10 millones de elementos. De los cuales 9 millones son copias idénticas del primer array.

Sin embargo, en ciertos casos, especialmente cuando trabajamos siguiendo el principio de inmutabilidad, uno de los pilares del paradigma funcional, queremos tener un comportamiento diferente. Nos gustaría copiar todos los valores que se encuentran en las direcciones de memoria a nuevas direcciones de memoria. Esta práctica nos libera del efecto secundario presente en la concurrencia de acceso a los datos: si dos lugares distintos de la aplicación compiten para acceder y modificar un mismo recurso en el mismo instante de tiempo, uno de los lugares que espera recibir una "pelota" puede obtener un "cuadrado" debido a la actualización que hizo el otro lugar anteriormente.

Construyendo una función de comprobación de tipos

Para iniciar nuestro ejemplo, desarrollaremos una función capaz de verificar el tipo de cualquier variable que se le pase y devolver su tipo en formato de cadena en minúsculas.

Esta función nos ayudará a probar el tipo de nuestras estructuras de arrays y objetos, además de enriquecer tu conjunto de habilidades como programador:

const typeCheck = (valor) => { 
    const typeString = Reflect.apply(Object.prototype.toString, valor, []) 
    return typeString.slice( 
        typeString.indexOf(' ') + 1, 
        typeString.indexOf(']') 
    ).toLowerCase() 
}

Básicamente, nuestra función typeCheck recibe un valor y, sobre ese valor, ejecutamos el método toString presente en el prototipo de los objetos con la ayuda de la API Reflect, que nos asegura la ejecución adecuada de toString. Su resultado será una cadena entre corchetes en la siguiente representación: [object String]. Por último, utilizando las funciones slice, indexOf y toLowerCase presentes en las cadenas, podemos manipular nuestro resultado y devolver una cadena que representa el valor pasado como parámetro en nuestra función.

Normalmente, alguien me preguntaría:

El typeof no puede diferenciar entre un null y un objeto.

console.log(typeof null === typeof {}) // true

El resultado de nuestra función para diferentes tipos:

console.log(typeCheck([])) // array 
console.log(typeCheck(null)) // null 
console.log(typeCheck({})) // object 
console.log(typeCheck('teste')) // string 
console.log(typeCheck(123)) // number

Construyendo una función de clonación profunda de arrays

Para construir una función que clone en profundidad una estructura de array, necesitamos adentrarnos en cada posición y comprobarla. Si cada posición del array es otro array, nos adentramos en él y lo comprobamos. Y así sucesivamente, de forma recursiva, hasta que se cumpla la condición de parada, que en nuestro caso es cualquier valor distinto de un array.

const cloneArray = (element) => { 
    const clonedArray = [] 
    for (const item of element) { 
        if (typeCheck(item) === 'array') clonedArray.push(cloneArray(item)) 
        else clonedArray.push(item) 
    } 
    return clonedArray 
}

El resultado es una función elegante, estructurada e imperativa.

Vamos a hacer un ajuste simple para hacerla aún más concisa y declarativa, aprovechando la magia de la programación funcional:

const cloneArray = (element) => { 
    if (typeCheck(element) !== 'array') return element 
    return element.map(cloneArray) 
}

Probando y comparando el resultado de nuestra función cloneArray. Observa que a continuación tenemos 2 ejemplos, en el primero asignamos numbersCopy a la misma dirección de memoria que numbers. Por lo tanto, la salida en la línea 3 es "true". Pero en la línea 4, nuestra función entra en acción y clona el array numbers a otra posición en la memoria, por eso el resultado es "false".

const numbers = [1, 2, 3] 
const numbersCopy = numbers 
console.log(numbers === numbersCopy) // true 
console.log(numbers === cloneArray(numbers)) // false

Construyendo una función de clonación profunda de objetos

Siguiendo el mismo razonamiento que nuestra función cloneArray, vamos a construir cloneObject. Su objetivo será recorrer las propiedades de un objeto y copiarlas en un nuevo objeto. Para esto, utilizaremos nuevamente la técnica de recursividad, ya que no tenemos un límite predefinido de profundidad. Mientras haya una propiedad que sea de tipo "object", ingresaremos en ella y la recorreremos, devolviendo un nuevo objeto.

const cloneObject = (element) => {
  if (typeCheck(element) !== 'object') return element
  // implementación
}

El primer paso dentro de nuestra función cloneObject es verificar el tipo de dato recibido como argumento, en este caso la variable "element". Si el tipo de "element" es diferente de "object", se devuelve "element". De lo contrario, continuamos con la implementación.

A partir de este punto, en la línea 3, debemos devolver un nuevo "objeto" que contenga una copia de las propiedades de "element". Hay varias formas de realizar esta implementación, pero opté por un enfoque declarativo para profundizar un poco más en las maravillas del paradigma funcional.

El constructor de objetos "Object" tiene un método estático llamado "fromEntries". Este método devuelve un nuevo objeto a partir de una estructura de datos que se parece a una matriz bidimensional: ['clave', 'valor']]. Ejemplo:

console.log(Object.fromEntries([['nome', 'caique'], ['age', 27]]))
// { nome: 'caique', age: 27 }

A partir de este punto, podemos obtener todas las claves de las propiedades de "element" utilizando un array con el método "Object.keys", y luego mapear este array para obtener un nuevo array bidimensional. En cada posición de este array, el valor de la propiedad respectiva de "element" se pasa recursivamente a la función "cloneObject":

const cloneObject = (element) => {
  if (typeCheck(element) !== 'object') return element
  return Object.fromEntries(
    Object.keys(element).map(key =>
      [key, cloneObject(element[key])]
    )
  )
}

En la línea 4, obtenemos un array que contiene todas las claves de las propiedades del objeto "element". Luego, mediante el método "map", recorremos cada posición de este array y devolvemos un nuevo array donde el valor de la propiedad respectiva de "element" que se está iterando se pasa recursivamente a cloneObject. Si este valor no es de tipo "object", se devuelve tal cual. De lo contrario, se recorren sus propiedades y se las prueba.

Probando nuestra función, obtenemos el siguiente resultado al clonar un objeto:

const user = { name: 'caique', address: {country: 'Brazil', state: 'SP'} }
const clonedUser = user
console.log(user.address === clonedUser.address) // true
console.log(user.address === cloneObject(user).addres

Observa que en la línea 3 estamos comparando la propiedad "address", un objeto anidado dentro de "user", con la propiedad "address" de "clonedUser". Como ambos apuntan a la misma dirección de memoria, el resultado es "true". En la línea 4, ponemos en acción a cloneObject y realizamos la misma comparación, esta vez obtenemos "false", ya que el nuevo objeto generado con sus propiedades apunta a otra dirección de memoria.

Construyendo la función deepClone

Perfecto, ahora tenemos nuestras funciones para clonar arrays y objetos. Todo lo que necesitamos hacer es unirlas en nuestra orquesta. Para eso, crearemos una función que será responsable de decidir qué clonación se ejecutará según los datos proporcionados:

javascriptCopy codeconst deepClone = (element) => {
  switch (typeCheck(element)) {
    case 'array':
      return cloneArray(element)
    case 'object':
      return cloneObject(element)
    default:
      return element
  }
}

La función deepClone evaluará "element" y, si es un array, ejecutará cloneArray. Si es un objeto, ejecutará cloneObject. Si no cumple ninguna de estas condiciones, simplemente devolverá su valor original.

Ahora necesitamos realizar un ajuste en cada una de nuestras funciones de clonación para que llamen a deepClone de forma recursiva:

javascriptCopy codeconst cloneArray = (element) => {
  if (typeCheck(element) !== 'array') return element
  return element.map(deepClone)
}

En cloneArray, cambiamos la función de devolución de map por deepClone.

const cloneObject = (element) => {
  if (typeCheck(element) !== 'object') return element
  return Object.fromEntries(
    Object.keys(element).map((key) => [key, deepClone(element[key])])
  )
}

En cloneObject, cambiamos la línea 5, donde decía cloneObject, por deepClone.

Realizando una prueba final, tendremos una estructura de datos de tipo objeto con un array interno, ambos apuntando a diferentes direcciones de memoria en comparación con la dirección del objeto original "person".

const person = {
  name: 'caique',
  age: 27,
  hobbies: [
    'movie',
    'music',
    'books'
  ]
}

console.log(deepClone(person).hobbies === person.hobbies) // false
console.log(deepClone(person) === person) // false

Recapitulando lo que hemos hecho:

  1. Aprendimos cuáles son los tipos primitivos y no primitivos en JavaScript.
  2. Aprendimos cómo se asignan en memoria las variables primitivas y no primitivas.
  3. Implementamos una función capaz de identificar el tipo de dato pasado como argumento y devolverlo como una cadena de texto.
  4. Vimos dos tipos de paradigmas: funcional y estructurado, para implementar la función cloneArray.
  5. Creamos la función cloneObject y aprendimos cómo funciona el método Object.fromEntries.

Bonus: Haciendo nuestros datos inmutables

A partir de este punto, somos capaces de clonar cualquier estructura de objeto y array, pero aún así no podemos evitar el comportamiento natural de JavaScript, en el que si modificamos un dato dentro de un tipo no primitivo, ese cambio se refleja en todas las variables que apuntan a la misma referencia.

Nuestras estructuras clonadas son mutables:

const person = {
  name: 'caique',
  age: 27,
  hobbies: [
    'movie',
    'music',
    'books'
  ]
}
const clonedPerson = deepClone(person)
console.log(clonedPerson === person) // false
console.log(clonedPerson.name) // caique

const newClonedPerson = clonedPerson
newClonedPerson.name = 'thomas'

console.log(newClonedPerson.name) // thomas
console.log(clonedPerson.name) // thomas

Pero podemos resolver este efecto con una función simple:

const freeze = (data) => Object.freeze(data)

La función constructora Object en JavaScript proporciona un método estático llamado freeze que permite congelar objetos. Esta congelación impide cualquier cambio, inserción o eliminación de datos dentro de la estructura congelada. Sin embargo, esta congelación se realiza a nivel superficial.

Esto significa que la estructura interna de datos del objeto congelado no estará congelada y seguirá siendo susceptible a cambios. Para resolver esto, tendremos que recurrir a la recursión nuevamente; la buena noticia es que ya hemos preparado el terreno anteriormente. Haremos un cambio simple en nuestra función deepClone:

const deepClone = (element) => {
  switch (typeCheck(element)) {
    case 'array':
      return freeze(cloneArray(element))
    case 'object':
      return freeze(cloneObject(element))
    default:
      return element
  }
}

En las líneas 4 y 6, agregamos la llamada a la función freeze, que se llamará recursivamente a través de las llamadas a deepClone.

Nuestro resultado final para el ejemplo anterior será:

const person = {
  name: 'caique',
  age: 27,
  hobbies: ['movie', 'music', 'books'],
}

const clonedPerson = deepClone(person)
console.log(clonedPerson === person) // false
console.log(clonedPerson.name) // caique

const newClonedPerson = clonedPerson
newClonedPerson.name = 'thomas'

console.log(newClonedPerson.name) // caique
console.log(clonedPerson.name) // caique

Como se puede observar, las estructuras person y clonedPerson, aunque tienen los mismos valores, apuntan a direcciones de memoria diferentes. Y en cuanto a la estructura resultante (clonedPerson), si intentamos sobrescribir alguna de sus propiedades, el cambio no ocurrirá porque nuestra estructura es inmutable.

Conclusión

A lo largo de este artículo, hemos explorado un territorio que nos abre puertas a una serie de cuestiones sobre cómo funciona JavaScript debajo de la superficie. Estas preguntas son la brújula que apunta al norte, donde se encuentran los mejores programadores.

Además, aplicamos técnicas de programación funcional, mediante las cuales cambiamos una instrucción imperativa por un enfoque declarativo.

Conocimos una función capaz de devolver el tipo de cualquier dato que se le pase, typeCheck, y espero que la guardes con cariño y la uses de manera efectiva.

Y para concluir, aquí tienes un desafío. Al principio del artículo mencioné que es posible clonar estructuras de datos como arrays y objetos, y de manera intencionada omití otras estructuras nativas de JavaScript, como Sets, Maps, WeakMaps y WeakSets. ¿Cómo implementarías funciones de clonación para estas estructuras? Tu nueva misión, si decides aceptarla, es encontrar esas respuestas.

Hace un tiempo encontré esas respuestas y desarrollé una biblioteca especializada en la clonación de estructuras, llamada Ramda. Te recomiendo que la explores.

¡Buena suerte en tu viaje hacia el infinito y más allá!

Artículos de Tecnología > Programación

En Alura encontrarás variados cursos sobre Programación. ¡Comienza ahora!

Precios en:
USD
  • USD
  • BOB
  • CLP
  • COP
  • USD
  • PEN
  • MXN
  • UYU

Semestral

  • 274 cursos

    Cursos de Programación, Front End, Data Science, Innovación y Gestión.

  • Videos y actividades 100% en Español
  • Certificado de participación
  • Estudia las 24 horas, los 7 días de la semana
  • Foro y comunidad exclusiva para resolver tus dudas
  • Luri, la inteligencia artificial de Alura

    Luri es nuestra inteligencia artificial que resuelve dudas, da ejemplos prácticos y ayuda a profundizar aún más durante las clases. Puedes conversar con Luri hasta 100 mensajes por semana

  • Acceso a todo el contenido de la plataforma por 6 meses
US$ 65.90
un solo pago de US$ 65.90
¡QUIERO EMPEZAR A ESTUDIAR!

Paga en moneda local en los siguientes países

Anual

  • 274 cursos

    Cursos de Programación, Front End, Data Science, Innovación y Gestión.

  • Videos y actividades 100% en Español
  • Certificado de participación
  • Estudia las 24 horas, los 7 días de la semana
  • Foro y comunidad exclusiva para resolver tus dudas
  • Luri, la inteligencia artificial de Alura

    Luri es nuestra inteligencia artificial que resuelve dudas, da ejemplos prácticos y ayuda a profundizar aún más durante las clases. Puedes conversar con Luri hasta 100 mensajes por semana

  • Acceso a todo el contenido de la plataforma por 12 meses
US$ 99.90
un solo pago de US$ 99.90
¡QUIERO EMPEZAR A ESTUDIAR!

Paga en moneda local en los siguientes países

Acceso a todos
los cursos

Estudia las 24 horas,
dónde y cuándo quieras

Nuevos cursos
cada semana