Привет, Хабр!

Сегодня коротко, но по существу разберёмся, зачем вообще нужен 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)


  1. Wiggin2014
    27.06.2025 09:45

    почему не написать for record in records? зачем тут enumerate?


    1. pda0
      27.06.2025 09:45

      Тут незачем, но иногда нужен и элемент и его индекс.


    1. d_ilyich
      27.06.2025 09:45

      Отправить номер строки в лог. Произвести дополнительные действия над элементами в определённых позициях. Дополнительная возможность в python-стиле.


  1. d_ilyich
    27.06.2025 09:45

    enumerate() умеет начинать с любого значения
    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:] создаст новый список, если я правильно понимаю.