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

2117

Насколько я понимаю, 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 был выбран из-за его полноты, но также см . Первый ответ abarnert для хорошего обсуждения того, что значит rangeбыть полноценной последовательностью в Python 3, и некоторую информацию / предупреждение относительно потенциальной несогласованности для __contains__оптимизации функций во всех реализациях Python. , Другой ответ abarnert входит в некоторые подробности и предоставляет ссылки для тех, кто интересуется историей, стоящей за оптимизацией в Python 3 (и отсутствием оптимизации xrangeв Python 2). Ответы poke и wim предоставляют соответствующий исходный код на C и объяснения для тех, кто заинтересован.

Рик поддерживает Монику
источник
70
Обратите внимание, что это имеет место только в том случае, если элемент, который мы проверяем, является типом boolили longтипом, с другими типами объектов это сойдет с ума. Попробуйте:100000000000000.0 in range(1000000000000001)
Ашвини Чаудхари
10
Кто тебе сказал, что rangeэто генератор?
abarnert
7
@abarnert Я думаю, что редактирование, которое я сделал, оставило путаницу нетронутой.
Рик поддерживает Монику
5
@AshwiniChaudhary - это не Python2 так xrangeже, как Python3range ?
Superbest
28
У xrange()объектов @Superbest нет __contains__метода, поэтому проверка элементов должна проходить по всем элементам. Кроме того, есть несколько других изменений range(), например, он поддерживает нарезку (которая снова возвращает rangeобъект), а также имеет countи indexметоды для обеспечения его совместимости с collections.SequenceABC.
Ашвини Чаудхари

Ответы:

2172

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

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

Из 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, в которой реализован тест на содержание.


* Почти постоянное время, потому что целые числа Python не ограничены, и поэтому математические операции также растут во времени с ростом N, что делает эту операцию O (log N). Поскольку все это выполняется в оптимизированном коде C, а Python хранит целочисленные значения в 30-битных блоках, вам не хватит памяти, прежде чем вы заметите какое-либо влияние на производительность из-за размера целых чисел, задействованных здесь.

Мартейн Питерс
источник
58
Интересный факт: поскольку у вас есть рабочая реализация __getitem__и __len__, __iter__реализация на самом деле не нужна.
Лукретиэль
2
@Lucretiel: В Python 2.3 , специальный xrangeiteratorбыл добавлен специально , потому что не было достаточно быстро. И затем где-то в 3.x (я не уверен, было ли это 3.0 или 3.2), он был брошен, и они используют тот же listiteratorтип, который listиспользует.
abarnert
1
Я бы определил конструктор как def __init__(self, *start_stop_step)и разобрал его оттуда; способ, которым аргументы помечены теперь, теперь немного сбивает с толку. Тем не менее +1; Вы все еще определенно объяснили поведение.
Коди Пирсолл,
1
@CodyPiersall: К сожалению, это подпись инициализатора реального класса. rangeстарше *args(гораздо меньше argclinicAPI, который позволяет функциям C-API иметь полные подписи Python). Несколько других старых функций (и несколько более новых функций, например xrange, sliceи itertools.isliceдля согласованности) работают аналогичным образом, но, по большей части, Guido и остальные основные разработчики, похоже, согласны с вами. Документы 2.0+ даже описывают rangeи друзей, как если бы они были перегрузками в стиле C ++, а не показывают действительную запутанную подпись.
abarnert
2
@CodyPiersall: На самом деле, вот цитата из argclinicдискуссии Гвидо , когда Ник Коглан придумал способ дать rangeоднозначное определение : «Пожалуйста, не делайте так, чтобы людям было проще скопировать мое худшее дизайнерское решение». Итак, я уверен, что он согласен, что rangeэто сбивает с толку, как написано.
abarnert
846

Основное недоразумение заключается в том, что 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

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

(В качестве примечания, если print(iter(a))вы заметите, что rangeиспользуется тот же listiteratorтип, что и list. Как это работает? A listiteratorне использует ничего особенного, 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)?

abarnert
источник
10
Это довольно важная вещь, чтобы разобраться. Я полагаю, что различия между Python 2 и 3 могли привести к моей путанице в этом вопросе. В любом случае, я должен был понять , так как rangeперечислено (наряду с listи tuple) как тип последовательности .
Рик поддерживает Монику
4
@RickTeachey: На самом деле, в версии 2.6+ (я думаю; может быть, 2.5+) xrangeтоже последовательность. Смотри 2.7 документа . Фактически, это всегда было почти последовательностью.
abarnert
5
@RickTeachey: На самом деле я был не прав; в 2.6-2.7 (и 3.0-3.1) он утверждает, что является последовательностью, но это все еще просто почти последовательность. Смотрите мой другой ответ.
abarnert
2
Это не итератор, это последовательность (итерируемая в терминах Java, IEnumerable из C #) - что-то с .__iter__()методом, который будет возвращать итератор. Это в свою очередь может быть использовано только один раз.
Смит Джонт
4
@ThomasAhle: Потому rangeчто не проверяет типы, когда это не целое число, поскольку всегда возможно, что у типа есть __eq__совместимый тип int. Конечно, strочевидно, что это не сработает, но они не хотели замедлять работу, явно проверяя все типы, которых там нет (и, в конце концов, strподкласс мог бы переопределить __eq__и содержаться в range).
ShadowRanger
378

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

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

  1. Проверьте, что число находится между startи stop, и
  2. Убедитесь, что значение шага не «перешагивает» наш номер.

Например, 994в range(4, 1000, 2)так как :

  1. 4 <= 994 < 1000, а также
  2. (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'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);
}

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

/* 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)
Wim
источник
145

Чтобы добавить ответ Martijn, это соответствующая часть источника (в 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
совать
источник
11
@ChrisWesseling: я думаю, что это достаточно разная информация (и достаточная), что редактирование ответа Мартина здесь не было бы уместным. Это суждение, но люди обычно ошибаются, если не вносят радикальные изменения в ответы других людей.
abarnert
106

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

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

В то же время:

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

Объекты Range имеют очень небольшое поведение: они поддерживают только индексацию, итерацию и 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__использовалось неявно, когда оптимизация не применяется.

abarnert
источник
4
Из комментариев здесь: улучшаетсяxrange.__contains__ , похоже, что они не перенесли его в Python 2 просто для того, чтобы оставить элемент неожиданности для пользователей, и было слишком поздно, o_O. countИ index патч был добавлен позже. Файл в то время: hg.python.org/cpython/file/d599a3f2e72d/Objects/rangeobject.c
Ашвини Чаудхари
12
У меня есть зловещее подозрение, что некоторые основные разработчики Python неравнодушны к "жесткой любви" к Python 2.x, потому что они хотят побудить людей переключиться на далеко превосходящий python3 :)
wim
4
Кроме того, держу пари, что добавлять новые функции к старым версиям - огромное бремя. Представьте, что вы пошли в Oracle и сказали: «Послушайте, я нахожусь на Java 1.4, и я заслуживаю лямбда-выражений! Бэкпортируйте их даром».
Роб Грант
2
@RickTeachey да, это всего лишь пример. Если бы я сказал 1.7, это все равно применимо. Это количественная разница, а не качественная. По сути (неоплачиваемые) разработчики не могут вечно создавать крутые новинки в 3.x и переносить их в 2.x для тех, кто не хочет обновляться. Это огромное и нелепое бремя. Думаешь, что-то не так с моими рассуждениями?
Роб Грант
3
@RickTeachey: 2,7 было между 3,1 и 3,2, а не около 3,3. И это означает, что 2.7 был в rc, когда были внесены последние изменения в 3.2, что облегчает понимание комментариев об ошибках. Во всяком случае, я думаю, что они сделали несколько ошибок в ретроспективе (особенно если предположить, что люди будут мигрировать через, 2to3а не через код с двумя версиями с помощью таких библиотек, как six, поэтому у нас есть такие вещи, dict.viewkeysкоторые никто никогда не будет использовать), и были несколько изменений, которые только что вышли слишком поздно в 3.2, но по большей части 2.7 был довольно впечатляющим "последним 2.x когда-либо" релизом.
abarnert
48

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

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

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

Стефан Почманн
источник
28

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

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

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

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

но:

Decimal(sys.maxsize) in range(sys.maxsize) довольно медленно .

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

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

Славомир Ленарт
источник
4
Будьте осторожны с плавающими большими числами. На большинстве машин, float(sys.maxsize) != sys.maxsize)хотя sys.maxsize-float(sys.maxsize) == 0.
Holdenweb
19

TL; DR

Возвращаемый объект на range()самом деле является rangeобъектом. Этот объект реализует интерфейс итератора, так что вы можете последовательно перебирать его значения, как генератор, список или кортеж.

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

RBF06
источник
0
  1. Благодаря оптимизации очень легко сравнивать данные целые числа только с минимальным и максимальным диапазоном.
  2. Причина того, что функция range () является настолько быстрой в Python3, заключается в том, что здесь мы используем математическое обоснование границ, а не прямую итерацию объекта диапазона.
  3. Итак, для объяснения логики здесь:
    • Проверьте, находится ли число между началом и остановкой.
    • Проверьте, не превышает ли значение точности шага наш номер.
  4. Возьмите пример, 997 находится в диапазоне (4, 1000, 3), потому что:

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

Наруто
источник
1
Можете ли вы поделиться источником для этого? Даже если это звучит правдоподобно, было бы хорошо обосновать эти претензии реальным кодом
Нико
Я думаю, что это пример того, что может быть реализовано. Не совсем так, как это реализовано. Хотя никакой ссылки не предоставлено, это хороший совет, достаточно хороший, чтобы понять, почему проверка включения для диапазона может быть намного быстрее, чем список или кортеж
Мохаммед Шариф C
0

Попробуйте x-1 in (i for i in range(x))для больших xзначений, которые используют понимание генератора, чтобы избежать вызова range.__contains__оптимизации.

benjimin
источник