L'affectation chaînée en Python, comme a = b = 10, est plus qu'un simple sucre syntaxique. Sa logique d'exécution sous-jacente diffère fondamentalement de celle observée dans des langages comme C. Plutôt qu'une simple "transmission de valeur", elle représente un "partage de références" où plusieurs noms sont liés au même objet. Cet article décortique ce mécanisme en s'appuyant sur le modèle objet et le code source de CPython.
Ordre d'exécution : de droite à gauche pour les expressions, basé sur la complexité des cibles
Contrairement à une idée reçue, l'affectation chaînée en Python ne s'exécute pas strictement de gauche à droite. La première étape consiste toujours à évaluer l'expression la plus à droite pour obtenir un objet unique. Ensuite, les noms (ou cibles d'affectation) sont liés à cet objet. L'ordre précis de ces liaisons dépend de la nature des cibles.
1. Évaluation de l'expression la plus à droite
Dans a = b = 10, l'expression 10 est d'abord évaluée. Python utilise le littéral entier 10 pour obtenir une référence à un objet entier préexistant dans une "petite piscine d'entiers" (small integer pool) grâce à la fonction PyLong_FromLong(10). Cet objet unique servira de cible à toutes les liaisons suivantes.
2. Liaison des noms : Priorité aux cibles simples
Une fois l'objet obtenu, les liaisons s'effectuent. Si toutes les cibles sont des variables simples (comme dans a = b = 10), l'ordre est de droite à gauche : d'abord b est lié à l'objet 10, puis a est lié au même objet 10. Cela est visible dans la séquence d'opcodes :
import dis
def chain_assignment_simple():
a = b = 10
dis.dis(chain_assignment_simple)
La sortie pertinente montre :
3 0 LOAD_CONST 1 (10) # Charge l'objet 10
2 DUP_TOP # Duplique la référence sur la pile
4 STORE_FAST 1 (b) # Lie 'b' à cet objet (liaison la plus à droite)
6 STORE_FAST 0 (a) # Lie 'a' au même objet
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
DUP_TOP crée une seconde référence à l'objet 10 sur la pile avant les affectations STORE_FAST.
3. Liaison des noms avec cibles complexes
Lorsque des cibles complexes (comme des éléments de liste ou des attributs d'objet) sont impliquées, l'ordre change. L'expression la plus à droite est toujours évaluée en premier. Ensuite, les liaisons se font en traitant d'abord les cibles simples (variables autonomes), puis les cibles complexes qui nécesssitent une évaluation supplémentaire.
Par exemple, dans i = ls1[i] = 2 :
2est chargé et dupliqué.- La première liaison est pour la cible simple
i. La variableiest mise à jour avec la valeur2. - Ensuite, la cible complexe
ls1[i]est traitée. Python chargels1, puis charge la valeur actuelle dei(qui est maintenant2), et enfin, effectue l'affectationSTORE_SUBSCRpour lier l'objet2à l'élément d'indice2de la listels1.
Réutilisation des objets : la petite piscine d'entiers
La raison pour laquelle a et b dans a = b = 10 pointent vers le même objet est due à l'optimisation de CPython pour les petits entiers. Lors de l'évaluation de 10, PyLong_FromLong(10) recherche cet entier dans une plage prédéfinie (généralement de -5 à 256). S'il existe, la référence existante est réutilisée au lieu de créer un nouvel objet.
Cela garantit que id(a) == id(b) pour a = b = 10, car les deux noms sont liés au même objet entier préexistant.
Comptage de références : Gestion dynamique de la vie des objets
Chaque objet Python possède un compteur de références (ob_refcnt) qui suit le nombre de noms ou de structures qui le référencent. Lorsqu'une nouvelle référence est créée (par une affectation, l'ajout à une liste, etc.), le compteur augmente. Lorsqu'une référence est détruite (une varible sort de portée, est réaffectée, etc.), le compteur diminue. Un objet est collecté par le ramasse-miettes lorsque son compteur de références atteint zéro.
Dans a = b = 10 :
- L'objet
10est initialisé avec une référence interne par l'interpréteur (le compteur de base est supérieur à 1). LOAD_CONST 10acquiert une référence temporaire pour la pile, incrémentant le compteur.STORE_FAST blie la variablebà l'objet, incrémentant le compteur.STORE_FAST alie la variableaau même objet, incrémentant encore le compteur.
Ces incrémentations garantissent que l'objet 10 reste en mémoire tant que a ou b (ou d'autres références potentielles) existent.
Comparaison inter-langages : Python vs C
La différence fondamentale réside dans le modèle sous-jacent :
- Python utilise un modèle de "liaison nom-objet". L'affectation crée ou met à jour des liaisons entre des noms (variables) et des objets en mémoire. Les objets ont un cycle de vie géré par le comptage de références.
- C utilise un modèle de "variable-cellule mémoire". L'affectation copie des valeurs entre des emplacements mémoire distincts. Les variables sont des zones mémoire préallouées, et la gestion de la mémoire est largement manuelle.
Ainsi, int a = b = 10; en C signifie que la valeur 10 est écrite dans la mémoire allouée pour b, puis la valeur de la mémoire de b est copiée dans la mémoire allouée pour a. Les deux sont des emplacements mémoire indépendants.
Si l'on considère l'affectation chaînée d'un objet mutable, comme a = b = [] en Python, modifier a (par exemple, a.append(1)) affectera également b, car les deux noms pointent vers la même liste partagée. Ceci contraste avec C où des structures de données indépendantes seraient créées.
En résumé, l'affectation chaînée en Python est un mécanisme puissant qui repose sur l'évaluation d'expressions uniques et le partage de références, géré dynamiquement par le comptage de références, et dont l'ordre d'exécution est adapté à la complexité des cibles d'affectation.