Deep Engineering

MEASUREMENT

bench/identity/contract.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

161 lines
"""Что `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()