Одно определение, из которого выводится половина всего, что в Python называют подводными камнями. Если усвоить его до конца, запоминать эти камни больше не придётся.
Четыре фрагмента. Прежде чем открывать ответ — сформулируй, что выведет каждый, и зафиксируй формулировку: не «что-то странное», а конкретный вывод и причина. Место, где предсказание разойдётся с реальностью, — самое ценное на этой странице.
def add(item, target=[]):
target.append(item)
return target
print(add(1))
print(add(2))
[1]
[1, 2]Второй вызов дописывает в тот же список. Не в новый.a = [1, 2]
b = a
b += [3]
print(a)
x = 1
y = x
y += 1
print(x)
[1, 2, 3]
1Один и тот же оператор +=, два разных исхода.grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]Изменили одну ячейку — поменялись три.t = ([1], 2)
t[0] += [9]
print(t)
TypeError: 'tuple' object does not support item assignment
…и при этом t теперь равен ([1, 9], 2). Исключение выброшено, но мутация произошла.
Начнём с определения, а не с примеров. В Python оно одно и звучит так:
Значение — всегда объект в куче. У объекта есть тип, содержимое и адрес.
Имя — не контейнер, а привязка. Запись в неймспейсе, указывающая на объект.
Присваивание не копирует объект. Оно перенаправляет имя.
Всё остальное на этой странице — следствия. Проверим это буквально: каждый фрагмент выше получится из определения за два-три шага.
b = a не создаёт список. Оно создаёт вторую привязку к тому же объекту. Значит любое изменение объекта видно через оба имени — это не побочный эффект, а буквально то, что записано в определении.
Фрагмент C — то же самое, только имён не два, а три, и они безымянные. Оператор * на списке повторяет элементы, а элемент здесь — ссылка на внутренний список. Внешний список получает три ссылки на один объект.
+= — это не одна операцияФрагмент B выглядит как противоречие: один оператор, разное поведение. Противоречия нет, потому что += не элементарен. Он спрашивает у объекта, умеет ли тот меняться на месте:
__iadd__ — вызывается он, объект мутирует, имя остаётся привязанным к нему же;a + b, создаётся новый объект, и имя перепривязывается к нему.У списка __iadd__ есть. У int его нет и быть не может: целые неизменяемы. Отсюда y += 1 оставляет x нетронутым — не потому, что «числа копируются», а потому, что y просто указало на другой объект.
Фрагмент D — этот же механизм, доведённый до абсурда, и он проясняет всё разом. t[0] += [9] разворачивается в две операции: сначала t[0].__iadd__([9]) — список мутирует успешно; затем t[0] = результат — и вот тут кортеж отказывается. Мутация уже случилась, присваивание провалилось. Одна строка, два действия, и понятны они только через определение.
def — исполняемая инструкцияФункция — такой же объект, как список. Строка def не является объявлением: она исполняется, создаёт объект функции и привязывает к нему имя. Значит выражения в списке параметров вычисляются в этот момент — один раз, при создании функции, а не при каждом вызове.
Вычисленные значения складываются в атрибут объекта функции. Их можно посмотреть руками:
print(add.__defaults__)
# → ([1, 2],)
Фрагмент A перестаёт быть ловушкой. Список создан один раз и лежит в add.__defaults__; каждый вызов без аргумента получает его. Это ровно то, что должно происходить, если def исполняется, а имя — привязка.
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 внутри массива.
«Неймспейс — это словарь» верно не везде. Локальные переменные функции живут не в словаре, а в массиве слотов фрейма; компилятор знает их по индексам и генерирует LOAD_FAST. locals() внутри функции собирает снимок, а не отдаёт настоящее хранилище — и запись в этот снимок ничего не меняет 🔧. Часть «имя → объект» остаётся верной; словарём является глобальный и модульный неймспейс, а также __dict__ обычного объекта.
«Неизменяемый» — про сам объект, а не про достижимое из него. Кортеж гарантирует, что его ячейки не поменяются, и ничего не обещает про объекты в этих ячейках. Отсюда и фрагмент D, и то, что кортеж со списком внутри нехешируем.
Идентичность объекта — не адрес. id() в CPython возвращает адрес 🔧, но язык обещает только уникальность на время жизни объекта. Адреса переиспользуются: id умершего объекта может достаться новому.
За всем этим стоит одно решение: единая объектная модель без примитивов. В Python нет двух категорий значений — «простых» и «настоящих объектов». Число, функция, класс, модуль и сам тип — объекты одного сорта, с одинаковыми правилами.
Решение унаследовано от ABC, языка, над которым Гвидо работал до Python, и усилено сознательно: один набор правил вместо двух. Цена известна и заплачена — заголовок у каждого числа, косвенность на каждом обращении, невозможность положить массив чисел в кэш процессора плотно. Выгода тоже известна: не существует вопроса «а это значение или объект», не существует автобоксинга, не существует двух видов равенства, и любую сущность можно положить в список, передать в функцию и приписать ей атрибут.
Если ты работал с numpy, интуиция уже есть — просто под другим именем. b = a для массива и b = a[:] как view: два имени на одни данные, запись через любое видна через оба. Разница только в том, что numpy заставил тебя выучить это явно (.copy() против view), а Python предполагает, что ты понял это из определения языка.
Точнее всего — std::shared_ptr без арифметики указателей: имя владеет объектом, копия имени добавляет владельца, объект жив пока есть хоть один. Эта аналогия ещё пригодится в R3: она объясняет не только присваивание, но и момент разрушения.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
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] — разные операции. Первая меняет объект и видна через все остальные имена, вторая создаёт новый и не видна нигде. Для чисел они неразличимы, для списков — нет, и на этом регулярно ловятся.
Потому что чинить пришлось бы не ловушку, а само правило — и оно правильное.
Дефолт вычисляется один раз при выполнении 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, и таблицу диспетчеризации.
def исполняется один раз), разное поведение += (протокол __iadd__, а не оператор), утроенная строка в [[0]*3]*3 (повторяются ссылки), одновременные мутация и TypeError в кортеже (две операции в одной строке) и разница is и == (идентичность против равенства). Ни одно из этого не требует запоминания — всё пересчитывается из определения за пару шагов. Цена решения — заголовок у каждого числа, и именно поэтому существует numpy.
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 (дорого, но однозначно), либо тем, что функция не мутирует вход вовсе и собирает новые словари. Второе почти всегда лучше — и заодно снимает вопрос о глубине копии.
Все знают, что склейка строк в цикле — квадратичная. Но микробенчмарк часто говорит обратное, и вот почему.
У 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 и прогони четыре фрагмента заново — теперь сверяя не с ответом, а со своим предсказанием из первого раздела. Разошлось только там, где модель была неверна; сходство ничего не доказывает, расхождение доказывает всё.
Затем добей три вопроса, ответы на которые из механизма выводятся, но в тексте не даны:
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 — единственное место в стандартной библиотеке, где язык признал следствие 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: он существует, потому что граф объектов может содержать циклы, а циклы — прямое следствие того, что ссылки свободно образуют произвольный граф.
Из 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.
Тег 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.copy — хватит разбора выше; в документацию идти, только если понадобится __deepcopy__.