Программирование PythonПамять и производительностьИнженер по производительности Python

Что объясняет рост памяти после замены неизменяемых кортежей на списки?

Что объясняет рост памяти после замены неизменяемых кортежей на списки?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

В CPython список обычно занимает больше памяти, чем кортеж с тем же числом элементов, потому что список хранит изменяемый массив ссылок с дополнительной ёмкостью для будущих добавлений. Кортеж хранит ссылки компактнее и не резервирует место под расширение. Поэтому замена кортежей на списки может заметно увеличить память, даже если сами элементы не изменились.

Исторический контекст

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

Такое разделение позволяет реализации оптимизировать структуры под их назначение: кортежу не нужна резервная ёмкость для роста, тогда как список должен выполнять частые добавления без перевыделения памяти при каждом изменении.

Постановка проблемы

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

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

Важно не путать размер контейнера с размером графа объектов: поверхностная оценка вроде sys.getsizeof учитывает сам список или кортеж, но не рекурсивно объекты, на которые они ссылаются.

Подробное решение

В CPython кортеж содержит массив ссылок фиксированной длины. После создания его размер не меняется, поэтому в нём нет запаса под будущие элементы.

Список также содержит массив ссылок, но при расширении обычно выделяет память с запасом. Это называется over-allocation. Запас уменьшает число дорогих перевыделений и копирований при последовательных добавлениях, но увеличивает потребление памяти относительно фактического количества элементов.

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

Если последовательность неизменяема, кортеж обычно предпочтительнее по памяти и дополнительно может быть хешируемым, если все его элементы хешируемы. Если нужны изменения, список оправдан: экономия памяти не должна достигаться отказом от требуемой семантики.

Точные размеры и стратегия резервирования относятся к деталям реализации CPython, а не к универсальной гарантии языка Python. При анализе следует измерять реальную нагрузку, учитывать вложенные объекты и проверять не только размер одного контейнера, но и количество контейнеров.

Для фиксированных записей также можно рассмотреть специализированные представления, например кортежи, namedtuple или классы со __slots__, но их выбор зависит от требований к изменяемости, читаемости, атрибутам и скорости доступа.

Ситуация из практики

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

Рассматривались три варианта. Оставить списки проще всего, но это сохраняет повышенный расход памяти. Создавать копию только на этапе, где действительно нужна модификация, экономит память в основном хранилище, но добавляет операции копирования. Разделить неизменяемые и изменяемые представления сложнее архитектурно, зато позволяет не платить за изменяемость всех записей.

Было выбрано хранение кортежей в основном наборе и локальное преобразование конкретной записи в список перед изменением. Это решение сохранило компактное представление большого набора данных, а стоимость копирования возникала только для реально изменяемых записей.

Что кандидаты часто упускают

  1. Всегда ли список больше кортежа с тем же количеством элементов?

В CPython для обычных случаев список обычно больше из-за изменяемости и резервной ёмкости, но точный размер зависит от способа создания, версии интерпретатора и текущей ёмкости списка. Это не следует превращать в гарантию языка Python. Кроме того, если элементы крупные, разница между контейнерами может быть малой относительно памяти самих элементов.

  1. Копирует ли список элементы при преобразовании кортежа в список?

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

  1. Почему нельзя выбирать кортеж только по результату sys.getsizeof?

sys.getsizeof показывает поверхностный размер конкретного объекта и не суммирует автоматически память вложенных объектов. Два контейнера могут иметь одинаковый поверхностный размер, но ссылаться на совершенно разные по размеру графы объектов. Для практической оптимизации нужно измерять структуру данных целиком и учитывать жизненный цикл объектов, количество экземпляров и стоимость операций изменения.