not - ¿Por qué "1000000000000000 en rango(1000000000000001)" es tan rápido en Python 3?
python3 xrange (9)
Tengo entendido que la función
range()
, 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 aún es 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á haciendo el objeto
range()
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 que el
range
sea una
secuencia
completa en Python 3, y alguna información / advertencia sobre la inconsistencia potencial para la optimización de la función
__contains__
través de 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 de
xrange
en Python 2).
Las respuestas
por poke
y
por wim
proporcionan el código fuente C relevante y explicaciones para aquellos que estén interesados.
TL; DR
El objeto devuelto por
range()
es en realidad un objeto de
range
.
Este objeto implementa la interfaz del iterador para que pueda recorrer sus valores secuencialmente, como un generador, pero también implementa la interfaz
__contains__
, que en realidad es lo que se llama cuando aparece un objeto en el lado derecho del operador
in
.
El
__contains__()
devuelve un bool de si el elemento está o no en el objeto.
Dado que los objetos de
range
conocen sus límites y zancadas, esto es muy fácil de implementar en O (1).
¡Usa la source , Luke!
En CPython, el
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:
-
Verifique que el número esté entre
start
ystop
, y - Verifique que el valor de paso no "sobrepase" nuestro número.
Por ejemplo,
994
está dentro del
range(4, 1000, 2)
porque:
-
4 <= 994 < 1000
, y -
(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, observe la función
range_contains
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
_PySequence_IterSearch
a una búsqueda de iteración tonta 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)
El malentendido fundamental aquí es pensar que el
range
es 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))
[]
En realidad, el
range
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 un
range
y una
list
es que un
range
es una secuencia
perezosa
o
dinámica
;
no recuerda todos sus valores, solo recuerda su
start
,
stop
y
step
, y crea los valores a pedido en
__getitem__
.
(Como nota al margen, si
print(iter(a))
, notará que el
range
utiliza el mismo tipo de
listiterator
que
list
. ¿Cómo funciona? Un
listiterator
no usa nada especial sobre
list
excepto por el hecho de que proporciona una implementación en C de
__getitem__
, por lo que también funciona bien para el
range
).
Ahora, no hay nada que diga que la
Sequence.__contains__
tiene que ser un 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 implementar el
range.__contains__
solo para verificarlo matemáticamente (
(val - start) % step
, pero con cierta complejidad adicional para lidiar con pasos negativos) que generar y probar todos los valores, entonces ¿por qué
no debería
hacerlo? es 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 convertirlo 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
range
realmente fuera un generador, como
my_crappy_range
, entonces no tendría sentido probar
__contains__
esta manera, o al menos la forma en que tiene sentido no sería obvio.
Si ya ha iterado los primeros 3 valores, ¿está todavía
1
in
el generador?
¿Deben las pruebas para
1
hacer que itere y consuma todos los valores hasta
1
(o hasta el primer valor
>= 1
)?
El objeto
range()
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 de tiempo constante O (1).
Nunca es necesario escanear todos los enteros posibles en el rango.
De la
documentación del objeto
range()
:
La ventaja del tipo de
range
sobre unalist
regular otuple
es que un objeto de rango siempre tomará la misma cantidad (pequeña) de memoria, sin importar el tamaño del rango que representa (ya que solo almacena los valores destart
,stop
ystep
, calcular elementos individuales y subranges según sea necesario).
Entonces, como mínimo, su objeto
range()
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 = 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
Todavía faltan varias cosas que admite un
range()
real
range()
(como los
.index()
o
.count()
, hashing, pruebas de igualdad o segmentación), pero deberían darle una idea.
También simplifiqué la implementación de
__contains__
para centrarme solo en pruebas de enteros;
si le da a un objeto
range()
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 contra 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.
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.
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 objetos
PyLong
(que es
int
en Python 3), usará la función
range_contains_long
para determinar el resultado.
Y esa función esencialmente verifica si
ob
está en el rango especificado (aunque parece un poco más complejo en C).
Si no es un objeto
int
, recurre 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
Se trata de un
enfoque perezoso
para la evaluación y
una optimización adicional
del
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, su número entero no es tan grande, considere
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:
float(sys.maxsize) in range(sys.maxsize)
es bastante lento
.
(en este caso, no hay optimización en el
range
, por lo que si Python recibe una flotación inesperada, 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.
Si se pregunta
por qué
esta optimización se agregó al
range.__contains__
, y por qué no
se
agregó a
xrange.__contains__
en 2.7:
Primero, como descubrió Ashwini Chaudhary, el
número 1766304
se abrió explícitamente para optimizar el
[x]range.__contains__
.
Se
aceptó
un parche para esto
y se registró en 3.2
, pero no se realizó un backport 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,
xrange
era 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 función
len
.
Esto no era del todo cierto;
un objeto
xrange
realidad soportaba algunas otras cosas que vienen automáticamente con indexación y
len
,
*
incluyendo
__contains__
(a través de búsqueda lineal).
Pero nadie pensó que valía la pena hacerlas secuencias completas en ese momento.
Luego, como parte de la implementación del PEP de las
clases base abstractas
, era importante determinar qué tipos incorporados deberían marcarse como implementando qué ABC y
xrange
/
range
afirmaban implementar
collections.Sequence
xrange
, a pesar de que todavía solo manejaba el mismo "muy poco comportamiento ".
Nadie notó ese problema hasta el
número 9213
.
El parche para ese problema no solo agregó
index
y
count
al
range
de 3.2, sino que también reestructuró las
__contains__
optimizadas (que comparte la misma matemática con el
index
y se utiliza directamente por
count
).
**
Este cambio también
entró en 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
xrange
objetos tienen un iterador personalizado.
** La primera versión realmente lo reimplementó y obtuvo los detalles incorrectos, por ejemplo, le 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
_PySequence_IterSearch
genérico y lento que el
range.__contains__
anterior a 3.2
range.__contains__
se usaba implícitamente cuando la optimización no se aplica.
Aquí está la
implementación en
C#
.
Puede ver cómo funciona
Contains
en el tiempo O (1).
public struct Range
{
private readonly int _start;
private readonly int _stop;
private readonly int _step;
// other members omitted for brevity
public bool Contains(int number)
{
// precheck - if the number is not in a valid step point, return false
// for example, if start=5, step=10, stop=1000, it is obvious that 163 is not in this range (due to remainder)
if ((_start % _step + _step) % _step != (number % _step + _step) % _step)
return false;
// with the help of step sign, we can check borders in linear manner
int s = Math.Sign(_step);
// no need if/else to handle both cases - negative and positive step
return number * s >= _start * s && number * s < _stop * s;
}
}