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éegrid 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.

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)

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

14. Questions de révision

  1. Que crée exactement la ligne b = a quand a est une liste ? Combien d'objets liste existent ensuite ?
  2. Donne deux exemples de mutation et deux exemples de réassignation, et le critère visuel pour les distinguer.
  3. Pourquoi le partage de références est-il invisible avec des int mais dévastateur avec des listes ?
  4. Quelle est la différence entre a == b et a is b ? Donne un cas où l'un est True et l'autre False.
  5. Cite les trois façons de faire une copie superficielle. Dans quel cas précis ne suffisent-elles pas, et pourquoi ?
  6. Après shallow = grid.copy(), pourquoi shallow[0] = [9] ne modifie-t-il pas grid, alors que shallow[0][0] = 9 le modifie ?
  7. Pourquoi [[0] * 3] * 2 est-il piégé alors que [0] * 3 ne l'est pas ?
  8. Une liste « change toute seule » dans ton programme : quels sont les trois print de diagnostic, et que cherches-tu ensuite ?

15. Checklist de compréhension

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.