Iteradores e geradores
Nesta aula, exploramos o protocolo de iteração em JavaScript, aprendendo a criar iteráveis personalizados. Em seguida, dominamos as funções geradoras com function* e yield, entendendo como elas simplificam a criação de iteradores. Por fim, discutimos casos de uso práticos, como sequências infinitas e processamento preguiçoso.
Nesta aula, vamos mergulhar no fascinante mundo dos iteradores e geradores em JavaScript. Esses conceitos são fundamentais para entender como o JavaScript lida com sequências de dados, especialmente em operações como loops for...of, operadores de espalhamento e programação assíncrona. Muitos desenvolvedores usam iteradores e geradores diariamente sem perceber, mas dominá-los permite escrever código mais elegante e eficiente.
Vamos começar desvendando o protocolo de iteração, que define como objetos podem ser iterados. Em seguida, aprenderemos a criar funções geradoras com function* e a palavra-chave yield, que transformam a maneira como pensamos sobre sequências. Por fim, exploraremos casos de uso práticos que mostram o poder dessas ferramentas em cenários do mundo real.
Protocolo de iteração
O protocolo de iteração é um conjunto de regras que permite que objetos sejam iterados 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 iterador. O iterador, por sua vez, é um objeto que possui o método next(), responsável por retornar um objeto com duas propriedades: value (o valor atual) e done (um booleano que indica se a iteração terminou).
Quando usamos estruturas como for...of, o JavaScript automaticamente chama o método Symbol.iterator do objeto iterável e, em seguida, chama next() repetidamente até que done seja true. Isso é uma abstração poderosa que permite que qualquer objeto personalizado seja iterado de forma consistente.
Vamos criar um iterável personalizado: um objeto que representa um intervalo de números.
const intervalo = {
inicio: 1,
fim: 5,
[Symbol.iterator]() {
let atual = this.inicio;
const fim = this.fim;
return {
next() {
if (atual <= fim) {
return { value: atual++, done: false };
} else {
return { done: true };
}
}
};
}
};
// Usando o iterável com for...of
for (const num of intervalo) {
console.log(num); // 1, 2, 3, 4, 5
}
// Também podemos usar o operador de espalhamento
const arr = [...intervalo];
console.log(arr); // [1, 2, 3, 4, 5]
Observe que o método Symbol.iterator retorna um objeto iterador, que mantém o estado atual da iteração via closure. Isso nos dá controle total sobre a sequência. Esse protocolo é a base para todas as estruturas de dados nativas do JavaScript, como Array, Set, Map e até mesmo String.
Além do for...of, o protocolo de iteração é usado por outras funcionalidades, como o operador de espalhamento (...), o Array.from() e destruturação. Por exemplo:
const [primeiro, segundo, ...resto] = intervalo;
console.log(primeiro); // 1
console.log(segundo); // 2
console.log(resto); // [3, 4, 5]
function*
Uma função geradora é declarada com a sintaxe function*. Ela é uma função especial que pode ser pausada e retomada, permitindo produzir uma sequência de valores sob demanda. Quando chamamos uma função geradora, ela não executa o corpo imediatamente; em vez disso, retorna um objeto gerador, que é ao mesmo tempo um iterável e um iterador.
A principal característica de uma função geradora é o uso da palavra-chave yield, que pausa a execução da função e retorna um valor para o chamador. Na próxima chamada de next(), a execução continua de onde parou.
Vamos ver um exemplo simples:
function* contador() {
yield 1;
yield 2;
yield 3;
}
const gen = contador();
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: undefined, done: true }
Note que cada chamada a next() executa até o próximo yield e retorna um objeto com value e done. Quando não há mais yield, a função termina e done se torna true.
Funções geradoras são especialmente úteis porque encapsulam a lógica de iteração de forma clara e concisa, sem a necessidade de implementar manualmente o protocolo de iteração. Elas também podem receber valores de volta por meio do parâmetro de next(), o que abre possibilidades avançadas.
Um detalhe importante: funções geradoras não podem ser construídas com new e não têm a propriedade prototype usada para herança. Elas são usadas principalmente para criar iteradores.
yield
A palavra-chave yield é o coração das funções geradoras. Ela tem dois papéis: primeiro, pausa a execução e emite um valor; segundo, recebe um valor quando o gerador é retomado. Esse valor pode ser passado como argumento para a chamada de next().
Vamos explorar o uso de yield com comunicação bidirecional:
function* pergunta() {
const nome = yield "Qual é o seu nome?";
const idade = yield "Qual é a sua idade?";
return `Olá, ${nome}! Você tem ${idade} anos.`;
}
const gen = pergunta();
console.log(gen.next().value); // "Qual é o seu nome?"
console.log(gen.next("Maria").value); // "Qual é a sua idade?"
console.log(gen.next(30).value); // "Olá, Maria! Você tem 30 anos."
No exemplo acima, cada yield envia um valor para o exterior e espera um valor de volta. O valor passado em next() é atribuído à variável à esquerda do yield. Isso permite criar conversas bidirecionais entre o gerador e o chamador, algo muito útil em cenários como processamento de fluxos de dados.
Outro recurso importante é o yield*, que delega a iteração para outro iterável ou gerador. Isso é útil para compor geradores:
function* gerador1() {
yield 1;
yield 2;
}
function* gerador2() {
yield* gerador1();
yield 3;
}
const gen = gerador2();
console.log([...gen]); // [1, 2, 3]
O yield* simplesmente delega cada valor do iterável interno para o gerador externo, como se estivesse sendo produzido diretamente.
Casos de uso
Os geradores são extremamente versáteis e têm muitos casos de uso práticos. Vamos explorar alguns dos mais comuns:
Sequências infinitas
Como os geradores são preguiçosos, podemos criar sequências infinitas sem esgotar a memória. Por exemplo, uma sequência de números Fibonacci:
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
}
Processamento preguiçoso
Em vez de criar um array grande, podemos usar geradores para processar dados sob demanda. Por exemplo, gerar uma lista paginada de resultados de uma API:
function* paginar(apiUrl, paginaAtual = 1) {
while (true) {
const url = `${apiUrl}?page=${paginaAtual}`;
const dados = fetch(url).then(res => res.json());
yield dados;
paginaAtual++;
}
}
// Uso: o consumidor decide quando buscar a próxima página
const gerador = paginar('https://api.exemplo.com/itens');
const primeiraPagina = gerador.next().value;
Implementação de iteradores personalizados
Geradores simplificam a implementação de iteradores, como vimos. Por exemplo, um iterador que percorre uma árvore:
function* percorrerArvore(no) {
yield no.valor;
if (no.filhos) {
for (const filho of no.filhos) {
yield* percorrerArvore(filho);
}
}
}
const arvore = {
valor: 1,
filhos: [
{ valor: 2, filhos: [{ valor: 4 }] },
{ valor: 3 }
]
};
console.log([...percorrerArvore(arvore)]); // [1, 2, 4, 3]
Programação assíncrona com async/await
Embora async/await seja a forma mais comum de lidar com assincronismo, geradores podem ser usados como base para implementar fluxos assíncronos, como em bibliotecas como co ou redux-saga. Em redux-saga, por exemplo, geradores são usados para descrever efeitos assíncronos de forma declarativa.
function* sagaExemplo() {
const usuario = yield call(fetchUsuario);
yield put({ type: 'USUARIO_CARREGADO', usuario });
}
Boas práticas e observações finais
Ao trabalhar com iteradores e geradores, é importante ter em mente algumas boas práticas:
- Prefira geradores para sequências complexas: se você precisa de uma lógica de iteração não trivial, geradores tornam o código mais legível e menos propenso a erros.
- Cuidado com loops infinitos: ao criar geradores infinitos, sempre garanta que o consumidor tenha uma condição de parada, caso contrário, seu programa pode travar.
- Use
returnpara finalizar com valor: um gerador pode retornar um valor final, que aparece quandodoneétrue. Isso pode ser útil para acumular resultados. - Combine com
for...ofe operadores de espalhamento: eles funcionam perfeitamente com geradores, simplificando o consumo.
Os iteradores e geradores são ferramentas poderosas que, quando dominadas, elevam seu nível de programação em JavaScript. Eles estão presentes em diversas bibliotecas e frameworks modernos, então vale a pena investir tempo para entendê-los profundamente.
Referências
- MDN - Protocolos de iteração
- MDN - function*
- MDN - yield
- MDN - yield*
- MDN - Symbol.iterator
- javascript.info - Iteráveis
- javascript.info - Geradores
Exercícios
- Exercício 1: Crie um iterável chamado
meuIteravelque produza os números pares de 0 a 10 (inclusive). Use o protocolo de iteração manualmente (sem gerador). Teste comfor...of. - Exercício 2: Escreva uma função geradora
contagemRegressivaque receba um númerone produza os números denaté 0. Teste comfor...of. - Exercício 3: Crie uma função geradora
paresAteque receba um limite e produza os números pares até esse limite. Useyield*para delegar a um gerador de números sequenciais. - Exercício 4: Implemente uma função geradora que produza os números da sequência de Fibonacci indefinidamente. Use-a para imprimir os primeiros 15 números.
- Exercício 5: Crie uma função geradora que simule um semáforo: ela deve produzir as cores "verde", "amarelo" e "vermelho" em loop infinito. Use
yielde um contador para alternar as cores.
const meuIteravel = {
[Symbol.iterator]() {
let atual = 0;
const limite = 10;
return {
next() {
if (atual <= limite) {
const valor = atual;
atual += 2;
return { value: valor, done: false };
}
return { done: true };
}
};
}
};
for (const num of meuIteravel) {
console.log(num); // 0, 2, 4, 6, 8, 10
}
function* contagemRegressiva(n) {
for (let i = n; i >= 0; i--) {
yield i;
}
}
for (const num of contagemRegressiva(5)) {
console.log(num); // 5, 4, 3, 2, 1, 0
}
function* sequencia(limite) {
for (let i = 0; i <= limite; i++) {
yield i;
}
}
function* paresAte(limite) {
for (const num of sequencia(limite)) {
if (num % 2 === 0) {
yield num;
}
}
}
console.log([...paresAte(10)]); // [0, 2, 4, 6, 8, 10]
function* fibonacci() {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const fib = fibonacci();
for (let i = 0; i < 15; i++) {
console.log(fib.next().value);
}
function* semaforo() {
const cores = ['verde', 'amarelo', 'vermelho'];
let indice = 0;
while (true) {
yield cores[indice];
indice = (indice + 1) % cores.length;
}
}
const sinal = semaforo();
for (let i = 0; i < 6; i++) {
console.log(sinal.next().value); // verde, amarelo, vermelho, verde, amarelo, vermelho
}