La Standard Template Library (STL)
std::vectorstd::map<algorithm>: Sort, Find, e molto altroNelle lezioni precedenti avete imparato l’allocazione dinamica della memoria (new/delete e gli Smart Pointers). Siete ora in grado di costruire il vostro personale Array ridimensionabile, o la vostra lista concatenata.
Ma nella programmazione reale non si fa MAI.
Scrivere strutture dati a mano significa esporre il proprio software a decine di bug invisibili, buffer overflow e inefficienze computazionali. Se vi serve una lista, un albero, o un dizionario, usate quello fornito dal linguaggio.
Il cuore pulsante dell’ecosistema C++ è la STL (Standard Template Library).
Creata originariamente da Alexander Stepanov, è una gigantesca collezione di codice già pronto, iper-ottimizzato da decenni, che si installa automaticamente assieme al compilatore g++.
Come suggerisce il nome, è pesantemente basata sui Template (Lezione 16). Questo significa che la STL vi fornisce lo “stampo” vuoto, e voi ci inserite dentro il tipo che volete (int, double, Layer, std::unique_ptr<Layer>).
L’architettura della STL è un capolavoro di ingegneria del software basata su tre concetti indipendenti ma interconnessi:
Esistono dozzine di Container nella STL, scelti in base alla complessità computazionale (Big O notation) dell’operazione che dovete fare più spesso.
std::vector, std::list, std::deque).std::map, std::set).std::queue FIFO, std::stack LIFO).Inizieremo dal Re indiscusso dei container: std::vector.
Ripassiamo perché gli Array del C (int arr[10];) sono il male.
int main() {
int voti[3] = {28, 30, 24};
// Il C non fa NESSUN CONTROLLO!
1 voti[5] = 18;
// Non posso aggiungerne un quarto senza distruggere
// l'array e ricrearne uno più grande a mano!
}18 nella RAM di un altro programma o variabile. Si chiama Buffer Overflow, ed è la causa del 90% delle vulnerabilità hacker del pianeta.
std::vectorLo std::vector (richiede #include <vector>) risolve tutto. È un Array Dinamico. Si ridimensiona da solo!
#include <vector>
#include <iostream>
int main() {
std::vector<double> loss_history; // Vettore vuoto!
1 loss_history.push_back(0.99);
loss_history.push_back(0.85);
loss_history.push_back(0.40);
std::cout << "Epoche processate: " << loss_history.size() << "\n";
std::cout << "Loss all'epoca 0: " << loss_history[0] << "\n";
}push_back accoda un nuovo elemento in fondo al vettore, e se la RAM non basta, ingrandisce il vettore automaticamente!
Come fa il vector a non farvi fare delete?
Al suo interno, il std::vector è una Classe RAII. Contiene un puntatore dinamico allocato nello Heap. Quando il vettore va “fuori scope” (es. finisce il main), il suo Distruttore viene chiamato, ed esso invocherà automaticamente il delete[] sull’array interno, azzerando i Memory Leaks.
È un contenitore sicuro, pulito ed efficientissimo.
Il vector garantisce che i suoi elementi siano allocati in modo strettamente contiguo in memoria. Se l’elemento 0 è all’indirizzo 0x1000, l’elemento 1 sarà a 0x1004 (se sono interi).
Note
Questa è la proprietà più importante in assoluto per il Machine Learning! Le CPU moderne usano la Cache Memory. Leggere byte contigui in memoria è ordini di grandezza più veloce che saltellare in posizioni casuali della RAM (come farebbe una std::list).
Nel 99% dei casi, se vi serve una lista di oggetti, usate std::vector.
Vi siete mai chiesti cosa succede quando fate push_back in un vettore pieno? Il vettore è costretto a chiedere al sistema operativo un nuovo blocco di RAM più grande, copiare tutti i vecchi dati, e distruggere il blocco vecchio.
Questa operazione (Reallocation) è LENTISSIMA. Per evitarla, il vettore chiede sempre molta più RAM di quanta gliene serva.
size(): Quanti elementi ci sono davvero nel vettore.capacity(): Per quanti elementi c’è spazio prima di dover fare una catastrofica Reallocation.Poiché i Template permettono di inscatolare qualsiasi cosa, potete inscatolare un Vettore dentro un Vettore! È così che si crea una Matrice dinamica in C++.
(Nel vostro progetto finale userete librerie ottimizzate come Eigen per le matrici, ma la logica è identica!).
Eravamo rimasti alla Lezione 16 con la necessità di inglobare questo concetto nell’architettura della nostra Rete.
Uniamo il Potere dei Vettori con il C++11 (unique_ptr e move):
Il Vettore farà tutto il lavoro sporco. Noi penseremo solo all’algoritmo di training.
std::mapL’indice numerico di un array va bene se avete una sequenza. Ma se avete un Database? Supponiamo di voler salvare i nomi di vari Layer e la loro grandezza (numero di neuroni). Non vogliamo usare un array e iterare ogni volta per cercare il nome!
Vogliamo usare una Chiave (Stringa) per estrarre un Valore (Intero).
Questo contenitore Associativo in C++ si chiama std::map (o Dizionario in Python).
La sintassi di std::map è tanto elegante quanto potente. Il primo Template parameter è il tipo della Chiave, il secondo è il tipo del Valore.
#include <map>
#include <string>
#include <iostream>
int main() {
// CHIAVE VALORE
std::map<std::string, int> dimensioni;
// Inserimento intuitivo tramite operator[]
1 dimensioni["Dense_Input"] = 784;
dimensioni["Dense_Hidden"] = 128;
std::cout << "Neuroni Hidden: " << dimensioni["Dense_Hidden"] << "\n";
}Dietro le quinte, std::map non usa un array, ma una struttura dati complessa chiamata Albero Rosso-Nero (Red-Black Tree).
Mantiene le chiavi sempre perfettamente Ordinate (alfabeticamente per le stringhe). Cercare un elemento tra un milione di chiavi richiede solo \(\approx 20\) step (Complessità computazionale: \(O(\log N)\)).
Se vi serve pura e bruta velocità e non vi interessa l’ordinamento, esiste una variante chiamata std::unordered_map (Hash Table) con complessità teorica \(O(1)\).
Come si usano i dizionari nella vita reale? Spesso li riempirete per salvare l’iperparametri di addestramento.
Questa sintassi (tramite l’operatore parentesi quadra []) è la più usata al mondo.
Ma nasconde un difetto terribile.
[]Warning
IL BUG PIÙ INSIDIOSO DEL C++ SULLE MAPPE
Cosa succede se, per un errore di battitura, cercate di leggere una chiave che non esiste?
Il C++ non andrà in crash. E non vi darà Errore! Invece, secernerà silenziosamente una nuova chiave "LearningRat" nella mappa, e la inizializzerà al valore di default (zero). La stampa dirà Learning Rate: 0, sfalsando tutti i vostri calcoli matematici senza che ve ne accorgiate!
.find() in sicurezzaSe non siete sicuri al 100% che la chiave esista, non usate MAI l’operatore [] per fare letture! Usate invece il metodo sicuro .find().
Esso restituisce un puntatore speciale (un Iteratore) che punta alla coppia Trovata. Se non la trova, punta all’abisso finale (ovvero a config.end()).
Torniamo allo std::vector. Come facciamo a stampare tutti i numeri al suo interno? Fin dal 1970 avete imparato a usare un contatore numerico i:
Funziona perfettamente. Ma sapete perché? Perché l’accesso v[i] è istantaneo (memoria contigua). E se i dati non fossero contigui? (Esempio: gli Alberi di std::map). Come fate un ciclo su un Albero? Lì non esiste nessun “indice i-esimo”!
Per risolvere l’iterazione sulle strutture dati complesse (Alberi, Grafo, Liste concatenate), la STL usa gli Iteratori.
Un Iteratore si comporta esattamente come un Puntatore grezzo del C (*ptr), ma è un oggetto intelligente che sa come “scavalcare” da un nodo all’altro della struttura dati quando gli dite di avanzare (ptr++), ignorando l’effettiva architettura di RAM sottostante!
begin() e end()Tutti i Container STL posseggono due funzioni magiche: - container.begin(): Restituisce un iteratore che punta al primissimo elemento. - container.end(): Restituisce un iteratore che punta nel vuoto cosmico sùbito dopo l’ultimo elemento. Serve solo come limite di terminazione.
Usando questi due limiti, è possibile iterare su qualsiasi contenitore dell’Universo senza sapere come sia fatto dentro.
Prima del 2011, si scriveva un ciclo universale tramite Iteratori in questo modo terribile e prolisso:
Lungo, difficile da leggere, pieno di keyword incomprensibili. Molti studenti odiavano il C++ per colpa di righe del genere.
Nel 2011, i designer del C++ hanno capito che la sintassi era indigeribile, e hanno creato due strumenti mastodontici per semplificarci la vita.
1. La keyword auto: Dice al compilatore: “Ehi, sei intelligente, lo sai qual è il tipo che esce da v.begin(). Scrivitelo da solo senza farmi impazzire”.
2. Il Range-based For Loop: Un costrutto speciale che nasconde del tutto la logica degli iteratori begin() ed end() sotto al tappeto!
È la sintassi definitiva, quella che userete nel 99% dei vostri file. Significa letteralmente: “Per ogni elemento all’interno del contenitore”.
Basta. Niente iteratori, niente asterischi, niente size(). Il C++ fa tutto il lavoro noioso in background garantendo le massime performance.
autoAttenzione però, il codice della slide precedente crea una Copia Fisica del valore a ogni iterazione! Se al posto di double avessimo avuto dei DenseLayer, clonare il Layer ad ogni ciclo sarebbe catastrofico.
Tip
Ecco il Vangelo dei For Loop in C++11:
for (auto x : v) -> Sola Lettura. Fa una Copia. Usare solo per tipi piccoli (int, double).for (auto& x : v) -> Lettura/Scrittura. Usa Reference. Se modifichi x, modifichi l’originale in v.for (const auto& x : v) -> Sola Lettura, ma usa Reference. Zero copie. Massima Sicurezza. È IL VOSTRO DEFAULT ASSOLUTO PER OGGETTI GRANDI!Il Range-Based For Loop diventa magico sulle Mappe! Dato che una mappa associa una Chiave a un Valore, ogni elemento che vi torna indietro durante il ciclo è un oggetto doppio, di tipo std::pair. Esso possiede una variabile .first (la chiave) e .second (il valore).
std::algorithm (Perché scrivere For?)E se invece di stampare i numeri volessimo contare quante Loss negative ci sono nell’array? O sommarle? O trovare la massima?
I novizi scrivono a mano cicli for enormi con contatori, flag temporanei e variabili d’appoggio.
I programmatori esperti aprono la <algorithm>. Questa super-libreria usa gli Iteratori per fornirvi la funzione matematica universale in una sola riga di codice pulito.
Includete sempre <algorithm> prima di usarli! Quasi tutti prendono un begin() e un end() come coordinate in cui operare.
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v = {99, 10, 50, 5};
// Ordinamento crescente in un colpo solo (QuickSort ottimizzato)
std::sort(v.begin(), v.end());
// Ora v è {5, 10, 50, 99}
// Trova l'elemento massimo in tutto l'array
auto max_it = std::max_element(v.begin(), v.end());
std::cout << "Il numero più grande è: " << *max_it << "\n";
}Con 2 righe avete risparmiato 20 righe di noioso codice manuale ad alto rischio bug.
Usiamo l’incredibile potenza della STL.
StlTest.cpp; includi <iostream>, <map>, <string>.std::map<std::string, double> Pesi.Pesi["N1"] = 0.5; Pesi["N2"] = -1.2; ...)."N99" con la quadra []. Guarda cosa succede..find(): verifica che non crei la chiave.(const auto& x : Pesi) stampa tutte le coppie: nota che sono ordinate alfabeticamente.