Экскурсия по магии памяти Python

Python — замечательная вещь, которая избавляет нас от всех сложностей управления памятью. Нам не нужно беспокоиться о предварительном выделении памяти для наших объектов или не забывать освобождать ее, когда мы закончим. Итак, учитывая, что мы не делаем это вручную, как это происходит? Должны ли мы заботиться? Ну, иногда. Может быть.

Например, вы когда-нибудь задумывались о разнице между is и == или зачем вам может понадобиться использовать deepcopy? Возможно, вы были озадачены изменением переменной, когда вы этого не ожидали, или вопросом интервью о времени жизни объекта. Или, возможно, вы просто очень хотите, чтобы некоторые кортежи вели себя плохо. Эта серия из трех частей отвечает на все эти и другие вопросы, охватывая следующее:

  • Часть 1: что такое указатель и где вы его встретите в Python
  • Часть 2: что такое id объекта Python и почему это важно
  • Часть 3: как CPython может определить, когда вы закончили использовать объект в памяти, и что он делает дальше

Давайте погрузимся с частью 1!

Что такое указатели и где их найти

Во-первых, нам нужно понять концепцию пространства имен. Пространство имен в Python — это список всех переменных, ключевых слов и функций, которые находятся в области видимости в любой заданной точке, то есть то, что вы можете написать, чтобы интерпретатор Python понял. Например, все встроенные функции, такие как print() и str(), и ключевые слова, такие как None и True, всегда находятся в каждом пространстве имен.

Когда вы создаете новую переменную, имя этой переменной добавляется в пространство имен той области, в которой она находится. Так, например, написав следующее, вы добавите имя my_string в глобальное пространство имен:

>>> my_string = "Hello World!"

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

Указатели можно рассматривать как имена, то есть записи в пространстве имен Python, которые соответствуют объектам в памяти Python. В приведенном выше примере указатель равен my_string, а объект в памяти — это строка со значением "Hello World!".

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

Примечание о терминологии: указатели, упомянутые в этой статье, не являются прямым эквивалентом указателям в C или C++ (на самом деле они больше похожи на ссылки в C++). Для тех смельчаков, которые программируют на C, отличный разбор мелочей можно найти здесь.

В качестве примера рассмотрим объект list с именем my_list и двумя произвольными элементами.

my_list = ['string', 42]

Затем это имя my_list указывает на объект list. Затем этот объект list содержит указатели на два объекта, которые являются элементами этого списка. Итак, когда вы создаете список, он автоматически будет содержать указатели, если в нем есть какие-либо элементы. По этой причине в этой статье мы будем использовать множество списков в качестве примеров.

Псевдоним указателя

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

>>> a = ["string", 42"]
>>> a
["string", 42]

Здесь мы определили наш список a и заставили наш интерпретатор распечатать его для нас, просто чтобы убедиться, что он соответствует нашим ожиданиям. Далее мы (наивно) пытаемся сделать копию, и вносим в нее некоторые изменения, а именно меняем "string" на "some words":

>>> b = a
>>> b[0] = "some words"
>>> b
["some words", 42]

Большой! За исключением того, что мы также изменили исходный список a:

>>> a
["some words", 42]

Распространенное заблуждение здесь состоит в том, что a является объектом списка, хотя на самом деле он просто указывает на него и может быть не единственным, что указывает на него. Что произошло выше, так это то, что в строке, где мы установили b = a, мы фактически не создавали новый объект списка. Мы только что создали новый указатель b и сделали так, чтобы он указывал на тот же базовый объект списка, на который уже указывал a.

Итак, когда мы меняем b, мы также меняем a. Но что, если бы мы действительно захотели создать новый объект списка и иметь возможность вносить в него изменения, не затрагивая оригинал? Ну, для этого есть метод списка:

>>> c = a.copy()
>>> c[0] = "hello!"
>>> c
["hello!", 42]
>>> a
["some words", 42]

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

Использование copy (будь то метод списка или функция из модуля copy) создает новый объект и заполняет его новыми указателями на существующие элементы.

Итак, когда мы меняем c[0], мы только меняем это, а не a[0].

Объект внешнего списка — то, что также имеет доступ к методам списка и содержит указатели на его содержимое, отличается — но с copy элементы каждого списка по-прежнему будут одними и теми же объектами в памяти. . Итак, что, если эти элементы сами по себе являются списками?

Списки внутри списков

Давайте определим новый список:

>>> a = [["alex", "beth"]]
>>> a
[["alex", "beth"]]

Вот визуальное представление:

Теперь давайте поступим так же, как и раньше, и создадим новый объект списка, используя метод copy. Однако на этот раз мы добавим что-то к первому элементу b, а не к самому b.

>>> b = a.copy()
>>> b[0].append("charlie")
>>> b[0]
["alex", "beth", "charlie"]

Когда мы используем copy, он создает в памяти новый объект внешнего списка.

Пока все хорошо, правда? Кроме….

>>> a[0]
["alex", "beth", "charlie"]

… нам удалось изменить содержимое a, несмотря на то, что мы использовали метод copy. Это связано с тем, что, как указано выше, указатели в b по-прежнему указывают на то же содержимое, что и исходный список, поэтому мы получаем то же поведение сглаживания указателей, что и в самом первом примере, только на один уровень глубже. Этот тип копирования (создание новых объектов только на один уровень в глубину и сглаживание указателя остальных) называется «поверхностной копией».

Итак, если мы хотим сделать настоящую, «глубокую» копию — то есть создать не только новый объект списка, но и новые версии всего его содержимого — как нам это сделать? Ответ: deepcopy, функция в модуле copy стандартной библиотеки.

>>> from copy import deepcopy
>>> c = deepcopy(a)
>>> c[0].append("dan")
>>> c[0]
["alex", "beth", "charlie", "dan"]
>>> a[0]
["alex", "beth", "charlie"]

Что делает deepcopy, так это рекурсивно создает новые версии каждого объекта, с которым он сталкивается, поэтому, когда он вызывается в нашем списке a, он создает новый список, и когда он видит, что элементы a также содержат указатели, он делает новые копии вещей, на которые указывают эти указатели. (Попробуйте сказать это трижды постясь с набитым ртом спагетти.)

Использование deepcopy создает совершенно новый внутренний список с новым содержимым. Таким образом, когда мы изменяем внутренний список c, он не затрагивает исходный внутренний список, на который указывает a, потому что deepcopy создал новую отдельную копию этого списка при создании c.

В этом конкретном сценарии мы используем строки в качестве содержимого наших внутренних списков. Это означает, что технически alex, на который указывает a[0][0], также является тем же объектом в памяти, что и тот, на который указывает c[0][0], потому что в Python есть некоторые оптимизации памяти, которые не позволяют ему создавать один и тот же неизменяемый объект дважды, если в этом нет необходимости. Если бы мы использовали, например, определяемый пользователем объект класса вместо строк, тогда deepcopy заставил бы Python также создавать новые экземпляры этих объектов.

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

Неизменяемые объекты (или: плохое поведение кортежей)

До сих пор мы рассматривали списки, которые являются изменяемыми объектами. Что произойдет, если мы посмотрим на что-то неизменное, например на кортеж?

Если мы говорим, что кортеж a является неизменяемым, мы имеем в виду, что при создании a все его элементы — a[0], a[1] и т. д. — фиксируются. Если его элементы неизменяемы, как строки или целые числа, довольно просто понять, что это значит.

>>> a = (42, 'beeblebrox')
>>> a[0] = 63
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

Если мы попытаемся изменить значение a[0], мы получим ошибку. Но что, если a[0] является указателем на что-то изменяемое, например на список?

>>> a = ([1, 2, 3], "hello")
>>> a[0].append(4)
>>> a
([1, 2, 3, 4], "hello")

Как оказалось, мы можем без проблем мутировать элементы a на месте! Это потому, что мы не меняем значение a[0] самого — это просто указатель. Мы меняем значение объекта, на который a[0] указывает. Если бы мы дали a[0] собственное имя — псевдоним указателя — это стало бы немного яснее:

>>> my_list = [1, 2, 3]
>>> a = (my_list, "hello")
>>> my_list.append(4)
>>> a
([1, 2, 3, 4], "hello")

Однако метод append — не единственный способ добавления в список!

Оператор +=

Мы можем сделать следующее:

>>> my_list = [1, 2, 3, 4]
>>> my_list += [5, ]
>>> my_list
[1, 2, 3, 4, 5]

Здесь мы используем оператор +=, который делает следующее:

  1. Во-первых, он создает нужный объект. Для изменяемых объектов, таких как список, это делается путем изменения объекта на месте. Для неизменяемых объектов, таких как строки, он создает совершенно новый объект. Это часть операции «+».
  2. Во-вторых, он переназначает указатель, который ему дали (в приведенном выше примере, my_list), чтобы он указывал на нужный объект.

Если += вызывается для изменяемого объекта, то шаг 2 довольно излишен — ведь указатель уже указывает на нужный объект. Но когда он вызывается для чего-то неизменного, например для строки, ему нужно изменить место, на которое указывает указатель. Например:

>>> my_string = 'Hello'
>>> my_string += ', World!'
>>> my_string
'Hello, World!'

Строки неизменны, поэтому на шаге 1 += создает совершенно новую строку 'Hello, World!' и изменяет указатель my_string так, чтобы он указывал на нее.

Вот визуальное представление:

А что, если мы попробуем применить оператор += к первому элементу нашего кортежа a? Спойлер: скоро произойдет что-то глупое!

>>> a = ([1, 2, 3, 4], "hello")
>>> a[0] += [5, ]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> a
([1, 2, 3, 4, 5], "hello")

Что там происходит? Мы получаем ошибку, когда пытаемся использовать += с элементом кортежа, но, похоже, операция все равно прошла; значение a[0] изменилось, по крайней мере.

На Шаге 2 мы ошибаемся: в этом случае мы не можем напрямую присваивать a[0], так как это элемент кортежа. Однако в примере с my_list проблем не было вообще, поскольку мы можем установить my_list так, чтобы он указывал на что угодно.

Но на шаге 1 объект списка, на который указывает a[0], был изменен на месте, а это то изменение, которое мы хотели. Затем, на шаге 2, += присваивается указателю, на который он вызывается — a[0] — и мы не можем его присвоить! Таким образом, мы получаем и изменение, и ошибку.

Итак, что мы узнали? Мы рассмотрели пространства имен, что такое указатели и где вы можете увидеть их в коде, а также несколько примеров того, как неизменяемость и указатели могут запутанно взаимодействовать. Но мы только царапаем поверхность — следите за обновлениями во второй части, где мы узнаем об идентификаторах объектов и их значении, о том, как Python определяет, когда два объекта на самом деле одинаковы, и о разнице между ними. is и ==.

Подробнее об Анвиле

Если вы здесь впервые, добро пожаловать! Anvil — это платформа для создания полнофункциональных веб-приложений только на Python. Не нужно возиться с JS, HTML, CSS, Python, SQL и всеми их фреймворками — просто создайте все это на Python.

Попробуй Анвил