
Что такое range и почему это не список
В Python 3 range — это встроенный тип, а не функция. Вызов range(10) не создаёт коллекцию чисел, а конструирует объект, который хранит три значения: start, stop и step. Элементы вычисляются на лету при обращении. Именно поэтому range(10**9) создаётся мгновенно и не съедает гигабайты оперативной памяти — в отличие от Python 2, где range возвращал настоящий список, а для экономии приходилось брать xrange.
Читайте также: Android SDK Manager: установка, команды и типичные ошибки.
Объект range ведёт себя как последовательность: поддерживает len(), индексацию, отрицательные индексы, срезы и оператор in. Но он неизменяем — присвоить r[0] = 5 нельзя, и это логично: диапазон описывается математически, а не хранится поэлементно.
r = range(0, 10, 2)
print(r) # range(0, 10, 2) — сам объект, а не список
print(list(r)) # [0, 2, 4, 6, 8]
print(len(r)) # 5
print(r[3]) # 6
print(6 in r) # True
print(r[-1]) # 8
Отдельная деталь: range нельзя склеивать оператором +. Выражение range(3) + range(3) выбросит TypeError, потому что тип не реализует конкатенацию. Нужен itertools.chain или объединение через list, если порядок и повторяемость важны.
Три аргумента: start, stop, step
Сигнатура range допускает три формы вызова, и путаница между ними — источник половины ошибок новичков:
- range(stop) — от 0 до stop, не включая stop;
- range(start, stop) — от start до stop, не включая stop;
- range(start, stop, step) — с заданным шагом, который может быть отрицательным.
Главное правило: правая граница всегда не входит в диапазон. Это полуинтервал [start, stop), и он выбран не случайно: количество элементов равно (stop - start + step - 1) // step при положительном шаге, а len(range(a, b)) = b - a. Никаких плюс-минут один при подсчёте длины.
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(2, 5))) # [2, 3, 4]
print(list(range(0, 10, 3))) # [0, 3, 6, 9]
print(list(range(5, 0, -2))) # [5, 3, 1]
# Шаг не может быть нулём — бесконечный цикл невозможен по определению
range(1, 10, 0) # ValueError: range() arg 3 must not be zero
Если шаг положительный, а start больше stop — диапазон пуст. Если шаг отрицательный, а start меньше stop — тоже пуст. range молча вернёт ноль элементов, никакой ошибки не будет. Проверяйте знак шага, а не только границы.
Сравнение range, list и numpy.arange
Выбор между тремя инструментами определяется задачей: нужен ли произвольный доступ, дробный шаг и векторные операции. Таблица ниже показывает разницу в поведении и цене.
| Критерий | range | list(range(n)) | numpy.arange |
|---|---|---|---|
| Тип объекта | неизменяемая последовательность | список Python | ndarray |
| Память при 106 элементах | ~48 байт | ~8 МБ только под указатели | ~8 МБ (int64) или ~4 МБ (int32) |
| Дробный шаг | нет (TypeError) | нет | да, но накапливает погрешность |
| Проверка вхождения x in obj | O(1), арифметика | O(n), линейный поиск | не применимо, вернёт массив булевых |
| Срезы | возвращают range | возвращают новый список | возвращают view |
| Векторные операции | нет | нет | да |
| Типичное применение | циклы, индексация | материализация данных | научные расчёты, графики |
Ключевой вывод: list(range(...)) нужен только тогда, когда вы собираетесь менять элементы, сортировать, добавлять или передавать коллекцию туда, где требуется именно список. Для обхода цикла материализация — чистая потеря памяти и времени.
Отрицательный шаг, срезы и проверка вхождения
Обратный проход — самый частый сценарий использования отрицательного шага. Здесь важно помнить: stop тоже не включается, поэтому для прохода до нуля включительно пишут range(n, -1, -1), а не range(n, 0, -1).
n = 5
print(list(range(n, 0, -1))) # [5, 4, 3, 2, 1]
print(list(range(n, -1, -1))) # [5, 4, 3, 2, 1, 0] — ноль включён
print(list(range(10)[::-1])) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
# Срез диапазона остаётся диапазоном, а не списком
print(range(0, 100, 5)[2:5]) # range(10, 25, 5)
Проверка вхождения в range работает за константное время — Python не перебирает элементы, а решает сравнение по модулю. Это редкая возможность для последовательности: у списка такая проверка линейна.
huge = range(0, 10**12, 3)
print(9 in huge) # True — мгновенно, несмотря на триллион элементов
print(10 in huge) # False
range — один из немногих объектов, который умеет отвечать на вопрос о вхождении элемента без итерации. Если вам нужна быстрая проверка на принадлежность арифметической прогрессии, range даст её даром.
Память и скорость: сколько это стоит
Разницу удобно измерить через sys.getsizeof. Размер объекта range не зависит от длины диапазона — он определяется только тремя хранимыми числами и служебным заголовком.
import sys
r = range(10**9)
print(sys.getsizeof(r)) # 48 байт
small = range(10)
print(sys.getsizeof(small)) # те же 48 байт
# Список такого же размера физически не влезет в память
# list(range(10**9)) — это примерно 8 ГБ только под указатели
По скорости обхода range и список близки: итерация всё равно распаковывает целые числа. Но создание диапазона — операция O(1), тогда как list(range(n)) требует O(n) времени и памяти. В коде, который вызывается миллион раз, эта разница складывается в заметные секунды.
Личное наблюдение автора: пару лет назад я разбирал чужой скрипт обработки логов, который стабильно падал по OOM на файле в 400 МБ. Причина оказалась смешной — в одной строке стояло list(range(len(chunk))) для простого перебора индексов. Замена на range без материализации убрала падение целиком, а профилировщик показал экономию почти 300 МБ. С тех пор я считаю list(range(...)) без последующих мутаций списка красным флагом на код-ревью.
Практические приёмы и подводные камни
Несколько правил, которые экономят время и нервы:
- enumerate вместо range(len()) — короче, читаемее и работает с любым итерируемым объектом, включая генераторы;
- zip для параллельного обхода — не нужно синхронизировать индексы вручную;
- reversed(range(n)) работает и не создаёт копию, но reversed(list) уже требует поддержки __reversed__ или __len__;
- дробный шаг — range его не умеет, и это осознанное решение. Для неравномерных шагов берите numpy.linspace или генератор;
- range не итератор — его можно обходить многократно, в отличие от генератора, который исчерпывается после первого прохода.
items = [11, 22, 33]
# Плохо: индекс ради индекса
for i in range(len(items)):
print(i, items[i])
# Хорошо
for i, item in enumerate(items, start=1):
print(i, item) # 1 11, 2 22, 3 33
# Параллельный обход
names = [a, b, c]
for name, value in zip(names, items):
print(name, value)
Частые вопросы
Почему stop не включается? Полуинтервал даёт чистую арифметику длины и стыкуется без наложений: range(0, 5) и range(5, 10) идеально продолжают друг друга.
range(0.1, 1, 0.1) сработает? Нет, TypeError. Для дробных значений используйте numpy.arange с осторожностью (накапливается погрешность) или list comprehension с Decimal.
Можно ли менять диапазон во время цикла? Объект неизменяем, но саму переменную можно переприсвоить — цикл при этом продолжит идти по исходному объекту, потому что итератор уже создан.
range(5) == range(0, 5)? Да, диапазоны сравниваются как последовательности, а не по идентичности объекта.
Коротко от автора
range — пример удачного дизайна стандартной библиотеки: минимум сущностей, предсказуемое поведение и нулевая цена по памяти. Освойте три аргумента, держите в голове полуинтервал и не материализуйте диапазон без нужды — и код станет заметно опрятнее. Если после прочтения вы поймали себя на мысли, что где-то в вашем проекте есть лишний list(range(...)), значит статья отработала свою пользу.