Переменные и изменчивость: ссылки, mutable vs immutable
В Python переменные устроены иначе чем в C или Go. Это не «коробки с данными», а имена, привязанные к объектам. Понимание этой модели объясняет много сюрпризов: почему a = b не копирует список, почему изменение элемента словаря может ломать кеш, и почему id() иногда совпадает для разных литералов. Этот урок один из самых важных в треке.
Имена и объекты
Когда пишешь:
x = 5
Python создаёт объект-целое со значением 5 и привязывает имя x к нему. Имя - не «коробка», а ярлык, указывающий на объект:
x = 5
y = x # y тоже указывает на тот же объект-5
print(id(x), id(y)) # одинаковый id
print(x is y) # True
Когда переприсваиваешь:
x = 10
Имя x теперь указывает на новый объект-10. Объект-5 (если на него никто больше не ссылается) уйдёт сборщику мусора.
Mutable и immutable
Объекты делятся на два класса:
Immutable (неизменяемые):
int,float,complex,boolstr,bytestuple,frozensetNone
Mutable (изменяемые):
list,dict,setbytearray- Большинство пользовательских классов
Разница важна для понимания того, что происходит при a = b:
# Immutable - всё чисто
a = "hello"
b = a
a = "world"
print(b) # 'hello' - не изменился
# Mutable - сюрпризы
a = [1, 2, 3]
b = a
a.append(4)
print(b) # [1, 2, 3, 4] - изменился!
Во втором случае a и b указывают на один и тот же объект-список. Изменение через a.append() модифицирует этот объект, и оба имени видят результат.
Identity и equality
>>> a = [1, 2, 3]
>>> b = [1, 2, 3]
>>> a == b # True - значения равны
>>> a is b # False - разные объекты
>>> c = a
>>> a is c # True - один объект
== сравнивает значения через __eq__(). is проверяет identity (один ли это объект). Про is None и truthy/falsy - в прошлом уроке.
Интернирование маленьких int и коротких str
CPython кеширует часто используемые объекты для оптимизации. Это даёт неожиданное:
>>> a = 100
>>> b = 100
>>> a is b # True - оба указывают на закешированный 100
>>> a = 1000
>>> b = 1000
>>> a is b # False - НЕ закеширован
CPython кеширует int от -5 до 256 (по соображениям производительности - они часто используются). Для остальных создаются новые объекты.
Никогда не сравнивай числа через is - надёжно только для None, True, False. Для значений всегда ==.
Tuple содержит ссылки
tuple immutable - его нельзя переприсвоить элементы:
t = (1, 2, 3)
t[0] = 99 # TypeError
Но tuple хранит ссылки на объекты. Если объект внутри mutable - его можно изменить:
t = ([1, 2], [3, 4])
t[0].append(99)
print(t) # ([1, 2, 99], [3, 4])
«Иммутабельность» tuple - про то, что нельзя поменять структуру (добавить/удалить элементы или переприсвоить), но не про содержимое ссылок. Подробно про кортежи - в уроке про tuple и NamedTuple.
Передача в функцию
Аргументы в Python передаются по ссылке на объект. Если функция получает immutable - изменения остаются локальными. Если mutable - изменения видны снаружи:
def modify(lst, num):
lst.append(999) # модифицирует объект
num = 42 # перепривязывает имя - наружу не виден
a = [1, 2]
b = 5
modify(a, b)
print(a) # [1, 2, 999]
print(b) # 5
<InfoCard type="warning" title="Это не "by value" и не "by reference""> Эта модель называется call by sharing или call by object reference. Функция получает ту же ссылку, что и снаружи, но если она перепривяжет имя - наружу это не видно. Изменения mutable-объекта по этой ссылке - видны.
Копирование
Чтобы получить независимую копию:
import copy
# Shallow copy - копирует верхний уровень, ссылки внутри те же
a = [[1, 2], [3, 4]]
b = a.copy() # или list(a), a[:], copy.copy(a)
b[0].append(99)
print(a) # [[1, 2, 99], [3, 4]] - изменился!
# Deep copy - рекурсивная копия всего
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0].append(99)
print(a) # [[1, 2], [3, 4]] - не изменился
Shallow vs deep:
- Shallow дешевле, но опасен для вложенных mutable
- Deep безопасен, но медленнее (рекурсивный обход всего дерева)
Большинство сценариев требуют shallow - просто помни про вложенность.
Аугментированное присваивание
+= и аналоги работают по-разному для mutable и immutable:
# Immutable
a = 5
b = a
a += 1 # эквивалент a = a + 1 - создаётся новый объект
print(b) # 5
# Mutable
a = [1, 2]
b = a
a += [3] # эквивалент a.extend([3]) - модифицирует объект на месте
print(b) # [1, 2, 3] - тоже изменился
Для list a += [x] - это in-place операция, для int - создание нового объекта. Это иногда удивляет.
Hashable
Хешируемые объекты могут быть ключами dict и элементами set. Это объекты, у которых:
- Есть
__hash__()метод __eq__()совместим с хешем (равные объекты должны иметь равный хеш)- Хеш не меняется за время жизни
Immutable объекты обычно хешируемы:
hash("hello") # OK
hash(42) # OK
hash((1, 2, 3)) # OK - tuple из hashable
hash([1, 2]) # TypeError - list mutable, не хешируется
hash({1, 2}) # TypeError - set mutable
hash(([1], 2)) # TypeError - tuple с mutable элементом
Поэтому ключами dict и элементами set могут быть строки, числа, tuple из неизменяемых - но не список.
Утечки ссылок и слабые ссылки
Иногда нужно ссылаться на объект, но не мешать его удалению из памяти. Для этого есть weakref:
import weakref
class User:
pass
u = User()
ref = weakref.ref(u)
print(ref()) # <User object>
del u
print(ref()) # None - объект собран
Это нишевая штука - в основном для кешей и observer-паттернов, где иначе создавались бы циклические ссылки.
globals, locals и scope
Каждая функция имеет свою область видимости. = создаёт локальное имя, если не указано иначе:
x = 10
def f():
x = 20 # локальное x, не глобальное
print(x) # 20
f()
print(x) # 10
Чтобы изменить глобальную - явное global:
x = 10
def f():
global x
x = 20
f()
print(x) # 20
Для вложенных функций - nonlocal:
def outer():
count = 0
def inner():
nonlocal count
count += 1
inner()
print(count) # 1
Подробно про scope - в уроке про функции и правило LEGB. Сейчас важно понимать: имена живут в namespace, и присваивание создаёт имя в текущем.
Сборка мусора
Python автоматически освобождает память:
- Подсчёт ссылок - когда количество ссылок на объект становится 0, он удаляется немедленно
- Цикл-детектор - для случаев когда объекты ссылаются друг на друга по кругу (
a.x = b; b.x = a), запускается периодически
Тебе обычно не нужно об этом думать, но знать полезно для отладки утечек памяти и понимания почему del x не всегда мгновенно освобождает память.
Мини-задание
- Проверь identity vs equality:
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(f"a == b: {a == b}")
print(f"a is b: {a is b}")
print(f"a is c: {a is c}")
print(f"id(a)={id(a)}, id(b)={id(b)}, id(c)={id(c)}")
- Сделай ошибку с shallow copy и почини её:
import copy
matrix = [[0, 0], [0, 0]]
shallow = matrix.copy()
shallow[0][0] = 99
print(matrix) # упс - тоже изменился
deep = copy.deepcopy(matrix)
deep[0][0] = 99
print(matrix) # не изменился
- Проверь хешируемость:
hashable_types = [42, "abc", (1, 2), frozenset([1, 2])]
for x in hashable_types:
print(f"hash({x!r}) = {hash(x)}")
unhashable_types = [[1, 2], {1, 2}, {"a": 1}]
for x in unhashable_types:
try:
hash(x)
except TypeError as e:
print(f"hash({x!r}) -> {e}")
Что дальше
Поняли как Python хранит данные и почему ссылки иногда удивляют. В следующем уроке - type hints: как декларативно описывать типы аргументов и возвращаемых значений, и как проверять это через mypy.