Name shadowing e import circolari.

Ovvero, ma perché diavolo i manuali, i corsi, i video su YouTube (brr...) e chiunque ha la pretesa di insegnare Python non avverte mai il principiante di questa trappola?

Sia chiaro: contrariamente a quello che dicono tutti, la realtà è che Python è un linguaggio molto difficile da imparare. Appena metti il piede fuori dal sentiero tracciato dal tuo libro o dal tuo corso, è praticamente certo che finirai in un burrone. Un manuale non può certo prevedere tutti i variegati modi in cui i suoi lettori possono spararsi in un piede. Quando un manuale prescrive di fare A, e poi B, e poi C... la verità è che il suo autore in quel momento chiude gli occhi, incrocia le dita e prega che a nessuno venga in mente di fare anche D, perché a quel punto il risultato potrebbe essere così distante dal previsto e talmente difficile da spiegare, che il lettore potrebbe bruciare il libro per il nervoso.

Tuttavia alcune trappole sono talmente ovvie e frequenti che davvero diventa una colpa, non avvisare il principiante almeno di queste. Il name shadowing è una. Basterebbe che ogni manuale avesse una pagina completamente bianca, con una scritta rossa al centro: "FATEVI UN FAVORE..." (ok, ve lo dico alla fine di questo post quale favore dovete farvi, altrimenti non lo leggete). Ora, il principiante non ha bisogno di capire tutti i dettagli del name shadowing, e quando farlo è pericoloso e quando non lo è: francamente, tutti i dettagli spesso non li capisce neanche un programmatore esperto. Io di sicuro no. Non c'è niente da fare: Python è difficile. Tuttavia basta dire al principiante di non farlo, tutto qua. Con il tempo imparerà più cose, ma almeno nel frattempo non casca nella trappola, che poi per tirarlo fuori bisogna fare lo spiegone terrorizzante.

Che cos'è un import circolare.

Prima di parlare di name shadowing, facciamo una premessa sugli import circolari, che poi è un'altra trappola frequente. Un import circolare accade quando un modulo A importa il modulo B, che a sua volta importa il modulo A. Per esempio, immaginiamo di avere tre moduli fatti in questo modo:

# file a.py
print('io sono A')
import b

# file b.py
print('io sono B')
import a

# file main.py
import a

Adesso, quando invochiamo dalla console python main.py, quello che otteniamo è semplicemente:

Io sono A
Io sono B

Forse questa è già una sorpresa: ci saremmo aspettati un rimpallo infinito tra i due moduli. Ma la verità è che Python tiene traccia dei moduli importati in sys.modules: quando un modulo è già stato importato, non lo importa più una seconda volta. Tuttavia, se invece di invocare "main.py" proviamo con python a.py, otteniamo invece:

Io sono A
Io sono B
Io sono A

Ops! Questo dipende dal fatto che, la prima volta, "a.py" non viene importato come un modulo, ma viene eseguito come uno script. E a partire da questa piccola crepa, possiamo scavare una voragine di complessità sugli import circolari, andando a caccia di quando è possibile farli, scoprendo come talvolta basta spostare un import dentro una funzione, catalogando astruserie assortite sull'ordine degli import, e così via all'infinito. Una delle perversioni più semplici è avventurarsi in strani "trielli messicani" mentre si importa con il "from":

# file a.py
import b

def funct_1():
    print('sono a.funct_1')
    b.funct_3()

def funct_2():
    print('sono a.funct_2')


# file b.py
from a import func_2  # import a

def funct_3():
    print('sono b.funct_3')
    funct_2()         # a.funct_2()


# file main.py
import a

a.funct_1()

Quando eseguiamo python main.py il risultato è terrificante:

Traceback (most recent call last):
  (...)
ImportError: cannot import name 'func_2' from partially initialized module 'a' (most likely due to a circular import)

Tuttavia, per come funzionano gli import in Python, se avessimo semplicemente scritto import a tutto sarebbe andato a posto. E beninteso, se invece le funzioni fossero state solo due che si chiamano a vicenda, avremmo ottenuto un "banale" RecursionError.

È necessario per un principiante capire queste trappole? Per niente! Anche un programmatore esperto potrebbe avere difficoltà a districarsi nel codice qui sopra. Gli import di Python sono difficili, per tutti. Ciò che sa il programmatore esperto, tuttavia, e che al principiante andrebbe detto subito, è questo: non usare gli import circolari! Così, puro e semplice. Ci sarà tempo per capire meglio tutte le raffinatezze, le regole, le eccezioni e le buone pratiche. Ma finché non si hanno due lauree in filologia pythonica e pythonologia comparata, è meglio lasciar perdere gli import circolari. Ci si guadagna anche e soprattutto con la nozione che gli import circolari sono comunque una cattiva idea, e si impara a mantenere più organizzato il codice.

Il name shadowing.

Quello del name shadowing è un concetto apparentemente distinto: consiste nel sovrascrivere dei nomi già assegnati da altri con i nostri. Una minuscola percentuale di name shadowing è semplicemente proibita per legge: per esempio, scrivere for = 10 è SyntaxError perché "for" è una parola riservata. Tuttavia Python è molto democratico: le parole riservate sono pochissime, tutto il resto è terreno di caccia libera.

Possiamo quindi a scrivere cose come dir = 'ciao', o int = 25. Ovviamente, molto dipende da dove lo facciamo: nel corpo di una funzione, tutto sommato poco male; a livello del modulo... molto peggio; fare __builtins__.int = 25 è proprio criminale (ma per fortuna fuori dalla portata del principiante, si spera).

Il name shadowing preferito dal principiante, non appena si mette a studiare le liste Python, è scrivere trionfalmente

list = [1, 2, 3, 4, 5]

Ogni volta che vedo una cosa del genere mi cade un capello e non ricresce più. Ma non ce l'ho con il principiante, ci mancherebbe. Il problema è del libro su cui sta studiando, del corso universitario che sta seguendo del... maledetto video su YouTube che guarda perché è gratis. Perché il libro, il corso, il video non si sono premurati di avvertirlo che i nomi sono importanti (le variabili, se volete). Se un nome è già "occupato", non bisogna usarlo. E se ho già sentito quel nome, vuol dire che probabilmente è già occupato. E ogni volta che io, insegnante o autore di libro o di video su YouTube, introduco un concetto nuovo, devo dire con chiarezza al principiante se questo concetto ha un nome, in Python, e qual è questo nome. Parliamo di "liste"? Le liste sono oggetti che hanno list come tipo: list, ricorda questo nome. Dizionari? Quelli sono dict. E così via.

Il problema è ovviamente che, qualche tempo dopo aver scritto impunemente list = [1, 2, 3] per qualche volta, il principiante scopre che può fare cose del tipo a = list('hello world')... e naturalmente una volta su due si spara nel piede, e a questo punto si attacca al forum e "aiuto non mi funzia!!!1!!!1!!". Ma di nuovo, la colpa non è sua. Ed è difficile consigliargli di seguire un buon manuale passo-passo, come faccio sempre, quando so benissimo che nessun manuale ha davvero quella pagina bianca con la scritta rossa in centro...

Ma bastasse. Il secondo name shadowing preferito del principiante, dopo "list", è quello dei nomi dei moduli della libreria standard. Creo un modulo che mette insieme qualche esercizio di matematica... e come lo chiamo? Ma math.py naturalmente! Mi sto esercitando con le date? E perché non chiamarlo datetime.py? I numeri casuali? random.py sembra proprio un nome adeguato, vero?

Ma bastasse! Il problema è che molto spesso all'interno di quei moduli tu stai già importando "math", "datetime", "random"...  perché ovviamente, se vuoi scrivere qualcosa sui numeri casuali, dovrai pure importarlo, quel "random" di Python, nel tuo modulo opportunamente chiamato "random.py". E vedete che qui già si prefigura il problema degli import circolari... ma il fatto è che bisogna sempre ricordare che in Python tutto è un "nome", e tutto è un namespace.

Ma andiamo con ordine. Se scrivo un modulo, lo chiamo "math.py" e ci metto dentro questo:

# file "math.py" (name shadowing...)
import math

def funct(x): 
    return math.sqrt(x)

if __name__ == '__main__':
    print(funct(4))

Se adesso eseguo il modulo (python math.py) tutto funziona senza problemi. Ma se invece voglio importare il modulo, per esempio da un "main.py" così:

# file "main.py"
import math
print(math.funct(4))

ecco che all'improvviso tutto si rompe con un AttributeError, per le leggi inflessibili degli import di Python. E già questo potrebbe comprensibilmente sconcertare il principiante. Inoltre, tutti i gli altri moduli nella stessa directory che dovessero per qualche ragione importare "math" fallirebbero, per la dura legge della sys.path.

Ma non basta ancora. Se adesso provo a fare una cosa assolutamente analoga, ma questa volta con il modulo "random"...

# file "random.py" (name shadowing...)
import random

def funct():
    return random.randint(1, 6)

if __name__ == '__main__': 
    print(funct())

Sembra esattamente la stessa cosa, vero? Solo usando "random" invece di "math". Eppure questa volta, anche quando provo a eseguire il modulo, ottengo un AttributeError. Perché questo scherzo succede con "random", ma non con "math"? Se ripeto il gioco usando "datetime" o "subprocess" (altri name shadowing tra i più gettonati) ottengo di nuovo AttributeError.

Ma non basta ancora... Se adesso provo a eseguire questi moduli attraverso Idle (con F5, per intenderci), trovo che "math" funziona come prima, "datetime" e "subprocess" falliscono come prima con AttributeError... e invece "random" manda completamente in tilt Idle, con una spettacolare catena di errori (la cosa migliore è avviare Idle dall'interprete dei comandi con python -m idlelib per vederlo).

Perché "math" funziona (almeno eseguendo il modulo) e gli altri no? E perché "random" dà così fastidio a Idle? Ehm... lo state chiedendo a me? Sapete che così su due piedi non saprei rispondere? Per quanto riguarda "math" il fatto è che non c'è davvero nessun math.py nella libreria standard di Python... "math" è scritto in C, e il nome viene probabilmente iniettato dinamicamente in fase di bootstrap. L'ambiente di esecuzione di Idle, poi, è ancora più complesso. Ma a dire il vero, tutto il sistema di import e di caricamento dei moduli in Python è complesso.

E non basta, non basta ancora. Fino a poco tempo fa, esempi come questi fallivano con un AttributeError il cui messaggio era semplicemente:

AttributeError: module 'XXX' has no attribute 'YYY'


A partire da Python 3.8, per merito di questo ticket, il messaggio di errore è diventato molto più esplicito e ci segnala la (probabile) ragione dell'errore:

AttributeError: partially initialized module 'XXX' has no attribute 'YYY' 
(most likely due to a circular import)

Ed eccolo che finalmente viene fuori, il nostro import circolare! Il problema qui è che se facciamo name shadowing di un modulo della libreria standard con il nome del nostro modulo, e quindi proviamo a importare l'originale, stiamo facendo proprio uno di quegli import circolari che abbiamo visto nella prima parte di questo post. E non solo facendo name shadowing con la libreria standard, beninteso: è probabilmente una cattiva idea chiamare "django.py" il nostro esercizio di Django. O chiamare "pygame.py" i nostri esperimenti con Pygame.

E questo spiega anche perché non occorre veramente sapere nei dettagli come e perché e quando e dove fallisce un import circolare: basta capire che non si deve fare name shadowing, e di colpo tutta una classe di import circolari semplicemente sparisce. (Poi certo, restano quelli che fai apposta, del tipo "modulo A che importa B che importa A".)

Non ho bisogno di sapere con esattezza quante specie di animali pericolosi vivono nel bosco. Mi basta non andare nel bosco. Certo, poi ci sono i casi in cui invece è importante approfondire. Tuttavia la regola "non fare name shadowing, mai" è semplice da applicare e risolve veramente un sacco di problemi strani. Basta dire al principiante di fare un po' di fatica e inventarsi dei nomi nuovi. La cosa più semplice è aggiungere il prefisso "my" per distinguere: "mymath", "myrandom" e così via.

Quindi, se io scrivessi mai un manuale di Python, terrei una pagina tutta bianca con una scritta rossa al centro che dice: "fatevi un favore, metteteci un po' di fantasia con i nomi". Ecco, tutto qui.

Commenti

Post più popolari