2025, Oct 18 15:16

Массив NumPy dtype=object из списка: что с copy=False и где будет копия

Как создать массив NumPy с dtype=object из списка Python: когда допустима поверхностная копия без дублирования, и почему copy=False вызывает ошибку в NumPy.

Создать NumPy ndarray из списка Python без дублирования данных кажется простым — пока в дело не вмешивается dtype=object. Массивы с dtype=object хранят ссылки на объекты Python, а не сырые, сплошные значения. Это усложняет ожидания вокруг «нулевого копирования», особенно когда пробуешь np.asarray(..., copy=False) и получаешь жёсткую ошибку. Давайте разберём, что действительно происходит и на что можно опираться.

Постановка задачи

Нужно превратить список Python в массив NumPy с dtype=object, не создавая копию при конструировании. Попытка принудительно добиться этого через copy=False приводит к ошибке:

import numpy as np
items = ['spam', 'eggs']
obj_view = np.asarray(items, dtype='object', copy=False)  # ValueError

Так происходит, хотя массивы с dtype=object действительно хранят ссылки, и tobytes() для таких массивов возвращает байты указателей, а не содержимое объектов. Вопрос в том, может ли ndarray напрямую разделять уже существующее хранилище списка.

Как NumPy работает со списками с dtype=object и без него

Начнём с простой последовательности и преобразуем её дважды: сначала позволим NumPy выбрать dtype автоматически, затем принудительно зададим dtype=object. Без явного dtype NumPy создаёт массив строк фиксированной длины, копируя строковые данные в компактное представление:

import numpy as np
words = ['one', 'two', 'three']
fixed_str = np.asarray(words)  # dtype становится чем-то вроде '<U5'

Такой массив хранит строки как Unicode фиксированной длины, а не как объекты Python. Это копия строковых данных. В этом примере объём составляет 3*5*4=60 байт.

С dtype=object NumPy сохраняет ссылки на исходные объекты Python. Получается поверхностная копия контейнера (массив владеет собственным блоком указателей), но сами элементы — те же самые объекты Python:

obj_arr = np.asarray(words, dtype=object)
# Для третьего элемента это один и тот же объект
id(words[2])
id(obj_arr[2])

Идентификаторы совпадают — значит, массив действительно хранит ссылки на исходные строки.

Добавим изменяемый элемент — поведение станет ещё нагляднее. Изменения, внесённые через любой контейнер, видны в обоих:

mixed = ['one', 'two', 'three', ['a', 'b']]
obj_box = np.array(mixed, object)
# Изменяем вложенный список через ссылку из массива
obj_box[3].append('c')
# Изменение видно в обоих контейнерах, так как они указывают на один и тот же вложенный список
mixed
obj_box

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

mixed[1] = 12.3
mixed
obj_box

Почему здесь copy=False приводит к ошибке

Попытка заставить NumPy избежать даже поверхностной копии блока указателей завершается явной ошибкой. Замысел copy=False — гарантировать отсутствие копирования; если NumPy не может этого обеспечить, он отказывается:

ValueError: Не удаётся избежать копирования при создании массива, как запрошено. Если вы используете np.array(obj, copy=False), замените это на np.asarray(obj), чтобы разрешить копирование при необходимости (в NumPy 1.x поведение не изменится). Подробности — в руководстве по миграции.

Практический вывод: copy=None (значение по умолчанию) здесь столь же уместно. Копия создаётся только при необходимости. Параметр copy=True принуждает копировать контейнер, но для dtype=object это всё равно поверхностная копия; сами объекты Python не дублируются. Настоящая глубокая копия ссылочных объектов потребует, например, copy.deepcopy.

Может ли ndarray разделять внутреннее хранилище списка?

Прямое переиспользование внутреннего буфера указателей списка Python в качестве основы для объектного массива NumPy потребовало бы опоры на внутренности интерпретатора. Хотя и список Python, и объектный массив NumPy на практике хранят элементы в виде непрерывного набора указателей, создание ndarray поверх буфера списка зависит от реализации и небезопасно. Тип list не предоставляет доступ к своему внутреннему буферу, поэтому для этого понадобились бы «хитрые» обходные пути, завязанные на детали вроде размера указателя, порядка байт и компоновки объектов CPython, и всё это было бы ненадёжно при изменении размера или во время работы.

Рабочее решение

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

import numpy as np
data_src = ['one', 'two', 'three', ['a', 'b']]
ref_array = np.asarray(data_src, dtype=object)  # copy=None по умолчанию; поверхностная копия ссылок
# Доказываем совместное использование ссылок через изменение вложенного списка
ref_array[3].append('c')
# data_src и ref_array теперь отражают один и тот же изменённый вложенный список
# Замена элемента верхнего уровня в списке не влияет на соответствующую ячейку массива
data_src[1] = 12.3

Почему это важно

Объектные массивы и списки Python похожи тем, что оба хранят ссылки на объекты, но ведут себя по-разному. Списки умеют дополняться, массивы — менять форму. С dtype=object вы редко выиграете в скорости; максимум — получите более удобную запись некоторых операций. Когда нужна векторизованная производительность, выбирайте «родные» числовые или строковые dtype. Когда важны изменяемость на уровне Python и неоднородные данные, часто лучше подходит список. Понимание того, что объектные массивы «мелкие» в отношении элементов, помогает избежать багов из-за разделяемого состояния и проясняет, что именно могут и чего не могут гарантировать флаги копирования.

Выводы

Если нужен массив NumPy, который ссылается на уже существующие объекты Python, создавайте его с dtype=object и полагайтесь на поведение по умолчанию для copy. Ожидайте поверхностную копию контейнера и общие ссылки на элементы. Принудительный copy=False вызовет ошибку, когда NumPy не может обойтись без собственного блока указателей. Для глубокого копирования самих объектов используйте подходящие инструменты, например copy.deepcopy. А если вы рассчитывали на настоящий «нулевой копий» вид поверх хранилища списка Python — такой путь не поддерживается и не считается надёжным.

Статья основана на вопросе на StackOverflow от Dryden и ответе hpaulj.