Capa do post

Você conhece os iterators e generators do Javascript?

5/15/2023

Com certeza você já usou um laço for..of algumas vezes, e não sei você, mas antes quando eu usava, muitas vezes tinha a curiosidade de saber como realmente funciona a implementação de tudo, e hoje vamos ver um pouco sobre.

Apesar de o iterator protocol ser um pouco mais antigo que o próprio Javascript, nessa linguagem a implementação dele como protocolo só veio surgir no ES6, mas não é das novidades do ES6 que vamos falar hoje.

Como já deve ter percebido, os iterators estão por quase toda a parte nosso amado JS. Eles estão lá no Array, Set, Map e até nas string também.

Iterators e Generators são ótimas formas de computar coisas sob demanda, sem a necessidade de jogar tudo em memória de uma vez.

Um exemplo que foi meu primeiro caso de uso foi.

Eu tinha um JSON que quando lia ele todo de uma vez, meu navegador simplesmente travava tudo, e meu desafio era lê-lo e fazer uma pesquisa do tipo full text search em três campos diferentes, com outros dois filtros opcionais e exibir apenas no máximo 100 resultados.

Minha solução foi criar generators para várias partes do processo, assim otimizei o uso de memória e de carregamento.

Tá, mas também não é disso que vamos falar.

Em Javascript, para um objeto ser iterável, ele precisa implementar o método @@iterator. Isso quer dizer que o nosso objeto ou outros acima dele na cadeia de protótipos devem implementar o método através da constante Symbol.iterator. Só para fins de esclarecimentos, esta é uma função que não deve esperar receber nenhum argumento.

Como falei anteriormente, sempre que um objeto é chamado em um laço do tipo for..of, a função @@iterator é a primeira coisa que internamente é chamada.

O retorno dessa função vai ser sempre um objeto com uma função next que retorna um objeto contendo um atributo value e um atributo done que indica se a iteração deve acabar ou não.

Exemplo:

const obj = {
  text: 'hello',
  count: 0,
  next() {
    if (this.count == this.text.length) {
      return { done: true }
    }
    return { value: this.text[this.count++] }
  },
  [Symbol.iterator]() {
    return { next: () => this.next() }
  }
}

console.log(obj.next()) // { value: 'h' }
console.log(obj.next()) // { value: 'e' }
console.log(obj.next()) // { value: 'l' }
console.log(obj.next()) // { value: 'l' }
console.log(obj.next()) // { value: 'o' }
console.log(obj.next()) // { done: true }

Este é um exemplo simples de um iterador.

Veja que a função next retorna o value, se existir, e o atributo done, este sendo obrigatório apenas se for o último, se não, ele sempre será false.

Também podemos apenas usar a sintaxe de espalhamento como o seguinte:

console.log([...obj]) // [ 'h', 'e', 'l', 'l', 'o' ]

Deu pra entender um pouco?

Há também ainda outra forma mais compacta, são eles os famosos generators.

Este código todo que fizemos aí seguindo os passos mais "dos tempos dos incas", podemos fazer com um generator simplesmente assim:

function* obj() {
  const text = 'hello'
  let count = 0

  while (count != text.length) {
    yield text[count++]
  }
}

console.log([...obj()]) // [ 'h', 'e', 'l', 'l', 'o' ]

É um exemplo bem básico, mas dá pra ter uma noção de como funciona.

Com iterators, devemos implementar a função @@iterator, nos generators, a gente retorna os dados com yield, e não no famoso return.

Os yield só podem ser usados em generators — funções que tenham o asteriscozinho ali.

Para deixarmos nosso iterator ainda mais compacto, ainda podemos também juntar os dois como o seguinte:

const obj = {
  text: 'hello',
  count: 0,
  *[Symbol.iterator]() {
    while (this.count != this.text.length) {
      yield this.text[this.count++]
    }
  }
}

Como vimos, estes meios nos proporcionam a possibilidade de termos apenas os dados com que vamos trabalhar no momento. Por serem lazy, eles acessam e retornam apenas um item por vez. Por isso são bastante utilizados quando precisa-se lidar com dados que possam ocupar muita memória, como o caso de buffers.

Um outro exemplo interessante é.

Suponhamos que por algum motivo, precisamos gerar número a partir de um certo número e com uma distância fixa entre eles.

Para isso, vamos criar um generator que nos retorna os números e a gente que para quando desejar:

function* count(start = 0, step = 1) {
  let acc = start
  while (true) {
    yield acc
    acc += step
  }
}

Com essa função, teremos saídas assim:

const nums = count(0, 1)

console.log(nums.next()) // { done: false, value: 0 }
console.log(nums.next()) // { done: false, value: 1 }
console.log(nums.next()) // { done: false, value: 2 }
console.log(nums.next()) // { done: false, value: 3 }

Outra implementação interessante seria:

Suponhamos que precisamos uma lista de números contando a partir de n, terminando em m e com z de distância entre eles, para isso, vamos usar o Typescript.

function range(stop: number): Generator<number, void, unknown>
function range(start: number, stop: number): Generator<number, void, unknown>
function range(start: number, stop: number, step: number): Generator<number, void, unknown>

function* range(...args: number[]) {
  let [start, stop, step] = [0, 0, 1]

  if (args.length == 3) {
    [start, stop, step] = args
  }

  if (args.length == 2) {
    [start, stop] = args
  }

  if (args.length == 1) {
    stop = args[0]
  }

  for (const cur of count(start, step)) {
    if (cur >= stop) {
      break
    }

    yield cur
  }
}

Aqui temos uma função que se receber apenas um parâmetro, ele vai ser armazenado na nossa variável que determina quando parar.

Se recebermos dois, eles serão, em ordem: o start e o stop.

Se recebermos três, aí teremos o start, stop e step.

Talvez seja autoexplicativo, mas vamos só recapitular cada uma:

  • start — Essa determina a partir de qual número vamos começar a contar, e por padrão é 0.
  • stop — Determina até onde vamos contar, e não tem valor padrão.
  • step — Quantos "passos" vamos dar entre os números, e o padrão é 1.

De forma que, se executarmos os seguintes códigos, teremos os seguintes retornos que tá no comentário:

console.log([...range(4)]) // [ 0, 1, 2, 3 ]
console.log([...range(1, 4)]) // [ 1, 2, 3 ]
console.log([...range(1, 4, 0.5)]) // [ 1, 1.5, 2, 2.5, 3, 3.5 ]

Deu pra ter uma ideia de como funciona?

Bom, espero que sim. Agora se desafie e crie suas próprias implementações.

Se você chegou aqui, obrigado, e espero nos vermos de novo.

Onde me encontrar