Solutions — série 03-4 (Mutabilité et références partagées ⚠️)
Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Ici plus qu'ailleurs : une prédiction vérifiée sans avoir été écrite d'abord ne t'apprend RIEN. Compare le raisonnement — le schéma à flèches — pas seulement la sortie.
Exercice facile a — l'alias de base
Raisonnement — Schéma après chaque ligne :
a = [1, 2, 3] a ──► [1, 2, 3]
b = a a ──┐
├──► [1, 2, 3] (copie du POST-IT, pas de la liste)
b ──┘
b.append(4) a ──┐
├──► [1, 2, 3, 4] (mutation : LA liste change)
b ──┘
Solution — La sortie est :
[1, 2, 3, 4]
[1, 2, 3, 4]
True
Pourquoi ça marche — Une seule liste, deux noms. append mute l'objet ; les deux noms « voient » la modification parce qu'ils regardent le même endroit. a is b est True : c'est LA preuve du partage.
Erreur classique sur cet exercice — Prédire [1, 2, 3] pour a — le modèle mental « boîte » (chaque variable contiendrait SA liste). C'est LE malentendu que cette leçon corrige : l'assignation ne copie jamais.
Variante plus difficile — Ajoute a.append(5) après le tout et prédis b. (Réponse : [1, 2, 3, 4, 5] — le partage marche dans les deux sens, il n'y a ni « original » ni « copie », juste deux noms égaux en droits.)
Exercice facile b — y = y + [3] réassigne
Raisonnement — La droite d'abord (⏪ 01-1) : y + [3] construit une nouvelle liste [1, 2, 3], puis le post-it y est déplacé dessus. x n'a pas bougé.
y = x x ──┐
├──► [1, 2]
y ──┘
y = y + [3] x ──► [1, 2] (le post-it y a déménagé)
y ──► [1, 2, 3] (objet NEUF)
Solution — La sortie est :
[1, 2]
[1, 2, 3]
Pourquoi ça marche — + sur des listes crée toujours un nouvel objet (comme + sur des nombres) ; le = avec le nom nu à gauche est une réassignation — le critère visuel de la leçon. Aucune mutation nulle part : x est resté sur la liste d'origine, intacte.
Erreur classique sur cet exercice — Prédire [1, 2, 3] pour x par généralisation hâtive de l'exercice a : « les listes, ça se partage ». Le partage n'agit que via une mutation ; ici il n'y en a pas. Compare ligne à ligne avec y.append(3) — c'est tout l'écart entre les deux exercices.
Variante plus difficile — Remplace y = y + [3] par y += [3] et re-prédis. (Réponse : [1, 2, 3] deux fois ! Sur les listes, += mute au lieu de réassigner — c'est l'exercice 9-c.)
Exercice facile c — la copie par slice tient bon
Raisonnement — nums[:] fabrique une nouvelle liste au même contenu ; copy1.sort() mute cette copie, l'originale ne bouge pas. À la fin, les contenus divergent… puis re-convergent par accident ? Non : [3, 1, 2] trié donne [1, 2, 3] ≠ [3, 1, 2].
Solution — La sortie est :
[3, 1, 2]
[1, 2, 3]
False False
Pourquoi ça marche — Deux objets indépendants depuis la slice. == compare les contenus : [3, 1, 2] == [1, 2, 3] → False (l'ordre fait partie du contenu d'une liste). is : deux objets distincts, False évidemment.
Erreur classique sur cet exercice — Prédire True pour le == (« ce sont les mêmes nombres ») : l'égalité de listes exige les mêmes éléments dans le même ordre. Et prédire que nums finit trié : la slice a copié AVANT le tri, la mutation est restée dans la copie.
Variante plus difficile — Même programme avec copy1 = nums (sans [:]). Prédis les trois lignes. (Réponse : [1, 2, 3], [1, 2, 3], True True — l'alias rend le tri visible des deux côtés, et is confirme.)
Exercice facile d — le dessin à cinq lignes
Raisonnement / Solution — État après chaque ligne :
a = [10] a ──► [10]
b = a a ──┐
├──► [10]
b ──┘
c = b a ──┐
b ──┼──► [10] trois post-its, UNE liste
c ──┘
b.append(20) a ──┐
b ──┼──► [10, 20] mutation : visible par les trois
c ──┘
c = [30] a ──┐
├──► [10, 20]
b ──┘
c ──► [30] c a déménagé, a et b restent
À la fin : deux listes existent ([10, 20] et [30]), et a vaut [10, 20].
Pourquoi ça marche — c = b copie le post-it de b (qui pointait déjà sur la liste de a) : l'aliasing est transitif. La réassignation de c ne « rend » rien aux autres : elle déplace UN post-it.
Erreur classique sur cet exercice — Répondre « trois listes » (une par variable) — encore le modèle boîte. Compte les crochets de création dans le code : [10] et [30], deux créations, donc deux objets (les assignations n'en créent jamais).
Variante plus difficile — Ajoute b = a[:] puis b.append(40) à la suite, et redessine. Combien de listes maintenant, et que vaut chaque nom ? (Réponse : trois listes ; a → [10, 20], b → [10, 20, 40], c → [30].)
Exercice moyen a — la copie superficielle au grand jour
Raisonnement — grid.copy() copie la liste extérieure : shallow est un objet neuf (le append ne touchera que lui), mais ses casiers pointent vers les mêmes sous-listes que ceux de grid (le [0][0] = 99 traversera).
grid ────► [ ●───────── , ●───────── ]
│ │
▼ ▼
[1, 2] [3, 4]
▲ ▲
│ │
shallow ──► [ ●───────── , ●───────── , ●──► [5, 6] ] ← append : chez elle
└── shallow[0][0] = 99 passe par ICI et mute [1, 2],
qui appartient... aux deux.
Solution — La sortie est :
[[99, 2], [3, 4]]
[[99, 2], [3, 4], [5, 6]]
False
True
Commentaire attendu : append est une mutation de la liste extérieure de shallow — un objet distinct de grid, donc rien ne traverse. shallow[0][0] = 99 commence par LIRE shallow[0]… qui est la sous-liste partagée : la mutation a lieu dans un objet que grid référence aussi.
Pourquoi ça marche — Une copie superficielle ne copie qu'UN niveau. Les deux derniers print le prouvent noir sur blanc : grid is shallow → False (extérieures distinctes), grid[0] is shallow[0] → True (sous-listes partagées). Ces deux tests sont ton kit de diagnostic — retiens- les ensemble.
Erreur classique sur cet exercice — Prédire que le [5, 6] apparaît aussi dans grid (« puisque c'est partagé ») : le partage concerne les sous-listes, PAS la liste extérieure. Confondre les deux niveaux est l'erreur normale ici — le schéma à deux étages la dissout.
Variante plus difficile — Ajoute grid[1].append(99) et prédis shallow. (Réponse : [[99, 2], [3, 4, 99], [5, 6]] — le partage fonctionne aussi depuis grid vers shallow : il n'y a pas de sens privilégié.)
Exercice moyen b — la sauvegarde fantôme du tournoi
Raisonnement — La ligne fautive est la deuxième : backup = players ne sauvegarde RIEN — elle colle un deuxième post-it sur la même liste. Les remove/append mutent donc « la sauvegarde » en même temps que la liste active, et la « restauration » players = backup remet un post-it… sur la liste déjà modifiée.
backup = players players ──┐
├──► ["Ada", "Linus", "Guido"]
backup ───┘
remove + append players ──┐
├──► ["Ada", "Guido", "Grace"] ← les DEUX
backup ───┘ noms voient le désastre
players = backup (ne change RIEN : les deux post-its étaient déjà
sur la même liste)
Solution
# ex-03-4-f.py — version corrigée : UNE ligne change
players = ["Ada", "Linus", "Guido"]
backup = players.copy() # une VRAIE copie : deux listes désormais
players.remove("Linus")
players.append("Grace")
players = backup # restauration : players re-pointe sur l'intacte
print(players) # ['Ada', 'Linus', 'Guido'] ✓
Pourquoi ça marche — La copie fige un instantané dans un objet séparé : les mutations suivantes ne concernent que la liste active. La restauration par réassignation remet le post-it players sur l'instantané. (backup = players[:] ou list(players) : équivalents.)
Erreur classique sur cet exercice — Chercher le bug dans remove ou dans la ligne de restauration : elles sont correctes toutes les deux. Les bugs de référence naissent à l'assignation et explosent ailleurs — d'où le réflexe : quand une « sauvegarde » ne sauvegarde pas, remonte à sa création et demande-toi « copie ou alias ? » (print(backup is players) répond en une ligne).
Variante plus difficile — Le tournoi stocke maintenant des fiches : players = [["Ada", 3], ["Linus", 1]] (nom, victoires). Montre que backup = players.copy() ne protège PLUS si on fait players[0][1] += 1… et corrige (copie profonde — boucle ou deepcopy, c'est l'exercice difficile a).
Exercice moyen c — le duel += contre +
Raisonnement — Ligne par ligne :
a = [1] a ──► [1]
b = a a, b ──► [1] (alias)
a += [2] a, b ──► [1, 2] MUTATION (extend caché)
a = a + [3] a ──► [1, 2, 3] (objet NEUF) RÉASSIGNATION
b ──► [1, 2] (b garde l'ancien)
Solution — La sortie est :
[1, 2, 3]
[1, 2]
False
Règle générale (le commentaire demandé) : sur une liste, a += x mute l'objet existant (tous les alias le voient) ; a = a + x crée un objet neuf et déplace seulement le post-it a (les alias gardent l'ancien).
Pourquoi ça marche — Python traduit a += [2] en a.extend([2]) quand a est une liste : c'est une mutation, b la voit. a + [3], lui, construit une liste neuve avant l'assignation — le lien avec b est rompu à cet instant précis, d'où le False final.
Erreur classique sur cet exercice — Traiter += comme un pur raccourci de a = a + ... (c'est vrai pour les nombres et les chaînes — immuables — mais faux pour les listes). Si tu t'es fait avoir : tu es en excellente compagnie, cette différence piège aussi des développeurs confirmés.
Variante plus difficile — Même duel avec des chaînes : s = "ab", t = s, s += "c" — prédis t. (Réponse : "ab" — une chaîne est immuable, son += est FORCÉMENT une réassignation. La règle +=-mute ne vaut que pour les mutables.)
Exercice difficile a — la copie profonde à la main
Raisonnement — Une copie superficielle copie l'extérieur ; il faut EN PLUS copier chaque sous-liste. Motif « construire une liste en boucle » (⏪ 03-2) : pour chaque row de grid, on append… non pas row (ce serait re-partager !), mais row.copy() — une ligne neuve à chaque tour.
Solution
# ex-03-4-h.py
grid = [[1, 2], [3, 4], [5, 6]]
deep = []
for row in grid:
deep.append(row.copy()) # une copie NEUVE de chaque ligne
# les trois preuves demandées :
print(deep is grid) # False : extérieures distinctes
print(deep[0] is grid[0]) # False : sous-listes distinctes AUSSI
deep[0][0] = 99
print(grid) # [[1, 2], [3, 4], [5, 6]] : intacte
print(deep) # [[99, 2], [3, 4], [5, 6]]
Pourquoi ça marche — Deux niveaux, deux copies : la liste extérieure est neuve (construite par la boucle), et chaque casier reçoit un objet neuf (row.copy()). Plus aucun chemin ne mène d'un nom à l'autre — les trois tests le prouvent. C'est exactement ce que copy.deepcopy(grid) fait pour toi (récursivement, à toutes les profondeurs).
Erreur classique sur cet exercice — deep.append(row) : le code a l'air de copier (il y a une boucle !) mais reconstruit une simple copie superficielle — chaque casier pointe sur la ligne originale. Le test 2 (deep[0] is grid[0] → True) le démasque instantanément : d'où l'importance de PROUVER ses copies plutôt que d'y croire.
Variante plus difficile — Que fait ta version sur grid = [[1, [2]], [3]] (trois niveaux) ? (Réponse : row.copy() est superficielle → le [2] du fond reste partagé. Ta copie « profonde » n'a qu'un étage de profondeur — c'est pourquoi deepcopy est récursif. Vérifie avec deep[0][1] is grid[0][1].)
Exercice difficile b — prédiction expert : b = [a, a]
Raisonnement — Ligne par ligne, en comptant les objets :
a = [1, 2] a ──► L1 = [1, 2] (1 liste)
b = [a, a] b ──► L2 = [ ● , ● ] (2 listes)
│ │
└─┬─┘
▼
L1 = [1, 2] les DEUX casiers pointent sur L1
a.append(3) L1 = [1, 2, 3] → b vaut [[1,2,3], [1,2,3]]
b[0].append(4) b[0] EST L1 → L1 = [1, 2, 3, 4] → a le voit
b[1] = [9] le CASIER 1 de L2 déménage sur L3 = [9] (3 listes)
L1 n'est plus référencée que par a et b[0]
Solution — Les trois print affichent :
[[1, 2, 3], [1, 2, 3]]
[1, 2, 3, 4]
[1, 2, 3, 4] [[1, 2, 3, 4], [9]]
Pourquoi ça marche — b ne contient pas « deux copies de a » : il contient deux références vers la même liste. Toute mutation de a se voit en double dans b (premier print). b[0].append(4) mute L1 via le casier — a le voit (deuxième print). b[1] = [9] est une réassignation de casier : elle remplace une référence sans toucher L1 (troisième print : a garde son 4).
Erreur classique sur cet exercice — Au premier print, prédire [[1, 2], [1, 2]] en imaginant que b = [a, a] a photographié a au moment de sa création. Une liste ne stocke jamais de photos : uniquement des références, résolues au moment où on LIT. Deuxième piège : croire que b[1] = [9] affecte aussi b[0] (« ils étaient pareils ») — chaque casier est un post-it indépendant.
Variante plus difficile — Après tout ça, ajoute b[0].clear() et prédis a et b. (Réponse : a devient [] et b vaut [[], [9]] — clear() vide L1 sur place, dernier rappel que casier et objet sont deux choses.)
Mini-projet — « les personnages clonés »
Raisonnement — Trois actes = les trois étages de la leçon : (1) l'alias nu (=), (2) la copie superficielle qui suffit (liste plate), (3) la copie superficielle qui trahit (liste imbriquée). Le scénario force à PRÉDIRE avant chaque print — c'est un banc d'essai de la leçon entière.
Solution
# character_clones.py
# --- Acte 1 : le bug -------------------------------------------------------
print("=== Acte 1 : le bug ===")
template = ["epee", "bouclier", "potion"]
player1 = template # PAS des copies :
player2 = template # trois post-its sur le MEME inventaire
player1.remove("potion") # player1 boit sa potion...
player2.append("arc") # ...player2 ramasse un arc
print(f"template : {template}")
print(f"player1 : {player1}")
print(f"player2 : {player2}")
print(f"player1 is player2 : {player1 is player2}")
# regle : "=" ne cree pas un personnage, il donne un 2e nom au meme.
# --- Acte 2 : le correctif -------------------------------------------------
print("\n=== Acte 2 : le correctif ===")
template = ["epee", "bouclier", "potion"]
player1 = template.copy() # chaque joueur recoit SA copie
player2 = template.copy()
player1.remove("potion")
player2.append("arc")
print(f"template : {template}")
print(f"player1 : {player1}")
print(f"player2 : {player2}")
# regle : une copie superficielle suffit pour un inventaire PLAT.
# --- Acte 3 : la rechute ---------------------------------------------------
print("\n=== Acte 3 : la rechute ===")
template = ["epee", ["potion", "potion"]] # une sacoche imbriquee
player1 = template.copy()
player2 = template.copy()
player1[1].remove("potion") # player1 boit DANS la sacoche
print(f"player2 : {player2}") # sa sacoche s'est videe aussi !
print(f"sacoche partagee ? {player1[1] is player2[1]}") # True
# le correctif : copier chaque niveau
player1 = []
for item in template:
if type(item) == list: # la sacoche : copie neuve
player1.append(item.copy())
else:
player1.append(item)
# (ou, plus simple : import copy ; player1 = copy.deepcopy(template))
player1[1].append("potion")
print(f"template : {template}") # la sacoche du modele est sauve
# regle : .copy() ne copie qu'UN niveau ; imbrique => copie profonde.
Sortie :
=== Acte 1 : le bug ===
template : ['epee', 'bouclier', 'arc']
player1 : ['epee', 'bouclier', 'arc']
player2 : ['epee', 'bouclier', 'arc']
player1 is player2 : True
=== Acte 2 : le correctif ===
template : ['epee', 'bouclier', 'potion']
player1 : ['epee', 'bouclier']
player2 : ['epee', 'bouclier', 'potion', 'arc']
=== Acte 3 : la rechute ===
player2 : ['epee', ['potion']]
sacoche partagee ? True
template : ['epee', ['potion']]
(Note l'acte 3 : même le template a perdu une potion — la sacoche était partagée à TROIS. Et après le correctif, la potion ajoutée par player1 ne réapparaît ni chez template ni chez player2.)
Pourquoi ça marche — Acte 1 : une seule liste, trois noms — chaque mutation est « vue » par tout le monde, is le prouve. Acte 2 : deux copy() = trois objets indépendants, chacun vit sa vie. Acte 3 : les copies extérieures sont indépendantes, mais leurs casiers 1 pointent tous sur LA sacoche — player1[1] is player2[1] → True ; la copie manuelle (ou deepcopy) recrée la sacoche pour chaque joueur.
Erreur classique sur cet exercice — À l'acte 3, « corriger » en recopiant plus fort : player1 = template.copy().copy() — deux copies superficielles restent superficielles (tu copies deux fois l'extérieur, la sacoche n'est jamais copiée). La profondeur ne s'obtient pas en répétant, il faut descendre d'un niveau.
Variante plus difficile — (1) Rejoue l'acte 1 avec un dict de personnage template = {"pv": 100, "sac": ["potion"]} : mêmes lois (le dict est mutable, .copy() existe, la valeur "sac" reste partagée). (2) Passe au projects/mini-projects/06-gestionnaire-de-taches.md (v1) : tu y modifieras des dicts DANS une liste — exactement la mutation « à travers un casier » de cette série, en contexte réel.