Leçon 03-4 — Mutabilité et références partagées ⚠️
1. Objectif
À la fin de cette leçon tu sauras : prédire, sans exécuter, ce qu'affiche un programme où plusieurs noms pointent vers la même liste ; distinguer mutation et réassignation ; vérifier avec is et id() si deux noms désignent le même objet ; copier une liste correctement — et dire dans quels cas la copie superficielle ne suffit pas.
2. Pourquoi c'est important
C'est LA leçon la plus importante du niveau, et l'un des trois concepts les plus mal compris de tout Python (avec le scope et self). Le bug qu'elle prévient — « j'ai modifié b et a a changé aussi ?! » — est silencieux : aucun traceback, juste des données corrompues découvertes trois écrans plus loin. Tant que ce modèle mental n'est pas en place, tu subiras des bugs incompréhensibles ; une fois qu'il l'est, toute la suite (arguments de fonctions au niveau 4, attributs d'objets au niveau 6) devient limpide.
Avertissement honnête, promis par le README du niveau : prévois le double de temps — une bonne semaine, tous les exercices, et un retour à J+7. Ça résiste pour tout le monde. Ce n'est pas toi, c'est la notion.
3. Explication simple
Depuis la leçon 01-1, tu sais qu'une variable est un post-it collé sur une valeur, pas une boîte qui la contient. Cette image devient maintenant cruciale.
a = [1, 2, 3]
b = a
b = a ne copie pas la liste. Ça colle un deuxième post-it sur la même liste :
a ──┐
├──► [1, 2, 3] UNE seule liste, DEUX noms
b ──┘
Du coup, modifier la liste en passant par b… modifie la liste — la seule qui existe. a n'a pas « changé » : a regarde simplement le même objet.
b.append(4)
print(a) # [1, 2, 3, 4] ← "aussi" ? Non : c'est LA liste, la seule.
a ──┐
├──► [1, 2, 3, 4]
b ──┘
Voilà tout le secret de la leçon. Le reste — is, id(), les copies — n'est que l'outillage autour de cette image.
4. Explication approfondie
a) L'assignation ne copie jamais rien. b = a copie le post-it (la référence), jamais l'objet. C'est vrai pour TOUT en Python — nombres, chaînes, listes, dicts. Alors pourquoi le problème n'apparaît qu'avec les listes et les dicts ?
b) Mutation ≠ réassignation — LA distinction de la leçon. Il existe deux façons de « changer » quelque chose, et elles n'ont rien à voir :
b.append(4) # MUTATION : modifie L'OBJET (visible via tous les noms)
b = [9, 9] # RÉASSIGNATION : déplace LE POST-IT b (les autres noms
# ne bougent pas)
Après b.append(4) : Après b = [9, 9] :
a ──┐ a ──► [1, 2, 3, 4]
├──► [1, 2, 3, 4]
b ──┘ b ──► [9, 9] (nouvel objet)
Repère visuel : la réassignation a un = avec le nom nu à gauche (b = ...). La mutation passe par une méthode (append, sort, remove…) ou par un index (b[0] = 9 mute la liste : le post-it b n'a pas bougé, c'est le contenu du casier 0 qui a changé).
c) Pourquoi les nombres et chaînes semblaient immunisés. Ils sont immuables : aucun moyen de modifier l'objet 5 ou l'objet "abc". Sans mutation possible, le partage est indétectable — d'où l'intuition (correcte au niveau 01) que « modifier y ne touche jamais x » :
x = 5
y = x # deux post-its sur le même 5...
y = y + 1 # ...mais y + 1 CRÉE un nouvel objet 6 : réassignation
print(x, y) # 5 6 — x est intact, forcément
Avec une liste (mutable), le même schéma à deux post-its + une mutation rend le partage visible. Mutable = les deux mondes divergent ; immuable = aucun risque. (C'est exactement pourquoi les tuples de la 03-3 sont si tranquilles.)
d) is et id() : l'outillage de vérification. id(obj) renvoie l'identité de l'objet (en pratique son adresse mémoire) ; a is b demande « même objet ? » ; a == b demande « même contenu ? ». Deux listes au contenu identique mais créées séparément : == vrai, is faux. En cas de doute sur un partage : print(a is b) — réponse instantanée et sans ambiguïté.
e) La copie superficielle. Trois écritures équivalentes pour obtenir une nouvelle liste au même contenu : a.copy(), list(a), a[:] (⏪ 03-2). « Superficielle » (shallow) = elle copie la liste extérieure, mais les éléments eux-mêmes ne sont pas copiés : chaque casier de la copie pointe vers le même objet que le casier original. Avec des ints/strs (immuables) : aucune importance. Avec des sous-listes : piège majeur —
grid = [[1, 2], [3, 4]]
shallow = grid.copy()
grid ────► [ ●─────── , ●─────── ]
│ │
▼ ▼
[1, 2] [3, 4] les DEUX listes extérieures
▲ ▲ partagent les MÊMES sous-listes
│ │
shallow ──► [ ●─────── , ●─────── ]
shallow.append(...) ou shallow[0] = ... ne touchent que la copie (mutations de la liste extérieure, qui est bien distincte). Mais shallow[0][0] = 99 traverse : casier 0 → la sous-liste partagée → grid voit 99. Pour tout copier en profondeur : copy.deepcopy(grid) (module copy, à importer).
f) Le piège de la multiplication. [[0] * 3] * 2 ne crée PAS deux lignes indépendantes : il duplique la référence vers la même ligne, deux post-its de plus. grid[0][0] = 9 modifie « les deux lignes » — qui n'en sont qu'une. Construis les lignes dans une boucle (exemple 6).
Plan de travail réaliste : sections 3–5 un jour, section 6 et exercices faciles le lendemain, moyens/difficiles sur deux jours, mini-projet, puis refais les prédictions de la section 8 à J+7 sans regarder. Si tu prédis tout juste à J+7, c'est acquis.
5. Exemples commentés
Exemple 1 — le partage, vérifié avec is et id()
a = [1, 2, 3]
b = a
print(a is b) # True : même objet
print(id(a), id(b)) # deux fois le MÊME nombre, p. ex. :
# 2458060127168 2458060127168
# (tes valeurs différeront — c'est le MÊME qui compte)
b.append(4)
print(a) # [1, 2, 3, 4]
Exemple 2 — même contenu ≠ même objet
a = [1, 2, 3]
c = [1, 2, 3] # créée SÉPARÉMENT
print(a == c) # True : même contenu
print(a is c) # False : deux objets distincts
c.append(4)
print(a) # [1, 2, 3] — aucun partage, a est tranquille
Exemple 3 — mutation vs réassignation, côte à côte
a = [1, 2]
b = a
b.append(3) # MUTATION → visible via a
print(a, b) # [1, 2, 3] [1, 2, 3]
b = [9] # RÉASSIGNATION → b déménage, a reste
print(a, b) # [1, 2, 3] [9]
print(a is b) # False : le lien est rompu DEPUIS la réassignation
Relis deux fois : le append a traversé, le = a séparé. Toute la leçon tient dans ces six lignes.
Exemple 4 — la copie superficielle fait le travail (cas plat)
inventory = ["sword", "potion"]
backup = inventory.copy() # ou list(inventory), ou inventory[:]
print(backup is inventory) # False : deux listes distinctes
inventory.remove("potion")
print(inventory) # ['sword']
print(backup) # ['sword', 'potion'] — la sauvegarde tient
Liste plate (éléments immuables : str, int…) → la copie superficielle suffit toujours.
Exemple 5 — ⚠️ la copie superficielle trahie par l'imbrication
grid = [[1, 2], [3, 4]]
shallow = grid.copy()
print(shallow is grid) # False : la liste EXTÉRIEURE est bien copiée
print(shallow[0] is grid[0]) # True : mais les sous-listes sont PARTAGÉES
shallow[0][0] = 99 # mutation de la sous-liste partagée...
print(grid) # [[99, 2], [3, 4]] ← l'originale est touchée !
shallow[1] = [7, 8] # réassignation d'un CASIER de la copie...
print(grid) # [[99, 2], [3, 4]] ← ...ne traverse pas
La ligne 7 mute un objet partagé (elle traverse) ; la ligne 9 remplace un post-it dans la copie (elle ne traverse pas). C'est exactement la distinction b) appliquée aux casiers. La copie profonde règle tout :
import copy
grid = [[1, 2], [3, 4]]
deep = copy.deepcopy(grid) # copie la liste ET ses sous-listes
deep[0][0] = 99
print(grid) # [[1, 2], [3, 4]] — intacte
Exemple 6 — construire une grille : le piège * et la bonne façon
row = [0] * 3
grid = [row] * 2 # DEUX post-its sur la MÊME ligne !
grid[0][0] = 9
print(grid) # [[9, 0, 0], [9, 0, 0]] ← les "deux" lignes
grid = [] # la bonne façon : créer une ligne NEUVE
for _ in range(2): # à chaque tour de boucle
grid.append([0] * 3)
grid[0][0] = 9
print(grid) # [[9, 0, 0], [0, 0, 0]] ✓
([0] * 3 en revanche est sans danger : les 0 sont immuables.)
6. Erreurs fréquentes
1) « Copier » avec = — LE bug emblématique du niveau
scores = [12, 8, 15]
backup = scores # pas une copie : un alias !
scores.append(3)
print(backup) # [12, 8, 15, 3] — la "sauvegarde" a bougé aussi
Aucune erreur, données corrompues. Correction : backup = scores.copy(). Règle : = ne copie jamais une liste.
2) Croire .copy() invincible
grid = [[1, 2], [3, 4]]
backup = grid.copy()
grid[0][0] = 99
print(backup) # [[99, 2], [3, 4]] — la copie est "corrompue" aussi
Copie superficielle + contenu imbriqué = sous-listes partagées. copy.deepcopy() si la liste contient des listes (ou des dicts).
3) Confondre is et ==
a = [1, 2]
c = [1, 2]
if a is c: # False — tu voulais comparer les CONTENUS
print("egales")
== compare les contenus, is l'identité. Pour tester l'égalité de valeurs : toujours ==. Réserve is au diagnostic de partage (et, plus tard, à is None).
4) Croire que la réassignation « répare » le partage rétroactivement
a = [1, 2]
b = a
b.append(3) # trop tard : la liste partagée contient déjà le 3
b = [0] # b déménage MAINTENANT — mais a garde [1, 2, 3]
print(a) # [1, 2, 3]
La réassignation ne défait pas les mutations passées ; elle cesse juste le partage pour la suite.
5) La grille [row] * n — exemple 6 : n post-its sur une seule ligne. Symptôme immanquable : « je modifie UNE case et toute la colonne change ».
6) += n'est pas + sur les listes
p = [1, 2]
q = p
p += [3] # MUTATION (équivaut à p.extend([3])) → q la voit
print(q) # [1, 2, 3]
p = p + [4] # RÉASSIGNATION (nouvelle liste) → q ne bouge plus
print(p, q) # [1, 2, 3, 4] [1, 2, 3]
Sur les listes, p += x mute l'objet partagé alors que p = p + x en crée un neuf. Subtil, vicieux, et vérifiable en 2 secondes avec is.
7. Lire les messages d'erreur
Honnêteté d'abord : les bugs de cette leçon ne lèvent presque jamais d'exception. Une liste partagée par accident produit des résultats faux en silence — c'est précisément ce qui les rend dangereux. Ici, « lire les erreurs » signifie donc : savoir instrumenter le programme.
Le kit de diagnostic, dans l'ordre :
print(a is b) # 1. partagent-ils le même objet ? (True = alias)
print(id(a), id(b)) # 2. même question, en voyant les identités
print(a[0] is b[0]) # 3. et leurs PREMIERS éléments ? (copie superficielle)
Réflexe à installer : dès qu'une liste « change toute seule », ne relis pas ton code pendant vingt minutes — pose trois print de diagnostic, la réponse tombe immédiatement. Ensuite remonte à la dernière assignation du nom suspect : c'est là que l'alias est né (b = a, un [row] * n, ou une copie oubliée).
Un vrai traceback existe quand même dans le coin : oublier l'import de deepcopy —
Traceback (most recent call last):
File "C:\Users\jtron\Claude\learnpython\grid_game.py", line 2, in <module>
deep = copy.deepcopy(grid)
^^^^
NameError: name 'copy' is not defined. Did you forget to import 'copy'?
De bas en haut : NameError sur copy — et Python 3 suggère lui-même le remède : le module n'a pas été importé. Ajoute import copy en tête de fichier (⏪ tu as déjà croisé NameError en 01-1 ; les imports sont approfondis au niveau 5).
8. Exercices — faciles
Règles : un fichier par exercice dans
lessons/level-03-structures-donnees/exercices/mes-reponses/, nomméex-03-4-a.py, etc. Écris ta prédiction en commentaire AVANT d'exécuter — c'est la prédiction qui fait travailler le modèle mental, pas l'exécution.
a) Prédis les trois lignes affichées, puis vérifie :
a = [1, 2, 3]
b = a
b.append(4)
print(a)
print(b)
print(a is b)
b) Prédis les deux lignes, puis vérifie (relis l'exemple 3 si besoin) :
x = [1, 2]
y = x
y = y + [3]
print(x)
print(y)
c) Prédis les trois lignes, puis vérifie :
nums = [3, 1, 2]
copy1 = nums[:]
copy1.sort()
print(nums)
print(copy1)
print(nums == copy1, nums is copy1)
d) Dessine (sur papier, avec des flèches comme en section 3) l'état des noms et des objets après CHAQUE ligne :
a = [10]
b = a
c = b
b.append(20)
c = [30]
Combien de listes existent à la fin ? Que vaut a ?
9. Exercices — moyens
a) Prédis les quatre lignes, puis vérifie. Deux d'entre elles piègent la majorité des débutants :
grid = [[1, 2], [3, 4]]
shallow = grid.copy()
shallow.append([5, 6])
shallow[0][0] = 99
print(grid)
print(shallow)
print(grid is shallow)
print(grid[0] is shallow[0])
Explique en commentaire pourquoi le append ne traverse pas mais le [0][0] = 99 traverse.
b) Ce programme de gestion de tournoi est buggé : la « sauvegarde » d'avant-match est censée permettre d'annuler, mais l'annulation ne restaure rien. Trouve la ligne fautive SANS exécuter, explique le mécanisme du bug avec un schéma à flèches, puis corrige (une seule ligne change) :
players = ["Ada", "Linus", "Guido"]
backup = players
players.remove("Linus") # Linus est elimine...
players.append("Grace") # ...Grace entre en jeu
# annulation : on restaure la sauvegarde
players = backup
print(players) # attendu : ['Ada', 'Linus', 'Guido']
c) Prédis, puis vérifie — le duel += contre + :
a = [1]
b = a
a += [2]
a = a + [3]
print(a)
print(b)
print(a is b)
Écris en commentaire la règle générale qui explique les trois lignes.
10. Exercices — difficiles
a) Copie profonde à la main. Sans import copy : écris un programme qui copie grid = [[1, 2], [3, 4], [5, 6]] en profondeur, avec une boucle et .copy() sur chaque ligne. Prouve ensuite ton résultat en trois tests : deep is grid doit être False, deep[0] is grid[0] doit être False, et deep[0][0] = 99 ne doit pas toucher grid.
- Indice 1 : c'est le motif « construire une liste en boucle » (⏪ 03-2) — liste vide +
for+append. - Indice 2 : qu'est-ce qui doit être ajouté à chaque tour :
row, ou autre chose ? - Indice 3 :
deep.append(row.copy())— chaque ligne ajoutée est une copie NEUVE de la ligne courante.
b) Prédiction expert. Sur papier uniquement, ligne par ligne, avec un schéma à flèches par étape (réponds : qu'affichent les trois print ?) :
a = [1, 2]
b = [a, a] # une liste qui contient DEUX fois la même liste
a.append(3)
print(b)
b[0].append(4)
print(a)
b[1] = [9]
print(a, b)
- Indice 1 : combien d'objets liste existent après la ligne 2 ? (Réponse : deux —
aetb; les deux casiers debpointent versa.) - Indice 2 :
b[0].append(4)mute quelle liste, exactement ? - Indice 3 :
b[1] = [9]est une réassignation de casier — quel post-it bouge, et lesquels ne bougent pas ?
11. Mini-projet lié — « les personnages clonés »
Écris character_clones.py, un mini-jeu de rôle qui rejoue le bug le plus classique du game dev débutant — puis le corrige.
Acte 1 — le bug. Un modèle de personnage template = ["epee", "bouclier", "potion"] (son inventaire de départ). Crée deux joueurs par player1 = template et player2 = template. player1 boit sa potion (remove), player2 ramasse un arc (append). Affiche les trois inventaires : constate le désastre (tout le monde a tout subi), et affiche player1 is player2 pour le prouver.
Acte 2 — le correctif. Même scénario avec de vraies copies. Vérifie que chaque inventaire évolue indépendamment et que le template reste vierge.
Acte 3 — la rechute. Le personnage devient template = ["epee", ["potion", "potion"]] (une sacoche imbriquée). Montre qu'avec .copy() les deux joueurs partagent encore la sacoche (l'un boit, l'autre n'a plus rien), puis règle le cas (deepcopy ou copie manuelle).
Contraintes : des print racontent l'histoire à chaque étape ; chaque acte se termine par un commentaire d'une ligne qui énonce la règle illustrée. Durée cible : 30–45 min. ⏩ Ce bug exact — modifier un dict/une liste partagé·e DANS une structure — est au cœur du projects/mini-projects/06-gestionnaire-de-taches.md (v1, niveau 3) : tu es maintenant armé pour le faire proprement.
12. Correction / méthode de correction
Les solutions détaillées sont dans solutions/serie-03-4-solutions.md.
Méthode spéciale prédictions : (1) ne JAMAIS exécuter avant d'avoir écrit la prédiction — une prédiction vérifiée après coup ne muscle rien ; (2) pour chaque prédiction fausse, refais le schéma à flèches ligne par ligne jusqu'à ce que la sortie réelle devienne évidente ; (3) note dans le journal la phrase du type « je croyais que X copiait, en fait X aliasse » ; (4) à J+7, refais les sections 8 et 9 de tête — c'est le vrai test de sortie de cette leçon, prévu dès le README du niveau.
13. À retenir
b = acolle un deuxième post-it sur le même objet — jamais de copie.- Mutation (
append,sort,b[0] = ...) : modifie l'objet, visible via tous les noms. Réassignation (b = ...) : déplace un post-it, les autres noms ne bougent pas. - Les immuables (int, str, tuple) rendent le partage inoffensif ; les mutables (list, dict, set) le rendent dangereux.
==compare les contenus ;isdemande « même objet ? » ;id()le prouve.- Copie superficielle :
a.copy()=list(a)=a[:]— suffit pour les listes plates. - Liste imbriquée : la copie superficielle partage les sous-listes →
copy.deepcopy(). [x] * nduplique des références — grilles : construire en boucle.- Sur les listes,
p += xmute,p = p + xréassigne. - Ces bugs sont silencieux : diagnostic au
print(a is b).
14. Questions de révision
- Que crée exactement la ligne
b = aquandaest une liste ? Combien d'objets liste existent ensuite ? - Donne deux exemples de mutation et deux exemples de réassignation, et le critère visuel pour les distinguer.
- Pourquoi le partage de références est-il invisible avec des
intmais dévastateur avec des listes ? - Quelle est la différence entre
a == beta is b? Donne un cas où l'un estTrueet l'autreFalse. - Cite les trois façons de faire une copie superficielle. Dans quel cas précis ne suffisent-elles pas, et pourquoi ?
- Après
shallow = grid.copy(), pourquoishallow[0] = [9]ne modifie-t-il pasgrid, alors queshallow[0][0] = 9le modifie ? - Pourquoi
[[0] * 3] * 2est-il piégé alors que[0] * 3ne l'est pas ? - Une liste « change toute seule » dans ton programme : quels sont les trois
printde diagnostic, et que cherches-tu ensuite ?
15. Checklist de compréhension
- Je peux dessiner le schéma post-its/objet de
b = apuisb.append(4)et expliquer pourquoi « a change » est une illusion. - Je sais classer n'importe quelle ligne en mutation ou réassignation.
- Je peux prédire les exercices 8-a à 8-d sans exécuter — et je les ai re-prédits juste à J+7.
- Je sais quand
.copy()suffit et quand il fautdeepcopy, avec le schéma des sous-listes partagées à l'appui. - Je sais expliquer le bug de la grille
[row] * 2à quelqu'un d'autre. - J'ai fait le mini-projet et les trois actes affichent ce que j'avais prédit.
Si une case reste vide : ne passe PAS à la leçon 03-5. Refais les exercices correspondants demain — cette leçon-ci est le péage du niveau.
16. Commit conseillé
git add lessons/level-03-structures-donnees/exercices/mes-reponses/
git commit -m "exercises: complete lesson 03-4 mutability and references"
Puis, si c'est ta fin de session : entrée de journal + git push. Et note un rappel à J+7 : re-prédire les sections 8 et 9 de tête.