24  Références, aliasing et mutabilité

🎯 Chapitre critique

Ce chapitre explique un des concepts les plus déroutants de Python. Les questions TOSA sur ce sujet sont très discriminantes : elles séparent ceux qui ont compris de ceux qui ont juste appris la syntaxe. Prenez votre temps, relisez, et faites tourner tous les exemples sur votre machine.

Pourquoi ce code produit-il un résultat surprenant ?

a = [1, 2, 3]
b = a
b.append(4)
print(a)
[1, 2, 3, 4]

La liste a contient maintenant 4 alors qu’on n’a modifié que b. Ce chapitre explique pourquoi, et comment éviter ce piège.

24.1 Variables et objets : le modèle Python

En Python, une variable n’est pas une case mémoire qui contient une valeur. C’est une étiquette qui pointe vers un objet en mémoire.

x = 42

Ce qui se passe :

  1. Python crée un objet entier 42 en mémoire.
  2. L’étiquette x est attachée à cet objet.
x ────► [ 42 ]

On peut visualiser cette référence avec la fonction id() qui renvoie l’identifiant unique d’un objet en mémoire :

x = 42
print(f"x pointe vers l'objet {id(x)}")
x pointe vers l'objet 140704084258520

24.2 L’alias : deux étiquettes, un seul objet

Quand on écrit b = a, on ne copie pas les données. On crée une nouvelle étiquette qui pointe vers le même objet.

a = [1, 2, 3]
b = a

print(f"id(a) = {id(a)}")
print(f"id(b) = {id(b)}")
print(f"a et b pointent vers le même objet : {a is b}")
id(a) = 1360570124736
id(b) = 1360570124736
a et b pointent vers le même objet : True
a ────┐
      ├───► [ 1, 2, 3 ]
b ────┘

On dit que a et b sont des alias — deux noms pour le même objet. Toute modification via b est aussi visible par a :

a = [1, 2, 3]
b = a

b.append(4)         # on modifie L'OBJET via b
print("a :", a)     # a voit la modification : même objet !
print("b :", b)
a : [1, 2, 3, 4]
b : [1, 2, 3, 4]

L’opérateur is — identité d’objet

L’opérateur is teste si deux variables pointent vers le même objet (identité), alors que == teste si leurs valeurs sont égales (égalité).

a = [1, 2, 3]
b = a              # alias
c = [1, 2, 3]      # nouvel objet, même contenu

print("a == b :", a == b)   # True : même contenu
print("a is b :", a is b)   # True : même objet

print("a == c :", a == c)   # True : même contenu
print("a is c :", a is c)   # False : objets différents !
a == b : True
a is b : True
a == c : True
a is c : False
a ────┐
      ├───► [ 1, 2, 3 ]    ← même objet
b ────┘

c ────────► [ 1, 2, 3 ]    ← autre objet, même contenu
⚠️ Piège TOSA : == vs is

Règle pratique :

  • is pour tester avec None, True, False.
  • == pour comparer des valeurs.
# ✅ Bonnes pratiques
if x is None:       # identité avec None
if x == 10:         # égalité de valeur
if nom == "Alice":  # égalité de chaîne

# ❌ À éviter
if x == None:       # fonctionne mais non idiomatique
if nom is "Alice":  # peut marcher ou non selon l'implémentation !

La dernière ligne est particulièrement piégeuse : Python mutualise certaines petites chaînes et petits entiers, ce qui peut faire que "Alice" is "Alice" est True… mais pas toujours. Ne vous fiez jamais à is pour comparer des valeurs.

24.3 Créer une vraie copie

Pour obtenir une copie indépendante d’une liste, on a plusieurs options :

Méthode 1 : slicing [:]

a = [1, 2, 3]
b = a[:]           # copie par slicing

b.append(4)
print("a :", a)    # inchangé
print("b :", b)
print(a is b)      # False : objets différents
a : [1, 2, 3]
b : [1, 2, 3, 4]
False

Méthode 2 : constructeur list()

a = [1, 2, 3]
b = list(a)        # même effet

b.append(4)
print("a :", a)
print("b :", b)
a : [1, 2, 3]
b : [1, 2, 3, 4]

Méthode 3 : méthode .copy()

a = [1, 2, 3]
b = a.copy()

b.append(4)
print("a :", a)
print("b :", b)
a : [1, 2, 3]
b : [1, 2, 3, 4]

Tableau des techniques de copie

Méthode Copie de Notes
b = a[:] liste, tuple, str Slicing complet
b = list(a) liste Explicit
b = a.copy() liste, dict, set Méthode dédiée
b = dict(a) dict
b = set(a) set
b = a alias, pas une copie ! Piège
Copie superficielle vs profonde

Les méthodes ci-dessus font une copie superficielle (shallow copy) : le conteneur est copié, mais pas les objets imbriqués.

matrice = [[1, 2], [3, 4]]
copie = matrice[:]        # copie superficielle

copie.append([5, 6])      # OK : on modifie la liste copiée
copie[0].append(99)       # ⚠️ modifie aussi matrice !

print("matrice :", matrice)
print("copie   :", copie)
matrice : [[1, 2, 99], [3, 4]]
copie   : [[1, 2, 99], [3, 4], [5, 6]]

Pour une copie vraiment indépendante (même en profondeur), il faut copy.deepcopy :

import copy

matrice = [[1, 2], [3, 4]]
profonde = copy.deepcopy(matrice)

profonde[0].append(99)

print("matrice  :", matrice)       # inchangée
print("profonde :", profonde)
matrice  : [[1, 2], [3, 4]]
profonde : [[1, 2, 99], [3, 4]]

La différence est surtout importante quand les listes contiennent d’autres listes, dicts ou objets mutables.

24.4 Objets mutables vs immuables

Python classe ses types en deux catégories :

Type Mutable ?
int, float, complex ❌ immuable
bool ❌ immuable
str ❌ immuable
tuple ❌ immuable
frozenset ❌ immuable
NoneType ❌ immuable
list mutable
dict mutable
set mutable
Objets personnels (classes) ✅ mutables par défaut

Conséquence pour les immuables

Puisqu’un immuable ne peut pas être modifié, le piège de l’alias ne se produit pas :

a = 10
b = a          # "alias" d'un int
b = b + 1      # en fait, b pointe maintenant vers un NOUVEL objet (11)

print("a :", a)   # a n'a pas bougé
print("b :", b)
a : 10
b : 11
Étape 1 :
a ────┐
      ├───► [ 10 ]
b ────┘

Étape 2 (après b = b + 1) :
a ────────► [ 10 ]
b ────────► [ 11 ]    ← nouvel objet

La réaffectation b = b + 1 ne modifie pas l’objet 10 — elle crée un nouvel entier et attache l’étiquette b à celui-ci.

Idem pour les chaînes

Les chaînes étant immuables, leurs méthodes renvoient une nouvelle chaîne (jamais modification en place) :

a = "Alice"
b = a
b = b.upper()     # crée une NOUVELLE chaîne

print("a :", a)   # inchangé
print("b :", b)
a : Alice
b : ALICE

24.5 Les fonctions et les références

Python passe les arguments par référence — mais comme l’affectation ne copie pas, les effets peuvent surprendre.

Cas d’un immuable — pas de piège

def incrementer(x):
    x = x + 1       # réaffectation → crée un nouvel objet localement
    print("Dans la fonction : x =", x)

a = 10
incrementer(a)
print("Après :", a)   # inchangé
Dans la fonction : x = 11
Après : 10

La variable locale x pointe initialement vers le même objet que a. Mais x = x + 1 réaffecte x à un nouvel objet. a reste intact.

Cas d’un mutable — attention !

def ajouter_element(liste, element):
    liste.append(element)     # modifie L'OBJET, pas de réaffectation

a = [1, 2, 3]
ajouter_element(a, 4)
print(a)     # [1, 2, 3, 4] : modification visible !
[1, 2, 3, 4]

liste.append() modifie l’objet en place. Comme liste et a pointent vers le même objet, a en subit les conséquences.

Règle à retenir
  • Opération qui modifie en place (comme .append, .sort, d[k] = v) : impacte l’original.
  • Réaffectation (comme liste = [...] dans la fonction) : ne modifie pas l’original, crée un nouvel objet local.
def vider_incorrect(liste):
    liste = []       # ❌ réaffectation locale, ne vide pas l'original

def vider_correct(liste):
    liste.clear()    # ✅ modifie l'objet en place

a = [1, 2, 3]
vider_incorrect(a)
print("Après vider_incorrect :", a)

vider_correct(a)
print("Après vider_correct  :", a)
Après vider_incorrect : [1, 2, 3]
Après vider_correct  : []

24.6 Les affectations combinées sur les collections

Voyons maintenant un point subtil : le comportement de += sur différents types.

Sur un int (immuable)

a = 10
b = a
b += 5        # équivalent à b = b + 5 : crée un nouvel objet

print("a :", a)   # inchangé
print("b :", b)
a : 10
b : 15

Sur une liste (mutable)

a = [1, 2, 3]
b = a
b += [4, 5]        # équivalent à b.extend([4, 5]) : modifie l'objet !

print("a :", a)
print("b :", b)
a : [1, 2, 3, 4, 5]
b : [1, 2, 3, 4, 5]
⚠️ x += y n’est PAS toujours équivalent à x = x + y !

Pour les immuables, x += y équivaut à x = x + y : on crée un nouvel objet.

Pour les mutables comme les listes, x += y appelle en fait x.__iadd__(y) qui modifie l’objet en place (comme extend).

# Liste : += modifie en place
a = [1, 2, 3]
id_avant = id(a)
a += [4]
print("Même objet ?", id(a) == id_avant)

# Int : += crée un nouvel objet
n = 10
id_avant = id(n)
n += 1
print("Même objet ?", id(n) == id_avant)
Même objet ? True
Même objet ? False

Le TOSA adore cette subtilité.

Sur une chaîne (immuable)

a = "Alice"
b = a
b += " Dupont"    # crée une nouvelle chaîne

print("a :", a)   # inchangé
print("b :", b)
a : Alice
b : Alice Dupont

24.7 Le piège des arguments par défaut mutables

On l’a déjà vu en chapitre 1, mais il mérite d’être rappelé ici dans son contexte théorique : c’est une conséquence directe de la mutabilité.

def ajouter_au_panier(article, panier=[]):
    panier.append(article)
    return panier

print(ajouter_au_panier("pomme"))
print(ajouter_au_panier("lait"))
print(ajouter_au_panier("pain"))
['pomme']
['pomme', 'lait']
['pomme', 'lait', 'pain']

Le panier=[] est évalué une seule fois, à la définition de la fonction. Cette liste est partagée entre tous les appels.

La solution canonique :

def ajouter_au_panier(article, panier=None):
    if panier is None:
        panier = []           # nouvelle liste à CHAQUE appel
    panier.append(article)
    return panier

print(ajouter_au_panier("pomme"))
print(ajouter_au_panier("lait"))
print(ajouter_au_panier("pain"))
['pomme']
['lait']
['pain']

24.8 Récapitulatif

Règles à mémoriser absolument
  1. = ne copie pas : il crée un alias (étiquette supplémentaire) vers le même objet.
  2. is teste l’identité (même objet), == teste l’égalité (même valeur).
  3. Pour copier une liste/dict/set : .copy(), list(...), ou [:].
  4. Pour une copie profonde : copy.deepcopy() (quand il y a des imbrications).
  5. Immuables : int, float, str, bool, tuple, frozenset, None. → pas de piège d’alias.
  6. Mutables : list, dict, set. → attention aux modifications partagées.
  7. Dans une fonction, réaffectermodifier en place.
  8. Jamais de mutable comme valeur par défaut : utilisez None + test.

🧩 Quiz 5.1 — Références et affectations

Question 1

Que s’affiche-t-il ?

a = [1, 2, 3]
b = a
b.append(4)
print(a)
  1. [1, 2, 3]
  2. [1, 2, 3, 4]
  3. [4, 1, 2, 3]
  4. Une erreur

b) [1, 2, 3, 4]b = a crée un alias. Les deux variables pointent vers la même liste. b.append(4) modifie l’objet, donc a voit le changement.

a = [1, 2, 3]
b = a
b.append(4)
print(a)
[1, 2, 3, 4]

Question 2

Que s’affiche-t-il ?

a = [1, 2, 3]
b = a[:]
b.append(4)
print(a)
  1. [1, 2, 3]
  2. [1, 2, 3, 4]
  3. [4, 1, 2, 3]
  4. Une erreur

a) [1, 2, 3]b = a[:] fait une copie par slicing. b est une nouvelle liste, donc modifier b n’affecte pas a.

a = [1, 2, 3]
b = a[:]
b.append(4)
print(a)
print(b)
[1, 2, 3]
[1, 2, 3, 4]

Question 3

Quelle expression teste correctement l’absence de valeur ?

  1. if x == None:
  2. if x is None:
  3. if not x:
  4. if x = None:

b) if x is None: — idiome Python correct. L’option a) fonctionne mais n’est pas recommandée. L’option c) est fausse : elle est vraie aussi pour 0, "", [] (valeurs falsy). L’option d) est une affectation (erreur de syntaxe).

Question 4

Quel type est mutable ?

  1. int
  2. str
  3. tuple
  4. list

d) list — les listes, dicts et sets sont mutables. Entiers, chaînes, tuples, booléens, None sont immuables.

Question 5

Que s’affiche-t-il ?

def vider(l):
    l = []

a = [1, 2, 3]
vider(a)
print(a)
  1. []
  2. [1, 2, 3]
  3. None
  4. Une erreur

b) [1, 2, 3]l = [] est une réaffectation locale : la variable locale l pointe vers une nouvelle liste vide, mais a (extérieur) n’est pas touchée.

Pour vider réellement a, il faudrait l.clear() ou del l[:].

def vider(l):
    l = []        # réaffectation locale

def vider_correct(l):
    l.clear()     # modification en place

a = [1, 2, 3]
vider(a)
print("Après vider :", a)

vider_correct(a)
print("Après vider_correct :", a)
Après vider : [1, 2, 3]
Après vider_correct : []

Question 6

Que s’affiche-t-il ?

a = [1, 2]
b = a
a += [3]
print(b)
  1. [1, 2]
  2. [1, 2, 3]
  3. [3]
  4. Une erreur

b) [1, 2, 3] — pour une liste, += modifie en place (comme extend). Comme a et b sont des alias, b voit la modification.

a = [1, 2]
b = a
a += [3]
print("a :", a)
print("b :", b)
a : [1, 2, 3]
b : [1, 2, 3]

À comparer avec une réaffectation complète :

a = [1, 2]
b = a
a = a + [3]    # crée une NOUVELLE liste
print("a :", a)
print("b :", b)   # inchangé
a : [1, 2, 3]
b : [1, 2]

Question 7

Que s’affiche-t-il ?

def f(x=[]):
    x.append(1)
    return x

print(f())
print(f())
  1. [1] puis [1]
  2. [1] puis [1, 1]
  3. [1, 1] puis [1, 1]
  4. Une erreur

b) [1] puis [1, 1] — le défaut mutable [] est évalué une seule fois à la définition. Elle est partagée entre tous les appels.

def f(x=[]):
    x.append(1)
    return x

print(f())
print(f())
print(f())
[1]
[1, 1]
[1, 1, 1]

Pour éviter : def f(x=None): puis if x is None: x = [].

Question 8

Combien d’objets différents existent en mémoire après ce code ?

a = [1, 2, 3]
b = a
c = list(a)
d = a[:]
  1. 1 (tous alias)
  2. 2
  3. 3
  4. 4

c) 3a et b sont alias (même objet). c est une nouvelle liste (via list()). d est encore une autre nouvelle liste (via [:]). Soit 3 objets distincts.

a = [1, 2, 3]
b = a
c = list(a)
d = a[:]

print(f"a et b même objet : {a is b}")
print(f"a et c même objet : {a is c}")
print(f"a et d même objet : {a is d}")
print(f"c et d même objet : {c is d}")
a et b même objet : True
a et c même objet : False
a et d même objet : False
c et d même objet : False

✏️ Exercice 5.1 — Détecter les alias

Pour chaque paire ci-dessous, dites si a et b pointent vers le même objet.

# Cas 1
a = [1, 2, 3]
b = a

# Cas 2
a = [1, 2, 3]
b = a.copy()

# Cas 3
a = "Bonjour"
b = a

# Cas 4
a = [1, 2, 3]
b = list(a)

# Cas 5
a = (1, 2, 3)
b = a

Pour chaque cas, prévoyez la valeur de a is b, puis vérifiez.

# Cas 1 : alias
a = [1, 2, 3]
b = a
print("Cas 1 :", a is b)    # True

# Cas 2 : copie
a = [1, 2, 3]
b = a.copy()
print("Cas 2 :", a is b)    # False

# Cas 3 : str immuable
a = "Bonjour"
b = a
print("Cas 3 :", a is b)    # True (mais peu importe, car immuable)

# Cas 4 : copie via list()
a = [1, 2, 3]
b = list(a)
print("Cas 4 :", a is b)    # False

# Cas 5 : tuple immuable
a = (1, 2, 3)
b = a
print("Cas 5 :", a is b)    # True
Cas 1 : True
Cas 2 : False
Cas 3 : True
Cas 4 : False
Cas 5 : True

Pourquoi ça importe : seuls les mutables aliasés peuvent créer des surprises. Pour les immuables, même s’il y a « alias », rien ne peut être modifié, donc pas de piège.


✏️ Exercice 5.2 — Corriger un bug de copie

Voici une fonction qui sabote les données qu’on lui passe :

def doubler_tous(notes):
    for i in range(len(notes)):
        notes[i] = notes[i] * 2
    return notes

notes_originales = [10, 12, 14]
notes_doublees = doubler_tous(notes_originales)

print("Originales :", notes_originales)
print("Doublées  :", notes_doublees)

Corrigez la fonction pour qu’elle renvoie une nouvelle liste sans modifier l’originale.

Le code ci-dessus. À vous de :

  1. Identifier le problème.
  2. Proposer une correction.

Problème : la fonction modifie notes en place (via notes[i] = ...). Or, comme notes et notes_originales pointent vers la même liste, l’originale est impactée.

Correction 1 — créer une nouvelle liste :

def doubler_tous(notes):
    resultat = []
    for n in notes:
        resultat.append(n * 2)
    return resultat

notes_originales = [10, 12, 14]
notes_doublees = doubler_tous(notes_originales)

print("Originales :", notes_originales)
print("Doublées  :", notes_doublees)
Originales : [10, 12, 14]
Doublées  : [20, 24, 28]

Correction 2 — copier d’abord :

def doubler_tous(notes):
    notes = notes.copy()       # on travaille sur une copie
    for i in range(len(notes)):
        notes[i] = notes[i] * 2
    return notes

notes_originales = [10, 12, 14]
notes_doublees = doubler_tous(notes_originales)

print("Originales :", notes_originales)
print("Doublées  :", notes_doublees)
Originales : [10, 12, 14]
Doublées  : [20, 24, 28]

Les deux versions sont correctes. La version 1 est plus idiomatique (surtout qu’elle se réécrit en une ligne avec une compréhension — vu en Partie 3).


✏️ Exercice 5.3 — Le piège du default mutable

Prédisez la sortie de ce code, puis vérifiez et corrigez-le pour qu’il produise [1] trois fois.

def enregistrer(valeur, historique=[]):
    historique.append(valeur)
    return historique

print(enregistrer(1))
print(enregistrer(1))
print(enregistrer(1))

Code ci-dessus. Expliquez le comportement et corrigez.

Ce que ça affiche (incorrect) :

def enregistrer(valeur, historique=[]):
    historique.append(valeur)
    return historique

print(enregistrer(1))
print(enregistrer(1))
print(enregistrer(1))
[1]
[1, 1]
[1, 1, 1]

La liste [] est créée une seule fois à la définition, et partagée entre les appels.

Version corrigée :

def enregistrer(valeur, historique=None):
    if historique is None:
        historique = []      # nouvelle liste à chaque appel
    historique.append(valeur)
    return historique

print(enregistrer(1))
print(enregistrer(1))
print(enregistrer(1))
[1]
[1]
[1]

Et si on veut vraiment un historique partagé (cas où c’est volontaire), on peut utiliser une variable globale ou une classe — à voir plus tard.


← Chapitre précédent : Itération avancéeChapitre suivant : Logique booléenne →