web-dev-qa-db-fra.com

Pourquoi "1000000000000000 dans la plage (1000000000000001)" si vite dans Python 3?

Si j'ai bien compris, la fonction range(), qui est en fait un type d'objet dans Python 3 , génère son contenu à la volée, à la manière d'un générateur.

Ceci étant le cas, je me serais attendu à ce que la ligne suivante prenne un temps démesuré, car pour déterminer si 1 quadrillion se situe dans la plage, il faudrait générer un quadrillion de valeurs:

1000000000000000 in range(1000000000000001)

De plus, il semble que peu importe le nombre de zéros ajoutés, le calcul prend plus ou moins le même temps (essentiellement instantané).

J'ai aussi essayé des choses comme ça, mais le calcul est encore presque instantané:

1000000000000000000000 in range(0,1000000000000000000001,10) # count by tens

Si j'essaie d'implémenter ma propre fonction de plage, le résultat n'est pas très beau !!

def my_crappy_range(N):
    i = 0
    while i < N:
        yield i
        i += 1
    return

Qu'est-ce que l'objet range() fait sous le capot qui le fait si vite?


Réponse de Martijn Pieters a été choisi pour sa complétude, mais reportez-vous également à la { première réponse de abarnert } pour une bonne discussion sur ce que signifie pour range être une séquence complète. Python 3 et quelques informations/avertissements concernant une incohérence potentielle pour l'optimisation de la fonction __contains__ dans les implémentations Python. l'autre réponse d'abarnert entre plus en détail et fournit des liens pour ceux qui sont intéressés par l'historique de l'optimisation dans Python 3 (et le manque d'optimisation de xrange dans Python 2). Les réponses par poke _ et par wim _ fournissent le code source C approprié et des explications aux personnes intéressées.

1660
Rick Teachey

L'objet range() de Python 3 ne produit pas de nombres immédiatement; c'est un objet séquence intelligent qui produit des nombres à la demande. Elle ne contient que vos valeurs de début, de fin et de pas. Lorsque vous parcourez l’objet, le nombre entier suivant est calculé à chaque itération.

L'objet implémente également le object.__contains__ hook , et calcule si votre numéro fait partie de sa plage. Le calcul est une opération O(1) à temps constant. Il n'est jamais nécessaire d'analyser tous les entiers possibles de la plage.

Depuis la documentation de l'objet range() :

L'avantage du type range par rapport à un list ou Tuple normal est qu'un objet de plage utilisera toujours la même (petite) quantité de mémoire, quelle que soit la taille de la plage qu'il représente (puisqu'il ne stocke que les valeurs start, stop et step , calculant les articles individuels et les sous-gammes au besoin).

Donc, au minimum, votre objet range() ferait:

class my_range(object):
    def __init__(self, start, stop=None, step=1):
        if stop is None:
            start, stop = 0, start
        self.start, self.stop, self.step = start, stop, step
        if step < 0:
            lo, hi = stop, start
        else:
            lo, hi = start, stop
        self.length = ((hi - lo - 1) // abs(step)) + 1

    def __iter__(self):
        current = self.start
        if self.step < 0:
            while current > self.stop:
                yield current
                current += self.step
        else:
            while current < self.stop:
                yield current
                current += self.step

    def __len__(self):
        return self.length

    def __getitem__(self, i):
        if i < 0:
            i += self.length
        if 0 <= i < self.length:
            return self.start + i * self.step
        raise IndexError('Index out of range: {}'.format(i))

    def __contains__(self, num):
        if self.step < 0:
            if not (self.stop < num <= self.start):
                return False
        else:
            if not (self.start <= num < self.stop):
                return False
        return (num - self.start) % self.step == 0

Il manque encore plusieurs éléments pris en charge par une fonction range() réelle (telles que les méthodes .index() ou .count(), le hachage, le test d'égalité ou le découpage en tranches), mais qui devraient vous donner une idée.

J'ai également simplifié l'implémentation __contains__ pour ne me concentrer que sur les tests de nombres entiers; Si vous attribuez à un objet range() réel une valeur non entière (y compris les sous-classes de int), une analyse lente est lancée pour déterminer s'il existe une correspondance, comme si vous utilisiez un test de confinement par rapport à la liste de toutes les valeurs contenues. Cela a été fait pour continuer à prendre en charge d'autres types numériques qui prennent en charge les tests d'égalité avec des entiers, mais ne sont pas censés prendre en charge l'arithmétique des entiers. Voir le problème original Python qui a implémenté le test de confinement.

1586
Martijn Pieters

Utilisez la source , Luke!

Dans CPython, range(...).__contains__ (un encapsuleur de méthode) finira par déléguer à un calcul simple qui vérifie si la valeur peut éventuellement être dans la plage. La raison de la vitesse ici est que nous utilisons un raisonnement mathématique sur les limites, plutôt qu'une itération directe de l'objet range. Pour expliquer la logique utilisée: 

  1. Vérifiez que le nombre est compris entre start et stop, et
  2. Vérifiez que la valeur de foulée ne "dépasse" pas notre nombre. 

Par exemple, 994 est dans range(4, 1000, 2) car:

  1. 4 <= 994 < 1000, et
  2. (994 - 4) % 2 == 0.

Le code C complet est inclus ci-dessous, ce qui est un peu plus détaillé en raison de la gestion de la mémoire et des détails de comptage des références, mais l’idée de base est la suivante:

static int
range_contains_long(rangeobject *r, PyObject *ob)
{
    int cmp1, cmp2, cmp3;
    PyObject *tmp1 = NULL;
    PyObject *tmp2 = NULL;
    PyObject *zero = NULL;
    int result = -1;

    zero = PyLong_FromLong(0);
    if (zero == NULL) /* MemoryError in int(0) */
        goto end;

    /* Check if the value can possibly be in the range. */

    cmp1 = PyObject_RichCompareBool(r->step, zero, Py_GT);
    if (cmp1 == -1)
        goto end;
    if (cmp1 == 1) { /* positive steps: start <= ob < stop */
        cmp2 = PyObject_RichCompareBool(r->start, ob, Py_LE);
        cmp3 = PyObject_RichCompareBool(ob, r->stop, Py_LT);
    }
    else { /* negative steps: stop < ob <= start */
        cmp2 = PyObject_RichCompareBool(ob, r->start, Py_LE);
        cmp3 = PyObject_RichCompareBool(r->stop, ob, Py_LT);
    }

    if (cmp2 == -1 || cmp3 == -1) /* TypeError */
        goto end;
    if (cmp2 == 0 || cmp3 == 0) { /* ob outside of range */
        result = 0;
        goto end;
    }

    /* Check that the stride does not invalidate ob's membership. */
    tmp1 = PyNumber_Subtract(ob, r->start);
    if (tmp1 == NULL)
        goto end;
    tmp2 = PyNumber_Remainder(tmp1, r->step);
    if (tmp2 == NULL)
        goto end;
    /* result = ((int(ob) - start) % step) == 0 */
    result = PyObject_RichCompareBool(tmp2, zero, Py_EQ);
  end:
    Py_XDECREF(tmp1);
    Py_XDECREF(tmp2);
    Py_XDECREF(zero);
    return result;
}

static int
range_contains(rangeobject *r, PyObject *ob)
{
    if (PyLong_CheckExact(ob) || PyBool_Check(ob))
        return range_contains_long(r, ob);

    return (int)_PySequence_IterSearch((PyObject*)r, ob,
                                       PY_ITERSEARCH_CONTAINS);
}

La "viande" de l'idée est mentionnée dans la ligne :

/* result = ((int(ob) - start) % step) == 0 */ 

Pour finir, regardez la fonction range_contains au bas de l'extrait de code. Si la vérification de type exacte échoue, nous n'utilisons pas l'algorithme intelligent décrit, mais retombons à la recherche d'une itération idiote de la plage à l'aide de _PySequence_IterSearch! Vous pouvez vérifier ce comportement dans l'interpréteur (j'utilise ici la v3.5.0):

>>> x, r = 1000000000000000, range(1000000000000001)
>>> class MyInt(int):
...     pass
... 
>>> x_ = MyInt(x)
>>> x in r  # calculates immediately :) 
True
>>> x_ in r  # iterates for ages.. :( 
^\Quit (core dumped)
311
wim

Pour ajouter à la réponse de Martijn, c’est la partie pertinente de la source (en C, l’objet de plage étant écrit en code natif):

static int
range_contains(rangeobject *r, PyObject *ob)
{
    if (PyLong_CheckExact(ob) || PyBool_Check(ob))
        return range_contains_long(r, ob);

    return (int)_PySequence_IterSearch((PyObject*)r, ob,
                                       PY_ITERSEARCH_CONTAINS);
}

Ainsi, pour les objets PyLong (qui est int en Python 3), il utilisera la fonction range_contains_long pour déterminer le résultat. Et cette fonction vérifie essentiellement si ob est dans la plage spécifiée (bien que cela semble un peu plus complexe en C).

S'il ne s'agit pas d'un objet int, il retourne à une itération jusqu'à ce qu'il trouve la valeur (ou non).

Toute la logique pourrait être traduite en pseudo-Python comme ceci:

def range_contains (rangeObj, obj):
    if isinstance(obj, int):
        return range_contains_long(rangeObj, obj)

    # default logic by iterating
    return any(obj == x for x in rangeObj)

def range_contains_long (r, num):
    if r.step > 0:
        # positive step: r.start <= num < r.stop
        cmp2 = r.start <= num
        cmp3 = num < r.stop
    else:
        # negative step: r.start >= num > r.stop
        cmp2 = num <= r.start
        cmp3 = r.stop < num

    # outside of the range boundaries
    if not cmp2 or not cmp3:
        return False

    # num must be on a valid step inside the boundaries
    return (num - r.start) % r.step == 0
116
poke

Si vous vous demandez pourquoi cette optimisation a été ajoutée à range.__contains__, et pourquoi n'était pas ajouté à xrange.__contains__ dans la version 2.7:

Tout d’abord, comme l’a découvert Ashwini Chaudhary, numéro 1766304 a été ouvert explicitement pour optimiser [x]range.__contains__. Un correctif pour cela était accepté et enregistré dans la version 3.2 , mais pas en backport à la version 2.7 car "xrange s'est comporté de la sorte depuis si longtemps que je ne vois pas ce que cela nous permettrait d'acheter le correctif de cette en retard." (2,7 était presque sorti à ce moment-là.)

Pendant ce temps:

À l'origine, xrange était un objet pas-tout-à-séquence. Comme les documents 3.1 } _, dites:

Les objets Range ont très peu de comportement: ils ne prennent en charge que l'indexation, l'itération et la fonction len.

Ce n'était pas tout à fait vrai. un objet xrange supportait en fait quelques autres choses qui viennent automatiquement avec l'indexation et len,* y compris __contains__ (via la recherche linéaire). Mais personne ne pensait que cela valait la peine de leur faire des séquences complètes à l’époque.

Ensuite, dans le cadre de la mise en œuvre du PEP { classes de base abstraites }, il était important de déterminer quels types intégrés devaient être marqués comme implémentant quel ABC, et xrange/range prétendait implémenter collections.Sequence, même s'il était uniquement géré le même "très peu de comportement". Personne n’a remarqué ce problème jusqu’à numéro 9213 . Le correctif de ce problème a non seulement ajouté index et count à la range de 3.2, mais il a également retravaillé le __contains__ optimisé (qui partage le même calcul avec index et est directement utilisé par count).** Ce changement } est également entré dans la version 3.2 et n'a pas été rétroporté en 2.x, car "c'est un correctif qui ajoute de nouvelles méthodes". (À ce stade, le statut 2.7 était déjà passé.)

Donc, il y avait deux chances pour que cette optimisation soit rétroportée à 2,7, mais elles ont toutes deux été rejetées.


* En fait, vous obtenez même une itération gratuite avec len et l'indexation, mais les objets { dans 2.3xrange ont un itérateur personnalisé. Ce qu’ils ont ensuite perdu dans 3.x, qui utilise le même type listiterator que list.

** La première version l'a réellement réimplémentée et s'est trompée - par exemple, elle vous donnerait MyIntSubclass(2) in range(5) == False. Mais la version mise à jour du correctif de Daniel Stutzbach a restauré la majeure partie du code précédent, y compris le repli sur le générique, lent _PySequence_IterSearch que la version antérieure à 3.2 range.__contains__ utilisait implicitement lorsque l'optimisation ne s'applique pas.

88
abarnert

Les autres réponses l'expliquaient déjà bien, mais j'aimerais proposer une autre expérience illustrant la nature des objets de plage:

>>> r = range(5)
>>> for i in r:
        print(i, 2 in r, list(r))

0 True [0, 1, 2, 3, 4]
1 True [0, 1, 2, 3, 4]
2 True [0, 1, 2, 3, 4]
3 True [0, 1, 2, 3, 4]
4 True [0, 1, 2, 3, 4]

Comme vous pouvez le constater, un objet de plage est un objet qui se souvient de sa plage et peut être utilisé plusieurs fois (même en itérant dessus), pas seulement un générateur ponctuel.

40
Stefan Pochmann

Il s’agit d’une approche paresseuse de l’évaluation et de l’optimisation supplémentaire de range. Les valeurs dans les intervalles n’ont pas besoin d’être calculées avant une utilisation réelle, voire davantage en raison d’une optimisation supplémentaire.

En passant, votre entier n'est pas si gros, considérez sys.maxsize

sys.maxsize in range(sys.maxsize) est assez rapide

en raison de l'optimisation - il est facile de comparer un nombre entier donné uniquement avec les valeurs min et max.

mais:

float(sys.maxsize) in range(sys.maxsize) est assez lent .

(dans ce cas, il n'y a pas d'optimisation dans range, donc si python reçoit un float inattendu, python comparera tous les nombres)

Vous devez être conscient des détails de la mise en œuvre mais ne pas s'y fier, car cela pourrait changer à l'avenir.

11
Sławomir Lenart

Voici implémentation similaire dans C#. Vous pouvez voir comment Contains est fait dans O(1) heure.

public struct Range
{

    private readonly int _start;
    private readonly int _stop;
    private readonly int _step;


    //other methods/properties omitted


    public bool Contains(int number)
    {
        // precheck: if the number isnt in valid point, return false
        // for example, if start is 5 and step is 10, then its impossible that 163 be in range at any interval      

        if ((_start % _step + _step) % _step != (number % _step + _step) % _step)
            return false;

        // v is vector: 1 means positive step, -1 means negative step
        // this value makes final checking formula straightforward.

        int v = Math.Abs(_step) / _step;

        // since we have vector, no need to write if/else to handle both cases: negative and positive step
        return number * v >= _start * v && number * v < _stop * v;
    }
}
5
Sanan Fataliyev

TL; DR

L'objet renvoyé par range() est en réalité un objet range. Cet objet implémente l'interface d'itérateur afin que vous puissiez parcourir ses valeurs de manière séquentielle, comme un générateur, mais il implémente également l'interface __contains__ qui est en fait ce qui est appelé lorsqu'un objet apparaît à droite de l'opérateur in . La méthode __contains__() renvoie une valeur booléenne indiquant si l'élément est dans l'objet ou non. Étant donné que les objets range connaissent leurs limites et leur foulée, il est très facile de les implémenter dans O (1). 

0
RBF06