Como utilizar Observers no typescript
em 10 de Novembro de 2023
Introdução
Olá a todos, me chamo Brennon Gabriel de Oliveira. Eu tenho 18 anos e sou apaixonado por tecnologia.
Durante meus estudos e trabalhos, a linguagem que mais usei foi, inevitavelmente, JavaScript. Uma linguagem amada por muitos e odiada pelos mesmos muitos.
Elá é uma linguagem muito versátil e simples de se trabalhar, porém sem dúvida possuí alguns comportamentos que, sem um conhecimento um pouco mais aprofundado em programação de baixo nível e sobre a própria linguagem, podem parecer bem estranhos (e outros que são realmente bem estranhos).
Uma das coisas que podem deixar você de cabelos em pé tentando entender o que está acontecendo é a forma com que o javascript (e várias outras linguagens) lida com clonagem de dados e atribuição de variáveis.
Você pode estranhar quando alterar seu array em um lugar, e ver isso refletindo em outra região a princípio sem nenhuma relação. Para resolver isso, hoje vamos entender o que é, para que serve e como fazer a clonagem profunda de dados em JavaScript.
Os tipos primitivos
Primeiro, precisamos entender como o javascript lida com suas variáveis. Ao criar uma variável de um tipo primitivo, o javascript automaticamente a torna imutável.
Antes de prosseguir, vamos entender o que são esses tais "Tipos Primitivos".
No geral, as linguagens de programação possuem alguns tipos que são "Padrão" na linguagem, como costuma ser o caso dos números, booleanos, etc. Esses tipos possuem uma definição clara de quanto espaço ocupam na memória ou como se comportam nativamente na linguagem. No caso do JavaScript, os tipos primitivos são: String, Number, Boolean, Null, Undefined e Symbol.
Passando rapidamente por cada um, temos:
Na grande parte das linguagens de programação (principalmente de baixo nível), uma string se trata apenas de um Array de caracteres, porém no JavaScript, de forma diferente, uma string se trata de um tipo primitivo. Podemos entender isso como se uma string em javascript fosse uma junção de strings e assim por diante.
Assim como no caso da String, um Number em JavaScript foge do padrão da maioria da linguagens de programação mais usadas. Nessas linguagens, os tipos numéricos podem se dividir em vários outros tipos por exemplo int8, int16, int32, double, float, etc. Essas variações ocorrem pois cada um desses tipos são efetivamente dados salvos em formatos diferentes, ocupando espaços diferentes em memória. Já no JavaScript, apesar de parecer que temos uma variedade de tipos numéricos, por baixo dos panos, todos os valores são convertidos para um "double-precision 64-bits floating point" (float64) (o que é particularmente estranho).
Nesse tipo, o javascript não se difere em conceito das demais linguagens mais gerais. O tipo de boolean apenas aceitas os valores lógicos para true e false (verdadeiro e falso ou 1 e 0).
O valor null é utilizado para demarcar o apontamento para um local que esteja vazio, por exemplo um local inexistente, sendo diferente de um valor undefined.
O valor undefined representa um valor ainda sem uma atribuição, sendo assim ainda vazio, porém conceitualmente diferente do valor null. O valor primitivo Undefined é automaticamente atribuído para campos e variáveis que não possuem um valor de inicialização.
O tipo symbol em javascript se refere a um tipo existente principalmente utilizado para definição de parâmetros de propriedades de objetos anônimos. Poderíamos se estender por um bom tempo sobre esse tipo, porém como foge muito do escopo do assunto, recomendo a leitura do artigo: JavaScript: você sabe o que são e por que usar Symbols?
Agora que temos uma ideia dos tipos primitivos de javascript, podemos entender como o javascript lida com esses tipos atribuídos a variáveis
let num1 = 10; |
Como podemos ver, conseguimos atribuir um valor a variável num1, atribuir ela a variável num2, e ao alterar o valor de num1, ele altera apenas nela, sem influenciar na variável num2.
Isso acontece por que, quando declaramos a variável num1 com o valor 10, o javascript vai alocar um espaço na memória para armazenar esse valor. Porém quando atribuímos ela a num2, ele apenas manda num2 apontar para o mesmo local na memória, então nossas variáveis apontam para o mesmo 10.
Acontece que, ao alterarmos o valor em qualquer uma das duas variáveis (Ou quantas houverem), a linguagem irá alocar um novo local na memória para o novo valor, e a variável alterada irá apontar para lá, enquanto as outras permanecem apontando para o mesmo local, com o dado totalmente intacto. Em outras palavras, ele clonou a variável num1 para num2
Mas se isso funciona, qual o problema? Bom, quando temos uma variável de um tipo primitivo, tudo vai correr bem, e não teremos nenhum erro referente a alteração de um dado vindo de outra variável, tendo assim segurança nos dados.
Porém quando entramos em tipos não primitivos (Vamos tratar de objetos e arrays), começamos a ter alguns problemas, veja no exemplo.
const person = { |
Repare que nesse caso, as alterações efetuadas em person afetam diretamente os dados de person2. Isso ocorre pois no caso de tipos não primitivos, a linguagem não clona completamente seus valores, apenas faz uma clonagem superficial da referência.
Isso faz sentido, pois imagine que temos uma lista com 20MB de dados, então, para utiliza-la em outro lugar apenas para visualização, atribuímos a outra variável. Se a linguagem clonasse os dados, apenas assim teríamos 40MB de memória utilizados, sendo 20MB (Ou seja, 50%) desse espaço perdido. Escale isso para uma tabela vinda de um banco grande, e rapidamente teremos vazamentos críticos de memória.
Porém vão haver casos onde a clonagem deve ser profunda, pois queremos trabalhar de formas diferentes com os mesmos dados iniciais, sem que um interfira no outro.
Quando se trata disso, precisamos de alguma forma de clonar esses dados em um novo valor.
Existem algumas formas de se fazer isso na linguagem. Vamos destrinchar as maneiras da mais simples até a mais requintada.
const person = { |
Nessa primeira forma, utilizando o mesmo exemplo de antes, adicionamos mais uma variável para podermos apenas comparar o resultado.
Para a clonagem profunda, estamos, ao atribuir person para person3, primeiro transformando em uma string json, e depois de volta em um objeto. Isso garante que a referência ao dado se perca durante a conversão, e temos uma clonagem profunda.
É uma solução simples, porém podemos de cara ver os problemas dessa implementação.
Essa implementação desperdiça memória e processamento mais do que o necessário, pois converte o valor em texto, depois de volta em objeto, então em casos de grandes arrays, por exemplo, seria pouco eficiente.
const person = { |
Essa segunda solução parece um pouco melhor, pois não precisa daquelas conversões da primeira, então é mais eficiente. Porém logo quando executamos o código, temos a surpresa que esse método não trabalha bem com arrays.
Ele se perde na hora de popular e apenas preenche com a referência desse array na memória diretamente.
Para resolver isso, precisamos de algo mais dinâmico. Algo que, recursivamente percorra todos os dados dentro de objetos e arrays, inclusive objetos dentro de arrays, dentro de outros array, dentro de outros objetos e assim por diante.
Precisamos de uma função recursiva que consiga fazer isso. O que nos leva para a terceira solução, mais requintada e eficiente que as anteriores, e que resolva os problemas apresentados.
Para essa solução funcionar, primeiro precisamos dar uma passo para trás e entender como os tipos em javascript são obtidos.
Quando queremos pegar o tipo de uma função, o intuitivo é utilizar a função própria da linguagem chamada typeof(), porém logo percebemos as falhas dessa utilização.
Typeof retorna qual o tipo do dado passado:
console.log(typeof(1)) |
Apenas nesse teste já vemos algumas coisas incomodas nessa utilização, por exemplo:
Dessa forma, não temos como ter uma checagem de tipo confiável utilizando essa função. Para resolver esse problema, vamos fazer nossa própria função de checagem de tipo.
Para isso, primeiro vamos conseguir algumas informações dos tipos.
console.log(Reflect.apply(Object.prototype.toString, 1, [])); |
Utilizando a biblioteca interna Reflect, com a função apply podemos executar a função padrão do prototype do Object toString em qualquer tipo de objeto, e como pudermos ver, ela vai retornar [object Type] de forma correta.
Sabendo disso fica fácil para criarmos nossa função, vamos apenas fazer uma função que receba um valor, busque o tipo com base nessa lógica, formate levemente o texto e retorne apenas o tipo em lowercase.
const getType = (item)=>{ |
Pronto! Dessa forma conseguimos os tipos corretos de cada valor no JavaScript.
Agora podemos passar de fato para a função de clonagem mesmo Vamos começar resolvendo o problema da clonagem de array apenas.
Para isso, precisamos de uma função que seja capaz de percorrer por cada item do array e retorna-lo caso não seja um primitivo, ainda mais, precisamos que ele recrie todos os valores que forem um array recursivamente
Podemos fazer isso usando um loop for por exemplo.
const cloneArray = (element)=>{ |
Como podemos ver, nossa solução funciona corretamente, ela clona o array o deixando independente dos demais.
Na linha 3, validamos se de fato é um array, então na linha 4, iteramos sobre o array e, para cada item, vemos se é um array, se for, damos push da chamada da função, para que repita o processo até o fim, então dará certo com arrays dentro de arrays.
Porém esse código não está exatamente bonito, podemos adicionar alguns conceitos de programação funcional para faze-lo ficar mais agradável:
const cloneArray = (element)=>{ |
Agora sim temos uma função muito mais elegante, pequena e direta. Caso element seja um array, retorna a reconstrução dele executando recursivamente a função até que o valor não seja mais um array, então vai ter uma sequência de retornos reconstruindo o objeto.
Agora que fizemos isso, precisamos da função para clonar objeto, que funciona bem parecido, porém para fazermos de forma simples, precisamos conhecer a função Object.keys, que criará um array com as chaves de um objeto.
Poderíamos criar a função assim:
const cloneObject = (element)=>{ |
Funcionou como esperado, porém agora podemos melhora-la assim como fizemos na função de clonagem de array. Para isso podemos usar a função Object.fromEntries, que basicamente recebe um vetor no padrão: [[chave, valor],[chave, valor],...]; e constrói um objeto a partir disso.
Com isso, conseguimos fazer da seguinte forma:
const cloneObject = (element)=>{ |
Muito mais elegante, não é?
Agora que temos nossos "instrumentos", precisamos de um maestro para dizer quem deve agir em qual momento. Para isso, vamos criar uma função simples que irá apenas decidir qual função chamar.
Então vamos fazer uma pequena alteração em nossas funções, ao invés de chamarem elas mesmas, vão passar a chamar a deepClone:
const cloneArray = (element)=>{ |
E aqui está nossa função de clonagem profunda, passando por nosso teste de stress, sem nenhum tipo de problema.
Com ela concluída, pode mos voltar a nosso primeiro caso de uso e ver se ela vai resolver nossos problemas.
const person1 = { |
Funcionou como esperado, agora conseguimos clonar qualquer objeto ou array em javascript, de forma a todos os dados serem clonados sem nenhum tipo de problema.
Considerações finais
Então conseguimos entender o que gera a necessidade da clonagem profunda. Isso ocorre em grande parte das linguagens de programação mais usadas, e, apesar de cada uma ter uma implementação diferente, o conceito sempre será o mesmo, então sinta-se a vontade, e até desafiado a criar uma função de clonagem profunda na linguagem que preferir!
Muito obrigado por acompanhar até aqui, até a próxima!
Bibliografia