CodeGym /Corsi /C++ SELF /Mini‑igiene delle prestazioni come parte del triage

Mini‑igiene delle prestazioni come parte del triage

C++ SELF
Livello 47, Lezione 3
Disponibile

1. La mini‑igiene delle prestazioni aiuta il triage

Quando sentite la parola «prestazioni», la mente del principiante spesso disegna questa scena: qualcuno in dolcevita nera ottimizza l'algoritmo di bubble sort per renderlo «quasi veloce». Nell'investigazione di bug di memoria la questione è più concreta: le prestazioni contano fino al punto in cui influenzano la stabilità del comportamento. Più ci sono copie e spostamenti casuali in memoria, più rumore si accumula attorno ai bug, e più diventa difficile distinguere causa primaria da conseguenza.

Immaginate di avere un bug di lifetime: da qualche parte avete salvato un puntatore a un elemento di std::vector, poi avete fatto push_back e ottenuto una reallocation. Se la reallocation avviene «a volte» (a seconda della corrente capacity), il bug si manifesterà «a volte». Se durante l'investigazione aggiungete temporaneamente reserve, lo spostamento potrebbe scomparire — e voi o semplificherete il tracciamento, o capirete che il problema non è la reallocation ma un'altra parte del codice. In entrambi i casi guadagnate: il segnale diventa gestibile.

È importante tenere a mente il principio principale del giorno: prima la correttezza, poi l'ottimizzazione. Non faremo tuning del programma per la velocità. Faremo piccole modifiche che contemporaneamente (a) di solito velocizzano il codice, (b) riducono il numero di eventi casuali (copie, spostamenti) e quindi (c) semplificano l'investigazione e rendono i report dei sanitizer più lineari.

2. std::vector: reserve, reallocation e differenza rispetto a resize

std::vector è il cavallo da lavoro dei container, ma ha un tratto caratteristico: conserva gli elementi in un blocco contiguo di memoria. Finché c'è spazio—ottimo. Quando lo spazio manca, il vector alloca un nuovo buffer (di solito più grande), sposta/cop ia gli elementi lì e libera il vecchio buffer. Questo è la reallocation. Dopo di essa tutti i puntatori/riferimenti/iterators/std::span agli elementi del vecchio buffer diventano invalidi.

Ecco uno schema semplice di ciò che succede:

flowchart LR
    A[vector buffer #1
capacity=3] -->|push_back 4th| B[allocate buffer #2
capacity=6] B --> C[move/copy elements] C --> D[free buffer #1]

Nell'investigazione dei bug la parola chiave è «a volte». A volte la capacity basta, a volte no. A volte push_back provoca uno spostamento, a volte no. Di conseguenza lo stesso puntatore sbagliato può «per caso» continuare a puntare ai dati, oppure puntare nel vuoto. In questi momenti il sanitizer somiglia a un detective arrivato sul luogo del delitto dopo una settimana: le tracce ci sono, ma metà sono state cancellate dalla pioggia.

Ed è qui che reserve smette di essere solo «ottimizzazione» e diventa un stabilizzatore dell'esperimento. Se conoscete approssimativamente il limite superiore delle dimensioni, potete chiedere al vector di allocare abbastanza memoria in anticipo, riducendo così il numero di spostamenti.

Esempio minimo in cui reserve rende il comportamento più prevedibile:

#include <vector>
#include <string>

int main() {
    std::vector<std::string> lines;
    lines.reserve(1000); // meno riallocazioni -> meno spostamenti casuali

    lines.push_back("one");
    lines.push_back("two");
}

Nota pratica: nel parlato e nel testo è facile dire «reserve()», ma nel codice reale chiamate reserve(size_type), cioè specificate sempre una dimensione concreta. In debug è utile ricordarlo letteralmente: «riservare questa quantità».

reserve e resize — parole simili, conseguenze diverse

Questi due metodi si confondono spesso perché entrambi sembrano riguardare la dimensione. Ma per semantica appartengono a mondi diversi: reserve riguarda la capacità (capacity), mentre resize riguarda la dimensione logica (size) e persino la creazione/rimozione di elementi. Nel triage questo è critico: confonderli significa avere elementi extra, valori inattesi, talvolta percorsi logici diversi, e trovarvi a investigare un bug diverso da quello originale.

Formuliamolo in termini umani. reserve(n) dice: «caro vector, per favore prepara spazio almeno per n elementi, ma non cambiare ciò che considero elementi realmente presenti». resize(n) dice: «caro vector, ora devi avere veramente n elementi; se mancavano — creane di nuovi, se ce n'erano di più — elimina quelli in eccesso».

Mini‑dimostrazione in un contesto didattico (leggiamo comandi utente e manteniamo una cronologia di stringhe):

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

int main() {
    std::vector<std::string> history;
    history.reserve(3);

    std::cout << history.size() << '\n'; // 0
    history.push_back("add task");
    std::cout << history.size() << '\n'; // 1
}

E ora lo stesso con resize per sentire la differenza:

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

int main() {
    std::vector<std::string> history;
    history.resize(3); // size diventa 3, vengono aggiunte 3 stringhe vuote

    std::cout << history.size() << '\n'; // 3
    std::cout << '"' << history[0] << '"' << '\n'; // ""
}

Nel triage questo è particolarmente importante: se per «ottimizzazione» avete per errore inserito resize, avete modificato il comportamento del programma. L'investigazione deve essere basata su cambiamenti minimi. Quindi ricordate: reserve — di solito è uno «stabilizzatore» sicuro, resize — è già una modifica ai dati e alla logica.

3. Meno copie e signature oneste

La copia in C++ è insidiosa. Il principiante spesso la considera innocua: «beh, ho passato la stringa». Poi scopre che una stringa è un buffer in heap, e copiare significa allocare memoria, trasferire dati, possibili riallocazioni. Anche se tutto è corretto, questo crea uno «sfondo»: più allocazioni, più movimento in memoria, più punti in cui un bug di lifetime può manifestarsi o essere mascherato.

Dal punto di vista dell'investigazione dei bug di memoria le copie superflue fanno danno in due modi. Primo, alterano il «quadro» delle allocazioni: il sanitizer mostra la traccia di allocazioni/deallocazioni e vedete più eventi di quelli necessari. Secondo, le copie possono inavvertitamente prolungare la vita dei dati (per esempio, se fate una copia della stringa — e improvvisamente std::string_view «funziona», anche se in realtà faceva riferimento a un oggetto temporaneo). Questo è particolarmente fastidioso: il bug diventa «non riproducibile» perché lo avete casualmente coperto con una copia.

La mini‑igiene qui è semplice: passare per valore solo quando abbiamo davvero bisogno della copia o della ownership, negli altri casi usare const& o tipi view (se il lifetime lo permette).

Una piccola tabella di riferimento (senza fanatismi, non stiamo scrivendo una tesi):

Cosa passare Tipo parametro Quando è opportuno Cosa ricordare
Piccolo ed economico (int, bool)
int x
Quasi sempre Semplice e chiaro
Grande e proprietario (std::string, std::vector)
const T&
Per leggere, senza copiare I dati devono vivere nel chiamante
Stringa «per il parsing temporaneo»
std::string_view
Parsing/checks all'interno della chiamata Non salvare il view per dopo
Dati contigui
std::span<const T>
Processare array/vector senza copie La sorgente non deve spostarsi

Signature oneste: esempio di task manager console

Per non lasciare il concetto astratto, colleghiamolo a un'applicazione: un semplice «task manager» da console. Abbiamo il modello:

#include <string>

struct Task {
    int id = 0;
    std::string text;
    bool done = false;
};

E uno storage:

#include <vector>

struct TaskStore {
    std::vector<Task> tasks;
};

Ora osserviamo le signature delle funzioni. Supponiamo di voler aggiungere un task. La variante ingenua spesso è così:

#include <string>
#include <vector>

void add_task(std::vector<Task>& tasks, std::string text) {
    tasks.push_back(Task{static_cast<int>(tasks.size()) + 1, text, false});
}

Qui text è passato per valore, quindi copiato alla chiamata. A volte va bene, ma più spesso vogliamo accettare «qualcosa di string‑like» senza copie inutili, e poi conservare una std::string internamente perché il task deve possedere il proprio testo.

Versione più attenta per il nostro caso:

#include <string>
#include <string_view>
#include <vector>

void add_task(std::vector<Task>& tasks, std::string_view text) {
    Task t;
    t.id = static_cast<int>(tasks.size()) + 1;
    t.text = std::string{text}; // facciamo la copia esattamente una volta: nello storage
    t.done = false;
    tasks.push_back(t);
}

Il ragionamento è sottile ma pratico: la funzione non obbliga il chiamante a costruire una std::string. Può accettare una std::string, un literal, o una porzione di stringa. Ma in Task conserviamo una stringa proprietaria, non una std::string_view, per evitare dangling.

Lo stesso vale per la stampa. Se stampiamo i task, passare il container per valore è la classica copia inutile (più memoria, più spostamenti):

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const Task& t : tasks) {
        std::cout << t.id << ". " << t.text << '\n';
    }
}

Non è una «super‑ottimizzazione». È onestà di base: la stampa non dovrebbe possedere la lista di task. Deve soltanto leggerla.

string_view e span — comodi view‑type ma con lifetime rigido

I view‑type sono come guardare i dati «da vicino» senza portarli via. Comodi e veloci, ma se li portate via accidentalmente (salvate il view in un campo, restituite un riferimento a un temporaneo) ottenete il classico «ieri funzionava».

Nel triage i view‑type sono utili perché riducono le copie, quindi riducono allocazioni e spostamenti. Ma questo richiede disciplina sul lifetime.

Consideriamo una tipica funzione che fa il parsing del comando utente. Leggiamo la stringa line e vogliamo verificare se inizia con "add ":

#include <string_view>

bool starts_with_add(std::string_view s) {
    return s.starts_with("add ");
}

Uso:

#include <iostream>
#include <string>

int main() {
    std::string line = "add buy milk";
    std::cout << std::boolalpha << starts_with_add(line) << '\n'; // true
}

Qui è tutto sicuro: line vive più a lungo della chiamata. Ma il pericolo comincia quando si prova a restituire una std::string_view che fa riferimento a una stringa locale:

#include <string>
#include <string_view>

std::string_view bad() {
    std::string tmp = "add buy milk";
    return tmp; // tmp muore -> il view diventa dangling
}

Questa trappola è già nota nel contesto dei problemi di lifetime, e qui è importante come «igiene»: a volte una copia superflua nasconde il problema, mentre una signature onesta lo fa emergere. Regola semplice: std::string_view e std::span sono ottimi come parametri, ma pericolosi come dati memorizzati se non c'è una garanzia di ownership.

reserve più meno copie: stabilizzazione in un frammento reale di codice

Raccogliamo un piccolo frammento dell'applicazione che illustra entrambe le idee: leggiamo comandi, aggiungiamo task, a volte stampiamo la lista. Nell'investigazione è importante che il vector dei task non si sposti ogni due operazioni e che le stringhe non vengano copiate tre volte «perché è successo così».

Scheletro minimale (senza architettura complessa, solo il principio):

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

int main() {
    std::vector<Task> tasks;
    tasks.reserve(100); // stabilizziamo gli indirizzi degli elementi durante il debug

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "list") {
            print_tasks(tasks);
        } else if (line.starts_with("add ")) {
            add_task(tasks, std::string_view(line).substr(4));
        }
    }
}

Qui è importante che add_task accetti std::string_view e che gli passiamo un view su una sottostringa di line. Questo è sicuro perché add_task crea una std::string da text e la mette in Task. Quindi non salviamo il view: lo usiamo «temporaneamente».

E tasks.reserve(100) non è una promessa «avremo esattamente 100 task», ma una mossa pratica: «mentre indaghiamo, facciamo in modo che std::vector non salti troppo spesso nella memoria». Quando il bug è trovato e corretto, il valore può essere rivisto o rimosso se non necessario.

Sottolineo: reserve non garantisce assolutamente che non ci saranno mai spostamenti. Se i task diventano 101 — ci sarà uno spostamento. Ma nel triage spesso è proprio quello che serve: rendere il comportamento «gestibile». Se vogliamo, impostiamo reserve(1000) per testare un'ipotesi, poi togliamo e vediamo se il sintomo cambia.

4. Errori tipici

Errore n.1: trasformare reserve in un amuleto magico «contro tutti i bug».
A volte, dopo un paio di successi, nasce il pensiero: «Ho aggiunto reserve e tutto ha smesso di cadere — quindi è risolto». No. reserve può rimuovere la manifestazione del use‑after‑realloc, ma la causa primaria (conservare un puntatore/riferimento a un elemento di std::vector) rimane. Il bug diventerà solo più subdolo: aspetterà finché i dati non cresceranno.

Errore n.2: confondere reserve e resize, e poi investigare un nuovo problema.
Se volevate ridurre le riallocazioni ma per errore avete chiamato resize, avete cambiato i dati del container. Potrebbero apparire elementi «vuoti», la logica potrebbe comportarsi diversamente, e vi ritroverete a correggere sintomi che prima non c'erano. Nel triage questo è particolarmente doloroso perché si perde il collegamento con il bug originale.

Errore n.3: «ottimizzare» le signature salvando std::string_view dentro il modello.
Scenario comune: uno studente vede che std::string_view evita copie e decide di memorizzarlo in Task per «non copiare mai le stringhe». Poi scopre che la stringa originale era temporanea o riutilizzata, e tutti i task improvvisamente «guardano» lo stesso buffer. Il modello dati dovrebbe quasi sempre possedere ciò di cui ha bisogno per vivere.

Errore n.4: passare oggetti grandi per valore «per semplicità», poi meravigliarsi dell'instabilità.
Passare std::vector<Task> o std::string per valore non è sempre sbagliato, ma se la funzione non deve possedere i dati questo introduce copie nascoste. Quelle copie generano allocazioni e spostamenti extra, e nella diagnostica gli eventi in più possono confondere: il report di ASan diventa più lungo e il comportamento meno ripetibile.

Errore n.5: usare view‑type dove serve un risultato proprietario, e cercare di «riparare» con reserve.
A volte si pensa: «Ritorno una std::string_view, è più veloce». Ma se il valore restituito deve vivere indipendentemente dalla stringa temporanea interna, il view è concettualmente errato. reserve non aiuterà: riguarda i container, non il lifetime di variabili locali. La soluzione corretta è spesso banale: restituire una std::string per valore e dormire sonni tranquilli.

1
Compito
C++ SELF, livello 47, lezione 3
Bloccato
Tabella dei quadrati
Tabella dei quadrati
1
Compito
C++ SELF, livello 47, lezione 3
Bloccato
Concatenazione di frasi
Concatenazione di frasi
1
Compito
C++ SELF, livello 47, lezione 3
Bloccato
Filtro di righe
Filtro di righe
1
Compito
C++ SELF, livello 47, lezione 3
Bloccato
Console delle attività
Console delle attività
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION