Avec Python, une question revient souvent, quelle est la différence entre copy et deepcopy ?
On s'est aussi (presque) tous fait avoir à un moment avec les listes : "Bizarre, ma liste se modifie toute seule 🤔".
Il faut en fait bien comprendre les différences entre l'affectation, la copie superficielle (shallow copy) et la copie en profondeur (deep copy). C'est ce que nous allons voir dans cet article.
À noter
Avant d'aller plus loin, cette note rapide vous permettra de mieux appréhender la suite :
-
Les objets muables possèdent une méthode
.copy()pour la copie superficielle -
Les objets immuables n'ont pas de méthode
.copy(), il faut utiliser le modulecopy -
La copie profonde se fait toujours via le module
copyaveccopy.deepcopy()
Quelle différence entre copie superficielle et copie profonde ?
Copie superficielle
La copie superficielle crée un nouvel objet, sans créer de nouvel objet pour les objets imbriqués. Pour ces derniers, Python insère des références aux objets contenu dans l'original.
Autrement dit, la copie superficelle ne copie que le premier niveau, les objets imbriqués restent partagés.
import copy original = [1, [2, 3], 4] copie = copy.copy(original) # On pourrait aussi utiliser original.copy() # Modifier un élément de premier niveau copie[0] = 999 print(original) # [1, [2, 3], 4] - inchangé # Modifier un objet imbriqué copie[1][0] = 999 print(original) # [1, [999, 3], 4] - modifié !
Copie profonde
La copie profonde crée un nouvel objet et copie récursivement tous les objets imbriqués qu'il contient. Avec la copie profonde, l'objet original est "protégé".
import copy original = [1, [2, 3], 4] copie = copy.deepcopy(original) # Modifier un élément de premier niveau copie[0] = 999 print(original) # [1, [2, 3], 4] - inchangé # Modifier un objet imbriqué copie[1][0] = 999 print(original) # [1, [2, 3], 4] - inchangé !
Objets muables et immuables
En Python, le comportement des objets est différent selon leur type.
Les types immuables
Un objet immuable ne peut plus être modifié une fois créé. Ce qui veut dire que le concept de copie est moins problématique avec les nombres entiers et décimaux (int, float), chaînes de caractères (str), les booléens (bool) et les tuples (tuple), car nous ne pouvons pas les altérer.
# Exemple avec un nombre x = 10 print(f"ID initial de x : {id(x)}") x = x + 1 # Crée un nouvel objet, ne modifie pas l'ancien print(f"Nouvel ID de x : {id(x)}") # L'ID a changé # Exemple avec une chaîne de caractères s = "hello" print(f"\nID initial de s : {id(s)}") s = s + " world" # Crée une nouvelle chaîne print(f"Nouvel ID de s : {id(s)}") # L'ID a changé
Les types muables
La distinction entre référence et copie devient essentielle, car les objets comme les listes (list), dictionnaires (dict) et set peuvent être modifiés après leur création.
# Exemple de modification d'une liste ma_liste = [1, 2] print(f"ID initial de la liste : {id(ma_liste)}") ma_liste.append(3) # Modifié l'objet existant print(f"ID de la liste après modif. : {id(ma_liste)}") # L'ID est identique # Exemple de modification d'un dictionnaire mon_dico = {'a': 1, 'b': 2} print(f"ID initial du dictionnaire : {id(mon_dico)}") mon_dico['c'] = 3 # Modifié l'objet existant print(f"ID du dictionnaire après modif. : {id(mon_dico)}") # L'ID est identique
Copier un objet immuable
À noter que la méthode copy, disponible sur les objets muables (listes, dictionnaires, etc), n'existe pas sur les objets immuables comme les chaînes de caractères ou les nombres.
Il est par contre possible d'utiliser le module copy sur un objet immuable.
Cependant, Python retourne une référence vers l'objet original plutôt qu'une nouvelle copie.
import copy # Avec une chaîne de caractères (immuable) a = "hello" b = copy.copy(a) c = copy.deepcopy(a) # Les ID sont identiques, Python a retourné le même objet print(f"IDs pour une chaîne de caractères : a={id(a)}, b={id(b)}, c={id(c)}") # Avec un tuple (immuable) ne contenant que des immuables t1 = (1, 2, 3) t2 = copy.copy(t1) # L'ID est aussi identique print(f"IDs pour un tuple simple : t1={id(t1)}, t2={id(t2)}")
Attention
Même si un tuple est immuable, il peut lui-même contenir des objets muables. L'objet muable à l'intérieur du tuple peut toujours être modifié. deepcopy peut donc être pertinent même pour un tuple.
# Avec une copie superficielle, les objets muables à l'intérieur du tuple sont partagés. import copy # t1 est un tuple contenant une liste muable. t1 = (1, 2, [30, 40]) t2 = copy.copy(t1) # avec copy.copy, on crée une copie superficielle de t1 # On modifie la liste à l'intérieur du tuple t2[2].append(50) # La modification est visible depuis t1 ! print(f"Tuple 1 : {t1}") # Affiche : (1, 2, [30, 40, 50]) print(f"Tuple 2 : {t2}") # Affiche : (1, 2, [30, 40, 50]) print(f"Les ID des tuples sont identiques : {id(t1) == id(t2)}") # True
# Avec une copie profonde, on peut modifier les éléments d'un tuple # sans affecter l'original, même si le tuple contient des objets muables. import copy # t1 est un tuple contenant une liste muable. t1 = (1, 2, [30, 40]) t2 = copy.deepcopy(t1) # deepcopy est nécessaire ici # On modifie la liste à l'intérieur du tuple t2[2].append(50) # La modification n'est PAS visible depuis t1 ! print(f"Tuple 1 : {t1}") # Affiche : (1, 2, [30, 40]) print(f"Tuple 2 : {t2}") # Affiche : (1, 2, [30, 40, 50]) print(f"Les ID des tuples sont différents : {id(t1) != id(t2)}") # True
Attention
L'opérateur = ne crée pas de nouvel objet, mais un nouveau nom qui pointe vers le même objet en mémoire.
# Exemple avec une liste liste_a = [1, 2, 3] liste_b = liste_a # liste_b est une autre étiquette sur le même objet liste_b.append(4) print(f"Liste A : {liste_a}") # Affiche : [1, 2, 3, 4] print(f"Liste B : {liste_b}") # Affiche aussi : [1, 2, 3, 4] print(f"ID de liste_a et liste_b sont identiques : {id(liste_a) == id(liste_b)}") # True # Exemple avec un dictionnaire dico_a = {'nom': 'Alice', 'ville': 'Paris'} dico_b = dico_a dico_b['age'] = 30 # On modifie via dico_b print(f"Dico A : {dico_a}") # Affiche : {'nom': 'Alice', 'ville': 'Paris', 'age': 30} print(f"Dico B : {dico_b}") # Affiche aussi : {'nom': 'Alice', 'ville': 'Paris', 'age': 30} print(f"ID de dico_a et dico_b sont identiques : {id(dico_a) == id(dico_b)}") # True
À noter
Pour la suite de l'article, nous allons nous intéresser aux types muables, là où se pose réellement le problème de copie.
Si on reprend l'exemple ci-dessus, liste_b ne représente pas une nouvelle liste, mais juste un nouveau nom, qui référence liste_a.
Si on modifie liste_b, on modifie donc aussi liste_a !
Copie superficielle sans module
Il est possible de créer une copie superficielle d'un objet muable sans utiliser le module copy.
Une copie superficielle crée un nouveau "conteneur" :
-
L'objet contenant (comme une liste ou un dictionnaire) est dupliqué, l'original et la copie sont deux objets distincts.
-
Les objets contenus ne sont pas dupliqués (important !)
Vous pouvez copier une liste, un dictionnaire ou un set de différentes façons sans le module copy :
-
ma_liste.copy(),ma_liste[:],list(ma_liste)pour les listes -
mon_dico.copy(),dict(mon_dico)pour les dictionnaires -
mon_set.copy(),set(mon_set)pour les ensembles
Copie superficielle des listes
Les listes peuvent être copiées de manière superficielle avec plusieurs méthodes :
# Liste originale ma_liste = [1, 2, [3, 4], 5] print(f"Liste originale : {ma_liste}") # Méthode 1 : .copy() liste_copie1 = ma_liste.copy() print(f"Copie avec .copy() : {liste_copie1}") # Méthode 2 : slice [:] liste_copie2 = ma_liste[:] print(f"Copie avec [:] : {liste_copie2}") # Méthode 3 : list() liste_copie3 = list(ma_liste) print(f"Copie avec list() : {liste_copie3}")
Copie superficielle des dictionnaires
Les dictionnaires offrent également plusieurs méthodes de copie superficielle :
# Dictionnaire original mon_dico = {"a": 1, "b": [2, 3], "c": {"d": 4}} print(f"Dictionnaire original : {mon_dico}") # Méthode 1 : .copy() dico_copie1 = mon_dico.copy() print(f"Copie avec .copy() : {dico_copie1}") # Méthode 2 : dict() dico_copie2 = dict(mon_dico) print(f"Copie avec dict() : {dico_copie2}")
Copie superficielle des ensembles
Les ensembles (sets) suivent les mêmes principes :
# Ensemble original mon_set = {1, 2, 3, frozenset([4, 5])} print(f"Ensemble original : {mon_set}") # Méthode 1 : .copy() set_copie1 = mon_set.copy() print(f"Copie avec .copy() : {set_copie1}") # Méthode 2 : set() set_copie2 = set(mon_set) print(f"Copie avec set() : {set_copie2}")
Attention
Le cas des ensembles (set) est différent. Contrairement aux listes, les éléments d'un ensemble doivent être hashable, donc immuable. Il ne peut donc pas y avoir de dictionnaire ou de liste dans un ensemble.
Il peut y avoir un tuple, mais le tuple lui-même ne peut pas contenir d'élément muable, car il devient non-hashable s'il contient des éléments muables.
Une copie superficielle est toujours suffisante pour les ensembles car ils ne peuvent pas contenir d'objets muables.
mon_set = {1, 2, 3, 4, 5, (6, 7, 8, 9, [10])} # Lèver une erreur TypeError
Les objets imbriqués
La copie a des limites lorsqu'une structure muable contient d'autres objets muables.
# Exemple avec une liste de listes liste_a = [[1, 2], [3, 4]] liste_b = liste_a.copy() liste_b[0].append(99) # On modifie la première sous-liste imbriquée liste_b.append(100) # On modifie directement l'objet copié (le "conteneur") print(f"Liste A (imbriquée) : {liste_a}") # Affiche : [[1, 2, 99], [3, 4]] print(f"Liste B (imbriquée) : {liste_b}") # Affiche : [[1, 2, 99], [3, 4], 100]
Avec le code ci-dessus, on voit que la modification de la première sous-liste a une répercussion à la fois sur liste_a et liste_b. La copie étant superficielle, on ne crée pas un nouvel objet en mémoire pour tous les objets muables contenus à l'intérieur de liste_a et liste_b : en modifiant la sous-liste, elle est donc modifiée dans tous les "conteneurs".
Quand on fait un liste_b.append(100) cependant, on modifie directement le conteneur liste_b. liste_a n'est donc pas affecté par cette opération.
Même comportement ici avec le dictionnaire : la liste des rôles est la même et on affecte donc les deux dictionnaires, ce qui n'est pas le cas quand on modifie directement le conteneur principal.
C'est ici qu'intervient le module copy et la deepcopy.
Exemple avec un dictionnaire contenant une liste
dico_a = {'user': 'Alice', 'roles': ['admin', 'editor']} dico_b = dico_a.copy() dico_b['roles'].append('viewer') dico_b['salaire'] = 2800 print(f"Dico A (imbriqué) : {dico_a}") # Affiche : {'user': 'Alice', 'roles': ['admin', 'editor', 'viewer']} print(f"Dico B (imbriqué) : {dico_b}") # Affiche : {'user': 'Alice', 'roles': ['admin', 'editor', 'viewer'], 'salaire': 2800}
Le module copy
Le module standard copy comprend deux fonctions principales :
-
copy.copy() -
copy.deepcopy()
copy.copy() équivaut à la copie superficielle... ou presque !
copy.copy()
Alors que la méthode .copy() est une méthode qui est explicitement implémentée par le type de l'objet (comme les listes), la fonction copy() du module copy fonctionne avec n'importe quel objet.
import copy x = 3 # x.copy() lève une erreur car les entiers ne sont pas muables y = copy.copy(x) # Copie superficielle de x print(y) # Affiche 3 print(id(x)) # Affiche l'identifiant de x print(id(y)) # Affiche l'identifiant de y, qui est identique à celui de x
copy.deepcopy()
La copie en profondeur copie récursivement l'objet muable et ses sous-objets. Ce qui implique que la copie profonde est un peu plus gourmande en mémoire.
import copy # Résolution pour la liste de listes liste_a = [[1, 2], [3, 4]] liste_b = copy.deepcopy(liste_a) liste_b[0].append(99) print(f"Liste A (deepcopy) : {liste_a}") # Affiche : [[1, 2], [3, 4]] print(f"Liste B (deepcopy) : {liste_b}") # Affiche : [[1, 2, 99], [3, 4]] # Résolution pour le dictionnaire dico_a = {'user': 'Alice', 'roles': ['admin', 'editor']} dico_b = copy.deepcopy(dico_a) dico_b['roles'].append('viewer') print(f"Dico A (deepcopy) : {dico_a}") # Affiche : {'user': 'Alice', 'roles': ['admin', 'editor']} print(f"Dico B (deepcopy) : {dico_b}") # Affiche : {'user': 'Alice', 'roles': ['admin', 'editor', 'viewer']}
À ce stade, aucune partie de la structure de données n'est partagée. Ce qui explique que le processus à un coût, car la deepcopy va créer de nouveaux objets au lieu de copier les références.
Quand utiliser la copie superficielle ?
C'est simple, si la structure de données ne contient qu'un niveau et ne contient que des objets immuables, la copie superficielle est sûre.
# Une structure de données simple avec des immuables original = [1, 5, "hello", True] copie = original.copy() # On peut modifier la copie sans aucun risque pour l'original copie.append(99) print(f"Original : {original}") # Affiche : [1, 5, 'hello', True] print(f"Copie : {copie}") # Affiche : [1, 5, 'hello', True, 99]
Même si la liste contient d'autres objets muables, la copie superficielle est cohérente tant que les modifications ne concernent que la liste principale.
copy vs deepcopy : performances
Si vous voulez voir la différence de performance, cet exemple est concret :
import copy
import time
# Création d'une structure complexe
data = {
"level1": {
"level2": {
"numbers": list(range(1000)),
"texts": [f"item_{i}" for i in range(100)]
}
}
}
# Test copy.copy()
start = time.time()
for _ in range(1000):
copy.copy(data)
time_copy = time.time() - start
# Test copy.deepcopy()
start = time.time()
for _ in range(1000):
copy.deepcopy(data)
time_deepcopy = time.time() - start
print(f"copy.copy(): {time_copy:.4f}s")
print(f"copy.deepcopy(): {time_deepcopy:.4f}s")
Si j'exécute le code :
copy.copy(): 0.0001s
copy.deepcopy(): 0.2311s
La différence entre les performances est flagrante : la deep copy est environ 1581 fois plus lent que shallow copy dans mon cas.
Copie et objets personnalisés
Avec vos propres classes, il est possible de modifier le comportement par défaut de copy.copy() et copy.deepcopy().
Python vous donne un contrôle sur le processus de copie via deux méthodes spéciales : __copy__() et __deepcopy__().
Contrôler la copie superficielle
La méthode __copy__() est appelée par la fonction copy.copy(). Elle retourne une nouvelle instance avec les mêmes attributs.
Exemple
Nous voulons pouvoir copier une instance de la classe Configuration, mais que la copie possède sa propre date.
import copy import time class Configuration: def __init__(self, params: dict, created_at=None): self.params = params # Si la date n'est pas fournie, on la crée self.created_at = created_at or time.time() def __repr__(self): return f"Configuration(params={self.params}, created_at={self.created_at:.0f})" def __copy__(self): # On ne veut pas copier la date de création. # On crée une nouvelle instance qui aura sa propre date. cls = self.__class__ result = cls(self.params) # Laisse __init__ créer une nouvelle date return result # Création de l'instance originale config_originale = Configuration({'user': 'admin', 'retries': 3}) print(f"Originale : {config_originale}") time.sleep(2) # On attend 2 secondes # Création de la copie config_copie = copy.copy(config_originale) print(f"Copie : {config_copie}") # Vérifions les attributs print(f"ID de l'original: {id(config_originale)}") print(f"ID de la copie : {id(config_copie)}") # Les dictionnaires de paramètres sont les mêmes (copie superficielle) print(f"ID des params : {id(config_originale.params)} vs {id(config_copie.params)}")
La copie est bien une nouvelle instance, mais avec une date de création différente, et la référence au dictionnaire params est toujours partagée.
Contrôler la copie profonde
__deepcopy__(self, memo) est appelée par copy.deepcopy(), permet de gérer la duplication récursive et possède le paramètre memo.
Le memo est un dictionnaire qui permet de suivre les objets déjà copiés, et donc de préserver le partage des références : si un même objet est référencé à plusieurs endroits, il ne doit être copié qu'une seule fois.
import copy class Personne: def __init__(self, nom): self.nom = nom self.ami = None self.collegues = [] def __repr__(self): return f"Personne({self.nom})" def __deepcopy__(self, memo): print(f"Copie de {self.nom}") # Vérifier si cet objet a déjà été copié if id(self) in memo: print(f" -> {self.nom} déjà copié, on retourne la référence") return memo[id(self)] # Créer une nouvelle instance et l'enregistrer immédiatement nouvelle_personne = Personne(self.nom) memo[id(self)] = nouvelle_personne # Copier les attributs nouvelle_personne.ami = copy.deepcopy(self.ami, memo) nouvelle_personne.collegues = copy.deepcopy(self.collegues, memo) return nouvelle_personne # Test avec partage de références alice = Personne("Alice") bob = Personne("Bob") # Bob est ami avec Alice ET dans ses collègues alice.ami = bob alice.collegues = [bob] # Bob apparaît 2 fois dans le graphe print("=== Copie avec memo ===") alice_copie = copy.deepcopy(alice) # Vérification : Bob n'a été copié qu'une fois print(f"\nMême Bob ? {alice_copie.ami is alice_copie.collegues[0]}") # True
Le paramètre memo est un dictionnaire utilisé par copy.deepcopy() qui permet d'éviter de copier plusieurs fois le même objet. Les objets déjà copiés sont "consignés" dans le memo.
Dans l'exemple ci-dessus, bob apparaît à deux endroits dans la structure d'alice :
-
alice.ami -
alice.collegues[0]
Sans memo, bob serait copié deux fois (deux objets distincts).
Processus complet :
-
Au premier passage, Alice est copiée et immédiatement enregistrée dans le memo
-
On copie les attributs,
alice.ami, Bob n'est pas dans le memo, alors on le copie et on l'enregistre dans le memo -
Dans
alice.collegues[0], on retrouve Bob, mais comme il est déjà dans le memo, on retourne la copie existante -
alice_copie.amietalice_copie.collegues[0]pointent vers le même objet
Le paramètre memo est obligatoire, si le paramètre est oublié, Python lèvera une TypeError :
import copy class Test: def __init__(self, nom): self.nom = nom def __deepcopy__(self): return Test(self.nom) obj = Test("Hello") copie = copy.deepcopy(obj) # TypeError: Test.__deepcopy__() takes 1 positional argument but 2 were given
On l'appelle memo par convention, mais on pourrait très bien l'appeler patrick :
import copy class Test: def __init__(self, nom): self.nom = nom def __deepcopy__(self, patrick): return Test(self.nom) obj = Test("Hello") copie = copy.deepcopy(obj)
Posons les choses !
Nous avons vu pas mal de concepts, et c'est tout à fait normal de s'y perdre...
Utilisez la copie superficielle (copy() ou les méthodes natives) quand vous ne modifiez que le conteneur principal, et la copie profonde (deepcopy()) quand la structure contient des objets muables imbriqués.
deepcopy() est à utiliser que quand c'est nécessaire, car il a un coût en performance.
Et n'oubliez pas, l'affectation simple (=) ne fait que créer une nouvelle référence vers le même objet... Plus de listes qui se "modifies toutes seules" maintenant !