Python & AI Tutorials Logo
Programmazione Python

41. Debugging e test del tuo codice

Scrivere codice è solo metà della battaglia. L’altra metà è assicurarsi che il codice funzioni correttamente e trovare i problemi quando non lo fa. Ogni programmatore, dai principianti agli esperti, scrive codice con bug. La differenza è che i programmatori esperti hanno sviluppato approcci sistematici per trovare e correggere quei bug.

In questo capitolo imparerai tecniche pratiche di debugging che ti aiutano a capire che cosa sta effettivamente facendo il tuo codice, individuare rapidamente i problemi e verificare che il tuo codice funzioni come previsto. Queste competenze ti renderanno un programmatore più sicuro ed efficace.

41.1) Leggere i traceback per individuare gli errori (Ripasso rapido)

Come abbiamo imparato nel Capitolo 24, Python fornisce messaggi di errore dettagliati chiamati traceback quando qualcosa va storto. Ripassiamo come leggerli in modo efficace, perché questa è la tua prima linea di difesa durante il debugging.

41.1.1) L’anatomia di un traceback

Quando Python incontra un errore, ti mostra esattamente dove si è verificato il problema e che tipo di errore era. Ecco un traceback tipico:

python
def calculate_average(numbers):
    total = sum(numbers)
    count = len(numbers)
    return total / count
 
def process_student_grades(grades):
    average = calculate_average(grades)
    return f"Average: {average:.1f}"
 
# Questo causerà un errore
student_grades = []
result = process_student_grades(student_grades)
print(result)

Output:

Traceback (most recent call last):
  File "grades.py", line 12, in <module>
    result = process_student_grades(student_grades)
  File "grades.py", line 7, in process_student_grades
    average = calculate_average(grades)
  File "grades.py", line 4, in calculate_average
    return total / count
           ~~~~~~^~~~~~~
ZeroDivisionError: division by zero

Scomponiamo ciò che questo traceback ci dice:

Riga 12: chiamata a process_student_grades

Riga 7: chiamata a calculate_average

Riga 4: operazione di divisione

ZeroDivisionError: division by zero

Leggere dal basso verso l’alto:

  1. Il tipo di errore e il messaggio (in basso): ZeroDivisionError: division by zero ci dice esattamente che cosa è andato storto
  2. La riga esatta in cui si è verificato l’errore: return total / count alla riga 4
  3. La catena di chiamate (call chain) che mostra come ci siamo arrivati: iniziata alla riga 12, passata per la riga 7, terminata alla riga 4

41.1.2) Usare i traceback per trovare la causa principale

Il traceback ti mostra il sintomo (dove si è verificato l’errore), ma devi trovare la causa (perché si è verificato). Ricostruiamo il problema:

python
# L'errore avviene qui
return total / count  # count è 0
 
# Ma il vero problema è qui
student_grades = []  # Lista vuota passata alla funzione

La divisione per zero avviene perché abbiamo passato una lista vuota. Il traceback punta alla riga 4, ma la correzione deve avvenire prima—o validando l’input o gestendo il caso della lista vuota:

python
def calculate_average(numbers):
    """Return the average of numbers, or None if the list is empty."""
    if not numbers:
        return None
    return sum(numbers) / len(numbers)
 
def process_student_grades(grades):
    """Process student grades and return a formatted string."""
    average = calculate_average(grades)
    if average is None:
        return "No grades to process"
    return f"Average: {average:.1f}"
 
# Ora questo funziona in modo sicuro
student_grades = []
result = process_student_grades(student_grades)
print(result)  # Output: No grades to process
 
# E anche questo funziona
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result)  # Output: Average: 86.2

Punti chiave:

  • Leggi i traceback dal basso verso l’alto
  • La posizione dell’errore (sintomo) non è sempre la causa principale
  • Valida gli input presto per prevenire errori più avanti
  • Usa programmazione difensiva (.get(), controlli sulla lunghezza) per codice più sicuro

Tipi diversi di errori producono traceback diversi, ma il processo di lettura è sempre lo stesso: parti dal basso per vedere che cosa è andato storto, poi risali per capire come ci sei arrivato. Se ti serve un ripasso su specifici tipi di eccezione, fai riferimento al Capitolo 24.

Ora che sai leggere i traceback in modo efficace, impariamo come tracciare mentalmente il tuo codice per capire cosa sta facendo passo dopo passo.

41.2) Tracciare mentalmente l’esecuzione del codice

A volte incontri un bug ma non puoi eseguire subito il codice—magari stai revisionando codice su carta, leggendo la pull request di qualcun altro, o cercando di capire perché una funzione si comporta in modo inaspettato. In queste situazioni, l’esecuzione mentale—passare attraverso il codice riga per riga nella tua testa, tenendo traccia di ciò che accade a ogni variabile—diventa preziosissima.

Anche i programmatori esperti usano regolarmente questa tecnica. Prima di aggiungere istruzioni di stampa o avviare un debugger, spesso tracciano mentalmente alcune iterazioni per formulare un’ipotesi su dove potrebbe essere il problema. Questo è più rapido del tentativo ed errore e ti aiuta a comprendere più a fondo il tuo codice.

L’esecuzione mentale è particolarmente utile quando:

  • Leggi codice non familiare per capire cosa fa
  • Rivedi funzioni piccole (5-15 righe) prima di eseguirle
  • Esegui debugging di errori di logica in cui il codice gira ma produce risultati sbagliati
  • Comprendi il comportamento dei cicli quando lo schema non è immediatamente evidente
  • Revisione del codice (code review) in cui non puoi eseguire facilmente il codice da solo

Per codice più grande o più complesso, combinerai la traccia mentale con altre tecniche che tratteremo più avanti in questo capitolo. Ma padroneggiare questa competenza ti renderà un debugger molto più efficace.

41.2.1) Il processo di esecuzione mentale

Quando esegui mentalmente il codice, agisci come l’interprete Python, seguendo le stesse regole che segue Python. Facciamo pratica con un esempio semplice:

python
def find_maximum(numbers):
    max_value = numbers[0]
    for num in numbers:
        if num > max_value:
            max_value = num
    return max_value
 
result = find_maximum([3, 7, 2, 9, 5])
print(result)  # Output: 9

Ecco come tracciare questo codice:

Traccia passo dopo passo:

Stato iniziale:
  numbers = [3, 7, 2, 9, 5]
  max_value = 3  (numbers[0])
 
Iterazione 1: num = 3
  Controllo: 3 > 3? → False
  max_value resta 3
 
Iterazione 2: num = 7
  Controllo: 7 > 3? → True
  max_value = 7 ✓
 
Iterazione 3: num = 2
  Controllo: 2 > 7? → False
  max_value resta 7
 
Iterazione 4: num = 9
  Controllo: 9 > 7? → True
  max_value = 9 ✓
 
Iterazione 5: num = 5
  Controllo: 5 > 9? → False
  max_value resta 9
 
Return: 9

41.2.2) Creare una tabella di traccia

Per codice più complesso, crea una tabella di traccia (trace table) che mostri come cambiano le variabili nel tempo. Questo è particolarmente utile per cicli e strutture annidate:

python
def calculate_running_totals(numbers):
    totals = []
    running_sum = 0
    for num in numbers:
        running_sum += num
        totals.append(running_sum)
    return totals
 
result = calculate_running_totals([10, 20, 30, 40])
print(result)  # Output: [10, 30, 60, 100]

Tabella di traccia:

La tabella mostra lo stato delle variabili a ogni passo. Nota come running_sum cambia da "prima" a "dopo" ogni addizione:

Iterationnumrunning_sum (before)running_sum (after)totals
Start-00[]
110010[10]
2201030[10, 30]
3303060[10, 30, 60]
44060100[10, 30, 60, 100]

Creare questa tabella ti aiuta a vedere esattamente come i dati scorrono nel tuo codice. Se l’output non corrisponde a ciò che ti aspetti, puoi individuare esattamente dove le cose vanno storte.

41.2.3) Tracciare la logica condizionale

Le istruzioni condizionali richiedono attenzione nel capire quali rami vengono eseguiti. Tracciamo un esempio più complesso:

python
def categorize_grade(score):
    if score >= 90:
        category = "Excellent"
        bonus = 10
    elif score >= 80:
        category = "Good"
        bonus = 5
    elif score >= 70:
        category = "Satisfactory"
        bonus = 0
    else:
        category = "Needs Improvement"
        bonus = 0
    
    final_score = score + bonus
    return category, final_score
 
result = categorize_grade(85)
print(result)  # Output: ('Good', 90)

Traccia mentale per score = 85:

  1. Controlla 85 >= 90 → False, salta il primo blocco
  2. Controlla 85 >= 80 → True, entra nel secondo blocco
  3. Imposta category = "Good" e bonus = 5
  4. Salta i restanti blocchi elif ed else (hai già trovato una corrispondenza)
  5. Calcola final_score = 85 + 5 = 90
  6. Restituisce ("Good", 90)

41.2.4) Tracciare chiamate e ritorni di funzioni

Quando le funzioni chiamano altre funzioni, devi tenere traccia della call stack—la sequenza di chiamate di funzione e delle loro variabili locali:

python
def calculate_tax(amount, rate):
    tax = amount * rate
    return tax
 
def calculate_total(price, quantity, tax_rate):
    subtotal = price * quantity
    tax = calculate_tax(subtotal, tax_rate)
    total = subtotal + tax
    return total
 
result = calculate_total(50, 3, 0.08)
print(f"Total: ${result:.2f}")  # Output: Total: $162.00

Traccia con call stack:

┌─ calculate_total(50, 3, 0.08)
│  price = 50, quantity = 3, tax_rate = 0.08
│  subtotal = 150

│  ┌─ calculate_tax(150, 0.08)
│  │  amount = 150, rate = 0.08
│  │  tax = 12.0
│  │  return 12.0
│  └─

│  tax = 12.0 (from calculate_tax)
│  total = 162.0
│  return 162.0
└─
 
result = 162.0

Questa traccia passo dopo passo mostra esattamente come i dati fluiscono tra le funzioni. Durante il debugging, se il risultato finale è sbagliato, puoi risalire e vedere quale funzione ha prodotto un valore intermedio errato.

La traccia mentale è potente, ma per codice complesso può essere tediosa. Nella prossima sezione, impareremo come usare in modo strategico le istruzioni di stampa per vedere cosa sta accadendo davvero mentre il codice gira, cosa che spesso è più veloce e affidabile della sola esecuzione mentale.

41.3) Debugging con print: f"{var=}" e repr()

Sebbene l’esecuzione mentale funzioni bene per funzioni piccole, diventa impraticabile per codice più grande o più complesso. Quando non sei sicuro di cosa stia succedendo dentro un ciclo, o quando un calcolo produce risultati inaspettati, il modo più rapido per indagare spesso è aggiungere istruzioni print() strategiche.

Il debugging con print ha alcuni vantaggi rispetto ad altre tecniche:

  • Nessuno strumento speciale necessario: funziona in qualsiasi ambiente Python
  • Rapido da implementare: aggiungi un’istruzione di stampa in pochi secondi
  • Output chiaro: vedi esattamente ciò che hai richiesto
  • Facile da rimuovere: elimina le stampe quando hai finito

Gli sviluppatori professionisti usano il debugging con print continuamente—non è una tecnica da "principianti". Impariamo come usarla in modo efficace.

41.3.1) Debugging con print di base

L’approccio più semplice al debugging è stampare i valori delle variabili in punti chiave del tuo codice:

python
def process_order(items, discount_rate):
    print(f"Starting process_order")
    print(f"Items: {items}")
    print(f"Discount rate: {discount_rate}")
    
    subtotal = sum(item['price'] * item['quantity'] for item in items)
    print(f"Subtotal: {subtotal}")
    
    discount = subtotal * discount_rate
    print(f"Discount amount: {discount}")
    
    total = subtotal - discount
    print(f"Final total: {total}")
    
    return total
 
order_items = [
    {'name': 'Book', 'price': 25.99, 'quantity': 2},
    {'name': 'Pen', 'price': 3.50, 'quantity': 5}
]
 
result = process_order(order_items, 0.10)

Output:

Starting process_order
Items: [{'name': 'Book', 'price': 25.99, 'quantity': 2}, {'name': 'Pen', 'price': 3.5, 'quantity': 5}]
Discount rate: 0.1
Subtotal: 69.47999999999999
Discount amount: 6.9479999999999995
Final total: 62.53199999999999

Queste istruzioni di stampa mostrano il flusso di esecuzione e i valori a ogni passaggio. Se il risultato finale è sbagliato, puoi vedere esattamente dove il calcolo ha iniziato a deviare.

41.3.2) Usare f"{var=}" per un’ispezione rapida

Python 3.8 ha introdotto una comoda sintassi di debugging: f"{var=}". Questa stampa sia il nome della variabile sia il suo valore:

python
def calculate_compound_interest(principal, rate, years):
    # Approccio tradizionale
    print(f"principal: {principal}")
    print(f"rate: {rate}")
    print(f"years: {years}")
    
    # Approccio più pulito con f"{var=}"
    print(f"{principal=}")
    print(f"{rate=}")
    print(f"{years=}")
    
    # Puoi usare espressioni, non solo variabili
    print(f"{principal * rate=}")
    print(f"{(1 + rate) ** years=}")
    
    amount = principal * (1 + rate) ** years
    print(f"{amount=}")
    
    return amount
 
result = calculate_compound_interest(1000, 0.05, 10)

Output:

principal: 1000
rate: 0.05
years: 10
principal=1000
rate=0.05
years=10
principal * rate=50.0
(1 + rate) ** years=1.628894626777442
amount=1628.894626777442

41.3.3) Usare repr() per vedere la forma reale dei dati

A volte ciò che vedi stampato non è ciò che pensi che sia. La funzione repr() ti mostra la rappresentazione esatta di un oggetto, inclusi i caratteri nascosti:

python
# Queste stringhe sembrano uguali quando vengono stampate
text1 = "Hello"
text2 = "Hello\n"  # Ha un newline alla fine
 
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
 
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")

Output:

Using print():
text1: Hello
text2: Hello
 
Using repr():
text1: 'Hello'
text2: 'Hello\n'

L’output di repr() mostra che text2 ha un carattere di newline nascosto. Questo è cruciale quando fai debugging della manipolazione di stringhe:

python
def clean_user_input():
    # L'input utente spesso contiene spazi bianchi nascosti
    username = input("Enter username: ")  # L'utente digita "Alice  "
    
    print(f"Username with print(): {username}")
    print(f"Username with repr(): {repr(username)}")
    
    # Pulisci l'input
    cleaned = username.strip()
    print(f"Cleaned with repr(): {repr(cleaned)}")
    
    return cleaned

Se un utente digita "Alice" seguito da spazi e preme Invio, potresti vedere:

Output:

Enter username: Alice  
Username with print(): Alice  
Username with repr(): 'Alice  '
Cleaned with repr(): 'Alice'

L’output di repr() rivela gli spazi finali che print() non mostra chiaramente.

Quando usare repr() vs str():

repr() è pensato per gli sviluppatori—mostra la rappresentazione "ufficiale" della stringa che potrebbe ricreare l’oggetto. str() (che print() usa di default) è pensato per gli utenti finali—mostra una versione leggibile e amichevole.

Per il debugging, repr() di solito è più utile perché rivela la vera struttura dei tuoi dati.

41.3.4) Posizionamento strategico dei print

Non spargere istruzioni di stampa ovunque. Posizionale in modo strategico:

python
def calculate_shipping_cost(weight, distance, express=False):
    print(f"=== calculate_shipping_cost called ===")
    print(f"Input: {weight=}, {distance=}, {express=}")
    
    # Calcola il costo base
    base_rate = 0.50
    base_cost = weight * distance * base_rate
    print(f"Calculated: {base_cost=}")
    
    # Applica il sovrapprezzo express
    if express:
        surcharge = base_cost * 0.50
        print(f"Express surcharge: {surcharge=}")
        total = base_cost + surcharge
    else:
        print("No express surcharge")
        total = base_cost
    
    print(f"Final: {total=}")
    print(f"=== calculate_shipping_cost returning ===\n")
    return total
 
# Prova diversi scenari
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)

Output:

=== calculate_shipping_cost called ===
Input: weight=10, distance=500, express=True
Calculated: base_cost=2500.0
Express surcharge: surcharge=1250.0
Final: total=3750.0
=== calculate_shipping_cost returning ===
 
=== calculate_shipping_cost called ===
Input: weight=5, distance=200, express=False
Calculated: base_cost=500.0
No express surcharge
Final: total=500.0
=== calculate_shipping_cost returning ===

I marcatori chiari (===) e l’output organizzato rendono facile seguire il flusso di esecuzione.

41.3.5) Rimuovere i print di debug

Una volta che hai trovato e risolto il bug, ricordati di rimuovere i print di debug. Ecco alcune strategie:

Strategia 1: Usa un prefisso distinto

python
# Facile da trovare e rimuovere con cerca/sostituisci
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")

Strategia 2: Usa un flag di debug

python
DEBUG = True
 
def calculate_total(items):
    if DEBUG:
        print(f"Processing {len(items)} items")
    
    total = sum(item['price'] for item in items)
    
    if DEBUG:
        print(f"{total=}")
    
    return total
 
# Disattiva tutto l'output di debug in una volta
DEBUG = False

Strategia 3: Commentali ma conservali

python
def process_data(data):
    # print(f"DEBUG: {data=}")  # Utile per debugging futuro
    result = transform(data)
    # print(f"DEBUG: {result=}")
    return result

Per logging più sofisticato che puoi lasciare nel codice in produzione, Python ha un modulo logging, ma semplici istruzioni di stampa sono perfette per un debugging rapido durante lo sviluppo.

Il debugging con print ti mostra i valori delle variabili, ma a volte devi capire la struttura di un oggetto—quali metodi ha, che tipo è e cosa può fare. Nella prossima sezione, impareremo come ispezionare gli oggetti usando type() e dir().

41.4) Ispezionare gli oggetti: type() e dir()

Il debugging con print ti mostra i valori delle tue variabili, ma a volte il problema non è il valore—è il tipo di oggetto con cui stai lavorando. Potresti aspettarti una lista ma ricevere una stringa, oppure stai lavorando con un oggetto non familiare e non sai quali metodi supporti.

Python fornisce strumenti integrati per ispezionare gli oggetti: type() ti dice che tipo di oggetto hai, e dir() mostra quali operazioni supporta. Queste funzioni sono essenziali quando:

  • Fai debugging di errori legati ai tipi (TypeError, AttributeError)
  • Lavori con librerie o API non familiari
  • Comprendi oggetti restituiti da codice di terze parti
  • Verifichi che il tuo codice riceva i tipi attesi

Impariamo a usare questi strumenti di ispezione in modo efficace.

41.4.1) Usare type() per identificare i tipi degli oggetti

La funzione type() ti dice esattamente che tipo di oggetto hai. Questo è cruciale quando fai debugging di errori legati ai tipi:

python
def process_data(data):
    print(f"Received data: {data}")
    print(f"Data type: {type(data)}")
    
    if isinstance(data, list):
        print("Processing as list")
        return sum(data)
    elif isinstance(data, dict):
        print("Processing as dictionary")
        return sum(data.values())
    else:
        print("Unexpected type!")
        return None
 
# Prova con tipi diversi
result1 = process_data([10, 20, 30])
print(f"Result: {result1}\n")
 
result2 = process_data({'a': 10, 'b': 20, 'c': 30})
print(f"Result: {result2}\n")
 
result3 = process_data("123")
print(f"Result: {result3}")

Output:

Received data: [10, 20, 30]
Data type: <class 'list'>
Processing as list
Result: 60
 
Received data: {'a': 10, 'b': 20, 'c': 30}
Data type: <class 'dict'>
Processing as dictionary
Result: 60
 
Received data: 123
Data type: <class 'str'>
Unexpected type!
Result: None

41.4.2) Fare debugging della confusione di tipi

La confusione di tipi è una fonte comune di bug, soprattutto quando lavori con funzioni che possono ricevere dati da più fonti—input utente, lettura di file, risposte API o altre funzioni. Potresti aspettarti una lista di numeri ma ricevere accidentalmente una stringa, oppure aspettarti un dizionario ma ottenere una lista.

Usare type() aiuta a identificare quando hai il tipo sbagliato. Stampando il tipo all’inizio della tua funzione, puoi individuare immediatamente le discrepanze di tipo prima che causino messaggi di errore confusi più in profondità nel tuo codice:

python
def calculate_average(numbers):
    print(f"{type(numbers)=}")
    print(f"{numbers=}")  # Mostra ciò che abbiamo effettivamente ricevuto
    
    # Questo fallirà se numbers non è una lista di numeri
    total = sum(numbers)
    count = len(numbers)
    return total / count
 
# Errore comune: dimenticato di convertire la stringa in lista
scores = "85"  # Dovrebbe essere [85] o solo 85
try:
    avg = calculate_average(scores)
    print(f"Average: {avg}")
except TypeError as e:
    print(f"TypeError: {e}")
    print(f"Expected list of numbers, got {type(scores)}")
    print(f"The string contains: {repr(scores)}")

Output:

type(numbers)=<class 'str'>
numbers='85'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Expected list of numbers, got <class 'str'>
The string contains: '85'

Il controllo con type() rivela immediatamente il problema: abbiamo passato una stringa quando ci serviva una lista. Senza questo output di debug, avresti potuto perdere tempo cercando di capire perché sum() è fallito, quando il vero problema è che il tipo sbagliato di dato è entrato nella funzione in primo luogo.

41.4.3) Usare dir() per scoprire i metodi disponibili

Quando lavori con oggetti non familiari—sia da una libreria che stai imparando, una risposta API, o persino i tipi integrati di Python—spesso devi sapere: "Cosa posso fare con questo oggetto?" La funzione dir() risponde a questa domanda elencando tutti gli attributi e metodi disponibili su un oggetto.

Questo è particolarmente utile quando:

  • Stai esplorando una nuova libreria e vuoi vedere quali metodi fornisce un oggetto
  • Ricevi un oggetto da codice di terze parti e devi capirne le capacità
  • Hai dimenticato il nome esatto di un metodo che vuoi usare
  • Stai facendo debugging e vuoi verificare che un oggetto abbia i metodi che ti aspetti

Esploriamo quali metodi ha una stringa:

python
# Esplorare quali metodi ha una stringa
text = "Python Programming"
 
print(f"Type: {type(text)}")
print(f"\nAvailable string methods (showing first 10):")
methods = [m for m in dir(text) if not m.startswith('_')]
for method in methods[:10]:  # Show first 10
    print(f"  {method}")
print(f"  ... and {len(methods) - 10} more")

Output:

Type: <class 'str'>
 
Available string methods (showing first 10):
  capitalize
  casefold
  center
  count
  encode
  endswith
  expandtabs
  find
  format
  format_map
  ... and 37 more

Ora puoi vedere tutte le operazioni disponibili sulle stringhe. Se non eri sicuro se le stringhe avessero un metodo count o un metodo endswith, dir() ti mostra che esistono. Poi puoi usare la funzione help() di Python per saperne di più su qualsiasi metodo specifico:

python
# Scopri di più su un metodo specifico
help(text.count)

Questo mostrerà la documentazione per il metodo count:

Help on built-in function count:
 
count(sub[, start[, end]], /) method of builtins.str instance
    Return the number of non-overlapping occurrences of substring sub in string S[start:end].
 
    Optional arguments start and end are interpreted as in slice notation.

La funzione dir() è come avere la documentazione integrata direttamente in Python—ti mostra cosa è possibile fare con qualsiasi oggetto con cui stai lavorando.

41.4.4) Ispezionare oggetti personalizzati

Quando lavori con classi personalizzate, type() e dir() ti aiutano a capire con cosa hai a che fare. Inoltre, Python fornisce hasattr() per controllare se un oggetto ha un attributo specifico prima di tentare di accedervi—questo previene eccezioni AttributeError.

python
class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    
    def get_status(self):
        return "Passing" if self.grade >= 60 else "Failing"
 
student = Student("Alice", 85)
 
print(f"Object type: {type(student)}")
print(f"\nAvailable attributes and methods:")
for attr in dir(student):
    if not attr.startswith('_'):
        print(f"  {attr}")
 
# Controlla se esistono attributi specifici
print(f"\nHas 'name' attribute: {hasattr(student, 'name')}")
print(f"Has 'age' attribute: {hasattr(student, 'age')}")
print(f"Has 'get_status' method: {hasattr(student, 'get_status')}")
 
# Ora possiamo accedere in modo sicuro agli attributi che sappiamo esistere
if hasattr(student, 'name'):
    print(f"\nStudent name: {student.name}")
else:
    print("\nNo name attribute found")
 
if hasattr(student, 'get_status'):
    print(f"Status: {student.get_status()}")
else:
    print("No get_status method found")
 
# Questo previene errori come questo:
# print(student.age)  # Would raise AttributeError!

Output:

Object type: <class '__main__.Student'>
 
Available attributes and methods:
  get_status
  grade
  name
 
Has 'name' attribute: True
Has 'age' attribute: False
Has 'get_status' method: True
 
Student name: Alice
Status: Passing

La funzione hasattr() è essenziale per scrivere codice difensivo—codice che controlla se le operazioni sono sicure prima di eseguirle. La funzione restituisce True se l’attributo esiste, False se non esiste—consentendoti di prendere decisioni prima di tentare di accedere agli attributi. Questo è particolarmente importante quando lavori con oggetti provenienti da librerie esterne o input utente, dove non puoi garantire quali attributi saranno presenti.

41.4.5) Usare getattr() per un accesso sicuro agli attributi

Quando non sei sicuro che un attributo esista, usa getattr() con un valore predefinito:

python
def display_student_info(student):
    """Safely display student info even if some attributes are missing."""
    print(f"Type: {type(student)}")
    
    # Accesso sicuro agli attributi con valori predefiniti
    name = getattr(student, 'name', 'Unknown')
    grade = getattr(student, 'grade', 0)
    age = getattr(student, 'age', 'Not specified')
    
    print(f"Name: {name}")
    print(f"Grade: {grade}")
    print(f"Age: {age}")
    
    # Controlla se il metodo esiste prima di chiamarlo
    if hasattr(student, 'get_status'):
        status = student.get_status()
        print(f"Status: {status}")
 
# Usando la stessa classe Student di sopra
student = Student("Bob", 72)
display_student_info(student)

Output:

Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: Passing

Questo approccio previene eccezioni AttributeError quando lavori con oggetti che potrebbero non avere tutti gli attributi previsti. La funzione getattr() è particolarmente utile quando:

  • Lavori con oggetti provenienti da API esterne che potrebbero avere versioni diverse
  • Gestisci attributi opzionali nelle tue classi
  • Costruisci codice difensivo che gestisce con eleganza dati mancanti

Capire che tipo di oggetto hai e quali metodi supporta è cruciale per il debugging. Ma a volte devi verificare non solo che il codice venga eseguito, ma che produca i risultati corretti. Nella prossima sezione, impareremo come usare istruzioni assert per testare le tue assunzioni e intercettare i bug in anticipo.

41.5) Testare con istruzioni assert

Abbiamo imparato come fare debugging del codice quando le cose vanno storte—leggere i traceback, tracciare l’esecuzione mentalmente, usare istruzioni di stampa e ispezionare oggetti. Ma c’è un approccio migliore che correggere bug dopo che compaiono: prevenirli in primo luogo tramite i test.

L’istruzione assert è lo strumento di test più semplice di Python. Ti permette di verificare che il tuo codice si comporti correttamente controllando assunzioni in punti critici. Quando un’asserzione fallisce, Python ti dice immediatamente che cosa è andato storto e dove, rendendo molto più facile individuare bug in anticipo—spesso prima ancora di eseguire il tuo programma principale.

Le asserzioni sono particolarmente utili per:

  • Verificare che le funzioni producano i risultati attesi
  • Controllare che gli input rispettino i tuoi requisiti
  • Testare casi limite che potrebbero rompere il tuo codice
  • Documentare le assunzioni su cui il tuo codice si basa

Pensa alle asserzioni come a controlli automatici che verificano continuamente che il tuo codice stia funzionando come previsto. Impariamo a usarle in modo efficace.

41.5.1) Cosa fa assert

Un’istruzione assert controlla se una condizione è vera. Se la condizione è vera, non accade nulla—il codice continua normalmente. Se è falsa, Python solleva un AssertionError e interrompe l’esecuzione.

Sintassi:

python
assert condition, "Optional error message"
  • condition: Qualsiasi espressione che valuti True o False
  • "Optional error message": Testo utile mostrato quando l’asserzione fallisce

Ecco come funziona in pratica:

python
# Asserzioni semplici
x = 10
assert x > 0  # Passa in silenzio (x è effettivamente > 0)
assert x < 5  # Fallisce! Solleva AssertionError
 
# Con messaggi di errore (molto più utili!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}"  # Fallisce con un messaggio chiaro

Ora vediamo le asserzioni in una funzione reale:

python
def calculate_discount(price, discount_percent):
    # Verifica che gli input siano validi
    assert price >= 0, "Price cannot be negative"
    assert 0 <= discount_percent <= 100, "Discount must be between 0 and 100"
    
    discount_amount = price * (discount_percent / 100)
    final_price = price - discount_amount
    
    # Verifica che l'output abbia senso
    assert final_price >= 0, "Final price cannot be negative"
    
    return final_price
 
# Input validi funzionano bene
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}")  # Output: Price after 20% discount: $80.0
 
# Input non validi attivano le asserzioni
try:
    result = calculate_discount(-50, 20)
except AssertionError as e:
    print(f"Assertion failed: {e}")  # Output: Assertion failed: Price cannot be negative
 
try:
    result = calculate_discount(100, 150)
except AssertionError as e:
    print(f"Assertion failed: {e}")  # Output: Assertion failed: Discount must be between 0 and 100

41.5.2) Usare le asserzioni per verificare il comportamento delle funzioni

Le asserzioni sono eccellenti per testare che le funzioni producano i risultati attesi:

python
def calculate_average(numbers):
    if not numbers:
        return 0.0
    return sum(numbers) / len(numbers)
 
# Test con input diversi
result = calculate_average([10, 20, 30])
assert result == 20.0, f"Expected 20.0, got {result}"
print(f"Test 1 passed: average of [10, 20, 30] = {result}")
 
result = calculate_average([5, 5, 5, 5])
assert result == 5.0, f"Expected 5.0, got {result}"
print(f"Test 2 passed: average of [5, 5, 5, 5] = {result}")
 
result = calculate_average([])
assert result == 0.0, f"Expected 0.0 for empty list, got {result}"
print(f"Test 3 passed: average of [] = {result}")
 
result = calculate_average([100])
assert result == 100.0, f"Expected 100.0, got {result}"
print(f"Test 4 passed: average of [100] = {result}")

Output:

Test 1 passed: average of [10, 20, 30] = 20.0
Test 2 passed: average of [5, 5, 5, 5] = 5.0
Test 3 passed: average of [] = 0.0
Test 4 passed: average of [100] = 100.0

Se una qualsiasi asserzione fallisce, sai immediatamente quale caso di test ha rivelato il problema.

41.5.3) Testare i casi limite

I casi limite sono input ai confini di ciò che la tua funzione dovrebbe gestire. Testarli rivela bug che input normali potrebbero non far emergere:

python
def get_first_and_last(items):
    """Return the first and last items from a sequence."""
    assert len(items) > 0, "Cannot get first and last from empty sequence"
    return items[0], items[-1]
 
# Test caso normale
result = get_first_and_last([1, 2, 3, 4, 5])
assert result == (1, 5), f"Expected (1, 5), got {result}"
print(f"Normal case: {result}")
 
# Test caso limite: elemento singolo
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
 
# Test caso limite: due elementi
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
 
# Test caso limite: sequenza vuota (dovrebbe fallire)
try:
    result = get_first_and_last([])
    print("ERROR: Should have raised AssertionError for empty list")
except AssertionError as e:
    print(f"Empty list correctly rejected: {e}")

Output:

Normal case: (1, 5)
Single item: (42, 42)
Two items: (10, 20)
Empty list correctly rejected: Cannot get first and last from empty sequence

41.5.4) Testare trasformazioni di dati

Quando la tua funzione trasforma dati, verifica con assert che la trasformazione sia corretta:

python
def remove_duplicates(items):
    """Remove duplicates while preserving order."""
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result
 
# Test rimozione base dei duplicati
input_data = [1, 2, 2, 3, 1, 4, 3, 5]
result = remove_duplicates(input_data)
expected = [1, 2, 3, 4, 5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 1 passed: {input_data} -> {result}")
 
# Test che l'ordine sia preservato
input_data = [3, 1, 2, 1, 3, 2]
result = remove_duplicates(input_data)
expected = [3, 1, 2]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 2 passed: {input_data} -> {result}")
 
# Test con nessun duplicato
input_data = [1, 2, 3, 4, 5]
result = remove_duplicates(input_data)
assert result == input_data, f"Expected {input_data}, got {result}"
print(f"Test 3 passed: {input_data} -> {result}")
 
# Test con tutti duplicati
input_data = [5, 5, 5, 5]
result = remove_duplicates(input_data)
expected = [5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 4 passed: {input_data} -> {result}")

Output:

Test 1 passed: [1, 2, 2, 3, 1, 4, 3, 5] -> [1, 2, 3, 4, 5]
Test 2 passed: [3, 1, 2, 1, 3, 2] -> [3, 1, 2]
Test 3 passed: [1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5]
Test 4 passed: [5, 5, 5, 5] -> [5]

41.5.5) Creare una semplice funzione di test

Man mano che il tuo codice cresce, spargere istruzioni assert in tutto il codice principale diventa disordinato e difficile da gestire. Un approccio migliore è organizzare i test in funzioni di test dedicate. Questo separa il codice di test dal codice di produzione e rende facile eseguire tutti i test in una volta sola.

Perché usare funzioni di test dedicate?

  • Organizzazione: tutti i test per una funzione sono in un unico posto
  • Riutilizzabilità: esegui i test ogni volta che cambi il codice
  • Documentazione: i test mostrano come dovrebbe comportarsi la funzione
  • Debugging: quando un test fallisce, sai immediatamente quale scenario si è rotto
  • Flusso di lavoro di sviluppo: testa prima, poi implementa o correggi il codice

Vediamolo in pratica:

python
def calculate_grade(score):
    """Convert numeric score to letter grade."""
    if score >= 90:
        return 'A'
    elif score >= 80:
        return 'B'
    elif score >= 70:
        return 'C'
    elif score >= 60:
        return 'D'
    else:
        return 'F'
 
def test_calculate_grade():
    """Test the calculate_grade function.
    
    This function tests all expected behaviors:
    - Each grade range (A, B, C, D, F)
    - Boundary values (90, 80, 70, 60)
    - Edge cases (just below each boundary)
    """
    print("Testing calculate_grade...")
    
    # Test voti A
    assert calculate_grade(95) == 'A', "95 should be A"
    assert calculate_grade(90) == 'A', "90 should be A (boundary)"
    print("  ✓ A grades: passed")
    
    # Test voti B
    assert calculate_grade(85) == 'B', "85 should be B"
    assert calculate_grade(80) == 'B', "80 should be B (boundary)"
    print("  ✓ B grades: passed")
    
    # Test voti C
    assert calculate_grade(75) == 'C', "75 should be C"
    assert calculate_grade(70) == 'C', "70 should be C (boundary)"
    print("  ✓ C grades: passed")
    
    # Test voti D
    assert calculate_grade(65) == 'D', "65 should be D"
    assert calculate_grade(60) == 'D', "60 should be D (boundary)"
    print("  ✓ D grades: passed")
    
    # Test voti F
    assert calculate_grade(55) == 'F', "55 should be F"
    assert calculate_grade(0) == 'F', "0 should be F"
    print("  ✓ F grades: passed")
    
    # Test casi limite (uno sotto ogni soglia)
    assert calculate_grade(89) == 'B', "89 should be B (just below A)"
    assert calculate_grade(79) == 'C', "79 should be C (just below B)"
    assert calculate_grade(69) == 'D', "69 should be D (just below C)"
    assert calculate_grade(59) == 'F', "59 should be F (just below D)"
    print("  ✓ Boundary cases: passed")
    
    print("All tests passed! ✓\n")
 
# Esegui i test
test_calculate_grade()
 
# Ora puoi usare la funzione con sicurezza
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")

Output:

Testing calculate_grade...
  ✓ A grades: passed
  ✓ B grades: passed
  ✓ C grades: passed
  ✓ D grades: passed
  ✓ F grades: passed
  ✓ Boundary cases: passed
All tests passed! ✓
 
Student score 87 = Grade B

Benefici di questo approccio:

  1. Organizzazione chiara dei test: puoi vedere tutti i casi di test a colpo d’occhio
  2. Facile da eseguire: basta chiamare test_calculate_grade() ogni volta che modifichi la funzione
  3. Feedback progressivo: vedi quali gruppi di test passano mentre la funzione gira
  4. Auto-documentante: la funzione di test mostra esattamente come calculate_grade() dovrebbe funzionare

Quando eseguire i test:

  • Prima di fare modifiche: assicurati che i test passino con il codice attuale
  • Dopo aver fatto modifiche: verifica di non aver rotto nulla
  • Quando aggiungi funzionalità: scrivi prima i test per la nuova funzionalità (test-driven development)
  • Quando correggi bug: aggiungi un test che riproduca il bug, poi correggilo

Questo semplice schema—scrivere funzioni di test con asserzioni—è la base del testing del software professionale. Man mano che avanzi, imparerai framework di test come pytest e unittest, ma l’idea di fondo resta la stessa: scrivere funzioni che verificano che il tuo codice funzioni correttamente.

41.5.6) Quando usare asserzioni vs eccezioni

Capire quando usare asserzioni rispetto alle eccezioni è fondamentale. Servono a scopi profondamente diversi:

Le asserzioni servono a trovare bug durante lo sviluppo:

  • Controllano cose che non dovrebbero mai essere false se il tuo codice è scritto correttamente
  • Verificano assunzioni e logica interne del tuo codice
  • Ti aiutano a intercettare errori di programmazione mentre scrivi e testi il codice
  • Esempio: "A questo punto nella mia funzione, questa lista non dovrebbe mai essere vuota"
  • Esempio: "Tutti gli elementi in questa lista dovrebbero essere interi perché li ho appena filtrati"

Le eccezioni servono a gestire errori che possono accadere durante il normale funzionamento:

  • Gestiscono condizioni esterne che non puoi controllare
  • Gestiscono situazioni che potrebbero verificarsi anche quando il tuo codice è perfetto
  • Permettono al programma di recuperare in modo elegante o fallire in modo informativo
  • Esempio: l’utente inserisce testo quando ti aspettavi un numero
  • Esempio: un file che il tuo codice prova ad aprire non esiste
  • Esempio: una richiesta di rete va in timeout

La differenza chiave: le asserzioni dicono "questo dovrebbe essere impossibile", mentre le eccezioni dicono "questo potrebbe accadere, ed ecco come lo gestiamo."

Vediamolo in pratica:

python
# Esempio 1: Funzione usata con INPUT UTENTE
# Gli utenti potrebbero inserire qualsiasi cosa, incluso 0
def calculate_user_ratio(numerator, denominator):
    """Calculate ratio from user-provided numbers."""
    # L'utente potrebbe inserire 0, quindi usa la gestione delle eccezioni
    if denominator == 0:
        raise ValueError("Denominator cannot be zero")
    
    return numerator / denominator
 
# Esempio 2: Calcolo interno dove 0 dovrebbe essere impossibile
def calculate_percentage(part, total):
    """Calculate what percentage 'part' is of 'total'."""
    # Questa è chiamata internamente dopo aver verificato total > 0
    # Se total è 0, è un bug di programmazione nel nostro codice
    assert total > 0, "total must be positive - check calling code"
    
    return (part / total) * 100

Altri esempi di cosa dovrebbe gestire ciascuno:

SituazioneUsare asserzioneUsare eccezione
L’utente inserisce input non valido❌ No✅ Yes
Il file non esiste❌ No✅ Yes
La richiesta di rete fallisce❌ No✅ Yes
La funzione riceve un tipo di parametro sbagliato dal tuo codice✅ Yes❌ No
La lista dovrebbe avere elementi ma è vuota a causa di un errore di logica✅ Yes❌ No
Struttura dati in uno stato inatteso a causa di un bug✅ Yes❌ No
La connessione al database fallisce❌ No✅ Yes
L’API restituisce un formato inatteso❌ No✅ Yes
Il tuo algoritmo produce un risultato matematicamente impossibile✅ Yes❌ No

Limitazione critica delle asserzioni:

Le asserzioni possono essere disabilitate completamente quando Python viene eseguito con ottimizzazione:

bash
python -O script.py  # All assert statements are ignored!

Quando le asserzioni sono disabilitate, semplicemente scompaiono—Python non le controlla affatto. Questo significa:

  • Non usare mai le asserzioni per validare input utente
  • Non usare mai le asserzioni per controlli di sicurezza
  • Non usare mai le asserzioni per qualunque cosa che debba funzionare sempre in produzione
python
# PERICOLOSO - NON FARLO:
def process_payment(amount):
    assert amount > 0, "Amount must be positive"  # WRONG! Gets disabled with -O
    # Process payment...
 
# CORRETTO - FAI COSÌ:
def process_payment(amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")  # Always checked!
    # Process payment...

In sintesi:

  • Asserzioni = "Sto controllando il mio codice per trovare bug durante lo sviluppo"

    • Pensa: "Questo dovrebbe essere impossibile se ho programmato correttamente"
    • Ti aiutano a trovare errori nella tua logica
  • Eccezioni = "Sto gestendo condizioni del mondo reale che possono davvero verificarsi"

    • Pensa: "Questo potrebbe accadere durante l’uso normale, e devo gestirlo"
    • Aiutano il tuo programma a gestire situazioni imprevedibili

Le asserzioni sono strumenti di sviluppo e debugging che ti aiutano a scrivere codice corretto. Le eccezioni sono strumenti di produzione che aiutano il tuo programma a gestire la realtà disordinata di input utente, file system, reti e altri fattori esterni che non puoi controllare.


Ora hai imparato le tecniche essenziali di debugging e test che ti saranno utili per tutto il tuo percorso di programmazione:

  • Leggere i traceback per individuare rapidamente dove si verificano gli errori
  • Tracciare mentalmente il codice per capire cosa fa passo dopo passo
  • Usare istruzioni print in modo strategico per vedere valori e flusso a runtime
  • Ispezionare oggetti con type() e dir() per capire con cosa stai lavorando
  • Testare con asserzioni per verificare che il codice funzioni e intercettare bug in anticipo

Queste competenze lavorano insieme come un toolkit completo di debugging. Quando incontri un problema:

  1. Leggi il traceback per trovare dove è fallito
  2. Usa print debugging o traccia mentale per capire perché
  3. Usa l’ispezione con type/dir quando non sei sicuro di cosa un oggetto possa fare
  4. Scrivi asserzioni per impedire che il bug ritorni

Con la pratica, svilupperai un’intuizione su quale tecnica usare in ogni situazione. Ricorda: ogni programmatore fa debugging del codice—la differenza è che i programmatori esperti lo fanno in modo sistematico ed efficiente. Queste tecniche faranno di te uno di loro.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai