CodeGym /Corsi /C++ SELF /Iteratori vs puntatori — somiglianze e differenze

Iteratori vs puntatori — somiglianze e differenze

C++ SELF
Livello 40, Lezione 2
Disponibile

1. Introduzione

Se avete già incontrato T* e v.begin()/v.end(), sorge una domanda naturale: «Perché abbiamo due modi diversi per scorrere gli elementi? Non si poteva lasciarne uno solo, per non affaticare il cervello?». Confrontare iteratori e puntatori è un modo per mettere ordine nella testa: capire cosa hanno in comune, dove sono fondamentalmente diversi e perché la libreria standard ha scelto proprio gli iteratori come «linguaggio» principale per container e algoritmi (in C++ ci sono persino sezioni dedicate agli iteratori nei documenti dello standard).

L'obiettivo principale di questa lezione è che, guardando il codice, capiate subito: «ah, qui accediamo direttamente alla memoria (puntatore), mentre qui accediamo tramite l'astrazione del container (iteratore)». E la cosa più importante: smetterete di aspettarvi che un iteratore si comporti «sempre come un puntatore». Questa aspettativa è una delle cause più comuni di errori tra i principianti.

Promemoria: cos'è un puntatore e cosa promette

Un puntatore in C++ è un valore che contiene l'indirizzo di un oggetto in memoria. È come un biglietto con scritto «questa è la strada e l'appartamento dove vive la variabile». Quando scrivete *p, dite: «vai a questo indirizzo e prendi l'oggetto». Quando scrivete p->field, intendete la stessa cosa, ma con accesso comodo al campo di una struct/di una classe. E sì, un puntatore può essere nullptr, cioè «nessun indirizzo», quindi i puntatori sono spesso usati per esprimere opzionalità nelle API.

È importante vedere qui un contratto. Il puntatore di per sé non garantisce che l'indirizzo sia valido, che l'oggetto sia vivo, che la memoria appartenga a voi, o che sia sicuro leggere/scrivere. Dà potere (accesso diretto alla memoria), ma quasi nessuna protezione. Per questo i puntatori hanno la reputazione di «puoi fare tutto, ma un passo falso e sei nei guai».

Mini-esempio per ricordare la meccanica di base:

#include <iostream>

int main() {
    int x = 10;
    int* p = &x;

    *p += 5;
    std::cout << x << '\n'; // 15
}

Qui p contiene l'indirizzo di x, e *p è l'accesso allo stesso oggetto. Niente magia: c'è un solo oggetto.

2. Promemoria: cos'è un iteratore e cosa promette

Un iteratore è un oggetto che permette di «camminare» attraverso gli elementi di un container. Per sensazioni somiglia davvero a un puntatore: si può incrementare (++it), confrontare (it != end), derefere n ziare (*it). Ma l'idea chiave è un'altra: l'iteratore non deve necessariamente essere un «indirizzo grezzo». È una maniglia (handle) o un marcatore di posizione all'interno di un container specifico.

Se il puntatore dice «conosco l'indirizzo in memoria», l'iteratore dice «so come arrivare all'elemento corrente del container, secondo le regole di quel container». Questo è importante perché il container può memorizzare gli elementi in modi diversi da come ve li immaginate. Anche se finora avete lavorato principalmente con std::vector, la libreria standard è costruita sul fatto che i container possono essere diversi, e gli iteratori forniscono un modo uniforme per attraversarli.

In pratica, la libreria standard ama molto gli iteratori: la maggior parte degli algoritmi (std::find, std::sort, std::count_if, ecc.) accetta un intervallo proprio come [begin, end) — cioè una coppia di iteratori.

Mini-esempio con std::vector:

#include <iostream>
#include <vector>

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

    auto it = v.begin();
    std::cout << *it << '\n'; // 10

    ++it;
    std::cout << *it << '\n'; // 20
}

Nota: la sintassi è «come un puntatore», ma il significato è «posizione nel container».

4. Somiglianze: perché l'iteratore spesso sembra un puntatore

La somiglianza tra iteratori e puntatori non è casuale: gli iteratori in C++ sono stati pensati come «puntatori generalizzati». Ovvero entità che permettono di scorrere gli elementi di una struttura dati e ottenere accesso a un elemento — ma non necessariamente tramite un indirizzo diretto. Perciò le operazioni di base sono molto simili: dereferenziazione, avanzamento, confronto di uguaglianza/ineguaglianza.

Un'analogia utile: il puntatore è le «coordinate sulla mappa», mentre l'iteratore è il «navigatore che conosce il percorso all'interno di una città specifica». Il navigatore può mostrare il «posto attuale» e il «punto successivo», ma non siete obbligati a sapere come sono costruite le strade. E se la città viene ristrutturata (il container cambia), il navigatore può non essere più aggiornato — qui entriamo nel tema della validità.

Di seguito una piccola tabella per fissare visivamente la somiglianza di sintassi:

Azione Puntatore T* p Iteratore it
Ottenere l'elemento
*p
*it
Accesso a un campo
p->field
it->field
(se *it è una struct/una classe)
Passare al successivo
++p
(per array/memoria contigua)
++it
Controllo di fine
p != end_ptr
it != end_it

Un altro aspetto che sorprende i principianti: un normale puntatore a un elemento di un array può comportarsi da iteratore per molti algoritmi. Questo è uno dei ponti fra il «mondo dei puntatori» e il «mondo della STL».

Esempio:

#include <algorithm>
#include <iostream>

int main() {
    int a[5]{3, 1, 4, 1, 5};

    int* begin = a;
    int* end = a + 5;

    auto it = std::find(begin, end, 4);
    std::cout << (it != end) << '\n'; // 1
}

Qui int* si comporta come un iteratore: può essere passato a un algoritmo come confine dell'intervallo.

5. Differenze: l'iteratore è una posizione nel container, non un indirizzo

Ora la parte più utile: le differenze. Le somiglianze sono solitamente evidenti, mentre le differenze emergono come errori strani o domande del tipo «perché non compila, se volevo solo fare it + 2?».

L'iteratore è legato al container e alle sue regole. Il container definisce cosa significa «elemento successivo», cosa significa «fine» e quali operazioni sono consentite. Per std::vector l'iteratore è spesso implementato quasi come un puntatore (internamente può essere un normale T*), ma questo è un dettaglio di implementazione, non una promessa del linguaggio. È importante abituarsi a pensare: l'iteratore non è «indirizzo», è un «oggetto di accesso» che può essere più complesso di quanto sembri.

Un'altra differenza chiave è il concetto di end(). Nei container standard end() è la posizione «dopo l'ultimo elemento». Non è un elemento. È una «linea di stop». Non si può dereferenziare. Questo è molto simile al puntatore a + n in un array: è valido come marcatore di confine, ma dereferenziarlo è errore.

Funziona bene questo schema di intervallo:

flowchart LR
    B[begin] --> E1[elem 0] --> E2[elem 1] --> E3[elem 2] --> END[end = dopo l'ultimo]

Di solito scriviamo un ciclo del tipo it != end, perché end non è «l'ultimo elemento», ma «dopo l'ultimo».

Mini-esempio di un'iterazione corretta tipica:

#include <iostream>
#include <vector>

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

    for (auto it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << ' '; // 1 2 3
    }
    std::cout << '\n';
}

Pensiero importante: se pensate «l'iteratore è un puntatore», potreste aspettarvi che tutti gli iteratori supportino, per esempio, la somma di uno shift (it + 5). Ma non è quasi mai vero. Anche se ora vedete che con vector funziona, non trasformate questo comportamento in «regola universale». Meglio adottare questo principio: faccio con un iteratore solo ciò che è chiaramente un accesso corretto: ++, *, confronto con end().

const: const_iterator e puntatori «costanti»

Il tema const può sembrare «burocratico», ma in pratica mostra chiaramente la differenza fra puntatore e iteratore.

Con i puntatori abbiamo diversi significati di const, e questo confonde facilmente. Per esempio, const int* p significa «attraverso p non si possono modificare gli int», mentre int* const p significa «non si può riassegnare p, ma gli int si possono modificare».

Con gli iteratori l'idea è simile, ma si esprime differentemente: c'è l'iteratore normale (condizionalmente «si possono modificare gli elementi») e il const_iterator (condizionalmente «si possono solo leggere gli elementi»). Quando il container è const, begin() restituisce un iteratore in sola lettura, e il tentativo di modificare un elemento tramite *it = ... non compila. Non è «cattiveria del compilatore», ma una protezione incorporata contro modifiche accidentali ai dati dichiarati const.

Esempio:

#include <iostream>
#include <vector>

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

    auto it = v.begin();          // iteratore in lettura
    const int& x = *it;

    std::cout << x << '\n';       // 1
    // *it = 10;                  // errore di compilazione
}

Anche un puntatore può fornire questo comportamento, ma l'iteratore lo fa «di default» tramite i tipi del container: è il container a decidere il tipo di accesso.

6. Pratica: TaskBook e ricerca di un task

Per non lasciare la materia in forma «teorica su oggetti strani», continuiamo con il nostro progetto console didattico TaskBook — una semplice lista di task basata su std::vector<std::string>. Non faremo architetture complesse: solo piccole funzioni che mostrano perché un iteratore è spesso più comodo di un puntatore nel mondo dei container.

Immaginiamo di avere già una lista di task:

#include <string>
#include <vector>

std::vector<std::string> make_demo_tasks() {
    return {"buy milk", "read cpp", "sleep"};
}

Ricerca di un elemento: contratto iteratorio [begin, end)

Quando cercate un task in un vector, l'interfaccia naturale nello stile STL è ritornare un iteratore all'elemento trovato (o end() se non trovato). Questo approccio è «nativo»: funziona subito per la stampa, per la rimozione e per la modifica dell'elemento trovato.

#include <string>
#include <vector>

std::vector<std::string>::iterator find_task(
    std::vector<std::string>& tasks,
    const std::string& query
) {
    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        if (*it == query) return it;
    }
    return tasks.end();
}

Qui si ritorna proprio la «posizione nel container». È comodo: non siamo legati a indici né a dover gestire std::size_t.

Esempio d'uso:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "read cpp", "sleep"};

    auto it = find_task(tasks, "read cpp");
    if (it != tasks.end()) {
        *it += " (30 min)";
    }

    std::cout << tasks[1] << '\n'; // read cpp (30 min)
}

Notate la bellezza: abbiamo trovato l'elemento — e direttamente tramite l'iteratore lo abbiamo aggiornato.

Ricerca equivalente tramite puntatori: funziona, ma è un altro livello di astrazione

Ora immaginiamo di voler fare «la stessa cosa» ma con i puntatori. Il vector ha il metodo data(), che restituisce un T* sull'array interno. Ma qui entriamo subito in un mondo di assunzioni: ci leghiamo al fatto che i dati siano contigui. Per vector questo è vero, ma per i «container in generale» non è detto (ed è una delle ragioni dell'esistenza degli iteratori).

Esempio di ricerca «con puntatori»:

#include <string>
#include <vector>

std::string* find_task_ptr(std::vector<std::string>& tasks, const std::string& q) {
    if (tasks.empty()) return nullptr;

    std::string* p = tasks.data();
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (p[i] == q) return &p[i];
    }
    return nullptr;
}

Ed ecco l'uso:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "read cpp", "sleep"};

    if (auto* p = find_task_ptr(tasks, "sleep")) {
        *p = "sleep (8h)";
    }

    std::cout << tasks.back() << '\n'; // sleep (8h)
}

Funziona, ma si percepisce subito una differenza di «tono» nel codice. La versione con iteratori sembra «lavoro sul container». La versione con puntatori sembra «sono entrato nella memoria del container». All'inizio questo potrebbe non essere critico, ma con la crescita del progetto tali differenze influiranno su sicurezza e portabilità delle soluzioni.

Perché è più facile integrare gli iteratori con gli algoritmi

Il vantaggio pratico principale degli iteratori è che si inseriscono direttamente negli algoritmi della STL. E la cosa più bella è che molti algoritmi accettano un intervallo espresso proprio da iteratori, quindi potete cambiare l'implementazione del container e l'interfaccia di attraversamento rimane coerente.

Per esempio, riscriviamo find_task usando std::find:

#include <algorithm>
#include <string>
#include <vector>

std::vector<std::string>::iterator find_task2(
    std::vector<std::string>& tasks,
    const std::string& query
) {
    return std::find(tasks.begin(), tasks.end(), query);
}

Il codice è diventato più corto e il significato più chiaro: «trova nell'intervallo».

7. Come scegliere: quando serve un puntatore e quando un iteratore

Se proviamo a formulare una regola pratica, senza filosofia, suona così: gli iteratori servono per attraversare container; i puntatori servono per accesso diretto all'oggetto (soprattutto quando l'oggetto può essere assente). In realtà i due strumenti si sovrappongono e talvolta un puntatore è un iteratore (come negli array). Ma, nel codice applicativo, una buona abitudine è non mischiare livelli di astrazione senza motivo.

Il puntatore è buono quando progettate una funzione e volete dire esplicitamente: «il parametro può essere assente» (nullptr). È un segnale più onesto rispetto a cercare di simulare un riferimento nullable. In più il puntatore è il ponte naturale verso API di basso livello e codice che manipola memoria direttamente (ma oggi non entriamo in questi dettagli).

L'iteratore è adatto quando ragionate per «intervalli» e volete lavorare con il container senza presupporre il suo layout interno. La coppia di iteratori [begin, end) è quasi la «presa universale» per l'attraversamento. Passi un intervallo e poi lo si tratta in modo uniforme.

Una piccola «cheat-sheet terapeutica»:

Situazione Cosa scegliere più spesso Perché
«Scorri il container» Iteratori Il container definisce le regole di attraversamento
«Rendi la posizione dell'elemento trovato» Iteratore Comodo confrontare con end(), comodo rimuovere/modificare
«Il parametro può essere assente» Puntatore Esiste nullptr come segnale onesto
«Lavoro con array/buffer» Puntatori (o begin/end) Il puntatore è naturale per memoria contigua

E sì: se vi viene voglia di «fare dell'iteratore un indirizzo perché è più semplice», spesso è un segnale che state risolvendo il problema al livello sbagliato. Meglio fermarsi un attimo e chiedersi: «sto pensando al container o alla memoria?».

8. Errori tipici nel confrontare iteratori e puntatori

Errore n.1: pensare che l'iteratore sia sempre “solo un puntatore”.
Su std::vector gli iteratori spesso si comportano molto come i puntatori e questo rilassa. Ma l'iteratore è un'astrazione, non la promessa di memoria contigua. Se vi abituate a trattare un iteratore esattamente come T*, presto vi troverete in situazioni in cui «sembra logico» ma «non compila». La buona abitudine è usare il minimo necessario di operazioni: *, ++, confronto con end().

Errore n.2: dereferenziare end() o «dopo l'ultimo».
end() non è un elemento. È il confine dell'intervallo. Dereferenziare *end() è un errore logico. I puntatori hanno la stessa trappola: a + n può servire come marcatore, ma non si deve fare *(a + n). Se desiderate «l'ultimo elemento», di solito si usa --end() (con cautela) o metodi del container come back() (se il container non è vuoto).

Errore n.3: tenere un iteratore/puntatore su un elemento e dimenticare che il container potrebbe essere cambiato.
Anche senza entrare nella tabella dettagliata delle invalidazioni (che vedrete più avanti), è utile ricordare un principio generale: se il container modifica la sua struttura interna (per esempio si rialloca su un'altra area), i riferimenti vecchi agli elementi possono diventare non validi. I principianti spesso prendono it su un elemento, poi fanno push_back da qualche parte e poi riusano it e si stupiscono dei comportamenti strani. Aiuta la disciplina: non conservare tali riferimenti a lungo, o controllare con attenzione le modifiche al container.

Errore n.4: confondere «copia del valore» e «riferimento all'elemento» quando si dereferenzia.
In espressioni come auto x = *it ottenete una copia dell'elemento, mentre auto& x = *it — un riferimento all'elemento. Nel primo caso modificare x non cambia il container, nel secondo caso sì. Lo stesso vale per i puntatori (int x = *p vs int& x = *p). Questo errore è particolarmente spiacevole: il programma compila e sembra funzionare, ma fa «qualcosa di diverso» e vi ritrovate a discutere con il monitor per mezz'ora.

Errore n.5: usare puntatori dove si vuole esprimere «attraversamento di un intervallo», e viceversa.
A volte uno studente scrive una funzione per «scorrere un vector» e accetta T* e size, perché «conosco già i puntatori». Funziona, ma si perde il senso del container e aumenta il rischio di errori sui limiti. Altre volte capita il contrario: cercare di esprimere opzionalità con un iteratore (quando sarebbe più onesto usare un puntatore e nullptr). Un'interfaccia ben progettata legge come una proposta: se vuoi scorrere, prendi iteratori; se l'elemento può mancare, prendi un puntatore.

1
Compito
C++ SELF, livello 40, lezione 2
Bloccato
Somma del buffer
Somma del buffer
1
Compito
C++ SELF, livello 40, lezione 2
Bloccato
Frame pari
Frame pari
1
Compito
C++ SELF, livello 40, lezione 2
Bloccato
Etichetta nel registro
Etichetta nel registro
1
Compito
C++ SELF, livello 40, lezione 2
Bloccato
Due ricerche
Due ricerche
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION