1 / 1
Indice della lezione

Capitolo 6 - Gestione delle eccezioni

Un programma reale non può assumere che tutto vada sempre secondo i piani.

L'utente può inserire un valore non valido, un'operazione può non essere consentita, una risorsa può non essere disponibile oppure una funzione può ricevere dati che non può elaborare.

Python affronta queste situazioni attraverso le eccezioni.

In questo capitolo impareremo a:

  • distinguere errori di sintassi ed eccezioni;
  • comprendere la gerarchia delle eccezioni;
  • usare try e except;
  • gestire più tipi di eccezione;
  • utilizzare else e finally;
  • recuperare informazioni dall'eccezione;
  • usare raise;
  • rilanciare un'eccezione;
  • creare eccezioni personalizzate;
  • usare il chaining delle eccezioni;
  • progettare una gestione degli errori chiara e robusta;
  • comprendere i context manager e with;
  • collegare le eccezioni alle classi viste nel capitolo precedente.

Nota: il prossimo capitolo sarà dedicato a File I/O. Qui introdurremo soltanto il minimo necessario sui context manager per capire perché with è importante nella gestione delle risorse.


6.1 Cosa sono gli errori?

Prima di parlare di eccezioni è utile distinguere due grandi categorie di problemi.

Errori di sintassi

Sono errori che impediscono al programma di essere interpretato correttamente.

if True
    print("Ciao")

Manca : dopo True.

Python segnala un:

SyntaxError

Un altro esempio:

print("Ciao"

Anche in questo caso la sintassi è incompleta.

Gli errori di sintassi devono essere corretti nel codice: non vengono normalmente gestiti con try/except.


6.2 Le eccezioni

Un programma può invece essere sintatticamente corretto ma incontrare un problema durante l'esecuzione.

numero = int("abc")

Il codice è sintatticamente valido, ma la stringa "abc" non può essere convertita in un intero.

Python genera:

ValueError

Un altro esempio:

risultato = 10 / 0

genera:

ZeroDivisionError

Questi sono esempi di eccezioni.

Errore di sintassi vs eccezione

Problema Esempio Tipologia


sintassi non valida if True senza : SyntaxError conversione non valida int("abc") ValueError tipo non compatibile "10" + 5 TypeError divisione per zero 10 / 0 ZeroDivisionError indice inesistente lista[10] IndexError chiave inesistente dizionario["x"] KeyError

La distinzione fondamentale è:

Un errore di sintassi riguarda la validità del codice; un'eccezione rappresenta un problema che si verifica durante l'esecuzione.


6.3 Cosa succede quando un'eccezione non viene gestita?

Consideriamo:

numero = int("abc")
print("Fine")

Quando viene eseguito:

int("abc")

Python genera un ValueError.

L'esecuzione della funzione corrente si interrompe e l'eccezione risale nello stack delle chiamate cercando un gestore compatibile.

Se nessuno la gestisce, il programma termina e Python mostra un traceback.

Il traceback contiene informazioni utili, tra cui:

  • il punto in cui si è verificato il problema;
  • la catena delle chiamate;
  • il tipo dell'eccezione;
  • il messaggio associato.

Imparare a leggere un traceback è una competenza fondamentale per il debugging.


6.4 Il primo try/except

La struttura base è:

try:
    istruzioni_rischiose
except TipoEccezione:
    gestione_dell_errore

Esempio:

try:
    numero = int(input("Inserisci un numero: "))
    print(numero)
except ValueError:
    print("Devi inserire un numero valido")

Se l'utente inserisce:

25

non si verifica alcuna eccezione.

Se inserisce:

ciao

viene eseguito il blocco except.

Flusso

        ┌─────────────┐
        │    try      │
        └──────┬──────┘
               │
        ┌──────▼──────┐
        │ eccezione?  │
        └──┬───────┬──┘
           │ no    │ sì
           │       │
           ▼       ▼
        continua  except

Se il codice nel try termina senza eccezioni, il blocco except viene saltato.


6.5 Una regola importante: proteggere solo il codice necessario

Evitiamo di scrivere:

try:
    # cinquanta righe di codice
    ...
except ValueError:
    ...

È preferibile limitare il try all'operazione che può realmente generare l'eccezione.

Meglio:

testo = input("Numero: ")

try:
    numero = int(testo)
except ValueError:
    print("Numero non valido")
else:
    print(numero)

In questo modo è più facile capire quale operazione stiamo proteggendo.

Un try troppo grande può nascondere errori che non avevamo intenzione di gestire.


6.6 Gestire più eccezioni

Possiamo avere più blocchi except:

try:
    numero = int(input("Numero: "))
    risultato = 10 / numero
except ValueError:
    print("Devi inserire un numero")
except ZeroDivisionError:
    print("Non puoi dividere per zero")

Python esegue il primo except compatibile con l'eccezione generata.

Esempio

Se l'utente inserisce:

abc

si verifica:

ValueError

Se inserisce:

0

la conversione riesce, ma:

10 / 0

genera:

ZeroDivisionError

6.7 Gestire più eccezioni con un solo except

Se vogliamo gestire nello stesso modo più eccezioni:

try:
    ...
except (ValueError, TypeError):
    print("Dato non valido")

Le parentesi contengono una tupla di tipi di eccezione.

Questo è utile quando la strategia di gestione è identica.

Se invece i due errori richiedono messaggi o comportamenti diversi, è meglio utilizzare due blocchi distinti.


6.8 Alcune eccezioni comuni

ValueError

Il tipo è corretto, ma il valore non è accettabile.

int("abc")

TypeError

L'operazione non è compatibile con il tipo degli oggetti.

"10" + 5

ZeroDivisionError

Divisione intera, floating point o modulo per zero quando non consentiti.

10 / 0

IndexError

Indice fuori dai limiti di una sequenza.

numeri = [10, 20, 30]
print(numeri[10])

KeyError

Accesso a una chiave inesistente:

utente = {"nome": "Anna"}
print(utente["email"])

FileNotFoundError

Tentativo di aprire un file che non esiste.

open("inesistente.txt")

Il File I/O verrà approfondito nel prossimo capitolo.

AttributeError

Tentativo di utilizzare un attributo o metodo inesistente:

numero = 10
numero.saluta()

NameError

Uso di un nome che Python non conosce:

print(nome_inesistente)

ImportError e ModuleNotFoundError

Possono verificarsi durante gli import quando un modulo o un elemento non è disponibile.


6.9 La gerarchia delle eccezioni

Le eccezioni sono organizzate in una gerarchia di classi.

In alto troviamo:

BaseException
    ├── BaseExceptionGroup
    ├── GeneratorExit
    ├── KeyboardInterrupt
    ├── SystemExit
    └── Exception

La maggior parte delle eccezioni applicative deriva da:

Exception

Per esempio, concettualmente:

Exception
├── ValueError
├── TypeError
├── LookupError
│   ├── IndexError
│   └── KeyError
└── OSError
    └── FileNotFoundError

Questo spiega perché possiamo scrivere:

except LookupError:
    ...

per intercettare sia IndexError sia KeyError.

Exception vs BaseException

In generale, nelle applicazioni si gestiscono eccezioni derivate da:

Exception

Non è buona pratica scrivere:

except BaseException:
    ...

perché questo include anche segnali come KeyboardInterrupt e SystemExit, che normalmente devono poter interrompere il programma.


6.10 L'ordine degli except

L'ordine dei blocchi è importante.

Sbagliato:

try:
    ...
except Exception:
    print("Errore generico")
except ValueError:
    print("Valore non valido")

Il secondo blocco è di fatto irraggiungibile per un ValueError, perché ValueError è già una sottoclasse di Exception.

Meglio:

try:
    ...
except ValueError:
    print("Valore non valido")
except Exception:
    print("Altro errore")

Regola:

Prima le eccezioni più specifiche, poi quelle più generiche.


6.11 Catturare l'oggetto eccezione

Possiamo ottenere l'oggetto eccezione con as:

try:
    numero = int("abc")
except ValueError as errore:
    print("Errore:", errore)

L'oggetto errore contiene informazioni sull'eccezione.

Possiamo utilizzarlo per produrre un messaggio più preciso:

try:
    numero = int(testo)
except ValueError as errore:
    print(f"Conversione fallita: {errore}")

Non mostrare automaticamente dettagli tecnici all'utente

In un'applicazione reale, il messaggio dell'eccezione può essere utile al programmatore ma non necessariamente adatto all'utente finale.

Possiamo quindi separare:

dettaglio tecnico → log
messaggio comprensibile → utente

6.12 else

Il blocco else viene eseguito solo se nel try non si è verificata alcuna eccezione.

try:
    numero = int(input("Numero: "))
except ValueError:
    print("Input non valido")
else:
    print(f"Hai inserito {numero}")

Perché è utile?

Permette di separare:

  • operazione potenzialmente rischiosa;
  • comportamento da eseguire se l'operazione è riuscita.

Esempio:

try:
    numero = int(input("Numero: "))
except ValueError:
    print("Numero non valido")
else:
    risultato = numero * 2
    print(risultato)

È spesso più chiaro di inserire tutto nel try.


6.13 finally

Il blocco finally viene eseguito comunque, indipendentemente dal fatto che si verifichi o meno un'eccezione.

try:
    numero = int(input("Numero: "))
except ValueError:
    print("Errore")
finally:
    print("Operazione terminata")

Il finally viene eseguito:

  • se non si verifica alcuna eccezione;
  • se si verifica un'eccezione gestita;
  • anche quando un'eccezione non gestita deve continuare a risalire.

A cosa serve?

È particolarmente utile per la pulizia delle risorse:

  • chiudere file;
  • rilasciare risorse;
  • chiudere connessioni;
  • ripristinare uno stato.

Nel prossimo capitolo vedremo come with renda molte di queste operazioni più sicure e leggibili.


6.14 try / except / else / finally

La struttura completa è:

try:
    operazione
except TipoErrore:
    gestione_errore
else:
    operazione_se_tutto_ok
finally:
    pulizia

Esempio:

try:
    numero = int(input("Numero: "))
    risultato = 100 / numero
except ValueError:
    print("Devi inserire un numero")
except ZeroDivisionError:
    print("Non puoi dividere per zero")
else:
    print("Risultato:", risultato)
finally:
    print("Elaborazione terminata")

Ordine di esecuzione

try
 │
 ├── errore ──► except
 │
 └── nessun errore ──► else
             │
             ▼
          finally

Il finally viene eseguito alla fine del costrutto.


6.15 raise: generare un'eccezione

Non dobbiamo soltanto gestire eccezioni generate da Python.

Possiamo generarne una noi:

eta = -5

if eta < 0:
    raise ValueError("L'età non può essere negativa")

Questo è molto importante nella progettazione delle funzioni e delle classi.

Esempio con una funzione

def dividi(a, b):
    if b == 0:
        raise ValueError("Il divisore non può essere zero")

    return a / b

Poi:

try:
    print(dividi(10, 0))
except ValueError as errore:
    print(errore)

La funzione segnala che i propri dati non rispettano una regola; il codice chiamante decide come gestire la situazione.


6.16 Validare i dati con raise

Le eccezioni sono molto utili per imporre invarianti.

class ContoBancario:

    def __init__(self, saldo):
        if saldo < 0:
            raise ValueError("Il saldo iniziale non può essere negativo")

        self.saldo = saldo

Ora:

conto = ContoBancario(100)

è valido.

Mentre:

conto = ContoBancario(-50)

genera un'eccezione.

Questo si collega direttamente al capitolo precedente:

una classe può utilizzare le eccezioni per proteggere lo stato dell'oggetto.


6.17 Eccezioni e property

Possiamo usare raise anche in una property:

class Prodotto:

    def __init__(self, prezzo):
        self.prezzo = prezzo

    @property
    def prezzo(self):
        return self._prezzo

    @prezzo.setter
    def prezzo(self, valore):
        if valore < 0:
            raise ValueError("Il prezzo non può essere negativo")

        self._prezzo = valore

Uso:

prodotto = Prodotto(20)

prodotto.prezzo = 25

ma:

prodotto.prezzo = -10

genera:

ValueError

La classe mantiene così una regola interna coerente.


6.18 Rilanciare un'eccezione

A volte vogliamo intercettare un'eccezione solo per eseguire una certa operazione, ma non vogliamo nasconderla.

Possiamo usare:

raise

senza specificare un nuovo tipo.

Esempio:

try:
    numero = int("abc")
except ValueError:
    print("Errore durante la conversione")
    raise

Il messaggio viene stampato, ma l'eccezione continua a risalire.

Perché può essere utile?

Per esempio per:

  • registrare un errore nei log;
  • aggiungere informazioni;
  • effettuare una pulizia;
  • poi lasciare che il livello superiore gestisca l'errore.

Non usare raise per nascondere l'errore: serve a permettere all'eccezione di proseguire la propria propagazione.


6.19 Eccezioni personalizzate

Possiamo definire nuove eccezioni creando una classe derivata da Exception.

class SaldoInsufficienteError(Exception):
    pass

Ora:

def preleva(saldo, importo):
    if importo > saldo:
        raise SaldoInsufficienteError(
            "Saldo insufficiente"
        )

    return saldo - importo

Gestione:

try:
    saldo = preleva(100, 150)
except SaldoInsufficienteError as errore:
    print(errore)

Perché creare eccezioni personalizzate?

Un tipo specifico comunica meglio il significato dell'errore.

Con:

ValueError

sappiamo che c'è un problema con un valore.

Con:

SaldoInsufficienteError

sappiamo immediatamente quale condizione applicativa si è verificata.


6.20 Gerarchia di eccezioni personalizzata

Possiamo costruire una gerarchia.

class BibliotecaError(Exception):
    pass


class LibroNonTrovatoError(BibliotecaError):
    pass


class LibroNonDisponibileError(BibliotecaError):
    pass

Ora possiamo gestire un errore specifico:

try:
    biblioteca.presta("123")
except LibroNonDisponibileError:
    print("Il libro è già in prestito")

oppure tutti gli errori della biblioteca:

try:
    ...
except BibliotecaError:
    print("Si è verificato un errore nella biblioteca")

Questo è un ottimo strumento per progettare API e applicazioni complesse.


6.21 Eccezioni e API

Supponiamo di avere:

def cerca_utente(id_utente):
    ...

Possiamo decidere che un utente inesistente generi:

class UtenteNonTrovatoError(Exception):
    pass

Il chiamante può quindi scrivere:

try:
    utente = cerca_utente(123)
except UtenteNonTrovatoError:
    print("Utente non trovato")

La funzione non decide cosa visualizzare.

Decide soltanto:

questa situazione rappresenta un errore di dominio.

La scelta di cosa fare spetta al livello superiore.

Questo principio è importante per separare logica applicativa e presentazione.


6.22 Exception chaining

Possiamo trasformare un'eccezione tecnica in un'eccezione più significativa utilizzando:

raise ... from ...

Esempio:

class ConfigurazioneError(Exception):
    pass


def leggi_configurazione():
    try:
        valore = int("abc")
    except ValueError as errore:
        raise ConfigurazioneError(
            "Configurazione non valida"
        ) from errore

Ora abbiamo due informazioni:

  • l'errore di alto livello;
  • la causa originale.

Il traceback mostrerà la relazione tra le due eccezioni.

Questo è particolarmente utile quando un livello dell'applicazione vuole esporre un errore coerente con il proprio dominio senza perdere la causa tecnica originale.


6.23 raise ... from None

In alcuni casi possiamo decidere di non mostrare il chaining della causa:

try:
    int("abc")
except ValueError:
    raise ConfigurazioneError(
        "Configurazione non valida"
    ) from None

La causa continua a essere disponibile nel contesto dell'eccezione, ma Python non mostrerà il chaining nello stesso modo nel traceback.

Questa tecnica va usata con criterio: nascondere informazioni diagnostiche può rendere più difficile il debugging.


6.24 Non usare except: generico

È possibile scrivere:

try:
    ...
except:
    ...

ma generalmente è una cattiva pratica.

Questo cattura anche eccezioni che normalmente non dovremmo intercettare, come KeyboardInterrupt e SystemExit.

Meglio:

except ValueError:
    ...

oppure, quando serve davvero un fallback generale:

except Exception as errore:
    ...

Perché evitare l'eccezione generica?

Perché questo codice:

try:
    ...
except Exception:
    print("Qualcosa è andato storto")

può nascondere problemi completamente diversi tra loro.

Un errore di programmazione non dovrebbe diventare automaticamente un generico "dato non valido".


6.25 Non usare le eccezioni per il flusso normale

Le eccezioni servono a gestire condizioni eccezionali, non a sostituire normalmente if.

Da evitare:

try:
    elemento = lista[indice]
except IndexError:
    elemento = None

se il controllo dell'indice fa parte del normale flusso del programma e può essere verificato chiaramente prima.

Meglio, quando appropriato:

if 0 <= indice < len(lista):
    elemento = lista[indice]
else:
    elemento = None

Non è una regola assoluta: in Python è perfettamente legittimo usare un'eccezione quando rappresenta naturalmente una condizione che si verifica durante un'operazione. L'importante è non trasformare ogni normale decisione in un meccanismo basato su eccezioni.


6.26 EAFP e LBYL

Python viene spesso descritto attraverso due approcci.

LBYL --- Look Before You Leap

"Guarda prima di saltare."

if indice < len(lista):
    valore = lista[indice]

EAFP --- Easier to Ask Forgiveness than Permission

"È più facile chiedere perdono che permesso."

try:
    valore = lista[indice]
except IndexError:
    valore = None

Entrambi gli approcci possono essere validi.

La scelta dipende dal contesto.

Esempio con dizionario

In Python è spesso naturale usare:

try:
    valore = dizionario["nome"]
except KeyError:
    valore = "Sconosciuto"

oppure:

valore = dizionario.get("nome", "Sconosciuto")

Nel secondo caso non serve un'eccezione perché il comportamento desiderato è già fornito dall'API del dizionario.

Le eccezioni non sono automaticamente migliori dei controlli condizionali: bisogna scegliere lo strumento più espressivo.


6.27 finally e gestione delle risorse

Un esempio classico:

f = None

try:
    f = open("dati.txt", "r")
    contenuto = f.read()
except FileNotFoundError:
    print("File non trovato")
finally:
    if f is not None:
        f.close()

Il finally garantisce che il tentativo di chiusura venga eseguito anche se si verifica un errore.

Tuttavia, Python offre una soluzione migliore per molte risorse:

with open("dati.txt", "r") as f:
    contenuto = f.read()

Il blocco with gestisce automaticamente l'uscita dalla risorsa.

Il File I/O e l'uso approfondito di with open(...) saranno argomento del prossimo capitolo.


6.28 Context manager

Un context manager definisce cosa deve accadere entrando e uscendo da un contesto.

La forma:

with risorsa:
    ...

è utilizzata per garantire una corretta gestione delle risorse.

Esempi tipici:

  • file;
  • lock;
  • connessioni;
  • transazioni;
  • risorse temporanee.

Un context manager può essere implementato attraverso:

__enter__()
__exit__()

Esempio semplificato:

class Risorsa:

    def __enter__(self):
        print("Apertura risorsa")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("Chiusura risorsa")

Uso:

with Risorsa():
    print("Uso la risorsa")

Output:

Apertura risorsa
Uso la risorsa
Chiusura risorsa

Questo meccanismo sarà fondamentale quando lavoreremo con i file.


6.29 Un context manager può gestire un'eccezione

Il metodo __exit__() riceve informazioni sull'eventuale eccezione.

class Gestore:

    def __enter__(self):
        print("Entrata")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("Uscita")

        if exc_type is not None:
            print("Si è verificata un'eccezione")

Se il blocco genera un errore:

with Gestore():
    raise ValueError("Problema")

__exit__() viene comunque chiamato.

Il valore restituito da __exit__() può anche determinare se l'eccezione viene soppressa. Nella pratica, non bisogna sopprimere eccezioni senza una precisa ragione.


6.30 contextlib

Python fornisce il modulo contextlib per creare context manager più facilmente.

Un esempio:

from contextlib import contextmanager


@contextmanager
def risorsa():
    print("Apertura")
    try:
        yield
    finally:
        print("Chiusura")

Uso:

with risorsa():
    print("Sto lavorando")

Il finally garantisce la pulizia anche se nel corpo del with si verifica un'eccezione.

Non è necessario utilizzare sempre contextlib: per molte risorse Python fornisce già context manager pronti all'uso.


6.31 Eccezioni nelle funzioni

Una funzione può:

  1. gestire un'eccezione;
  2. generare un'eccezione;
  3. lasciarla propagare al chiamante.

Esempio:

def converti_intero(testo):
    return int(testo)

La funzione non deve necessariamente contenere:

try:
    ...
except:
    ...

Se testo non è convertibile, può lasciare che il ValueError venga propagato.

Il chiamante decide:

try:
    numero = converti_intero("abc")
except ValueError:
    print("Input non valido")

Questo spesso produce un design più pulito.


6.32 Dove gestire un'eccezione?

Una buona regola è:

Gestisci l'eccezione nel punto in cui sai cosa farne.

Esempio:

def calcola_totale(prezzo, quantita):
    return prezzo * quantita

Se un valore è negativo e questo rappresenta una violazione del contratto della funzione, possiamo generare:

def calcola_totale(prezzo, quantita):
    if prezzo < 0:
        raise ValueError("Prezzo negativo")

    if quantita < 0:
        raise ValueError("Quantità negativa")

    return prezzo * quantita

Il livello superiore decide invece se:

  • mostrare un messaggio;
  • chiedere nuovamente l'input;
  • registrare l'errore;
  • restituire una risposta HTTP;
  • interrompere un'operazione.

Questa separazione rende il codice più riutilizzabile.


6.33 Un esempio completo: input robusto

Vogliamo chiedere all'utente un numero intero.

def leggi_intero():
    while True:
        testo = input("Inserisci un intero: ")

        try:
            return int(testo)
        except ValueError:
            print("Valore non valido. Riprova.")

Uso:

numero = leggi_intero()
print("Hai inserito:", numero)

Qui l'eccezione rappresenta una condizione prevista dell'interazione con l'utente.

La funzione continua a chiedere il valore finché non ne riceve uno valido.


6.34 Un esempio completo: divisione sicura

def leggi_numero():
    while True:
        try:
            return float(input("Numero: "))
        except ValueError:
            print("Inserisci un numero valido")


def dividi(a, b):
    if b == 0:
        raise ZeroDivisionError("Il divisore non può essere zero")

    return a / b

Programma:

a = leggi_numero()
b = leggi_numero()

try:
    risultato = dividi(a, b)
except ZeroDivisionError as errore:
    print(errore)
else:
    print("Risultato:", risultato)

Abbiamo separato:

  • lettura dell'input;
  • validazione;
  • operazione matematica;
  • gestione dell'errore;
  • presentazione del risultato.

6.35 Un esempio OOP: conto bancario

Riprendiamo il concetto del capitolo precedente.

class SaldoInsufficienteError(Exception):
    pass


class ContoBancario:

    def __init__(self, intestatario, saldo=0):
        if saldo < 0:
            raise ValueError("Saldo iniziale non valido")

        self.intestatario = intestatario
        self.saldo = saldo

    def deposita(self, importo):
        if importo <= 0:
            raise ValueError("L'importo deve essere positivo")

        self.saldo += importo

    def preleva(self, importo):
        if importo <= 0:
            raise ValueError("L'importo deve essere positivo")

        if importo > self.saldo:
            raise SaldoInsufficienteError(
                "Saldo insufficiente"
            )

        self.saldo -= importo

Utilizzo:

conto = ContoBancario("Anna", 100)

try:
    conto.preleva(150)
except SaldoInsufficienteError:
    print("Operazione non consentita")

La classe definisce le regole del dominio.

Il codice chiamante decide come reagire.


6.36 Eccezioni e livelli dell'applicazione

In un'applicazione più grande possiamo avere:

Interfaccia
    │
    ▼
Servizio
    │
    ▼
Repository
    │
    ▼
Database

Un livello basso può generare un'eccezione:

DatabaseError

Il livello di servizio può trasformarla in:

OrdineNonDisponibileError

e l'interfaccia può trasformarla in un messaggio comprensibile:

Non è stato possibile completare l'ordine.

La gestione delle eccezioni diventa quindi uno strumento per separare i livelli dell'applicazione.


6.37 Errori tecnici ed errori di dominio

È utile distinguere:

Errore tecnico

FileNotFoundError
ConnectionError
TimeoutError

Descrive un problema legato all'infrastruttura.

Errore di dominio

SaldoInsufficienteError
LibroNonDisponibileError
OrdineNonValidoError

Descrive una regola dell'applicazione.

Una buona architettura evita di far arrivare necessariamente gli errori tecnici fino all'interfaccia utente.


6.38 Logging e print

Durante lo sviluppo possiamo usare:

print("Errore")

ma nelle applicazioni reali è spesso preferibile il modulo logging.

Esempio:

import logging

logging.basicConfig(level=logging.INFO)

logging.info("Avvio applicazione")
logging.warning("Dato sospetto")
logging.error("Operazione fallita")

Per un'eccezione:

try:
    numero = int("abc")
except ValueError:
    logging.exception("Conversione fallita")

logging.exception() è particolarmente utile all'interno di un except perché registra anche le informazioni dell'eccezione.

Il logging verrà approfondito quando affronteremo applicazioni più strutturate.


6.39 Non nascondere gli errori

Questo codice è pericoloso:

try:
    esegui_operazione()
except Exception:
    pass

Se l'operazione fallisce, non succede nulla.

Il problema può rimanere completamente invisibile.

Meglio:

try:
    esegui_operazione()
except Exception as errore:
    logging.exception("Operazione fallita")

oppure, se non sappiamo gestire l'errore:

try:
    esegui_operazione()
except Exception:
    raise

Il principio è:

Un'eccezione deve essere gestita, trasformata o propagata; raramente deve essere semplicemente ignorata.


6.40 Errori di programmazione vs condizioni previste

Non tutte le eccezioni devono essere catturate.

Se scriviamo:

def calcola_media(valori):
    return sum(valori) / len(valori)

e il programma contiene un bug che passa un oggetto completamente sbagliato, catturare ogni possibile eccezione potrebbe nascondere il problema.

Meglio lasciare emergere gli errori di programmazione durante lo sviluppo.

Le eccezioni vanno gestite soprattutto quando:

  • la situazione è prevista;
  • possiamo reagire in modo sensato;
  • abbiamo un'alternativa;
  • vogliamo tradurre l'errore in un concetto di dominio.

6.41 Anti-pattern: except Exception ovunque

Da evitare:

def funzione1():
    try:
        ...
    except Exception:
        ...


def funzione2():
    try:
        funzione1()
    except Exception:
        ...


def funzione3():
    try:
        funzione2()
    except Exception:
        ...

Questo rende praticamente impossibile capire dove sia il problema.

È preferibile stabilire un punto chiaro in cui l'errore viene:

  • gestito;
  • trasformato;
  • registrato;
  • mostrato;
  • oppure lasciato propagare.

6.42 assert non sostituisce raise

Python offre anche:

assert condizione

Esempio:

assert eta >= 0

Se la condizione è falsa viene generato:

AssertionError

Le asserzioni sono utili soprattutto per controllare invarianti e condizioni che il programmatore considera necessariamente vere.

Non dovrebbero essere usate come unico sistema di validazione degli input dell'utente.

Per esempio, per validare un dato esterno è preferibile:

if eta < 0:
    raise ValueError("Età non valida")

piuttosto che:

assert eta >= 0

6.43 Eccezioni e type hints

Possiamo documentare chiaramente quali dati riceve una funzione:

def dividi(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("Divisore nullo")

    return a / b

I type hints non fanno però comparire automaticamente un controllo a runtime.

Il tipo:

float

non significa che Python impedirà in ogni caso di passare un altro tipo.

Le eccezioni rimangono quindi importanti anche in presenza dei type hints.


6.44 Mini-verifica

Domande

  1. Qual è la differenza tra errore di sintassi ed eccezione?
  2. A cosa serve try?
  3. A cosa serve except?
  4. Perché è meglio specificare il tipo di eccezione?
  5. Che cosa fa else?
  6. Che cosa fa finally?
  7. A cosa serve raise?
  8. Come si crea un'eccezione personalizzata?
  9. Perché l'ordine degli except è importante?
  10. Che cosa significa rilanciare un'eccezione?
  11. A cosa serve raise ... from ...?
  12. Perché evitare except:?
  13. Che differenza c'è tra Exception e BaseException?
  14. Che cosa significa EAFP?
  15. Che cos'è un context manager?
  16. A cosa serve with?
  17. Quando è utile creare un'eccezione personalizzata?
  18. Perché non è una buona idea usare except Exception: pass?

Risposte sintetiche

  1. La sintassi non è valida nel primo caso; nel secondo il problema si verifica durante l'esecuzione.
  2. Delimita il codice che può generare un'eccezione.
  3. Gestisce un'eccezione compatibile.
  4. Per gestire solo gli errori che sappiamo trattare.
  5. Viene eseguito se il try termina senza eccezioni.
  6. Viene eseguito alla fine del costrutto anche in presenza di eccezioni.
  7. Per generare o rilanciare un'eccezione.
  8. Definendo una classe derivata da Exception.
  9. Gli except vengono verificati dall'alto verso il basso.
  10. Significa lasciare nuovamente propagare l'eccezione.
  11. Per collegare una nuova eccezione alla causa originale.
  12. Può catturare errori che non intendiamo gestire.
  13. Exception è la base della maggior parte degli errori applicativi; BaseException è più generale e comprende anche SystemExit e KeyboardInterrupt.
  14. "Easier to Ask Forgiveness than Permission": tentare l'operazione e gestire l'eventuale eccezione.
  15. Un oggetto che definisce come entrare e uscire da un contesto, gestendo correttamente le risorse.
  16. Per utilizzare un context manager in modo sicuro e leggibile.
  17. Quando un errore rappresenta una condizione specifica del dominio.
  18. Perché nasconde completamente il problema.

6.45 Esercizi di riepilogo

Esercizio 1 --- Numero intero

Scrivi una funzione:

leggi_intero()

che continui a chiedere all'utente un numero finché non viene inserito un intero valido.

Utilizza try/except ValueError.


Esercizio 2 --- Divisione

Scrivi:

dividi(a, b)

che:

  • restituisca a / b;
  • gestisca il divisore uguale a zero tramite eccezione;
  • venga utilizzata da un programma che gestisce l'errore.

Esercizio 3 --- Validazione di un prodotto

Crea una classe:

Prodotto

con:

  • nome;
  • prezzo.

Il prezzo non può essere negativo.

Utilizza una property e raise ValueError.


Esercizio 4 --- Eccezione personalizzata

Crea:

class SaldoInsufficienteError(Exception):
    pass

e utilizzala in una classe ContoBancario.

Il metodo preleva() deve sollevare l'eccezione se l'importo supera il saldo.


Esercizio 5 --- Biblioteca

Riprendi la biblioteca della sfida del capitolo 5.

Definisci:

class BibliotecaError(Exception):
    pass

class LibroNonTrovatoError(BibliotecaError):
    pass

class LibroNonDisponibileError(BibliotecaError):
    pass

Utilizza queste eccezioni nei metodi:

cerca()
presta()
restituisci()
rimuovi()

6.46 Sfida finale --- Gestione robusta di un ordine

Completa il sistema di gestione ordini introdotto nei capitoli precedenti.

Definisci le eccezioni:

class OrdineError(Exception):
    pass


class ProdottoNonDisponibileError(OrdineError):
    pass


class QuantitaNonValidaError(OrdineError):
    pass


class OrdineVuotoError(OrdineError):
    pass

Requisiti

Prodotto

Deve contenere:

  • codice;
  • nome;
  • prezzo;
  • quantità disponibile.

La quantità non può essere negativa.


Ordine

Deve permettere di:

aggiungi(prodotto, quantita)

Se la quantità è minore o uguale a zero:

raise QuantitaNonValidaError(...)

Se non c'è disponibilità:

raise ProdottoNonDisponibileError(...)

Totale

Definisci:

@property
def totale(self):
    ...

che calcoli il totale dell'ordine.


Conferma

Definisci:

conferma()

che non permetta di confermare un ordine vuoto.

In caso contrario:

raise OrdineVuotoError(...)

Programma principale

Gestisci separatamente le diverse condizioni:

try:
    ...
except QuantitaNonValidaError:
    ...
except ProdottoNonDisponibileError:
    ...
except OrdineVuotoError:
    ...

Aggiungi infine:

except OrdineError:
    ...

come fallback per gli errori specifici dell'applicazione.


6.47 Soluzione guidata

Una possibile implementazione:

class OrdineError(Exception):
    pass


class ProdottoNonDisponibileError(OrdineError):
    pass


class QuantitaNonValidaError(OrdineError):
    pass


class OrdineVuotoError(OrdineError):
    pass

Prodotto

class Prodotto:

    def __init__(self, codice, nome, prezzo, disponibilita):
        if prezzo < 0:
            raise ValueError("Prezzo non valido")

        if disponibilita < 0:
            raise ValueError("Disponibilità non valida")

        self.codice = codice
        self.nome = nome
        self.prezzo = prezzo
        self.disponibilita = disponibilita

Ordine

class Ordine:

    def __init__(self):
        self._righe = []

    def aggiungi(self, prodotto, quantita):
        if quantita <= 0:
            raise QuantitaNonValidaError(
                "La quantità deve essere positiva"
            )

        if quantita > prodotto.disponibilita:
            raise ProdottoNonDisponibileError(
                f"Disponibilità insufficiente per {prodotto.nome}"
            )

        self._righe.append((prodotto, quantita))
        prodotto.disponibilita -= quantita

    @property
    def totale(self):
        return sum(
            prodotto.prezzo * quantita
            for prodotto, quantita in self._righe
        )

    def conferma(self):
        if not self._righe:
            raise OrdineVuotoError(
                "Non puoi confermare un ordine vuoto"
            )

        print(
            f"Ordine confermato: {self.totale:.2f} €"
        )

Programma:

mouse = Prodotto(
    "M01",
    "Mouse",
    25.90,
    10
)

ordine = Ordine()

try:
    ordine.aggiungi(mouse, 3)
    ordine.conferma()

except QuantitaNonValidaError as errore:
    print("Quantità:", errore)

except ProdottoNonDisponibileError as errore:
    print("Disponibilità:", errore)

except OrdineVuotoError as errore:
    print("Ordine:", errore)

except OrdineError as errore:
    print("Errore ordine:", errore)

Un miglioramento importante

Nella soluzione precedente la disponibilità viene decrementata quando il prodotto viene aggiunto all'ordine.

In un sistema reale potremmo voler distinguere:

disponibilità
     │
     ├── quantità disponibile
     ├── quantità riservata
     └── quantità acquistata

La gestione corretta dipende dal dominio applicativo.

Questo esempio serve soprattutto a esercitare la progettazione delle eccezioni.


6.48 Una regola fondamentale: l'eccezione appartiene al livello giusto

Consideriamo:

def presta_libro(isbn):
    ...

Se il libro non è disponibile, la funzione può generare:

LibroNonDisponibileError

Non dovrebbe necessariamente fare:

print("ERRORE!!!")

perché non sa se il chiamante sia:

  • un programma da terminale;
  • un'applicazione web;
  • una API REST;
  • un'interfaccia grafica;
  • un test automatico.

La funzione deve comunicare il problema.

Il chiamante decide come presentarlo.

Questa separazione diventa fondamentale nello sviluppo di applicazioni professionali.


6.49 Le regole d'oro delle eccezioni

Possiamo riassumere il capitolo in alcune regole pratiche.

1. Gestisci ciò che sai gestire

except ValueError:
    ...

è generalmente meglio di:

except:
    ...

2. Non nascondere gli errori

Da evitare:

except Exception:
    pass

3. Usa messaggi significativi

Meglio:

raise ValueError("Il prezzo non può essere negativo")

che:

raise ValueError("Errore")

4. Crea eccezioni personalizzate per il dominio

SaldoInsufficienteError
LibroNonDisponibileError
OrdineVuotoError

comunicano molto più di un generico Exception.

5. Lascia propagare ciò che non sai gestire

Non è obbligatorio mettere un try/except in ogni funzione.

6. Usa finally o context manager per le risorse

La pulizia delle risorse deve essere garantita anche in caso di errore.

7. Non usare le eccezioni per ogni normale decisione

Se un'API offre già un metodo come:

dict.get()

può essere preferibile usarlo invece di generare e catturare un KeyError.


6.50 Riepilogo del capitolo

Le eccezioni permettono a Python di rappresentare e gestire condizioni anomale durante l'esecuzione.

La struttura fondamentale è:

try:
    ...
except TipoErrore:
    ...

Possiamo aggiungere:

else:
    ...

per il caso di successo e:

finally:
    ...

per le operazioni che devono essere eseguite comunque.

Possiamo generare eccezioni:

raise ValueError("...")

e creare eccezioni personalizzate:

class MioErrore(Exception):
    pass

Possiamo inoltre trasformare e collegare le eccezioni:

raise MioErrore("...") from errore_originale

Abbiamo visto che:

  • le eccezioni sono organizzate in una gerarchia;
  • except dovrebbe essere specifico;
  • Exception è la base usuale degli errori applicativi;
  • BaseException non va catturata genericamente;
  • else rappresenta il percorso di successo;
  • finally garantisce la fase finale di pulizia;
  • raise permette di comunicare una violazione di una regola;
  • le eccezioni personalizzate rappresentano errori del dominio;
  • le eccezioni possono propagarsi tra livelli diversi dell'applicazione;
  • with e i context manager aiutano a gestire correttamente le risorse.

Il concetto più importante è forse questo:

Un'eccezione non è semplicemente qualcosa da "nascondere": è un messaggio strutturato che comunica che il normale percorso dell'esecuzione non può proseguire come previsto.


6.51 Verso il capitolo successivo: File I/O

Ora abbiamo gli strumenti necessari per affrontare uno dei compiti più comuni nei programmi reali: leggere e scrivere dati su file.

Nel prossimo capitolo vedremo:

  • open();
  • modalità r, w, a, x;
  • with;
  • lettura completa di un file;
  • read();
  • readline();
  • readlines();
  • iterazione sulle righe;
  • scrittura con write();
  • writelines();
  • encoding;
  • pathlib;
  • gestione dei percorsi;
  • file CSV;
  • file JSON;
  • gestione degli errori durante il File I/O.

Il collegamento con questo capitolo sarà diretto.

Per esempio:

try:
    with open("dati.txt", "r", encoding="utf-8") as file:
        contenuto = file.read()
except FileNotFoundError:
    print("Il file non esiste")

Qui convivono già due concetti:

File I/O
   +
Gestione delle eccezioni

Ed è proprio questa combinazione che permette di costruire programmi capaci di lavorare con il mondo reale.

← → per navigare F fullscreen