Привет, Хабр!
Сегодня коротко, но по существу разберёмся, зачем вообще нужен enumerate()
в Python и почему он почти всегда лучше, чем старый добрый range(len(...))
.
Проблема range(len(...))
Сразу:
records = fetch_records()
for i in range(len(records)):
process(records[i])
С виду — ничего страшного. Даже читается вроде знакомо. Но чем больше таких конструкций в кодовой базе, тем хуже.
Читаемость
Такой цикл требует ментального дешифрования:
i
— это что? индекс? порядковый номер? offset?records[i]
— где сам элемент? Где структура?process(records[i])
— приходится сканировать глазами вверх, чтобы вспомнить, откудаrecords
, и чтоi
— к нему.
Это не очевидный код. Читатель должен это угадывать — каждый раз. Учитывая, что Python — динамический язык, мы вообще не знаем, что такое records
.
Хрупкость: генераторы, ленивые структуры, итераторы
А что будет, если records
— это не список, а генератор? Например:
records = (record for record in db_stream())
len(records)
не сработает вовсе: TypeError: object of type 'generator' has no len()
. Даже если обернёте list(...)
, потеряете ленивость, получите RAM-overflow на больших входных.
Другими словами:
range(len(...))
— это допущение, что ваш объект точно индексируем и точно заранее измерим. Это ложная уверенность.
Баги
Пример:
for i in range(len(records)):
if should_skip(records[i]):
continue
process(records[i])
Теперь представим, что кто-то хочет отложить records[i]
в переменную:
for i in range(len(records)):
if should_skip(records[i]):
continue
rec = records[i]
process(rec)
Но уже два обращения по индексу. А если вставим удаление?
for i in range(len(records)):
if should_skip(records[i]):
records.pop(i)
Ломаем индексы на ходу, т.к удаление смещает следующую итерацию, и цикл перепрыгивает элементы.
Почти всегда это приводит к одному из двух:
IndexError
(если удалили последний элемент),или тихой логической ошибке, которую потом дебажим неделю, обвиняя кеш, БД и прокси.
Стандарт нарушается
Python считается языком, где for element in iterable
— это норма. Когда вы пишете for i in range(len(...))
, вы нарушаете естественный паттерн языка.
Это похоже на то, как если бы в Rust кто-то вручную писал for i in 0..vec.len()
вместо for (i, item) in vec.iter().enumerate()
— да, можно, но сразу видно: человек ещё не прочувствовал стиль языка.
Как enumerate() делает код чище
Берём тот же цикл:
for i, record in enumerate(records):
process(record)
С первого взгляда — минимальная правка. Но если копнуть, это переход от «примитивного императивного кода» к «декларативной конструкции, встроенной в семантику языка».
Читаемость:
enumerate()
говорит на человеческом языке:
for index, value in enumerate(collection):
— ты читаешь: "Итерируйся по collection
, на каждом шаге у тебя будет index
и value
". Без len
, без collection[index]
, без зрительного слома, где что. Код документирует сам себя.
Безопасность
В отличие от range(len(...))
, enumerate()
:
не зависит от индексируемости: можно пройтись по генератору, файлу, сокету, пайплайну;
не требует
len()
: не вызывается ничего, что может сломаться на ленивых структурах;не дублирует обращение к коллекции:
records[i]
— больше не нужно.
records = get_lazy_stream()
for idx, rec in enumerate(records):
process(rec) # здесь всё живёт
А если бы get_lazy_stream()
возвращал генератор, range(len(...))
выдал бы TypeError
.
Поведение
Поглядим на enumerate()
:
>>> e = enumerate(['?', '?', '?'])
>>> e
<enumerate object at 0x...>
>>> next(e)
(0, '?')
>>> next(e)
(1, '?')
В CPython, enumerate()
реализован в bltinmodule.c
как builtin_enumerate
, и внутри просто инкрементирует счётчик, вызывая PyIter_Next
для источника.
Формально:
static PyObject *
enumerate_next(enumerateobject *en)
{
PyObject *next_item = PyIter_Next(en->it);
...
result = PyTuple_Pack(2, en->index, next_item);
en->index++;
}
Это максимально дешёвый итератор. Он:
не держит копии,
не вызывает
len()
,не требует seekable-источника.
enumerate() умеет начинать с любого значения
for i, el in enumerate(seq, start=1):
print(f"{i}: {el}")
Аргумент start
полезен в пользовательских интерфейсах (CLI, логах, отчётах), где 1-based нумерация привычнее, чем 0.
Применение
Классический цикл с логированием
for idx, user in enumerate(users):
if idx % 1000 == 0:
logger.info("Обработано %d пользователей", idx)
enrich(user)
Без ручного счётчика.
Объединение с zip — параллельная обработка
for idx, (x, y) in enumerate(zip(xs, ys)):
result.append(x + y)
Работает даже если xs
, ys
— это генераторы. range(len(...))
бы тут не сработал: у zip нет длины.
Чтение из файла + отслеживание строк
with open("config.ini") as f:
for lineno, line in enumerate(f, start=1):
if '=' not in line:
raise ValueError(f"Ошибка на строке {lineno}: {line.strip()}")
lineno
есть, line
есть. Никаких .readlines()
(которые грузят всё в память), никакого range
.
Быстрая индексация в генераторе
indexed = {i: val for i, val in enumerate(seq) if val}
Однострочник, который всё делает правильно: итерируется, фильтрует и создаёт map.
Мутация по индексам (safe)
for idx, el in enumerate(items):
if is_corrupted(el):
items[idx] = repair(el)
В отличие от удаления (где всё ломается), замена значения по индексу через enumerate()
— безопасна.
Асинхронная версия — aenumerate() (в trio)
async for idx, event in aenumerate(event_stream(), start=42):
await process_event(idx, event)
Работает ровно как sync-версия. А если нет aenumerate
, можно руками:
async def aenumerate(aiter, start=0):
idx = start
async for val in aiter:
yield idx, val
idx += 1
Заключение
enumerate()
— это один из тех инструментов, которые вроде бы мелочь, но реально спасают от бардака: избавляют от лишней логики, не ломаются на генераторах, и читаются легко. А если у вас есть интересный опыт — делитесь в комментариях, обсудим.
Если настройки окружения и постоянные ошибки вам знакомы, то этот открытый урок 24 июля — именно то, что нужно. Узнайте, как Docker упрощает деплой и разработку Python-приложений. Мы разберем:
Что такое Docker и зачем он нужен.
Как контейнеры упрощают работу.
Как создавать и использовать Docker-образы для Python.
Присоединяйтесь, чтобы понять, как профессионалы решают проблемы окружений и деплоя.
Пройдите вступительный тест курса "Python Developer. Basic" и получите скидку на обучение.
Комментарии (4)
d_ilyich
27.06.2025 09:45enumerate() умеет начинать с любого значения
for i, el in enumerate(seq, start=1):
К сожалению,
start
-- это начальное значение индекса, а не начальный элемент последовательности. А есть ли лаконичный способ начатьenumerate
с любого элемента? Что-то типаfor i, obj in enumerate(iterable).skip(n)
Для списка можно выкрутиться
for i, obj in enumerate(my_list[n:], start=n)
но
my_list[n:]
создаст новый список, если я правильно понимаю.
Wiggin2014
почему не написать for record in records? зачем тут enumerate?
pda0
Тут незачем, но иногда нужен и элемент и его индекс.
d_ilyich
Отправить номер строки в лог. Произвести дополнительные действия над элементами в определённых позициях. Дополнительная возможность в python-стиле.