Iteradores e geradores
Esta aula explora o protocolo de iteração em JavaScript, detalhando como objetos se tornam iteráveis por meio do método Symbol.iterator, e apresenta funções geradoras com a sintaxe function* e a palavra-chave yield, demonstrando seus casos de uso práticos e suas vantagens para criar sequências sob demanda e controlar o fluxo de execução.
Nesta aula, vamos mergulhar em dois conceitos poderosos do JavaScript moderno: iteradores e geradores. Eles são fundamentais para entender como laços como for...of funcionam por baixo dos panos, e abrem portas para padrões de programação assíncrona e manipulação de fluxos de dados de forma elegante e eficiente.
Primeiro, exploraremos o protocolo de iteração, que define a interface que objetos precisam implementar para serem iteráveis. Em seguida, veremos como as funções geradoras (function*) e a palavra-chave yield simplificam drasticamente a criação de iteradores personalizados, permitindo escrever código que pausa e retoma sua execução de forma controlada. Vamos também analisar casos de uso reais onde essas técnicas brilham, como na geração de sequências infinitas, no processamento de grandes conjuntos de dados e na implementação de fluxos assíncronos.
Protocolo de iteração
O protocolo de iteração é um conjunto de regras que define como um objeto pode ser iterado em JavaScript. Ele é composto por duas partes: o protocolo iterável e o protocolo iterador. Um objeto é considerado iterável se implementar o método Symbol.iterator, que retorna um objeto iterador. O iterador, por sua vez, deve ter um método next() que retorna um objeto com duas propriedades: value (o valor atual) e done (um booleano que indica se a iteração terminou).
Esses protocolos são a base para construções como for...of, o operador de espalhamento (...) e a desestruturação de arrays. Por exemplo, arrays, strings, Map e Set são iteráveis nativamente. Quando você usa for...of em um array, o JavaScript internamente chama Symbol.iterator para obter um iterador e então chama next() repetidamente até que done seja true.
Vamos criar nosso próprio objeto iterável para entender o mecanismo:
const sequencia = {
[Symbol.iterator]() {
let i = 1;
return {
next() {
if (i <= 5) {
return { value: i++, done: false };
} else {
return { done: true };
}
}
};
}
};
for (const num of sequencia) {
console.log(num); // 1, 2, 3, 4, 5
}
// Também podemos usar o operador de espalhamento
console.log([...sequencia]); // [1, 2, 3, 4, 5]
Observe que o objeto sequencia define um método Symbol.iterator que retorna um iterador com o método next(). A cada chamada, ele retorna o próximo valor da sequência até que a condição de parada seja atingida. Esse padrão é explícito e um pouco verboso, o que nos leva às funções geradoras, que simplificam essa implementação.
function*
Uma função geradora é uma função especial que pode ser pausada e retomada. Ela é definida com a sintaxe function* (asterisco após a palavra-chave function). Quando chamada, ela não executa o corpo imediatamente; em vez disso, retorna um objeto gerador, que é tanto um iterador quanto um iterável. O corpo da função é executado incrementalmente a cada chamada de next().
As funções geradoras são uma forma mais concisa e legível de criar iteradores. Elas eliminam a necessidade de manter manualmente o estado interno do iterador, como fizemos no exemplo anterior. O gerador mantém o estado automaticamente entre as chamadas de next().
Vejamos como reescrever a sequência anterior usando uma função geradora:
function* gerarSequencia() {
for (let i = 1; i <= 5; i++) {
yield i;
}
}
const gen = gerarSequencia();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: 4, done: false }
console.log(gen.next()); // { value: 5, done: false }
console.log(gen.next()); // { value: undefined, done: true }
// Também pode ser usado com for...of
for (const num of gerarSequencia()) {
console.log(num); // 1, 2, 3, 4, 5
}
Perceba como a lógica fica muito mais limpa. A função geradora usa um laço for e a palavra-chave yield para produzir cada valor. Quando next() é chamado, a execução começa do início e vai até o primeiro yield, retornando o valor. Na próxima chamada, a execução retoma logo após o yield, continuando o laço até o próximo yield, e assim por diante.
Além disso, geradores são iteráveis, ou seja, podemos usá-los diretamente em for...of, espalhamento, etc., sem chamar Symbol.iterator manualmente.
yield
A palavra-chave yield é o coração das funções geradoras. Ela pausa a execução da função e envia um valor de volta para o chamador. Quando o chamador invoca next() novamente, a execução retoma do ponto onde parou. O yield também pode receber valores de volta do chamador, mas isso é um tópico mais avançado; aqui focaremos no uso básico.
Um dos usos mais comuns do yield é gerar sequências infinitas ou grandes conjuntos de dados sem ocupar memória desnecessária. Como a função é pausada, ela não calcula todos os valores de uma vez; cada valor é produzido sob demanda.
Vamos criar um gerador de números de Fibonacci infinito:
function* fibonacci() {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3
// Podemos obter quantos números quisermos
Note que o laço while (true) nunca termina, mas isso não causa um loop infinito porque a execução é pausada a cada yield. Podemos consumir quantos números precisarmos e depois simplesmente parar de chamar next().
Outra característica interessante é que yield pode ser usado para delegar a outro gerador com a sintaxe yield*. Isso permite compor geradores de forma modular:
function* gerarPares() {
yield 2;
yield 4;
}
function* gerarImpares() {
yield 1;
yield 3;
}
function* gerarTodos() {
yield* gerarImpares();
yield* gerarPares();
}
console.log([...gerarTodos()]); // [1, 3, 2, 4]
O operador yield* delega a iteração para outro gerador ou iterável, como se os valores fossem produzidos diretamente pelo gerador atual. Isso é útil para quebrar lógicas complexas em partes menores e reutilizáveis.
Casos de uso
Os geradores e iteradores têm uma ampla gama de aplicações práticas. Vamos explorar alguns dos mais relevantes.
1. Geração de sequências infinitas ou grandes: como vimos com Fibonacci, geradores permitem representar sequências matematicamente infinitas sem estourar a memória. Isso é útil em simulações, cálculos numéricos e jogos.
2. Processamento de dados em streaming: quando você precisa processar grandes arquivos ou dados vindos de uma API, geradores podem ler e processar pedaços sob demanda, reduzindo o consumo de memória. Por exemplo, ler um arquivo linha por linha:
const fs = require('fs');
const readline = require('readline');
async function* lerLinhas(arquivo) {
const rl = readline.createInterface({
input: fs.createReadStream(arquivo),
crlfDelay: Infinity
});
for await (const linha of rl) {
yield linha;
}
}
(async () => {
for await (const linha of lerLinhas('dados.txt')) {
console.log('Linha:', linha);
}
})();
Neste exemplo, usamos for await...of para consumir um gerador assíncrono, que é uma extensão dos geradores para trabalhar com Promises. Isso é um tópico avançado, mas mostra o poder dos geradores em contextos assíncronos.
3. Máquinas de estado e fluxos de controle: geradores podem modelar máquinas de estado de forma legível, onde cada yield representa uma transição. Por exemplo, um semáforo:
function* semaforo() {
while (true) {
yield 'verde';
yield 'amarelo';
yield 'vermelho';
}
}
const sinal = semaforo();
setInterval(() => {
console.log(sinal.next().value);
}, 1000);
4. Implementação de iteradores personalizados: se você tem uma estrutura de dados própria (como uma árvore, um grafo ou uma lista ligada), pode implementar o protocolo de iteração facilmente com geradores, sem escrever todo o boilerplate do iterador manual.
5. Testes e simulações: geradores podem ser usados para criar dados de teste sequenciais ou simular eventos em ordem, facilitando a verificação de comportamentos dependentes de estado.
Boas práticas e observações finais
Ao usar iteradores e geradores, lembre-se de que geradores são lazy: eles só computam valores quando solicitados. Isso pode melhorar a performance em cenários com muitos dados, mas também pode causar efeitos colaterais inesperados se você não consumir o gerador completamente. Sempre que possível, prefira geradores a iteradores manuais, pois o código fica mais claro e menos propenso a erros.
Outra boa prática é evitar geradores para operações simples que podem ser feitas com arrays ou funções comuns, a menos que você precise de lazy evaluation. Além disso, cuidado com geradores infinitos: sempre tenha uma condição de parada clara no consumidor.
Referências
- MDN: Protocolos de iteração
- MDN: function*
- MDN: yield
- MDN: yield*
- MDN: Symbol.iterator
- JavaScript.info: Generators
- Exploring JS: Generators
Exercícios
- Exercício 1: Crie um objeto iterável que gere os números pares de 0 a 10 (inclusive), usando o protocolo de iteração manualmente (sem geradores). Use
for...ofpara testá-lo.✓ Resposta:const pares = { [Symbol.iterator]() { let i = 0; return { next() { if (i <= 10) { const value = i; i += 2; return { value, done: false }; } return { done: true }; } }; } }; for (const num of pares) { console.log(num); } // 0, 2, 4, 6, 8, 10 - Exercício 2: Escreva uma função geradora
gerarMultiplosque recebe um númerone gera os primeiros 5 múltiplos den(incluindo 0). Usefor...ofpara imprimir os múltiplos de 3.✓ Resposta:function* gerarMultiplos(n) { for (let i = 0; i < 5; i++) { yield i * n; } } for (const m of gerarMultiplos(3)) { console.log(m); } // 0, 3, 6, 9, 12 - Exercício 3: Implemente uma função geradora
fibonaccique retorne infinitamente os números da sequência de Fibonacci (começando por 0, 1, 1, 2, ...). Em seguida, obtenha os primeiros 10 números usando um laçoforcom uma condição de parada.✓ Resposta:function* fibonacci() { let a = 0, b = 1; while (true) { yield a; [a, b] = [b, a + b]; } } const fib = fibonacci(); for (let i = 0; i < 10; i++) { console.log(fib.next().value); } // 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 - Exercício 4: Crie uma função geradora
combinarque recebe dois arrays e gera pares[elemento1, elemento2]para cada combinação possível (produto cartesiano). Por exemplo, com[1,2]e['a','b'], deve gerar[1,'a'],[1,'b'],[2,'a'],[2,'b'].✓ Resposta:function* combinar(arr1, arr2) { for (const a of arr1) { for (const b of arr2) { yield [a, b]; } } } console.log([...combinar([1,2], ['a','b'])]); // [[1,'a'], [1,'b'], [2,'a'], [2,'b']] - Exercício 5: Escreva uma função geradora
paginadorque recebe um array e um tamanho de página, e gera subarrays (páginas) de acordo. Por exemplo, com[1,2,3,4,5]e tamanho 2, deve gerar[1,2],[3,4],[5].✓ Resposta:function* paginador(arr, tamanho) { for (let i = 0; i < arr.length; i += tamanho) { yield arr.slice(i, i + tamanho); } } console.log([...paginador([1,2,3,4,5], 2)]); // [[1,2], [3,4], [5]]