Skip to content

Instantly share code, notes, and snippets.

@Dettorer
Last active October 23, 2024 13:09
Show Gist options
  • Save Dettorer/7ff273a29eae111f7fdd7e5e172e3397 to your computer and use it in GitHub Desktop.
Save Dettorer/7ff273a29eae111f7fdd7e5e172e3397 to your computer and use it in GitHub Desktop.
Énigmes Python pour les étudiants d'EPITA

Énigme 1

Sujet

Crédit : seirl

Expliquer le comportement suivant :

>>> def increment(l=[1, 2, 3]):
...     for i in range(len(l)):
...         l[i] += 1
...     return l
...
>>> print(increment())
[2, 3, 4]
>>> print(increment([0, 0, 0]))
[1, 1, 1]
>>> print(increment())
[3, 4, 5]

Explication

Spoiler !

Les paramètres par défaut d'une fonction ne sont évalués (initialisés) qu'une seule fois : à la définition de celle-ci, leur valeur est ensuite gardée en mémoire pour les appels suivants. Puisqu'ils ne sont pas réinitialisés à chaque appel, si un paramètre par défaut a une valeur "mutable" (modifiable), chaque modification apporté à cette valeur se verra dans les appels suivants.

Le comportement de ces paramètres est défini plus en détail ici : https://docs.python.org/3/tutorial/controlflow.html#default-argument-values

Si l'on souhaite analyser plus loin et aller regarder comment fonctionne l'intérpréteur en interne, on se rend compte que chaque fonction est en fait enregistré en tant qu'objet dans le programme (tout est objet en Python), cet objet est créé au moment de la définition de la fonction (quand l'intérpréteur découvre la ligne def […]) et les paramètres par défaut sont tout simplement stockés comme attributs de cet objet.

Plus de détails sur d'autres attributs de fonction se trouvent dans la PEP 232. Les PEP (pour "Python Enhancement Proposal") sont le format choisi par la communauté Python pour proposer, débattre et valider de nouvelles fonctionnalités du langage, diverses modification ou même philosophies de développement (voir par exemple la PEP 8).

Énigme 2

Sujet

Crédit : seirl

Expliquer le comportement suivant :

>>> for i in range(250,260): print(id(i))
9086976
9087008
9087040
9087072
9087104
9087136
9087168
139749663505296
139749662954736
139749663505296

Explication

Soiler !

La fonction id() donne "l'identité" d'un objet en Python, l'identité d'un objet est un numéro unique qui permet de le différentier des autres objets présents en mémoire, cette identité peut donc être différente pour deux objets qui sont pourtant égaux. Ce qui donne par exemple le comportement suivant :

>>> a = ['Votai', 'Test.']
>>> b = ['Votai', 'Test.']
>>> a == b
True
>>> id(a) == id(b)
False
>>>

On apprend dans la documentation de la fonction id() que, dans l'implémentation de référence (CPython), le numéro choisi pour représenter l'identité d'un objet est tout simplement son adresse en mémoire (ce qui satisfait très facilement le but de id() puisque deux objets distincts ne peuvent pas avoir la même adresse en mémoire).

Revenons à l'énigme, sachant que id() renvoie l'adresse d'un objet à mémoire, on peut donc en conclure que les entiers avant 256 (inclus) se trouvent dans un même "bloc" contigue de mémoire, alors qu'après 257 (inclus) ils sont à un endroit totalement différent de la mémoire, pourquoi donc ?

Pour citer la documentation de l'API C :

The current implementation keeps an array of integer objects for all integers between -5 and 256, when you create an int in that range you actually just get back a reference to the existing object.

Cela signifie qu'au lancement de tout programme, l'interpréteur commence par créer un objet pour chaque entier entre -5 et 256, "au cas où" (ces nombres étant très souvent utilisés, les créer tout de suite, tous en même temps, et une seule fois pour toute la durée du programme, représente souvent un bon gain de temps), ces objets sont donc stockés très proches les uns des autres en mémoire. Ensuite l'interpréteur créé d'autres objets qui seront utiles pour le programme exécuté, puis celui-ci commence à s'exécuter, à créer des objets lui-même (vous savez par exemple maintenant que chaque fonction créé un nouvel objet au moment de sa définition, tout étant objet en Python).

Enfin, on arrive à notre boucle, on demande à l'interpréteur d'afficher l'identité (donc l'adresse) des entiers entre 250 et 260. Ceux jusqu'à 256 (inclus) existant déjà, c'est l'adresse des objets créés au lancement de l'interpréteur qui s'affiche. Puis, à partir de 257, l'interpréteur créé un nouvel objet à la première adresse disponible pour représenter chaque entier, cette adresse est donc très éloignée des adresses des premiers entiers.

Les personnes souhaitant aller plus loin pourront même remarquer que, dans le code de CPython, le tableau servant à stocker ces entiers entre -5 et 256 est static, cela signifie que (sous Linux) son contenu se trouve dans la section .data de l'exécutable (rappel : ici l'exécutable, c'est l'interpréteur). Cette section sera en général chargée à un endroit de la mémoire bien différent du "tas", ce dernier étant la zone mémoire où les allocations dynamiques sont faite, et où les autres entiers seront donc créés quand on aura besoin d'eux.

C'est d'ailleurs ce dernier détail qui est à l'origine de la différence qu'on observe dans la taille du "saut" d'adresses entre différents systèmes (voir la discussion ayant ammené à ce message). Comme l'a fait remarquer Mareo, le résultat de l'énigme est obtenu avec un interpréteur qui n'a pas été compilé avec -fPIC, en utilisant cette option le saut d'adresse est toujours là mais beaucoup moins visible. Il s'agit d'une option de compilateur permettant de générer du Position Independent Code. Sans aller dans les détails, cette option a pour effet secondaire de changer l'emplacement où le segment .data est chargé en mémoire lors du chargement de l'exécutable. Pour plus de détails sur ce mécanisme, un bon point de départ est la page wikipedia sur PIC.

Énigme 3

Sujet

Crédit : seirl

Expliquer le comportement suivant :

>>> [1, 2, 3] is []
False
>>> id([1, 2, 3]) == id([])
True

Explication

(TL;DR à la fin)

Soiler !

En regardant la documentation du mot-clé is on constate que l'opération qu'il décrit est simple : a is b est vrai si a et b sont les mêmes objets ; on nous dit d'ailleurs que l'identité d'un objet est donnée par la fonction id(), vérifier que deux noms sont le même objet revient donc à regarder si id() renvoie la même valeur pour les deux. Dans l'énigme, la première ligne vérifie donc que [1, 2, 3] et [] sont les mêmes objets, alors que la deuxième vérifie s'ils ont la même identité, on devrait donc obtenir la même chose (False, les deux listes ne sont même pas égales, elles ne peuvent donc pas être "le même objet") mais ce n'est pas le cas, pourquoi ?

is vérifiant que les id() des deux objets sont égaux, le problème ne vient pas du fait que l'on utilise id() et == dans la deuxième ligne, mais forcément de comment on les utilise, c'est là que la subtilité se joue. En Python (et dans la plupart des langages), tout opérateur binaire a besoin que ses deux opérandes soient évaluées et présentes en mémoire avant de faire son opération (en l'occurrence en Python, la plupart de ces opérateurs sont en fait des fonctions, et les opérandes des paramètres que l'on donne à ces fonctions).

Voici ce qui se passe, dans l'ordre, quand on évalue la première ligne :

  • Python constate que l'expression est le résultat d'un is, c'est un opérateur binaire donc il faut commencer par évaluer ses deux opérandes ;
  • Python évalue la première opérande : [1, 2, 3], c'est une liste de trois éléments, donc il créé un nouvel objet liste et lui ajoute les trois éléments demandés ; Python garde cette liste en mémoire pour plus tard (c'est une opérande dans une opération qu'il reste à évaluer) ;
  • Python évalue la deuxième opérande, c'est une liste vide, il créé donc un nouvel objet liste et la laisse vide ; Python garde cette liste en mémoire pour plus tard (même raison) ;
  • Python évalue l'opération is, les deux listes ont un id() différent (dans l'interpréteur de référence, CPython, id() donne simplement l'adresse de l'objet), le résultat de l'opération est donc faux ;
  • Python conclue que l'expression vaut False et il supprime les deux listes car elles ne sont plus utilisées.

C'est cette dernière partie qui ne se passe pas de la même façon dans la deuxième expression, voici ce qui se passe :

C'est cette dernière partie qui ne se passe pas de la même façon dans la deuxième expression, voici ce qui se passe :

  • Python constate que l'expression est le résultat d'un ==, c'est aussi un opérateur binaire, il faut donc évaluer ses deux opérandes avant d'évaluer l'opération elle-même ;
  • Python évalue la première opérande : id([1, 2, 3]), c'est un appel de fonction, il faut donc commencer par évaluer ses paramètres :
    • Python évalue le paramètre : [1, 2, 3], c'est une liste de trois éléments, il créé donc un nouvel objet pour la liste et y ajoute les trois éléments, il la garde en mémoire pour plus tard car cette liste est le paramètre d'une fonction ;
    • Python évalue l'appel à la fonction id(), celle-ci lui donne (dans CPython) l'adresse de l'objet précédent ;
  • la valeur de la première opérande est donc l'entier renvoyé par id(), Python garde cet entier en mémoire pour plus tard (car c'est une opérande d'une opération qu'il reste à évaluer) et il supprime la liste car elle n'est plus utilisée (la liste n'était qu'un paramètre de la fonction id() dont l'évaluation est terminée, inutile donc de la garder en mémoire) ;
  • Python connaît donc la première opérande (un entier), il évalue de la même façon la deuxième opérande : id([]), c'est un appel de fonction ;
    • Python évalue le paramètre : [], c'est une liste, il créé donc un nouvel objet liste et le garde en mémoire le temps d'évaluer la fonction dont elle est paramètre
    • Python évalue l'appel à id(), celle-ci lui donne l'adresse de l'objet précédent, et c'est là que tout se joue : la première liste ([1, 2, 3]) ayant été supprimée, la deuxième liste ([]) a été créé au premier emplacement mémoire disponible, qui se trouve être le même emplacement que celui où a été créé la premier, maintenant disponible. id() renvoie donc l'adresse de la deuxième liste : qui se trouve être la même que la première ;
  • Python connaît donc maintenant la valeur de la deuxième opérande de l'opération (un autre entier renvoyé par un autre appel à id()), il supprime la deuxième liste qui n'est maintenant plus utilisée (elle n'était que le paramètre d'un appel à une fonction qui est maintenant terminé) ;
  • Python évalue l'opération, c'est un ==, les deux opérandes étant effectivement égales, l'expression vaut True.

En résumé (TL;DR) : dans les deux cas on a une opération binaire et les deux opérandes doivent exister en mémoire simultanément pour son évaluation. Dans le premier cas les opérandes sont les listes elles-mêmes, elles existent donc simultanément en mémoire et ne peuvent avoir le même id() puisque leurs adresses sont forcément différentes. Dans le deuxième cas les opérandes sont les résultats des deux appels à id(), à savoir des entiers, ce sont donc ces deux entiers qui devront exister simultanément en mémoire, mais toutes les valeurs qui ont servi à les trouver peuvent être supprimées bien plus tôt. En l'occurrence, on créé une liste le temps d'évaluer le premier appel à id(), on sauvegarde le résultat et on supprime la liste, puis on en créé une nouvelle au même endroit pour évaluer le deuxième appel à id().

Dans cette deuxième expression, les deux listes sont bien deux objets différents, mais ils n'ont pas existé simultanément en mémoire, et la façon dont l'expression est construite fait qu'ils auront tous les deux étés créé au même endroit dans la mémoire, l'un puis l'autre.

Énigme 4

Sujet

Crédit : seirl

Expliquer le comportement suivant :

>>> 99999 is 99999
True
>>> a = 99999
>>> b = 99999
>>> a is b
False

dans un interpréteur intéractif.

Explication

Soiler !

Une piste souvent proposée ici, intuitive mais trompeuse, est de dire que a et b sont des pointeurs vers 99999, ce seraient donc deux objets différents qui pointent vers le même objet (ce dernier étant un entier). Ce n'est pas comme ça que Python fonctionne. Lorsque l'on fait une assignation en Python, on ne créé pas de nouvel objet qui pointe vers la valeur assignée, Python ne réfléchis même pas en terme de "variable" à proprement parler mais en terme de valeurs et de noms (une nuance qui peut aider à comprendre certains mécanismes du langage) : lorsque l'on fait a = 99999, nous ne faisons que donner un nom à la valeur 99999. Ainsi, l'expression a is b signifie "la valeur dont le nom est a est le même objet que la valeur dont le nom est b", on compare bien les valeurs directement, ici : les 99999. L'indice donné hier est d'ailleurs un contre-exemple à cette théorie :

>>> a = 99999; b = 99999; a is b
True

Ici on donne un nom à deux occurrences de 99999 de la même façon que tout à l'heure, les points-virgules ne nous servent qu'à séparer les instructions pour pouvoir les écrire sur une seule ligne. Pourtant, cette fois, a est bien le même objet que b.

Si le problème ne vient pas de l'assignation, alors le mystère s'épaissie. Pourquoi les deux premiers 99999 font-ils référence à un seul même objet, alors que les deux suivants non ? La seule différence entre le code de l'énigme et celui de l'indice est que nous utilisons des point-virgule et que tout se trouve sur une seule ligne. La première ligne de l'énigme n'utilise quant à elle pas de point-virgule, mais tout est aussi sur une ligne, ce qui nous échape doit donc manifestement ce jouer sur ce détail, mais qu'est-ce qui se passe ?

Le devguide[1] de Python nous apprend que l'implémentation de référence (CPython) évalue le code d'un programme en deux étapes : elle compile (traduit) le code donné vers un langage intermédiaire, puis elle interprète le code généré. Ce langage intermédiaire est appelé le bytecode Python ("bytecode" est un terme souvent utilisé pour les langages intermédiaire). Cette étape de compilation permet à l'interpréteur de réorganiser le code et d'appliquer quelques optimisations avant de l'exécuter réellement.

C'est là que tout se joue. Une des optimisations appliquée au moment de la compilation et avant l'interprétation consiste à détecter les occurrences multiples d'une même valeur constante. Si une constante est utilisée plusieurs fois dans le programme, pas besoin de créer un objet complet pour chaque occurrence, il suffit d'en créer un seul et de faire en sorte que chaque occurrence fasse référence à ce même objet (cela ne fonctionne que pour les constantes, qui ne changeront pas de valeur durant toute la vie du programme).

Cela explique pourquoi dans la première ligne les deux 99999 sont le même objet : CPython a détecté que l'on utilisait deux fois la même constante et n'en a donc créé en réalité qu'une seule. Mais pourquoi n'a-t-il pas réussi à optimiser les deux derniers ? Ce sont là aussi des constantes, les même que tout à l'heure qui plus est, a et b devraient techniquement pouvoir faire référence au même objet.

Le problème est que nous nous trouvons dans l'interpréteur intéractif. Dans cet interpréteur chaque ligne doit être évaluée immédiatement et complètement afin de nous afficher le résultat de l'évaluation. Ainsi, lorsqu'on arrive à la ligne b = 99999, la ligne précédente a déjà été entièrement compilée vers le bytecode et interprété, il est trop tard pour modifier le bytecode et faire en sorte que les deux occurrences fassent référence au même objet, CPython en créé donc un nouveau.

Pour finir, regardons le code de l'indice : tout se passe en une ligne, CPython a donc la possibilité d'analyser tout le code donné avant de commencer à traduire vers le bytecode, il peut donc se rendre compte que la valeur de a est une constante qui est aussi utilisée plus loin, et peut donc générer un bytecode où a et b font référence au même objet.

[1]: merci à Eliaz pour cette ressource que je ne connaissais pas !

Énigme 5

Sujet

Crédit : seirl

Écrire le code de l'énigme 4 dans un fichier comme ceci, l'exécuter et expliquer le résultat, notamment pourquoi il est différent de celui de l'énigme numéro 4 :

print(99999 is 99999)
a = 99999
b = 99999
print(a is b)

Explication

Soiler !

L'explication est assez simple une fois qu'on a compris celle de l'énigme précédente. Introduisons simplement deux nouveaux termes pertinents pour refléchir à ces questions : les "blocs de code" ("code block" en anglais) et les "unités de compilation" de CPython ("compilation unit" en anglais).

En Python un programme est formé d'un ou plusieurs blocs de code, un bloc peut être un module, une fonction, une classe, le contenu de l'option -c donnée à l'interpréteur, etc. Chaque bloc possède ses propres noms (les "variables"), qu'ils rendent disponible ou non aux autres blocs en fonction du contexte où ils se trouvent. Cette définition n'est pas propre à l'implémentation du langage mais fait bien parti de la spécification du langage lui-même, réfléchir en terme de bloc permet de fixer quelques règles sur le modèle d'exécution[1] d'un programe Python.

Comme nous l'avons vu dans l'énigme précédente, CPython (l'interpréteur de référence) compile le code qu'on lui donne vers un langage intermédiaire (un "bytecode") avant de l'exécuter, mais on a vu qu'il était parfois obligé de compiler le programme donné morceaux par morceaux (dans le cas de l'interpréteur intéractif : la plupart du temps ligne par ligne). Ces "morceaux" sont appelés des unités de compilation[2], chaque unité pouvant contenir un ou plusieurs blocs de code tels que définis par la spécification du langage. Dans l'interpréteur intéractif, une ligne de code est donc un "bloc" valide.

Le sujet de l'énigme de cette semaine était d'écrire le même code que précédemment, mais cette fois-ci dans un fichier, et de demander à l'interpréteur d'interpréter le contenu de ce fichier. Puisque nous donnons cette fois-ci les quatre lignes de code à l'interpréteur "d'un seul coup" (nous avons écrit les quatre lignes avant de lui demander d'interpréter), celui-ci peut lire la totalité du fichier avant de commencer son exécution. CPython considère alors tout le fichier comme une seule unité de compilation, c'est à dire qu'il va essayer de compiler la totalité du fichier vers son bytecode avant d'en commencer l'exécution. Puisque le 99999 auquel est associé a est une constante qui se trouve cette fois-ci dans la même unité de compilation que le 99999 associé à b, CPython peut donc appliquer l'optimisation qui consiste à ne créer qu'un seul objet 99999 et à l'associer à la fois à a et à b, d'où le fait que la dernière ligne de l'énigme affiche True. D'ailleurs, les quatre 99999 se trouvant maintenant dans la même unité de compilation, chacune des quatre utilisations de ce nombre font en fait référence à un seul et même objet pendant toute l'exécution du programme.

Pour aller un peu plus loin

Maintenant que l'on connaît un peu mieux le concept de bloc de code et d'unité de compilation, revenons dans l'interpréteur intéractif et entrons les lignes suivantes :

>>> a = 99999
>>> b = 99999
>>> a is b
False
>>> def in_a_function():
...     a = 99999
...     b = 99999
...     return a is b
...
>>> in_a_function()
True

Les trois premières lignes correspondent au sujet de l'énigme numéro 4, chaque ligne est une unité de compilation et aucune optimisation n'est possible entre elles, donc les deux 99999 font chacun référence à un objet distinct.

On créé ensuite une fonction qui effectue semble-t-il les mêmes opérations (assigner a puis b à une valeur 99999) puis retourne le résultat de l'expression a is b. Lorsque l'on appelle la fonction, on obtient True, ce qui signifie que CPython a cette fois eu la possibilité d'optimiser les deux utilisations de 99999. Nous venons de voir que cette optimisation n'était possible que lorsque les deux utilisations de 99999 se trouvent dans la même unité de compilation, quelle est donc précisément cette unité dans ce cas ?

Lorsque l'on définit une fonction dans l'interpréteur intéractif, celui-ci ne commence pas son évaluation (et donc sa compilation vers le bytecode) avant que nous l'ayons fini. Évaluer chaque ligne dès qu'on la tape n'est pas util puisqu'on ne voudra (et pourra) connaître le résultat de l'évaluation qu'au moment de l'appel de la fonction, l'interpréteur nous indique donc qu'il "attend" la suite en écrivant ... au lieu de >>> au début de la ligne suivante. C'est en écrivant une ligne vide que l'on indique à l'interpréteur que l'on a fini d'écrire la fonction et qu'il peut la compiler, d'où le fait qu'une ligne contenant uniquement ... soit présente avant le prochain >>>.

En "attendant" que nous ayons fini d'écrire la fonction avant de la compiler, CPython a pu considérer tout le corps de la fonction comme une seule unité de compilation, ce qui lui a permis d'optimiser les deux utilisations de 99999 qui y sont faites. D'une façon générale, dans l'interpréteur intéractif, vous pouvez considérer que toutes les lignes entre deux >>> sont contenues dans la même unité de compilation.

Énigme 6

Sujet

Crédit : supakeen

Expliquer la différence de temps d'évaluation de ces deux expressions :

>>> import timeit
>>>
>>> timeit.timeit("{i * (2**61 - 2) for i in range(10000)}", number=1)
0.00266260898206383
>>> timeit.timeit("{i * (2**61 - 1) for i in range(10000)}", number=1)
0.7670511459582485

Explication

Soiler !

Commençons par comprendre ce qui est écrit dans le code. Celui-ci commence par importer le module timeit qui permet de mesurer le temps d'évaluation d'une expression Python. Puis nous mesurons justement le temps d'évaluation de deux expressions, les deux étants très similaire à un chiffre près.

Regardons cette première expression : {i * (2**61 - 2) for i in range(10000)}. Il s'agit d'une set comprehension, c'est à dire un raccourcis syntaxique pour créer un set[^1] (un ensemble au sens mathématique : sans doublons et sans relation d'ordre). Étant donné que c'est un raccourcis, on peut l'exprimer sous sa forme "déroulée", l'expression est strictement équivalente au code suivant :

result = set()  # Création d'un set vide
for i in range(10000):
    result.add(i * (2**61 - 2))

On créé donc un set contenant les 10000 premiers nombres multiples de 2**61 -2.

Dans la deuxième expression, c'est quasiment la même chose qui est faite, à ceci prêt que ce sont les multiples de 2**61 - 1 que l'on ajoute, et la création du set est manifestement ici significativement plus longue (presque 300 fois plus long). La question est donc de savoir pourquoi les multiples de ce nombre là prennent autant de temps à être ajoutées à un set.

Regardons comment l'ajout à un set se fait en Python :

A set object is an unordered collection of distinct hashable objects.

En interne, Python sauvegarde en fait les hash de chaque élément du set (on dit aussi "l'empreinte" de l'élément, en français, bien que le terme "hash" soit beaucoup plus souvent utilisé). Un hash est une suite de bit relativement courte (souvent de la taille d'un entier) qui sert d'identifiant unique pour la valeur d'un élément. Contrairement à id() en revanche, on souhaite que deux objets différents mais égaux aient le même hash. Contrairement à id() encore, on ne garanti l'unicité des hash qu'au sein d'un même set ou d'un même dictionnaire (c'est à dire que deux valeurs égales peuvent avoir un hash différent si elles se trouvent dans deux set différents, le but d'un hash n'étant que de différentier les éléments au sein d'un set).

Pour ajouter un élément à un set il faut donc commencer par calculer son hash. Beaucoup de méthodes sont possibles, mais une bonne fonction de hash doit être rapide et ne pas créer de "collision". Une "collision" est le nom que l'on donne à deux valeurs différentes qui auraient le même hash. La plupart des fonctions de hash existantes commencent par faire un calcul rapide sur la valeur hashée pour en déterminer un premier hash possible, puis vérifie si ce hash n'a pas déjà été attribué à une autre valeur. Si c'est le cas, la fonction calcule alors une nouvelle valeur en utilisant une stratégie différente, afin d'éviter de générer une collision.

C'est un peu comme ça que fonctionne la méthode choisie par l'interpréteur de référence, regardons de plus prêt (plus précisemment cette fonction) :

        /* Here x is a quantity in the range [0, _PyHASH_MODULUS); we
           want to compute x * 2**PyLong_SHIFT + v->ob_digit[i] modulo
           _PyHASH_MODULUS.
           […]
        */

La fonction de hash de CPython utilise quelques optimisations compliquée, mais le commentaire nous permet de comprendre que le hash d'une valeur est le résultat d'un calcul modulo _PyHASH_MODULUS. C'est intéressant car cela signifie que si le calcul est un multiple de _PyHASH_MODULUS, alors le hash vaudra 0. Si nous hashons donc plusieurs fois à la suite des valeurs différentes mais multiples de _PyHASH_MODULUS, cette fonction renverra 0 à chaque fois et forcera l'appelant à trouver une stratégie alternative pour éviter la collision. Cela semble être ce qui se passe mais cherchons la valeur exacte de ce _PyHASH_MODULUS. C'est dans ce fichier que nous trouvons sa définition :

#if SIZEOF_VOID_P >= 8
#  define _PyHASH_BITS 61
#else
#  define _PyHASH_BITS 31
#endif

#define _PyHASH_MODULUS (((size_t)1 << _PyHASH_BITS) - 1)

_PyHASH_MODULUS vaut donc (1 << _PyHASH_BITS) - 1. _PyHASH_BITS vaut 61 sur les architectures 64 bits et 31 sur les architectures 32 bits. Les ordinateurs récents (comme celui utilisé pour le sujet de l'énigme) ont une architecture 64 bits, on peut donc remplacer _PyHASH_BITS par sa valeur dans l'expression de _PyHASH_MODULUS, qui devient donc (1 << 61) - 1. L'opérateur << en C effectue un "shift" : on prend la représentation binaire de l'opérande de gauche et on la décale d'autant de cases sur la gauche qu'indiqué dans l'opérande de droite, donc 1 << 61 signifie "1 décalé de 61 bits sur la gauche". On a vu en architecture que décaler un nombre d'un bit sur la gauche revenait à le multiplier par deux, donc un shift de 61 sur la gauche revient à multiplier 61 fois par deux, ce qui signifie que 1 << 61 vaut 261.

On y est : _PyHASH_MODULUS, la valeur du modulo utilisé pour calculer le hash d'un objet dans CPython, est 261-1, or tous les nombres que l'on ajoute au set dans la deuxième expression de l'énigme sont de la forme i * (2**61 - 1), c'est à dire des multiples de 261-1. Cela signifie que quand la fonction set.add() calcule le hash d'un élément que l'on ajoute au set, la fonction de hash de CPython lui renvoie 0 à chaque fois, la forçant à trouver une stratégie alternative de calcul de hash pour chaque élément, ce qui prend énormément de temps.

[1]: https://docs.python.org/3/library/stdtypes.html#set-types-set-frozenset et https://docs.python.org/3/tutorial/datastructures.html#sets

@Et7f3
Copy link

Et7f3 commented Apr 22, 2020

@Dettorer I see two "Égnime 5"

@Dettorer
Copy link
Author

Thanks! I copy-pasted too fast.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment