Почему "1000000000000000 в диапазоне (1000000000000001)" так быстро в Python 3?

Я понимаю, что функция range(), которая на самом деле тип объекта в Python 3, генерирует ее содержимое на лету, подобно генератору.

В этом случае я ожидал бы, что следующая строка займет слишком много времени, потому что для определения того, будет ли 1 квадриллион в диапазоне, необходимо создать квадратичные значения:

1000000000000000 in range(1000000000000001)

Кроме того: кажется, что независимо от того, сколько нулей я добавляю, расчет более или менее занимает одинаковое количество времени (в основном мгновенное).

Я также пробовал такие вещи, но расчет по-прежнему почти мгновен:

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

Если я попытаюсь реализовать свою собственную функцию диапазона, результат не так хорош!!

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

Что делает объект range(), созданный под капотом, что делает его настолько быстрым?


Ответ Martijn Pieters был выбран за его полноту, но также см. ответить на первый ответ за хороший обсуждение того, что означает range как полноценную последовательность в Python 3, и некоторую информацию/предупреждение о потенциальной несогласованности для оптимизации функций __contains__ в реализациях Python. abarnert other answer идет более подробно и содержит ссылки для тех, кто интересуется историей оптимизации в Python 3 (и отсутствием оптимизации xrange в Python 2), Ответы poke и с помощью wim предоставляют соответствующий исходный код C и объяснения для тех, кто заинтересован.

Ответы

Ответ 1

Объект Python 3 range() не генерирует числа сразу; это умный объект последовательности, который производит числа по требованию. Все, что он содержит, - это ваши значения start, stop и step, а затем при выполнении итерации по объекту вычисляется следующее целое число на каждой итерации.

Объект также реализует ловушку object.__contains__ и вычисляет, является ли ваше число частью его диапазона. Расчет - это операция O (1) с постоянным временем. Нет необходимости сканировать все возможные целые числа в диапазоне.

Из range() документации по объекту:

Преимущество типа range перед обычным list или tuple состоит в том, что объект диапазона всегда будет занимать одинаковое (небольшое) количество памяти, независимо от размера диапазона, который он представляет (так как он хранит только start, stop и step значения, вычисляющие отдельные элементы и поддиапазоны по мере необходимости).

Итак, как минимум, ваш объект range() будет делать:

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

В нем по-прежнему не хватает нескольких вещей, которые поддерживает настоящий range() (таких как методы .index() или .count(), хэширование, тестирование на равенство или срезы), но это должно дать вам представление.

Я также упростил реализацию __contains__, чтобы сосредоточиться только на целочисленных тестах; если вы дадите реальному объекту range() нецелое значение (включая подклассы int), будет запущено медленное сканирование, чтобы увидеть, есть ли совпадение, так же, как если бы вы использовали тест сдерживания для списка всех содержащиеся значения. Это было сделано для продолжения поддержки других числовых типов, которые, как оказалось, поддерживают тестирование на равенство с целыми числами, но не ожидается, что они также будут поддерживать целочисленную арифметику. Смотрите оригинальную проблему с Python, в которой реализован тест на содержание.

Ответ 2

Основное недоразумение здесь состоит в том, что range является генератором. Не это. На самом деле это не какой-то итератор.

Вы можете сказать это довольно легко:

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

Если бы это был генератор, итерация его однажды исчерпала бы его:

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

На самом деле range есть последовательность, как и список. Вы можете даже проверить это:

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

Это означает, что он должен следовать всем правилам последовательности:

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

Разница между range и a list заключается в том, что a range является ленивой или динамической последовательностью; он не запоминает всех своих значений, он просто запоминает свои start, stop и step и создает значения по запросу на __getitem__.

(Как примечание, если вы print(iter(a)), вы заметите, что range использует тот же тип listiterator как list. Как это работает? A listiterator не использует ничего специального about list за исключением того факта, что он обеспечивает реализацию C __getitem__, поэтому он отлично работает и для range.)


Теперь нет ничего, что говорит о том, что Sequence.__contains__ должно быть постоянным временем - фактически, для очевидных примеров таких последовательностей, как list, это не так. Но нет ничего, что говорило бы, что этого не может быть. И проще реализовать range.__contains__, чтобы просто проверить его математически ((val - start) % step, но с некоторой дополнительной сложностью справиться с отрицательными шагами), чем фактически генерировать и тестировать все значения, поэтому почему бы не сделать это лучше

Но на языке, похоже, ничего не происходит, гарантируя, что это произойдет. Как указывает Ашвини Чаудхари, если вы даете ему нецелое значение, вместо того, чтобы преобразовывать его в целое и выполнять математический тест, он будет возвращаться к итерации всех значений и их сопоставлению по одному. И только потому, что версии CPython 3.2+ и PyPy 3.x содержат эту оптимизацию, и это очевидная хорошая идея и ее легко сделать, нет никаких причин, по которым IronPython или NewKickAssPython 3.x не могли бы это исключить. (И на самом деле CPython 3.0-3.1 не включил его.)


Если range фактически был генератором, например my_crappy_range, тогда было бы бессмысленно тестировать __contains__ таким образом, или, по крайней мере, так, как это имеет смысл, не было бы очевидно. Если вы уже повторили первые 3 значения, это 1 еще in генератор? Если тестирование для 1 заставляет его итерировать и потреблять все значения до 1 (или до первого значения >= 1)?

Ответ 3

Используйте источник, Luke!

В CPython range(...).__contains__ (обертка метода) в конечном итоге делегирует простой расчет, который проверяет, может ли значение быть в диапазоне. Причиной скорости здесь является использование математических рассуждений о границах, а не прямая итерация объекта диапазона. Чтобы объяснить используемую логику:

  • Убедитесь, что номер находится между start и stop, и
  • Убедитесь, что значение шага не "перешагивает" наш номер.

Например, 994 находится в range(4, 1000, 2), потому что:

  • 4 <= 994 < 1000 и
  • (994 - 4) % 2 == 0.

Полный код C приведен ниже, что немного более подробно из-за управления памятью и деталей подсчета ссылок, но основная идея:

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

"Мяч" идеи упоминается в в строке:

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

В качестве окончательной заметки - посмотрите на функцию range_contains в нижней части фрагмента кода. Если точная проверка типа не выполняется, мы не используем описанный умный алгоритм, вместо этого возвращаемся к немому поиску итерации диапазона с помощью _PySequence_IterSearch! Вы можете проверить это поведение в интерпретаторе (здесь я использую 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)

Ответ 4

Чтобы добавить к Martijns ответ, это важная часть источника (в C, поскольку объект диапазона написан в собственном коде):

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

Итак, для объектов PyLong (который находится int в Python 3), он будет использовать функцию range_contains_long для определения результата. И эта функция по существу проверяет, находится ли ob в указанном диапазоне (хотя в C он немного сложнее).

Если это не объект int, он возвращается к итерации до тех пор, пока не найдет значение (или нет).

Вся логика может быть переведена на псевдо-Python следующим образом:

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

Ответ 5

Если вам интересно, почему эта оптимизация была добавлена в range.__contains__, и почему она не была добавлена в xrange.__contains__ в 2.7:

Во-первых, как обнаружила Эшвини Чаудхари, выпуск 1766304 был открыт явно для оптимизации [x]range.__contains__. Патч для этого был принят и зарегистрирован для версии 3.2, но не перенесен в версию 2.7, потому что xrange вел себя так долго, что я не вижу, что он покупает, чтобы зафиксировать этот патч поздно ". (2.7 было почти в тот момент.)

В то же время:

Первоначально, xrange был не совсем последовательным объектом. Как сказано в документах 3.1 :

Объекты диапазона имеют очень небольшое поведение: они поддерживают только индексирование, итерацию и функцию len.

Это было не совсем так; объект xrange на самом деле поддерживает несколько других вещей, которые приходят автоматически с индексированием и len, *, включая __contains__ (через линейный поиск). Но никто не думал, что в то время стоило делать их полными последовательностями.

Затем, в рамках реализации абстрактных базовых классов PEP, было важно выяснить, какие встроенные типы должны быть помечены как реализующие, какие ABC, и xrange/range заявили, что реализуют collections.Sequence, даже при том, что это все еще обрабатывало то же самое "очень маленькое поведение". Никто не заметил этой проблемы до выпуска 9213. Патч для этой проблемы не только добавил index и count к 3.2 range, он также переработал оптимизированный __contains__ (который использует ту же математику с index, и непосредственно используется count). ** Это изменение также коснулось 3.2 и не было перенесено в 2.x, потому что "это исправление, которое добавляет новые методы". (На данный момент, 2.7 уже прошел статус rc.)

Таким образом, было две возможности вернуть эту оптимизацию в 2.7, но оба они были отклонены.


* На самом деле, вы даже получаете итерацию бесплатно только с индексированием, но в 2.3 объекты xrange получили собственный итератор.

** Первая версия фактически реализовала его и неправильно указала детали - например, она дала бы вам MyIntSubclass(2) in range(5) == False. Но обновленная версия патча Даниэля Штутцбаха восстановила большую часть предыдущего кода, включая откат к универсальному медленному _PySequence_IterSearch, который до 3.2 range.__contains__ неявно использовался, когда оптимизация не применяется.

Ответ 6

Другие ответы объяснили это хорошо уже, но я хотел бы предложить еще один эксперимент, иллюстрирующий характер объектов диапазона:

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

Как вы можете видеть, объект диапазона - это объект, который помнит свой диапазон и может использоваться много раз (даже при итерации по нему), а не только одноразовый генератор.

Ответ 7

Это все о ленивом подходе к оценке и некоторой дополнительной оптимизации из range. Значения в диапазонах не нужно вычислять до реального использования или даже из-за дополнительной оптимизации.

Кстати, ваше целое число не такое большое, рассмотрим sys.maxsize

sys.maxsize in range(sys.maxsize) довольно быстрый

из-за оптимизации - легко сравнить данное целое число только с минимальным и максимальным диапазоном.

но:

float(sys.maxsize) in range(sys.maxsize) довольно медленный.

(в этом случае оптимизация в range отсутствует, поэтому, если python получает неожиданное значение с плавающей запятой, python сравнивает все числа)

Вы должны знать о деталях реализации, но на них не следует полагаться, потому что это может измениться в будущем.

Ответ 8

Вот реализация в C#. Вы можете увидеть, как Contains работает за 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;
    }
}

Ответ 9

TL; DR

Объект, возвращаемый range(), на самом деле является объектом range. Этот объект реализует интерфейс итератора, так что вы можете последовательно выполнять его значения, как генератор, но он также реализует интерфейс __contains__, который фактически вызывается, когда объект появляется справа оператора in. Метод __contains__() возвращает логическое значение того, находится ли элемент в объекте. Так как объекты range знают свои границы и шаг, это очень легко реализовать в O (1).