Какую структуру данных выбрать в Python: краткий ответ
Для больших однотипных числовых наборов берите NumPy: он держит сырые значения в непрерывном буфере и выполняет операцию над всем массивом на C, без разбора байткода на каждом шаге. Когда нужна компактность без сторонних пакетов, а данные — однотипные числа, подходит array из стандартной библиотеки. Разнородные значения, строки логов, конфиги и небольшие объёмы остаются за list.
Ориентировочные цифры для 10 млн целых: list занимает порядка 360 МБ, array('i') и NumPy int32 — около 40 МБ, NumPy int64 — около 80 МБ. Поэлементное умножение тех же данных через цикл или list comprehension идёт сотни миллисекунд, векторный вариант NumPy — заметно быстрее. Точный разрыв зависит от версии Python, версии NumPy, CPU и поддержки SIMD, поэтому проверяйте его на своём окружении.
Таблица ниже даёт быстрый ориентир по четырём критериям.
| Критерий | list | array | NumPy (ndarray) |
|---|---|---|---|
| Допустимые типы | любые объекты Python, смешение типов в одном списке | только числовые typecode: 'b', 'i', 'q', 'f', 'd' и другие | фиксированный dtype: целые, float, bool, complex, object |
| Память на элемент | 8 байт указателя плюс 24–28 байт объекта | 1, 2, 4 или 8 байт по typecode | 1, 2, 4 или 8 байт по dtype |
| Многомерность | вложенные списки, без векторизации | нет | есть, плюс broadcasting |
| Скорость массовых операций | цикл Python, интерпретация на каждом шаге | цикл Python, выигрыш только по памяти | векторный код на C, кратный выигрыш |
| Зависимости | встроено | встроено | нужна установка пакета numpy |
Три структуры — три сценария
list в CPython — динамический массив указателей PyObject*. Сам блок ссылок лежит непрерывно, а объекты, на которые он указывает, разбросаны по куче. Отсюда два следствия: каждый проход перебирает ссылки с разыменованием, кэш процессора работает хуже, чем на плотном буфере, а память тратится на оба уровня. Как устроен непрерывный блок и адресная арифметика, разобрано в материале о раскладке массивов в памяти.
array из стандартной библиотеки хранит значения одного числового типа в непрерывном буфере, как C-массив. Многомерности и векторизации нет: поэлементная операция требует цикла на Python. Взамен — предсказуемый размер и полное отсутствие зависимостей.
NumPy хранит ndarray как непрерывный буфер плюс заголовок с dtype, shape и strides. Операции уходят в циклы на C, поэтому data * 2 или data.sum() работают над всем массивом целиком.
Когда list избыточен
Если элементы однотипные и их больше сотни тысяч, посчитайте память до запуска задачи. Для 10 млн целых list даёт порядка 360 МБ против 40 МБ у array('i') и NumPy int32, то есть разницу примерно в девять раз. На мелких объёмах этой разницы не видно, и list удобнее. Характерный пример: список из 10 случайных чисел с подсчётом чётных элементов циклом (разбор такой задачи) — здесь list полностью уместен, а накладные расходы на NumPy превысили бы выигрыш.
Массивы в Python для больших данных почти всегда означают NumPy или array, и переход стоит делать именно на этом пороге: сотни тысяч однотипных чисел и выше.
Допустимые типы данных: list, array и NumPy
list: любые объекты, но с ценой
list принимает что угодно: целые, строки, None, вложенные списки, словари, функции. Пример смешанного списка: [1, 'log', None, 3.14, [2, 3]]. Плата за гибкость в том, что каждый элемент — отдельный объект в куче со своим заголовком, а в списке лежит только ссылка на него. Для int в CPython объект занимает около 28 байт (по результату sys.getsizeof(int())), для float около 24 байт, ссылка — 8 байт на 64-битной платформе. Плюс сам список держит буфер с запасом под будущие вставки, поэтому фактический размер больше суммы ссылок.
array: только числа и typecode
Модуль array принимает единственный числовой typecode, заданный при создании: 'b' и 'B' для знаковых и беззнаковых байтов, 'h' и 'H' для short, 'i' и 'I' для int, 'l' и 'L' для long, 'q' и 'Q' для long long, 'f' и 'd' для float и double. Создание выглядит так: array('i', [1, 2, 3]) или array('d', [1.0, 2.5]). Строка внутри вызовет TypeError, а значение вне диапазона typecode, например array('b', [200]), даст OverflowError. Многомерность не поддерживается, векторизации нет, срез возвращает новый array с копией данных.
NumPy: dtype и многомерность
dtype фиксируется на весь массив: np.array([1, 2, 3], dtype=np.int32) и np.array([1.0, 2.0], dtype=np.float64). Доступны int8, int16, int32, int64 и беззнаковые варианты, float16, float32, float64, bool, complex64, complex128, а также object. Форма задаётся через shape: np.zeros((1000, 1000), dtype=np.float32) занимает 4 МБ под данные. Порядок обхода, C или Fortran, влияет на скорость циклов, детали в статье о row-major и column-major.
dtype=object хранит ссылки на объекты Python и сохраняет гибкость, но теряет плотность буфера и векторизацию: операции идут через интерпретатор, как в обычном list. Для разнородных данных NumPy с object не даёт ни экономии памяти, ни ускорения.
Потребление оперативной памяти: list против array и NumPy
Каждый элемент в непрерывном буфере лежит по адресу base + i * size, поэтому объём массива считается как число элементов, умноженное на размер одного значения. У list схема другая: непрерывный только блок ссылок, подробнее в разборе модели памяти и вычисления адреса по индексу.
Сколько байт на элемент в каждой структуре
| Структура | Байт на элемент | Что именно хранится |
|---|---|---|
| list с int | 8 + около 28 | указатель плюс объект int в куче, итого около 36 байт |
| list с float | 8 + около 24 | указатель плюс объект float в куче |
| array('b'), array('i'), array('d') | 1, 4, 8 | сырое значение без объекта Python |
| NumPy int8, int16, int32, int64 | 1, 2, 4, 8 | сырое значение, один буфер на весь массив |
| NumPy float32, float64 | 4, 8 | сырое значение, частый выбор для метрик |
Заголовок контейнера на 64-битной сборке CPython занимает десятки байт: у array и ndarray это тоже десятки байт. На миллионах элементов этим можно пренебречь, на десятках элементов — нельзя. Отдельно стоит помнить про запас ёмкости списка: CPython увеличивает буфер с избытком пропорционально размеру списка (mild over-allocation), чтобы обеспечить амортизированное линейное время для последовательности append(); шаблон роста: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ... Поэтому фактическая память под большой list выше суммы ссылок, а при уменьшении размера, если новый размер опускается ниже половины выделенного, выполняется realloc() для сжатия списка. Точный процент запаса зависит от истории вставок и удалений, поэтому проверяйте его на своём сценарии.
Практический расчёт для больших данных
| Структура для 10 млн целых | Ориентировочный объём | Комментарий |
|---|---|---|
| list | около 360 МБ | 80 МБ ссылок плюс около 280 МБ объектов int |
| array('i') | около 40 МБ | 4 байта на значение |
| NumPy int32 | около 40 МБ | 4 байта на значение |
| NumPy int64 | около 80 МБ | 8 байт на значение; numpy.int_ совместим с C long, на Linux x86_64 алиасится на int64 |
| NumPy int8 | около 10 МБ | только если диапазон значений доказанно укладывается в -128..127 |
Проверять объём в рантайме удобно тремя вызовами: sys.getsizeof(lst) показывает сам список без объектов, на которые он ссылается, sys.getsizeof(arr) для array даёт буфер плюс заголовок, а у ndarray атрибут nbytes возвращает чистый объём данных. Как отделить расчётный объём от фактического и найти раздутые буферы, разобрано в статье об измерении памяти под массивы.
Отдельная ловушка — конвертация. Вызов np.array(lst) для 10 млн целых выделяет новый буфер до 40 МБ, пока исходный list на 360 МБ ещё жив, поэтому пиковая память процесса складывается из двух структур.
Цифры в таблицах выше — оценки для 64-битной сборки CPython со стандартным аллокатором. Фактический объём зависит от версии Python, платформы, фрагментации кучи и настроек аллокатора, поэтому перед переносом продакшн-скрипта проверяйте значения на своём окружении через sys.getsizeof, атрибут nbytes и профилировщик памяти.
Скорость выполнения операций: где NumPy даёт выигрыш
Векторизация против циклов Python
Цикл for в CPython на каждой итерации разбирает байткод, проверяет типы и создаёт новые объекты. NumPy выносит цикл в C и работает с сырыми числами: истинная векторизация встроенными операциями выполняется в оптимизированном C/Fortran коде и обрабатывает массивы целиком. Поэтому выражение data * 2 на большом массиве обгоняет [x * 2 for x in data] кратно, без ручного распараллеливания.
Важная оговорка: np.vectorize — не истинная векторизация. Это удобная обёртка, которая итерирует по элементам массива в Python и применяет функцию по одному элементу, наследуя накладные расходы Python-цикла; для пользовательских функций она часто оказывается медленнее чистых Python-циклов. Ускорение дают именно встроенные операции NumPy, а не обёртка с похожим названием.
Для калибровки порядка величин полезен микробенчмарк из документации: timeit для операции «5 * x» над numpy.arange(1000) даёт около 16.6 usec на цикл (100000 loops, best of 3). Это не прямое сравнение с list на 10 млн элементов, но показывает, что векторная арифметика измеряется микросекундами на тысячах элементов.
| Операция на 10 млн целых | list (цикл Python) | NumPy |
|---|---|---|
| Поэлементное умножение на 2 | сотни миллисекунд и больше | десятки миллисекунд и меньше |
| Сумма элементов | десятки-сотни миллисекунд | единицы-десятки миллисекунд |
Значения ориентировочные: они зависят от версии Python, версии NumPy, тактовой частоты CPU и поддержки SIMD. Для честного сравнения на своей машине запускайте один и тот же код через timeit на одинаковых данных. array из стандартной библиотеки экономит память, но векторизации не даёт, и поэлементные операции в нём всё равно требуют цикла Python, поэтому кратного ускорения от перехода с list на array ждать не стоит.
Когда разница несущественна
До примерно 10 000 элементов разница между list и NumPy теряется на фоне накладных расходов: импорт numpy занимает заметное время при старте скрипта, а конвертация списка в массив проходит все элементы по одному. Если данные читаются один раз, обрабатываются одной операцией и результат сразу уходит в файл или JSON, переписывание на NumPy не окупается. Оптимизацию стоит начинать, когда объём данных или частота операций делают накладные расходы незаметными на фоне полезной работы.
Практические примеры: создание, индексация и изменение
Создание и индексация
list: lst = [1, 2, 3], доступ lst[0] возвращает 1, срез lst[1:3] возвращает новый список [2, 3].
array: from array import array, a = array('i', [1, 2, 3]), доступ a[0] даёт 1, срез a[1:3] возвращает новый array с копией значений.
NumPy: import numpy as np, arr = np.array([1, 2, 3], dtype=np.int32), доступ arr[0] возвращает скаляр np.int32, а срез arr[1:3] отдаёт представление на тот же буфер.
Разница в срезах критична при работе с большими данными: list и array копируют значения, NumPy делит буфер с исходным массивом. Проверить связь можно функцией np.shares_memory(arr, arr[1:3]). Механика представлений, алиасинга и времени жизни буфера разобрана в статье о срезах и view без копирования данных.
Изменение и добавление элементов
list: lst[0] = 10, lst.append(4), lst.extend([5, 6]), lst.insert(0, 0). Все операции меняют исходный объект, память под новые элементы выделяется с запасом.
array: a[0] = 10, a.append(4), a.extend([5, 6]), a.insert(0, 0). Присваивание проходит ту же проверку диапазона, что и создание: значение вне границ typecode вызовет OverflowError.
NumPy: arr[0] = 10 меняет значение в буфере. Добавление работает иначе: np.append(arr, 4) создаёт новый массив и не меняет исходный. Вызов np.append в цикле даёт квадратичную сложность, потому что каждый шаг копирует весь массив. Правильные подходы: заранее выделить np.empty(n) и заполнять по индексу либо собрать значения в list и один раз выполнить np.array(data, dtype=np.int32).
Типичные ошибки при работе с массивами в Python
Переполнение типов
Узкий dtype ломает значения. В NumPy 2.0 преобразование целого Python, выходящего за границы dtype, приводит к OverflowError: например, деление np.uint8 на Python int 256 даёт «OverflowError: Python integer 256 out of bounds for uint8», а преобразование Python integer 128 в int8 — «OverflowError: Python integer 128 out of bounds for int8». В предыдущих версиях такой ошибки не было: release notes 1.24.0 указывают, что конвертация Python integer в NumPy value теперь всегда проверяет представимость результата, а release notes 2.0 говорят о «conversion of out of bounds python integers to integer arrays». Проверьте поведение на своей версии перед переносом кода. В стандартной библиотеке array('b', [200]) всегда завершается OverflowError, скрытого искажения не будет.
Вторая ловушка — арифметика. Умножение двух массивов int8 даёт int8, и результат может обернуться без предупреждения. Если значения могут выйти за границы, приводите данные к int64 через astype или сразу выбирайте широкий dtype. Правило для новых задач: int64 по умолчанию, int32 для идентификаторов и счётчиков, узкие типы только там, где диапазон доказан.
Views и копии в NumPy
Базовый срез возвращает view: после b = arr[1:3] и b[0] = 99 изменится и arr. Fancy indexing вида arr[[0, 2]] и выборка по булевой маске возвращают копию. Отсюда два класса ошибок: случайная порча исходных данных через view и лишние копии больших массивов там, где хватило бы представления. Для независимой копии вызывайте .copy(), для проверки связи — np.shares_memory.
Ещё три ошибки, которые дорого стоят на больших объёмах:
- Хранить разнородные данные в NumPy через dtype=object и ждать ускорения. Такой массив ведёт себя как list и по памяти, и по скорости.
- Набирать массив через np.append или np.concatenate в цикле вместо предварительного выделения буфера.
- Держать миллионы однотипных чисел в list и не замечать рост RSS процесса. Проверяйте объём до инцидента: sys.getsizeof для контейнера, nbytes для ndarray.
Какую структуру выбрать в DevOps-задачах
Парсинг логов и хранение строк: list, потому что строки разнородны по длине и требуют объектов Python. Хранение метрик и временных рядов на миллионы точек: NumPy, а для сложных преобразований — pandas поверх него. Компактное хранение однотипных чисел в скрипте на сервере без внешних пакетов: array. Обработка конфигов YAML и JSON: dict и list. Агрегации в ETL: NumPy, если данные уже в числовом виде, иначе этап разбора на list с последующей конвертацией. Для потоковой обработки больших файлов применяйте генераторы и обработку порциями: держать весь набор в памяти не нужно, если его можно читать блоками.
Чек-лист выбора структуры
- Данные разнородные, есть строки или None? Берите list.
- Однотипные числа, объём до сотен тысяч, NumPy недоступен по политике окружения? Берите array.
- Однотипные числа, объём большой, нужны вычисления или агрегации? Берите NumPy.
- Нужна многомерность, broadcasting или матричные операции? Только NumPy.
- Критична память, но вычислений мало? array или NumPy с узким dtype и проверкой диапазонов.
- Данные приходят потоком и целиком не нужны? Генератор вместо любой из трёх структур.
Когда NumPy не нужен
NumPy добавляет зависимость в образ и в требования к развёртыванию: пакет ставится через pip, занимает десятки мегабайт и требует совместимой сборки под платформу. Для скрипта на 500 строк, который раз в сутки разбирает файл на несколько тысяч записей, выигрыша не будет, а усложнение окружения и времени сборки образа останется. Если данных мало, операции редкие, а разнородность высокая, list закрывает задачу полностью.
Итог: ориентиры для выбора структуры
| Критерий | list | array | NumPy |
|---|---|---|---|
| Типы данных | любые объекты Python | числовые typecode | фиксированный dtype |
| Память на одно целое | около 36 байт | 4 байта при typecode 'i' | 4 байта при int32 |
| Скорость массовых операций | базовая, цикл Python | как у list, выигрыш только по памяти | кратно выше на больших массивах |
| Многомерность | вложенные списки | нет | есть, плюс broadcasting |
| Зависимости | нет | нет | пакет numpy |
Ключевые цифры для быстрой оценки: около 36 байт на целое в list против 4 байт в array('i') и NumPy int32, разница по памяти примерно в девять раз на 10 млн значений, разница по скорости массовых операций — до двух порядков на подходящих операциях. Перед переносом рабочего скрипта посчитайте ожидаемый объём умножением числа элементов на байты из таблицы, сравните с доступной памятью хоста или лимитом контейнера и только после этого меняйте структуру. Если расчёт укладывается в лимит с запасом и операций мало, оставьте list: простота чтения кода тоже стоит времени команды.