ЗАМЕР
bench/identity/contract.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/is-vs-eq
- Как запустить
python3.11 bench/identity/traps.py python3.12 bench/identity/traps.py python3.13 bench/identity/traps.py python3.14 bench/identity/traps.py python3.13 bench/identity/cost.py python3.14 bench/identity/cost.py
Запись прогона
Замеры для урока «is против ==»
| Скрипт | Что делает |
|---|---|
traps.py |
наблюдения без времени: кеш малых чисел, объединение констант, интернирование строк, настоящие одиночки, nan, переопределённый __eq__, переиспользование id, счётчики вечных объектов |
cost.py |
цена: is против == на объектах, строках и больших числах |
python3.11 bench/identity/traps.py
python3.12 bench/identity/traps.py
python3.13 bench/identity/traps.py
python3.14 bench/identity/traps.py
python3.13 bench/identity/cost.py
python3.14 bench/identity/cost.py
Практика урока
practice.py — источник ответов двух практических задач урока, а runs/practice.txt —
дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет
заявленное с этой записью (scripts/validate-practice.mjs) и не проходит,
если они разошлись.
Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.
Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.
Что здесь важно прочитать правильно
Числа в traps.py строятся во время выполнения, а не пишутся литералами.
1000 is 1000 в исходнике даёт True — но не из-за кеша чисел, а потому что
компилятор объединяет равные константы в пределах одной компиляции. Чтобы
мерить кеш, а не компилятор, числа собираются из строк, которые компилятор
посчитать заранее не может. Второй раздел скрипта показывает эту развилку
прямо: два литерала 1000 в РАЗНЫХ функциях одного файла — один объект, тот же
литерал из отдельной компиляции — другой.
cost.py сравнивает только строки внутри своего блока. Абсолютные
наносекунды включают две загрузки имени, одинаковые во всех строках; смысл
имеет отношение между строками, а не число само по себе.
Из cost.py не следует «пишите is вместо ==». Это разные вопросы. Там, где
нужен ==, is отвечает неверно — просто быстро.
Что получилось на 3.13.7:
Рост по длине (равные строки, собранные во время выполнения):
| длина строки | is, нс |
==, нс |
|---|---|---|
| 200 | 12,01 | 24,29 |
| 2 000 | 11,85 | 45,53 |
| 20 000 | 12,00 | 496,91 |
| 200 000 | 11,78 | 4701,22 |
Остальное:
| что сравнивается | ==, нс |
|---|---|
объект без своего __eq__ |
24,68 |
объект со своим __eq__ на Python |
101,83 — в 8,5 раза дороже is |
| строки по 200, равные | 24,62 |
| строки по 200, различающиеся с первого символа | 19,58 |
| равные большие числа | 23,42 |
Цена is во всех строках обеих таблиц — 11,8–12,1 нс.
Одно и то же сравнение — равные строки по 200 символов — стоит в обеих таблицах и даёт 24,62 и 24,29 нс. Это не расхождение: числа сравнимы только внутри своего блока, а между блоками разница в четверть процента и есть шум машины.
Число для равных строк по 200 000 символов от запуска к запуску держится в
пределах 4701–4775 нс, отношение к is — 397–402.
Наблюдения traps.py, одинаковые на 3.11, 3.12, 3.13 и 3.14.7:
- одним объектом возвращаются числа от −5 до 256;
- равные литералы в разных функциях ОДНОГО файла — один объект, из разных компиляций — разные;
- строка, собранная во время выполнения, не интернируется, даже если по виду
она идентификатор;
sys.internеё интернирует; - литерал вида идентификатора — один объект даже между разными компиляциями, а литерал с пробелом — нет;
tuple(),"",None,Trueдают один объект,frozenset()иlist()— нет;nan != nan, ноnan in [nan]даёт True: контейнеры сравнивают черезPyObject_RichCompareBool, а он сначала проверяет тождество;id([]) == id([])даёт True — первый список умирает до создания второго.
Одно наблюдение по версиям расходится, и в этом его смысл: sys.getrefcount
для None на 3.11 возвращает обычное число (порядка тысяч), с 3.12 —
служебную константу, и она разная на 3.12/3.13 (4294967295) и 3.14 (3221225472).
Что этими числами доказать НЕЛЬЗЯ
Что на совпадение объектов можно опираться в коде. Ровно наоборот: диапазон
кеша малых чисел, объединение констант и интернирование — детали реализации
CPython. Справочник языка гарантирует одиночность только для None, True и
False; всё остальное здесь снято наблюдением и может измениться.
Скрипт
161 строк"""Что `is` обещает языком и чем это оказывается в CPython.
ЗАЧЕМ ЭТОТ ФАЙЛ. В уроке `is` определялся как «сравнение адресов». Аудит
возразил: контракт другой — `x is y` истинно тогда и только тогда, когда `x` и
`y` один и тот же объект. Адрес тут ни при чём: он появляется только потому,
что ИМЕННО ТАК тождество реализовано в CPython, а `id()` документирован как
уникальное и постоянное В ТЕЧЕНИЕ ЖИЗНИ объекта число, а не как место в памяти.
Разница не философская, и блок 2 показывает почему: число, возвращённое `id()`,
переиспользуется. Объект уничтожен — и следующий может получить тот же `id`.
Если бы `id` был «адресом объекта» в том смысле, в каком его понимает читатель
(постоянный признак вещи), такого быть не могло бы.
ЧТО ЗДЕСЬ ЕСТЬ. Блоки 1 и 2 — про контракт и про его реализацию. Блоки 3–6 —
про то, где `is` уместен (одиночки и часовые) и где он обманывает (кеш малых
целых, интернирование, NaN в контейнерах). Всё печатается прогоном: числа и
ответы не назначены руками.
ЗАПУСК: python3.13 bench/identity/contract.py
Вывод по версиям: runs/contract-3.11.txt и соседние.
"""
import enum
import gc
import sys
def show(title: str) -> None:
print()
print(title)
print("-" * len(title))
def row(label: str, value: object) -> None:
print(f" {label:<46} {value}")
# ------------------------------------------------------------------ 1
def block1() -> None:
show("1. Контракт: тот же объект или нет")
a = ["x"]
b = ["x"]
c = a
row("a == b (равны по значению)", a == b)
row("a is b (один ли это объект)", a is b)
row("a is c (c — то же имя того же объекта)", a is c)
a.append("y")
row("после a.append('y'): c", c)
row("то есть c и a", "один объект, а не копия")
# ------------------------------------------------------------------ 2
def block2() -> None:
show("2. id уникален в течение ЖИЗНИ объекта, а не навсегда")
class Thing:
__slots__ = ("value",)
def __init__(self, value):
self.value = value
first = Thing(1)
first_id = id(first)
del first
gc.collect()
# Ищем переиспользование: создаём объекты того же типа и смотрим, не
# достанется ли кому-нибудь то же число. Цикл ограничен — если не
# повторилось, так и печатаем: утверждение «id переиспользуется» проверяемо
# и не должно превращаться в «обычно бывает».
reused_at = None
keep = []
for i in range(1000):
candidate = Thing(2)
if id(candidate) == first_id:
reused_at = i
break
keep.append(candidate)
row("id первого объекта", first_id)
row("тот же id достался другому объекту", "нет" if reused_at is None else f"да, попытка {reused_at}")
row("вывод", "id уникален среди ОДНОВРЕМЕННО живущих объектов")
# ------------------------------------------------------------------ 3
def block3() -> None:
show("3. Где `is` уместен: одиночки и часовые")
class Color(enum.Enum):
RED = 1
BLUE = 2
MISSING = object()
def find(mapping, key, default=MISSING):
got = mapping.get(key, MISSING)
return "ключа нет" if got is MISSING else got
row("None is None", None is None)
row("NotImplemented is NotImplemented", NotImplemented is NotImplemented)
row("Ellipsis is ...", Ellipsis is ...)
row("Color.RED is Color(1)", Color.RED is Color(1))
row("часовой различает отсутствие и None", find({"k": None}, "k"))
row("и настоящее отсутствие", find({}, "k"))
row("почему не == None", "== зовёт __eq__ чужого объекта; is не зовёт ничего")
# ------------------------------------------------------------------ 4
def block4() -> None:
show("4. Кеш малых целых: деталь реализации, а не правило")
a, b = 256, 256
c, d = 257, 257 # оба из одной строки: их складывает компилятор
e = int("257")
f = int("257")
row("256 is 256 (из констант)", a is b)
row("257 is 257 (из констант одной строки)", c is d)
row("int('257') is int('257')", e is f)
row("границы кеша в этой сборке", f"[{-5}, {256}]")
# ------------------------------------------------------------------ 5
def block5() -> None:
show("5. Интернирование строк: тоже про сборку, а не про язык")
x = "deep"
y = "deep"
built = "de" + "ep" # сложит компилятор
runtime = "".join(["d", "e", "e", "p"])
row("два одинаковых литерала", x is y)
row("литерал и склейка констант", x is built)
row("литерал и строка, собранная в рантайме", x is runtime)
row("после sys.intern", x is sys.intern("".join(["d", "e", "e", "p"])))
weird = "не идентификатор!"
weird2 = "не идентификатор!"
row("строка с пробелами и знаками", weird is weird2)
# ------------------------------------------------------------------ 6
def block6() -> None:
show("6. NaN: где тождество опережает равенство")
nan = float("nan")
row("nan == nan", nan == nan)
row("nan is nan", nan is nan)
row("[nan] == [nan] (один и тот же объект)", [nan] == [nan])
row("nan in [nan]", nan in [nan])
row("[float('nan')] == [float('nan')]", [float("nan")] == [float("nan")])
row("почему", "контейнер сначала спрашивает про тождество, потом про равенство")
def main() -> None:
print(f"Python {sys.version.split()[0]} ({sys.implementation.name})")
block1()
block2()
block3()
block4()
block5()
block6()
if __name__ == "__main__":
main()