Excelente!
Esse é o tipo de coisa que muita gente não se preocupa, porque na maioria das vezes estão lidando com arrays pequenos e a diferença não é perceptível. Afinal, para poucos dados, tudo é rápido. O problema só aparece em grandes volumes, e é aí que conhecer melhor os algoritmos e estruturas de dados pode fazer toda a diferença.
Indo um pouco além, console.time
é uma maneira rápida de medir o tempo de execução, mas tem alguns problemas (leia para mais detalhes, não é o foco aqui se alongar neste tópico). Enfim, um jeito de contornar esses problemas é usando uma lib dedicada, como o Benchmark.js (deixei um exemplo mais abaixo).
E aproveitando, dá pra melhorar ainda mais (se o desempenho for um fator crítico e estiver lidando com muitos dados). Métodos como map
e filter
recebem como parâmetro uma função de callback que é chamada para todos os elementos. Apesar de não fazer tanta diferença para poucos arrays pequenos, ainda sim chamadas de função têm o seu custo, e para muitos dados pode fazer diferença. Então a ideia é eliminar estas chamadas:
function filterForOf(emails, users) {
const usersEmailsSet = new Set();
for (const u of users) { // em vez de map, um for..of
usersEmailsSet.add(u.email);
}
const diff = [];
for (const email of emails) { // em vez de filter, um for..of
if (!usersEmailsSet.has(email)) {
diff.push(email);
}
}
return diff;
}
E mais ainda: for..of
é conveniente, mas também tem custo maior do que o for
"tradicional". Então vamos fazer outra versão:
function filterFor(emails, users) {
const usersEmailsSet = new Set();
for (var i = 0; i < users.length; i++) { // for "tradicional" em vez de for..of
usersEmailsSet.add(users[i].email);
}
const diff = [];
for (var i = 0; i < emails.length; i++) { // for "tradicional" em vez de for..of
if (!usersEmailsSet.has(emails[i])) {
diff.push(emails[i]);
}
}
return diff;
}
Enfim, fiz um teste com todas estas opções usando o Benchmark.js. O código completo:
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;
const { faker } = require('@faker-js/faker');
const emails = [];
const users = [];
Array.from({ length: 50000 }, (_, i) => i).forEach(() => {
const email = faker.internet.email({ provider: 'fake' });
emails.push(email);
users.push({ email });
});
Array.from({ length: 10000 }, (_, i) => i).forEach(() => {
const email = faker.internet.email({ provider: 'fake' });
emails.push(email);
});
function filterWithSome(emails, users) {
return emails.filter((email) => !users.some((user) => user.email === email));
}
function filterWithSet(emails, users) {
const usersEmailsSet = new Set(users.map((user) => user.email));
const difference = emails.filter((email) => !usersEmailsSet.has(email));
return difference;
}
function filterForOf(emails, users) {
const usersEmailsSet = new Set();
for (const u of users) {
usersEmailsSet.add(u.email);
}
const diff = [];
for (const email of emails) {
if (!usersEmailsSet.has(email)) {
diff.push(email);
}
}
return diff;
}
function filterFor(emails, users) {
const usersEmailsSet = new Set();
for (var i = 0; i < users.length; i++) {
usersEmailsSet.add(users[i].email);
}
const diff = [];
for (var i = 0; i < emails.length; i++) {
if (!usersEmailsSet.has(emails[i])) {
diff.push(emails[i]);
}
}
return diff;
}
suite
.add('filterWithSome', function () {
filterWithSome(emails, users);
})
.add('filterWithSet', function () {
filterWithSet(emails, users);
})
.add('filterForOf', function () {
filterForOf(emails, users);
})
.add('filterFor', function () {
filterFor(emails, users);
})
.on('complete', function () {
console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.on('cycle', function (event) {
console.log(String(event.target));
})
.run({ 'async': true });
Ele mostra a quantidade de operações por segundo ("ops/sec"), ou seja, quanto mais, melhor. O resultado foi:
filterWithSome x 0.18 ops/sec ±18.44% (5 runs sampled)
filterWithSet x 158 ops/sec ±2.53% (81 runs sampled)
filterForOf x 179 ops/sec ±3.48% (76 runs sampled)
filterFor x 205 ops/sec ±2.54% (87 runs sampled)
Fastest is filterFor
Ou seja, o for
tradicional foi mais rápido, seguido do for..of
. Aqui dá pra ver que eliminar os callbacks fez diferença. Rodei mais algumas vezes e os resultados foram consistentes, com pouca variação nos números.
Ah, e também tem a opção de usar um objeto simples em vez de Set
:
function filterObject(emails, users) {
const usersEmails = {};
for (var i = 0; i < users.length; i++) {
usersEmails[users[i].email] = true;
}
const diff = [];
for (var i = 0; i < emails.length; i++) {
if (!usersEmails[emails[i]]) {
diff.push(emails[i]);
}
}
return diff;
}
Assim, cada email se torna uma propriedade do mesmo (e caso o email se repita, ele sobrescreve a mesma propriedade, ou seja, "já cuida dos valores duplicados").
Nos meus testes, esta solução se mostrou um pouco pior que filterWithSet
, mas ainda sim bem melhor que filterWithSome
. O que me parece é que a implementação do Set
está mais otimizada do que o lookup das propriedades de um objeto.
Por curiosidade, a partir do Node 22 (e nas versões recentes dos principais browsers) é possível calcular a diferença entre dois Set
's usando o método difference
. Ou seja, também poderia ser assim:
function filterSetsDiff(emails, users) {
const usersEmailsSet = new Set();
for (var i = 0; i < users.length; i++) {
usersEmailsSet.add(users[i].email);
}
// método difference funciona a partir do Node 22
return [...new Set(emails).difference(usersEmailsSet)];
}
Porém, esta não se mostrou melhor que o for
. Nos meus testes ela ficou um pouco pior que o filterObject
(mas ainda é bem melhor que filterWithSome
). Provavelmente porque precisa criar também o Set
de emails, depois o método difference
retorna outro Set
, e por fim este é convertido para array. Esta volta toda acaba tendo um custo que neste caso parece não compensar.