Outro dia um amigo viu eu escrevendo uma função em Python que usava yield ao invés de return e perguntou:

— Qual a utilidade disso? Nunca entendi…

Na verdade eu estava escrevendo um gerador e não uma função. A sintaxe é basicamente a mesma, a única diferença é o que meu amigo reparou: o yield ao invés do return. Mas e aí? Para que serve?

Um função retorna um valor e pronto, digamos que se encerra ali a linda existência dessa função no mundo. Um gerador não, ele te entrega um valor e fica esperando você pedir o próximo.

Por exemplo, posso ter uma função que me retorna uma lista de números [1, 2, 3] e depois eu vejo o que faço com eles. Um gerador me entrega o 1, e espera eu perguntar e aí gerador, o que vem depois?, aí ele me entrega o 2 e assim por diante.

Mas melhor do que isso, acho, é ir para um exemplo prático: imagine uma função, que retorne todos os números inteiros entre 0 e max_number (ok, claro que você pode usar o range direto, mas… imagine apenas):

def numbers_up_to(max_number):
    output = []

    for number in range(max_number + 1):
        output.append(number)

    return output

Escrita assim essa função cria uma lista [0, 1, 2, 3, 4, …] até chegar no max_number. Ela já cria essa lista, aloca na memória, guardando toda a lista e seu conteúdo. Ocupa espaço, usa recursos de hardware para isso. E não tem problema algum se o max_number for pequeno…

… mas tente usar essa função aí com um mol, ou seja, com numbers_up_to(623 * 10 ** 21). Não, não tente. Teu computador vai surtar. Sério.

Para isso temos uma alternativa mais eficiente: geradores! Vamos transformar essa função em um gerador. É simples: não criamos lista nenhuma e usamos o yield ao invés do return:

def numbers_up_to(max_number):
    for number in range(max_number + 1):
        yield number

Agora tente usar essa função com o número gigante: numbers_up_to_as(623 * 10 ** 21). Vai com fé. Dessa vez pode tentar Vai que teu computador não surta assim.

Ele só vai calcular o primeiro elemento da sequência quando precisar dele. E vai te entregar 1 e parar. Não processa mais nada, não aloca nada em memória. Nadinha. Até você pedir o próximo número. Aí ele esquece do primeiro e te entrega o segundo. E assim vai. Você vai pedindo e ele vai entregando um por vez, o terceiro, depois o quarto, depois o quinto e assim por diante. Um por vez.

Ao invés de criar a lista toda, ele cria um gerador (de listas, por exemplo, mas um iterável) e vai calculando um a um os elementos, de acordo com a necessidade de acessá-los… e de fato ele só vai calcular alguma coisa a cada next() – que é a função chamada internamente se você passar um gerador para um for, por exemplo.

Mas o next() também pode ser usado manualmente — o que é ótimo para explorar:

my_first_generator = numbers_up_to(42)
next(my_first_generator)
next(my_first_generator)
next(my_first_generator)
next(my_first_generator)
next(my_first_generator)

Nos meus exemplos, inclusive, o range que é nativo do Python 3 já é um gerador em si.

Geradores são muito úteis e muito gentis com a memória. Mas como nem tudo são flores, claro, trazem algumas limitações: por exemplo, você não consegue usar dois for no mesmo gerador diretamente — geradores só avançam na sequência, não retornam nunca ao começo dela. Então quando o primeiro for esgotar o gerador, o segundo for não vai mais conseguir usá-lo.

Texto curto e simplista, adaptado da rápida resposta que ofereci para meu amigo. Ele disse: Excelente explicação, agora entendi. Então espero que ajude alguém mais além dele ; )