Iteradores são um dos conceitos fundamentais do Python, pois estão por trás de laços for, compreensões de lista e de toda a iteração sobre coleções. Nesta aula, vamos desvendar o protocolo de iteração, que define como objetos podem ser percorridos, e aprender a criar nossos próprios objetos iteráveis.

Vamos começar entendendo a diferença entre objetos iteráveis e iteradores, depois explorar as funções iter() e next(), criar nossas próprias classes iteráveis e, finalmente, dominar o tratamento da exceção StopIteration, que sinaliza o fim da iteração. Ao final, você terá uma base sólida para trabalhar com streams, geradores e pipelines de dados.

Protocolo de iteração

O protocolo de iteração em Python é composto por dois métodos especiais: __iter__() e __next__(). Um objeto é considerado iterável se implementar __iter__(), que deve retornar um objeto iterador. O iterador, por sua vez, implementa __next__(), que retorna o próximo elemento da sequência, ou levanta StopIteration quando não há mais elementos.

Essa distinção é importante: todo iterador é iterável (pois geralmente implementa __iter__() retornando a si mesmo), mas nem todo iterável é um iterador. Por exemplo, uma lista é iterável, mas não é um iterador: quando você chama iter(lista), obtém um iterador separado que percorre a lista. Isso permite que você percorra a lista várias vezes de forma independente, enquanto um iterador é consumido após uma passagem.

Vejamos um exemplo simples de uma classe que implementa ambos os métodos e, portanto, é um iterador e um iterável:

class Contador:
    def __init__(self, limite):
        self.limite = limite
        self.atual = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.atual < self.limite:
            self.atual += 1
            return self.atual - 1
        else:
            raise StopIteration

# Testando
for numero in Contador(3):
    print(numero)

Quando você executa o laço for, o Python chama iter() no objeto Contador(3), que retorna o próprio objeto (porque __iter__ retorna self). Em seguida, chama next() repetidamente até que StopIteration seja levantada. Esse é o mecanismo fundamental por trás de qualquer iteração.

iter e next

As funções embutidas iter() e next() são as portas de entrada para o protocolo. iter(obj) chama o método __iter__ do objeto e retorna o iterador correspondente. Se o objeto não for iterável, levanta TypeError. next(iterator) chama __next__ e retorna o próximo item, ou levanta StopIteration se o iterador estiver esgotado.

Essas funções também aceitam um valor padrão opcional: next(iterator, default) retorna default em vez de levantar StopIteration quando o iterador se esgota. Isso é útil para evitar exceções quando você não tem certeza se ainda há elementos.

Vejamos exemplos práticos:

# Obtendo um iterador a partir de uma lista
lista = [10, 20, 30]
it = iter(lista)
print(next(it))  # 10
print(next(it))  # 20
print(next(it))  # 30
# print(next(it))  # StopIteration (descomente para ver)

# Usando valor padrão
it2 = iter(lista)
print(next(it2, 'fim'))  # 10
print(next(it2, 'fim'))  # 20
print(next(it2, 'fim'))  # 30
print(next(it2, 'fim'))  # fim (não levanta exceção)

A função iter() também pode ser usada com uma função sentinela: iter(callable, sentinel). Nesse caso, a função callable é chamada repetidamente até que retorne o valor sentinel, momento em que o iterador é encerrado. Isso é útil para ler linhas de um arquivo até um marcador, por exemplo:

# Lendo até a linha 'fim'
linhas = []
with open('dados.txt', 'r') as arquivo:
    for linha in iter(lambda: arquivo.readline().strip(), 'fim'):
        linhas.append(linha)

Dominar iter() e next() é essencial para entender como o Python itera internamente e para criar funções que consomem iteradores de forma eficiente.

Criando iteráveis

Para criar seus próprios objetos iteráveis, você pode implementar a interface __iter__ e __next__ em uma classe, como fizemos no primeiro exemplo. Porém, muitas vezes é mais conveniente usar geradores, que são funções que produzem uma sequência de valores sob demanda usando yield. Geradores são uma forma elegante de criar iteradores sem escrever uma classe completa.

Além disso, você pode criar iteráveis que não são iteradores: a classe implementa __iter__ retornando um objeto iterador separado, geralmente definido em uma classe auxiliar. Isso permite que o objeto seja percorrido várias vezes, pois cada chamada a iter() retorna um novo iterador.

Vejamos um exemplo de um iterável que gera a sequência de Fibonacci:

class Fibonacci:
    def __init__(self, quantidade):
        self.quantidade = quantidade

    def __iter__(self):
        return FibonacciIterator(self.quantidade)

class FibonacciIterator:
    def __init__(self, quantidade):
        self.quantidade = quantidade
        self.a, self.b = 0, 1
        self.contador = 0

    def __next__(self):
        if self.contador >= self.quantidade:
            raise StopIteration
        self.contador += 1
        valor = self.a
        self.a, self.b = self.b, self.a + self.b
        return valor

fib = Fibonacci(5)
print(list(fib))  # [0, 1, 1, 2, 3]
print(list(fib))  # [0, 1, 1, 2, 3]  (pode iterar novamente)

Note que a classe Fibonacci não implementa __next__, apenas __iter__; ela é iterável, mas não é um iterador. Cada chamada a iter(fib) cria um novo FibonacciIterator, permitindo múltiplas iterações independentes.

Usando geradores, o mesmo conceito fica muito mais conciso:

def fibonacci(quantidade):
    a, b = 0, 1
    for _ in range(quantidade):
        yield a
        a, b = b, a + b

fib = fibonacci(5)
print(list(fib))  # [0, 1, 1, 2, 3]

Geradores são a forma recomendada para criar iteradores simples, pois são mais legíveis e menos propensos a erros. No entanto, entender a implementação com classes é fundamental para compreender o protocolo subjacente.

StopIteration

A exceção StopIteration é usada para sinalizar o fim da iteração. Quando um iterador não tem mais itens para retornar, seu método __next__ deve levantá-la. O laço for captura essa exceção silenciosamente e termina o loop. Se você chamar next() manualmente e a exceção for levantada, ela se propaga, a menos que você forneça um valor padrão.

Em Python 3.7+, StopIteration dentro de um gerador é transformada em RuntimeError se não for capturada, para evitar bugs sutis. Isso significa que você não deve lançar StopIteration manualmente dentro de um gerador; em vez disso, use return para encerrar a geração.

Vejamos um exemplo de tratamento manual:

it = iter([1, 2, 3])
while True:
    try:
        item = next(it)
        print(item)
    except StopIteration:
        print("Fim da iteração")
        break

Você também pode usar StopIteration para controlar a lógica de funções que consomem iteradores, como na implementação da função reduce ou em algoritmos que precisam saber quando parar. É importante lembrar que StopIteration é uma exceção normal, não um erro: ela é usada para controle de fluxo no protocolo de iteração.

Quando você cria um iterador personalizado, certifique-se de que __next__ levante StopIteration exatamente quando não houver mais itens, e não antes nem depois, para evitar loops infinitos ou itens faltantes.

Boas práticas e observações finais

Algumas recomendações ao trabalhar com iteradores:

  • Prefira geradores a classes para iteradores simples, pois são mais concisos e legíveis.
  • Se você precisa de um iterável que possa ser percorrido várias vezes, implemente __iter__ retornando um novo iterador a cada chamada, em vez de retornar self.
  • Use o valor padrão de next() para evitar exceções quando você não tem certeza se o iterador está esgotado.
  • Não armazene iteradores em estruturas de dados se você precisar percorrê-los mais de uma vez; eles são consumíveis.
  • Lembre-se de que StopIteration é uma exceção de controle, mas em geradores, prefira return a raise StopIteration.

Com esses conceitos, você está pronto para explorar tópicos avançados como itertools, geradores infinitos e pipelines de dados.

Referências

Exercícios

  1. Exercício 1: Implemente uma classe chamada Pares que seja um iterador que gera os primeiros n números pares (0, 2, 4, ...). A classe deve receber n no construtor. Teste com um loop for.
  2. ✓ Resposta:
    class Pares:
        def __init__(self, n):
            self.n = n
            self.atual = 0
            self.contador = 0
    
        def __iter__(self):
            return self
    
        def __next__(self):
            if self.contador >= self.n:
                raise StopIteration
            self.contador += 1
            valor = self.atual
            self.atual += 2
            return valor
    
    # Teste
    for p in Pares(5):
        print(p)  # 0, 2, 4, 6, 8
    
  3. Exercício 2: Usando a função iter() com sentinela, leia linhas do teclado (input) até que o usuário digite "fim". Armazene as linhas digitadas (sem incluir "fim") em uma lista e imprima.
  4. ✓ Resposta:
    linhas = []
    print("Digite linhas (ou 'fim' para encerrar):")
    for linha in iter(lambda: input(), 'fim'):
        linhas.append(linha)
    print("Linhas digitadas:")
    for l in linhas:
        print(l)
    
  5. Exercício 3: Crie um iterável chamado ContagemRegressiva que possa ser percorrido várias vezes. Ele deve gerar números de um valor inicial até 0. Use uma classe auxiliar para o iterador. Teste percorrendo duas vezes.
  6. ✓ Resposta:
    class ContagemRegressiva:
        def __init__(self, inicio):
            self.inicio = inicio
    
        def __iter__(self):
            return ContagemIterator(self.inicio)
    
    class ContagemIterator:
        def __init__(self, inicio):
            self.atual = inicio
    
        def __next__(self):
            if self.atual < 0:
                raise StopIteration
            valor = self.atual
            self.atual -= 1
            return valor
    
    # Teste
    c = ContagemRegressiva(3)
    print(list(c))  # [3, 2, 1, 0]
    print(list(c))  # [3, 2, 1, 0] (novamente)
    
  7. Exercício 4: Escreva um gerador chamado quadrados que receba um número n e gere os quadrados de 1 até n (1², 2², ...). Use um loop for para imprimir os valores.
  8. ✓ Resposta:
    def quadrados(n):
        for i in range(1, n+1):
            yield i*i
    
    for q in quadrados(5):
        print(q)  # 1, 4, 9, 16, 25
    
  9. Exercício 5: Implemente um iterador que leia de um arquivo linha por linha, mas que levante StopIteration quando encontrar uma linha vazia (ou seja, pare no primeiro vazio). Use a classe ArquivoLinhas que recebe o caminho do arquivo. Teste com um arquivo de exemplo.
  10. ✓ Resposta:
    class ArquivoLinhas:
        def __init__(self, caminho):
            self.caminho = caminho
    
        def __iter__(self):
            self.arquivo = open(self.caminho, 'r')
            return self
    
        def __next__(self):
            linha = self.arquivo.readline()
            if linha == '':
                self.arquivo.close()
                raise StopIteration
            linha = linha.rstrip('\n')
            if linha == '':
                self.arquivo.close()
                raise StopIteration
            return linha
    
    # Supondo que 'dados.txt' tenha:
    # linha1
    # linha2
    #
    # linha4
    for l in ArquivoLinhas('dados.txt'):
        print(l)  # imprime linha1, linha2 e para antes da linha4