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 return para finalizar com valor: um gerador pode retornar um valor final, que aparece quando done é true. Isso pode ser útil para acumular resultados.
  • Combine com for...of e 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

Exercícios

  1. Exercício 1: Crie um iterável chamado meuIteravel que produza os números pares de 0 a 10 (inclusive). Use o protocolo de iteração manualmente (sem gerador). Teste com for...of.
  2. ✓ Resposta:
    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
    }
    
  3. Exercício 2: Escreva uma função geradora contagemRegressiva que receba um número n e produza os números de n até 0. Teste com for...of.
  4. ✓ Resposta:
    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
    }
    
  5. Exercício 3: Crie uma função geradora paresAte que receba um limite e produza os números pares até esse limite. Use yield* para delegar a um gerador de números sequenciais.
  6. ✓ Resposta:
    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]
    
  7. 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.
  8. ✓ 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 < 15; i++) {
      console.log(fib.next().value);
    }
    
  9. 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 yield e um contador para alternar as cores.
  10. ✓ Resposta:
    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
    }