2025, Oct 18 00:33
Присваивание среза из того же списка в Python: как это работает
Разбираем присваивание среза списков в Python: создаётся ли временный список или возможна оптимизация на месте. Дизассемблирование CPython, причины и варианты.
Когда вы присваиваете одному срезу списка Python другой срез из этого же списка, возникает закономерный вопрос: создаётся ли справа временный список или существует оптимизация на месте, позволяющая обойтись без лишнего выделения памяти? Понимание того, что происходит на самом деле, помогает писать предсказуемый, эффективный код и не полагаться на оптимизации, которые в действительности не гарантированы.
Постановка задачи
Базовая операция выглядит так:
L[a:b] = L[c:d]В руководстве по Python сказано о срезах следующее:
Любая операция со срезом возвращает новый список с запрошенными элементами.
Однако там не уточняется, используется ли при присваивании среза из этого же списка временный список или возможно обойтись без него за счёт оптимизации.
Минимальный код, который поднимает вопрос
Следующая функция читает срез и записывает его в другой срез того же списка:
def demo_fn():
    lst_ref = [1, 2, 3, 4]
    lst_ref[0:1] = lst_ref[2:3]
    return lst_refНемного изменённая версия делает промежуточный результат ещё нагляднее, расширяя правую часть:
def demo_plus():
    buf = [1, 2, 3, 4]
    buf[0:1] = buf[2:3] + [5, 6]
    return bufЧто происходит на самом деле и почему
Сперва вычисляется правая часть, и это вычисление создаёт новый список. Иными словами, выражение со срезом справа возвращает свежий список с нужными элементами до того, как будет обновлена левая часть. Это подтверждается дизассемблированием такого кода в CPython: чтение среза выполняется через BINARY_SLICE, а запись — через STORE_SLICE, причём материализация списка происходит между ними. Раздельные операции чтения и записи ясно показывают, что никакого особого «memmove» внутренностей списка не применяется.
У такого поведения есть две практические причины. Во‑первых, элементы списка — это независимые ссылки на объекты. Повторное использование тех же объектов в другой позиции требует корректной правки счётчиков ссылок; простое копирование сырой памяти этого не делает и нарушило бы корректность. Во‑вторых, присваивание по срезу может менять длину списка, заставляя сдвигать элементы. «На месте» перенос памяти для общего случая присваивания срезов быстро превращается в сложную и рискованную операцию, особенно при перекрывающихся областях источника и назначения.
Важно и то, что в общем случае Python не может опираться на трюки, специфичные для встроенного списка. Объект слева может вообще не быть встроенным list; разные типы могут настраивать чтение и запись срезов. Такая гибкость делает межобъектные и межтиповые спецоптимизации на уровне байткода намного менее осуществимыми.
Практика и варианты
Надёжное правило простое: выражение со срезом справа порождает новый список, и уже он присваивается целевому срезу. Если ваша цель — не создавать отдельный список явно, можно сразу передать итерируемый объект, например генератор, который выдаёт нужные элементы по индексу вместо предварительного взятия среза. Тогда вы по‑прежнему делаете присваивание по срезу, но наполняете его итератором, а не заранее собранным списком.
seq = [...]  # некоторый существующий список
def iter_window(source, lo, hi):
    for pos in range(lo, hi):
        yield source[pos]
seq[0:10] = iter_window(seq, 10, 20)Так выбираются элементы по индексу без предварительного построения списка через срез. По мере того как издержки исполнения в CPython снижались, подача присваивания по срезу из генератора стала вполне жизнеспособным приёмом; к тому же он легче поддаётся оптимизации при сохранении ясной семантики.
Есть и более радикальная идея — прямые операции с памятью для «вклейки» указателей через ctypes. Схема могла бы выглядеть так: сначала создать исходный срез, чтобы счётчики ссылок были корректны, затем убедиться, что целевой диапазон заполнен «бессмертным» объектом вроде None, чтобы избежать преждевременного уменьшения счётчиков, потом перезаписать внутренние указатели с помощью ctypes.memmove и убрать временный срез. Теоретически это возможно, но на деле это попытка заново реализовать гарантии, которые Python уже даёт, с минимальной или нулевой практической выгодой и значительно более высоким риском.
Наконец, важно помнить: речь идёт о списках Python. Массивы с «сырыми» числовыми данными — это другое. Для численных задач стандартные массивы и, чаще, массивы NumPy хранят не ссылки на объекты, а сами данные подряд, что позволяет выполнять быстрые, непрерывные операции с памятью. В NumPy присваивание по срезу между массивами может распознать массивы с обеих сторон и выполнить эффективное копирование, а взятие среза зачастую возвращает представление (view), а не копию. Если ваш случай — численные вычисления, чаще всего это правильный инструмент.
Зачем это важно
Ожидания насчёт мутаций, временных объектов и производительности влияют на структуру кода. Предположение о «перемещении памяти на месте» для срезов списков приводит к неверной ментальной модели и неожиданностям. Осознание того, что правый срез превращается в новый список, проясняет стоимость по времени и памяти и помогает решить, когда лучше применить итерируемое из генератора, простой цикл for или профильный тип массива.
Выводы
Присваивание по срезу для списков сначала вычисляет правую часть и создаёт новый список. Дизассемблирование в CPython показывает отдельное чтение среза и последующую запись среза, а не слитный перенос «на месте». Поскольку элементы списка — это ссылки, чьи счётчики нужно корректно обновлять, и поскольку присваивание по срезу может менять размер списка, всеобъемлющей оптимизации «на месте» для списков в Python нет. Если нужно избежать построения списка справа, подайте элементы из итератора. Если требуются семантики непрерывной памяти для чисел, используйте специализированные массивы — обычно NumPy. Главное — опираться на реальные семантики, чтобы код оставался и корректным, и понятным.
Статья основана на вопросе с StackOverflow от user29120650 и ответе jsbueno.