Mécanismes sous-jacents de l'affectation chaînée en Python : des liaisons de noms au comptage de références

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 :

  1. 2 est chargé et dupliqué.
  2. La première liaison est pour la cible simple i. La variable i est mise à jour avec la valeur 2.
  3. Ensuite, la cible complexe ls1[i] est traitée. Python charge ls1, puis charge la valeur actuelle de i (qui est maintenant 2), et enfin, effectue l'affectation STORE_SUBSCR pour lier l'objet 2 à l'élément d'indice 2 de la liste ls1.

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 :

  1. L'objet 10 est initialisé avec une référence interne par l'interpréteur (le compteur de base est supérieur à 1).
  2. LOAD_CONST 10 acquiert une référence temporaire pour la pile, incrémentant le compteur.
  3. STORE_FAST b lie la variable b à l'objet, incrémentant le compteur.
  4. STORE_FAST a lie la variable a au 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.

Étiquettes: Python CPython affectation liaison de noms comptage de références

Publié le 12 juillet à 19h12