MEASUREMENT
bench/identity/traps.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/python/is-vs-eq
- How to run it
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
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для урока «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; всё остальное здесь снято наблюдением и может измениться.
Script
211 lines"""
Наблюдения для урока «is против ==».
ЗАПУСК (числа урока сняты на 3.13.7):
for v in 3.11 3.12 3.13 3.14; do python$v bench/identity/traps.py; done
ЗДЕСЬ НЕТ ЗАМЕРОВ ВРЕМЕНИ. Скрипт печатает ответы на вопрос «один это объект
или два», то есть `is`. Такие ответы от машины не зависят. Время меряется
отдельно, в `cost.py`.
ПОЧЕМУ ВЕЗДЕ int(str(v)), А НЕ ПРОСТО ЛИТЕРАЛ. Литерал в исходнике —
константа, и компилятор объединяет равные константы в пределах одного файла;
`1000 is 1000` в исходнике даст True не из-за кеша чисел, а из-за этого
объединения. Чтобы мерить кеш, а не компилятор, числа строятся во время
выполнения — из строки, которую компилятор посчитать заранее не может.
ЧЕГО ЭТОТ СКРИПТ НЕ ДОКАЗЫВАЕТ. Что на эти ответы можно опираться в коде.
Ровно наоборот: половина разделов существует, чтобы показать, что `is` даёт
True по причинам, к написанному коду отношения не имеющим, и меняются эти
причины между версиями и между способами запуска.
"""
import sys
def header(title: str) -> None:
print(f"\n--- {title} ---")
def observe_small_ints() -> None:
"""Диапазон предсозданных чисел — единственная часть, на которую можно ссылаться."""
header("1. Кеш малых чисел")
def built_at_runtime(value: int) -> bool:
"""Число строится из строки: компилятор посчитать его заранее не может."""
return int(str(value)) is int(str(value))
low = None
high = None
for value in range(-1000, 1500):
if built_at_runtime(value):
if low is None:
low = value
high = value
print(f"одним объектом возвращаются числа от {low} до {high}")
print(f"int('256') is int('256'): {int('256') is int('256')}")
print(f"int('257') is int('257'): {int('257') is int('257')}")
def observe_constants() -> None:
"""Почему `1000 is 1000` в исходнике даёт True — и почему это ничего не значит."""
header("2. Одинаковые литералы в одном файле")
def first() -> int:
return 1000
def second() -> int:
return 1000
print("две РАЗНЫЕ функции одного файла, литерал 1000:", first() is second())
print("то же с длинной строкой: ",
(lambda: "hello world")() is (lambda: "hello world")())
# Отдельная компиляция — то же, что отдельный файл: свой пул констант.
other: dict = {}
exec(compile("value = 1000\ntext = 'hello world'\n", "<другой файл>", "exec"), other)
print("тот же литерал из ДРУГОЙ компиляции: ", other["value"] is first())
print("та же строка из ДРУГОЙ компиляции: ",
other["text"] is (lambda: "hello world")())
print()
print("Компилятор объединяет равные константы в пределах одной компиляции —")
print("даже если они лежат в разных функциях. Из другого файла тот же")
print("литерал даёт другой объект. То есть True в первых двух строках —")
print("свойство сборки исходника, а не свойство чисел и строк.")
def observe_interning() -> None:
"""Строки: что интернируется само, а что нет."""
header("3. Интернирование строк")
# Литералы вынесены в переменные намеренно: `x is "литерал"` даёт
# SyntaxWarning с 3.8, и предупреждение это правильное — сравнивать с
# литералом через is не следует нигде, включая замеры.
ident = "hello_world"
spaced = "hello world"
runtime = "".join(["hello", "_", "world"])
print("строка вида идентификатора, собранная в рантайме:")
print(" собранная is литерал: ", runtime is ident)
print(" sys.intern(собранная) is литерал:", sys.intern(runtime) is ident)
with_space = "".join(["hello", " ", "world"])
print("строка с пробелом, собранная в рантайме:")
print(" собранная is литерал: ", with_space is spaced)
# Литерал вида идентификатора — один объект на всю программу, включая
# ДРУГИЕ компиляции. Проверяется тем же приёмом, что и объединение
# констант в разделе 2: exec отдельного исходника — это отдельный пул
# констант, и совпадение объектов там может дать только интернирование.
other: dict = {}
exec(compile("ident = 'hello_world'\nspaced = 'hello world'\n", "<другой файл>", "exec"), other)
print("из ДРУГОЙ компиляции:")
print(" литерал вида идентификатора — тот же объект:", other["ident"] is ident)
print(" литерал с пробелом — тот же объект: ", other["spaced"] is spaced)
print()
print("Правило: сам собой интернируется литерал, похожий на идентификатор.")
print("Собранная во время выполнения строка не интернируется никогда —")
print("даже если по виду она идентификатор. Это делает sys.intern.")
def observe_singletons() -> None:
"""Что действительно один объект на всю программу."""
header("4. Настоящие одиночки")
empty_str = ""
rows = [
("None", None is None),
("True", True is bool(1)),
("пустой кортеж", tuple() is tuple()),
("пустая строка", empty_str is "".join([])),
("пустой frozenset", frozenset() is frozenset()),
("пустой список", list() is list()),
]
for name, same in rows:
print(f"{name:<20} {same}")
print()
print("Опираться в коде можно на None, True и False — они гарантированы")
print("справочником языка. Остальное — детали реализации CPython.")
def observe_nan() -> None:
"""Единственное значение, для которого == и is расходятся В ОБЕ стороны."""
header("5. nan")
nan = float("nan")
print("nan == nan:", nan == nan)
print("nan is nan:", nan is nan)
print("nan in [nan]:", nan in [nan])
print("[nan] == [nan]:", [nan] == [nan])
print("другой nan в множестве:", float("nan") in {nan})
print()
print("Контейнеры сравнивают элементы через PyObject_RichCompareBool, а он")
print("сначала проверяет тождество и только потом равенство. Поэтому nan,")
print("не равный сам себе, всё-таки НАХОДИТСЯ в списке — по is, не по ==.")
def observe_eq_override() -> None:
"""Почему `is None`, а не `== None`."""
header("6. == можно переопределить, is — нельзя")
class AlwaysEqual:
def __eq__(self, other: object) -> bool:
return True
__hash__ = None # type: ignore[assignment]
obj = AlwaysEqual()
nothing = None
print("obj == None:", obj == nothing)
print("obj is None:", obj is None)
class WeirdEqual:
def __eq__(self, other: object):
return "не булево значение"
print("bool(WeirdEqual() == 1):", bool(WeirdEqual() == 1))
print()
print("__eq__ может вернуть что угодно, и `if a == b` приведёт это к bool.")
print("`is` — сравнение адресов, перехватить его нечем.")
def observe_id_reuse() -> None:
"""id уникален только среди ОДНОВРЕМЕННО живущих объектов."""
header("7. id переиспользуется")
print("id([]) == id([]):", id([]) == id([]))
print()
print("Первый список умирает до того, как создаётся второй, и адрес")
print("достаётся второму. Сравнение id объектов, не живущих одновременно,")
print("не значит ничего — а выглядит как сравнение объектов.")
def observe_refcounts() -> None:
"""Числа, в которые нельзя вчитываться."""
header("8. Счётчики ссылок вечных объектов")
print(f"sys.getrefcount(None) = {sys.getrefcount(None)}")
print(f"sys.getrefcount(1) = {sys.getrefcount(1)}")
print()
print("С 3.12 (PEP 683) None, True, False и малые числа не считают ссылок")
print("вовсе, и getrefcount возвращает служебную константу. Её ЗНАЧЕНИЕ")
print("между версиями разное — сравните вывод на 3.12, 3.13 и 3.14.")
def main() -> None:
print(f"Python {sys.version.split()[0]}")
observe_small_ints()
observe_constants()
observe_interning()
observe_singletons()
observe_nan()
observe_eq_override()
observe_id_reuse()
observe_refcounts()
if __name__ == "__main__":
main()