¿Por qué "1000000000000000 en rango (1000000000000001)" es tan rápido en Python 3?

2116

Tengo entendido que la range()función, que en realidad es un tipo de objeto en Python 3 , genera su contenido sobre la marcha, similar a un generador.

Siendo este el caso, hubiera esperado que la siguiente línea tomara una cantidad excesiva de tiempo, porque para determinar si 1 billón está en el rango, se tendrían que generar un billón de valores:

1000000000000000 in range(1000000000000001)

Además: parece que no importa cuántos ceros agregue, el cálculo lleva más o menos la misma cantidad de tiempo (básicamente instantáneo).

También he intentado cosas como esta, pero el cálculo sigue siendo casi instantáneo:

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

¡Si trato de implementar mi propia función de rango, el resultado no es tan bueno!

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

¿Qué está range()haciendo el objeto debajo del capó que lo hace tan rápido?


La respuesta de Martijn Pieters fue elegida por su integridad, pero también puede ver la primera respuesta de abarnert para una buena discusión sobre lo que significa rangeser una secuencia completa en Python 3, y alguna información / advertencia sobre la inconsistencia potencial para la __contains__optimización de la función en las implementaciones de Python . La otra respuesta de abarnert entra en más detalles y proporciona enlaces para aquellos interesados ​​en la historia detrás de la optimización en Python 3 (y la falta de optimización xrangeen Python 2). Las respuestas por poke y por wim proporcionan el código fuente C relevante y explicaciones para aquellos que estén interesados.

Rick apoya a Monica
fuente
70
Tenga en cuenta que este es el caso solo si el elemento que estamos verificando es un boolo longtipo, con otros tipos de objetos se volverá loco. Prueba con:100000000000000.0 in range(1000000000000001)
Ashwini Chaudhary
10
¿Quién te dijo que rangees un generador?
abarnert
77
@abarnert Creo que la edición que hice dejó la confusión intacta.
Rick apoya a Mónica
55
@AshwiniChaudhary no es python2 xrangelo mismo que python3range ?
Excelente
28
Los xrange()objetos @Superbest no tienen ningún __contains__método, por lo que la verificación de elementos debe recorrer todos los elementos. Además, hay algunos otros cambios en range(), al igual que apoya el corte en lonchas (que a su vez devuelve un rangeobjeto) y ahora también tiene county indexmétodos para que sea compatible con collections.SequenceABC.
Ashwini Chaudhary

Respuestas:

2171

El range()objeto Python 3 no produce números de inmediato; Es un objeto de secuencia inteligente que produce números a pedido . Todo lo que contiene son sus valores de inicio, parada y paso, luego, a medida que itera sobre el objeto, se calcula el siguiente entero en cada iteración.

El objeto también implementa el object.__contains__gancho y calcula si su número es parte de su rango. El cálculo es una operación (casi) de tiempo constante * . Nunca es necesario escanear todos los enteros posibles en el rango.

De la range()documentación del objeto :

La ventaja del rangetipo de más de un habitual listo tuplees que un objeto de rango siempre tendrá la misma (pequeña) cantidad de memoria, sin importar el tamaño de la gama que representa (ya que sólo se almacena los start, stopy steplos valores, el cálculo de los elementos individuales y subintervalos según sea necesario).

Entonces, como mínimo, su range()objeto haría:

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, step = stop, start, -step
        else:
            lo, hi = start, stop
        self.length = 0 if lo > hi else ((hi - lo - 1) // 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

Todavía faltan varias cosas que un range()soporte real (como los métodos .index()o .count(), el hash, la prueba de igualdad o el corte), pero deberían darle una idea.

También simplifiqué la __contains__implementación para centrarme solo en pruebas de enteros; Si le da a un range()objeto real un valor no entero (incluidas las subclases de int), se inicia una exploración lenta para ver si hay una coincidencia, al igual que si utiliza una prueba de contención en una lista de todos los valores contenidos. Esto se hizo para continuar admitiendo otros tipos numéricos que simplemente admiten pruebas de igualdad con enteros, pero no se espera que también admitan la aritmética de enteros. Vea el problema original de Python que implementó la prueba de contención.


* Tiempo casi constante porque los enteros de Python son ilimitados y, por lo tanto, las operaciones matemáticas también crecen en el tiempo a medida que N crece, lo que hace que sea una operación O (log N). Como todo se ejecuta en código C optimizado y Python almacena valores enteros en fragmentos de 30 bits, se quedará sin memoria antes de ver algún impacto en el rendimiento debido al tamaño de los enteros involucrados aquí.

Martijn Pieters
fuente
58
Dato curioso: debido a que tiene una implementación funcional de __getitem__y __len__, la __iter__implementación es realmente innecesaria.
Lucretiel
2
@Lucretiel: En Python 2.3 , xrangeiteratorse agregó un especial específicamente porque no fue lo suficientemente rápido. Y luego en algún lugar en 3.x (no estoy seguro de si era 3.0 o 3.2) fue arrojado y usan el mismo listiteratortipo que listusa.
abarnert
1
Definiría el constructor como def __init__(self, *start_stop_step)y lo analizaría desde allí; La forma en que los argumentos están etiquetados ahora es un poco confusa. Sin embargo, +1; todavía definitivamente explicaste el comportamiento.
Cody Piersall
1
@CodyPiersall: Desafortunadamente, esa es la firma del inicializador de la clase real. rangees anterior a *args(mucho menos la argclinicAPI que permite que las funciones de C-API tengan firmas completas de Python). Algunas otras funciones antiguas (y algunas funciones más nuevas, como xrange, slicey itertools.islice, por coherencia) funcionan de la misma manera, pero en su mayor parte, Guido y el resto de los desarrolladores centrales parecen estar de acuerdo con usted. Los documentos 2.0+ incluso describen a sus rangeamigos como si fueran sobrecargas de estilo C ++ en lugar de mostrar la firma confusa real.
abarnert
2
@CodyPiersall: En realidad, aquí hay una cita de Guido en la argclinicdiscusión, cuando Nick Coghlan ideó una manera de permitir definir rangesin ambigüedades: "Por favor, no facilite que la gente copie mi peor decisión de diseño". Entonces, estoy bastante seguro de que él está de acuerdo en que rangees confuso como está escrito.
abarnert
845

El malentendido fundamental aquí es pensar que rangees un generador. No es. De hecho, no es ningún tipo de iterador.

Puedes decir esto con bastante facilidad:

>>> a = range(5)
>>> print(list(a))
[0, 1, 2, 3, 4]
>>> print(list(a))
[0, 1, 2, 3, 4]

Si fuera un generador, iterarlo una vez lo agotaría:

>>> b = my_crappy_range(5)
>>> print(list(b))
[0, 1, 2, 3, 4]
>>> print(list(b))
[]

Lo que en rangerealidad es, es una secuencia, como una lista. Incluso puedes probar esto:

>>> import collections.abc
>>> isinstance(a, collections.abc.Sequence)
True

Esto significa que tiene que seguir todas las reglas de ser una secuencia:

>>> a[3]         # indexable
3
>>> len(a)       # sized
5
>>> 3 in a       # membership
True
>>> reversed(a)  # reversible
<range_iterator at 0x101cd2360>
>>> a.index(3)   # implements 'index'
3
>>> a.count(3)   # implements 'count'
1

La diferencia entre ay rangea listes que a rangees una secuencia perezosa o dinámica ; que no recuerda todos sus valores, sólo recuerda su start, stopy step, y crea los valores de la demanda en __getitem__.

(Como nota al margen, si lo hace print(iter(a)), notará que rangeusa el mismo listiteratortipo que list. ¿Cómo funciona? A listiteratorno usa nada especial, listexcepto por el hecho de que proporciona una implementación de C __getitem__, por lo que funciona bien para rangetambién.)


Ahora, no hay nada que diga que Sequence.__contains__tiene que ser tiempo constante; de ​​hecho, para ejemplos obvios de secuencias como list, no lo es. Pero no hay nada que diga que no puede ser. Y es más fácil de implementar range.__contains__simplemente verificarlo matemáticamente ( (val - start) % steppero con cierta complejidad adicional para lidiar con los pasos negativos) que generar y probar todos los valores, entonces, ¿por qué no debería hacerlo de la mejor manera?

Pero no parece haber nada en el lenguaje que garantice que esto suceda. Como señala Ashwini Chaudhari, si le da un valor no integral, en lugar de convertir a entero y hacer la prueba matemática, recurrirá a iterar todos los valores y compararlos uno por uno. Y solo porque las versiones CPython 3.2+ y PyPy 3.x contienen esta optimización, y es una buena idea obvia y fácil de hacer, no hay razón para que IronPython o NewKickAssPython 3.x no puedan dejarla de lado. (Y, de hecho, CPython 3.0-3.1 no lo incluyó).


Si rangerealmente fuera un generador, my_crappy_rangeentonces, no tendría sentido probar de __contains__esta manera, o al menos la forma en que tiene sentido no sería obvio. Si ya había iterado los primeros 3 valores, ¿sigue 1siendo inel generador? ¿Deben las pruebas 1hacer que itere y consuma todos los valores hasta 1(o hasta el primer valor >= 1)?

abarnert
fuente
10
Esto es algo muy importante para aclarar. Supongo que las diferencias entre Python 2 y 3 pueden haber llevado a mi confusión sobre este punto. En cualquier caso, debería haberme dadorangelisttuple cuenta ya que aparece (junto con y ) como un tipo de secuencia .
Rick apoya a Mónica
44
@RickTeachey: En realidad, en 2.6+ (creo; quizás 2.5+), también xrangees una secuencia. Ver 2.7 docs . De hecho, siempre fue casi una secuencia.
abarnert
55
@ RickTeachey: En realidad, estaba equivocado; en 2.6-2.7 (y 3.0-3.1), afirma ser una secuencia, pero sigue siendo solo una casi secuencia. Mira mi otra respuesta.
abarnert
2
No es un iterador, es una secuencia (Iterable en términos de Java, IEnumerable de C #), algo con un .__iter__()método que devolverá un iterador. A su vez, solo se puede usar una vez.
Smit Johnth
44
@ThomasAhle: Porque rangeno está comprobando tipos cuando no es un número entero, ya que siempre es posible que un tipo tenga un tipo __eq__compatible int. Claro, strobviamente no funcionará, pero no querían ralentizar las cosas al verificar explícitamente todos los tipos que no pueden estar allí (y después de todo, una strsubclase podría anular __eq__y estar contenida en el range).
ShadowRanger
377

¡Usa la fuente , Luke!

En CPython, range(...).__contains__(un contenedor de métodos) eventualmente delegará en un cálculo simple que verifica si el valor puede estar en el rango. La razón de la velocidad aquí es que estamos usando un razonamiento matemático sobre los límites, en lugar de una iteración directa del objeto de rango . Para explicar la lógica utilizada:

  1. Verifique que el número esté entre starty stop, y
  2. Verifique que el valor de paso no "sobrepase" nuestro número.

Por ejemplo, 994está en range(4, 1000, 2)porque:

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

El código C completo se incluye a continuación, que es un poco más detallado debido a la administración de memoria y los detalles de conteo de referencias, pero la idea básica está ahí:

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 "carne" de la idea se menciona en la línea :

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

Como nota final, mire la range_containsfunción en la parte inferior del fragmento de código. Si la verificación de tipo exacta falla, entonces no usamos el algoritmo inteligente descrito, sino que recurrimos a una tonta búsqueda de iteración del rango usando _PySequence_IterSearch! Puede verificar este comportamiento en el intérprete (estoy usando v3.5.0 aquí):

>>> 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)
wim
fuente
144

Para agregar a la respuesta de Martijn, esta es la parte relevante de la fuente (en C, ya que el objeto de rango está escrito en código nativo):

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);
}

Entonces, para los PyLongobjetos (que está inten Python 3), usará la range_contains_longfunción para determinar el resultado. Y esa función esencialmente comprueba si obestá en el rango especificado (aunque parece un poco más complejo en C).

Si no es un intobjeto, vuelve a iterar hasta que encuentre el valor (o no).

Toda la lógica podría traducirse a pseudo-Python de esta manera:

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
dar un toque
fuente
11
@ChrisWesseling: Creo que esta información es lo suficientemente diferente (y suficiente) que editar la respuesta de Martijn no hubiera sido apropiado aquí. Es una decisión de juicio, pero las personas generalmente se equivocan al no hacer cambios drásticos en las respuestas de otras personas.
abarnert
105

Si se pregunta por qué se agregó esta optimización range.__contains__y por qué no se agregó xrange.__contains__en 2.7:

Primero, como descubrió Ashwini Chaudhary, el número 1766304 se abrió explícitamente para optimizar [x]range.__contains__. Se aceptó un parche para esto y se registró en 3.2 , pero no se agregó a 2.7 porque "xrange se ha comportado así durante tanto tiempo que no veo lo que nos compra para enviar el parche tan tarde". (2.7 estaba casi fuera en ese punto).

Mientras tanto:

Originalmente, xrangeera un objeto que no era de secuencia completa. Como dicen los documentos 3.1 :

Los objetos de rango tienen muy poco comportamiento: solo admiten indexación, iteración y la lenfunción.

Esto no era del todo cierto; un xrangeobjeto en realidad admite algunas otras cosas que vienen automáticamente con la indexación y *len , incluido (a través de la búsqueda lineal). Pero nadie pensó que valía la pena hacerlas secuencias completas en ese momento.__contains__

Luego, como parte de la implementación de la PEP de las clases base abstractas , fue importante determinar qué tipos incorporados deberían marcarse como implementando qué ABC, y xrange/ o rangesegún afirmaron implementar collections.Sequence, a pesar de que todavía manejaba el mismo "comportamiento muy pequeño". Nadie notó ese problema hasta el número 9213 . El parche para ese problema no solo se agregó indexy counten 3.2 range, también reestructuró el optimizado __contains__(que comparte las mismas matemáticas indexy lo usa directamente count). ** Este cambio también fue para 3.2, y no fue retrocedido a 2.x, porque "es una corrección de errores que agrega nuevos métodos". (En este punto, 2.7 ya había pasado el estado de RC).

Por lo tanto, hubo dos oportunidades para que esta optimización se transfiriera a 2.7, pero ambos fueron rechazados.


* De hecho, incluso obtienes iteraciones gratis solo con indexación, pero en 2.3 los xrange objetos tienen un iterador personalizado.

** La primera versión en realidad lo reimplementó y se equivocaron los detalles, por ejemplo, te daría MyIntSubclass(2) in range(5) == False. Pero la versión actualizada del parche de Daniel Stutzbach restauró la mayor parte del código anterior, incluido el retroceso al genérico, lento _PySequence_IterSearchque pre-3.2 range.__contains__estaba usando implícitamente cuando la optimización no se aplica.

abarnert
fuente
44
De los comentarios aquí: mejorarxrange.__contains__ , parece que no lo respaldaron en Python 2 solo para dejar un elemento de sorpresa para los usuarios y ya era demasiado tarde o_O. El parchecount y se agregó más tarde. Archivo en ese momento: hg.python.org/cpython/file/d599a3f2e72d/Objects/rangeobject.cindex
Ashwini Chaudhary
12
Tengo una sospecha siniestra de que algunos desarrolladores centrales de Python son parciales al "amor duro" por Python 2.x porque quieren alentar a las personas a cambiar a la muy superior python3 :)
wim
44
También apuesto a que es una carga enorme tener que agregar nuevas funciones a las versiones anteriores. Imagínese si fue a Oracle y le dijo: "Mira, estoy en Java 1.4 y merezco expresiones lambda. Respaldarlos por nada".
Rob Grant
2
@ RickTeachey, sí, es solo un ejemplo. Si dijera 1.7 todavía se aplicaría. Es una diferencia cuantitativa no cualitativa. Básicamente, los desarrolladores (no pagados) no pueden hacer cosas nuevas para siempre en 3.xy volverlas a 2.x para aquellos que no desean actualizar. Es una carga enorme y ridícula. ¿Crees que todavía hay algo mal con mi razonamiento?
Rob Grant
3
@RickTeachey: 2.7 estaba entre 3.1 y 3.2, no alrededor de 3.3. Y eso significa que 2.7 estaba en rc cuando entraron los últimos cambios a 3.2, lo que hace que los comentarios de errores sean más fáciles de entender. De todos modos, creo que cometieron algunos errores en retrospectiva (especialmente asumiendo que las personas migrarían a través 2to3de un código de versión dual con la ayuda de bibliotecas como six, por eso obtuvimos cosas como dict.viewkeysesa que nadie usará nunca), y hubo algunos cambios que llegaron demasiado tarde en 3.2, pero en su mayor parte 2.7 fue un lanzamiento impresionante "último 2.x nunca".
abarnert
47

Las otras respuestas ya lo explicaron bien, pero me gustaría ofrecer otro experimento que ilustre la naturaleza de los objetos de rango:

>>> 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]

Como puede ver, un objeto de rango es un objeto que recuerda su rango y se puede usar muchas veces (incluso al iterar sobre él), no solo un generador de una sola vez.

Stefan Pochmann
fuente
27

Se trata de un enfoque perezoso para la evaluación y una optimización adicional de range. Los valores en rangos no necesitan ser calculados hasta el uso real, o incluso más debido a una optimización adicional.

Por cierto, tu número entero no es tan grande, considera sys.maxsize

sys.maxsize in range(sys.maxsize) es bastante rápido

debido a la optimización: es fácil comparar un entero dado solo con el rango mínimo y máximo.

pero:

Decimal(sys.maxsize) in range(sys.maxsize) es bastante lento .

(en este caso, no hay optimización range, por lo que si Python recibe un decimal inesperado, Python comparará todos los números)

Debe conocer los detalles de la implementación, pero no debe confiar en ellos, ya que esto puede cambiar en el futuro.

Sławomir Lenart
fuente
44
Tenga cuidado flotando enteros grandes. En la mayoría de las máquinas, float(sys.maxsize) != sys.maxsize)aunque sys.maxsize-float(sys.maxsize) == 0.
holdenweb
18

TL; DR

El objeto devuelto por range()es en realidad un rangeobjeto. Este objeto implementa la interfaz del iterador para que pueda iterar sobre sus valores secuencialmente, al igual que un generador, una lista o una tupla.

Pero también implementa la __contains__interfaz, que en realidad es lo que se llama cuando aparece un objeto en el lado derecho del inoperador. El __contains__()método devuelve boolsi el elemento en el lado izquierdo del inestá o no en el objeto. Dado que los rangeobjetos conocen sus límites y zancadas, esto es muy fácil de implementar en O (1).

RBF06
fuente
0
  1. Debido a la optimización, es muy fácil comparar enteros dados solo con el rango mínimo y máximo.
  2. La razón por la que la función range () es tan rápida en Python3 es que aquí usamos razonamiento matemático para los límites, en lugar de una iteración directa del objeto range.
  3. Entonces, para explicar la lógica aquí:
    • Compruebe si el número está entre el inicio y la parada.
    • Verifique si el valor de precisión del paso no supera nuestro número.
  4. Tomemos un ejemplo, 997 está dentro del rango (4, 1000, 3) porque:

    4 <= 997 < 1000, and (997 - 4) % 3 == 0.

Naruto
fuente
1
¿Puedes compartir la fuente para eso? Incluso si eso suena legítimo, sería bueno respaldar estas afirmaciones por código real
Nico Haase,
Creo que este es un ejemplo de que podría implementarse. No es la forma exacta en que se implementa. Aunque no se proporcionó ninguna referencia, es una buena pista lo suficientemente buena como para entender por qué la verificación de inclusión para el rango puede ser mucho más rápida que la lista o la tupla
Mohammed Shareef C
0

Pruebe x-1 in (i for i in range(x))con xvalores grandes , que utilizan una comprensión del generador para evitar invocar la range.__contains__optimización.

benjimin
fuente