Насколько я понимаю, функция 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() под капотом, что делает его таким быстрым?


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

2304
Rick supports Monica 6 Май 2015 в 18:32

9 ответов

Лучший ответ

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

2049
Martijn Pieters 12 Фев 2020 в 09:05

Если вам интересно почему эта оптимизация была добавлена в 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 3.2, но и переработал оптимизированный __contains__ (который разделяет ту же математику с {{ X7}} и непосредственно используется 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__ неявно использовался, когда оптимизация не применяется.

102
ShadowRanger 13 Сен 2019 в 20:06

Чтобы добавить к ответу Мартин, это соответствующая часть источника (в 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
137
poke 7 Май 2015 в 06:25

Основное недоразумение заключается в том, что мы думаем, что 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 и list заключается в том, что range является последовательностью ленивый или динамический ; он не запоминает все свои значения, он просто запоминает свои start, stop и step и создает значения по запросу в __getitem__.

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


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

Но в языке, похоже, нет ничего, что гарантирует , что это произойдет. Как указывает Ашвини Чаудхари, если вы дадите ему нецелое значение, вместо того, чтобы конвертировать в целое число и выполнять математический тест, он вернется к итерации всех значений и сравнивает их одно за другим. И только потому, что версии 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)?

815
wim 1 Окт 2016 в 05:46

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

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

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

46
Stefan Pochmann 22 Май 2015 в 12:18

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

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

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

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

Но:

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

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

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

22
Sławomir Lenart 22 Сен 2019 в 09:33

Вот реализация в 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;
    }
}
11
Sanan Fataliyev 2 Май 2019 в 20:41

TL ; DR

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

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

15
RBF06 21 Ноя 2019 в 04:01

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

В 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)
366
wim 19 Сен 2016 в 18:16