Чому "1000000000000000 в діапазоні (1000000000000001)" так швидко на Python 3?


2113

Наскільки я розумію, що 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 та пояснення тим, хто цікавиться.


70
Зауважте, що це так лише в тому випадку, якщо предмет, який ми перевіряємо, є a boolабо longтип, а для інших типів об'єкта він зійде з розуму. Спробуйте з:100000000000000.0 in range(1000000000000001)
Ашвіні Шадхарі

10
Хто вам сказав, що rangeце генератор?
abarnert

7
@abarnert Я думаю, що зміна, яку я зробила, залишила плутанину недоторканою.
Рік підтримує Моніку


28
xrange()Об'єкти @Superbest не мають __contains__методу, тому перевірка елементів повинна пройти крізь усі елементи. Плюс є мало інших змін range(), наприклад, він підтримує нарізку (що знову повертає rangeоб’єкт), а тепер також є countі indexметоди, щоб зробити його сумісним з collections.SequenceABC.
Ашвіні Шадхарі

Відповіді:


2169

Об'єкт Python 3 range()не створює числа одразу; це об'єкт розумної послідовності, який видає числа на вимогу . Все, що він містить, - це значення вашого початку, стопу та кроку, після того, як ви повторюєте об'єкт, наступне ціле число обчислюється кожною ітерацією.

Об'єкт також реалізує 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). Оскільки це все виконано в оптимізованому коді С і 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, за послідовністю) працюють так само, але здебільшого Гвідо та решта основних розробників, здається, згодні з вами. Документи 2.0+ навіть описують rangeі друзями, ніби вони перевантажують стиль C ++, а не показують фактичну плутанину підпису.
abarnert

2
@CodyPiersall: Насправді, ось цитата з Гуйдо, argclinicобговорення, коли Нік Коглан придумав спосіб rangeоднозначного визначення : "Будь ласка, не полегшуйте людям копіювати моє найгірше дизайнерське рішення". Отже, я майже впевнений, що він згоден, що rangeце заплутано як написано.
abarnert

843

Основне непорозуміння тут полягає в мисленні, яке 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)?


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

377

Користуйся джерелом , Лука!

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

144

Щоб додати до відповіді Мартійна, це відповідна частина джерела (в 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знаходиться в заданому діапазоні (хоча це виглядає трохи складніше в С).

Якщо це не 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

105

Якщо вам цікаво, чому до цієї оптимізації додано 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__неявно використовували, коли оптимізація не застосовується.


4
З коментарів тут: поліпшенняxrange.__contains__ , схоже, що вони не підтримали його на Python 2 просто для того, щоб залишити елемент сюрпризу для користувачів, і вже було пізно o_O. countІ index патч був доданий пізніше. Файл у той час: hg.python.org/cpython/file/d599a3f2e72d/Objects/rangeobject.c
Ашвіні

12
У мене є зловісна підозра, що деякі основні розробники пітона є частковими до "жорсткої любові" до python 2.x, тому що вони хочуть заохотити людей перейти на набагато перевершений python3 :)
Вім

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 було в РК, коли відбулися останні зміни до 3.2, що полегшує розуміння коментарів про помилки. У будь-якому випадку, я думаю, що вони зробили кілька помилок в ретроспективі (особливо припускаючи, що люди мігруватимуть через 2to3замість коду подвійної версії за допомогою бібліотек на кшталт six, ось чому ми отримали такі речі, dict.viewkeysякими ніхто ніколи не збирається користуватися), і були декілька змін, які були надто пізні у 3.2, але здебільшого 2.7 були досить вражаючим "останнім 2.x коли-небудь" випуском.
abarnert

47

Інші відповіді це вже добре пояснили, але я хотів би запропонувати ще один експеримент, що ілюструє природу об'єктів діапазону:

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

Як бачимо, об’єкт діапазону - це об'єкт, який запам'ятовує свій діапазон і може бути використаний багато разів (навіть під час повторення над ним), а не просто одноразовий генератор.


27

Це все про ледачому підході до оцінки і деякої додаткової оптимізації в range. Значення в діапазонах не потрібно обчислювати до реального використання або ще більше через додаткову оптимізацію.

До речі, ваше ціле число не таке велике, врахуйте sys.maxsize

sys.maxsize in range(sys.maxsize) досить швидко

завдяки оптимізації - легко порівняти задане ціле число лише з min та max діапазоном.

але:

Decimal(sys.maxsize) in range(sys.maxsize) досить повільно .

(у цьому випадку оптимізація не відбувається range, тому якщо python отримає несподіваний десятковий знак, python порівняє всі числа)

Ви повинні знати про деталі впровадження, але на них не слід покладатися, оскільки це може змінитися в майбутньому.


4
Будьте обережні з плаваючими великими цілими числами. На більшості машин, float(sys.maxsize) != sys.maxsize)хоча sys.maxsize-float(sys.maxsize) == 0.
holdenweb

18

TL; DR

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

Але він також реалізує __contains__інтерфейс, який насправді називається, коли об'єкт з'являється в правій частині inоператора. У __contains__()методі повертає boolвід наявності або відсутності пункту в лівій-стороні inзнаходиться в об'єкті. Оскільки rangeоб’єкти знають свої межі та крок, це реалізується в O (1) дуже просто.


0
  1. Завдяки оптимізації дуже легко порівняти задані цілі числа лише з min та max діапазоном.
  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__оптимізацію.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.