Введение

Важно: Эксперимент нужно проводить именно в стандартной интерактивной оболочке Python (запускается командой python в терминале). Если вы пишете этот код в Jupyter Notebook, Google Colab или PyCharm, результат может отличаться, так как эти среды воспринимают ячейку кода как единый блок (об этом ниже).

Примеры в статье актуальны для CPython версий 3.8 — 3.11. В других реализациях (PyPy) или будущих версиях поведение оптимизатора может измениться.

Python часто называют языком с низким порогом входа: написал код — и всё работает. Мы привыкли, что интерпретатор берет на себя всю грязную работу: управление памятью, сборку мусора и аллокацию объектов. Но иногда эта «магия» под капотом выдает результаты, которые ставят в тупик даже тех, кто пишет на Python уже не первый месяц.

Давайте проведем простой эксперимент. Откройте консоль интерпретатора (REPL) и введите следующий код:

>>> a = 256
>>> b = 256
>>> a is b
True

Пока всё логично. Переменные ссылаются на одно и то же число, поэтому оператор is (который проверяет идентичность объектов, а не просто равенство значений) возвращает True.

А теперь увеличим ставки всего на единицу:

>>> a = 257
>>> b = 257
>>> a is b
False

Стоп, что? Почему для числа 256 Python использует один и тот же объект в памяти, а для 257 создает два разных? Неужели математика сломалась?

Спойлер: с математикой всё в порядке. Мы столкнулись с механизмом оптимизации CPython, который называется интернирование (или кеширование) малых целых чисел. Сегодня разберемся, как именно это работает, почему выбраны именно такие границы и почему использование is для сравнения чисел — это лотерея, в которую лучше не играть.

Разбор полетов: что на самом деле сравнивает is?

Прежде чем лезть в исходный код CPython, давайте разберемся с базой. Чтобы понять магию чисел, нужно вспомнить фундаментальное правило Python: переменная — это не коробка, в которой лежит значение. Это стикер (ссылка), который мы лепим на объект в памяти.

Когда вы пишете a = 257, происходит следующее:

  1. Python создает в памяти объект типа int со значением 257.

  2. Переменная a становится ссылкой на этот объект.

Если следом написать b = 257, Python (по умолчанию) честно создаст второй объект в другом участке памяти и повесит на него ярлык b. У нас получается два разных объекта с одинаковым содержимым.

В игру вступает id()

У каждого объекта в Python есть уникальный идентификатор. В стандартной реализации (CPython) этот идентификатор — прямой адрес объекта в оперативной памяти. Узнать его можно с помощью встроенной функции id().

Давайте проверим наши переменные из введения:

>>> a = 257
>>> b = 257

>>> id(a)
140702123456784  # Адрес первого объекта
>>> id(b)
140702123456816  # Адрес второго объекта. Он другой!

is против ==

Вот тут многие новички и попадают в ловушку.

  • Оператор == сравнивает значения. Ему важно содержимое: «Равны ли числа внутри этих объектов?». В нашем случае 257 = 257, поэтому a == b вернет True.

  • Оператор is сравнивает адреса памяти (identity). Он проверяет, является ли a и b ссылками на один и тот же объект физически. Грубо говоря, a is b — это просто более красивый способ написать id(a) == id(b).

Возвращаясь к нашему примеру с числом 256:

>>> a = 256
>>> b = 256
>>> id(a)
140702123456752
>>> id(b)
140702123456752  # Адреса совпали!

Здесь is возвращает True, потому что ссылки a и b указывают на один и тот же кусок памяти. Но почему Python решил сэкономить память на числе 256, но "пожадничал" для 257?

Секрет числа 256: Small Integer Caching

Разгадка кроется в том, как CPython (стандартная реализация языка, на которой мы все сидим) подходит к оптимизации. Создание нового объекта — это всегда расходы: нужно выделить память, инициализировать счетчик ссылок, настроить тип. Если делать это для каждого числа в цикле, производительность просядет.

Разработчики Python проанализировали, какие числа используются в коде чаще всего. Это индексы массивов, счетчики циклов, коды возврата (-1), булевы значения (0 и 1).

Диапазон «Бессмертных»

В исходном коде CPython (файл Objects/longobject.c) есть механизм, называемый Small Integer Caching.

При запуске интерпретатора Python заранее создает массив объектов для целых чисел в диапазоне от -5 до 256 (включительно). Эти числа загружаются в память еще до того, как вы напишете первую строчку кода, и остаются там до завершения процесса.

  • Когда вы пишете a = 5, Python не создает новый объект. Он просто отдает вам ссылку на уже существующий, «закешированный» объект 5.

  • Когда вы пишете a = 256, вы получаете ссылку на последний элемент этого кеша.

  • Но как только вы запрашиваете a = 257, вы выходите за пределы кешированного массива. Python вынужден честно выделить новую память под этот объект.

Почему именно от -5 до 256?

  • Нижняя граница (-5): Отрицательные числа используются редко, но часто нужны для индексации с конца списка (например, list[-1]). Диапазон до -5 покрывает самые частые случаи.

  • Верхняя граница (256): Это число не случайное. 256 — это 2^8. Этот диапазон покрывает все возможные значения байта (0–255), а также подавляющее большинство счетчиков в циклах и длин массивов.

Расширять этот диапазон дальше посчитали нецелесообразным: это увеличило бы потребление памяти на старте программы ради чисел, которые используются не так часто.

Проверяем границы

Убедиться в этом легко. Давайте «пощупаем» границы этого массива:

# Нижняя граница
>>> a = -5
>>> b = -5
>>> a is b
True  # Входит в кеш

>>> a = -6
>>> b = -6
>>> a is b
False # Уже создаются новые объекты

# Верхняя граница
>>> a = 256
>>> b = 256
>>> a is b
True  # Всё ещё кеш

>>> a = 257
>>> b = 257
>>> a is b
False # Новый объект

Итог: Числа от -5 до 256 в CPython работают по принципу паттерна Flyweight (Приспособленец): вместо создания тысяч одинаковых объектов мы используем ссылки на один общий. Хотя в обиходе их часто называют «синглтонами», технически это кешированный пул объектов. В памяти программы может существовать только один экземпляр числа 42. Сколько бы переменных вы ни создали со значением 42, все они будут указывать на один и тот же адрес.

Еще немного магии: Строки (String Interning)

Если с числами всё жестко регламентировано (диапазон от -5 до 256), то со строками ситуация более гибкая. Здесь работает механизм, называемый String Interning (интернирование строк).

Суть та же: Python пытается хранить в памяти только одну копию уникальной неизменяемой строки. Это экономит память, но главное — ускоряет работу словарей (dict), на которых в Python ��ержится вообще всё (пространства имен, атрибуты классов и т.д.). Сравнить два адреса памяти (is) гораздо быстрее, чем побайтово сверять две строки.

Попробуем повторить трюк в консоли:

>>> a = "habr"
>>> b = "habr"
>>> a is b
True

Работает! Python увидел два одинаковых литерала и решил: «Зачем мне плодить сущности? Пусть ссылаются на одно место».

А теперь попробуем что-то посложнее:

>>> a = "habr!" * 100
>>> b = "habr!" * 100
>>> a is b
False

Магия исчезла. Строки одинаковые (== вернет True), но объекты разные.

Как это работает? (Правила фейс-контроля)

Интернирование строк в Python не тотальное, иначе процесс создания любой строки занимал бы слишком много времени на поиск дубликатов. Есть негласные правила, кого пускать в кеш автоматически:

  1. Похоже на имя переменной:
    Обычно автоматически интернируются короткие строки, состоящие только из ASCII-букв, цифр и нижнего подчеркивания. Это сделано для оптимизации доступа к атрибутам и именам переменных (они ведь тоже хранятся как строки).
    Поэтому "hello_world" скорее всего будет закеширована, а "hello world!" (с пробелом и восклицательным знаком) — уже нет.

  2. Compile-time vs Run-time:
    Строковые литералы, которые Python видит прямо в коде (как "habr" в примере выше), интернируются при компиляции байт-кода.
    Но если строка создается динамически в момент выполнения программы, Python обычно ленится её кешировать.

    Сравните:

    >>> a = "hello" + "world" # Конкатенация констант (оптимизируется при компиляции)
    >>> b = "helloworld"
    >>> a is b
    True
    
    >>> s1 = "hello"
    >>> s2 = "world"
    >>> a = s1 + s2  # Сложение переменных (выполняется в Run-time)
    >>> b = "helloworld"
    >>> a is b
    False
    

Можно ли управлять этим вручную?

Осторожно: Используйте sys.intern() только для строк, которые часто повторяются и их конечное количество (например, названия тегов в XML, ключи в JSON). Если вы будете интернировать уникальные строки (например, имена пользователей), вы засорите память мусором, который Python не сможет очистить до перезапуска программы.

Да. Если вы пишете приложение, которое обрабатывает миллионы повторяющихся текстовых тегов (например, парсите XML или CSV), вы можете принудительно заставить Python интернировать строку через модуль sys.

import sys

s1 = sys.intern("строка с пробелами и чем угодно")
s2 = sys.intern("строка с пробелами и чем угодно")

print(s1 is s2) # True

Используя sys.intern(), вы гарантируете, что получите ссылку на уже существующую строку, если она есть в таблице интернирования. Это мощный инструмент оптимизации памяти, но пользоваться им стоит осознанно.

Ловушка оптимизатора кода (Code Blocks)

Сейчас кто-то из читателей наверняка скопировал код a = 257; b = 257 в файл main.py, запустил его и готов писать гневный комментарий: «Автор, ты всё врешь! У меня вывело True!».

И этот читатель будет прав. Но и я вас не обманывал. Добро пожаловать в удивительный мир областей видимости и компиляции блоков кода.

REPL vs. Файл

То, о чем мы говорили выше (кеширование чисел до 256), работает глобально на уровне всего интерпретатора. Но есть еще один уровень оптимизации — уровень компилятора.

  1. Интерактивная консоль (REPL): Работает в режиме «прочитал строку — выполнил — забыл».
    Когда вы вводите a = 257 и нажимаете Enter, Python компилирует и выполняет эту команду как отдельный блок.
    Когда следом вы вводите b = 257, это уже новый блок. Компилятор не помнит, что было строкой выше, и честно создает новый объект.

  2. Скрипт (.py) или функция:
    Когда Python запускает файл, он воспринимает его (или тело функции) как единый блок кода.
    Компилятор парсит весь блок целиком, находит в нем константы и складывает их в специальную таблицу (co_consts).
    Он видит: «Ага, в этом коде дважды встречается число 257. Зачем мне создавать два объекта? Я создам один и буду ссылаться на него в обоих случаях».

Трюк с точкой с запятой

Эту оптимизацию можно увидеть даже в консоли, если записать команды в одну строку:

>>> a = 257; b = 257; a is b
True

Почему? Потому что теперь это одна строка, а значит — один блок компиляции. Python успел сообразить, что может переиспользовать объект 257 для обеих переменных.

Важное различие

Не путайте эти два механизма:

  • Small Integer Caching (-5..256): Это Run-time оптимизация. Она работает всегда и везде, гарантируя, что id(256) будет одинаковым хоть в консоли, хоть в скрипте, хоть на Марсе.

  • Оптимизация констант: Это Compile-time оптимизация. Она работает в пределах одного блока кода (функции, модуля). Если вы создадите a = 257 в одной функции, а b = 257 в другой — это будут разные объекты (потому что разные блоки кода), и is вернет False.

Именно поэтому полагаться на такое поведение нельзя. В разных реализациях Python (Pypy, IronPython) или даже разных версиях CPython оптимизатор может вести себя иначе. Единственная константа в этом хаосе — гарантированный кеш от -5 до 256.

Заключение

Понимание этих механизмов отличает инженера от простого пользователя языка. Знание того, как Python работает с памятью, помогает не только проходить каверзные собеседования, но и отлаживать странные баги, когда логика кажется верной, а результат — нет.

Однако, главный урок сегодняшнего разбора — не в том, как сэкономить 20 байт оперативной памяти. Главный урок — в гигиене кода.

Оптимизации CPython — это детали реализации, которые могут измениться в следующей версии (или отсутствовать в альтернативных реализациях, вроде PyPy). Опираться на них в бизнес-логике — плохая идея.

Золотое правило Python:

  • Используйте is только для сравнения с синглтонами (None, True, False) или когда вам действительно важно проверить идентичность объектов.

  • Для сравнения чисел, строк и других значений всегда используйте ==.

Пусть магия Python работает на вас, а не против вас.

Мини-квиз: 5 задач на закрепление (проверь себя)

Попробуйте предсказать результат выполнения кода в стандартной консоли CPython (REPL), не запуская его. Ответы спрятаны в описании.

Задача 1: Граница дозволенного

a = -6
b = -6
print(a is b)

Ответ: False.
Почему: Кеширование малых целых чисел работает в диапазоне от -5 до 256. Число -6 в этот диапазон уже не попадает, поэтому создаются два разных объекта.


Задача 2: Арифметика и кеш

a = 250 + 6
b = 256
print(a is b)

Ответ: True.
Почему: Результат вычисления 256 попадает в диапазон кеширования. Python не создает новый объект для результата сложения, а возвращает ссылку на уже существующий в кеше "синглтон" числа 256.


Задача 3: Строковая магия

a = "hello_world"
b = "hello_world"
print(a is b)

Ответ: True (скорее всего).
Почему: Строковые литералы, состоящие только из букв, цифр и подчеркиваний, обычно интернируются автоматически при компиляции. Это не гарантировано спецификацией языка на 100%, но в CPython работает именно так.


Задача 4: Динамические строки

s1 = "py"
s2 = "thon"
a = s1 + s2
b = "python"
print(a is b)

Ответ: False.
Почему: Переменная a вычисляется в Run-time (во время выполнения). Python по умолчанию не интернирует результаты динамической конкатенации строк, поэтому a будет ссылаться на новый объект, отличный от литерала b.


Задача 5: Тот самый One-liner

a = 1000; b = 1000; print(a is b)

Ответ: True.
Почему: Несмотря на то, что 1000 больше 256, код выполняется в одну строку. Для компилятора это один блок кода. Он видит две одинаковые константы и оптимизирует их, создавая один объект для обоих переменных. Если бы команды были введены на разных строках в REPL, результат был бы False.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.

Уверен, у вас все получится. Вперед, к экспериментам

Комментарии (2)


  1. Viacheslav-hub
    29.11.2025 09:31

    У меня есть подозрения, что эта статья сгенерирована)

    Возможно, я не прав, но если это ИИ, то я не понимаю смысла в этих статьях) Какой смысл в наборе количества статей и привлечении в канал, если автор ничего не представляет из себя как специалист

    P.S: 48 статей за месяц это конечно сильно)


    1. enamored_poc Автор
      29.11.2025 09:31

      Действительно какой смысл в обучающих статья? Сам задаюсь этим вопросом, зачем создавать статьи, в которых объясняется какая то важная тема... Я обязательно подумаю)