Vamos implementar uma função de clonagem profunda com imutabilidade em JS?

Vamos implementar uma função de clonagem profunda com imutabilidade em JS?
Caique Moraes
Caique Moraes

Compartilhe

Introdução

Este artigo foi escrito pelo aluno Caique Moraes.

Há um tempo, estive entusiasmado estudando sobre o paradigma funcional, fortemente utilizado em frameworks como React. Um de seus princípios, a imutabilidade, prega que nenhum dado/estado deve ser alterado, e sim evoluído e transformado. Este princípio garante a consistência de uma informação, que pode ser acessada e lida por diversos pontos de um software, mas que nenhum destes pontos podem alterá-la, causando uma violação. Somente um único ponto isolado pode evoluir essa informação e disponibilizá-la para o restante do software consumir.

Isso despertou, em mim, uma imensa curiosidade sobre como construir uma informação imutável, e mesmo se fosse replicada, seus clones não poderiam causar impacto na informação original, pois estes clones, internamente, correspondem a novos endereços de memória.

Existem bibliotecas como lodash, que possuem funções capazes de criar clones em profundidades de estruturas complexas mas, para mim, não bastava adicionar em meus projetos uma biblioteca que fizesse isso. O que eu queria era ver o que de fato acontecia debaixo dos panos dentro de uma função capaz de clonar grandes estruturas de dados. O grande físico Richard Feynman dizia: "Aquilo que eu não posso criar, não consigo entender".

Essa busca me levou há alguns conceitos-chave que nos levarão a um novo patamar como programadores. Tais conceitos serão apresentados ao longo deste artigo: como o Javascript armazena dados em memória, e como construir uma função capaz de identificar o tipo de um dado; e por fim construiremos uma função utilizando Javascript puro, capaz de produzir clones imutáveis.

Vamos aos primeiros conceitos-chave?!

Pra começar: existem diversas maneiras de se clonar uma estrutura de dados como arrays e objetos. Entretanto, há dois tipos de clonagens:

  1. Uma clonagem superficial, também chamada de Shallow Clone, que copia somente a superfície de uma estrutura alvo para um outro endereço de memória, e mantém suas propriedades apontando para o mesmo endereço de memória da estrutura original. Qualquer alteração nas propriedades internas de um Shallow Clone afetará a estrutura original e vice-versa.
  2. Temos também a clonagem profunda chamada Deep Clone, que realiza uma cópia da estrutura alvo e todas as suas propriedades internas para novos endereços de memória. Isto é, o Deep Clone gera uma nova estrutura idêntica e desconectada da estrutura original, em que qualquer alteração nesta estrutura não refletirá na estrutura original.

Cada uma destas técnicas de clonagem “brilha” em contextos diferentes. Quando trabalhamos com estruturas constituídas de tipos primitivos, não há necessidade de criarmos mecanismos complexos e custosos para clonagem, podemos seguir com a técnica de Shallow Clone. Porém, ao trabalharmos com estruturas de dados complexas e aninhadas, o Deep Clone é a melhor alternativa.

Ao longo deste artigo serão apresentadas as motivações para utilizar a técnica de Deep Clone, e como bônus vamos implementar uma técnica de imutabilidade para garantirmos que nossos dados não poderão ser alterados. Então, bora lá!

Banner promocional da Alura, com um design futurista em tons de azul, apresentando dois blocos de texto, no qual o bloco esquerdo tem os dizeres:

Como os tipos de dados do Javascript são armazenados em memória

Sabemos que o Javascript possui dois grupos de tipos de dados: os tipos primitivos e os tipos não primitivos, compostos por: Boolean, Null, Undefined, BigInt, String, Number e Symbol.

Os tipos primitivos são imutáveis por natureza. Ao realizar qualquer alteração em um tipo primitivo, o próprio interpretador do Javascript, em tempo de execução, se encarregará de alocar um novo endereço de memória para o resultado transformado:

let num1 = 0
let num2 = num1
num2++

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

Quando a variável num1 foi criada, o interpretador do Javascript criou um identificador único para ela. Alocou um endereço na memória (por exemplo, EJ0001), e armazenou o valor "1" no endereço alocado.

Representação da variável num1 apontando para o endereço de memória EJ0001.

Quando definimos que num2 é igual a num1, o que o Javascript fez por debaixo dos panos foi:

  1. Definiu um identificador único para a variável: num2.
  2. Apontou o identificador para o mesmo endereço de memória da variável num1 (ex: EJ0001).
Representação das variáveis num1 e num2 apontando para o endereço de memória EJ0001.

Mas na linha 3, no momento em que incrementamos o valor da variável num2, o Javascript alocou uma nova unidade de memória (por exemplo: XJ0501) , armazenou o valor da expressão: "num2++" e apontou o identificador da variável num2 para este novo endereço de memória. Portanto, teremos duas variáveis apontando para dois endereços de memória distintos.

Representação das variáveis num1 e num2 apontando cada uma para um endereço de memória distinto.

Este comportamento é diferente se for realizado com objetos e arrays, que são considerados tipos não primitivos. Estas estruturas de dados são armazenadas em outra região dentro da arquitetura da linguagem. Esta região chama-se Heap, e ela é capaz de armazenar dados não ordenados que podem crescer e diminuir dinamicamente como objetos e arrays.

Quando declaramos uma variável "person", e atribuímos a ela um tipo não primitivo como um objeto vazio:

const person = {}

É isso que acontece por debaixo dos panos:

  1. Um identificador é criado com o nome "person".
  2. Um endereço de memória é alocado em tempo de execução na Stack (ex: CM9323).
  3. É armazenado neste endereço criado na Stack, uma referência para um endereço de memória alocado na Heap.
  4. O endereço de memória na Heap armazenará o valor que foi atribuído a "person", neste caso um objeto vazio.

A partir deste ponto, qualquer alteração que fizermos no objeto person acontecerá na Heap, e todas as variáveis que apontam para o mesmo endereço de memória de person, serão afetadas pela mudança.

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

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

Este é o comportamento nativo do Javascript na hora de lidar com a memória. Mas por que ele se comporta desse jeito? Justamente para economizar memória e ganharmos performance.

Imagine um cenário hipotético onde temos um simples array com 1 milhão de itens. Se copiarmos este array 10 vezes, teremos 10 arrays distintamente desconectados com 1 milhão de itens cada, totalizando em 10 milhões de itens. Sendo que 9 milhões destes, são cópias idênticas do primeiro array.

Mas, em certos casos, principalmente quando trabalhamos seguindo o princípio da imutabilidade, um dos pilares do paradigma funcional, queremos ter um comportamento diferente. Gostaríamos de copiar todos os valores que estão contidos dentro de endereços de memória, para novos endereços de memória. Esta prática nos livra do efeito colateral presente na concorrência de acesso a dados: Se dois locais distintos da aplicação concorrem para acessarem e subscreverem um mesmo recurso, no mesmo instante de tempo, um dos locais que espera receber uma "bola", pode obter um "quadrado" devido a atualização que o outro local fez anteriormente.

Construindo uma função de checagem de tipos

Para iniciarmos nosso exemplo, desenvolveremos uma função capaz de checar o tipo de qualquer variável que passamos para ela e nos retornar seu tipo em formato de string em caixa baixa.

Essa função nos auxiliará a testar o tipo de nossas estruturas arrays e objetos, além de enriquecer seu repertório de programador:

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

Basicamente nossa função typeCheck recebe um valor, em cima deste valor executamos a chamada do método toString, presente no prototype dos objetos com auxílio da API Reflect, que nos assegura a devida execução de toString. O seu retorno será uma string envolvida por colchetes como a seguinte representação: [object String]. Por fim, com auxílio das funções slice, indexOf e toLowerCase presentes nas strings, podemos manipular nosso resultado e devolver uma string que representa o valor passado como parâmetro em nossa função.

Normalmente alguém me perguntaria:

  • Por que você não usa o operador typeof do próprio Javascript ao invés de criar uma função como essa?

O typeof não sabe diferenciar um null de um object.

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

O resultado de nossa função 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    

Construindo uma função de clonagem profunda de arrays

Para construirmos uma função que clona profundamente uma estrutura de array, precisamos adentrar em cada posição e testá-la. Se cada posição do array for um array, adentre neste e teste-o. E assim recursivamente até chegar na condição de parada, em nosso caso, qualquer valor diferente de um 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
} 

O resultado é uma função bonita, estruturada e imperativa.

Vamos fazer um ajuste simples, torná-la ainda mais enxuta e declarativa, nos beneficiando da magia da programação funcional:

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

Testando e comparando o resultado de nossa função cloneArray. Observe que abaixo temos 2 exemplos, no primeiro apontamos numbersCopy para o mesmo endereço de memória de numbers. Portanto a saída no console na linha 3 é "true". Mas na linha 4 nossa função entra em ação, clonando o array numbers para outra posição na memória, por isso o resultado é "false".

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

Construindo uma função de clonagem profunda de objetos

Seguindo o mesmo raciocínio de nossa função cloneArray, vamos construir a cloneObject. O seu objetivo será percorrer as propriedades de um objeto e copiá-las para um novo objeto. Para isso vamos utilizar novamente da técnica de recursividade, pois não temos um limite pré-definido de profundidade, enquanto houver uma propriedade que seja do tipo "object", entre nela e percorra-a também retornando um novo objeto.

const cloneObject = (element) => {
  if (typeCheck(element) !== 'object') return element
  // implementação
}

Primeiro passo, dentro de nossa função cloneObject, devemos testar o tipo de dado recebido por argumento, no caso a variável "element". Se o tipo de element for diferente de "object", return element. Senão, prossiga para o restante da implementação.

A partir desta etapa, na linha 3 precisamos retornar um novo "objeto" que conterá uma cópia das propriedades de "element". Há diversas formas de realizar essa implementação, mas optei pela abordagem declarativa para nos aprofundarmos um pouco mais nas maravilhas do universo do paradigma funcional.

A função construtora de objetos “Object” possui um método estático chamado fromEntries. Este método retorna um novo objeto a partir de uma estrutura de dados que se assemelha a um array bidimensional: ['chave', 'valor'] ]. Ex.:

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

Partindo deste ponto, podemos obter todas as chaves de propriedades de element por meio de um array, com o método "Object.keys" e em cima deste array podemos mapear um novo array bidimensional, onde cada valor de propriedade será passada recursivamente para a função "cloneObject":

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

Na linha 4 obteremos um array contendo todas as chaves das propriedades do objeto "element". Por meio do método "map", vamos percorrer cada posição deste array retornando um novo array onde o valor da respectiva propriedade de "element" que está sendo iterada, será passado para dentro de cloneObject recursivamente. Se este valor não for do tipo "object", retorna-o. Senão, intere por suas propriedades e teste-as.

Testando nossa função, obteremos o seguinte resultado ao clonar um 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).address) // false

Observe que na linha 3 estamos comparando a propriedade address, um objeto aninhado dentro de user, com a propriedade address de clonedUser. Como ambos apontam para o mesmo endereço de memória, o resultado é "true". Na linha 4, colocamos cloneObject em ação e fizemos a mesma comparação, desta vez obtivemos "false", pois o novo objeto gerado com suas propriedades estão apontando para outro endereço de memória.

Construindo a função deepClone

Perfeito, agora temos nossas funções de clonagem de array e de objeto. Tudo que precisamos fazer é uni-las em nossa orquestra. Para isso criaremos uma função que será responsável por decidir qual clonagem será executada de acordo com o dado informado:

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

A deepClone avaliará o element, se ele for do tipo array, execute o cloneArray. Se ele for do tipo object, execute o cloneObject. Caso não entre em nenhuma dessas condições, apenas retorna seu valor original.

Agora precisamos fazer um ajuste em cada uma de nossas funções de clonagem, para que elas chamem o deepClone recursivamente:

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

Em cloneArray, alteramos o callback de map para deepClone.

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

Em cloneObject, alteramos a linha 5, onde estava cloneObject para deepClone.

Fazendo um teste final, teremos uma estrutura de dados do tipo object, com um array interno, ambos apontando para diferentes endereços de memória em relação ao endereço do 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 o que fizemos:

1 - Aprendemos quais são os tipos primitivos e não primitivos do Javascript.

2 - Aprendemos como são alocadas em memória as variáveis primitivas e não primitivas.

3 - Implementamos uma função capaz de identificar o tipo de dado passado para ela e retornar seu valor em string.

4 - Vimos dois tipos de paradigmas: funcional e estruturado para implementar a função cloneArray.

5 - Criamos a função cloneObject e vimos como funciona o método Object.fromEntries.

Bônus - Tornando nossos dados imutáveis

A partir deste ponto somos capazes de clonar qualquer estrutura de objeto e array, mas ainda assim não escapamos do comportamento natural do Javascript, em que alteramos um dado dentro de um tipo não primitivo e esta alteração é refletida em todas as variáveis que apontam para a mesma referência.

Nossas estruturas clonadas são mutáveis:

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

Mas podemos resolver esse efeito com uma simples função:

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

A função construtora Object em Javascript, disponibiliza um método estático capaz de congelar objetos. Este congelamento impede qualquer alteração, inserção ou remoção de dados dentro da estrutura congelada. Entretanto, esse congelamento é realizado em nível superficial.

Isso significa que a estrutura de dados interna do objeto congelado não será congelada e a mesma estará suscetível a mudanças. Para resolvermos isso teremos que partir para a recursão novamente; a boa notícia é que já deixamos o terreno pronto anteriormente. Faremos uma simples mudança em nossa função deepClone:

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

Nas linhas 4 e 6 adicionamos a chamada para a função freeze, que será chamada recursivamente por meio das chamadas de deepClone.

Nosso resultado final sobre o exemplo 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 podemos observar as estruturas person e clonedPerson, apesar de possuírem os mesmos valores, elas apontam para endereços de memória diferentes. E sobre a estrutura resultante (clonedPerson), se tentarmos sobrescrever alguma de suas propriedades, a mudança não acontecerá, pois nossa estrutura é imutável.

Conclusão

Ao longo deste artigo, exploramos um território que nos abre portas para uma série de questões a respeito de como o Javascript funciona por debaixo dos panos. Essas questões formam a bússola que aponta para o norte, onde encontram-se os melhores programadores.

Além disso, aplicamos técnicas de programação funcional, através das quais mudamos uma instrução imperativa para uma abordagem declarativa.

Conhecemos uma função capaz de retornar o tipo de qualquer dado passado para ela, a typeCheck, e espero que a guarde com carinho e faça bom uso.

E pra finalizar, deixo aqui um desafio. No início do artigo eu comentei que é possível clonar estruturas de dados como arrays e objetos, e omiti intencionalmente outras estruturas nativas do Javascript como: Sets, Maps, WeakMaps e WeakSets. Como implementar funções de clonagem para essas estruturas? Sua nova missão, caso decida aceitar, é encontrar essas respostas.

Há um tempo atrás encontrei essas respostas e desenvolvi uma biblioteca especializada em clonagem de estruturas, a Simple Immuter.

Referências

Veja outros artigos sobre Programação