Поверхностная копия списка python что это
Перейти к содержимому

Поверхностная копия списка python что это

  • автор:

copy — Shallow and deep copy operations¶

Assignment statements in Python do not copy objects, they create bindings between a target and an object. For collections that are mutable or contain mutable items, a copy is sometimes needed so one can change one copy without changing the other. This module provides generic shallow and deep copy operations (explained below).

Return a shallow copy of x.

Return a deep copy of x.

exception copy. Error ¶

Raised for module specific errors.

The difference between shallow and deep copying is only relevant for compound objects (objects that contain other objects, like lists or class instances):

A shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original.

A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.

Two problems often exist with deep copy operations that don’t exist with shallow copy operations:

Recursive objects (compound objects that, directly or indirectly, contain a reference to themselves) may cause a recursive loop.

Because deep copy copies everything it may copy too much, such as data which is intended to be shared between copies.

The deepcopy() function avoids these problems by:

keeping a memo dictionary of objects already copied during the current copying pass; and

letting user-defined classes override the copying operation or the set of components copied.

This module does not copy types like module, method, stack trace, stack frame, file, socket, window, or any similar types. It does “copy” functions and classes (shallow and deeply), by returning the original object unchanged; this is compatible with the way these are treated by the pickle module.

Shallow copies of dictionaries can be made using dict.copy() , and of lists by assigning a slice of the entire list, for example, copied_list = original_list[:] .

Classes can use the same interfaces to control copying that they use to control pickling. See the description of module pickle for information on these methods. In fact, the copy module uses the registered pickle functions from the copyreg module.

In order for a class to define its own copy implementation, it can define special methods __copy__() and __deepcopy__() . The former is called to implement the shallow copy operation; no additional arguments are passed. The latter is called to implement the deep copy operation; it is passed one argument, the memo dictionary. If the __deepcopy__() implementation needs to make a deep copy of a component, it should call the deepcopy() function with the component as first argument and the memo dictionary as second argument. The memo dictionary should be treated as an opaque object.

Discussion of the special methods used to support object state retrieval and restoration.

Работа с поверхностными и глубокими копиями в Python

В этой статье объясняется, как делать копии списков Python, массивов NumPy и датафреймов Pandas при помощи операций получения срезов, списочного индексирования (fancy indexing) и логического (boolean indexing). Эти операции очень часто используются при анализе данных и должны рассматриваться всерьёз, поскольку ошибочные предположения могут привести к падению быстродействия или неожиданным результатам.

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

Вступление

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

Это осознание зачастую возникает во время чтения вводной части книги, которая намеренно содержит простой материал, подготавливая читателя к основной сути. Приведённая фраза Эйнштейна звучит у меня в сознании, когда в очередной раз после использования интерпретатора Python, я задумываюсь, почему столь простые вещи открываются для меня только сейчас?

И текущая статья появилась вслед за одним из таких случаев. В ней я хочу объяснить, как списки Python, массивы NumPy и датафреймы Pandas создают представления или полноценные копии данных при получении срезов, а также множественном и логическом индексировании. В этой теме возникает некоторая путаница, поскольку термины «поверхностная копия» и «глубокая копия» не всегда означают одно и то же, а также неясно, когда дополнительная информация вроде метаданных массива NumPy и индексов Pandas копируется полноценно, а когда поверхностно.

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

Все примеры кода были подготовлены с использованием Python v3.8.10, Pandas v1.5.1 и NumPy v1.23.4.

Списки Python

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

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

Очевидно, что при каждом выполнении адреса будут отличаться. В связи с этим мы сделаем так, чтобы массив a далее не изменялся. Ниже мы попробуем реплицировать этот массив разными способами, а именно с помощью простого присваивания другой переменной и глубокого копирования:

Первым делом нужно обратить внимание, что адрес списка (первый численный столбец) изменяется при каждой попытке, за исключением первой операции изменения привязки. Это говорит о том, что в ходе неё была сделана копия. Данный код реализует четыре разных способа создания поверхностной копии, подразумевающей изменение списка, при котором его элементы остаются прежними объектами. Если попробовать изменить немутабельный элемент такой неполной копии списка, исходный список останется нетронутым. При изменении же мутабельного элемента оригинальный список также меняется. Например:

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

Показанный пример несложно обобщить. Любой способ получения среза списка в Python, например, a[:] , a[1:4] , a[:5] или a[::-1] , приводит к созданию поверхностной копии извлекаемой части списка. А что происходит при конкатенировании или умножении списков? Сможете спрогнозировать исход операций ниже?

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

Ещё раз призываю вас самостоятельно поэкспериментировать с подобными примерами. Простота синтаксиса и его лаконичность делают Python отличным языком для экспериментов.

Массивы NumPy

По аналогии со списками Python массивы NumPy также можно копировать или раскрывать через их представление. Чтобы продемонстрировать эту функциональность, мы создадим массив из случайных целых чисел в диапазоне от 0 до 9 включительно.

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

Вывод кода выше:

Тип данных этого массива явно установлен как int64 , значит, каждый его элемент потребляет 8 байт. Все 25 элементов массива занимают 200 байт, но общий его размер в памяти составляет 328 байт ввиду присутствия метаданных, отражающих тип данных, размер шагов (stride) и прочую важную информацию, помогающую с этим массивом работать. Мы видим, что наш массив содержит собственные данные ( owndata is True ), в связи с чем его base установлена как None .

Посмотрим, что произойдёт при создании представления:

Содержимое массива осталось неизменным. Не изменился и тип данных, а также количество байт, занимаемых его элементами. Остальные же атрибуты теперь иные. Размер массива сократился до 128 байт (то есть 328 – 200), поскольку его представление потребляет память для хранения атрибутов. Элементы массива не копировались, на них были созданы ссылки. Об этом говорит изменившееся значение атрибута base . На языке NumPy представление содержит тот же буфер данных (фактические данные), но при этом имеет собственные метаданные. Изменение элемента представления приведёт к изменению исходного массива.

Посмотрим, что произойдёт при создании копии:

Вывод выглядит идентично выводу исходного массива. Изменение элемента копии не ведёт к изменению оригинала.

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

Поведение некоторых функций не всегда одинаково. К примеру, numpy.ravel возвращает непрерывный уплощённый массив в качестве копии только при необходимости. И напротив, numpy.ndarray.flatten всегда возвращает копию массива, свёрнутую до одного измерения. Поведение numpy.reshape несколько запутанней, так что лучше почитать о ней в официальной документации.

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

Копии, созданные в результате продвинутого индексирования, как и копии, полученные через numpy.copy , не подразумевают глубокого копирования мутабельных элементов внутри массивов. Как и поверхностные копии списков Python, копия массива NumPy содержит тот же самый объект, что может привести к неожиданностям, если этот объект допускает изменение (то есть мутабелен):

Хотя это больше теоретический аспект, поскольку массивы NumPy обычно для хранения мутабельных объектов не используются. Но всё же будет нелишним знать, что copy.deepcopy() здесь тоже работает.

Датафреймы pandas

По уже налаженной схеме мы определим датафрейм и вспомогательную функцию для вывода его описания.

Структура этих данных совпадает со структурой массива NumPy, то есть в датафрейме присутствует 5х5 элементов int64 , но вдобавок к ним мы определили индексы и имена столбцов. Вспомогательная функция была изменена. Датафреймы Pandas могут содержать в разных столбцах разные типы данных, поэтому мы возвращаем уникальные при помощи a_df.dtypes.unique() . Чтобы увидеть, когда содержащиеся данные копируются, а когда на них лишь даётся ссылка, мы сначала через a_df.to_numpy() получим внутренний массив NumPy, а затем используем интерфейс массива для получения указателя на первый элемент его данных.

Теперь у нас есть всё необходимое для экспериментов с копиями и представлениями.

Глядя на интерфейс API, можно найти функцию копирования датафрейма, получающую логический аргумент deep . Если он True (по умолчанию), создаётся новый объект с копией данных вызывающего объекта и индексами (это не глубокая копия в смысле copy.deepcopy() стандартной библиотеки; см. ниже). Эти данные и индексы можно изменить, не затронув исходный датафрейм. Если же deep = False , новый объект создаётся без копирования данных или индексов вызывающего объекта, то есть генерируются только ссылки на них. В таком случае любые изменения в данных оригинального датафрейма будут отражаться в его копии.

Поэкспериментируем с представлением:

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

Теперь создадим копию ( deep=True используется по умолчанию, но я включаю его для большей ясности).

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

Это не очень частый вариант использования датафреймов Pandas, но его всё же стоит иметь в виду. К сожалению, в Pandas невозможно сделать истинную глубокую копию, используя функцию copy.deepcopy() из стандартной библиотеки, поскольку разработчики этой библиотеки реализовали pd.DataFrame.__deepcopy__() как pd.DataFrame.copy(deep=True) . Не уверен, изменится ли это в будущем, но в любом случае данный приём считается антипаттерном. Pandas в этом плане отличается от NumPy.

Теперь можно рассмотреть разные способы выбора строк и столбцов с помощью Pandas.

При базовом индексировании и получении срезов, например, в случае простого индексирования по столбцам с использованием квадратных скобок или аксессора .loc[] , используется одна основа, но при остальных операциях это не так. В случае сомнений вышеприведённая схема вычислительных экспериментов позволит получить быстрый ответ.

К сожалению, проверки неизменности основы не всегда достаточно для прогнозирования последствий использования цепного индексирования (см. ниже), но она даёт некоторое базовое понимание. В последней попытке основа остаётся прежней, но даже если мы используем это цепное индексирование для установки значений, исходный датафрейм останется неизменным. Хотя есть и обратный вариант: если основа изменяется, значит, мы работаем с копией. Здесь бы не помешали ваши комментарии, поскольку в этом вопросе я начинаю плавать.

Далее же мы перейдём к заключительной теме, связанной с Pandas, а именно к пресловутому цепному индексированию и связанным с ним SettingWithCopyWarning . Используя ранее определённый датафрейм a_df , можно попробовать изменить значения конкретных элементов столбца при помощи логического индекса. Если предположить использование цепного индексирования, то на ум приходят два способа:

Здесь имеет значение порядок выполнения операций. Первая попытка приводит к выдаче SettingWithCopyWarning , что вполне ожидаемо. При использовании аксессора .loc[] с логической маской мы получаем копию. Присваивание элементам копии новых значений не ведёт к изменению исходного датафрейма. Это ожидаемое поведение, но Pandas, в отличие от NumPy, делает дополнительный шаг и даёт пользователю рекомендацию. Хотя даже в случае Pandas не стоит особо полагаться на такие предупреждения, поскольку выводятся они не всегда. К примеру,

Не выдаёт предупреждения, хотя датафрейм не изменяется, что видно по выводу:

Нужно ли всё это помнить? Не обязательно. Не только потому, что практически нереально перечислить все возможности цепного индексирования, но также ввиду отсутствия гарантии неизменности поведения в различных версиях Pandas при использовании SettingWithCopyWarning . Хуже того, может случиться так, что в одной версии датафрейм будет изменяться, а в другой нет (личных подтверждений этому у меня нет, просто опасения).

Использование виртуальной среды и настройка файла requirements.txt не только предотвратят ад зависимостей, но и защитят от подобных опасностей, хотя лучше всего знать, какие присваивания в Pandas представляют риски, чтобы их избегать. Ситуация дополнительно усложняется, когда датафрейм содержит иерархические индексы и разные типы данных. В таком случае пытаться предугадать результат цепного индексирования опасно.

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

Этот код выводит изменённый датафрейм, не выводя предупреждение.

Так весь смысл в использовании одного аксессора?

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

Первая попытка подразумевает три подобных эксперимента.

Отличаются эти эксперименты только способом изменения исходного датафрейма. Первый изменяет лишь один элемент столбца a , второй изменяет весь столбец a , а третий также изменяет весь этот столбец, но уже с помощью df.loc[:,’a’] .

В результате успешного изменения датафрейма его срез также изменяется только в первых двух экспериментах. Если посмотреть внимательно, тип одного из столбцов датафрейма изменился на int64 , а его буфер данных переместился в памяти. Я думаю, что причина преобразования типа в изменении значения всего столбца a . В этом можно убедиться, если установить тип данных явно при создании датафрейма.

Думаю, нет особого смысла оставлять уже неактуальный срез, если только он не был явно скопирован с помощью df.loc[1:3].copy() . В противном случае всегда можно получить свежий срез датафрейма именно в момент необходимости. Хотя это вполне рабочий эксперимент, позволяющий лучше понять тему представлений и копий.

Заключение

Чтобы понять, когда Python создаёт копии, а когда представления, необходима практика. Списки Python, массивы NumPy и датафреймы Pandas предлагают функции для создания копий и представлений, как это показано в таблице ниже.


Интерактивная версия доступна в оригинале статьи

Однако самые важные выводы связаны с поведением инструкций присваивания на основе индексов при использовании массивов NumPy и датафреймов Pandas:

Список (list) в Python

Списки в Python используются для одновременного хранения множества данных. Список создается путем размещения элементов внутри квадратных скобок [] , разделенных запятыми. Список может содержать любое количество элементов, и они могут быть разных типов (int, float, string и др.).

Создание списка в Python

Список создается путем размещения элементов внутри [] , разделенных запятыми. Например:

Здесь мы создали список с именем numbers , содержащий 3 целочисленных элемента.

Список может содержать любое количество элементов, и они могут быть разных типов (int, float, string и т.д.). Например:

Доступ к элементам списка в Python

В Python каждый элемент списка ассоциируется с индексом. Мы можем получить доступ к элементам массива, используя номер индекса (0, 1, 2,…). Например:

Здесь мы видим, что каждый элемент списка связан с номером индекса. И мы использовали индекс для доступа к элементам.

Примечание: Отсчет элементов в списке всегда начинается с индекса 0. Отсюда следует, что первый элемент списка находится под индексом 0, а не 1.

Отрицательная индексация в Python

Python позволяет использовать отрицательный индекс для своих последовательностей. Индекс -1 относится к последнему элементу, -2 относится к предпоследнему элементу и так далее. Например:

Примечание: Если указанный индекс не существует в списке, то Python выдаст ошибку IndexError .

Срез списка в Python

В Python с помощью оператора среза : можно получить доступ сразу к группе элементов (а не только к одному). Например:

Примечание: При выполнении среза в списках, первый индекс является включающим, а конечный — исключающим.

Добавление элементов в список в Python

В Python есть сразу несколько методов для добавления элементов в список.

1. Использование метода append()

Метод append() добавляет элемент в конец списка. Например:

Before Append: [21, 34, 54, 12]
After Append: [21, 34, 54, 12, 32]

Мы создали список с именем numbers . Обратите внимание на строку:

Здесь функция append() добавляет 32 в конец массива.

2. Использование метода extend()

Метод extend() используется для добавления всех элементов одного списка в другой. Например:

List1: [2, 3, 5]
List2: [4, 6, 8]
List after append: [2, 3, 5, 4, 6, 8]

У нас есть два списка с именами prime_numbers и even_numbers . Обратите внимание на стейтмент:

Здесь мы добавили все элементы even_numbers к prime_numbers .

Изменения значений элементов списка

Списки в Python являются изменяемыми. Это означает, что мы можем изменять элементы списка, присваивая им новые значения с помощью оператора = . Например:

Удаление элементов из списка

1. Использование оператора del

В Python мы можем использовать оператор del для удаления одного или нескольких элементов из списка. Например:

2. Использование метода remove()

Мы также можем использовать метод remove() для удаления элементов из списка. Например:

Методы для работы со списками в Python

Рассмотрим наиболее часто используемые методы для работы со списками в Python.

Метод Описание
append() Добавляет элемент в конец списка.
extend() Добавляет элементы из списка в конец другого списка.
insert() Вставляет элемент по указанному индексу.
remove() Удаляет элемент по указанному индексу.
pop() Возвращает и удаляет элемент, присутствующий по указанному индексу.
clear() Удаляет все элементы из списка.
index() Возвращает индекс указанного элемента.
count() Возвращает количество указанных элементов в списке.
sort() Сортирует список в порядке возрастания/убывания.
reverse() Возвращает список в обратном порядке («разворачивает» последовательность).
copy() Возвращает *поверхностную копию списка.

*Примечание: Поверхностная копия создает новый составной объект, и затем (по мере возможности) вставляет в него ссылки на объекты, находящиеся в оригинале. Глубокая копия создает новый составной объект, и затем рекурсивно вставляет в него копии объектов, находящихся в оригинале.

Итерация по списку в Python

Мы можем использовать цикл for для перебора элементов списка. Например:

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *