Vettori in C++

Una delle caratteristiche più potenti del C++ è la sua libreria standard, che fornisce una vasta gamma di funzionalità pronte all'uso. Una delle componenti più utili della libreria standard è la classe vector, che implementa un array o vettore dinamico, cioè un array che può crescere e ridursi a tempo di esecuzione.

In questa lezione, esamineremo la classe vector, come usarla e come funziona. I vector sono uno dei tipi più utili nella libreria standard e sono ampiamente usati in molti programmi C++.

Concetti Chiave
  • I vector sono collezioni dinamiche di oggetti dello stesso tipo.
  • I vector possono crescere e ridursi a tempo di esecuzione.
  • I vector forniscono operazioni per aggiungere, rimuovere e accedere agli elementi.

L'oggetto vector

Un vector è una collezione di oggetti, tutti dello stesso tipo.

Ogni oggetto nella collezione ha un indice associato, che dà accesso a quell'oggetto. Un vector è spesso chiamato contenitore o container perché contiene altri oggetti. Avremo molto altro da dire sui contenitori nelle prossime lezioni.

Per usare un vector, dobbiamo includere l'header appropriato. Nei nostri esempi, supponiamo anche che sia fatta un'appropriata dichiarazione using:

#include <vector>
using std::vector;

Un vector è un template di classe. C++ ha sia template di classe che template di funzione. Scrivere un template richiede una comprensione abbastanza profonda di C++. Studieremo i template nelle prossime lezioni ma, fortunatamente, possiamo usare i template senza sapere come scriverli.

I template non sono essi stessi funzioni o classi. Invece, possono essere pensati come istruzioni per il compilatore per generare classi o funzioni. Il processo che il compilatore usa per creare classi o funzioni dai template è chiamato istanziazione. Quando usiamo un template, specifichiamo che tipo di classe o funzione vogliamo che il compilatore istanzi.

Per un template di classe, specifichiamo quale classe istanziare fornendo informazioni aggiuntive, la cui natura dipende dal template. Il modo in cui specifichiamo le informazioni è sempre lo stesso: le forniamo all'interno di una coppia di parentesi angolari seguendo il nome del template.

Nel caso dei vector, le informazioni aggiuntive che forniamo sono il tipo degli oggetti che il vector conterrà:

// ivec contiene oggetti di tipo int
vector<int> ivec;

// contiene oggetti di tipo Libro
vector<Libro> libri_vec;

// vector di vector di string
vector<vector<string>> file;

In questo esempio, il compilatore genera tre tipi distinti dal template vector: vector<int>, vector<Libro>, e vector<vector<string>>.

Consiglio

vector è un template, non un tipo

vector è un template, non un tipo. I tipi generati da vector devono includere il tipo di elemento, ad esempio, vector<int>.

Possiamo definire vector per contenere oggetti di quasi ogni tipo. Poiché i riferimenti non sono oggetti, non possiamo avere un vector di riferimenti. Tuttavia, possiamo avere vector della maggior parte degli altri tipi built-in (non riferimento) e della maggior parte dei tipi classe. In particolare, possiamo avere vector i cui elementi sono essi stessi vector.

Vale la pena notare che le versioni precedenti di C++ (rispetto al C++11) usavano una sintassi leggermente diversa per definire un vector i cui elementi sono essi stessi vector (o un altro tipo template). In passato, dovevamo fornire uno spazio tra la parentesi angolare di chiusura del vector esterno e il suo tipo di elemento:

// Valido in C++98 e successivi
vector<vector<int> >

// Valido in C++11 e successivi
vector<vector<int>>
Nota

Attenzione alla versione del compilatore

Alcuni compilatori potrebbero richiedere le dichiarazioni in stile vecchio per un vector di vector, ad esempio:

vector<vector<int> >

Definire e Inizializzare un vector

Come con qualsiasi tipo classe, il template vector controlla come definiamo e inizializziamo i vector. La tabella che segue riassume i modi in cui possiamo definire e inizializzare un vector:

Modo Descrizione
vector<T> v1 vector che contiene oggetti di tipo T. Inizializzazione predefinita; v1 è vuoto.
vector<T> v2(v1) v2 ha una copia di ogni elemento in v1.
vector<T> v2 = v1 Equivalente a v2(v1), v2 possiede una copia degli elementi in v1.
vector<T> v3(n, val) v3 ha n elementi con valore val.
vector<T> v4(n) v4 ha n copie di un oggetto inizializzato di default.
vector<T> v5{a,b,c} v5 ha tanti elementi quanti sono gli inizializzatori; gli elementi sono inizializzati dai corrispondenti inizializzatori.
vector<T> v5 = {a,b,c} Equivalente a v5{a,b,c}.
Tabella 1: Modi in cui possiamo definire e inizializzare un vector

Possiamo inizializzare in modo predefinito un vector, che crea un vector vuoto del tipo specificato:

// Un vettore di stringhe inizialmente vuoto
vector<string> svec;

Potrebbe sembrare che un vector vuoto sia di scarsa utilità. Tuttavia, come vedremo a breve, possiamo (efficientemente) aggiungere elementi a un vector a tempo di esecuzione. Infatti, il modo più comune di usare i vector è definire un vector inizialmente vuoto a cui vengono aggiunti elementi man mano che i loro valori diventano noti a tempo di esecuzione.

Possiamo anche fornire valori iniziali per gli elementi quando definiamo un vector. Ad esempio, possiamo copiare elementi da un altro vector. Quando copiamo un vector, ogni elemento nel nuovo vector è una copia dell'elemento corrispondente nel vector originale. I due vector devono essere dello stesso tipo:

// ivec inizialmente vuoto
vector<int> ivec;

// Aggiungiamo alcuni valori a ivec
ivec.push_back(10);
ivec.push_back(20);
ivec.push_back(30);

// ivec ora contiene 10, 20, 30

// Copia gli elementi di ivec in ivec2
vector<int> ivec2(ivec);

// Copia gli elementi di ivec in ivec3
vector<int> ivec3 = ivec;

// ERRORE: svec e ivec contengono tipi diversi
vector<string> svec = ivec; // errore

Inizializzare un vector con una Lista di Inizializzatori

Esiste un altro modo per fornire valori degli elementi di un vettore a partire dallo standard C++11. Possiamo inizializzare attraverso una lista di inizializzazione un vector da una lista di zero o più valori iniziali di elementi racchiusi in parentesi graffe:

vector<string> articoli = {"un", "uno", "il"};

Il vector risultante ha tre elementi; il primo contiene la string "un", il secondo contiene "uno", e l'ultimo è "il".

Come abbiamo visto, C++ fornisce diverse forme di inizializzazione. In molti casi, ma non tutti, possiamo usare queste forme di inizializzazione in modo intercambiabile.

Finora, abbiamo visto due esempi in cui la forma di inizializzazione è importante:

  • quando usiamo la forma di inizializzazione per copia (cioè, quando usiamo l'operatore =) possiamo fornire solo un singolo inizializzatore;
  • e quando forniamo un inizializzatore in-class dobbiamo usare o l'inizializzazione per copia o usare le parentesi graffe.

Una terza restrizione è che possiamo fornire una lista di valori di elementi solo usando l'inizializzazione a lista in cui gli inizializzatori sono racchiusi in parentesi graffe. Non possiamo fornire una lista di inizializzatori usando le parentesi:

// inizializzazione a lista
vector<string> v1{"un", "uno", "il"};

// ERRORE: non possiamo usare le parentesi
// per fornire una lista di inizializzatori
vector<string> v2("un", "uno", "il");

Creare un Numero Specificato di Elementi

Possiamo anche inizializzare un vector da un conteggio e un valore di un singolo elemento. Il conteggio determina quanti elementi avrà il vector; il valore fornisce il valore iniziale per ciascuno di quegli elementi:

// dieci elementi int, ciascuno inizializzato a -1
vector<int> ivec(10, -1);

// dieci string; ogni elemento è "ciao!"
vector<string> svec(10, "ciao!");

Inizializzazione Multipla di Default

Possiamo solitamente omettere il valore e fornire solo una dimensione.

In questo caso la libreria crea un vettore del numero specificato di elementi, ognuno dei quali è inizializzato in modo predefinito. Ovviamente il valore predefinito dipende dal tipo degli elementi memorizzati nel vector.

Se il vector contiene elementi di un tipo built-in, come int, allora il valore di default vale 0. Se gli elementi sono di un tipo classe, come string, allora ogni elemento è inizializzato in modo predefinito secondo le regole di inizializzazione di quel tipo:

// dieci elementi, ciascuno inizializzato a 0
// Il valore predefinito per int è 0
vector<int> ivec(10);

// dieci elementi, ciascuno una string vuota
// Il valore predefinito per string è la string vuota
vector<string> svec(10);

Ci sono due restrizioni su questa forma di inizializzazione: la prima restrizione è che alcune classi richiedono che forniamo sempre un inizializzatore esplicito. In altre parole, esistono classi che non hanno un inizializzatore di default ma, anzi, richiedono dei parametri espliciti.

Se il nostro vector contiene oggetti di un tipo che non possiamo inizializzare in modo predefinito, allora dobbiamo fornire un valore iniziale; non è possibile creare vector di tali tipi fornendo solo una dimensione.

La seconda restrizione è che quando forniamo un conteggio di elementi senza fornire anche un valore iniziale, dobbiamo usare la forma diretta di inizializzazione:

// VALIDO: Usa l'inizializzazione diretta per fornire una dimensione
vector<int> vi(10);

// ERRORE: deve usare l'inizializzazione diretta per fornire una dimensione
vector<int> vi = 10;

Qui stiamo usando 10 per istruire vector su come creare il vettore: vogliamo un vector con dieci elementi inizializzati di default. Non stiamo "copiando" 10 nel vector. Quindi, non possiamo usare la forma di copia dell'inizializzazione. Vedremo di più su come funziona questa restrizione nelle prossime lezioni.

Ambiguità tra Inizializzatore a Lista e Conteggio di Elementi

In alcuni casi, ciò che significa l'inizializzazione dipende dal fatto che usiamo parentesi graffe o parentesi normali per passare gli inizializzatori.

Ad esempio, quando inizializziamo un vector<int> da un singolo valore int, quel valore potrebbe rappresentare la dimensione del vector o potrebbe essere un valore di un singolo elemento.

Allo stesso modo, se forniamo esattamente due valori int, quei valori potrebbero essere una dimensione e un valore iniziale, o potrebbero essere valori per un vector di due elementi.

Specifichiamo quale significato intendiamo a seconda che usiamo parentesi graffe o parentesi:

// v1 ha dieci elementi, ciascuno inizializzato a 0
// Parentesi normali indicano che 10 è la dimensione
vector<int> v1(10);

// v2 ha un elemento con valore 10
// Parentesi graffe indicano che 10 è un inizializzatore di elemento
vector<int> v2{10};

// v3 ha due elementi, ciascuno inizializzato a 42
// Parentesi normali indicano che 10 e 42 sono dimensione e valore
vector<int> v3(10, 42);

// v4 ha due elementi con valori 10 e 42
// Parentesi graffe indicano che 10 e 42 sono inizializzatori di elemento
vector<int> v4{10, 42};

Quando usiamo le parentesi normali, stiamo dicendo che i valori che forniamo devono essere usati per costruire l'oggetto. Quindi, v1 e v3 usano i loro inizializzatori per determinare la dimensione del vector, e la sua dimensione e valori degli elementi, rispettivamente.

Quando usiamo le parentesi graffe, {...}, stiamo dicendo che, se possibile, vogliamo inizializzare a lista l'oggetto. Cioè, se c'è un modo di usare i valori all'interno delle parentesi graffe come lista di inizializzatori di elementi, la classe lo farà. Solo se non è possibile inizializzare a lista l'oggetto verranno considerati gli altri modi per inizializzare l'oggetto. I valori che forniamo quando inizializziamo v2 e v4 possono essere usati come valori di elementi. Questi oggetti sono inizializzati a lista; i vector risultanti hanno uno e due elementi, rispettivamente.

D'altra parte, se usiamo le parentesi graffe e non c'è modo di usare gli inizializzatori per inizializzare a lista l'oggetto, allora quei valori verranno usati per costruire l'oggetto. Ad esempio, per inizializzare a lista un vector di string, dobbiamo fornire valori che possono essere usati come string. In questo caso, non c'è confusione sul fatto di inizializzare a lista gli elementi o costruire un vector della dimensione data:

// inizializzazione a lista: v5 ha un elemento
vector<string> v5{"ciao"};

// ERRORE: non si può costruire un vector da un letterale string
vector<string> v6("ciao");

// v7 ha dieci elementi inizializzati in modo predefinito
vector<string> v7{10};

// v8 ha dieci elementi con valore "ciao"
vector<string> v8{10, "ciao"};

Sebbene abbiamo usato le parentesi graffe su tutte tranne una di queste definizioni, solo v5 è inizializzata a lista. Per inizializzare a lista il vector, i valori all'interno delle parentesi graffe devono corrispondere al tipo di elemento. Non possiamo usare un int per inizializzare una string, quindi gli inizializzatori per v7 e v8 non possono essere inizializzatori di elementi. Se l'inizializzazione a lista non è possibile, il compilatore cerca altri modi per inizializzare l'oggetto dai valori dati.

Esercizi

  • Quali, se presenti, delle seguenti definizioni di vector sono errate? Per quelle che sono legali, spiega cosa fa la definizione. Per quelle che non sono legali, spiega perché sono illegali.

    vector<vector<int>> ivec;
    vector<string> svec = ivec;
    vector<string> svec(10, "null");
    

    Risposta:

    • vector<vector<int>> ivec; è legale. Definisce un vector di vector di int, inizialmente vuoto.
    • vector<string> svec = ivec; è illegale. Stiamo cercando di inizializzare un vector di stringhe da un vector di vector di int, che sono tipi incompatibili.
    • vector<string> svec(10, "null"); è legale. Definisce un vector di stringhe con 10 elementi, ciascuno inizializzato alla stringa "null".
  • Quanti elementi ci sono in ciascuno dei seguenti vector? Quali sono i valori degli elementi?

    vector<int> v1;
    vector<int> v2(10);
    vector<int> v3(10, 42);
    vector<int> v4{10};
    vector<int> v5{10, 42};
    vector<string> v6{10};
    vector<string> v7{10, "ciao"};
    

    Risposta:

    • vector<int> v1; ha 0 elementi (è vuoto).
    • vector<int> v2(10); ha 10 elementi, ciascuno inizializzato a 0.
    • vector<int> v3(10, 42); ha 10 elementi, ciascuno inizializzato a 42.
    • vector<int> v4{10}; ha 1 elemento, con valore 10.
    • vector<int> v5{10, 42}; ha 2 elementi, con valori 10 e 42.
    • vector<string> v6{10}; ha 10 elementi, ciascuno inizializzato alla stringa vuota.
    • vector<string> v7{10, "ciao"}; ha 10 elementi, ciascuno inizializzato alla stringa "ciao".

Aggiungere Elementi a un vector

Inizializzare direttamente gli elementi di un vector è fattibile solo se abbiamo un piccolo numero di valori iniziali noti, se vogliamo fare una copia di un altro vector, o se vogliamo inizializzare tutti gli elementi allo stesso valore. Più comunemente, quando creiamo un vector, non sappiamo quanti elementi ci serviranno, o non conosciamo il valore di quegli elementi. Anche se conosciamo tutti i valori, se abbiamo un gran numero di valori iniziali di elementi diversi, può essere scomodo specificarli quando creiamo il vector.

Come esempio, se abbiamo bisogno di un vector<int> con valori da 0 a 9, possiamo facilmente usare l'inizializzazione a lista. E se volessimo elementi da 0 a 99 o da 0 a 999? L'inizializzazione a lista sarebbe troppo ingombrante. In tali casi, è meglio creare un vector vuoto e usare un membro di vector chiamato push_back per aggiungere elementi a tempo di esecuzione. L'operazione push_back prende un valore e "spinge" quel valore come nuovo ultimo elemento sul "retro" del vector. Ad esempio:

// vector inizialmente vuoto
vector<int> v2;

for (int i = 0; i != 100; ++i)
    v2.push_back(i); // aggiunge interi sequenziali a v2

// alla fine del ciclo v2 ha 100 elementi, valori 0 ... 99

Anche se sappiamo che alla fine avremo 100 elementi, definiamo v2 come vuoto. Ogni iterazione aggiunge il successivo intero sequenziale come nuovo elemento in v2.

Usiamo lo stesso approccio quando vogliamo creare un vector in cui non sappiamo fino a tempo di esecuzione quanti elementi dovrebbe avere il vector. Ad esempio, potremmo leggere l'input, memorizzando i valori che leggiamo nel vector:

// legge parole dall'input standard e le memorizza come elementi in un vector
string parola;
vector<string> testo; // vector vuoto
while (cin >> parola) {
    testo.push_back(parola); // aggiunge parola a testo
}

Di nuovo, iniziamo con un vector inizialmente vuoto. Questa volta, leggiamo e memorizziamo un numero sconosciuto di valori in testo.

Consiglio

I vector crescono in modo efficiente

Lo standard richiede che le implementazioni di vector possano aggiungere elementi efficientemente a tempo di esecuzione. Poiché i vector crescono efficientemente, è spesso non necessario, e può risultare in prestazioni peggiori, definire un vector di una dimensione specifica.

L'eccezione a questa regola è se tutti gli elementi hanno effettivamente bisogno dello stesso valore. Se sono necessari valori di elementi diversi, è solitamente più efficiente definire un vector vuoto e aggiungere elementi man mano che i valori di cui abbiamo bisogno diventano noti a tempo di esecuzione.

Inoltre, come vedremo nelle prossime lezioni, vector offre capacità per permetterci di migliorare ulteriormente le prestazioni a tempo di esecuzione quando aggiungiamo elementi.

Iniziare con un vector vuoto e aggiungere elementi a tempo di esecuzione è distintamente diverso da come usiamo gli array built-in in linguaggio C e nella maggior parte degli altri linguaggi. In particolare, se sei abituato a usare C o Java, potresti aspettarti che sarebbe meglio definire il vector alla sua dimensione prevista. In realtà, è solitamente il caso contrario in C++.

Conseguenze della Natura Dinamica dei vector

Il fatto che possiamo facilmente ed efficientemente aggiungere elementi a un vector semplifica notevolmente molti compiti di programmazione. Tuttavia, questa semplicità impone un nuovo obbligo sui nostri programmi: dobbiamo assicurarci che qualsiasi ciclo che scriviamo sia corretto anche se il ciclo cambia la dimensione del vector.

Altre implicazioni che seguono dalla natura dinamica dei vector diventeranno più chiare man mano che impariamo di più sul loro utilizzo. Tuttavia, c'è un'implicazione che vale la pena notare già ora: per ragioni che esploreremo nelle prossime lezioni, non possiamo usare un range for se il corpo del ciclo aggiunge elementi al vector.

Nota

Range for e push_back

Il corpo di un range for non deve cambiare la dimensione della sequenza su cui sta iterando.

Esercizi

  • Scrivi un programma per leggere una sequenza di int da cin e memorizzare quei valori in un vector.

    Soluzione:

    #include <iostream>
    #include <vector>
    
    using std::cin;
    using std::cout;
    using std::endl;
    using std::vector;
    
    int main() {
        vector<int> numbers; // vector vuoto
        int value;
        cout << "Inserisci una sequenza di numeri interi (termina con EOF):" << endl;
        while (cin >> value) {
            numbers.push_back(value); // aggiunge il numero al vector
        }
    
        cout << "Hai inserito i seguenti numeri:" << endl;
        for (const auto &num : numbers) {
            cout << num << " ";
        }
        cout << endl;
    
        return 0;
    }
    
  • Riscrivi il programma precedente leggendo string questa volta.

    Soluzione:

    #include <iostream>
    #include <vector>
    #include <string>
    
    using std::cin;
    using std::cout;
    using std::endl;
    using std::vector;
    using std::string;
    
    int main() {
        vector<string> words; // vector vuoto
        string word;
        cout << "Inserisci una sequenza di parole (termina con EOF):" << endl;
        while (cin >> word) {
            words.push_back(word); // aggiunge la parola al vector
        }
    
        cout << "Hai inserito le seguenti parole:" << endl;
        for (const auto &w : words) {
            cout << w << " ";
        }
        cout << endl;
    
        return 0;
    }
    

Altre Operazioni su vector

Oltre a push_back, vector fornisce solo poche altre operazioni, la maggior parte delle quali sono simili alle corrispondenti operazioni sulle string. La tabella che segue elenca le più importanti:

Operazione Descrizione
v.empty() Restituisce true se v è vuoto; altrimenti restituisce false.
v.size() Restituisce il numero di elementi in v.
v.push_back(t) Aggiunge un elemento con valore t alla fine di v.
v[n] Restituisce un riferimento all'elemento alla posizione n in v.
v1 = v2 Sostituisce gli elementi in v1 con una copia degli elementi in v2.
v1 = {a,b,c} Sostituisce gli elementi in v1 con una copia degli elementi nella lista separata da virgole.
v1 == v2, v1 != v2, v1 < v2, v1 <= v2, v1 > v2, v1 >= v2 v1 e v2 sono uguali se hanno lo stesso numero di elementi e ogni elemento in v1 è uguale all'elemento corrispondente in v2. v1 è minore di v2 se il primo elemento che differisce è minore in v1 che in v2.
Tabella 2: Alcune operazioni comuni sui vector

Accediamo agli elementi di un vector allo stesso modo in cui accediamo ai caratteri in una string: attraverso la loro posizione nel vector. Ad esempio, possiamo usare un range for per elaborare tutti gli elementi in un vector:

vector<int> v{1,2,3,4,5,6,7,8,9};

// per ogni elemento in v (nota: i è un riferimento)
for (auto &i : v)
    i *= i; // eleva al quadrato il valore dell'elemento

// per ogni elemento in v
for (auto i : v)
    cout << i << " "; // stampa l'elemento

cout << endl;

Nel primo ciclo, definiamo la nostra variabile di controllo, i, come un riferimento in modo da poter usare i per assegnare nuovi valori agli elementi in v. Lasciamo che auto deduca il tipo di i. Questo ciclo usa una nuova forma dell'operatore di assegnamento composto. Come abbiamo visto, += aggiunge l'operando destro al sinistro e memorizza il risultato nell'operando sinistro. L'operatore *= si comporta in modo simile, tranne che moltiplica gli operandi sinistro e destro, memorizzando il risultato in quello sinistro. Il secondo range for stampa ogni elemento.

I membri empty e size si comportano come i corrispondenti membri di string: empty restituisce un bool che indica se il vector ha elementi, e size restituisce il numero di elementi nel vector. Il membro size restituisce un valore del tipo size_type definito dal corrispondente tipo vector.

// OK: il tipo size_type è definito all'interno di vector<int>
vector<int>::size_type

// ERRORE: size_type non è definito in vector non specializzato
vector::size_type

Gli operatori di uguaglianza e relazionali hanno lo stesso comportamento delle corrispondenti operazioni su string. Due vector sono uguali se hanno lo stesso numero di elementi e se gli elementi corrispondenti hanno tutti lo stesso valore. Gli operatori relazionali applicano un ordinamento da dizionario: se i vector hanno dimensioni diverse, ma gli elementi che sono in comune sono uguali, allora il vector con meno elementi è minore di quello con più elementi. Se gli elementi hanno valori diversi, allora la relazione tra i vector è determinata dalla relazione tra i primi elementi che differiscono.

Possiamo confrontare due vector solo se possiamo confrontare gli elementi in quei vector. Alcuni tipi classe, come string, definiscono il significato degli operatori di uguaglianza e relazionali. Altri tipi potrebbero non definire tali operatori. In tal caso, non possiamo confrontare vector di tali tipi.

Ad esempio, supponiamo di avere una classe Libro che non definisce gli operatori di uguaglianza o relazionali. Non possiamo confrontare due vector<Libro> perché non possiamo confrontare i singoli oggetti Libro in quei vector. D'altra parte, se Libro definisce gli operatori di uguaglianza e relazionali, allora possiamo confrontare due vector<Libro>.

Calcolare un Indice di vector

Possiamo recuperare un dato elemento usando l'operatore di indice. Come con le string, gli indici per vector iniziano da 0; il tipo di un indice è il corrispondente size_type; e, supponendo che il vector sia non const, possiamo scrivere nell'elemento restituito dall'operatore di indice. Inoltre, come abbiamo fatto per le stringhe, possiamo calcolare un indice e recuperare direttamente l'elemento in quella posizione.

Come esempio, supponiamo di avere una collezione di voti che vanno da 0 a 100. Vorremmo contare quanti voti rientrano in vari cluster di 10. Tra zero e 100 ci sono 101 voti possibili. Questi voti possono essere rappresentati da 11 cluster: 10 cluster di 10 voti ciascuno più un cluster per il punteggio perfetto di 100. Il primo cluster conterà i voti da 0 a 9, il secondo conterà i voti da 10 a 19, e così via. Il cluster finale conta quanti punteggi di 100 sono stati ottenuti.

Raggruppando i voti in questo modo, se il nostro input è

42 65 95 100 39 67 95 76 88 76 83 92 76 93

allora l'output dovrebbe essere

0-9: 0
10-19: 0
20-29: 0
30-39: 1
40-49: 1
50-59: 0
60-69: 2
70-79: 3
80-89: 2
90-99: 4
100: 1

che indica che non ci sono stati voti sotto 30, un voto nei 30, uno nei 40, nessuno nei 50, due nei 60, tre nei 70, due negli 80, quattro nei 90, e un voto di 100.

Useremo un vector<unsigned> con 11 elementi per contenere i contatori per ogni cluster. Possiamo determinare l'indice del cluster per un dato voto dividendo quel voto per 10. Quando dividiamo due interi, otteniamo un intero in cui la parte frazionaria viene troncata. Ad esempio, 42/10 è 4, 65/10 è 6 e 100/10 è 10. Una volta calcolato l'indice del cluster, possiamo usarlo per indicizzare il nostro vector e recuperare il contatore che vogliamo incrementare:

#include <iostream>
#include <vector>

using std::cin;
using std::vector;
using std::cout;

int main() {
// conta il numero di voti per cluster di dieci:
// 0-9, 10-19, ..., 90-99, 100

    // 11 bucket, tutti inizialmente 0
    vector<unsigned> punteggi(11, 0);

    unsigned voto;
    while (cin >> voto) { // legge i voti
        if (voto <= 100) // gestisce solo voti validi
            // incrementa il contatore per il cluster corrente
            ++punteggi[voto/10];
    }

    // stampa i risultati
    for (decltype(punteggi.size()) i = 0; i != punteggi.size(); ++i) {
        if (i == 10)
            cout << "100: "; // il cluster finale è solo per il 100
        else
            cout << i*10 << "-" << i*10 + 9 << ": ";
        cout << punteggi[i] << "\n";
    }

    return 0;
}

Iniziamo definendo un vector per contenere i conteggi dei cluster. In questo caso, vogliamo effettivamente che ogni elemento abbia lo stesso valore, quindi allochiamo tutti gli 11 elementi, ciascuno dei quali è inizializzato a 0. La condizione while legge i voti. All'interno del ciclo, verifichiamo che il voto che abbiamo letto abbia un valore valido (cioè, che sia minore o uguale a 100). Supponendo che il voto sia valido, incrementiamo il contatore appropriato per voto.

L'istruzione che fa l'incremento è un buon esempio del tipo di codice conciso caratteristico dei programmi C++. Questa espressione

// incrementa il contatore per il cluster corrente
++punteggi[voto/10];

è equivalente a

// ottiene l'indice del bucket
auto ind = voto/10;
// incrementa il conteggio per quel bucket
punteggi[ind] = punteggi[ind] + 1;

Calcoliamo l'indice del bucket dividendo voto per 10 e usiamo il risultato della divisione per indicizzare punteggi. L'indicizzazione di punteggi recupera il contatore appropriato per questo voto. Incrementiamo il valore di quell'elemento per indicare l'occorrenza di un punteggio nell'intervallo dato.

Come abbiamo visto, quando usiamo un indice, dovremmo pensare a come controllare che gli indici sono nell'intervallo. In questo programma, verifichiamo che l'input sia un voto valido nell'intervallo tra 0 e 100. Quindi, sappiamo che gli indici che possiamo calcolare sono tra 0 e 10. Questi indici sono tra 0 e punteggi.size() - 1.

L'Indicizzazione Non Aggiunge Elementi

I programmatori nuovi a C++ a volte pensano che l'indicizzazione di un vector aggiunga elementi; ma ciò non è vero.

Il seguente codice intende aggiungere dieci elementi a ivec:

vector<int> ivec; // vector vuoto
for (decltype(ivec.size()) ix = 0; ix != 10; ++ix)
    // DISASTRO: ivec non ha elementi
    ivec[ix] = ix;

Tuttavia, è errato: ivec è un vector vuoto; non ci sono elementi da indicizzare! Come abbiamo visto, il modo corretto per scrivere questo ciclo è usare push_back per aggiungere nuovi elementi:

for (decltype(ivec.size()) ix = 0; ix != 10; ++ix)
    // ok: aggiunge un nuovo elemento con valore ix
    ivec.push_back(ix);

L'operatore di indice su vector (e string) recupera un elemento esistente; non aggiunge un elemento.

Nota

Indicizzazione di elementi inesistenti in un vector

È di cruciale importanza capire che possiamo usare l'operatore di indice (l'operatore []) per recuperare solo elementi che effettivamente esistono. Ad esempio:

vector<int> ivec; // vector vuoto
cout << ivec[0]; // errore: ivec non ha elementi!
vector<int> ivec2(10); // vector con dieci elementi
cout << ivec2[10]; // errore: ivec2 ha elementi 0 ... 9

È un errore indicizzare un elemento che non esiste, ma è un errore che il compilatore difficilmente rileverà. Invece, il valore che otteniamo a tempo di esecuzione è indefinito.

Tentare di indicizzare elementi che non esistono è, sfortunatamente, un errore di programmazione estremamente comune e pernicioso. I cosiddetti errori di buffer overflow sono il risultato dell'indicizzazione di elementi che non esistono. Tali bug sono la causa più comune di problemi di sicurezza nelle applicazioni PC e altre.

Un buon modo per assicurarsi che i pedici siano nell'intervallo è evitare del tutto l'indicizzazione usando un range for ogni volta che è possibile.

Esercizi

  • Leggi una sequenza di parole da cin e memorizza i valori in un vector<string>. Dopo aver letto tutte le parole, elabora il vector e cambia ogni parola in maiuscolo. Stampa gli elementi trasformati, otto parole per riga.

    Soluzione:

    #include <iostream>
    #include <vector>
    #include <string>
    #include <cctype> // per std::toupper
    
    using std::cin;
    using std::cout;
    using std::endl;
    using std::vector;
    using std::string;
    
    int main() {
        vector<string> words; // vector vuoto
        string word;
        cout << "Inserisci una sequenza di parole (termina con EOF):" << endl;
        while (cin >> word) {
            words.push_back(word); // aggiunge la parola al vector
        }
    
        // Trasforma ogni parola in maiuscolo
        for (auto &w : words) {
            for (auto &ch : w) {
                ch = std::toupper(ch);
            }
        }
    
        // Stampa le parole trasformate, 8 per riga
        for (size_t i = 0; i < words.size(); ++i) {
            cout << words[i] << " ";
            if ((i + 1) % 8 == 0) {
                cout << endl; // nuova riga ogni 8 parole
            }
        }
        cout << endl;
    
        return 0;
    }
    
  • Il seguente frammento è legale? In caso contrario, come potresti correggerlo?

    vector<int> ivec;
    ivec[0] = 42;
    

    Risposta: No, non è legale perché ivec è un vector vuoto e non ha elementi da indicizzare. Per correggerlo, puoi usare push_back per aggiungere l'elemento:

    vector<int> ivec;
    ivec.push_back(42);
    
  • Elenca tre modi per definire un vector e dargli dieci elementi, ciascuno con il valore 42. Indica se c'è un modo preferito per farlo e perché.

    Risposta:

    1. Usando l'inizializzazione diretta con conteggio e valore:

      vector<int> v1(10, 42);
      

    2. Usando l'inizializzazione a lista:

      vector<int> v2{42, 42, 42, 42, 42, 42, 42, 42, 42, 42};
      

    3. Definendo un vector vuoto e usando un ciclo per aggiungere gli elementi:

      vector<int> v3;
      for (int i = 0; i < 10; ++i) {
          v3.push_back(42);
      }
      

    Il modo preferito è il primo (usando l'inizializzazione diretta con conteggio e valore) perché è conciso, chiaro e efficiente. Non richiede un ciclo esplicito e rende immediatamente evidente che il vector avrà dieci elementi tutti inizializzati a 42.

  • Leggi un insieme di interi in un vector<int>. Stampa la somma di ogni coppia di elementi adiacenti. Modifica il tuo programma in modo che stampi la somma del primo e dell'ultimo elemento, seguita dalla somma del secondo e del penultimo, e così via.

    Soluzione:

    #include <iostream>
    #include <vector>
    
    using std::cin;
    using std::cout;
    using std::endl;
    using std::vector;
    
    int main() {
        vector<int> numbers; // vector vuoto
        int value;
        cout << "Inserisci una sequenza di numeri interi (termina con EOF):" << endl;
        while (cin >> value) {
            numbers.push_back(value); // aggiunge il numero al vector
        }
    
        // Stampa la somma di ogni coppia di elementi adiacenti
        cout << "Somma di ogni coppia di elementi adiacenti:" << endl;
        for (size_t i = 0; i < numbers.size() - 1; ++i) {
            cout << numbers[i] + numbers[i + 1] << " ";
        }
        cout << endl;
    
        // Stampa la somma del primo e dell'ultimo, del secondo e del penultimo, ecc.
        cout << "Somma del primo e dell'ultimo, "
                "del secondo e del penultimo, ecc.:" << endl;
        size_t n = numbers.size();
        for (size_t i = 0; i < n / 2; ++i) {
            cout << numbers[i] + numbers[n - 1 - i] << " ";
        }
        // Se c'è un elemento centrale (numero dispari di elementi), stampalo da solo
        if (n % 2 != 0) {
            cout << numbers[n / 2];
        }
        cout << endl;
    
        return 0;
    }