La copie d’objets est un concept fondamental en Python, et une mauvaise compréhension de celui-ci conduit souvent à des bogues difficiles à trouver. Lorsque vous modifiez un objet et qu’un autre change de manière inattendue, le problème réside probablement dans la manière dont il a été copié. Démystifions cela une bonne fois pour toutes.
🔎 Pourquoi la copie d’objets est-elle si importante ?
En Python, les variables ne sont pas des « boîtes » contenant des données, mais plutôt des « étiquettes » ou des références pointant vers des objets en mémoire. Lorsque vous effectuez une simple affectation, comme celle-ci :
liste_a = [1, 2, 3]
liste_b = liste_a
Vous ne créez pas une nouvelle liste. Vous créez simplement une autre référence (liste_b) qui pointe vers le même objet liste.
👉 Toute modification de liste_a sera immédiatement répercutée dans liste_b (et vice versa) car il s’agit du même objet.
Analogie : Imaginez que vous avez un document Google Docs. Si vous envoyez un lien à un ami, il modifiera votre document original. C’est une affectation. En revanche, si vous sélectionnez « Fichier » → « Créer une copie », un document complètement indépendant est créé. Le module copy en Python existe pour créer ce genre de copies indépendantes.
⚙️ Deux types de copie
Le module copy fournit deux fonctions clés :
copy.copy()— crée une copie superficielle (shallow copy).copy.deepcopy()— crée une copie profonde (deep copy).
La principale différence entre elles réside dans la manière dont elles traitent les objets imbriqués (comme des listes à l’intérieur de listes ou des dictionnaires à l’intérieur de dictionnaires).
🟦 Copie superficielle (copy.copy())
copy.copy() crée un nouvel objet conteneur de haut niveau, mais au lieu de copier les objets imbriqués, il y insère des références vers les objets originaux.
import copy
liste_originale = [1, 2, [3, 4]]
copie_superficielle = copy.copy(liste_originale)
# Modifier la liste imbriquée dans l'original
liste_originale[2][0] = 99
print(f"Original : {liste_originale}") # Affiche : Original : [1, 2, [99, 4]]
print(f"Copie superficielle : {copie_superficielle}") # Affiche : Copie superficielle : [1, 2, [99, 4]]
Comme vous pouvez le voir, la modification de la liste imbriquée a affecté à la fois l’original et la copie, car ils partagent cette liste imbriquée.
Diagramme ASCII :
liste_originale → [ 1, 2, ───► liste_interne ]
copie_superficielle → [ 1, 2, ───► liste_interne ]
liste_interne → [ 99, 4 ]
🟩 Copie profonde (copy.deepcopy())
copy.deepcopy() résout le problème des références partagées. Cette fonction parcourt récursivement l’ensemble de l’objet et crée des copies complètes et indépendantes de tout ce qu’elle rencontre, y compris tous les objets imbriqués.
import copy
liste_originale = [1, 2, [3, 4]]
copie_profonde = copy.deepcopy(liste_originale)
# Modifier la liste imbriquée dans l'original
liste_originale[2][0] = 99
print(f"Original : {liste_originale}") # Affiche : Original : [1, 2, [99, 4]]
print(f"Copie profonde : {copie_profonde}") # Affiche : Copie profonde : [1, 2, [3, 4]]
Désormais, la copie_profonde est complètement indépendante.
Diagramme ASCII :
liste_originale → [ 1, 2, ───► liste_a ]
copie_profonde → [ 1, 2, ───► liste_b ]
liste_a → [ 99, 4 ]
liste_b → [ 3, 4 ]
📑 Aide-mémoire : copy.copy() vs copy.deepcopy()
| Type d’objet | Exemple | copy.copy() (superficielle) | copy.deepcopy() (profonde) |
|---|---|---|---|
| list (liste) | [1, 2, [3, 4]] | Une nouvelle liste, mais la liste imbriquée [3, 4] est partagée | Une copie entièrement indépendante ; la liste imbriquée est aussi copiée |
| dict (dictionnaire) | {"a": 1, "b": {"c": 2}} | Un nouveau dictionnaire, mais le {"c": 2} imbriqué est partagé | Un dictionnaire indépendant et tous les dictionnaires imbriqués |
| set (ensemble) | {1, 2, (3, 4)} | Un nouvel ensemble ; les éléments mutables imbriqués seraient partagés | Un nouvel ensemble ; tous les éléments imbriqués sont copiés récursivement |
| tuple | (1, 2, [3, 4]) | Retourne le même tuple, mais la liste imbriquée est partagée | Un nouveau tuple avec une copie de la liste imbriquée |
| str, int, float, bool | "salut", 42, 3.14, True | Retourne le même objet (ils sont immuables, pas besoin de copier) | Identique à la copie superficielle |
| classe personnalisée | Noeud(1, Noeud(2)) | Un nouvel objet de haut niveau ; les objets imbriqués sont partagés | Une copie entièrement indépendante de l’objet et de tous les objets imbriqués |
objet avec __slots__ | Une classe avec __slots__ | Seul l’objet de haut niveau est copié | Tous les objets imbriqués sont copiés s’ils le permettent |
| fichier / socket / flux | open("file.txt") | Erreur (objet non copiable) | Erreur (objet non copiable) |
🧪 Exemples concrets pour le tableau
📌 list
import copy
a = [1, 2, [3, 4]]
b = copy.copy(a)
c = copy.deepcopy(a)
a[2][0] = 99
print(f"copy: {b}") # [1, 2, [99, 4]]
print(f"deepcopy: {c}") # [1, 2, [3, 4]]
📌 dict
import copy
a = {"a": 1, "b": {"c": 2}}
b = copy.copy(a)
c = copy.deepcopy(a)
a["b"]["c"] = 42
print(f"copy: {b}") # {'a': 1, 'b': {'c': 42}}
print(f"deepcopy: {c}") # {'a': 1, 'b': {'c': 2}}
📌 set
import copy
a = {1, 2, (3, 4)} # Les ensembles ne peuvent contenir que des types immuables
b = copy.copy(a)
c = copy.deepcopy(a)
print(b == c) # True (mais ce sont deux objets indépendants en mémoire)
📌 tuple
import copy
a = (1, 2, [3, 4]) # Le tuple est immuable, mais son élément de type liste ne l'est pas
b = copy.copy(a)
c = copy.deepcopy(a)
a[2][0] = 99
print(f"copy: {b}") # (1, 2, [99, 4])
print(f"deepcopy: {c}") # (1, 2, [3, 4])
📌 Classe personnalisée
import copy
class Noeud:
def __init__(self, valeur, enfant=None):
self.valeur = valeur
self.enfant = enfant
a = Noeud(1, Noeud(2))
b = copy.copy(a)
c = copy.deepcopy(a)
a.enfant.valeur = 99
print(f"copy: {b.enfant.valeur}") # 99
print(f"deepcopy: {c.enfant.valeur}") # 2
📌 Personnalisation du comportement : __copy__ et __deepcopy__
Vous pouvez contrôler la manière dont vos propres objets sont copiés en définissant des méthodes spéciales dans leur classe.
import copy
class Personnalisee:
def __init__(self, x):
self.x = x
def __copy__(self):
print("Appel de __copy__ !")
# Retourne une nouvelle instance, mais avec la même référence aux données internes
return Personnalisee(self.x)
def __deepcopy__(self, memo):
# memo est un dictionnaire pour suivre les objets déjà copiés (évite la récursion)
print("Appel de __deepcopy__ !")
# Crée une copie entièrement nouvelle, y compris les données imbriquées
return Personnalisee(copy.deepcopy(self.x, memo))
obj = Personnalisee([1, 2])
c1 = copy.copy(obj) # Affiche : Appel de __copy__ !
c2 = copy.deepcopy(obj) # Affiche : Appel de __deepcopy__ !
⚡ Performances et pièges
- Vitesse :
copy.copy()est beaucoup plus rapide car il ne parcourt pas récursivement toute la structure de données.copy.deepcopy()peut être très lent pour des objets volumineux et profondément imbriqués. - Objets non copiables : Les ressources système comme les fichiers ouverts, les sockets ou les flux ne peuvent pas être copiées. Tenter de le faire lèvera une erreur.
- Références cycliques :
deepcopyest assez intelligent pour gérer les références cycliques (lorsqu’un objetAfait référence àB, etBà son tour àA) sans entrer dans une boucle infinie.
Quand utiliser quoi : Les règles principales
- Utilisez une copie superficielle (
copy.copy()) si votre objet ne contient que des données immuables ou si vous souhaitez intentionnellement que la copie et l’original partagent des objets imbriqués. C’est rapide et efficace. - Utilisez une copie profonde (
copy.deepcopy()) lorsque vous avez besoin d’une copie complète et 100% indépendante de l’original, en particulier lorsque vous travaillez avec des structures de données complexes (listes, dictionnaires, instances de classe). - Pour les objets immuables (nombres, chaînes de caractères, tuples sans éléments mutables), la copie est généralement inutile — Python optimise déjà leur utilisation.
- N’oubliez pas que vous pouvez personnaliser le processus avec
__copy__et__deepcopy__pour un contrôle total.