Dans cet article, nous explorerons d'abord les concepts fondamentaux des objets en Python, puis aborderons la copie superficielle et profonde ainsi que leurs différences. Après la lecture, vous devriez maîtriser les points suivants :
- Comprendre la relation entre variables, références et objets
- Saisir les concepts d'identity, type et value dans les objets Python
- Savoir ce que sont les objets mutables et immutables, ainsi que leur lien avec la hashabilité
- Comprendre le processus et les différences entre copie superficielle et profonde
1. Variables, références et objets
En Python, les variables n'ont pas de type ; leur rôle se limite à référencer un objet spécifique à un moment donné. En mémoire, une variable est simplement un pointeur occupant l'espace nécessaire pour pointer vers un objet.
La relation entre variables et objets repose sur la référence. Lorsqu'une variable référence un objet, cela correspond à une opération d'affectation.
En Python, tout est objet. Chaque objet occupe un espace mémoire et possède trois caractéristiques : identity, type et value.
L'identity, une fois l'objet créé, reste inchangée. Dans CPython, elle correspond à l'adresse mémoire de l'objet. L'opérateur is compare les objets en se basant sur cette valeur. La fonction id() permet d'obtenir sa représentation entière.
Le type, comme l'identity, ne change pas après la création de l'objet. Il définit les valeurs et opérations possibles (par exemple, la méthode de longueur pour les listes). La fonction type() renvoie le type d'un objet.
La value représente la valeur de certains objets. Si la valeur peut changer après la création, l'objet est mutable ; sinon, il est immuable.
Prenons l'exemple du code C : int x = 4 alloue d'abord un espace mémoire pour un entier, puis y stocke la valeur 4.
En Python, x = 4 fait l'inverse : il alloue un espace pour la valeur 4, puis fait pointer x vers cet espace. Comme les variables peuvent pointer vers des objets de différents types, il n'est pas nécessaire de déclarer le type d'une variable comme en C. C'est ce qui rend Python un langage à typage dynamique.
En Python, on peut supprimer des variables, mais pas les objets eux-mêmes.
2. Objets mutables et immutables
Les objets immutables ont une valeur fixe, incluant les nombres, les chaînes de caractères et les tuples. Lorsqu'une nouvelle valeur est assignée, un nouvel objet est créé. Ces objets jouent un rôle crucial dans les valeurs de hachage, notamment comme clés de dictionnaire.
Les objets mutables peuvent changer de valeur sans que leur identité ne soit modifiée.
Lorsqu'un objet contient des références à d'autres objets, on les appelle des conteneurs (listes, tuples, dictionnaires, etc.). Il est important de noter qu'un conteneur immuable peut contenir des références à des objets mutables (comme un tuple contenant une liste). L'objet reste cependant immuable car son identité ne change pas.
3. Objets hashables
Un objet est hashable si sa valeur de hachage reste constante pendant tout son cycle de vie (implémente la méthode __hash__()) et s'il peut être comparé à d'autres objets (implémente la méthode __eq__()).
La plupart des objets immuables intégrés en Python sont hashables. Les conteneurs immuables (tuples, frozenset) ne sont hashables que si tous les objets qu'ils référencent le sont. Les conteneurs mutables ne sont jamais hashables. Les classes définies par l'utilisateur sont hashables par défaut.
4. Copie superficielle et profonde
Avant d'aborder la copie d'objets, examinons les opérations d'affectation en Python pour mieux comprendre le processus de copie.
5. Opérations d'affectation
Lorsque l'expression à droite de l'opérateur d'affectation est une simple expression :
def operations_simples():
# objets immuables
# entier
nombre_a = 42
nombre_b = 42
print('----- entier')
print("id de a:{} , id de b: {}".format(id(nombre_a), id(nombre_b)))
print(nombre_a == nombre_b) # True
print(nombre_a is nombre_b) # True
# chaîne
chaine_a = 'python'
chaine_b = 'python'
print('----- chaîne')
print("id de a:{} , id de b: {}".format(id(chaine_a), id(chaine_b)))
print(chaine_a == chaine_b) # True
print(chaine_a is chaine_b) # True
# tuple
tuple_a = (1, 2, 3)
tuple_b = (1, 2, 3)
print('----- tuple')
print("id de a:{} , id de b: {}".format(id(tuple_a), id(tuple_b)))
print(tuple_a == tuple_b) # True
print(tuple_a is tuple_b) # False
# objets mutables
# ensemble
ensemble_a = {1, 2, 3}
ensemble_b = {1, 2, 3}
print('----- ensemble')
print("id de a:{} , id de b: {}".format(id(ensemble_a), id(ensemble_b)))
print(ensemble_a == ensemble_b) # True
print(ensemble_a is ensemble_b) # False
# liste
liste_a = [1, 2, 3]
liste_b = [1, 2, 3]
print('----- liste')
print("id de a:{} , id de b: {}".format(id(liste_a), id(liste_b)))
print(liste_a == liste_b) # True
print(liste_a is liste_b) # False
# dictionnaire
dict_a = {"nom": "alice", "age": "30"}
dict_b = {"nom": "alice", "age": "30"}
print('----- dictionnaire')
print("id de a:{} , id de b: {}".format(id(dict_a), id(dict_b)))
print(dict_a == dict_b) # True
print(dict_a is dict_b) # False
Dans CPython, id() reflète l'adresse mémoire de l'objet. On observe que pour les nombres et les chaînes (objets immuables), CPython effectue une optimisation : lorsque le contenu est identique, il fait pointer les variables vers la même adresse mémoire, permettant ainsi la réutilisation.
Cependant, Python n'applique pas cette optimisation à tous les objets mutables, car cela impliquerait un coût d'exécution. Pour les objets en mémoire, il faut d'abord les rechercher (ce qui prend du temps). Pour les nombres et les chaînes, cette recherche est simple, justifiant l'optimisation. Pour les autres types d'objets, même si le contenu est identique, une nouvelle zone mémoire est entièrement créée.
6. Lorsque l'expression à droite de l'opérateur d'affectation est une varible existante :
def operation_affectation():
# objets immuables
# entier
nombre_a = 42
nombre_b = nombre_a
print('----- entier')
print("id de a:{} , id de b: {}".format(id(nombre_a), id(nombre_b)))
print(nombre_a == nombre_b) # True
print(nombre_a is nombre_b) # True
# chaîne
chaine_a = 'python'
chaine_b = chaine_a
print('----- chaîne')
print("id de a:{} , id de b: {}".format(id(chaine_a), id(chaine_b)))
print(chaine_a == chaine_b) # True
print(chaine_a is chaine_b) # True
# tuple
tuple_a = (1, 2, 3)
tuple_b = tuple_a
print('----- tuple')
print("id de a:{} , id de b: {}".format(id(tuple_a), id(tuple_b)))
print(tuple_a == tuple_b) # True
print(tuple_a is tuple_b) # True
# objets mutables
# ensemble
ensemble_a = {1, 2, 3}
ensemble_b = ensemble_a
print('----- ensemble')
print("id de a:{} , id de b: {}".format(id(ensemble_a), id(ensemble_b)))
print(ensemble_a == ensemble_b) # True
print(ensemble_a is ensemble_b) # True
# liste
liste_a = [1, 2, 3]
liste_b = liste_a
print('----- liste')
print("id de a:{} , id de b: {}".format(id(liste_a), id(liste_b)))
print(liste_a == liste_b) # True
print(liste_a is liste_b) # True
# dictionnaire
dict_a = {"nom": "alice", "age": "30"}
dict_b = dict_a
print('----- dictionnaire')
print("id de a:{} , id de b: {}".format(id(dict_a), id(dict_b)))
print(dict_a == dict_b) # True
print(dict_a is dict_b) # True
Lorsque l'expression à droite de l'opérateur d'affectation est un objet Python existant, quel que soit son type, aucun nouvel objet n'est créé en mémoire. On simplement déclare une nouvelle variable pointant vers l'objet déjà créé en mémoire, comme un alias.
>>> dict_a = {'cle': 'valeur'}
>>> dict_b = dict_a
>>> print("id de a:{} , id de b: {}".format(id(dict_a), id(dict_b)))
id de a:140355639151936 , id de b: 140355639151936
>>> dict_b = {}
>>> print("id de a:{} , id de b: {}".format(id(dict_a), id(dict_b)))
id de a:140355639151936 , id de b: 140355639922176
L'opération dict_b = dict_a fait pointer deux variables vers la même zone mémoire. Ainsi, leurs identités sont égales. Lorsqu'on réaffecte dict_b, seule la variable b pointe vers une nouvelle zone mémoire, sans affecter la référence de a. Comme les deux zones mémoire sont différentes, leurs identités ne sont pas égales.
7. Modification d'objets affectés
def modification_objets_affectes():
# objets immuables
# entier
nombre_a = 42
print("id de a:{}".format(id(nombre_a)))
nombre_b = nombre_a
nombre_a = nombre_a + 10
print('----- entier')
print("id de a:{} , id de b: {}".format(id(nombre_a), id(nombre_b)))
print(nombre_a == nombre_b) # False
print(nombre_a is nombre_b) # False
# objets mutables
# liste
liste_a = [1, 2, 3]
liste_b = liste_a
liste_a.append(4)
print('----- liste')
print("id de a:{} , id de b: {}".format(id(liste_a), id(liste_b)))
print(liste_a == liste_b) # True
print(liste_a is liste_b) # True
Lors de la modification d'un objet immuable, comme sa valeur ne peut pas changer, un nouvel espace mémoire est alloué pour stocker le contenu modifié. Pour l'opération nombre_a = 52 ci-dessus, lorsque l'on compare a et b, ils pointent vers des emplacements mémoire différents, donc a et b ne sont plus égaux. L'ancienne zone mémoire pointée par a n'est pas récupérée car b y pointe toujours. On constate que l'adresse mémoire pointée par b correspond à l'ancienne adresse de a.
Lors de la modification d'un objet mutable, comme plusieurs variables pointent vers la même adresse mémoire, la modification de la variable liste_a affecte également la variable liste_b.
En résumé :
- Plusieurs variables pointant vers un objet immuable : si l'une des variables est modifiée, les autres ne sont pas affectées car la variable modifiée pointe vers un nouvel objet créé.
- Plusieurs variables pointant vers un objet mutable : si l'une des variables modifie cet objet, toutes les variables pointant vers cet objet sont affectées.
8. Copie superficielle
La copie superficielle crée un nouvel objet qui contient des références aux éléments de l'original. Lorsqu'on copie des objets conteneurs, seule les références des éléments imbriqués sont copiées.
import copy
def copie_superficielle():
# objets immuables
# entier
nombre_a = 42
nombre_b = copy.copy(nombre_a)
print('----- entier')
print("id de a:{} , id de b: {}".format(id(nombre_a), id(nombre_b)))
print(nombre_a == nombre_b) # True
print(nombre_a is nombre_b) # True
# chaîne
chaine_a = 'python'
chaine_b = copy.copy(chaine_a)
print('----- chaîne')
print("id de a:{} , id de b: {}".format(id(chaine_a), id(chaine_b)))
print(chaine_a == chaine_b) # True
print(chaine_a is chaine_b) # True
# tuple
tuple_a = (1, 2, 3)
# Trois méthodes de copie superficielle
# tuple_b = tuple_a[:]
# tuple_b = tuple(tuple_a)
tuple_b = copy.copy(tuple_a)
print('----- tuple')
print("id de a:{} , id de b: {}".format(id(tuple_a), id(tuple_b)))
print(tuple_a == tuple_b) # True
print(tuple_a is tuple_b) # True
# objets mutables
# ensemble
ensemble_a = {1, 2, 3}
# Deux méthodes de copie superficielle
# ensemble_b = set(ensemble_a)
ensemble_b = copy.copy(ensemble_a)
print('----- ensemble')
print("id de a:{} , id de b: {}".format(id(ensemble_a), id(ensemble_b)))
print(ensemble_a == ensemble_b) # True
print(ensemble_a is ensemble_b) # False
# liste
liste_a = [1, 2, 3]
# Trois méthodes de copie superficielle
# liste_b = liste_a[:]
# liste_b = list(liste_a)
liste_b = copy.copy(liste_a)
print('----- liste')
print("id de a:{} , id de b: {}".format(id(liste_a), id(liste_b)))
print(liste_a == liste_b) # True
print(liste_a is liste_b) # False
# dictionnaire
dict_a = {"nom": "alice", "age": "30"}
# Deux méthodes de copie superficielle
# dict_b = dict(dict_a)
dict_b = copy.copy(dict_a)
print('----- dictionnaire')
print("id de a:{} , id de b: {}".format(id(dict_a), id(dict_b)))
print(dict_a == dict_b) # True
print(dict_a is dict_b) # False
Il est important de noter que pour les chaînes et les nombres, comme mentionné précédemment, CPython effectue une optimisation permettant à différentes variables de pointer vers la même adresse mémoire, donc leurs identités sont égales.
Pour les tuples (éléments immuables), lors d'une copie superficielle, aucun nouvel espace mémoire n'est créé ; seule une référence au tuple original est retournée.
Pour les autres objets mutables, après une copie superficielle, un nouvel espace mémoire est créé, contenant des références aux éléments de l'original.
La copie superficielle, comme son nom l'indique, pose problème lors de la copie d'éléments mutables imbriqués :
def modification_apres_copie_superficielle():
# liste
liste_a = [1, 2, 3, [4, 5, 6]]
liste_b = copy.copy(liste_a)
liste_a[0] = 10
liste_a[3].append(7)
print('----- liste')
print("liste_a:{} , liste_b: {}".format(liste_a, liste_b))
print("id de a:{} , id de b: {}".format(id(liste_a), id(liste_b)))
print(liste_a == liste_b) # False
print(liste_a is liste_b) # False
Voici une explication graphique de la copie de liste ci-dessus :
Après l'opération de copie superficielle :
72a62de32295d24a41f15aecb2f65eac
Après la copie superficielle de liste_b, un nouvel objet est créé. L'élément liste_a[0] de ce nouvel objet pointe vers la valeur 1.
Lors de la modification de liste_a :
f82e60e92a6c04ab94146965ee4e985e
Lorsqu'on exécute liste_a[0] = 10, comme liste_a[0] est de type nombre, un nouvel espace est créé pour stocker la nouvelle valeur 10. Le nouvel élément liste_b[0] n'est pas affecté et continue de pointer vers l'ancienne zone mémoire.
Lors de la modification de liste_a[3], comme liste_a[3] dans le nouvel objet n'a pas créé de nouvelle liste mais pointe simplement vers l'ancienne liste_a[3], la modification de liste_a[3] affecte également liste_b[3].
9. Copie profonde
Pour une copie profonde, non seulement un nouvel objet est créé, mais tous les éléments imbriqués de l'original sont également parcourus de manière récursive pour créer de nouvelles copies.
def copie_profonde_modification():
# liste
liste_a = [1, 2, 3, [4, 5, 6]]
liste_b = copy.deepcopy(liste_a)
liste_a[0] = 10
liste_a[3].append(7)
print('----- liste')
print("liste_a:{} , liste_b: {}".format(liste_a, liste_b))
print("id de a:{} , id de b: {}".format(id(liste_a), id(liste_b)))
print(liste_a == liste_b) # False
print(liste_a is liste_b) # False
Voici le processus graphique correspondant :
Après l'opération de copie profonde :
4a050f660c31f509ecde59eb8b589c38
Lors de la modification de liste_a :
cd944895e4e8cb41278113baa1906af9
Ici, liste_a et liste_b sont complètement deux objets distincts.
Résumé
Dans cet article, nous avons principalement abordé les objets en Python et leur processus de copie, avec les points importants suivants :
- En Python, les variables n'ont pas de type et peuvent être considérées comme des pointeurs qui référencent des objets via des références. Les variables peuvent être supprimées, mais pas les objets.
- Un objet Python créé possède les attributs identity, type et value.
- La différence entre mutable et immutable réside dans la possibilité de changer la valeur d'un objet pendant son cycle de vie.
- Lors de la modification d'un objet mutable, toutes les variables le référençant sont affectées. Lors de la modification d'un objet immuable, les autres variables le référençant ne sont pas affectées.
- La plupart des objets immuables sont hashables, mais il faut considérer les cas particuliers des conteneurs immuables.
- La copie superficielle crée un nouvel espace mémoire (objet) dont les éléments internes sont des références à l'original, présentant certains risques avec les objets mutables.
- La copie profonde crée non seulement un nouvel espace mémoire (objet) mais aussi récursivement des copies de tous les objets imbriqués, bien que cela puisse entraîner des problèmes d'efficacité.