Vamos implementar uma função de comparação profunda em JS?
Este artigo foi escrito pelo aluno Caique Moraes.
Introdução
Quem aqui já escreveu testes e se deparou com funções de comparação como "toEqual", "deepStrictEqual" e ficou curioso para entender como elas funcionam? Pois bem, subam todos a bordo desta embarcação, pois hoje teremos a missão de desmistificá-las. Vamos implementar uma função de comparação em profundidade do total zero com JavaScript puro!
Como funciona uma função de comparação profunda
E o que há de interessante nessas funções de comparação profunda? O legal é que elas realizam comparações entre as propriedades e seus respectivos valores de estruturas que foram passadas para elas.
Mas é importante ressaltar o seguinte: essas comparações são feitas a nível de valor das propriedades, e não em nível de endereço de memórias. São comparações profundas, isto é, se forem encontradas propriedades aninhadas, estas serão comparadas também.
O resultado dessas funções é um valor booleano: true (no caso de ambas as estruturas forem idênticas, tanto em quantidade de propriedades e quanto em valores) e false (no caso de alguma correspondência for diferente).
Em meu último artigo, vimos como o interpretador do JavaScript armazena dados em memória. Portanto, sabemos que se compararmos com o operador Strict equal operator (===) duas estruturas distintas, mas com entradas iguais, teremos um resultado falsy.
Vejamos o seguinte exemplo:
const obj_1 = {
name: 'john',
age: 28,
}
const obj_2 = {
name: 'john',
age: 28,
}
console.log(obj_1 === obj_2) // false
No exemplo acima comparamos duas estruturas com a intenção de verificar se elas são equivalentes a nível de valores.
Mas o que o JavaScript faz quando trabalhamos com tipos de dados não primitivos é verificar se ambas as estruturas, obj_1
e obj_2
, apontam para o mesmo endereço de memória, e neste caso elas apontam para endereços diferentes. Portanto o resultado é falsy.
Em alguns casos, quando trabalhamos com instâncias de objetos ou arrays, temos a necessidade de comparar se uma estrutura é estritamente igual a outra para que possamos tomar alguma ação, como por exemplo: adicioná-la a uma lista.
Imagine que temos uma aplicação que gerencia negociações. Cada negociação é uma instância da classe Negotiation e possui atributos como data, quantidade e valor.
Para cada negociação criada precisamos adicioná-la em uma lista de negociações. Por exemplo: um array de negociações.
Mas antes de adicionar precisamos validar se já existe uma negociação com os mesmos valores dentro deste array.
Há várias maneiras de realizar essa comparação. Podemos comparar cada atributo da nova negociação que está sendo comparada com cada negociação da lista de negociações.
No exemplo a seguir, faremos isso com uma abordagem procedural:
...
// código anterior omitido
this.date = date
this.quantity = quantity
this.amount = amount
// código omitido
isEqual(negotiation) {
return (
this.date.getDate() === negotiation.date.getDate() &&
this.date.getMonth() === negotiation.date.getMonth() &&
this.date.getFullYear() === negotiation.date.getFullYear() &&
this.amount === negotiation.amount &&
this.quantity === negotiation.quantity
)
}
Neste exemplo acima estamos comparando a negociação do contexto "this" com uma negociação recebida via parâmetro. Cada propriedade de ambas negociações são comparadas entre si e no final obteremos um resultado booleano.
Mas essa abordagem, apesar de funcionar muito bem para este exemplo, ainda é bastante verbosa. Imagine que, se tivéssemos mais atributos para serem comparados, e nesses atributos houvesse estruturas de dados e arrays aninhados, este método ficaria cada vez mais inchado. Mas ainda podemos fazer um ajuste e reduzir essa comparação com auxílio do JSON API, da seguinte forma:
isEqual(negotiation) {
return JSON.stringify(this) === JSON.stringify(negotiation)
}
Essa solução resolveu nosso problema com apenas uma linha. Nesta instrução, estamos convertendo nosso contexto "this" e a negociação recebida via parâmetro para strings e comparando-as. Se algum caractere resultante de uma negociação for diferente da outra, o resultado será false
, o contrário será true
.
Se observarmos o resultado de JSON.stringify
sobre nosso objeto, teremos o seguinte resultado:
console.log(JSON.stringify(new Negotiation(new Date(), 2, 4)))
// { "date": "20222-09-30T15:34:09.364Z", "quantity": 2, "amount": 4 }
Problema resolvido, obrigado por ler até aqui e até a próxima...
Só que não!
Vamos adicionar um novo atributo em nossa classe Negotiation
, mas dessa vez será um atributo especial do tipo Symbol, conforme a sequência:
this.date = date
this.quantity = quantity
this.amount = amount
this.simbolo = Symbol('atributoPrivado')
// código posterior omitido
Se agora observarmos o novo resultado de JSON.stringify sobre nosso objeto, veremos o seguinte resultado abaixo:
console.log(JSON.stringify(new Negotiation(new Date(), 2, 4)))
// { "date": "20222-09-30T15:34:09.364Z", "quantity": 2, "amount": 4 }
Repare que está faltando algo aí nesta jogada. A propriedade simbolo
, do tipo primitivo Symbol
não é convertida para string durante o processo de JSON.stringify
.
Isso significa que ao compararmos duas negociações, mesmo que elas tenham todas as propriedades com valores iguais, com exceção da propriedade availableSymbol
, teríamos um resultado falso positivo. Não somente Symbols são ignorados durante o processo de JSON.stringify
, mas também os métodos e funções.
Vejamos um outro exemplo:
const log = (...args) => console.log(...args)
const myObj = {
log,
description: 'example',
}
log(JSON.stringify(log)) // undefined
log(JSON.stringify(myObj)) // {"description":"example"}
Neste exemplo acima criamos uma função chamada log
e um objeto chamado myObj
. Neste objeto, passamos para ele a função log
, onde ela se torna um método dentro de myObj
. Neste mesmo exemplo, logo abaixo, chamamos nossa função log para exibir os resultados de JSON.stringify
sobre myObj
e log
. Podemos observar que a primeira chamada de JSON.stringify
para log é undefined
e a segunda somente exibiu a propriedade description
do objeto.
Não podemos negar o fato de que os métodos fazem parte do conjunto de propriedades de um objeto, tanto que ao executarmos a função getOwnPropertyNames
de um objeto, teremos os métodos listados junto dos atributos:
const log = (...args) => console.log(...args)
const myObj = {
log,
description: 'example',
}
log(Object.getOwnPropertyNames(myObj)) // [ 'log', 'description' ]
E quanto aos Symbols?
Vejamos outro exemplo:
const log = (...args) => console.log(...args)
const privateDescription = Symbol('description')
const privateLog = Symbol('log')
const myObjSymbol = {
[privateLog]: log,
[privateDescription]: 'example Symbol',
}
log(Object.getOwnPropertyNames(myObjSymbol)) // []
log(Object.getOwnPropertySymbols(myObjSymbol)) // [ Symbol(log), Symbol(description) ]
Os Symbols não são recuperados pelo método getOwnPropertyNames
, mas podem ser recuperados por getOwnPropertySymbols
.
Um dos casos de uso dos Symbols é a criação de propriedades privadas dentro de objetos, além de serem valores únicos e imutáveis. Toda chamada de Symbol retornará um novo endereço de memória:
log(Symbol('foo') === Symbol('foo')) // false
Partindo do princípio que para uma comparação bem efetiva precisamos varrer todas as propriedades em profundidade, incluindo métodos e Symbols, vamos projetar uma função de comparação profunda, capaz de realizar esta tarefa.
Para isso, utilizaremos recursos de programação funcional e metaprogramação, como veremos a seguir.
Então bora lá!
Construindo uma função de verificação de tipos primitivos
Para iniciar, primeiro precisaremos de uma função auxiliar que receberá um dado de qualquer tipo e retornará true
se for primitivo e false
caso contrário.
Essa função será a “cereja do bolo” em nossas comparações, pois caso um valor primitivo seja retornado, tomaremos uma decisão, caso contrário teremos que descer um nível de aninhamento, já que se trata de uma estrutura aninhada.
A título de curiosidade, no paradigma da programação funcional: funções puras que retornam um booleano são chamadas de predicado. E funções puras são funções que para os mesmos valores de entrada, retornam os mesmos valores de saída sem gerar efeitos colaterais; isto é, ela não altera informações fora de seu contexto.
Confira o código, a seguir:
Função isPrimitive
:
const isPrimitive = (element) => !(Object(element) === element)
log(isPrimitive('caique')) // true
log(isPrimitive(3)) // true
log(isPrimitive(null)) // true
log(isPrimitive([])) // false
log(isPrimitive(log)) // false
log(isPrimitive({})) // false
Percebe como o seu funcionamento é simples? Todo tipo de dado não primitivo que for passado para dentro da função construtora Object
, terá como resultado: ele mesmo.
Construindo a função deepStrictEqual
Agora, nossa função receberá duas estruturas e deverá retornar um valor booleano. True
se ambas as estruturas forem iguais e false
caso contrário.
const deepStrictEqual = (obj_1, obj_2) => {
// implementação
}
Para realizar comparações em profundidade utilizaremos a técnica de recursão, pois além desta técnica facilitar a leitura do código, não temos como saber de antemão a quantidade exata de níveis de profundidade que deveremos comparar entre as estruturas. A cada nível de profundidade decompomos a estrutura até encontrarmos o seu elemento primitivo, e essa será a condição de parada da nossa função recursiva.
Inclusive, níveis de profundidade me lembram aquele filme Inception (A Origem), onde o ator Leonardo DiCaprio entra dentro de um sonho e depois dentro de outro, que está dentro de outro... E no final, ele precisa acordar de todos os sonhos aninhados para retornar à realidade. Então, com as funções recursivas faremos algo parecido!
Não, não é viagem não, é bem isso!
Recursão é uma técnica de programação funcional onde uma função se chama dentro de si mesma; entretanto precisamos tomar cuidado para que essas chamadas não caiam em um loop infinito.
Para isso, toda função recursiva possui um ou mais pontos de parada, conhecidos como caso base. Esses pontos são onde retornamos algum valor que não necessita de nenhuma recursão. E é aí que entra nossa função auxiliar isPrimitive.
Acompanhe o trecho do código a seguir:
const isPrimitive = (element) => !(Object(element) === element)
export const deepStrictEqual = (obj_1, obj_2) => {
if (isPrimitive(obj_1) && isPrimitive(obj_2)) {
return Object.is(obj_1, obj_2)
}
// restante da implementação
}
Nosso primeiro caso base é verificar se ambos os dados são primitivos. Caso seja verdadeiro, deve retornar o valor da operação de Object.is(obj_1, obj_2)
.
Talvez você esteja se perguntando porque usamos o método Object.is
ao invés do operador strict equal (===
). Aqui está a diferença entre os dois métodos, segundo a própria documentação:
“Object.is() também não é equivalente ao operador ===. A única diferença entre Object.is() e === está no tratamento de zeros com sinal e valores NaN.
O operador === (e o operador ==) trata os valores numéricos -0 e +0 como iguais, mas trata NaN como diferentes entre si.”
Ou seja, Object.is
sabe diferenciar os valores "+0" de "-0", e sabe comparar NaN
(valor do tipo Number que significa “Not a Number”, ou “não é um número”) com NaN
. Portanto seu uso é ainda mais preciso do que (===
).
Voltando ao nosso raciocínio, neste primeiro caso base retornamos um booleano que encerrará a recursividade atual; ou apenas encerraremos a função, no caso de ambas as estruturas serem dados primitivos.
Agora vamos para o nosso segundo caso base e neste ponto podemos constatar que não são tipos primitivos. Então podemos verificar se ambos os objetos possuem as mesmas quantidades de propriedades, incluindo os Symbols.
Essa verificação trivial evitará o custo em processamento de percorrer ambas as estruturas, para só então descobrirmos que são diferentes por conta da quantidade de propriedades, mesmo que as propriedades existentes sejam iguais.
Analise o código a seguir:
const isPrimitive = (element) => !(Object(element) === element)
export const deepStrictEqual = (obj_1, obj_2) => {
if (isPrimitive(obj_1) && isPrimitive(obj_2)) {
return Object.is(obj_1, obj_2)
}
if (Reflect.ownKeys(obj_1).length !== Reflect.ownKeys(obj_2).length) {
return false
}
// restante da implementação
}
Neste ponto você deve ter reparado o método Reflect.ownKeys
. E é aqui onde entra a metaprogramação, mas você sabe o que é isso?
Metaprogramação é a capacidade da linguagem ou do programa de pensar sobre si, ou escrever outros programas. Este assunto é tão vasto e interessante que merece diversos artigos dedicados somente a ele.
Mas precisamos saber que o JavaScript dispõe de uma API chamada Reflect
, que nos dá a capacidade de analisar e manipular objetos em tempo de execução. E, neste caso, recorremos ao método Reflect.ownKeys
, que nos retornará um array contendo todas as propriedades, incluindo os Symbols de um objeto passado como argumento.
Como o resultado desta operação é um array, podemos por meio da propriedade length
obter o seu tamanho e comparar os tamanhos de ambas as estruturas. Caso elas sejam diferentes, apenas retornamos false
e saberemos que ambas as estruturas não são iguais.
Obs.: Sugiro conferir a documentação da API Reflect, ela é imprescindível em seu repertório de dev.
"Para entender o que é recursão, antes, você precisa entender o que é recursão"
Neste ponto temos dois casos bases, mas agora vem o Grand Finale! O momento de colocarmos a recursividade ao nosso favor e deixar a mágica computacional acontecer.
O terceiro e último caso base é o resultado das recursões. Vamos analisá-lo cuidadosamente:
const isPrimitive = (element) => !(Object(element) === element)
export const deepStrictEqual = (obj_1, obj_2) => {
if (isPrimitive(obj_1) && isPrimitive(obj_2)) {
return Object.is(obj_1, obj_2)
}
if (Reflect.ownKeys(obj_1).length !== Reflect.ownKeys(obj_2).length) {
return false
}
return Reflect.ownKeys(obj_1).every((obj_1_key) => {
return Reflect.has(obj_2, obj_1_key)
? deepStrictEqual(obj_2[obj_1_key], obj_1[obj_1_key])
: false
})
}
No terceiro caso não possuímos um if, apenas retornamos o resultado de uma operação. Sabemos que Reflect.ownKeys
retornará um array contendo todas as propriedades incluindo os *Symbols” de um objeto passado como parâmetro.
Sobre este array podemos chamar o método Array.prototype.every que recebe uma função de predicado, avalia cada propriedade da iteração e retorna true
se e somente se todos os resultados forem verdadeiros. Caso algum seja falso, imediatamente será retornado false
e encerrará a iteração concluindo que ambas as estruturas são diferentes.
Analisando mais a fundo nossa função de predicado, temos o seguinte trecho de código:
Reflect.has(obj_2, obj_1_key)
? deepStrictEqual(obj_2[obj_1_key], obj_1[obj_1_key])
: false
Novamente apelamos para a API Reflect e desta vez utilizamos o método Reflect.has
, que analisa se em um determinado objeto alvo existe a seguinte propriedade.
Neste caso, analisamos a segunda estrutura que passamos e verificamos se a propriedade que está sendo iterada da primeira estrutura existe dentro da segunda estrutura.
Já em caso verdadeiro, chamamos o deepStrictEqual
passando a correspondente propriedade da segunda estrutura, junto com a correspondente propriedade iterada da primeira estrutura. E é neste ponto onde mora a recursão e todos os três casos bases serão realizados sobre estes dois.
E no caso de Reflect.has()
retornar false
, apenas retornaremos false
finalizando a iteração atual e todas as iterações seguintes. E isto retornará false
para a primeira chamada recursiva de deepStrictEqual
, concluindo que ambas as estruturas são diferentes.
É hora da ação!
Com nossa função de comparação profunda concluída, nada melhor do que colocá-la em ação.
const log = (...args) => console.log(...args)
const isPrimitive = (element) => !(Object(element) === element)
const kInfo = Symbol('info')
export const deepStrictEqual = (obj_1, obj_2) => {
if (isPrimitive(obj_1) && isPrimitive(obj_2)) {
return Object.is(obj_1, obj_2)
}
if (Reflect.ownKeys(obj_1).length !== Reflect.ownKeys(obj_2).length) {
return false
}
return Reflect.ownKeys(obj_1).every((obj_1_key) => {
return Reflect.has(obj_2, obj_1_key)
? deepStrictEqual(obj_2[obj_1_key], obj_1[obj_1_key])
: false
})
}
const obj_1 = {
name: 'caique',
age: 28,
available: true,
[kInfo]: 'info',
getName() {
return this.name
},
hobbies: [[[[[[['books', {}]]]]]]]
}
const obj_2 = {
name: 'caique',
age: 28,
available: true,
[kInfo]: 'info',
getName() {
return this.name
},
hobbies: [[[[[[['books', {}]]]]]]],
}
const obj_3 = {
name: 'thomas',
age: 21,
available: true,
[kInfo]: 'info',
getName() {
return this.name
},
hobbies: [[[[[[['books', {}]]]]]]],
}
const obj_4 = {
name: 'caique',
age: 28,
available: true,
[kInfo]: 'info',
getName() {
return this.name
},
hobbies: [[[[[[['films', {}]]]]]]],
}
log(deepStrictEqual(obj_1, obj_2)) // true
log(deepStrictEqual(obj_1, obj_3)) // false
log(deepStrictEqual(obj_1, obj_4)) // false
Conclusão
Neste artigo você aprendeu sobre algumas terminologias do universo da programação funcional, como: funções puras, recursivas e de predicado.
Abordamos também alguns casos de uso de metaprogramação com o auxílio da Reflect API.
Na sequência, conhecemos um método efetivo para compararmos dados: Object.is
. Além disso, aumentamos nosso arsenal de dev com uma função pura e auxiliar: isPrimitive
. Função essa, que é capaz de identificar se um dado passado para ela é primitivo ou não.
Espero que estes conhecimentos tenham despertado o(a) cientista que há dentro de você e lhe dado mais entusiasmo para persistir e subir de nível. Obrigado por ter chegado até aqui. Nos vemos em breve!
"O que eu não posso criar, não posso entender" - Richard Feynman
Dedico este artigo ao meu querido tio Charles Robison Alves Leite, que hoje descansa em paz. Com muito amor, de seu querido sobrinho, Caíque Vinícius de Moraes.