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

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

Кроме длины у списка есть второе число, и из 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 прогона bench/lists/growth.py:

append подрядскачков ёмкостимассив остался на местемассив переехал
600241311
20000534112

Из таблицы следует только одно: встречаются оба исхода. Пропорция не воспроизводится: на 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, попавший на границу, может стоить дорого — если массиву некуда расти на месте, он копируется целиком. Но границы расставлены так, что на длинной череде добавлений эта стоимость распределяется и в пересчёте на один 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 длина растёт на единицу, и эта ветка не срабатывает никогда, поэтому дальше речь только о первой формуле.

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

длина, на которой скачокновая ёмкость
14
58
916
1724
2532
3340
4152
5364
6576
7792
93108
109128
129148
149172

Ровно эта последовательность выписана и в комментарии рядом с формулой: 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, bench/lists/shrink.py:

операциядлина послеёмкость после
L.pop(0)100184
del L[0]1001000
L.pop(len(L)//2)100184
del L[len(L)//2]1001000
L.pop()100184
del L[-1]1001000

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.

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

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

что сделано со спискомдлинаёмкость
ничего — список, каким его оставил del1001000
L.copy()100100
list(L)100100
L[:]100100
один append и один pop100116

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

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

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

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

Квадратичность не на словах, а в замерах

«insert(0) квадратичен» — верно, но пока это только слова. Проверить это можно удвоением: если время растёт квадратично, то при вдвое большей длине получается вчетверо больше времени. Наполнение списка через insert(0), замер bench/lists/ops.py:

длинавсего, мкснс на операциюк предыдущей строке
2500583233,4—
50002129425,8×3,65
100009066906,6×4,26
20000367731838,7×4,06

Та же таблица для append из того же прогона — здесь отношение около двух, а цена одной операции не меняется вовсе:

длинавсего, мкснс на операциюк предыдущей строке
25004919,7—
500010120,3×2,06
1000020120,1×1,98
2000044122,0×2,19

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

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

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

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

Изменение, описанное в части II, видно не только в байтах. Каждая операция ниже опустошает список из десяти тысяч элементов; всё снято в одном запуске 3.13.7, bench/lists/ops.py:

операциявсего, мкснс на операцию
L.pop()23623,6
del L[-1]21321,3
L.pop(0)12076612076,6
del L[0]173421734,2
d.pop()27227,2
d.popleft()26926,9

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) в середине). Вот во что это обходится на ста тысячах элементов; каждое выражение повторено по тысяче раз за круг замера, bench/lists/ops.py:

выражениенс на операцию
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 дословно.

Унести в работу

  • Строить список сразу нужной длины, когда она известна: литерал, [0] * n и list(range(n)) берут место ровно под элементы, а включение — с запасом.
  • Не вставлять в начало и не снимать с начала в цикле: insert(0) и pop(0) квадратичны. Брать collections.deque — но не ходить по индексу в его середину, там сотни наносекунд против девяти у списка.
  • Список, из которого много удаляли через del, пересобирать: L.copy(), list(L) или L[:] приводят ёмкость к длине на всех проверенных сборках.
  • Не читать sys.getsizeof(L) как память элементов: он считает массив указателей по числу выделенных мест и не включает объекты, на которые указатели ведут.
  • Не закладываться ни на формулу роста, ни на сжатие: это детали реализации CPython, и поведение сжатия уже менялось без единой строки в «Что нового».

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

Утверждение

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

На самом деле

Ёмкость отдаёт, и условие записано в 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