web-dev-qa-db-fra.com

Comment les dictionnaires intégrés de Python sont-ils implémentés?

Est-ce que quelqu'un sait comment le type de dictionnaire intégré pour python est implémenté? Je pense que c'est une sorte de table de hachage, mais je n'ai pas été en mesure de trouver une réponse définitive.

251
ricree

Voici tout ce qui concerne Python dict que j'ai pu assembler (probablement plus que quiconque aimerait savoir; mais la réponse est exhaustive).

  • Les dictionnaires Python sont implémentés sous forme de tables de hachage .
  • Les tables de hachage doivent permettre les collisions de hachage c'est-à-dire que même si deux clés distinctes ont la même valeur de hachage, l'implémentation de la table doit avoir une stratégie pour insérer et récupérer la clé. et les paires de valeur sans ambiguïté.
  • Python dict utilise un adressage ouvert pour résoudre les collisions de hachage (expliqué ci-dessous) (voir dictobject.c: 296-297 ).
  • La table de hachage Python est juste un bloc de mémoire contigu (un peu comme un tableau, vous pouvez donc effectuer une recherche O(1) par index).
  • Chaque emplacement de la table peut stocker une et une seule entrée. Ceci est important.
  • Chaque entrée de la table est en fait une combinaison des trois valeurs: <hash, key, value> . Ceci est implémenté en tant que structure C (voir dictobject.h: 51-56 ).
  • La figure ci-dessous est une représentation logique d'une table de hachage Python. Dans la figure ci-dessous, 0, 1, ..., i, ... À gauche sont des index des fentes . dans la table de hachage (ils sont juste à des fins d'illustration et ne sont pas stockés avec la table évidemment!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • Lorsqu'un nouveau dict est initialisé, il commence par 8 slots. (voir dictobject.h: 49 )

  • Lors de l'ajout d'entrées à la table, nous commençons par un emplacement, i, basé sur le hachage de la clé. CPython utilise initialement i = hash(key) & mask (où mask = PyDictMINSIZE - 1, Mais ce n'est pas vraiment important). Notez simplement que l'emplacement initial, i, qui est coché, dépend du hash de la clé.
  • Si cet emplacement est vide, l'entrée est ajoutée à l'emplacement (par entrée, je veux dire, <hash|key|value>). Mais si cette fente est occupée!? Probablement parce qu'une autre entrée a le même hash (collision de hash!)
  • Si l'emplacement est occupé, CPython (et même PyPy) compare le hachage ET la clé (par comparaison, je veux dire ==is comparaison) de l'entrée dans l'emplacement contre le hachage et la clé de l'entrée actuelle à insérer ( dictobject.c: 337,344-345 ) respectivement. Si les deux, il pense que l'entrée existe déjà, abandonne et passe à l'entrée suivante à insérer. Si le hachage ou la clé ne correspondent pas, le test commence .
  • Le fait de sonder signifie simplement qu’il cherche les emplacements par emplacement pour trouver un emplacement vide. Techniquement, nous pourrions simplement aller un par un, i+1, i+2, ... Et utiliser le premier disponible (le sondage linéaire). Mais pour des raisons bien expliquées dans les commentaires (voir dictobject.c: 33-126 ), CPython utilise un sondage aléatoire . Dans le sondage aléatoire, la tranche suivante est sélectionnée dans un ordre pseudo-aléatoire. L'entrée est ajoutée au premier emplacement vide. Pour cette discussion, l’algorithme utilisé pour choisir l’emplacement suivant n’est pas vraiment important (voir dictobject.c: 33-126 pour l’algorithme de vérification). Ce qui est important, c'est que les slots soient sondés jusqu'à ce que le premier slot vide soit trouvé.
  • La même chose se produit pour les recherches, commence juste par l'emplacement initial i (où i dépend du hachage de la clé). Si le hachage et la clé ne correspondent pas à l'entrée de la fente, la détection commence jusqu'à ce qu'elle trouve une fente avec une correspondance. Si tous les emplacements sont épuisés, un échec est signalé.
  • En passant, le dict sera redimensionné s’il est plein aux deux tiers. Cela évite de ralentir les recherches. (voir dictobject.h: 64-65 )

NOTE: J'ai fait la recherche sur Python Implémentation de dict en réponse à ma propre question à propos de la façon dont plusieurs entrées dans un dict peuvent avoir les mêmes valeurs de hachage. J'ai posté une édition légèrement modifiée. version de la réponse ici parce que toute la recherche est très pertinente pour cette question aussi.

419
Praveen Gollakota

Les dictionnaires Python utilisent adressage ouvert ( référence dans Beautiful code )

NB! Adressage ouvert, alias hachage fermé devrait, comme indiqué dans Wikipedia , ne pas confondre avec son contraire open hashing !

L'adressage ouvert signifie que le dict utilise des emplacements de tableau et que, lorsque la position principale d'un objet est prise dans le dict, la tache de l'objet est recherchée à un index différent dans le même tableau, à l'aide d'un schéma de "perturbation", dans lequel la valeur de hachage de l'objet joue un rôle. .

44
u0b34a0f6ae

Comment les dictionnaires intégrés de Python sont-ils implémentés?

Voici le cours court:

  • Ce sont des tables de hachage.
  • Une nouvelle procédure/algorithme, à partir de Python 3.6, les rend
    • ordonné par insertion de clé, et
    • prendre moins de place,
    • pratiquement sans frais en performance.
  • Une autre optimisation permet de gagner de la place lorsque les personnages partagent des clés (dans certains cas).

L'aspect ordonné est non officiel à partir de Python 3.6, mais officiel dans Python 3,7 ).

Les dictionnaires de Python sont des tables de hachage

Pendant longtemps, cela a fonctionné exactement comme ça. Python préalloue 8 lignes vides et utilise le hachage pour déterminer où coller la paire clé-valeur. Par exemple, si le hachage de la clé se termine en 001, il le restera dans le 1 index (comme dans l'exemple ci-dessous.)

     hash         key    value
     null        null    null
...010001    ffeb678c    633241c4 # addresses of the keys and values
     null        null    null
      ...         ...    ...

Chaque ligne occupe 24 octets sur une architecture 64 bits et 12 sur 32 bits. (Notez que les en-têtes de colonne ne sont que des étiquettes - elles n'existent pas en mémoire.)

Si le hachage se termine de la même manière que le hachage d'une clé préexistante, il s'agit d'une collision. La paire clé-valeur est alors collée à un emplacement différent.

Une fois que 5 valeurs-clés sont stockées, lors de l'ajout d'une autre paire clé-valeur, la probabilité de collisions de hachage est trop grande, de sorte que la taille du dictionnaire est doublée. Dans un processus 64 bits, avant le redimensionnement, nous avons 72 octets vides et après, nous perdons 240 octets en raison des 10 lignes vides.

Cela prend beaucoup de place, mais le temps de recherche est relativement constant. L'algorithme de comparaison de clé consiste à calculer le hachage, à accéder à l'emplacement attendu, à comparer l'identifiant de la clé. S'ils sont identiques, ils sont égaux. Si ce n'est pas le cas, comparez les valeurs de hachage, si elles sont et non identiques, elles ne sont pas égales. Sinon, nous comparons enfin les clés pour l’égalité et, si elles sont égales, renvoyons la valeur. La comparaison finale pour l'égalité peut être assez lente, mais les vérifications antérieures raccourcissent généralement la comparaison finale, rendant les recherches très rapides.

(Les collisions ralentissent les choses et un attaquant pourrait théoriquement utiliser des collisions de hachage pour réaliser une attaque par déni de service. Nous avons donc randomisé la fonction de hachage de sorte qu'elle calcule un hachage différent pour chaque nouveau processus Python. )

L'espace perdu décrit ci-dessus nous a amenés à modifier la mise en œuvre des dictionnaires, avec une nouvelle fonctionnalité intéressante (bien que non officielle) selon laquelle les dictionnaires sont maintenant commandés (par insertion).

Les nouvelles tables de hachage compactes

Nous commençons plutôt par pré-allouer un tableau pour l'index de l'insertion.

Puisque notre première paire clé-valeur va dans le deuxième emplacement, nous indexons comme ceci:

[null, 0, null, null, null, null, null, null]

Et notre table est simplement renseignée par ordre d'insertion:

     hash         key    value
...010001    ffeb678c    633241c4 
      ...         ...    ...

Ainsi, lorsque nous recherchons une clé, nous utilisons le hachage pour vérifier la position attendue (dans ce cas, nous passons directement à l'index 1 du tableau), puis à cet index dans la table de hachage (par exemple l'index 0 ), vérifiez que les clés sont égales (en utilisant le même algorithme que celui décrit précédemment) et, dans l’affirmative, renvoyez la valeur.

Nous conservons un temps de recherche constant, avec des pertes de vitesse mineures dans certains cas et des gains dans d’autres, avec l’avantage de gagner beaucoup d’espace par rapport à la mise en œuvre préexistante. Le seul espace gaspillé sont les octets nuls du tableau d'index.

Raymond Hettinger a introduit ceci à python-dev en décembre 2012. Il est finalement entré dans CPython en Python 3.6 . La commande par insertion est toujours considérée comme un détail d'implémentation permettant aux autres implémentations de Python une chance de se rattraper.

Clés partagées

Une autre optimisation pour économiser de l'espace est une implémentation qui partage les clés. Ainsi, au lieu d'avoir des dictionnaires redondants qui occupent tout cet espace, nous avons des dictionnaires qui réutilisent les clés partagées et leurs hachages. Vous pouvez penser comme ça:

     hash         key    dict_0    dict_1    dict_2...
...010001    ffeb678c    633241c4  fffad420  ...
      ...         ...    ...       ...       ...

Pour une machine 64 bits, cela pourrait économiser jusqu'à 16 octets par clé par dictionnaire supplémentaire.

Clés partagées pour objets personnalisés et alternatives

Ces clés à clé partagée sont destinées à être utilisées pour les objets personnalisés '__dict__. Pour avoir ce comportement, je pense que vous devez finir de remplir votre __dict__ avant d'instancier votre prochain objet ( voir PEP 412 ). Cela signifie que vous devez attribuer tous vos attributs dans le __init__ ou __new__, sinon vous pourriez ne pas économiser votre espace.

Cependant, si vous connaissez tous vos attributs au moment où votre __init__ est exécuté, vous pouvez également fournir __slots__ pour votre objet, et garantissez que __dict__ n'est pas créé du tout (s'il n'est pas disponible chez les parents), ni même permet __dict__ mais garantissez que vos attributs prévus sont quand même stockés dans des slots. Pour plus sur __slots__, voir ma réponse ici .

Voir également:

42
Aaron Hall