Deep Engineering

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()