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:
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á!
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:
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:
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.
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
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
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.
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:
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.
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á!
Cursos de Programación, Front End, Data Science, Innovación y Gestión.
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
Paga en moneda local en los siguientes países
Cursos de Programación, Front End, Data Science, Innovación y Gestión.
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
Paga en moneda local en los siguientes países
Puedes realizar el pago de tus planes en moneda local en los siguientes países:
País | |||||||
---|---|---|---|---|---|---|---|
Plan Semestral |
487.37
BOB |
69289.36
CLP |
307472.10
COP |
65.90
USD |
264.35
PEN |
1435.53
MXN |
2978.57
UYU |
Plan Anual |
738.82
BOB |
105038.04
CLP |
466107.17
COP |
99.90
USD |
400.74
PEN |
2176.17
MXN |
4515.32
UYU |
Acceso a todos
los cursos
Estudia las 24 horas,
dónde y cuándo quieras
Nuevos cursos
cada semana