Имя, объект, ссылка

Одно определение, из которого выводится половина всего, что в Python называют подводными камнями. Если усвоить его до конца, запоминать эти камни больше не придётся.

L1 · 7 минL2 · 12 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Четыре фрагмента. Прежде чем открывать ответ — сформулируй, что выведет каждый, и зафиксируй формулировку: не «что-то странное», а конкретный вывод и причина. Место, где предсказание разойдётся с реальностью, — самое ценное на этой странице.

Фрагмент A
def add(item, target=[]):
    target.append(item)
    return target

print(add(1))
print(add(2))
[1]
[1, 2]
Второй вызов дописывает в тот же список. Не в новый.
Фрагмент B
a = [1, 2]
b = a
b += [3]
print(a)

x = 1
y = x
y += 1
print(x)
[1, 2, 3]
1
Один и тот же оператор +=, два разных исхода.
Фрагмент C
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
Изменили одну ячейку — поменялись три.
Фрагмент D
t = ([1], 2)
t[0] += [9]
print(t)
TypeError: 'tuple' object does not support item assignment

…и при этом t теперь равен ([1, 9], 2). Исключение выброшено, но мутация произошла.

2Механизм

Начнём с определения, а не с примеров. В Python оно одно и звучит так:

Значение — всегда объект в куче. У объекта есть тип, содержимое и адрес.

Имя — не контейнер, а привязка. Запись в неймспейсе, указывающая на объект.

Присваивание не копирует объект. Оно перенаправляет имя.

Всё остальное на этой странице — следствия. Проверим это буквально: каждый фрагмент выше получится из определения за два-три шага.

Следствие 1 · два имени, один объект

b = a не создаёт список. Оно создаёт вторую привязку к тому же объекту. Значит любое изменение объекта видно через оба имени — это не побочный эффект, а буквально то, что записано в определении.

Фрагмент C — то же самое, только имён не два, а три, и они безымянные. Оператор * на списке повторяет элементы, а элемент здесь — ссылка на внутренний список. Внешний список получает три ссылки на один объект.

Следствие 2 · += — это не одна операция

Фрагмент B выглядит как противоречие: один оператор, разное поведение. Противоречия нет, потому что += не элементарен. Он спрашивает у объекта, умеет ли тот меняться на месте:

У списка __iadd__ есть. У int его нет и быть не может: целые неизменяемы. Отсюда y += 1 оставляет x нетронутым — не потому, что «числа копируются», а потому, что y просто указало на другой объект.

Фрагмент D — этот же механизм, доведённый до абсурда, и он проясняет всё разом. t[0] += [9] разворачивается в две операции: сначала t[0].__iadd__([9]) — список мутирует успешно; затем t[0] = результат — и вот тут кортеж отказывается. Мутация уже случилась, присваивание провалилось. Одна строка, два действия, и понятны они только через определение.

Следствие 3 · def — исполняемая инструкция

Функция — такой же объект, как список. Строка def не является объявлением: она исполняется, создаёт объект функции и привязывает к нему имя. Значит выражения в списке параметров вычисляются в этот момент — один раз, при создании функции, а не при каждом вызове.

Вычисленные значения складываются в атрибут объекта функции. Их можно посмотреть руками:

print(add.__defaults__)
# → ([1, 2],)

Фрагмент A перестаёт быть ловушкой. Список создан один раз и лежит в add.__defaults__; каждый вызов без аргумента получает его. Это ровно то, что должно происходить, если def исполняется, а имя — привязка.

Следствие 4 · is и == отвечают на разные вопросы

Раз значение — объект с адресом, возникают два разных вопроса: «это один и тот же объект?» и «эти объекты равны?». Первый — is, второй — ==. Они не взаимозаменяемы и совпадают только случайно.

Знаменитое a = 256; b = 256; a is b → True, а для 257 — False, не является свойством языка. Это два разных механизма реализации: кэш мелких целых от −5 до 256 🔧 и сворачивание одинаковых констант внутри одного объекта кода. Поэтому в скрипте a = 257; b = 257 в одной строке даст True, а те же две строки в REPL по отдельности — False. Правильный ответ на такой вопрос — не «True» и не «False», а «вопрос поставлен неверно».

Что отсюда следует дальше

Копирование по умолчанию поверхностное — иначе и быть не может: copy обязан скопировать объект, а его содержимое — ссылки. Хочешь копию графа — нужен deepcopy, который тащит за собой словарь уже скопированных объектов, чтобы не зациклиться.

И самое дорогое следствие: раз каждое число — полноценный объект с заголовком, миллион чисел — это миллион объектов плюс массив указателей на них. Numpy существует не потому, что «C быстрее». Он существует потому, что кладёт миллион чисел в один непрерывный буфер без объектов вообще. Это не оптимизация того же — это отказ от модели R1 внутри массива.

3Границы модели

Где сказанное перестаёт держать

«Неймспейс — это словарь» верно не везде. Локальные переменные функции живут не в словаре, а в массиве слотов фрейма; компилятор знает их по индексам и генерирует LOAD_FAST. locals() внутри функции собирает снимок, а не отдаёт настоящее хранилище — и запись в этот снимок ничего не меняет 🔧. Часть «имя → объект» остаётся верной; словарём является глобальный и модульный неймспейс, а также __dict__ обычного объекта.

«Неизменяемый» — про сам объект, а не про достижимое из него. Кортеж гарантирует, что его ячейки не поменяются, и ничего не обещает про объекты в этих ячейках. Отсюда и фрагмент D, и то, что кортеж со списком внутри нехешируем.

Идентичность объекта — не адрес. id() в CPython возвращает адрес 🔧, но язык обещает только уникальность на время жизни объекта. Адреса переиспользуются: id умершего объекта может достаться новому.

4Корень

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

Решение унаследовано от ABC, языка, над которым Гвидо работал до Python, и усилено сознательно: один набор правил вместо двух. Цена известна и заплачена — заголовок у каждого числа, косвенность на каждом обращении, невозможность положить массив чисел в кэш процессора плотно. Выгода тоже известна: не существует вопроса «а это значение или объект», не существует автобоксинга, не существует двух видов равенства, и любую сущность можно положить в список, передать в функцию и приписать ей атрибут.

5Аналогия

Если ты работал с numpy, интуиция уже есть — просто под другим именем. b = a для массива и b = a[:] как view: два имени на одни данные, запись через любое видна через оба. Разница только в том, что numpy заставил тебя выучить это явно (.copy() против view), а Python предполагает, что ты понял это из определения языка.

Точнее всего — std::shared_ptr без арифметики указателей: имя владеет объектом, копия имени добавляет владельца, объект жив пока есть хоть один. Эта аналогия ещё пригодится в R3: она объясняет не только присваивание, но и момент разрушения.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему is иногда работает для одинаковых строк и чисел, а иногда нет?

Потому что вопрос не про is, а про то, сколько объектов создал компилятор. Оператор всегда честно сравнивает идентичность; меняется то, один перед ним объект или два.

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

def f():
    a = "hello"
    b = "hello"
    return a is b
print(f())          # → True   одна константа на всю функцию

x = "hel"
print(x + "lo" is "hello")   # → False  строка собрана в рантайме

Оба механизма — 🔧 деталь реализации, и границы обоих менялись между версиями. Правило без исключений: is только для None, True, False и собственных синглтонов-часовых. Для всего остального ==.

Если хочется гарантии совпадения объектов для строк — она есть и называется sys.intern. Урок 02 показывает, что на миллионе повторяющихся строк это даёт 21.2 → 2.6 МБ.

Если имя — это ссылка, почему y = x; y += 1 не меняет x?

Потому что += — не одна операция, а вопрос объекту: «умеешь меняться на месте?»

Интерпретатор вызывает __iadd__, если он есть, и __add__, если нет. Затем — и это главное — результат присваивается обратно имени слева. Для неизменяемого числа __iadd__ отсутствует, создаётся новый объект, и имя y перепривязывается к нему. Имя x не трогали, оно смотрит туда же, куда смотрело.

x = [1]; y = x
y += [2]
print(x)            # → [1, 2]   список менялся на месте

x = 1; y = x
y += 1
print(x)            # → 1        число неизменяемо, y перепривязано

Отсюда неочевидное следствие: y += [2] и y = y + [2] — разные операции. Первая меняет объект и видна через все остальные имена, вторая создаёт новый и не видна нигде. Для чисел они неразличимы, для списков — нет, и на этом регулярно ловятся.

Почему mutable default не починили за тридцать лет?

Потому что чинить пришлось бы не ловушку, а само правило — и оно правильное.

Дефолт вычисляется один раз при выполнении def, потому что def — исполняемая инструкция, создающая объект функции. Значения по умолчанию хранятся в __defaults__ этого объекта. Альтернатива — вычислять выражение при каждом вызове — означала бы, что def f(x=expensive()) платит за expensive() на каждом вызове, а def f(x=SOME_CONST) перестал бы видеть значение на момент определения.

def f(x=[]): x.append(1); return x
print(f.__defaults__)     # → ([],)  объект существует и живёт с функцией
f(); f()
print(f.__defaults__)     # → ([1, 1],)

Дальше вступает R9: на текущее поведение опирается слишком много кода, включая идиому кеширования через дефолт и трюк с ранним связыванием def f(_cache={}). Поэтому язык не тронули, а проблему обошли точечно — dataclass просто запрещает изменяемый дефолт и требует field(default_factory=list).

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

Почему copy.deepcopy такой медленный и когда он вообще нужен?

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

deepcopy обходит весь достижимый граф, ведёт словарь уже скопированных объектов по id (иначе циклическая структура зациклила бы его), для каждого типа ищет подходящий способ копирования — __deepcopy__, __reduce_ex__, протокол pickle. Это интерпретируемый обход с диспетчеризацией на каждом узле.

Практический вывод: если структура сериализуема, обычно быстрее сделать круг через сериализацию, чем звать deepcopy — потому что сериализатор написан на C. Но проверять надо замером на своих данных: у pickle свои накладные расходы, и на мелких объектах он проигрывает.

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

Lib/copy.py — сорок строк, которые стоит прочитать целиком: там видно и словарь memo, и таблицу диспетчеризации.

Итог. Значение в Python — всегда объект в куче; имя — привязка к нему; присваивание перенаправляет привязку, а не копирует. Из этих трёх фраз выводятся общий mutable default (def исполняется один раз), разное поведение += (протокол __iadd__, а не оператор), утроенная строка в [[0]*3]*3 (повторяются ссылки), одновременные мутация и TypeError в кортеже (две операции в одной строке) и разница is и == (идентичность против равенства). Ни одно из этого не требует запоминания — всё пересчитывается из определения за пару шагов. Цена решения — заголовок у каждого числа, и именно поэтому существует numpy.
Дальше — по желанию
контрфактуалJava, C++ — как это выглядит у тех, кто решил иначеАвтобоксинг Java даёт ту же ловушку с кэшем; C++ платит копированием вместо алиасинга
Контрфактуал · как это выглядит у тех, кто решил иначе

Java выбрала два набора правил — int и Integer — и получила автобоксинг. А вместе с ним ровно ту же ловушку: Integer a = 127, b = 127; a == b даёт true, а для 128 — false. Тот же кэш, та же путаница идентичности и равенства, только вдобавок два типа вместо одного и NullPointerException при распаковке. Python заплатил производительностью, Java заплатила сложностью правил — и от проблемы это её не спасло.

C++ выбрал семантику значений: присваивание копирует, алиасинг требует явной ссылки. Ловушек с общим состоянием нет; вместо них — цена копирования, конструкторы копирования, move-семантика и висячие ссылки.

Обе альтернативы исполнены и обе живы. Это не «Python сделал неправильно» — это выбранная точка на реальном компромиссе.

🐛 багЗащитная копия, которая не защищаетСрез копирует список, но не его элементы — и мутация уходит вызывающему
🐛 Баг, который проходит ревью

Функция делает защитную копию входа. Копия есть, ревьюер её видит, дефект остаётся.

def mark_processed(items):
    items = items[:]                 # защитная копия, чтобы не портить вход
    for item in items:
        item["status"] = "done"
        item["processed_at"] = now()
    return items

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

orig = [{"id": 1, "status": "new"}]
mark_processed(orig)
print(orig)
# → [{'id': 1, 'status': 'done', 'processed_at': ...}]

Из следствия 1 это видно сразу: items[:] создаёт новый список, но кладёт в него те же ссылки. Копия защищает от append и remove и не защищает ни от чего внутри элементов. Ревью пропускает это потому, что защита формально присутствует — глазами ищут её отсутствие, а не её глубину.

Особенно неприятно, что баг проявляется не здесь. Вызывающий код продолжает работать со «своим» списком, видит проставленные статусы и делает вывод, что запись уже обработана. Расследование начнётся через неделю и в другом модуле.

Лечится либо честным copy.deepcopy (дорого, но однозначно), либо тем, что функция не мутирует вход вовсе и собирает новые словари. Второе почти всегда лучше — и заодно снимает вопрос о глубине копии.

⚡ ценаСклейка строк: оптимизация, которой нетРасширение на месте работает, пока ссылка одна — вторая ссылка даёт 3× замедления
⚡ Оптимизация, которой нет, хотя замер её показывает

Все знают, что склейка строк в цикле — квадратичная. Но микробенчмарк часто говорит обратное, и вот почему.

У CPython есть оптимизация: если у строки ровно одна ссылка, интерпретатор при s += part расширяет буфер на месте вместо создания новой строки. Тогда цикл ведёт себя линейно и выглядит быстрым. Оптимизация держится ровно до тех пор, пока ссылка одна 🔧:

def cat(n):                    # счётчик строки равен единице
    s = ""
    for _ in range(n):
        s += "x"               # CPython расширяет буфер на месте
    return s

def cat_held(n):               # на строку смотрят двое
    s = ""
    prev = None
    for _ in range(n):
        prev = s               # вторая ссылка — и оптимизация выключается
        s += "x"               # теперь каждый шаг это новая строка
    return s
вариант20 000 конкатенаций × 20
одна ссылка на строку0.019 с
появилась вторая ссылка0.061 с

Один и тот же код, разница в три раза — и вызвана она безобидной строчкой prev = s, которую при рефакторинге добавит кто угодно. В настоящем коде вторая ссылка появляется от логирования, от передачи в функцию, от сохранения промежуточного значения. Оптимизация исчезает молча.

Вывод не «пиши join, потому что так принято». Вывод: производительность здесь зависит от числа ссылок на объект, то есть от свойства, которого не видно в строке кода. "".join(parts) не полагается на счётчик ссылок и потому предсказуем — а предсказуемость тут дороже трёх раз.

💻 терминалПроверь в REPLЧетыре фрагмента плюс три вопроса на вывод из механизма

Проверь

Открой REPL и прогони четыре фрагмента заново — теперь сверяя не с ответом, а со своим предсказанием из первого раздела. Разошлось только там, где модель была неверна; сходство ничего не доказывает, расхождение доказывает всё.

Затем добей три вопроса, ответы на которые из механизма выводятся, но в тексте не даны:

def f(x, cache={}):
    cache[x] = True
    return len(cache)
# Как одной строкой сбросить cache снаружи функции?

a = [[0] * 3 for _ in range(3)]
a[0][0] = 1
# Почему здесь нет эффекта фрагмента C?

t = ([1], 2)
t[0].append(9)
# Почему это работает без всякого TypeError?
исходникиdataclasses и copyГде стандартная библиотека ставит ограждение вокруг mutable default

Где это живёт в реальном коде

dataclasses — единственное место в стандартной библиотеке, где язык признал следствие 3 ошибкой и запретил её:

from dataclasses import dataclass
@dataclass
class C:
    items: list = []
# → ValueError: mutable default <class 'list'> for field items is not allowed:
# → use default_factory

Загляни в Lib/dataclasses.py и найди эту проверку: она смотрит, не является ли дефолт экземпляром list, dict или set. Заметь что именно там сделано — не исправлено поведение языка, а добавлено ограждение поверх него. Изменить сам механизм нельзя: на нём стоит весь существующий код. Это первая встреча с R9, и дальше такое будет попадаться постоянно.

Lib/copy.py — сорок строк, которые стоит прочитать целиком. Обрати внимание на словарь memo в deepcopy: он существует, потому что граф объектов может содержать циклы, а циклы — прямое следствие того, что ссылки свободно образуют произвольный граф.

L2Сколько это стоит в байтах16 байт заголовка, 28 байт на int, чтение счётчика ссылок через ctypes

Из L1 ты знаешь, что каждое значение — объект. Здесь — во что это обходится, с числами.

У любого объекта есть заголовок: счётчик ссылок и указатель на тип. На 64-битной сборке это 16 байт ещё до всякого содержимого. У объектов переменной длины добавляется поле размера — 24 байта.

import sys
print((sys.getsizeof(0), sys.getsizeof(1), sys.getsizeof(2**70)))
# → (28, 28, 36)
print(sys.getsizeof([]))
# → 56
print(sys.getsizeof([None] * 1_000_000))
# → 8000056

Целое — не машинное слово. Это число произвольной точности: массив 30-битных «цифр» в 32-битных словах плюс заголовок, отсюда 28 байт у единицы и 36 у 2**70, которому нужно уже три цифры 🔧.

Теперь сложи: list(range(1_000_000)) — это 8 МБ указателей плюс миллион объектов по 28 байт. Порядка 36 МБ там, где numpy.arange(1_000_000) займёт 8 МБ одним непрерывным блоком, а при dtype=int32 — 4 МБ. Разница не в скорости языка, а в количестве объектов.

Счётчик ссылок можно прочитать из самого Python — заголовок начинается по адресу объекта:

import ctypes
x = [1, 2, 3]
y = x
print(ctypes.c_ssize_t.from_address(id(x)).value)
# → 2

Два — x и y. Удали одно имя и посмотри снова; добавь третье — тоже. Заголовок объекта читается прямо из Python, без всякого C, и этот приём будет основным инструментом всего R3.

L3Где это в исходниках CPythonobject.h, longobject.c, listobject.c — по двадцать строк

Тег v3.11.15. Всё перечисленное — по паре десятков строк, читается за один присест.

  • Include/object.h — макросы PyObject_HEAD и PyObject_VAR_HEAD. Вот они, те самые 16 и 24 байта.
  • Objects/longobject.c — представление целого. Ищи _PyLong_New и работу с ob_digit.
  • Python/ceval.c — инструкции STORE_FAST и LOAD_FAST. Видно, что локальная переменная — индекс в массиве, а не ключ в словаре.
  • Objects/listobject.c, функция list_inplace_concat — реализация __iadd__ у списка, то есть буквально следствие 2.
связиКуда это ведётR3, L03, L08 · контраст с семантикой значений в C++

Связи

корень R1 · Всё — объект, имя — ссылка
→ дальше R3 · Подсчёт ссылок — время жизни объекта прямо следует из того, что имена владеют им сообща
→ дальше L03 · Контейнеры — список как массив указателей, во что обходится хранение
→ дальше L08 · Функции и декораторы — «функция это объект» из следствия 3 разворачивается там полностью
↔ контраст Семантика значений в C++ и Go: тот же вопрос, противоположный ответ, другая цена
чего здесь нет Хеширование и почему изменяемое не бывает ключом — в L05, вместе со словарём
источникиЧто почитатьData model 🟥 · Fluent Python 🟧 · copy 🟦

Что почитать