JavaScript — Entendendo Generators
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:
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:
- Toda vez que é chamado executa tudo até chegar em um
yield
- Quando o
yield
é alcançado, então o valor é emitido e a função é suspensa naquele ponto exato - Da próxima vez que
g.next()
for chamado, a execução continua de onde parou até o próximoyield
- 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, nossoconsole.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!