ЗАМЕР
bench/identity/cost.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; всё остальное здесь снято наблюдением и может измениться.
Скрипт
147 строк"""
Цена `is` против цены `==` для урока «is против ==».
ЗАПУСК (числа урока сняты на 3.13.7):
python3.13 bench/identity/cost.py
python3.14 bench/identity/cost.py
ЧТО МОЖНО СРАВНИВАТЬ. Только строки одного запуска между собой. Абсолютные
наносекунды с другой машины несравнимы, между версиями — тоже (общее правило
замеров про тулчейн).
ПОЧЕМУ ЗАМЕР БЕЗ ЦИКЛА. Первая версия ставила сравнение внутрь `for`, и
разница утонула: на каждое сравнение приходились ещё итерация и проверка
границы, то есть мерился в основном цикл. Здесь тело — двести одинаковых
сравнений подряд, отдельными строками; накладной расход на них — это две
загрузки имени, одинаковые во всех строках таблицы.
ЧТО ЭТИ ЧИСЛА ЗНАЧАТ. Ровно одно: `is` не зависит ни от типа, ни от размера
объекта, потому что это сравнение адресов, а `==` зависит и от того, и от
другого. Вывода «пишите is вместо ==» отсюда НЕ следует: это разные вопросы, и
там, где нужен `==`, `is` даёт неверный ответ быстро.
"""
import sys
import timeit
REPEATS_IN_BODY = 200
def header(title: str) -> None:
print(f"\n--- {title} ---")
def ns(stmt: str, env: dict, number: int) -> float:
"""Наносекунды на одно сравнение: минимум из семи повторов."""
body = (stmt + "\n") * REPEATS_IN_BODY
best = min(timeit.repeat(body, globals=env, number=number, repeat=7))
return best / (number * REPEATS_IN_BODY) * 1e9
class Plain:
"""Без своего __eq__: сравнение падает обратно на тождество."""
def __init__(self, value: int) -> None:
self.value = value
class WithEq(Plain):
"""Со своим __eq__ на Python."""
def __eq__(self, other: object) -> bool:
return isinstance(other, Plain) and self.value == other.value
__hash__ = None # type: ignore[assignment]
def build_env() -> dict:
# Строки собираются во время выполнения: литералы компилятор объединил бы
# в один объект, и сравнение по is стало бы сравнением объекта с собой.
short_a = "".join(["a"] * 200)
short_b = "".join(["a"] * 200)
assert short_a is not short_b and short_a == short_b
long_a = "".join(["a"] * 200_000)
long_b = "".join(["a"] * 200_000)
assert long_a is not long_b and long_a == long_b
early = "b" + "a" * 199
big_a = int("1" + "0" * 40)
big_b = int("1" + "0" * 40)
assert big_a is not big_b
return {
"p1": Plain(1),
"p2": Plain(1),
"e1": WithEq(1),
"e2": WithEq(1),
"short_a": short_a,
"short_b": short_b,
"long_a": long_a,
"long_b": long_b,
"early": early,
"big_a": big_a,
"big_b": big_b,
}
def bench() -> None:
env = build_env()
header("1. is не зависит от того, что сравнивается")
rows = [
("объект без __eq__", "p1 is p2", 20000),
("строка 200 символов", "short_a is short_b", 20000),
("строка 200 000 символов", "long_a is long_b", 20000),
("большое число", "big_a is big_b", 20000),
]
for name, stmt, number in rows:
print(f"{name:<26} {ns(stmt, env, number):>8.2f} нс")
header("2. == зависит и от типа, и от размера")
rows = [
("объект без __eq__", "p1 == p2", 20000),
("объект со своим __eq__", "e1 == e2", 20000),
("строки 200, равные", "short_a == short_b", 20000),
("строки 200, разные с 1-го символа", "short_a == early", 20000),
("строки 200 000, равные", "long_a == long_b", 2000),
("большие числа, равные", "big_a == big_b", 20000),
]
for name, stmt, number in rows:
print(f"{name:<36} {ns(stmt, env, number):>9.2f} нс")
header("3. Как цена растёт по длине")
print(f"{'длина строки':>14} {'is, нс':>10} {'==, нс':>12}")
for size in (200, 2_000, 20_000, 200_000):
left = "".join(["a"] * size)
right = "".join(["a"] * size)
assert left is not right and left == right
pair = {"left": left, "right": right}
number = 20000 if size <= 20_000 else 2000
print(
f"{size:>14} "
f"{ns('left is right', pair, 20000):>10.2f} "
f"{ns('left == right', pair, number):>12.2f}"
)
print("\nis не меняется вовсе: это сравнение двух адресов.")
print("== обязан дочитать до первого различия, а у равных строк его нет.")
header("4. Во сколько раз")
identity = ns("long_a is long_b", env, 20000)
equality = ns("long_a == long_b", env, 2000)
print(f"строка 200 000: == дороже is в {equality / identity:.0f} раз")
py_eq = ns("e1 == e2", env, 20000)
base = ns("p1 is p2", env, 20000)
print(f"свой __eq__ на Python: дороже is в {py_eq / base:.1f} раза")
def main() -> None:
print(f"Python {sys.version.split()[0]}")
bench()
if __name__ == "__main__":
main()