Deep Engineering

ЗАМЕР

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