deшка
Урок 3.2

Объекты, ссылки и изменяемость

Имя это ссылка, а не коробка: == и is, изменяемые и неизменяемые типы, копирование, int без переполнения и типизация Python.

Половина «странных» ошибок в скриптах загрузки и половина вопросов на собеседовании по Python сводятся к одной модели: в Python нет переменных-коробок, в которых лежат значения. Есть объекты в памяти и имена, которые на них ссылаются. Пока эта модель не в голове, код вроде b = a; b.append(4) ведёт себя как магия.

Всё объект, имя это ссылка

Каждое значение в Python это объект с тремя свойствами: идентичность (адрес, который возвращает id()), тип и содержимое. Присваивание не копирует объект, а привязывает имя к нему.

a = [1, 2, 3]
b = a
b.append(4)
print(a)        # [1, 2, 3, 4]
print(a is b)   # True

a и b это два имени одного списка. Метод append изменил объект, а не «переменную b», поэтому через a виден тот же результат. Это вопрос номер один на собеседованиях, и правильный ответ не «[1, 2, 3, 4]», а объяснение: присваивание создаёт вторую ссылку, а не второй список.

То же самое происходит при передаче в функцию, при добавлении в другой список, при сохранении в словарь. Объект один, ссылок на него много.

rows = []
row = {"id": 1}
rows.append(row)
row["id"] = 2
print(rows)     # [{'id': 2}]

== и is

== сравнивает содержимое, is сравнивает идентичность: один ли это объект в памяти.

x = [1, 2]
y = [1, 2]
print(x == y)   # True: содержимое одинаковое
print(x is y)   # False: два разных списка

is нужен ровно в двух случаях: сравнение с одиночками None, True, False (if value is None) и проверка, что две ссылки ведут на один объект. Для всего остального ==.

Классическая ловушка: is с числами и строками иногда «работает».

a = 256
b = 256
print(a is b)   # True
 
a = 1000
b = 1000
print(a is b)   # зависит от того, как выполнялся код

CPython держит готовые объекты для целых от −5 до 256 и для коротких строк-идентификаторов, поэтому is для них случайно даёт True. Это деталь реализации, а не правило языка. Сравнение значений через is это ошибка, даже когда тест проходит.

Изменяемые и неизменяемые типы

Объекты делятся на два вида. Изменяемые можно менять на месте: list, dict, set, bytearray, экземпляры своих классов. Неизменяемые нельзя: int, float, str, bytes, tuple, frozenset, bool, None.

У неизменяемого объекта любая «модификация» создаёт новый объект, а имя перепривязывается:

s = "abc"
t = s
s += "d"
print(s, t)     # abcd abc
print(s is t)   # False

s += "d" не изменил строку (строки не меняются), а создал новую и привязал к ней имя s. Имя t осталось на старой. Со списком тот же код ведёт себя иначе:

s = [1, 2]
t = s
s += [3]
print(s, t)     # [1, 2, 3] [1, 2, 3]
print(s is t)   # True

Для списка += вызывает extend и меняет объект на месте. Один и тот же оператор, два разных поведения, и объясняются они только изменяемостью типа.

Кортеж неизменяем, но хранит ссылки. Если внутри кортежа список, список по-прежнему можно изменить:

t = (1, [2, 3])
t[1].append(4)
print(t)        # (1, [2, 3, 4])

Неизменяемость кортежа означает, что нельзя заменить его элементы, а не что содержимое элементов заморожено.

Копирование

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

import copy
 
rows = [{"id": 1, "tags": ["a"]}]
 
shallow = list(rows)              # или rows[:], или rows.copy()
deep = copy.deepcopy(rows)
 
rows[0]["tags"].append("b")
print(shallow[0]["tags"])         # ['a', 'b']: словарь внутри общий
print(deep[0]["tags"])            # ['a']: скопировано всё дерево

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

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

int без переполнения

int в Python это объект произвольной длины: переполнения нет, 2 ** 100 считается точно. Цена этого в памяти и скорости: каждое число это объект размером от 28 байт, а не 8 байт в регистре процессора.

import sys
print(2 ** 100)                  # 1267650600228229401496703205376
print(sys.getsizeof(0))          # 28
print(sys.getsizeof(2 ** 100))   # 40

Поэтому миллион чисел в списке Python занимают в разы больше памяти, чем те же числа в массиве NumPy или колонке pandas, где они лежат плотно, по 8 байт. Отсюда и ответ на вопрос «почему для больших данных Python использует библиотеки на C»: не потому что циклы в Python медленные сами по себе, а потому что каждый элемент это отдельный объект с заголовком.

Деньги в float хранить нельзя: 0.1 + 0.2 не равно 0.3. Для сумм, которые сверяются с бухгалтерией, нужен decimal.Decimal или целые копейки.

Типизация: динамическая, но строгая

Тип привязан к объекту, а не к имени: одно имя может по очереди ссылаться на число, строку и список. Это динамическая типизация. При этом Python не приводит типы молча: "1" + 1 это TypeError, а не "11" и не 2. Это строгая типизация. JavaScript, для сравнения, динамический и нестрогий.

Аннотации типов (def load(rows: list[dict]) -> int) ничего не проверяют во время выполнения. Они нужны редактору, mypy и людям, которые читают код. Если в функцию с аннотацией int передать строку, Python её не остановит.

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

Как отвечать на собеседовании

Что выведет a = [1,2,3]; b = a; b.append(4); print(a)? [1, 2, 3, 4]: b = a создаёт вторую ссылку на тот же список, append меняет объект, и через a видно то же самое.

Чем == отличается от is? == сравнивает значения, is проверяет, что это один объект в памяти. is используется для None и для проверки идентичности, для сравнения чисел и строк он некорректен, даже если иногда возвращает True из-за кэша маленьких целых.

Назовите изменяемые и неизменяемые объекты. Изменяемые: list, dict, set, bytearray, объекты классов. Неизменяемые: int, float, str, bytes, tuple, frozenset, bool, None. Кортеж неизменяем, но элементы внутри него могут быть изменяемыми.

Что представляет из себя int? Объект произвольной длины без переполнения, от 28 байт в памяти. Маленькие целые от −5 до 256 кэшируются интерпретатором.

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

Практика

python 3.12 · песочницарешено 0 / 3

Функция with_defaults(row, defaults) должна вернуть новый словарь: все пары из defaults, поверх них пары из row. Исходные row и defaults меняться не должны.

Заготовка меняет defaults на месте: после первого вызова значения из row остаются в defaults и портят следующие вызовы.

Ctrl+Enter
ТЕСТЫ
ВводВаш выводОжидаемый вывод
d = {"city": "-", "segment": "new"} print(with_defaults({"city": "Казань"}, d)) print(d)—{'city': 'Казань', 'segment': 'new'} {'city': '-', 'segment': 'new'}
d = {"a": 1} r = {"a": 2} out = with_defaults(r, d) print(out is d, out is r, out)—False False {'a': 2}
скрытый тест 3
решённые задания сохранятся в прогресс после входа
Войти, чтобы отмечатьдальше →
3.3 →Память: счётчик ссылок, сборщик мусора и почему скрипт съел 8 ГБ