O Fio que Une Tudo — Iteradores como Conceito Central

O Fio que Une Tudo — Iteradores como Conceito Central

Um ponteiro generalizado: é isso que o iterador é, e é o que permite escrever uma função uma vez e usá-la sobre vector, set ou lista. O artigo trata da convenção do intervalo meio-aberto, de por que end() fica além do último elemento, e das regras de invalidação que variam de container para container.
Linguagem C++

11 min de leitura

No artigo Buscas Instantâneas — map, set e suas Versões Hash fechamos o tour pelos containers da STL: vector, string, map, set e suas versões hash. Você deve ter notado algo se repetindo em todos eles — o par begin()/end(), o find que devolve "um iterador", o percurso que funciona igual em qualquer container. Isso não é coincidência: existe um conceito por baixo que costura todos esses containers num sistema único e permite que o mesmo algoritmo funcione sobre qualquer um deles. Esse conceito é o iterador, e ele é, talvez, a ideia mais elegante de toda a STL. Hoje você entende o que é um iterador, por que ele generaliza o "ponteiro que percorre" que você conhece do C, e como ele é a ponte que liga containers a algoritmos. Depois desta aula, a biblioteca <algorithm> — próximo artigo — fará todo o sentido.

A ideia: um ponteiro generalizado

Em C, para percorrer um array, você usa um ponteiro que anda de elemento em elemento: incrementa (++p), desreferencia (*p) e compara com o fim. Um iterador é exatamente essa ideia, generalizada para funcionar sobre qualquer container — não só arrays contíguos, mas também árvores (map), tabelas hash (unordered_set) e o que mais existir. Ele oferece a mesma gramática: ++ para avançar, * para acessar o elemento, ==/!= para comparar:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v = {10, 20, 30};

    // begin() aponta para o primeiro elemento; end() para UMA POSIÇÃO ALÉM do último.
    // O laço anda com ++it e para quando alcança end().
    for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << ' ';   // *it desreferencia, como *ponteiro
    }
    std::cout << '\n';   // 10 20 30

    return 0;
}

Esse std::vector<int>::iterator é longo — e é exatamente o tipo de situação onde o auto do artigo Adeus NULL, Bem-vindo à Dedução — nullptr e auto brilha. Escrevendo auto it = v.begin();, o compilador deduz o tipo e o código fica limpo. Guarde esse par mental: iteradores costumam ter tipos verbosos, e auto foi feito, em parte, para eles.

O conceito de "meio-aberto": por que end() fica além do fim

Um detalhe que confunde no início e ilumina depois: end() não aponta para o último elemento, mas para uma posição imaginária logo após ele. O intervalo é meio-aberto: [begin, end) inclui begin e exclui end. Isso parece estranho, mas é profundamente prático. Primeiro, torna o laço natural: it != end() para exatamente quando não há mais elementos. Segundo, um container vazio tem begin() == end(), então o laço simplesmente não executa — sem caso especial. Terceiro, permite representar "não encontrado": quando find devolve end(), significa "cheguei ao fim sem achar":

#include <iostream>
#include <vector>
#include <algorithm>   // std::find

int main() {
    std::vector<int> v = {1, 2, 3, 4};

    auto it = std::find(v.begin(), v.end(), 3);   // procura o 3
    if (it != v.end())                            // achou? (não chegou ao fim)
        std::cout << "encontrado: " << *it << '\n';   // encontrado: 3

    auto it2 = std::find(v.begin(), v.end(), 99);  // procura o 99
    if (it2 == v.end())                            // não achou (chegou ao fim)
        std::cout << "99 não está no vetor\n";
    return 0;
}

Esse padrão — comparar o resultado com end() para saber se achou — é onipresente na STL. Você já o viu com map::find no artigo Buscas Instantâneas — map, set e suas Versões Hash; agora entende que é o mesmo padrão, porque iteradores unificam a interface.

A grande sacada: o mesmo código para qualquer container

Aqui está o poder que justifica tudo. Porque map, set, vector e todos os outros expõem begin()/end() e iteradores com a mesma gramática (++, *, !=), um algoritmo escrito em termos de iteradores funciona sobre qualquer um deles, sem saber qual é. Veja a mesma função de impressão servir a três containers completamente diferentes por dentro:

#include <iostream>
#include <vector>
#include <set>
#include <map>

// Um template (Fase 5) que percorre QUALQUER container via iteradores.
// Não sabe nem se importa se é vector, set ou map — só usa begin/end.
template <typename Iterador>
void imprime(Iterador inicio, Iterador fim) {
    for (auto it = inicio; it != fim; ++it)
        std::cout << *it << ' ';
    std::cout << '\n';
}

int main() {
    std::vector<int> v = {3, 1, 2};
    std::set<int>    s = {3, 1, 2};   // conjunto ordenado

    imprime(v.begin(), v.end());   // 3 1 2 (ordem de inserção do vector)
    imprime(s.begin(), s.end());   // 1 2 3 (o set mantém ordenado)
    return 0;
}

A função imprime foi escrita uma vez e serve ao vector e ao set, cujas estruturas internas (array contíguo versus árvore balanceada) não têm nada em comum. O iterador é a interface que esconde essa diferença. Não se prenda ao template — ele é a Fase 5; o que importa agora é a ideia: iteradores desacoplam algoritmos de containers. Um algoritmo fala a língua dos iteradores; qualquer container que a fale pode usá-lo.

Uma honestidade sobre categorias e invalidação

Prometo sempre os limites, e há dois aqui. Primeiro, nem todos os iteradores são igualmente poderosos. O iterador de um vector é aleatório — você pode pular direto para o elemento it + 5, porque a memória é contígua. Já o de um set ou list é bidirecional — só anda de um em um (++/--), sem saltos, porque a estrutura não é contígua. Isso significa que alguns algoritmos (como ordenação, que precisa de saltos) funcionam com vector mas não com set. Você não precisa decorar as categorias agora, mas saiba que elas existem e explicam por que certos algoritmos exigem certos containers.

Segundo, a armadilha que já vimos em outra roupa: iteradores podem ser invalidados. Assim como uma referência a um elemento de vector fica pendente após uma realocação (artigo O Array que Cresce Sozinho — std::vector a Fundo), um iterador para esse elemento também fica. Modificar um container enquanto você o percorre com iteradores é uma fonte clássica de bugs. A regra de bolso: não altere o tamanho de um container enquanto itera sobre ele, a menos que você saiba exatamente como aquele container trata a invalidação.

O iterador é o que permite escrever uma função uma vez e usá-la sobre qualquer container: ela não conhece a estrutura, só sabe avançar e desreferenciar. O intervalo meio-aberto, com end() uma posição além do último, é a convenção que faz o container vazio ter begin() == end() e a subtração dar o tamanho direto. A parte que exige cuidado é a invalidação — cada container tem suas regras sobre quais operações derrubam iteradores obtidos antes, e o código quebra longe de onde o erro foi cometido.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/iterator: a referência sobre iteradores, suas categorias (input, output, forward, bidirectional, random access) e operações.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre a STL: a apresentação dos iteradores como o elo entre containers e algoritmos.
  • cppreference.com/w/cpp/named_req/Iterator: os requisitos formais que definem cada categoria de iterador — útil para entender por que certos algoritmos exigem certos iteradores.
  • Scott Meyers, Effective STL (2001), Itens 26–30 (sobre iteradores e sua invalidação): conselhos práticos sobre os perigos desta aula.
  • ISO C++ Core Guidelines, seção sobre STL e iteradores: as diretrizes sobre uso seguro de intervalos.

Exercícios

Exercício 1

Percorra um std::vector<std::string> usando explicitamente iteradores (begin()/end() e ++it), imprimindo cada elemento com *it. Depois reescreva o mesmo laço usando auto e comente a diferença de legibilidade.

Ver resposta

✓ Resposta: Com iteradores explícitos e com auto:

#include <iostream>
#include <vector>
#include <string>
int main() {
    std::vector<std::string> nomes = {"Ana", "Bruno", "Carla"};

    // Explícito: tipo verboso
    for (std::vector<std::string>::iterator it = nomes.begin(); it != nomes.end(); ++it)
        std::cout << *it << ' ';
    std::cout << '\n';

    // Com auto: idêntico em comportamento, muito mais legível
    for (auto it = nomes.begin(); it != nomes.end(); ++it)
        std::cout << *it << ' ';
    std::cout << '\n';
    return 0;
}

A versão com auto elimina o std::vector<std::string>::iterator — que só cresce com tipos aninhados — sem perder nada: o compilador deduz o mesmo tipo. É o caso de uso clássico do auto.

Exercício 2

Explique, com suas palavras, por que end() aponta para "uma posição além do último" e não para o último elemento. Cite dois benefícios concretos dessa convenção.

Ver resposta

✓ Resposta: end() aponta para além do último elemento porque isso define um intervalo meio-aberto [begin, end), que inclui o primeiro e exclui a posição final. Dois benefícios concretos: primeiro, um container vazio tem begin() == end(), então qualquer laço while (it != end()) simplesmente não executa, sem precisar de um caso especial para "container vazio"; segundo, permite representar "não encontrado" de forma natural — algoritmos como find devolvem end() para dizer "percorri tudo e não achei", um valor que nunca colide com um elemento real, já que end() não aponta para nenhum.

Exercício 3

Use std::find para localizar um valor num std::vector<int> e trate os dois casos (achou / não achou) comparando o iterador com end(). Depois faça o mesmo com um std::set<int> e comente por que o padrão é idêntico.

Ver resposta

✓ Resposta: Com vector e com set:

#include <iostream>
#include <vector>
#include <set>
#include <algorithm>
int main() {
    std::vector<int> v = {1, 2, 3};
    auto itv = std::find(v.begin(), v.end(), 2);
    std::cout << (itv != v.end() ? "achou no vector\n" : "não achou\n");

    std::set<int> s = {1, 2, 3};
    auto its = s.find(2);   // set tem seu próprio find (mais eficiente que std::find nele)
    std::cout << (its != s.end() ? "achou no set\n" : "não achou\n");
    return 0;
}

O padrão é idêntico — comparar o iterador retornado com end() — porque ambos os containers usam a mesma convenção de iteradores: "achou" significa "o iterador não é end()". A estrutura interna difere, mas a interface de resultado é a mesma, graças à unificação por iteradores.

Exercício 4

O trecho abaixo tem um bug de invalidação de iterador. Explique o que dá errado e proponha uma correção (dica: erase devolve um iterador válido para o próximo elemento).

#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5, 6};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) v.erase(it);   // remover enquanto itera...
}
Ver resposta

✓ Resposta: O bug: v.erase(it) invalida it (o elemento apontado deixa de existir e, num vector, os posteriores se deslocam). O ++it seguinte então opera sobre um iterador inválido — comportamento indefinido, podendo pular elementos ou corromper o laço. Correção usando o iterador que erase devolve:

#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5, 6};
for (auto it = v.begin(); it != v.end(); ) {
    if (*it % 2 == 0)
        it = v.erase(it);   // erase devolve iterador válido para o próximo; NÃO incrementa aqui
    else
        ++it;               // só avança quando NÃO removeu
}

erase retorna um iterador válido para o elemento seguinte; ao atribuí-lo a it e não incrementar nesse caso, o laço continua corretamente. (Alternativamente, o idioma erase-remove com std::remove_if, que veremos no próximo artigo, resolve isso de forma mais limpa.)

Exercício 5

Explique por que a função imprime(inicio, fim) desta aula consegue funcionar tanto com um vector quanto com um set, apesar de suas estruturas internas serem completamente diferentes. O que exatamente os dois containers têm em comum que torna isso possível?

Ver resposta

✓ Resposta: imprime(inicio, fim) funciona com ambos porque ela foi escrita em termos da interface de iteradores — !=, ++it e *it — e nada mais. Tanto vector quanto set fornecem iteradores que suportam exatamente essas três operações: comparar, avançar e desreferenciar. O que os dois têm em comum não é a estrutura interna (um é array contíguo, o outro é árvore balanceada), mas o contrato que seus iteradores cumprem: "posso avançar de um em um, posso ler o elemento atual, posso saber se cheguei ao fim". Enquanto um container honrar esse contrato, imprime funciona sobre ele. É essa separação entre "o que o algoritmo precisa" (a interface do iterador) e "como o container é feito por dentro" que dá à STL sua generalidade.

Comentários

Mais em Linguagem C++

A Vida Começa Aqui — Inicialização de Membros a Fundo
A Vida Começa Aqui — Inicialização de Membros a Fundo

Inicializar membros parece assunto resolvido até aparecerem os casos em que…

A Sabedoria Codificada — Core Guidelines e Código Limpo em C++
A Sabedoria Codificada — Core Guidelines e Código Limpo em C++

As Core Guidelines destilam experiência de quem projetou a linguagem, mas…

A Quarta Pedra — Tratamento de Erros Coerente com std::expected
A Quarta Pedra — Tratamento de Erros Coerente com std::expected

Três ferramentas de erro convivendo sem critério viram bagunça. Aqui elas…