Vai al contenuto

Contenitori sequenziali: vector, deque, list, array

Esplora i contenitori sequenziali della STL — vector, deque, list, forward_list e array. Scopri i compromessi prestazionali, i layout di memoria e le regole di invalidazione degli iteratori.

Cosa sono i contenitori sequenziali?

I contenitori sequenziali memorizzano gli elementi in un ordine lineare rigoroso. La STL fornisce cinque contenitori sequenziali, ciascuno ottimizzato per diversi pattern di accesso e modifica:

- std::vector — array dinamico, memoria contigua
- std::deque — coda a doppia estremità, inserimento veloce a entrambe le estremità
- std::list — lista doppiamente concatenata
- std::forward_list — lista concatenata singola
- std::array — array a dimensione fissa, allocato sullo stack

Scegliere il contenitore giusto è una delle decisioni più impattanti sulle prestazioni in C++. I fattori chiave sono il pattern di accesso, il pattern di inserimento/cancellazione e la località della cache.

std::vector — La scelta predefinita

std::vector è il contenitore più utilizzato. Memorizza gli elementi in un singolo blocco contiguo di memoria, garantendo eccellenti prestazioni della cache e accesso casuale O(1). L'aggiunta di elementi con push_back è ammortizzata O(1) perché il vector raddoppia la propria capacità quando è pieno.

vector_basics.cpp
#include <iostream>
#include <vector>

int main() {
    std::vector<int> nums;          // empty vector
    nums.reserve(100);              // pre-allocate for 100 elements (no reallocation)

    for (int i = 0; i < 10; ++i) {
        nums.push_back(i * i);      // amortized O(1)
    }

    // Size vs capacity
    std::cout << "Size:     " << nums.size()     << '\n';  // 10
    std::cout << "Capacity: " << nums.capacity() << '\n';  // >= 100

    // Random access — O(1)
    std::cout << "Element 3: " << nums[3] << '\n';         // 9
    std::cout << "Element 3: " << nums.at(3) << '\n';      // 9 (bounds-checked)

    // Erase-remove idiom (pre-C++20)
    nums.erase(std::remove_if(nums.begin(), nums.end(),
                              [](int x) { return x > 50; }),
               nums.end());

    // C++20: std::erase_if (cleaner)
    std::erase_if(nums, [](int x) { return x > 30; });

    // Range-based for loop
    for (int n : nums) {
        std::cout << n << ' ';
    }
    std::cout << '\n';
}

deque, list e forward_list

std::deque fornisce inserimento e cancellazione O(1) sia in testa che in coda, ma utilizza un layout di memoria segmentato (array di blocchi a dimensione fissa) anziché una singola allocazione contigua. std::list è una lista doppiamente concatenata che offre inserimento/cancellazione O(1) in qualsiasi punto dato un iteratore, a scapito di una scarsa località della cache. std::forward_list è una lista concatenata singola che utilizza meno memoria per nodo ma supporta solo lo scorrimento in avanti.

deque_list.cpp
#include <deque>
#include <list>
#include <forward_list>
#include <iostream>

int main() {
    // deque — O(1) push_front and push_back
    std::deque<int> dq;
    dq.push_front(1);   // O(1) — vector cannot do this efficiently
    dq.push_back(2);    // O(1)
    dq.push_front(0);   // O(1)
    // dq: {0, 1, 2}

    // list — O(1) insert/erase at any position with iterator
    std::list<std::string> names = {"Alice", "Charlie", "Eve"};
    auto it = std::next(names.begin());  // points to "Charlie"
    names.insert(it, "Bob");             // O(1) insert before "Charlie"
    names.erase(it);                     // O(1) erase "Charlie"
    // names: {"Alice", "Bob", "Eve"}

    // list has splice — O(1) move of entire sublists
    std::list<std::string> more = {"Frank", "Grace"};
    names.splice(names.end(), more);     // moves all of 'more' into 'names'
    // names: {"Alice", "Bob", "Eve", "Frank", "Grace"}
    // more is now empty

    // forward_list — singly linked, no size(), push_front only
    std::forward_list<int> fl = {3, 1, 4, 1, 5};
    fl.push_front(0);
    fl.sort();           // member sort — O(n log n), stable
    fl.unique();         // remove consecutive duplicates
    // fl: {0, 1, 3, 4, 5}

    for (int v : fl) std::cout << v << ' ';
    std::cout << '\n';
}

std::array — Dimensione fissa, zero overhead

std::array incapsula un array in stile C in una classe appropriata, fornendo .size(), .at(), iteratori e compatibilità con tutti gli algoritmi STL — con zero overhead rispetto a un T[N] grezzo.

std_array.cpp
#include <array>
#include <algorithm>
#include <iostream>

int main() {
    std::array<int, 5> arr = {5, 2, 8, 1, 9};

    std::sort(arr.begin(), arr.end());       // works with STL algorithms

    std::cout << "Size: " << arr.size() << '\n';   // constexpr — known at compile time
    std::cout << "Front: " << arr.front() << '\n';  // 1
    std::cout << "Back:  " << arr.back()  << '\n';  // 9

    // Bounds-checked access
    try {
        arr.at(10);  // throws std::out_of_range
    } catch (const std::out_of_range& e) {
        std::cout << "Out of range: " << e.what() << '\n';
    }

    // Compile-time size via std::tuple_size
    constexpr std::size_t n = std::tuple_size_v<decltype(arr)>;  // 5
    static_assert(n == 5);
}

Scegliere il contenitore sequenziale giusto

Usa questa guida decisionale:

Usa std::vector (l'opzione predefinita) quando hai bisogno di accesso casuale, iterazione cache-friendly o push/pop in coda. La stragrande maggioranza del codice dovrebbe usare vector.

Usa std::deque quando hai bisogno di inserimento/rimozione frequente sia in testa che in coda (ad es., una finestra scorrevole, coda BFS).

Usa std::list quando hai bisogno di inserimento/rimozione O(1) nel mezzo dato un iteratore, o quando hai bisogno di splice() per spostare sotto-liste tra liste senza copiare.

Usa std::forward_list quando la memoria per nodo è importante e ti serve solo lo scorrimento in avanti.

Usa std::array quando la dimensione è nota a tempo di compilazione e non cambierà mai.

Nota sulle prestazioni: A causa degli effetti della cache della CPU, std::vector spesso supera std::list anche per inserimenti nel mezzo sull'hardware moderno, fino a dimensioni sorprendentemente grandi (migliaia di elementi). Misura sempre prima di scegliere una lista concatenata.

Insidia

Ogni contenitore ha regole diverse su quando iteratori, puntatori e riferimenti diventano non validi:

vector — L'inserimento che provoca una riallocazione invalida tutti gli iteratori. L'inserimento senza riallocazione invalida gli iteratori al punto di inserimento o successivi. La cancellazione invalida gli iteratori al punto di cancellazione o successivi.

deque — L'inserimento in testa o in coda invalida tutti gli iteratori ma non i riferimenti/puntatori agli elementi esistenti. L'inserimento nel mezzo invalida tutto. La cancellazione in testa/coda invalida solo l'iteratore dell'elemento cancellato.

list / forward_list — Inserimento e cancellazione non invalidano mai iteratori, riferimenti o puntatori ad altri elementi. Solo l'iteratore dell'elemento cancellato viene invalidato.

array — Gli iteratori non vengono mai invalidati (l'array non può cambiare dimensione).

Un bug comune è modificare un vector all'interno di un ciclo range-based for. Se un push_back provoca una riallocazione, gli iteratori nascosti del ciclo diventano pendenti.

Buona pratica

Se conosci (o puoi stimare) quanti elementi conterrà un vector, chiama reserve() prima di riempirlo. Questo pre-alloca la memoria ed evita riallocazioni costose:

- reserve(n) modifica la capacità ma non la dimensione — non vengono aggiunti elementi.
- resize(n) modifica la dimensione — costruisce effettivamente gli elementi con il costruttore predefinito.
- shrink_to_fit() è una richiesta non vincolante per ridurre la capacità alla dimensione attuale.

Per cicli critici che costruiscono vector, una singola chiamata a reserve() può ridurre drasticamente l'overhead delle allocazioni. Misura con grandi set di dati per vedere la differenza.

Concetti chiave
  • std::vector è la scelta predefinita — la memoria contigua significa iterazione cache-friendly e accesso casuale O(1)
  • Usa reserve() per pre-allocare la memoria del vector quando la dimensione approssimativa è nota
  • std::deque fornisce push/pop O(1) sia in testa che in coda, ma non è contiguo
  • std::list offre insert/erase O(1) con un iteratore e non invalida mai gli altri iteratori
  • std::array è un wrapper a zero overhead attorno agli array C con piena compatibilità STL
  • Le regole di invalidazione degli iteratori differiscono per contenitore — conoscile per evitare bug da iteratori pendenti

Quiz — Metti alla prova le tue conoscenze

(15 XP)

1. Quale contenitore sequenziale fornisce `push_back` ammortizzato O(1) e layout di memoria contigua?

2. Cosa succede a tutti gli iteratori del vector quando un `push_back` provoca una riallocazione?

3. Qual è la differenza tra `reserve(n)` e `resize(n)` su un vector?