Эта статья была первоначально опубликована на сайте Python Morsels.

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

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

Оглавление:

  • Терминология

  • Переменные в Python являются указателями, а не бакетами

  • Присвоения указывают переменную на объект

  • Два типа "изменений" в Python

  • Равенство сравнивает объекты, а тождество сравнивает указатели

  • Нет исключений для неизменяемых объектов

  • Структуры данных содержат указатели

  • Аргументы функций действуют как операторы присваивания

  • Копии поверхностны, и обычно это считается нормальным.

  • Резюме

Терминология

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

Объект (он же значение): "вещь". Списки, словари, строки, числа, кортежи, функции и модули — все это объекты. Понятие "объект" не поддается определению, потому что в Python все является объектом.

Переменная (она же имя): имя, используемое для ссылки на объект.

Указатель (он же ссылка): описывает, где находится объект (часто изображается визуально в виде стрелки).

Равенство: представляют ли два объекта одни и те же данные.

Идентичность: ссылаются ли два указателя на один и тот же объект.

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

Переменные в Python — это указатели, а не бакеты

Переменные в Python — это не бакеты, содержащие вещи; это указатели (они указывают на объекты).

Слово "указатель" может звучать пугающе, но большая часть этого страха происходит от взаимосвязанных понятий (например, разыменование), которые не имеют отношения к Python. В Python указатель просто представляет собой связь между переменной и объектом.

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

Приведенная выше диаграмма представляет состояние нашего процесса Python после выполнения данного кода:

>>> numbers = [2, 1, 3, 4, 7]
>>> numbers2 = [11, 18, 29]
>>> name = "Trey"

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

  • указатель ⇒ ссылка

  • указать на ⇒ ссылаться на

  • указал на ⇒ сослался

  • указывать X на Y ⇒ заставлять X ссылаться на Y

Присваивания указывают переменную на объект

Операторы присваивания указывают переменную на объект. Вот и все.

Если мы запустим этот код:

>>> numbers = [2, 1, 3, 4, 7]
>>> numbers2 = numbers
>>> name = "Trey"

Состояние наших переменных и объектов будет выглядеть следующим образом:

Обратите внимание, что numbers и numbers2 указывают на один и тот же объект. Если мы изменим этот объект, то обе переменные будут как бы "видеть" данную перемену:

>>> numbers.pop()
7
>>> numbers
[2, 1, 3, 4]
>>> numbers2
[2, 1, 3, 4]

Вся эта странность была связана с данным оператором присваивания:

>>> numbers2 = numbers

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

Два типа "изменения" в Python

В Python есть два различных типа "изменения":

  1. Присваивание изменяет переменную (изменяет объект, на который она указывает).

  2. Мутация изменяет объект (на который может указывать любое количество переменных).

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

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

Равенство сравнивает объекты, а тождество сравнивает указатели

Оператор == в Python проверяет, что два объекта представляют одни и те же данные (так называемое равенство):

>>> my_numbers = [2, 1, 3, 4]
>>> your_numbers = [2, 1, 3, 4]
>>> my_numbers == your_numbers
True

Оператор is в Python проверяет, являются ли два объекта одинаковыми (также известный как идентичность):

>>> my_numbers is your_numbers
False

Переменные my_numbers и your_numbers указывают на объекты, представляющие одни и те же данные, но они не являются одним и тем же объектом.

Поэтому изменение одного объекта не приводит к изменению другого:

>>> my_numbers[0] = 7
>>> my_numbers == your_numbers
False

Если две переменные указывают на один и тот же объект:

>>> my_numbers_again = my_numbers
>>> my_numbers is my_numbers_again
True

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

>>> my_numbers_again.append(7)
>>> my_numbers_again
[2, 1, 3, 4, 7]
>>> my_numbers
[2, 1, 3, 4, 7]

Оператор == проверяет равенство, а оператор is проверяет тождество. Это различие между идентичностью (тождественностью) и равенством существует потому, что переменные не содержат объектов, они на них указывают.

В Python проверки на равенство встречаются очень часто, а проверки на идентичность - крайне редки.

Нет исключения для неизменяемых (иммутабельных) объектов

Но подождите, модификация числа не изменяет другие переменные, указывающие на то же число, верно?

>>> n = 3
>>> m = n  # n and m point to the same number
>>> n += 2
>>> n  # n has changed
5
>>> m  # but m hasn't changed!
3

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

Так что насчет оператора += выше? Разве он не изменил число? (Не изменил.)

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

>>> n += 2
>>> n = n + 2

Для иммутабельных объектов дополненные присваивания (+=, *=, %= и т.д.) выполняют операцию (которая возвращает новый объект), а затем выполняют присваивание (этому новому объекту).

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

Структуры данных содержат указатели

Как и переменные, структуры данных не содержат объектов, они содержат указатели на объекты.

Допустим, мы создаем список, содержащий списки в качестве элементов (список списков):

>>> matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

Затем мы создаем переменную, указывающую на второй список в нашем списке списков:

>>> row = matrix[1]
>>> row
[4, 5, 6]

Состояние наших переменных и объектов теперь выглядит следующим образом:

Наша переменная row указывает на тот же объект, что и индекс 1 в списке нашей matrix:

>>> row is matrix[1]
True

Поэтому, если мы изменим список, на который указывает row:

>>> row[0] = 1000

Мы увидим это изменение в обоих местах:

>>> row
[1000, 5, 6]
>>> matrix
[[1, 2, 3], [1000, 5, 6], [7, 8, 9]]

Принято говорить о структурах данных, "содержащих" объекты, но на самом деле они содержат только указатели на объекты.

Аргументы функций действуют как операторы присваивания

Вызовы функций также выполняют присваивание.

Если вы мутируете объект, который был передан в вашу функцию, вы также мутировали исходный объект:

>>> def smallest_n(items, n):
...     items.sort()  # This mutates the list (it sorts in-place)
...     return items[:n]
...
>>> numbers = [29, 7, 1, 4, 11, 18, 2]
>>> smallest_n(numbers, 4)
[1, 2, 4, 7]
>>> numbers
[1, 2, 4, 7, 11, 18, 29]

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

>>> def smallest_n(items, n):
...     items = sorted(items)  # this makes a new list (original is unchanged)
...     return items[:n]
...
>>> numbers = [29, 7, 1, 4, 11, 18, 2]
>>> smallest_n(numbers, 4)
[1, 2, 4, 7]
>>> numbers
[29, 7, 1, 4, 11, 18, 2]

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

В первом случае мы изменили объект, а во втором - изменили переменную.

Вот еще один пример, с которым можно иногда столкнуться:

class Widget:
    def __init__(self, attrs=(), choices=()):
        self.attrs = list(attrs)
        self.choices = list(choices)

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

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

Копии поверхностны, и это обычно нормально

Нужно скопировать список в Python?

>>> numbers = [2000, 1000, 3000]

Вы можете вызвать метод copy (если уверены, что ваш итерируемый объект является списком):

>>> my_numbers = numbers.copy()

Или можете передать его в конструктор list (это работает с любым итерируемым объектом):

>>> my_numbers = list(numbers)

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

Два списка различны, но объекты в них одинаковы:

>>> numbers is my_numbers
False
>>> numbers[0] is my_numbers[0]
True

Поскольку целые числа (и все числа) в Python иммутабельны, нам не важно, что каждый список содержит одни и те же объекты, поскольку мы все равно не можем их мутировать.

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

>>> matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
>>> new_matrix = list(matrix)

Эти два списка не одинаковы, но каждый элемент в них один и тот же:

>>> matrix is new_matrix
False
>>> matrix[0] is new_matrix[0]
True

Вот довольно сложное визуальное представление этих двух объектов и содержащихся в них указателей:

Таким образом, если мы мутируем первый элемент в одном списке, это приведет к мутации того же элемента в другом списке:

>>> matrix[0].append(100)
>>> matrix
[[1, 2, 3, 100], [4, 5, 6], [7, 8, 9]]
>>> new_matrix
[[1, 2, 3, 100], [4, 5, 6], [7, 8, 9]]

Когда вы копируете объект в Python, если он указывает на другие объекты, то копируются указатели на них, а не копируются сами объекты.

Начинающие программисты Python, в связи с таким поведением, добавляют в свой код функцию copy.deepcopy. Функция deepcopy пытается рекурсивно скопировать объект вместе со всеми объектами, на которые он указывает.

Иногда новички в Python используют deepcopy для рекурсивного копирования структур данных:

from copy import deepcopy
from datetime import datetime

tweet_data = [{"date": "Feb 04 2014", "text": "Hi Twitter"}, {"date": "Apr 16 2014", "text": "At #pycon2014"}]

# Parse date strings into datetime objects
processed_data = deepcopy(tweet_data)
for tweet in processed_data:
    tweet["date"] = datetime.strptime(tweet["date"], "%b %d %Y")

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

# Parse date strings into datetime objects
processed_data = [
    {**tweet, "date": datetime.strptime(tweet["date"], "%b %d %Y")}
    for tweet in tweet_data
]

В Python мы отдаем предпочтение поверхностным копиям. Если вы не мутируете объекты, которые вам не принадлежат, у вас обычно нет необходимости в использовании deepcopy.

Функция deepcopy, конечно, применяется, но зачастую в ней нет необходимости. "Как избежать использования deepcopy" заслуживает отдельного обсуждения в одной из последующих статей.

Резюме

Переменные в Python — это не бакеты, содержащие вещи; это указатели (они указывают на объекты).

Модель переменных и объектов в Python сводится к двум основным правилам:

  1. Мутация изменяет объект.

  2. Присваивание указывает переменную на объект.

А также с вытекающими из этого правилами:

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

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

Более того, структуры данных работают точно так же: списки и словари содержат указатели на объекты, а не сами объекты. Аналогично работают и атрибуты: атрибуты указывают на объекты (точно так же, как любая переменная указывает на объект). Таким образом, объекты не могут содержать объекты в Python (они могут только указывать на них).

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

Подробнее об этой теме смотрите:

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


Всех, дочитавших статью до конца, приглашаем на открытое занятие «Tabula rasa Python проекта». На занятии рассмотрим best practices по настройке окружения для разработки свежего проекта на Python, а также поговорим про всевозможные инструменты и автоматизации, которые могут применяться в таком случае. Регистрация на вебинар.

Комментарии (4)


  1. AAbrosov
    20.05.2022 14:12

    "бакеты" меня немного пугают. Я понимаю что это значит, но всё-таки ожидал бы увидеть на этом месте "коробки" или хотя-бы "корзины".


  1. matabili1973
    20.05.2022 14:22
    +2

    В любом уважающем себя курсе или учебнике должен быть примерно такой пример:

    a = [1, 2, 3, 4, 5, 6, 7, 8, 9]

    b = a

    c = a[:]

    a.append(10)

    print(a, b, c, sep = '\n')


  1. igand
    21.05.2022 18:15

    # Parse date strings into datetime objects
    processed_data = [
        {**tweet, "date": datetime.strptime(tweet["date"], "%b %d %Y")}
        for tweet in tweet_data
    ]

    Как-то даже не сразу сообразил, как это работает. При создании словаря ключ "date" у нас будет в двух экземплярах - один распакуется из **tweet, а второй явно написан. И в результат попадёт значение именно из него, так как он стоит правее.


  1. BARONtheKNIGHT
    23.05.2022 11:52

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