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

Raisonnementnums[:] 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 marchec = 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

Raisonnementgrid.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 shallowFalse (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 exercicedeep.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 marcheb 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.