JavaScript — Entendendo Generators

Lucas Santos
Training Center
Published in
8 min readNov 6, 2017

Você sabe o que é um generator?

Generator? É de comer?

Generators são algumas das muitas adições que o EcmaScript6 trouxe para nossas vidas simples de desenvolvedores. Basicamente uma função que é um generator é chamado de, pasmem, generator function e o que ela retorna? Bem, generator objects. Simples não?

Generator functions (e objects)

Basicamente uma generator function é uma função que, quando declarada, vai sempre retornar um generator object. Imagine que g é um objeto do tipo generator , este objeto pode ser iterado usando qualquer tipo de construto de repetição, como Array.from(g) , [...g] ou também loops do tipo for valor of g .

Uma generator function permite que você declare um tipo especial de iterator que pode suspender sua própria execução enquanto retém o seu próprio contexto. Assim como examinamos os iterators neste artigo, e como utilizamos o método .next() para puxar o próximo valor de uma sequência.

Abaixo está um exemplo muito simples de um generator:

Um generator muito simples

Neste exemplo podemos ver uma nova palava, yield , já vamos explicar ele logo mais, por enquanto vamos nos contentar em entender que ele vai nos retornar uma letra de cada vez da palavra ‘foo’ a cada execução de .next() .

Veja que uma função do tipo generator tem um * depois da sua definição, isto é a sintaxe que define este tipo de construto da linguagem.

Os generator objects seguem os protocolos definidos do iterable e iterator, o que significa que podemos fazer isso aqui:

Apresentando, Yield

Quando criamos um generator object (o qual vamos nos referir apenas como generator daqui em diante), recebemos um iterador que usa o próprio generator para produzir sua sequência de saída. Sempre que chamamos uma expressão yield , o valor do iterator é emitido como um evento e a execução da função é suspensa. Isso mesmo que você ouviu, podemos “pausar” o código enquanto esperamos um retorno.

Abaixo temos um exemplo diferente, vamos misturar algumas coisas no meio dos nossos yield ‘s. Este exemplo é bem simples, mas ele tem um comportamento muito interessante de se estudar:

Agora temos um generator e ele nos retorna um monte de letras. Vamos iterar por ela:

Isso acontece porque estamos iterando por todos os yield de dentro do nosso generator, a cada yield temos uma letra que nos é retornada e, logo em seguida, rodamos um console.log , depois temos outro yield , então mais uma vez a função é suspensa esperando continuação. Esta continuação nos é dada pelo for … of . Mas e se a gente utilizar um spread operator?

As coisas ficaram um pouco diferentes aqui não é? Pode ser meio inesperado, mas é assim que eles funcionam, tudo que não está com um yield acaba se tornando um efeito colateral. No momento que a nossa sequência (com yield ) está sendo construída, os console.log que intercalamos entre eles vão sendo executados e printam as letras no console antes que o nosso generator possa ser iterado pelo spread.

Isto é diferente do exemplo com for ... of porque, nele, nós estamos printando os caracteres a medida que eles são puxados da sequência, aqui neste caso, o spread operator espera toda a sequencia estar pronta antes de poder printá-la.

Generators de Generators

Para deixar tudo ainda mais simples do que a gente já está vendo, temos uma forma de retornar um generator a partir de outro generator delegando a execução de um para o outro.

Para isso, usamos yield* , desta forma a execução de um generator vai ser delegada para outro. Se, por exemplo, quisermos um jeito bem chique de quebrar a palavra TrainingCenter em um array, podemos utilizar um generator, já que strings aderem ao mesmo protocolo do iterable:

Claro que não faríamos isso no mundo real, podemos fazer apenas [...'TrainingCenter'] já que spread operators conseguem iterar por objetos que implementam a interface iterable numa boa.

Além de strings podemos dar yield* em literalmente qualquer coisa que seja aderente ao protocolo iterable. Isso inclui: outros generators, arrays e basicamente qualquer outra coisa.

Iterando manualmente

Podemos também iterar por tudo isso na mão, usando o .next() , isso te da um maior controle sobre tudo, mas você não consegue tirar proveito de algumas otimizações do interpretador.

Usando o exemplo anterior com a nossa função trailmix podemos, além de for of e spread operators ou até mesmo Array.from , iterar utilizando o próprio generator que é retornado pela função, embora o que a gente fez ali em cima seja um caso muito sofisticado e sem necessidade só para mostrar o yield* podemos voltar para o nosso caso do console ali no inicio:

Todo o item retornado por um objeto iterator vai ter uma propriedade done que indica quando a sequencia terminou, também temos uma propriedade value que é o valor atual da sequencia.

Como funciona o Yield

Um pequeno apêndice aqui apenas para explicar mais ou menos como funciona a ideia por trás do yield e também o por que conseguimos printar a letra ‘r’ no nosso exemplo anterior, mesmo ela estando depois do último item da sequencia.

A razão pela qual isso acontece é que o g.next() não sabe quando uma sequencia acaba. O iterador funciona da seguinte forma:

  1. Toda vez que é chamado executa tudo até chegar em um yield
  2. Quando o yield é alcançado, então o valor é emitido e a função é suspensa naquele ponto exato
  3. Da próxima vez que g.next() for chamado, a execução continua de onde parou até o próximo yield
  4. Quando não há mais yield na função, o generator retorna {done: true} , o que acaba com a execução, mas até isso acontecer, nosso console.log('r') já foi executado.

Sempre que um .next() é chamado em algum generator, existem 4 tipos de coisas que vamos chamar de eventos (mas estão mais para sinais de suspensão) que podem suspender a execução da função e retornar um IteratorResult para quem chamou o .next() .

  • Uma expressão yield retornando o próximo valor da sequência
  • Uma expressão return retornando o último valor da sequência
  • Uma expressão throw que para completamente a execução do generator e lança um erro
  • Chegando ao fim de qualquer uma das funções (aonde nenhuma das expressões acima podem ser encontradas) o generator emite um {done: true}

Uma vez que {done:true} foi emitido, todas as chamadas subsequentes a g.next() vão ser completamente ignoradas e o generator vai retornar {done:true} :

As coisas interessantes

Além do .next() os generators tem alguns outros métodos. Temos o .return e .throw que já falamos ali atrás. Apesar de termos já falado muito sobre o .next tem uma outra coisa que precisamos comentar. Você pode usar .next(value) pra passar um valor para o generator.

Sabe aquela bola 8 que fala algumas frases aleatórias falando se você pode ou não fazer alguma coisa? Vamos tentar fazer um destes. Peguei um modelo online para exemplificar e ele ficou assim:

Ao invés de usarmos o random, podemos muito bem usar um generator para agir como nosso “vidente”. Veja como descartamos o primeiro resultado de g.next() , isso acontece porque a primeira chamada para o .next entra e não temos nenhum yield pronto para capturar o valor passado pela função.

Mas usar esse g.next() só para descartar alguma coisa é meio que um bad smell. Podemos utilizar uma inversão de controles.

Inversão de controles

A princípio, podemos deixar o vidente ter o controle e o generator fazer as perguntas. Pode parecer meio estranho a princípio, mas a maioria das libs que usam generators no fundo fazem isso.

Você esperaria que um generator fizesse todo o trabalho pesado da iteração, mas, na verdade, o generator faz com que iterar sobre coisas fique bem fácil suspendendo a sua própria execução, essa é uma das coisas mais importantes quando falamos deles.

Vamos supor que agora nosso vidente é um método em uma biblioteca:

Fluxos assíncronos

Agora vamos imaginar que nossas respostas venham de uma API, simplesmente chamamos esta API e ele nos devolve um JSON com a assinatura {answer: 'No'} .

Antes de tudo, tenha em mente que estamos fazendo um exemplo simples para processar todas as respostas e perguntas em série, em um modelo real provavelmente você criaria uma fila de execução e processaria isso tudo de uma vez em paralelo.

Mesmo que a gente tenha transformado isso tudo em algo assíncrono fazendo chamadas para uma API, nosso client consumidor ainda vai usar a nossa biblioteca passando a mesma assinatura de função da questions anterior, que permanece inalterada.

Mas como podemos tratar se um erro vem da API?

Erros em generators

Agora sabemos que o aspecto mais importante dos generators é, na verdade, controle de fluxo (um código que simplesmente decide quando chamar g.next() ), podemos analisar os outros métodos e entender seus propósitos.

Antes de eu poder falar para vocês mudarem de ideia e aceitar a seguinte citação:

O generator define o que vamos iterar, e não como vamos iterar

O que com certeza geraria um pouco de confusão, o que fazemos com o g.throw ? Isso não é um modo de pensar no como ao invés no o que?

Depois desse exemplo da API, podemos ter uma visão mais clara de que o controle de fluxo que dá esse benefício todo para os generators precisa saber dizer para o mesmo quando a sequencia que ele está iterando dá errado.

No caso do nosso exemplo com o vidente ali em cima, estamos usando xhr , e isso significa que podemos ter problemas de queda de rede e não possamos mais continuar processando os itens, ou então queremos avisar o usuário sobre algum tipo de erro inesperado que aconteceu. Então podemos simplesmente adicionar g.throw(err) ao invés do nosso comentário lá em cima.

O código que pertence ao usuário fica inalterado, mas agora estamos mandando erros pra ele, o que significa que as coisas podem dar bastante erradas quando o usuário não pega essas nossas exceções. Isso é, felizmente, fácil de resolver, basta que adicionemos uns blocos try/catch entre os yields :

Retornando como um generator

Algo que não é tão interessante em mecanismos de controle de fluxo ou códigos assíncronos é o g.return() . Esse método permite que você continue a execução dentro do próprio generator, muito parecido com o g.throw ali em cima. A diferença principal é que g.return() não vai resultar em uma exception, muito embora ele também finalize a sequencia.

Você pode retornar um valor usando g.return(value) , quando isso é feito o IteratorResult que for retornado vai conter esse seu valor. É o mesmo que fazer um return value em qualquer lugar dentro do generator. No entanto é importante tomar cuidado com isso porque nem os spread operators, Array.from ou for of trazem o value dentro do resultado quando o mesmo é { done: true } :

Pense que é exatamente como o return comum, ele simplesmente vai dar tudo como resolvido. Mas você pode evitar que uma sequencia seja interrompida se ela estiver dentro de um bloco try/finally (sem catch). Neste caso todo o código que esta no finally será executado antes de o valor do g.return ser devolvido. Veja um exemplo:

Concluindo

Bom, não tem muito mais o que eu possa falar para vocês sobre essa feature. Mas, se vocês quiserem entender um pouco mais, leiam este artigo que me ajudou muito em pegar alguns conteúdos (principalmente exemplos) para este post.

Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!

--

--

Lucas Santos
Training Center

Brazilian Programmer, caught between the black screen and rock n' roll 🤘 — Senior software Engineer