Переменные и изменчивость: ссылки, 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 (если на него никто больше не ссылается) уйдёт сборщику мусора.

Функция `id(obj)` возвращает уникальный идентификатор объекта (в CPython это адрес в памяти). Если `id(a) == id(b)` - значит `a is b`. Это один и тот же объект.

Mutable и immutable

Объекты делятся на два класса:

Immutable (неизменяемые):

  • int, float, complex, bool
  • str, bytes
  • tuple, frozenset
  • None

Mutable (изменяемые):

  • list, dict, set
  • bytearray
  • Большинство пользовательских классов

Разница важна для понимания того, что происходит при 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. Это объекты, у которых:

  1. Есть __hash__() метод
  2. __eq__() совместим с хешем (равные объекты должны иметь равный хеш)
  3. Хеш не меняется за время жизни

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 не всегда мгновенно освобождает память.

Мини-задание

  1. Проверь 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)}")
  1. Сделай ошибку с 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)   # не изменился
  1. Проверь хешируемость:
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.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.