CodeGym /Corsi /C++ SELF /Errori I/O — eccezioni vs controllo dello stato

Errori I/O — eccezioni vs controllo dello stato

C++ SELF
Livello 63, Lezione 3
Disponibile

1. Perché l'I/O "funziona" e poi all'improvviso si rompe

I file sono una forma di "realtà" che non è obbligata a essere benevola. Il programma può essere perfetto, ma l'utente l'ha avviato dalla cartella sbagliata, il file è vuoto, i permessi negano la lettura, il disco è pieno, o in mezzo al file invece di un numero c'è la parola cat. E improvvisamente non fate più "lettura dati", ma avete un piccolo giallo da risolvere.

Il problema è che l'I/O in C++ è storicamente concepito così: di default lo stream non è obbligato a scatenare un'isteria (eccezione) in caso di problema. Spesso si limita a impostare flag interni che indicano "non sto bene" e smette di leggere/scrivere. Perciò lo sviluppatore alle prime armi vede spesso il sintomo: "dopo la lettura non succede nulla", "il vettore è vuoto", "la somma è zero", ma non ci sono errori. Classico.

Tra l'altro, il fatto che nella libreria standard ci sia un ampio capitolo su input/output (internamente chiamato input.output) è visibile anche dai rapporti editoriali dello standard: si correggono regolarmente formulazioni e struttura di quella sezione.

Due stili per gestire gli errori nei stream

In C++ abbiamo due approcci principali, ed è importante non cercare di "stare su due sedie" contemporaneamente, specialmente nello stesso blocco di codice.

Il primo stile — controlli manuali dello stato. Apri il file, verifichi if (!in), leggi nel ciclo while (in >> x) o while (getline(in, line)), e dopo il ciclo analizzi: è EOF (normale) o errore (anormale). Questo stile è un po' più verboso, ma ben controllato: distingui esplicitamente "file finito" da "dati corrotti".

Il secondo stile — eccezioni del stream. Dici allo stream: "se stai male — lancia un'eccezione", configurando la maschera con exceptions(...). Il codice diventa più lineare: meno if, più codice "normale", e la gestione degli errori si sposta nel try/catch. Ma questo stile ha delle sottigliezze: per esempio si può accidentalmente trasformare un EOF normale in un'eccezione e ottenere un "errore" dove tutto era regolare.

Oggi analizzeremo entrambi gli approcci e poi li integreremo con cura nel nostro mini-progetto.

2. Stile 1: controllo dello stato del stream

Il controllo manuale dello stato è l'approccio "sono grande, decido io cosa è errore". È ottimo per esercizi didattici e per codice applicativo dove gli errori sono attesi: il file potrebbe non esistere, l'utente potrebbe aver fornito un formato "sporco", e la fine file è spesso una situazione normale, non un motivo per far crollare il programma.

Si comincia sempre allo stesso modo: apri — verifichi. E la verifica if (!in) è spesso più comoda di is_open(), perché considera lo stato complessivo dello stream, non solo il fatto che il descrittore sia "aperto". Poi si legge nella condizione del ciclo, non con while (!eof()) (perché — lo abbiamo già discusso nella lezione precedente).

Mini-template per leggere righe:

#include <fstream>
#include <iostream>
#include <string>

int main() {
    std::ifstream in("tasks.txt");
    if (!in) {
        std::cerr << "Impossibile aprire tasks.txt\n";
        return 1;
    }

    std::string line;
    while (std::getline(in, line)) {
        std::cout << "Letto: " << line << '\n';
    }
}

Qui la cosa chiave: std::getline ritorna lo stream, che nella condizione viene convertito in bool. Se la lettura ha avuto successo — true, altrimenti — false.

4. good/eof/fail/bad: come capire perché la lettura si è fermata

Gli stream I/O mantengono internamente un insieme di flag di stato. Non è necessario conoscerli come "magia dei bit", ma è importante saperli interpretare come cause della terminazione. Altrimenti non distinguerete "abbiamo letto tutto" da "si è rotto tutto alla seconda riga".

Il vocabolario semantico è più o meno il seguente:

Check Cosa significa in parole semplici Esempio tipico
stream.good()
"tutto ok" (nessun errore) avete appena letto/scritto con successo
stream.eof()
"siamo arrivati alla fine" il file è terminato, spesso è normale
stream.fail()
"l'operazione non è riuscita" (spesso formato/parsing) avete tentato di leggere un int, ma c'era cat
stream.bad()
"problema I/O serio" indica, in pratica: lettura dal disco realmente danneggiata

La cosa più pratica: dopo il ciclo di lettura potete capire perché si è fermato. Ad esempio, nella lettura riga per riga di solito ci aspettiamo eof() == true. Se EOF non è vero, ma il ciclo è terminato — allora qualcosa è andato storto.

Esempio: leggiamo righe e poi controlliamo che l'arresto sia dovuto proprio a EOF:

#include <fstream>
#include <iostream>
#include <string>

int main() {
    std::ifstream in("tasks.txt");
    if (!in) return 1;

    std::string line;
    while (std::getline(in, line)) {
        // elaborazione
    }

    if (!in.eof()) {
        std::cerr << "getline si è fermato NON per EOF\n";
        return 2;
    }
}

Nota psicologica importante: eof() non è "sto vicino alla fine". Di solito EOF diventa vero dopo un tentativo di lettura oltre la fine. Per questo il pattern while (!in.eof()) porta quasi sempre a "iterazione in eccesso" e bug strani.

Ora un esempio con numeri, dove è importante distinguere: "file finito" o "dentro c'è spazzatura":

#include <fstream>
#include <iostream>

int main() {
    std::ifstream in("nums.txt");
    if (!in) return 1;

    long long sum = 0;
    int x = 0;

    while (in >> x) {
        sum += x;
    }

    if (in.bad()) {
        std::cerr << "Errore I/O serio\n";
        return 2;
    }
    if (in.fail() && !in.eof()) {
        std::cerr << "Errore di formato: era atteso un numero\n";
        return 3;
    }

    std::cout << "sum=" << sum << '\n'; // sum=...
}

Qui facciamo una distinzione importante: fail() insieme a !eof() — è quasi sempre "formato sbagliato", mentre bad() è "qualcosa di veramente grave" a livello di dispositivo/stream.

5. Stile 2: attivare le eccezioni sullo stream

Le eccezioni sugli stream sono l'opzione "se qualcosa va storto, non farmi chiedere ogni volta 'come stai?' — urla subito". Questo può rendere il codice più breve e diretto, specialmente se l'errore è davvero anomalo: per esempio, "la configurazione è obbligatoria", "il log deve essere scritto", "il file risultato deve essere creato".

Gli stream hanno il metodo exceptions(mask), che imposta: per quali flag di stato lo stream lancerà un'eccezione di tipo std::ios_base::failure. Poi catturate quell'eccezione nel catch e decidete cosa fare (di solito: stampare un messaggio e uscire/interrompere l'operazione).

Importante: qui è solo una panoramica dello strumento. Non rendiamo il corso una "religione delle eccezioni" né richiediamo di scrivere sempre così. L'obiettivo è capire lo strumento e dove è comodo usarlo.

Mini-esempio: "il file è obbligatorio, altrimenti lo consideriamo un errore di esecuzione":

#include <fstream>
#include <iostream>

int main() {
    try {
        std::ifstream in;
        in.exceptions(std::ifstream::failbit | std::ifstream::badbit);

        in.open("config.txt"); // se non si apre — lancerà eccezione

        int mode = 0;
        in >> mode;            // se non c'è un numero — anche questa lancerà eccezione
        std::cout << "mode=" << mode << '\n';
    } catch (const std::ios_base::failure& e) {
        std::cerr << "I/O exception: " << e.what() << '\n';
        return 1;
    }
}

Sì, è compatto. Ma qui è nascosta la "mina": se avete attivato failbit, allora qualsiasi errore di formato diventerà eccezione. A volte è desiderabile (formato rigoroso), altre volte è scomodo (file parzialmente corrotto, lettura best-effort, ecc.). Come sempre: la comodità si paga con le regole d'uso.

6. Maschera exceptions(): failbit, badbit e le trappole dell'EOF

Il punto in cui i principianti "attivano le eccezioni e incontrano dolore" è spesso la scelta della maschera sbagliata. Si vuole mettere "tutto e subito", e poi scoprite che eventi normali diventano "errori". Parliamone con calma.

badbit si riferisce quasi sempre a veri problemi di I/O. È sensato trasformarlo in eccezione: se lo stream è veramente rotto, continuare in genere non ha senso.

failbit è più insidioso. Viene impostato quando un'operazione di lettura non è riuscita. Questo include sia "formato sbagliato" sia "abbiamo tentato di leggere oltre la fine del file". Quindi failbit spesso partecipa alla terminazione regolare della lettura.

eofbit di per sé non è una tragedia, è più spesso "non ci sono più dati". Perciò rendere l'EOF un'eccezione è normalmente strano (a meno che non abbiate uno scenario specifico "il file deve contenere esattamente N byte/righe").

Un'idea pratica per chi inizia: se volete provare le "eccezioni sugli stream", iniziate con badbit. Così "cadiamo" solo su veri problemi I/O, ma non trasformiamo EOF e piccoli errori di formato in eccezioni.

Esempio: attiviamo eccezioni solo su badbit, e formato/EOF li gestiamo con i controlli:

#include <fstream>
#include <iostream>
#include <string>

int main() {
    try {
        std::ifstream in("tasks.txt");
        in.exceptions(std::ifstream::badbit); // solo gravi problemi I/O

        std::string line;
        while (std::getline(in, line)) {
            std::cout << line << '\n';
        }

        if (!in.eof()) {
            std::cerr << "Ci siamo fermati non per EOF\n";
            return 2;
        }
    } catch (const std::ios_base::failure& e) {
        std::cerr << "I/O exception: " << e.what() << '\n';
        return 1;
    }
}

Un altro piccolo dettaglio pratico: anche l'output è bufferizzato, e a volte è utile forzare lo svuotamento del buffer con flush() (o std::flush / std::endl, ma con quest'ultimo siate cauti). L'esistenza e la discussione di basic_ostream::flush() come operazione separata sono evidenti anche dalle revisioni dello standard.

7. Dove "catturare" gli errori: confini di responsabilità

Uno dei problemi più comuni non è la scelta dello stile, ma l'assenza di confini: metà del codice fa if (!in), l'altra metà improvvisamente attiva exceptions(...), e una terza parte ignora tutto e "si affida al destino". Il programma si comporta come un gatto: a volte affettuoso, a volte graffia — e non sapete perché.

Un buon modello mentale: all'interno di una piccola funzione scegliete uno stile dominante e attenetevi a quello. Se la funzione restituisce bool e una stringa d'errore — non dovrebbe improvvisamente lanciare eccezioni "nel mezzo". Se la funzione lancia eccezioni — che sia parte del suo contratto, e catturare tali eccezioni dovrebbe avvenire a un confine chiaro (per esempio in main o nel gestore superiore della CLI).

Di seguito una semplice flowchart che aiuta a scegliere lo stile senza troppe filosofie:

flowchart TD
    A[Operazione su file] --> B{L'errore è previsto?}

    B -->|Sì, è normale| C[Controlliamo lo stato dello stream: if / while / eof / fail / bad]

    B -->|No, lo scenario è rotto| D{Vogliamo codice lineare?}

    D -->|Sì| E[Attiviamo exceptions badbit o failbit e try/catch]
    D -->|No o incerti| C

Se vedete che l'errore "può capitare spesso" (per esempio l'utente ha fornito un percorso sbagliato) — i controlli di stato sono più amichevoli. Se l'errore "è quasi impossibile" (per esempio un file interno che voi stessi avete creato), le eccezioni sono spesso più comode.

8. Mini-refactor: caricamento e salvataggio delle task

Facciamo un piccolo ma reale miglioramento nella nostra applicazione didattica: TaskList CLI (nome convenzionale). Abbiamo in memoria una lista di task: std::vector<std::string> tasks;. Vogliamo poterla salvare su file e ricaricarla. Il formato è il più semplice possibile: una riga — una task. Senza "super-validazione" e contratti complessi — quello lo vedremo nella prossima lezione.

Caricamento nello stile dei controlli

Iniziamo con l'approccio più leggibile per i principianti: la funzione o popola il vettore o ritorna false e scrive un testo di errore.

#include <fstream>
#include <string>
#include <vector>

bool load_tasks(const std::string& filename,
                std::vector<std::string>& tasks,
                std::string& error) {
    std::ifstream in(filename);
    if (!in) { error = "Impossibile aprire il file"; return false; }

    tasks.clear();
    std::string line;
    while (std::getline(in, line)) tasks.push_back(line);

    if (!in.eof()) { error = "Errore di lettura (non EOF)"; return false; }
    return true;
}

Qui è importante che distinguiamo "abbiamo letto tutto" e "ci siamo rotti durante la lettura". Sì, il messaggio è per ora generico — ciò che conta è il pattern.

Salvataggio nello stile dei controlli e "checkpoint"

Ora la scrittura. La comune sofferenza dei principianti: "ho scritto, ma il file è vuoto". Spesso non è magia, ma il programma è terminato prima che il buffer venisse svuotato, oppure la scrittura è fallita (per esempio manca il permesso). Facciamo un semplice "checkpoint": dopo la scrittura controlliamo lo stream.

#include <fstream>
#include <string>
#include <vector>

bool save_tasks(const std::string& filename,
                const std::vector<std::string>& tasks,
                std::string& error) {
    std::ofstream out(filename);
    if (!out) { error = "Impossibile aprire il file in scrittura"; return false; }

    for (const auto& t : tasks) out << t << '\n';
    out.flush(); // checkpoint

    if (!out) { error = "Errore di scrittura"; return false; }
    return true;
}

Chiamiamo esplicitamente flush() per forzare lo svuotamento del buffer, e solo dopo controlliamo if (!out).

Lo stesso caricamento, ma nello stile delle eccezioni

Ora la versione per chi vuole codice lineare e accetta che "errore = eccezione". Per esempio, realizzate un comando interno --import, e in caso di errore volete semplicemente abortire il comando.

#include <fstream>
#include <string>
#include <vector>

std::vector<std::string> load_tasks_throw(const std::string& filename) {
    std::ifstream in;
    in.exceptions(std::ifstream::failbit | std::ifstream::badbit);
    in.open(filename);

    std::vector<std::string> tasks;
    std::string line;
    while (std::getline(in, line)) tasks.push_back(line);
    return tasks;
}

È compatto, ma ricordate la sottigliezza: se alla fine della lettura getline "inciampa" sull'EOF e lo stream imposta failbit, con failbit attivato potreste ricevere un'eccezione su una terminazione normale. Nella pratica questo codice viene spesso rifinito (per esempio cambiando la maschera, o controllando eof() nel catch). Ora l'importante è cogliere l'idea e capire che "attivare tutto" non è sempre la scelta giusta.

Un unico punto di gestione errori in main

Infine, uno scenario semplice: "carico — mostro il numero di task". Notate: la gestione degli errori è concentrata in un solo punto.

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

int main() {
    std::vector<std::string> tasks;
    std::string error;

    if (!load_tasks("tasks.txt", tasks, error)) {
        std::cerr << "Errore durante il caricamento: " << error << '\n';
        return 1;
    }

    std::cout << "Task: " << tasks.size() << '\n'; // Task: ...
}

Questo è il "main snello" che vi tornerà utile: minima logica, massima orchestrazione dello scenario.

9. Errori tipici

Errore n.1: "Ho controllato eof(), quindi va tutto bene".
eof() non è un indicatore di "nessun errore". Indica che lo stream ha raggiunto la fine. Si può ottenere fail() per un errore di formato senza eof(), oppure ottenere fail() insieme a eof() quando si tenta di leggere oltre la fine. Perciò dopo il ciclo conviene domandarsi: "ci siamo fermati per EOF o per errore?", non solo "eof() è vero, quindi tutto ok".

Errore n.2: il pattern while (!in.eof()) e l'"iterazione in eccesso".
Questo ciclo quasi sempre effettua una lettura di troppo, perché EOF diventa vero solo dopo una lettura fallita. Ne consegue che potete processare l'ultima riga due volte, ottenere una riga vuota finale o iniziare a scrivere soluzioni-ponte. Il pattern corretto è leggere direttamente nella condizione: while (getline(in, line)) o while (in >> x).

Errore n.3: avete abilitato exceptions(failbit | badbit) e ottenuto un'eccezione su EOF.
È la trappola classica: volevate che lo stream "si lamentasse", e invece inizia a lamentarsi anche dove la lettura è semplicemente terminata. La soluzione tipica è una sola: o non attivate failbit per scenari dove EOF è regolare, o catturate l'eccezione e verificate separatamente eof(), oppure ristrutturate la lettura in modo che l'EOF non sia considerato un errore del vostro contratto.

Errore n.4: mescolare due stili senza regole e ottenere "a volte return, a volte throw".
Quando parte del codice ritorna false e altra parte lancia eccezioni, è facile dimenticare di gestire una via. Per esempio, open() ha lanciato un'eccezione mentre voi vi aspettavate if (!in); o al contrario catturate eccezioni ma una funzione altrove ha semplicemente ritornato false e l'errore è "perso". Meglio decidere prima: in questa funzione usiamo controlli, in quell'altra usiamo eccezioni, e attenersi alla scelta.

Errore n.5: scrivere su file senza controllare il risultato (e senza checkpoint).
Le operazioni << possono sembrare "riuscite", ma la scrittura potrebbe non essere avvenuta (permessi, disco pieno, errore dispositivo). Se il risultato è importante, fate un controllo sensato: o dopo il blocco di scrittura chiave, o dopo flush(). E sì, std::endl fa anche flush, ma abusarne è una cattiva abitudine costosa da correggere.

1
Compito
C++ SELF, livello 63, lezione 3
Bloccato
Somma fino alla fine
Somma fino alla fine
1
Compito
C++ SELF, livello 63, lezione 3
Bloccato
File con checkpoint
File con checkpoint
1
Compito
C++ SELF, livello 63, lezione 3
Bloccato
Somma da file
Somma da file
1
Compito
C++ SELF, livello 63, lezione 3
Bloccato
Lettura rigorosa
Lettura rigorosa
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION