Deep Engineering
Продвинутый·Опубликовано·3.12 · 3.13 · 3.14·40 МИН

Списки изнутри: рост, сжатие и цена работы с началом

У списка два числа длины, и второго из Python не видно ни одним публичным именем. Из него следует всё остальное: почему одинаковые с виду списки занимают разное место, почему append дёшев, а insert(0) квадратичен, и почему начиная с 3.13 del L[0] и L.pop(0) оставляют объект в разном состоянии — при одинаковой длине в сто элементов список занимает 8056 байт вместо 1528.

Полное техническое изложение

У списка два числа длины, и в этом вся статья.

Первое знают все: len(L) — сколько в списке элементов. Второе не показывает ни одно публичное имя: сколько мест под указатели уже выделено. Их обычно называют длиной и ёмкостью, и почти всё поведение списка объясняется вторым числом, а не первым.

Пока ёмкость невидима, объяснить нечего. Почему два списка из десяти одинаковых чисел занимают разное место. Почему append в среднем дёшев, хотя массив при росте иногда приходится переносить целиком. Почему после девятисот удалений из списка в тысячу элементов он может продолжать занимать место под тысячу — и когда именно так бывает.

Статья идёт снизу вверх: сначала два числа и как они связаны, потом что происходит при удалении, и только потом время. Время последним не потому, что оно неважно, а потому, что без первых двух частей оно читается как набор несвязанных фактов.

Часть I. Длина и ёмкость

Зачем списку запас

Список в CPython — это три уровня, и их стоит разделить сразу: сам объект PyListObject, отдельный непрерывный массив указателей и сами объекты, которые лежат где угодно в памяти. Из этого следует и то, что считает getsizeof (заголовок плюс массив, без объектов), и почему сдвиг при удалении из начала двигает указатели, а не элементы.

Удлинить массив на месте иногда можно: realloc расширяет выделение, не переезжая, если за его концом есть свободное место. Чего нельзя — рассчитывать на это.

Проверить это можно адресом самого массива — полем ob_item до и после. Ёмкость меняется не на единицу, а скачками (разбор скачков ниже), и на каждом из них прогон сравнивает два адреса: тот же — значит, ничего не копировалось. На 3.13.7 со включённым GIL (на свободнопоточных сборках эта проверка не делается — ctypes читает поле по смещению, а раскладка объекта там другая):

6. ПЕРЕЕЗЖАЕТ ЛИ МАССИВ ПРИ РОСТЕ
---------------------------------
  Ходовое объяснение запаса: «массив нельзя удлинить на месте».
  Проверяется это адресом самого массива: ob_item до скачка ёмкости
  и после. Если адрес тот же — realloc расширил выделение на месте,
  ничего не копируя.
     600 append: скачков  24, массив остался на месте  13, переехал  11
   20000 append: скачков  53, массив остался на месте  41, переехал  12

Встречаются оба исхода — вот всё, что отсюда следует. Пропорция не воспроизводится: на 3.12.3 и 3.14.7 тот же прогон даёт двенадцать на двенадцать, и от запуска к запуску она тоже гуляет, потому что зависит от состояния аллокатора и истории процесса. Когда свободного места за концом нет, выделение переезжает вместе со всем содержимым, и заранее неизвестно, какой это будет раз.

Поэтому дело не в том, что расширение невозможно, а в том, СКОЛЬКО РАЗ за него приходится платить. Без запаса ёмкость менялась бы на каждом append; с запасом она меняется двадцать четыре раза на первые шестьсот — и копированием обходится не каждый из этих раз.

Выход — брать место с запасом. Комментарий над функцией, которая этим занимается, говорит и зачем, и с каким результатом:

This over-allocates proportional to the list size, making room for additional growth. The over-allocation is mild, but is enough to give linear-time amortized behavior over a long sequence of appends() in the presence of a poorly-performing system realloc().

перевод

Выделяется с запасом, пропорциональным размеру списка, чтобы оставить место для дальнейшего роста. Запас небольшой, но его хватает, чтобы на длинной последовательности appends() поведение было линейным в среднем даже при плохо работающем системном realloc()

«В среднем» здесь не смягчение: отдельный append, попавший на границу, стоит дорого — он копирует весь массив. Но такие границы расставлены так, что на длинной череде добавлений стоимость размазывается в постоянную.

Запас — это и есть второе число. Его не видно ни через len, ни через какой угодно метод списка. Зато его видно через размер объекта, и вот почему.

Ёмкость читается из getsizeof, и это не обходной путь

list.__sizeof__ в CPython считает размер по числу выделенных мест, а не по длине:

C
list___sizeof___impl(PyListObject *self)
{
    size_t res = _PyObject_SIZE(Py_TYPE(self));
    Py_ssize_t allocated = FT_ATOMIC_LOAD_SSIZE_RELAXED(self->allocated);
    res += (size_t)allocated * sizeof(void*);
    return PyLong_FromSize_t(res);
}

Отсюда способ, которым ёмкость читается во всех замерах этой статьи:

PYTHON
def capacity(seq):
    return (sys.getsizeof(seq) - sys.getsizeof([])) // struct.calcsize("P")

Делитель — размер указателя, а не восьмёрка: на 64-битной сборке это восемь, но писать восьмёрку значит зашить разрядность в формулу.

Он не требует ctypes — а обычный способ прочитать то же поле требует: он адресуется к полям объекта по id(obj). В прогоне этот способ стоит рядом с ctypes-чтением того же поля, и прогон их СРАВНИВАЕТ, а не объявляет совпавшими: на сборках с GIL они сошлись, а на свободнопоточных ob_size по тому же смещению читается нулём — раскладка объекта там другая. Именно поэтому во всех замерах ёмкость берётся из getsizeof.

деталь реализации · 3.13

То, что getsizeof считает по allocated, — свойство CPython, а не обещание языка. Документация функции говорит только, что учитывается «потребление памяти, непосредственно относящееся к объекту», и что под этим понимать, решает реализация типа.

Одинаковые списки, разное место

Пять способов построить список из десяти элементов. Длина у всех десять; размер — 136 байт у трёх и 184 у двух. Разделяет их одно: знал ли интерпретатор длину заранее.

Литерал и [0] * 10 знают её из самой записи: сколько элементов перечислено и сколько раз повторено, известно ещё до того, как список начал заполняться. У range для этого есть __len__, и конструктор списка им пользуется. Место берётся ровно под десять элементов, запаса нет.

Списковое включение и list от генератора не знают: генератор о своей длине ничего не сообщает, и элементы добавляются по одному. Значит, работает тот же механизм запаса, что и у append, и на десяти элементах он даёт шестнадцать мест.

184 байта против 136 — сорок восемь байт на список, шесть пустых мест по восемь. Мелочь. Но это мелочь, умножающаяся на число списков, и её стоит знать там, где списков миллионы: если включение только перебирает range, замена его на list(range(...)) убирает эту мелочь целиком, ничего не меняя в результате. Там, где включение что-то вычисляет, такой замены нет — и тогда запас — это цена вычисления, а не небрежности.

Формула роста

Запас берётся не «примерно», а по формуле, и формула в исходнике одна:

C
new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;

Читается она так: новая длина плюс её восьмая часть плюс шесть, округлённое вниз до кратного четырём. То есть запас — не константа, а ДОЛЯ: чем длиннее список, тем больше берётся про запас, и именно поэтому число перевыделений на длинной череде append растёт логарифмически, а не линейно.

Рядом с формулой в исходнике стоит вторая ветка — на случай, когда длина вырастает сразу на много, как при extend:

C
if (newsize - Py_SIZE(self) > (Py_ssize_t)(new_allocated - newsize))
    new_allocated = ((size_t)newsize + 3) & ~(size_t)3;

Смысл её в том, что запас не берётся, если прыжок и так больше запаса. При append длина растёт на единицу, и эта ветка не срабатывает никогда — поэтому дальше речь только о первой формуле.

Прогон проверяет её на каждом скачке ёмкости: на первых шестистах append их двадцать четыре, и совпали все двадцать четыре на пяти сборках: 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t и 3.14.7t — во всех числах, до последнего.

2. ЧТО ДЕЛАЕТ append: ЁМКОСТЬ РАСТЁТ СКАЧКАМИ
---------------------------------------------
  скачок происходит на длине, ёмкость становится равной:
    длина:      1     5     9    17    25    33    41    53    65    77    93   109   129   149
    ёмкость:    4     8    16    24    32    40    52    64    76    92   108   128   148   172
  всего скачков на первых 600 append: 24
  после 600 append: длина 600, ёмкость 672, запас 72

Ровно эта последовательность выписана и в комментарии рядом с формулой: The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ... (Последовательность роста такая: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...)

деталь реализации · 3.14

Здесь три разных утверждения, и путать их нельзя. Первое — свойство практическое: append в среднем дёшев, и это обещано комментарием в исходнике. Второе — как именно CPython этого добивается: вот эта формула роста, деталь реализации, которой нет в документации языка. Третье — наблюдение: двадцать четыре изменения ёмкости на первых шестистах append, одинаково на пяти сборках. Опираться в коде можно только на первое.

Часть II. Что список отдаёт обратно

«Список никогда не отдаёт память» — неверно, и неверно дважды

Утверждение ходит в двух видах, и оба не выдерживают проверки. Одна оговорка вперёд: речь дальше про ЁМКОСТЬ, то есть про размер массива указателей. Вернулась ли при этом память операционной системе — вопрос не списка, а распределителя памяти: освободившийся кусок он обычно держит у себя. Сколько памяти процесс занимает по данным системы (RSS), по этим числам предсказать нельзя.

Первый вид: «ёмкость только растёт». Не только. Условие, по которому массив ОСТАВЛЯЮТ как есть, стоит в той же list_resize, и сжатие начинается там, где оно не выполнено:

C
if (allocated >= newsize && newsize >= (allocated >> 1)) {
    assert(self->ob_item != NULL || newsize == 0);
    Py_SET_SIZE(self, newsize);
    return 0;
}

Пока новая длина не меньше половины ёмкости, массив остаётся прежним и меняется только длина. Как только длина падает ниже половины — управление уходит дальше, и массив перевыделяется под меньший размер. Прогон это и показывает: тысяча pop() с конца даёт двенадцать уменьшений ёмкости.

Второй вид: «раз отдаёт, то отдаёт всегда». Тоже нет — и вот здесь начинается то, ради чего статья написана.

Две операции с одним смыслом и разным следом

Из списка в тысячу элементов удаляется девятьсот — из начала, двумя способами. Длина после одинаковая: сто. Память — нет.

На 3.12 обе операции оставляют ёмкость 184, то есть обе сжали массив. На 3.13 и 3.14 L.pop(0) оставляет те же 184, а del L[0] оставляет 1000: список из ста элементов продолжает занимать место под тысячу, 8056 байт против 1528.

То же и с другого конца — всё на том же 3.13.7, и это половина, которой не ждёшь:

3. ТО ЖЕ САМОЕ ИЗ СЕРЕДИНЫ И С КОНЦА
------------------------------------
  операция, повторённая 900 раз над списком из 1000:
  операция             длина  ёмкость
  L.pop(0)               100      184
  del L[0]               100     1000
  L.pop(len(L)//2)       100      184
  del L[len(L)//2]       100     1000
  L.pop()                100      184
  del L[-1]              100     1000

L.pop() ёмкость отдаёт. del L[-1] — нет. Две строки, которые в обзоре кода выглядят одинаково безобидно.

Почему так: два разных пути в исходнике

Причина не выведена из чисел, а прочитана. До 3.13 удаление по индексу ничего не делало само, а передавало работу общему пути присваивания срезу:

C
static int
list_ass_item(PyListObject *a, Py_ssize_t i, PyObject *v)
{
    if (!valid_index(i, Py_SIZE(a))) {
        PyErr_SetString(PyExc_IndexError,
                        "list assignment index out of range");
        return -1;
    }
    if (v == NULL)
        return list_ass_slice(a, i, i+1, v);

list_ass_slice сдвигает хвост и вызывает list_resize — тот самый, с условием сжатия. Отсюда и уменьшение ёмкости.

В 3.13 у функции появился вариант, работающий под блокировкой, и удаление он делает сам:

C
static int
list_ass_item_lock_held(PyListObject *a, Py_ssize_t i, PyObject *v)
{
    ...
    PyObject *tmp = a->ob_item[i];
    if (v == NULL) {
        Py_ssize_t size = Py_SIZE(a);
        for (Py_ssize_t idx = i; idx < size - 1; idx++) {
            FT_ATOMIC_STORE_PTR_RELAXED(a->ob_item[idx], a->ob_item[idx + 1]);
        }
        Py_SET_SIZE(a, size - 1);
    }

list_resize здесь не вызывается вовсе: длина уменьшается на месте, массив остаётся какой был. Ни list_ass_slice, ни list_resize на этом пути больше не встречаются.

Изменение пришло вместе со свободнопоточной сборкой — отсюда и атомарные записи, и суффикс _lock_held в имени: функция рассчитывает, что список уже заблокирован тем, кто её позвал. Поведение ёмкости при этом побочный эффект, а не цель, и это видно по тому, что в «Что нового в Python 3.13» о нём нет ни строки: ни в разделе про изменения языка, ни в разделе про оптимизации.

деталь реализации · 3.13

Всё это — устройство CPython, а не правило языка. Ни одна версия не обещает, когда список сжимается: обещано только то, что del L[i] уберёт элемент. Писать код, который рассчитывает на сжатие после pop, так же неверно, как рассчитывать на его отсутствие после del.

Что освобождает ёмкость наверняка

Раз на поведение операции полагаться нельзя, полезно знать, что работает независимо от версии. Прогон перебирает варианты над списком, у которого после девятисот del ёмкость осталась тысячей:

4. ЧТО ОСВОБОЖДАЕТ ЁМКОСТЬ НАВЕРНЯКА
------------------------------------
  после 900 × del L[0]:        длина 100, ёмкость 1000
  L.copy() от него:            длина 100, ёмкость 100
  list(L) от него:             длина 100, ёмкость 100
  L[:] от него:                длина 100, ёмкость 100
  append + pop над ним:        длина 100, ёмкость 116

Три первых способа — это одно и то же действие: построить новый список, которому длина известна заранее. Место берётся ровно под сто элементов, а старый массив освобождается вместе со старым списком.

Последняя строка — не рецепт, а предупреждение: append с последующим pop ёмкость меняет, но приводит её не к длине, а к тому, что даст формула. Сто шестнадцать вместо ста — это снова запас.

Практическое правило одно: если список сильно уменьшился и должен жить дальше, его надо ПЕРЕСОБРАТЬ. L = L.copy() — не суеверие: пересборка любым из трёх способов отвечает одинаково на всех проверенных сборках, а операции удаления — нет.

Часть III. Цена работы с началом

Квадратичность, предъявленная, а не названная

«insert(0) квадратичен» — верно, но само по себе это слова. Проверяется оно удвоением: если время растёт как квадрат, удвоение длины даёт вчетверо больше времени.

3. КВАДРАТИЧНОСТЬ, ПРЕДЪЯВЛЕННАЯ, А НЕ НАЗВАННАЯ
------------------------------------------------
  наполнение через insert(0): длина удваивается — время вчетверо
  длина    всего, мкс   нс на операцию   отношение к предыдущей строке
    2500          583            233.4            —
    5000         2129            425.8        ×3.65
   10000         9066            906.6        ×4.26
   20000        36773           1838.7        ×4.06

  Для сравнения — та же таблица для append, где отношение около двух:
  длина    всего, мкс   нс на операцию   отношение к предыдущей строке
    2500           49             19.7            —
    5000          101             20.3        ×2.06
   10000          201             20.1        ×1.98
   20000          441             22.0        ×2.19

Правая колонка и есть вся разница. У append цена одной операции постоянна: около двадцати наносекунд и на двух с половиной тысячах элементов, и на двадцати тысячах. У insert(0) она растёт вместе с длиной, потому что каждая вставка сдвигает всё, что уже есть.

наблюдение замера

Все числа этого раздела сняты одним запуском 3.13.7 и сравниваются только между собой. Абсолютные значения — свойство машины замера (Intel Xeon 2,80 ГГц, 2 vCPU), а не языка; воспроизводится и переносится на другое железо отношение, а не наносекунда.

pop(0) против del L[0]: та же работа, разная цена

Изменение из части II видно не только в байтах. Внутри одного запуска 3.13.7:

2. ОПУСТОШИТЬ СПИСОК: С КОНЦА, С НАЧАЛА, ДВУМЯ СПОСОБАМИ
--------------------------------------------------------
  операция, повторённая 10000 раз       всего, мкс   нс на операцию
  L.pop()                                 236             23.6
  del L[-1]                               213             21.3
  L.pop(0)                             120766          12076.6
  del L[0]                              17342           1734.2
  d.pop()                                 272             27.2
  d.popleft()                             269             26.9

  L.pop(0) / del L[0] = ×7.0 — одна и та же работа по смыслу,
  разные пути в реализации. Что при этом происходит с ёмкостью — в shrink.py.

L.pop(0) дороже del L[0] в семь раз. Обе операции удаляют первый элемент и обе сдвигают хвост; различает их всё тот же путь в реализации — общий путь присваивания срезу против цикла на месте.

Обратите внимание на первые две строки той же таблицы: с КОНЦА списка те же два пути стоят 23,6 и 21,3 наносекунды, то есть различаются на проценты. У конца сдвигать нечего, и видно только накладные расходы вызова — они у обоих путей почти одинаковы. Семикратная разница возникает там, где на каждый вызов приходится сдвиг всего хвоста: различают эти два пути не проверки перед работой, а то, что каждый из них делает с хвостом.

Чего этим числом утверждать нельзя — что вся семикратная разница приходится на list_resize. Пути расходятся не в одном месте: pop ещё и ВОЗВРАЩАЕТ снятый элемент, то есть отдаёт наружу ссылку и следит за её временем жизни, а del ничего не возвращает. Что из этого сколько стоит, замер не раскладывает: он показывает суммарную цену двух разных путей, а list_resize на одном из них — названная по исходнику причина различия, а не измеренное слагаемое.

И второе, чего этим числом утверждать нельзя, — что «del стал быстрее». Это было бы сравнением времени между версиями, а оно здесь ничего не значит: у сборок разные компиляторы и разные флаги, и разделить «стал быстрее язык» и «стала быстрее сборка» нечем. Утверждать можно ровно то, что измерено внутри одного запуска: на 3.13.7 две операции с одинаковым смыслом стоят по-разному и оставляют объект в разном состоянии.

Чем за это платит deque

Стандартный ответ на «нужно работать с обоими концами» — collections.deque, и обещание там официальное:

Deques support thread-safe, memory efficient appends and pops from either side of the deque with approximately the same O(1) performance in either direction

перевод

Двусторонние очереди поддерживают потокобезопасные и экономные по памяти добавления и снятия с любой стороны с примерно одинаковой производительностью O(1) в обе стороны

Замер это подтверждает: у deque appendleft стоит 29,4 наносекунды против 29,3 у d.append, а popleft — 26,9 против 27,2 у d.pop. Против девятисот наносекунд на insert(0) это другой класс.

Но в той же документации стоит и вторая половина: Indexed access is O(1) at both ends but slows to O(n) in the middle (Доступ по индексу — O(1) на обоих концах, но замедляется до O(n) в середине). Вот во что это обходится на списке в сто тысяч элементов:

4. ЧЕМ ЗА ЭТО ПЛАТИТ deque: ЦЕНА ИНДЕКСА
----------------------------------------
  Обе стороны у deque дёшевы — но индекс перестал быть постоянным.
  выражение, повторённое 1000 раз в витке     нс на операцию
  L[0]                                              7.5
  L[50000]                                          9.0
  L[99999]                                          9.1
  d[0]                                             11.6
  d[50000]                                       2418.3
  d[99999]                                         18.1

У списка три числа почти совпали — индекс не зависит от позиции. У deque середина дороже краёв в сотни раз: это не список с двумя дешёвыми концами, а связка БЛОКОВ — не связный список из отдельных элементов и не массив, а цепочка кусков по нескольку элементов, до середины которой надо дойти.

Отсюда практический критерий, и он не про «что быстрее», а про то, ЧТО ВЫ ДЕЛАЕТЕ с контейнером. Нужен доступ по произвольному индексу — список, и тогда работа с началом обойдётся дорого. Нужны оба конца и обход подряд — deque, и тогда дорого обойдётся индекс. Список, из которого постоянно снимают первый элемент, — это deque, написанный неправильно.

Часть IV. Практика

Практика · что напечатает

Два одинаковых списка, из каждого удаляют девятьсот элементов из начала — один через pop(0), другой через del. Что напечатает этот код на 3.13?
import struct
import sys


def capacity(seq):
  return (sys.getsizeof(seq) - sys.getsizeof([])) // struct.calcsize("P")


a = list(range(1000))
b = list(range(1000))

for _ in range(900):
  a.pop(0)

for _ in range(900):
  del b[0]

print(len(a), capacity(a))
print(len(b), capacity(b))

Практика · оцените

Из списка в десять тысяч элементов снимают все элементы с начала: один раз через L.pop(0), другой через del L[0]. Во сколько раз первый способ дороже второго на 3.13?
раза

Часть V. Что с этим делать

Четыре правила, которые следуют из замеров

Строить список сразу нужной длины, если она известна. list(range(n)), литерал, [0] * n берут место ровно под элементы; включение и генератор — с запасом. На одном списке это сорок восемь байт, на миллионе — сорок восемь мегабайт.

Не работать с началом списка в цикле. Цена одной такой операции растёт вместе с длиной: на десяти тысячах элементов вставка в начало стоит девятьсот наносекунд против двадцати у append, а снятие с начала — 1734,2 наносекунды через del L[0] и 12 076,6 через L.pop(0). Если нужны оба конца — collections.deque; если нужен ещё и индекс в середину — значит, нужен другой алгоритм, а не другой контейнер.

Пересобирать список, который сильно уменьшился и живёт дальше. L.copy() приводит ёмкость к длине на всех пяти проверенных сборках, а del не приводит её ни на одной из четырёх сборок от 3.13 и выше; на 3.12.3 он её ещё уменьшал. И отдельно: список отдаёт ЁМКОСТЬ, а не память операционной системе — освободившийся кусок распределитель памяти обычно держит у себя, так что занятая процессом память (RSS) после пересборки может не измениться.

Не полагаться ни на сжатие, ни на его отсутствие. Ни одна версия не обещает, когда список отдаёт ёмкость. То, что здесь измерено, — поведение пяти конкретных сборок, и оно уже менялось один раз без единой строки в «Что нового».

История версий

ВерсияИзменениеЧто это значит для кода
3.12Удаление по индексу идёт через list_ass_slice, который вызывает list_resize. del L[i] и L.pop(i) оставляют объект в ОДИНАКОВОМ состоянии: после девятисот удалений из списка в тысячу ёмкость 184 у обоих.
3.13Появляется list_ass_item_lock_held, и list_resize на пути удаления по индексу больше не вызывается. del L[i] перестаёт уменьшать ёмкость вовсе — с любого конца, — а L.pop(i) продолжает её уменьшать. В «Что нового» об этом нет ни строки.
3.14Поведение то же, что в 3.13: ёмкость после del не уменьшается — и так же в свободнопоточных сборках 3.13.7t и 3.14.7t, где у списка свои пути выделения. Формула роста не менялась ни разу — прогон bench/lists/growth.py даёт одну и ту же последовательность ёмкостей и те же двадцать четыре скачка на всех пяти сборках.

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.

Байты и ёмкость — сравнимы между версиями, записи на всех пяти сборках, включая две свободнопоточных:

Время — одна запись, одна сборка:

Опора под задачи раздела «Практика»:

Python 3.12.3 (GCC 13.3.0), 3.13.7 (Clang 20.1.4), 3.14.7 (Clang 22.1.3) и свободнопоточные 3.13.7t, 3.14.7t; Intel Xeon 2,80 ГГц, 2 vCPU, 64 бита. Ёмкость и байты между версиями сравнимы — это раскладка объекта. Время нет: у сборок разные компиляторы и разные флаги, и разделить «стал быстрее язык» и «стала быстрее сборка» этими замерами нельзя. Время снято только на 3.13.7 со включённым GIL.

Фрагменты Objects/listobject.c приводятся по тегам v3.12.3 и v3.13.7 дословно.

Частые заблуждения

Утверждение

Список никогда не отдаёт память обратно

На самом деле

Ёмкость отдаёт, и условие записано в list_resize одной строкой: if (allocated >= newsize && newsize >= (allocated >> 1)) — пока длина не упала ниже половины ёмкости, массив остаётся прежним, а как только упала, он перевыделяется под меньший размер. Тысяча pop() с конца списка в тысячу элементов даёт двенадцать таких уменьшений. Неверна не только эта формулировка, но и обратная: начиная с 3.13 del L[i] ёмкость не уменьшает вовсе. И отдельно: уменьшение ёмкости — это не возврат памяти операционной системе, аллокатор обычно держит освободившийся кусок у себя.

Утверждение

del L[0] и L.pop(0) — одно и то же, просто разная запись

На самом деле

На 3.12 действительно одно и то же. На 3.13 и 3.14 — нет: после девятисот удалений из списка в тысячу элементов pop(0) оставляет ёмкость 184, а del — 1000, то есть 1528 байт против 8056. Длина в обоих случаях сто, и по ней разницы не видно. Причина в том, что удаление по индексу перестало проходить через list_ass_slice: с 3.13 его делает list_ass_item_lock_held, где list_resize не вызывается. То же расхождение и во времени внутри одного запуска 3.13.7: pop(0) дороже del L[0] в семь раз.

Утверждение

sys.getsizeof(L) показывает, сколько места занимают элементы списка

На самом деле

Показывает размер массива указателей по числу ВЫДЕЛЕННЫХ мест, а не по длине, и не включает сами объекты, на которые указатели ведут. Документация функции говорит это прямо: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to (Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). Отсюда и способ прочитать ёмкость, которой нет публичного имени: (sys.getsizeof(L) - sys.getsizeof([])) // struct.calcsize("P").

Утверждение

Два списка с одинаковыми элементами занимают одинаковое место

На самом деле

Только если построены способом, который знает длину заранее. Список из десяти элементов занимает 136 байт, если это литерал, [0] * 10 или list(range(10)), и 184 байта, если это списковое включение или list от генератора: у последних двух длина заранее неизвестна, элементы добавляются по одному, и место берётся с запасом — шестнадцать мест вместо десяти.

Утверждение

append дёшев, потому что массив просто удлиняется

На самом деле

Иногда его удлиняют на месте: realloc расширяет выделение, если за концом есть свободное место, — на 3.13.7 с GIL прогон насчитал 13 таких скачков ёмкости из 24 на первых шестистах append, а на 3.12.3 и 3.14.7 — 12 из 24; пропорция зависит от состояния аллокатора, воспроизводится только то, что встречаются оба исхода. Но рассчитывать на это нельзя, и когда места нет, массив переезжает целиком. Дёшев append потому, что место берётся с запасом по формуле (n + (n >> 3) + 6) & ~3, то есть запас пропорционален длине. На первых шестистах append массив перевыделяется двадцать четыре раза, а не шестьсот. Комментарий в исходнике называет это прямо: запас нужен, чтобы дать linear-time amortized behavior over a long sequence of appends() (линейное в среднем поведение на длинной последовательности appends()).

Утверждение

collections.deque — это список, у которого дёшевы оба конца

На самом деле

Это другая структура с другой платой. Оба конца действительно дёшевы — appendleft стоит столько же, сколько append. Но индекс перестаёт быть постоянным: на ста тысячах элементов d[50000] стоит 2418,3 наносекунды против 9,0 у L[50000], то есть в сотни раз дороже. Документация обещает и то и другое сразу: Indexed access is O(1) at both ends but slows to O(n) in the middle (Доступ по индексу — O(1) на обоих концах, но замедляется до O(n) в середине).

Утверждение

Формула роста списка — часть языка, на неё можно опираться

На самом деле

Нельзя. В документации языка её нет вовсе; она записана комментарием в Objects/listobject.c и остаётся деталью реализации CPython. То, что она не менялась между 3.12 и 3.14, — наблюдение из пяти прогонов, а не обещание: вывод bench/lists/growth.py даёт одну и ту же последовательность ёмкостей и те же двадцать четыре скачка на 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t, 3.14.7t. Опираться можно на то, что append в среднем дёшев; на конкретные числа ёмкости — нет.

Проверьте себя

Вопрос 1 из 6

len(L) равен 100. Сколько памяти занимает список?

Источники и что читать дальше

7 ИСТОЧНИКОВ

  1. Objects/listobject.c — list_resize и формула ростаИсходный код CPython. Место, из которого берётся весь рост списка. Комментарий над функцией называет и цель, и последовательность: «This over-allocates proportional to the list size, making room for additional growth. The over-allocation is mild, but is enough to give linear-time amortized behavior over a long sequence of appends()» (Выделяется с запасом, пропорциональным размеру списка, чтобы оставить место для дальнейшего роста. Запас небольшой, но его хватает, чтобы на длинной последовательности appends() поведение было линейным в среднем). Там же — сама формула `new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;` и условие, при котором массив ОСТАВЛЯЮТ как есть: `if (allocated >= newsize && newsize >= (allocated >> 1))`; сжатие начинается там, где оно не выполнено. Читалось по тегу v3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
  2. Objects/listobject.c в 3.12.3 — удаление по индексу через list_ass_sliceИсходный код CPython. Для сравнения с 3.13: до неё `list_ass_item` при удалении не делал ничего сам, а передавал работу общему пути присваивания срезу — `if (v == NULL) return list_ass_slice(a, i, i+1, v);`. Именно `list_ass_slice` после сдвига хвоста вызывает `list_resize`, и оттуда бралось уменьшение ёмкости, которого в 3.13 больше нет.https://github.com/python/cpython/blob/v3.12.3/Objects/listobject.c
  3. Objects/listobject.c — list_pop_impl: каким путём идёт popИсходный код CPython. Нужен затем, чтобы «pop идёт общим путём присваивания срезу» не оставалось словом автора. Функция сама делит два случая: если снимают ПОСЛЕДНИЙ элемент, она зовёт `list_resize` напрямую — `if (index == Py_SIZE(self) - 1) { status = list_resize(self, Py_SIZE(self) - 1);`; во всех остальных случаях, включая `pop(0)`, работу делает общий путь — `status = list_ass_slice(self, index, index+1, (PyObject *)NULL);`. И там же видно второе расхождение с `del`: перед этим стоит `Py_INCREF(v)`, потому что снятый элемент возвращают наружу. Читалось в `Objects/listobject.c` на ветке 3.13.https://github.com/python/cpython/blob/3.13/Objects/listobject.c
  4. Objects/listobject.c в 3.13.7 — list_ass_item_lock_heldИсходный код CPython. Функция, в которой исчез вызов `list_resize`. Удаление она делает сама: цикл `FT_ATOMIC_STORE_PTR_RELAXED(a->ob_item[idx], a->ob_item[idx + 1]);` и затем `Py_SET_SIZE(a, size - 1);`. Ни `list_resize`, ни `list_ass_slice` на этом пути не встречаются — отсюда и то, что ёмкость после `del` не уменьшается.https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
  5. sys.getsizeof — что именно возвращаетсяОфициальная документация. Формулировка, из-за которой размер списка читают неправильно: «Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to» (Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). У списка «непосредственно относящееся» — это массив указателей, и считается он по числу ВЫДЕЛЕННЫХ мест, а не по длине.https://docs.python.org/3/library/sys.html#sys.getsizeof
  6. collections.deque — что обещано про сложностьОфициальная документация. Единственное место, где официально обещана стоимость операций с обоими концами: «Deques support thread-safe, memory efficient appends and pops from either side of the deque with approximately the same O(1) performance in either direction» (Двусторонние очереди поддерживают потокобезопасные и экономные по памяти добавления и снятия с любой стороны с примерно одинаковой производительностью O(1) в обе стороны). Там же сказано и то, чем за это платят: «Indexed access is O(1) at both ends but slows to O(n) in the middle» (Доступ по индексу — O(1) на обоих концах, но замедляется до O(n) в середине).https://docs.python.org/3/library/collections.html#collections.deque
  7. Что нового в Python 3.13Официальная документация. Приводится как источник ОТСУТСТВИЯ записи: изменения в поведении `del L[i]` относительно ёмкости списка в документе нет. Проверялось поиском по разделам Other Language Changes и Optimizations. Отсюда правило этой статьи: об изменении говорится как о наблюдении с указанием версий, а не как о задокументированном решении.https://docs.python.org/3/whatsnew/3.13.html