44  Scopes, namespaces et closures

Quand vous écrivez x = 10, où cette variable x existe-t-elle ? Dans la fonction ? Dans le module ? Ailleurs ? Python suit une règle précise, la règle LEGB, pour résoudre les noms. Maîtriser cette règle évite des bugs mystérieux et permet d’écrire des closures et décorateurs solides. C’est un sujet fondamental au niveau Expert.

44.1 Qu’est-ce qu’un namespace ?

Un namespace (espace de noms) est simplement un dictionnaire qui associe des noms (variables, fonctions, classes) à des objets.

Quand vous écrivez x = 10, Python ajoute une entrée dans le namespace courant : {"x": 10}.

# Le namespace local d'une fonction peut être inspecté
def f():
    a = 1
    b = 2
    print(locals())     # dict des variables locales

f()
{'a': 1, 'b': 2}
# Le namespace global aussi
pays = "France"
langue = "Python"

# globals() renvoie le dict du namespace global (filtré pour affichage)
noms_interessants = {k: v for k, v in globals().items() if not k.startswith("_")}
# (On affiche juste les clés pertinentes)

Python maintient plusieurs namespaces simultanément, dans une hiérarchie.

44.2 La règle LEGB

Quand Python rencontre un nom (comme x), il le cherche dans cet ordre :

Niveau Nom
L Local Variables locales de la fonction courante
E Enclosing Variables des fonctions englobantes
G Global Variables du module
B Built-in Fonctions/noms prédéfinis de Python (print, len, int…)

Dès qu’il trouve, il s’arrête. Si rien ne correspond, c’est NameError.

Illustration complète

x = "global"        # G : global

def externe():
    x = "enclosing"     # E : englobante

    def interne():
        x = "local"     # L : local
        print(f"Dans interne : {x}")    # L trouvé d'abord

    interne()
    print(f"Dans externe : {x}")        # E

externe()
print(f"Global : {x}")                   # G
Dans interne : local
Dans externe : enclosing
Global : global

Chaque fonction a son propre scope local qui cache les noms identiques des niveaux supérieurs.

Accéder aux niveaux supérieurs

Si un nom n’existe pas en local, Python remonte la chaîne.

x = "global"

def fonction():
    # Pas de x local → remonte au global
    print(x)

fonction()
global

Les built-ins

Les fonctions comme print, len, max, int… sont dans le niveau B (built-in).

# print est trouvé au niveau B
print("Hello")

# On peut le redéfinir (mauvaise idée !)
print = "pas une fonction"

# Restaurons
del print
print("Normal")
Hello
Normal
⚠️ Ne masquez jamais les built-ins

Nommer une variable list, dict, str, max, sum, id, typeécrase la version built-in dans votre scope :

list = [1, 2, 3]        # ❌ list n'est plus la classe
# Maintenant list([1, 2]) planterait !

# Nettoyer
del list
print(list([1, 2, 3]))
[1, 2, 3]

Convention : si vous voulez vraiment un nom comme ça, ajoutez un underscore : list_, type_, sum_.

44.3 Lire vs écrire une variable

Dans une fonction, Python distingue fortement lecture et écriture.

Lire : remonte la chaîne LEGB

x = 10      # global

def f():
    print(x)     # lit x depuis le global : OK

f()
10

Écrire : crée une variable locale

x = 10      # global

def f():
    x = 20       # crée une variable LOCALE x (n'affecte pas le global)
    print(f"Dans f : x = {x}")

f()
print(f"Global : x = {x}")      # inchangé !
Dans f : x = 20
Global : x = 10

C’est le point le plus subtil. Dès que Python voit x = ... dans une fonction, il considère x comme local à cette fonction. Pas de question.

Le piège du mixte

x = 10

def f():
    print(x)     # tentative de lecture AVANT affectation
    x = 20

f()
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[9], line 7
      3 def f():
      4     print(x)     # tentative de lecture AVANT affectation
      5     x = 20
      6 
----> 7 f()

Cell In[9], line 4, in f()
      3 def f():
----> 4     print(x)     # tentative de lecture AVANT affectation
      5     x = 20

UnboundLocalError: cannot access local variable 'x' where it is not associated with a value

UnboundLocalError : puisque x = 20 apparaît dans f, Python a classé x comme local pour toute la fonction. Le print(x) initial cherche donc x local… qui n’a pas encore été créé.

44.4 Modifier une variable externe : global et nonlocal

global : accéder au niveau module

Pour modifier une variable globale depuis une fonction :

compteur = 0

def incrementer():
    global compteur      # dit à Python : pas de local, utilise le global
    compteur += 1

incrementer()
incrementer()
incrementer()
print(f"Compteur : {compteur}")
Compteur : 3

Sans global, Python aurait créé une nouvelle variable locale compteur.

nonlocal : accéder au niveau enclosing

Pour modifier une variable d’une fonction englobante (pas globale) :

def compteur():
    n = 0

    def increment():
        nonlocal n          # accès à n de compteur (pas global !)
        n += 1
        return n

    return increment


c = compteur()
print(c())       # 1
print(c())       # 2
print(c())       # 3
1
2
3

nonlocal ne peut viser qu’une variable de fonction englobante, pas le global.

Quand utiliser global / nonlocal ?

Rarement ! En pratique :

  • global : code « pas très propre ». Préférez renvoyer une valeur ou passer un objet mutable.
  • nonlocal : utile dans les closures et les décorateurs (vu chapitre 3).

Le besoin d’global est souvent le signe qu’une classe serait plus appropriée.

44.5 Scope des compréhensions

⚠️ Piège TOSA : les compréhensions ont leur propre scope

Depuis Python 3, les compréhensions (liste, dict, set, générateur) créent leur propre scope local. Les variables d’itération ne fuient pas à l’extérieur.

x = 100

resultat = [x for x in range(5)]    # x ici est local à la compréhension

print(resultat)
print(f"x globalement : {x}")        # inchangé !
[0, 1, 2, 3, 4]
x globalement : 100

En Python 2 (ancien), x aurait été modifié. En Python 3, c’est isolé — c’est un comportement plus sûr mais piégeux si on ne le sait pas.

Scope d’une for classique (par contraste)

Attention : une boucle for « normale » fuit ses variables !

for i in range(5):
    pass

# i existe encore après la boucle
print(f"i = {i}")
i = 4

C’est une source classique de bugs : si vous comptiez sur i pour autre chose, la boucle l’a écrasé.

44.6 Scope d’une fonction imbriquée (closure)

Déjà vu au chapitre 3 : une fonction interne capture le scope englobant.

def multiplicateur(facteur):
    def multiplier(x):
        return x * facteur       # facteur est dans le scope E
    return multiplier


doubler = multiplicateur(2)
tripler = multiplicateur(3)

# Les deux fonctions ont capturé leur propre facteur
print(doubler(10), tripler(10))
20 30

Fait intéressant : chaque closure garde sa copie des variables capturées. doubler et tripler ne se partagent pas leur facteur.

Inspecter une closure

L’attribut __closure__ d’une fonction révèle les variables capturées.

def externe():
    a = 10
    b = 20
    def interne():
        return a + b
    return interne


f = externe()

# Noms capturés
print(f.__code__.co_freevars)

# Valeurs capturées
for cell in f.__closure__:
    print(f"  → {cell.cell_contents}")
('a', 'b')
  → 10
  → 20

44.7 Le piège des closures dans une boucle

On l’a survolé au chapitre 3 (Partie 3). Approfondissons pourquoi ça se comporte ainsi.

# Créer 3 fonctions qui multiplient par 1, 2, 3
fonctions = []
for i in range(1, 4):
    fonctions.append(lambda x: x * i)

# Surprise : toutes multiplient par 3 !
for f in fonctions:
    print(f(10))
30
30
30

Pourquoi ? Les 3 lambdas capturent la même variable i (pas sa valeur, mais la référence). Quand on appelle les fonctions, la boucle est terminée et i vaut 3.

Solutions

Solution 1 : figer la valeur avec un paramètre par défaut.

fonctions = [lambda x, i=i: x * i for i in range(1, 4)]

for f in fonctions:
    print(f(10))
10
20
30

i=i évalue i au moment de la définition de la lambda, et stocke cette valeur comme défaut.

Solution 2 : une fonction créatrice explicite.

def fabrique(n):
    return lambda x: x * n         # closure sur n local, différent à chaque appel

fonctions = [fabrique(i) for i in range(1, 4)]

for f in fonctions:
    print(f(10))
10
20
30

Chaque appel à fabrique(i) crée un nouveau scope local avec sa propre variable n.

44.8 Le scope des classes

Les classes ont un scope propre, mais il se comporte différemment des fonctions.

x = "global"

class MaClasse:
    x = "de classe"

    def methode(self):
        print(x)           # ⚠️ cherche x dans le global, PAS dans la classe


obj = MaClasse()
obj.methode()
global

Le scope d’une classe n’est pas accessible directement depuis ses méthodes. Pour accéder aux attributs de classe, il faut self.x ou MaClasse.x.

class MaClasse:
    x = "de classe"

    def methode(self):
        print(self.x)              # OK
        print(MaClasse.x)          # OK aussi


obj = MaClasse()
obj.methode()
de classe
de classe
Pourquoi cette « bizarrerie » ?

Les classes sont des namespaces, pas des scopes imbriqués au sens LEGB. C’est un détail d’implémentation hérité des premières versions de Python. Retenez simplement : utilisez self. ou le nom de la classe pour accéder aux attributs depuis les méthodes.

44.9 Inspecter les scopes

Python expose locals() et globals() qui renvoient les namespaces actuels sous forme de dict.

locals()

def f():
    a = 1
    b = 2
    noms = list(locals().keys())
    print(f"Variables locales : {noms}")

f()
Variables locales : ['a', 'b']

globals()

a_global = 42
b_global = "hello"

# On filtre pour ne garder que nos variables (pas les builtins Python)
noms_globaux = [k for k in globals() if not k.startswith("_") and k not in ("In", "Out", "get_ipython", "exit", "quit")]
print("Noms globaux (filtrés) :", [n for n in noms_globaux if n.startswith("a_") or n.startswith("b_")])
Noms globaux (filtrés) : ['a_global', 'b_global']
Pour le débogage, pas pour le code normal

locals() et globals() sont utiles pour l’introspection et le debug, mais ne devraient pas être utilisés couramment dans du code normal. Leur usage dénote souvent un design à revoir.

44.10 Récapitulatif visuel

  ┌─────────────────────────────────────────────┐
  │  B — Built-in (print, len, int, ...)         │
  │  ┌───────────────────────────────────────┐   │
  │  │  G — Global (module entier)            │   │
  │  │  ┌─────────────────────────────────┐   │   │
  │  │  │  E — Enclosing (fonction père)   │   │   │
  │  │  │  ┌───────────────────────────┐   │   │   │
  │  │  │  │  L — Local (fonction fils) │   │   │   │
  │  │  │  └───────────────────────────┘   │   │   │
  │  │  └─────────────────────────────────┘   │   │
  │  └───────────────────────────────────────┘   │
  └─────────────────────────────────────────────┘

Recherche de noms : de l’intérieur vers l’extérieur (L → E → G → B). Premier trouvé = utilisé.


🧩 Quiz 4.1 — Scopes et closures

Question 1

Dans quel ordre Python cherche-t-il un nom ?

  1. Global → Local → Built-in
  2. Built-in → Global → Enclosing → Local
  3. Local → Enclosing → Global → Built-in
  4. Aléatoire

c) Local → Enclosing → Global → Built-in — la règle LEGB. Premier trouvé, premier servi.

Question 2

Que s’affiche-t-il ?

x = 10

def f():
    x = 20
    print(x)

f()
print(x)
  1. 20 20
  2. 10 20
  3. 20 10
  4. Une erreur

c) 20 10x = 20 dans f crée une variable locale. Le x global reste à 10.

x = 10

def f():
    x = 20
    print(x)

f()
print(x)
20
10

Question 3

Que s’affiche-t-il ?

x = 10

def f():
    print(x)
    x = 20

f()
  1. 10
  2. 20
  3. UnboundLocalError
  4. NameError

c) UnboundLocalError — Python voit x = 20 plus bas et décide que x est local pour toute la fonction. Le print(x) essaie alors de lire x local avant qu’il soit affecté.

x = 10

def f():
    print(x)
    x = 20

f()
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[24], line 7
      3 def f():
      4     print(x)
      5     x = 20
      6 
----> 7 f()

Cell In[24], line 4, in f()
      3 def f():
----> 4     print(x)
      5     x = 20

UnboundLocalError: cannot access local variable 'x' where it is not associated with a value

Question 4

Quel mot-clé permet de modifier une variable globale depuis une fonction ?

  1. nonlocal
  2. global
  3. extern
  4. Aucun besoin

b) global — sans lui, une affectation crée une variable locale.

x = 0

def f():
    global x
    x = 42

f()
print(x)
42

Question 5

Que s’affiche-t-il ?

def f():
    i = 100
    resultat = [i for i in range(3)]
    print(i, resultat)

f()
  1. 2 [0, 1, 2]
  2. 100 [0, 1, 2]
  3. Erreur
  4. 0 [0, 1, 2]

b) 100 [0, 1, 2] — les compréhensions ont leur propre scope en Python 3. Le i de la compréhension n’écrase pas le i de la fonction.

def f():
    i = 100
    resultat = [i for i in range(3)]
    print(i, resultat)

f()
100 [0, 1, 2]

Question 6

Que s’affiche-t-il ?

def externe():
    x = 1
    def interne():
        nonlocal x
        x += 10
    interne()
    return x

print(externe())
  1. 1
  2. 10
  3. 11
  4. Une erreur

c) 11nonlocal x permet à interne de modifier le x de externe.

def externe():
    x = 1
    def interne():
        nonlocal x
        x += 10
    interne()
    return x

print(externe())
11

Question 7

Que s’affiche-t-il ?

fs = [lambda: i for i in range(3)]
print([f() for f in fs])
  1. [0, 1, 2]
  2. [2, 2, 2]
  3. [3, 3, 3]
  4. [0, 0, 0]

b) [2, 2, 2] — piège de la capture : toutes les lambdas partagent le même i, qui vaut 2 à la fin de la compréhension.

fs = [lambda: i for i in range(3)]
print([f() for f in fs])

# Correction : figer la valeur
fs_ok = [lambda i=i: i for i in range(3)]
print([f() for f in fs_ok])
[2, 2, 2]
[0, 1, 2]

Question 8

Depuis une méthode, comment accéder à un attribut de classe ?

  1. Directement par son nom
  2. Avec self.attribut ou NomClasse.attribut
  3. Avec global attribut
  4. Impossible

b) — le scope d’une classe n’est pas accessible directement depuis ses méthodes. Il faut self. ou le nom de la classe.


✏️ Exercice 4.1 — Créer un compteur avec closure

Écrivez une fonction compteur(debut=0, pas=1) qui renvoie une fonction interne qui incrémente et renvoie un compteur à chaque appel.

def compteur(debut=0, pas=1):
    ...

c = compteur()
print(c(), c(), c())      # 1 2 3

c2 = compteur(10, 5)
print(c2(), c2(), c2())   # 15 20 25
def compteur(debut=0, pas=1):
    n = debut
    def incrementer():
        nonlocal n
        n += pas
        return n
    return incrementer


c1 = compteur()
print(c1(), c1(), c1())

c2 = compteur(10, 5)
print(c2(), c2(), c2())

# Les deux compteurs sont indépendants
print(c1())           # continue de c1
1 2 3
15 20 25
4

✏️ Exercice 4.2 — Prédire la sortie

Sans exécuter, prédisez ce qui s’affiche pour chaque extrait. Puis vérifiez.

# Extrait A
x = 1
def f():
    x = 2
    def g():
        x = 3
    g()
    return x
print(f())

# Extrait B
x = 1
def f():
    x = 2
    def g():
        nonlocal x
        x = 3
    g()
    return x
print(f())

# Extrait C
x = 1
def f():
    def g():
        global x
        x = 3
    g()
    return x
print(f(), x)

# Extrait D
liste = [1, 2, 3]
def f():
    liste.append(4)         # sans global, ça marche ?
f()
print(liste)

A2. Chaque fonction a son x local, indépendants.

x = 1
def f():
    x = 2
    def g():
        x = 3
    g()
    return x
print(f())
2

B3. nonlocal x permet à g de modifier le x de f.

x = 1
def f():
    x = 2
    def g():
        nonlocal x
        x = 3
    g()
    return x
print(f())
3

C1 3. global x fait que g modifie le x global. Mais f n’a pas de x local (il n’y est référencé nulle part), donc return x remonte… au global, qui vaut maintenant 3 — attendez, mais au moment du return x, il vient d’être mis à 3. Regardons :

x = 1
def f():
    def g():
        global x
        x = 3
    g()
    return x     # lit x global = 3
print(f(), x)
3 3

Les deux valeurs sont 3 — car g() a modifié le global.

D[1, 2, 3, 4]. Pas besoin de global pour modifier un objet mutable ; on ne réaffecte pas liste, on appelle une méthode dessus.

liste = [1, 2, 3]
def f():
    liste.append(4)
f()
print(liste)
[1, 2, 3, 4]

Leçon essentielle

  • liste = ... dans une fonction : réaffectation → crée une variable locale sauf si global.
  • liste.append(...) : mutation d’objet → accède à l’objet global (ou englobant) sans souci.

Cette distinction explique pourquoi on peut modifier un dict/liste global depuis une fonction sans global.


✏️ Exercice 4.3 — Corriger le piège de la capture

Ce code devrait générer des fonctions qui ajoutent respectivement 1, 2 et 3 à leur argument. Il est buggué. Corrigez-le.

# Version buggée
additionneurs = [lambda x: x + n for n in range(1, 4)]

for f in additionneurs:
    print(f(10))      # attendu : 11, 12, 13

# Correction ?

Version buggée (comportement actuel) :

additionneurs = [lambda x: x + n for n in range(1, 4)]

for f in additionneurs:
    print(f(10))       # tous renvoient 13 (n=3 à la fin)
13
13
13

Correction 1 — paramètre par défaut :

additionneurs = [lambda x, n=n: x + n for n in range(1, 4)]

for f in additionneurs:
    print(f(10))
11
12
13

Correction 2 — fonction factrice :

def fabrique_additionneur(n):
    return lambda x: x + n

additionneurs = [fabrique_additionneur(n) for n in range(1, 4)]

for f in additionneurs:
    print(f(10))
11
12
13

Les deux versions marchent. La première est plus concise, la seconde plus lisible si la logique est complexe.


✏️ Exercice 4.4 — Factoriser avec une closure

Créez creer_validateur(type_attendu) qui renvoie une fonction qui vérifie si son argument est du type attendu. Utilisez une closure.

def creer_validateur(type_attendu):
    ...

est_entier = creer_validateur(int)
est_chaine = creer_validateur(str)

print(est_entier(42))       # True
print(est_entier("42"))     # False
print(est_chaine("hello"))  # True
def creer_validateur(type_attendu):
    def valider(valeur):
        return isinstance(valeur, type_attendu)
    return valider


est_entier = creer_validateur(int)
est_chaine = creer_validateur(str)
est_liste = creer_validateur(list)

# Tests
for v in [42, "42", [1, 2], 3.14]:
    print(f"{v!r:<10} entier={est_entier(v):<6} chaine={est_chaine(v):<6} liste={est_liste(v)}")
42         entier=1      chaine=0      liste=False
'42'       entier=0      chaine=1      liste=False
[1, 2]     entier=0      chaine=0      liste=True
3.14       entier=0      chaine=0      liste=False

Bonus : validateur combiné

def creer_validateur_plusieurs(*types_acceptes):
    """Accepte plusieurs types."""
    def valider(valeur):
        return isinstance(valeur, types_acceptes)
    return valider


est_nombre = creer_validateur_plusieurs(int, float)

print(est_nombre(42))
print(est_nombre(3.14))
print(est_nombre("42"))
True
True
False

Les closures permettent de paramétrer des fonctions sans passer par des classes — c’est une forme légère de POO parfois très élégante.


À retenir

Points clés du chapitre
  1. LEGB : Python cherche les noms dans l’ordre Local → Enclosing → Global → Built-in.
  2. Lire un nom externe : automatique. Écrire : crée une variable locale sauf global/nonlocal.
  3. global x : modifier une variable du module. Rare en bon code.
  4. nonlocal x : modifier une variable du scope englobant (closures). Plus courant.
  5. Piège UnboundLocalError : une affectation plus loin rend la variable locale pour toute la fonction.
  6. Compréhensions : leur propre scope en Python 3. Les variables d’itération ne fuient pas.
  7. Boucles for classiques : les variables fuient (à l’inverse des compréhensions).
  8. Piège de capture dans une boucle : lambda x=x: ... pour figer la valeur.
  9. Scope de classe non accessible directement depuis les méthodes → utiliser self. ou le nom de classe.
  10. Ne jamais masquer les built-ins (list, max, id, …).

← Chapitre précédent : DécorateursChapitre suivant : Modules sys, os, pathlib →