Come disinnescare un if: nove attrezzi per raddrizzare il codice a freccia
Erediti spedizioni.py: una funzione a freccia con complessità 18. Un pomeriggio, nove attrezzi (guardie, dizionari, bisect, polimorfismo, Null Object, match, EAFP) e la scoperta che in Python il trucco branchless rallenta.
Lunedì 15 settembre 2025, primo pomeriggio. Il caffè è ancora caldo quando apri spedizioni.py, il modulo che da oggi è tuo. Chi l'ha scritto ha cambiato lavoro, e in eredità ti ha lasciato una funzione, un commento in cima che dice «non toccare» e una forma geometrica: il codice scivola verso destra, un rientro dopo l'altro, come una freccia puntata contro il margine dello schermo.
def calcola_spedizione(ordine, express=False):
if ordine is not None:
if ordine.get("righe"):
peso = sum(r["peso"] for r in ordine["righe"])
if peso <= 30:
if ordine["paese"] == "IT":
corriere = "BRT"
elif ordine["paese"] == "FR":
corriere = "Colissimo"
elif ordine["paese"] == "DE":
corriere = "DHL"
elif ordine["paese"] == "ES":
corriere = "SEUR"
else:
corriere = "UPS"
if peso <= 2:
costo = 4.90
elif peso <= 5:
costo = 6.90
elif peso <= 10:
costo = 9.90
elif peso <= 20:
costo = 14.90
else:
costo = 19.90
if express:
costo = costo * 2
if ordine.get("coupon") is not None:
if ordine["coupon"]["tipo"] == "percentuale":
costo = costo * (1 - ordine["coupon"]["valore"] / 100)
elif ordine["coupon"]["tipo"] == "fisso":
costo = max(0, costo - ordine["coupon"]["valore"])
elif ordine["coupon"]["tipo"] == "gratis":
costo = 0
return corriere, round(costo, 2)
else:
raise ValueError("troppo pesante")
else:
raise ValueError("ordine vuoto")
else:
raise ValueError("nessun ordine")Lo leggi due volte. Funziona, questo va detto: da tre anni calcola il corriere e il costo di ogni pacco che esce dal magazzino. Ma per sapere cosa succede a un ordine francese da 12 chili con un coupon fisso, devi seguire con il dito quattro livelli di rientro. E gli errori sono finiti in fondo, lontanissimi dagli if che li hanno causati: il raise ValueError("nessun ordine") dell'ultima riga risponde a un if che sta 40 righe più su.
Lanci radon, che misura la complessità del codice Python, e lui ti risponde con un voto da pagella:
$ radon cc -s spedizioni.py
spedizioni.py
F 1:0 calcola_spedizione - C (18)Diciotto. Oggi pomeriggio quel numero lo farai scendere, un attrezzo alla volta.
Che cosa misura quel 18
Il numero si chiama complessità ciclomatica. Lo ha inventato Thomas McCabe, che nel 1976 lavorava per il Dipartimento della Difesa americano e lo pubblicò in un articolo intitolato, senza troppa fantasia, A Complexity Measure. L'idea è semplice: disegni la funzione come una mappa di incroci e conti le strade indipendenti che vanno dall'ingresso all'uscita. In pratica è il numero di decisioni più uno: ogni if, ogni elif, ogni and e ogni or aggiunge una biforcazione.
Perché ti dovrebbe interessare? Perché ogni strada è un caso da provare. Diciotto strade indipendenti significa che per percorrerle tutte almeno una volta servono almeno diciotto test diversi, e nessuno li ha mai scritti. McCabe propose di spezzare le funzioni che superano 10. Vent'anni dopo, in una guida del NIST sul collaudo del software, lui e Arthur Watson scrivevano che quel limite aveva «un notevole supporto sperimentale», anche se c'era chi usava 15 senza problemi. Diciotto non va bene in nessuno dei due casi.
Un italiano contro gli if
Non sei la prima persona a provare antipatia per quella freccia. Il 23 novembre 2007, all'Italian Agile Day, Francesco Cirillo presentò la Anti-IF Campaign. Sì, è lo stesso Cirillo della tecnica del Pomodoro, quella del timer da cucina a forma di pomodoro per lavorare a blocchi di 25 minuti. Il sito della campagna spiegava così il motivo: «Molti team vogliono essere agili, ma non conoscono le basi per ridurre la complessità del codice». Nel giugno 2008 aderì Kent Beck, il padre dell'Extreme Programming, e dopo di lui un lungo elenco di sviluppatori.
Il bersaglio non era ogni singolo if. Lo ha chiarito anche Craig Larman, amico di Cirillo: la campagna «non significa che tutti gli if siano cattivi». Il nemico erano soprattutto gli if che controllano che tipo di cosa hai in mano per decidere cosa farne. Nel tuo modulo ce ne sono parecchi.
Prima di tutto, la rete
C'è una regola che vale più di tutti gli attrezzi che stai per usare: non si cambia la forma del codice senza una rete sotto. Prima di toccare una riga, copi la vecchia funzione in un file a parte e scrivi un test che genera ordini a caso (paesi, pesi, coupon, ordini vuoti) e confronta il risultato del vecchio codice con quello del nuovo:
for _ in range(20_000):
o = ordine_a_caso()
for express in (False, True):
vecchio = vecchia.calcola_spedizione(o, express=express)
nuovo = (spedizione_express if express else spedizione_standard)(o)
assert vecchio == nuovo, (o, express, vecchio, nuovo)Ventimila ordini, ciascuno in versione normale ed express: quarantamila confronti. La funzione nuova deve dare esattamente gli stessi risultati della vecchia, comprese le eccezioni. Adesso puoi cominciare.
Attrezzo 1: le guardie all'ingresso
La prima cosa che fai è raddrizzare la freccia. Le tre condizioni di errore (nessun ordine, ordine vuoto, pacco troppo pesante) sono controlli d'ingresso: se l'ordine non è valido non ha senso calcolare niente. Allora le metti in cima, una dopo l'altra, ognuna con il suo raise subito sotto, come un buttafuori alla porta della discoteca:
def _costo_base(ordine):
if not ordine or not ordine.get("righe"):
raise ValueError("ordine vuoto")
peso = sum(r["peso"] for r in ordine["righe"])
if peso > PESO_MAX:
raise ValueError("troppo pesante")
# da qui in poi: l'ordine esiste, ha righe, pesa il giusto
...Martin Fowler, nel suo catalogo di refactoring, chiama questa operazione Replace Nested Conditional with Guard Clauses: sostituire le condizioni annidate con delle clausole di guardia. Nota una cosa onesta: gli if non sono spariti. Ma non sono più annidati, e tutto quello che viene dopo può dare per scontato che l'ordine sia valido. È come leggere un libro dove le note sono a piè di pagina invece che in un'appendice di 40 pagine.
Attrezzo 2: un dizionario al posto della scala
Poi guardi la scala if/elif dei corrieri: cinque gradini per dire «se il paese è X, usa il corriere Y». Quella non è logica, è una tabella travestita da codice. E le tabelle in Python hanno un nome: dizionario.
CORRIERI = {"IT": "BRT", "FR": "Colissimo", "DE": "DHL", "ES": "SEUR"}
corriere = CORRIERI.get(ordine["paese"], "UPS")Una riga, e il caso «tutti gli altri paesi» è il secondo argomento di .get(). Aggiungere il Portogallo non vuol dire più aggiungere un ramo al codice: vuol dire aggiungere una voce a un elenco.
Questo, per Python, è quasi un dogma. Il linguaggio non ha mai avuto lo switch che hanno C e Java, e non per caso. Fu proposto nel 2001 (PEP 275) e di nuovo nel 2006 (PEP 3103, scritta da Guido van Rossum in persona). Nel 2007, durante il suo intervento al PyCon, Guido fece un sondaggio veloce tra il pubblico. Il risultato: la proposta fu respinta perché «non ha alcun sostegno popolare». Per anni la risposta ufficiale a «come faccio uno switch in Python?» è stata: usa un dizionario.
Attrezzo 3: la tabella e bisect
Le fasce di peso sono più insidiose: non sono uguaglianze («il paese è FR»), sono intervalli («fino a 2 kg», «fino a 5 kg»). Un dizionario non basta. Ma la libreria standard ha un modulo che fa esattamente questo, bisect, che cerca in quale posizione di una lista ordinata andrebbe un valore. La documentazione ufficiale ne mostra l'uso con un esempio sui voti degli esami:
>>> def grade(score):
... i = bisect([60, 70, 80, 90], score)
... return "FDCBA"[i]Quattro soglie, cinque voti, zero if. Tu fai lo stesso con le tariffe:
from bisect import bisect_left
SOGLIE_KG = [2, 5, 10, 20, 30]
TARIFFE = [4.90, 6.90, 9.90, 14.90, 19.90]
PESO_MAX = SOGLIE_KG[-1]
def tariffa(peso):
return TARIFFE[bisect_left(SOGLIE_KG, peso)]C'è una trappola, ed è proprio quella in cui stavi per cadere. bisect (cioè bisect_right) e bisect_left si comportano diversamente quando il valore cade esattamente su una soglia:
>>> from bisect import bisect, bisect_left
>>> bisect_left([2, 5, 10, 20, 30], 2) # 2 kg: prima fascia, 4,90
0
>>> bisect([2, 5, 10, 20, 30], 2) # 2 kg: seconda fascia, 6,90. Ops.
1Il vecchio codice diceva peso <= 2, quindi 2 kg fanno parte della prima fascia, e serve bisect_left. Con l'altra versione ogni pacco da esattamente 2, 5, 10 o 20 chili costerebbe una fascia in più. Il test con 40.000 ordini lo trova al primo giro, perché tra i pesi casuali ci sono anche quelli esatti. Questa è la ragione per cui la rete si scrive prima.
Il premio, però, è grande: adesso le tariffe sono dati. Possono stare in un file di configurazione o in una tabella del database. Quando il listino cambierà (e cambierà), non toccherai la logica.
Attrezzo 4: lascia decidere agli oggetti
Arrivi ai coupon, e qui c'è proprio il tipo di if contro cui è nata la campagna di Cirillo: «se il coupon è di tipo percentuale fai questo, se è fisso fai quest'altro, se è gratis fai quest'altro ancora». Ogni volta che qualcuno inventa un nuovo tipo di coupon, qualcun altro deve trovare tutti i posti del codice dove c'è quella scala di if e aggiungere un gradino. Se ne dimentica uno, il bug è servito.
L'alternativa è che ogni tipo di coupon sappia da solo cosa fare. Fowler la chiama Replace Conditional with Polymorphism:
from dataclasses import dataclass
@dataclass
class Percentuale:
valore: float
def applica(self, costo): return costo * (1 - self.valore / 100)
@dataclass
class Fisso:
valore: float
def applica(self, costo): return max(0, costo - self.valore)
class Gratis:
def applica(self, costo): return 0Tre classi, ciascuna con un metodo applica. Chi usa il coupon non chiede più «di che tipo sei?»: chiama coupon.applica(costo) e basta. Python non chiede nemmeno una classe base comune, perché gli basta che il metodo ci sia (è il duck typing: se fa «qua» come un'anatra, per Python è un'anatra). Un nuovo tipo di coupon domani sarà una classe nuova, senza toccare quelle che già funzionano.
Attrezzo 5: l'oggetto che non fa niente
Resta un if sui coupon: quello che controlla se il coupon c'è (is not None). Questo if ha una storia illustre. Nel 1965 Tony Hoare, progettando il linguaggio ALGOL W, introdusse il riferimento nullo «semplicemente perché era così facile da implementare». Nel 2009, a una conferenza a Londra, lo ha chiamato pubblicamente «il mio errore da un miliardo di dollari»: da allora ogni riferimento che potrebbe essere nullo costringe a un controllo ogni volta che lo usi. Se lo dimentichi, il programma crolla.
Il rimedio è un po' zen: un coupon che non fa niente.
class NessunCoupon:
def applica(self, costo): return costoSi chiama Null Object, e Fowler lo cataloga come Introduce Special Case. Quando l'ordine non ha coupon, invece di None ricevi un NessunCoupon, che restituisce il costo senza modifiche. Il resto del codice non deve più chiedersi niente. In piccolo, Python lo fa già ogni volta che scrivi dict.get(chiave, valore_di_default), o quando usi un defaultdict: il caso «non c'è» diventa un valore normale.
Attrezzo 6: match, ma al confine
A questo punto ti serve un posto dove trasformare il coupon grezzo (un dizionario che arriva dal JSON dell'ordine) in uno degli oggetti di prima. È il lavoro perfetto per match, il pattern matching strutturale arrivato con Python 3.10 nell'ottobre 2021 (PEP 634):
COUPON = {"percentuale": Percentuale, "fisso": Fisso,
"gratis": lambda valore=0: Gratis()}
def coupon_da(dati):
match dati:
case None:
return NessunCoupon()
case {"tipo": tipo, **resto}:
return COUPON[tipo](**resto)Due casi: o non c'è niente, e restituisci il coupon che non fa niente, o c'è un dizionario con una chiave tipo, e il resto delle chiavi va al costruttore giusto. Sia chiaro: match non è un modo per evitare le decisioni. È un altro modo di scriverle, e radon lo sa, infatti a coupon_da dà 3. Ma ora la decisione si prende una volta sola, al confine, dove i dati entrano nel programma. Da lì in poi il codice lavora con oggetti che sanno cosa fare, e non deve più chiedere niente a nessuno.
È la regola più importante di tutto il pomeriggio: un if sul tipo di una cosa va bene una volta, quando la crei. Se lo ripeti ogni volta che la usi, è lì che nasce la freccia.
Attrezzo 7: via la bandierina
Ti resta il parametro express=False. Cerchi nel progetto dove viene chiamata la funzione, e trovi questo:
calcola_spedizione(ordine, True) # True cosa?True cosa? Per scoprirlo bisogna aprire la funzione. Un parametro booleano che cambia il comportamento è una funzione che ne contiene due, con un if dentro a decidere quale delle due eseguire. Fowler la chiama Remove Flag Argument: dai a ciascun comportamento il suo nome.
def _chiudi(ordine, corriere, costo):
coupon = coupon_da(ordine.get("coupon"))
return corriere, round(coupon.applica(costo), 2)
def spedizione_standard(ordine):
corriere, costo = _costo_base(ordine)
return _chiudi(ordine, corriere, costo)
def spedizione_express(ordine):
corriere, costo = _costo_base(ordine)
return _chiudi(ordine, corriere, costo * 2)Adesso, nel codice che le usa, si legge spedizione_express(ordine). Nessuno deve più chiedersi cosa voglia dire True.
Attrezzo 8: chiedere scusa invece del permesso
Mentre sei lì, nello stesso file trovi una funzioncina per le giacenze che fa un if prima di leggere un dizionario. Il glossario ufficiale di Python ha due sigle per questi due stili. LBYL, Look Before You Leap («guarda prima di saltare»), è lo stile pieno di if che controllano prima di agire. EAFP, Easier to Ask for Forgiveness than Permission («è più facile chiedere scusa che chiedere il permesso»), invece agisce e gestisce l'eccezione se qualcosa va storto.
# LBYL: guarda prima di saltare
if sku in giacenze:
return giacenze[sku]
return 0
# EAFP: salta, e se cadi chiedi scusa
try:
return giacenze[sku]
except KeyError:
return 0
# oppure, ancora più corto
return giacenze.get(sku, 0)Il glossario aggiunge un dettaglio che pochi ricordano: con più thread, LBYL può essere proprio sbagliato. Tra il momento in cui controlli if sku in giacenze e quello in cui leggi giacenze[sku], un altro thread potrebbe aver tolto quella chiave. Con EAFP il controllo e la lettura sono un'unica operazione. Un'avvertenza, però: l'eccezione serve per i casi eccezionali, come una chiave che manca o un file che non c'è. Non usarla per le regole di business. Ne abbiamo già parlato in un altro articolo.
Attrezzo 9: il trucco senza if (che in Python non funziona)
Sul web ti è capitata più volte una domanda di Stack Overflow del 2012 che è diventata leggendaria: perché un ciclo in C++ su un array ordinato è molto più veloce dello stesso ciclo sullo stesso array in disordine? La risposta è la previsione dei salti (branch prediction). Il processore, per non perdere tempo, prova a indovinare in anticipo come andrà ogni if. Con i dati ordinati indovina quasi sempre, con quelli in disordine sbaglia circa una volta su due, e ogni errore costa parecchi cicli di clock, perché il lavoro fatto in anticipo va buttato. Il rimedio, in C, è scrivere codice branchless: niente salti, solo aritmetica.
In Python anche i booleani sono numeri (True + True fa 2), quindi il trucco si può scrivere:
def con_if(dati):
t = 0
for x in dati:
if x >= 128:
t += x
return t
def senza_if(dati):
t = 0
for x in dati:
t += x * (x >= 128) # True vale 1, False vale 0
return tTi viene la curiosità e misuri, con un milione di numeri tra 0 e 255 su Python 3.13. I risultati sono il tempo migliore su sette prove:
casuale ordinato
con if 32,4 ms 28,1 ms
senza if 42,0 ms 35,6 msDue sorprese. La versione senza if è più lenta di circa il 30%: la moltiplicazione e la conversione del booleano costano più del salto che vorresti evitare. E l'array ordinato è più veloce anche nella versione senza if, quindi la differenza non viene (solo) dal tuo if. Viene dall'interprete, che per eseguire ogni tua riga fa migliaia di scelte sue, e probabilmente da come i dati stanno in memoria. Morale: in Python i trucchi branchless sono folklore. Gli if si tolgono per chi legge il codice, non per il processore. E se ti serve davvero la velocità, i cicli si spostano dentro NumPy o in codice compilato.
L'if che resta
Sono le sei e mezza. Il test gira: quarantamila confronti, tutti identici. Rilanci radon:
$ radon cc -s spedizioni.py
spedizioni.py
F 41:0 _costo_base - A (5)
F 29:0 coupon_da - A (3)
C 11:0 Percentuale - A (2)
...
F 54:0 spedizione_standard - A (1)
F 59:0 spedizione_express - A (1)La funzione da 18 non esiste più. Al suo posto ci sono funzioni piccole, la più complessa vale 5, e ognuna si legge in un colpo d'occhio. Il file, però, è più lungo di prima: da 41 righe a una sessantina. Se qualcuno in azienda misura la produttività contando le righe, oggi hai fatto un ottimo lavoro, ma per i motivi sbagliati.
E alcuni if sono rimasti, di proposito. Quello in _costo_base che rifiuta i pacchi oltre i 30 chili non è un difetto di progettazione: è una regola del mondo reale, e quell'if è il modo più chiaro di scriverla. Non serve trasformarlo in una gerarchia di classi. Allo stesso modo, un if/else con due casi non diventa più elegante se lo trasformi in un dizionario con due chiavi. E il polimorfismo ha un costo: la logica che prima stava in una funzione ora è divisa tra diverse classi.
La differenza, alla fine, è tutta qui. Un if che esprime una regola del dominio («oltre 30 kg non spediamo») è documentazione che si esegue. Un if che chiede a un oggetto «di che tipo sei?» per la decima volta in dieci file diversi è un problema di progettazione, e prima o poi qualcuno ne pagherà il prezzo.
Fai il commit e cancelli il commento «non toccare». Ci hai messo un pomeriggio, cioè, per dirla alla Cirillo, nove pomodori, pause comprese.
Fonti
- Thomas J. McCabe, A Complexity Measure (IEEE Transactions on Software Engineering, 1976)
- Arthur H. Watson, Thomas J. McCabe, Structured Testing (NIST SP 500-235, 1996), sezione sul limite di 10
- Anti-IF Campaign, il sito originale nell'archivio di Internet Archive (2010)
- Craig Larman, Anti-If Campaign
- Martin Fowler, catalogo dei refactoring: guard clauses, polimorfismo, special case, flag argument
- PEP 3103 (switch, respinta) e PEP 634 (pattern matching)
- Documentazione di Python: bisect e glossario (EAFP, LBYL)
- Tony Hoare, Null References: The Billion Dollar Mistake (QCon London 2009)
- Stack Overflow, Why is processing a sorted array faster than processing an unsorted array? (2012)
- radon, documentazione