Числа и строки: примитивов нет

В Python нет «простых» значений. Целое — объект произвольной точности, строка — объект, выбирающий себе представление. Из этого следуют и бесконечная арифметика без переполнения, и деление, работающее не как в C, и цена, из-за которой существует numpy.

L1 · 8 минL2 · 12 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Фрагмент A
import sys
print(sys.getsizeof(0), sys.getsizeof(2**30), sys.getsizeof(2**1000))
print(2**5000 % 1000003)
28 32 160
(мгновенно, точный ответ)
Ни переполнения, ни потери точности — и растущий размер объекта.
Фрагмент B
print(10**23 == int(1e23))
print(10**23)
print(int(1e23))
False
100000000000000000000000
99999999999999991611392
Два способа записать одно число дают разные числа.
Фрагмент C
print(-7 // 2, -7 % 2)
print(7 // -2, 7 % -2)
-4 1
-4 -1
В C и Java те же выражения дают -3 и -1. Это не ошибка ни там, ни здесь.
Фрагмент D
import sys
print(sys.getsizeof(""), sys.getsizeof("a"), sys.getsizeof("aa"))
print(sys.getsizeof("é"), sys.getsizeof("😀"))
49 50 51
74 80
ASCII-символ добавляет один байт. Один не-ASCII добавляет двадцать три.

2Механизм

Из урока 01 известно: значение — всегда объект. Здесь мы смотрим, что это означает для двух самых массовых типов, и что из этого следует.

Следствие 1 · целое произвольной точности — единственный целый тип

Раз число всё равно объект в куче, привязывать его к машинному слову незачем. Поэтому int в Python — не 64-битное целое, а массив цифр по основанию 2³⁰, хранящихся в 32-битных словах, плюс заголовок и поле знака-и-длины.

Отсюда сразу два вывода. Первый: переполнения не существует как явления. Не «поддерживаются большие числа» — а нет верхней границы вовсе, и нет второго типа для больших чисел. Второй: размер объекта растёт с величиной — 28 байт до 2³⁰, 32 байта дальше, 160 байт у 2¹⁰⁰⁰. Фрагмент A — про это.

Мелкие целые от −5 до 256 закэшированы 🔧 — и теперь понятно зачем: если каждое число это объект, то самые частые числа выгоднее создать один раз при старте. Это оптимизация, вынужденная моделью, а не украшение.

Следствие 2 · float живёт по другим правилам, и на границе теряется точность

float — исключение из следствия 1: это обычное IEEE 754 double, 64 бита, унаследованные от железа. Точных цифр в нём около пятнадцати, а целые представимы точно только до 2⁵³.

Фрагмент B ровно об этом. 1e23 — литерал типа float, и ближайшее к 10²³ представимое число не равно 10²³. int() честно переводит то, что есть, и получается 99999999999999991611392. А 10**23 вычислено в целых и точно.

Практическое правило выводится само: любой переход int → float → int выше 2⁵³ теряет данные молча. Никакого исключения, никакого предупреждения — просто другое число.

Следствие 3 · деление округляет вниз, а не к нулю

Фрагмент C. -7 // 2 в Python равно −4, в C и Java равно −3. Расхождение осознанное, и причина в инварианте, который Python решил сохранить:

a == (a // b) * b + (a % b)

Если // округляет вниз, то остаток всегда имеет знак делителя, и a % n для положительного n всегда лежит в [0, n). Это ровно то, что нужно для циклических индексов, хеш-корзин, координат на торе и арифметики времени.

В C, где деление усекает к нулю, остаток может быть отрицательным, и приходится писать ((i % n) + n) % n. Каждый, кто писал кольцевой буфер на C, эту конструкцию знает. В Python она не нужна никогда — и это прямое следствие одного решения об округлении.

Следствие 4 · строка выбирает себе ширину символа

Строка неизменяема, значит её представление можно выбрать один раз при создании — и Python этим пользуется. С PEP 393 у строки три возможных формы: 1, 2 или 4 байта на символ, по максимальному кодпоинту. Плюс отдельный, ещё более компактный вариант для чистого ASCII.

Фрагмент D становится прозрачным. Пустая ASCII-строка — 49 байт заголовка, каждый следующий ASCII-символ ровно один байт. А один символ вне Latin-1 переводит всю строку в четырёхбайтовую форму и добавляет к заголовку служебные поля — отсюда 80 байт у односимвольной строки с эмодзи.

Важное следствие, ради которого всё и делалось: индексация по строке остаётся O(1). s[i] — это чтение по смещению, потому что все символы внутри одной строки одинаковой ширины. Python платит памятью в редком случае, чтобы индексация была дешёвой всегда.

Следствие 5 · интернирование

Раз строки неизменяемы, одинаковые строки можно не дублировать. Python делает это автоматически для строк, похожих на идентификаторы, и для литералов внутри одного объекта кода 🔧:

a = "hello"; b = "hello"; a is b
# → True
exec("x = 'hello world!'", ns); exec("y = 'hello world!'", ns)
print(ns['x'] is ns['y'])
# → False
print(sys.intern("".join(["hel","lo"])) is a)
# → True

Отсюда окончательный ответ на вопрос «почему is для строк иногда работает»: потому что иногда это буквально один объект. И отсюда же — sys.intern как осознанный инструмент, а не курьёз; в разделе 7 он экономит восьмикратно.

3Границы модели

Где сказанное перестаёт держать

Арифметика больших чисел не константна по времени. Умножение чисел в тысячи бит — это работа по всем цифрам, и она в сотни раз дороже умножения мелких. Криптография и комбинаторика на Python из-за этого работают, но медленно. Кроме того, с 3.11 преобразование очень больших целых в строку ограничено по умолчанию — защита от квадратичного разбора, снимается через sys.set_int_max_str_digits.

Интернирование — не гарантия 🔧. Какие именно строки интернируются, менялось между версиями и зависит от того, литерал ли это и похож ли он на идентификатор. Опираться на is для строк нельзя никогда, даже если сейчас оно возвращает True.

Точная арифметика есть, но не по умолчанию. decimal.Decimal для денег, fractions.Fraction для рациональных. Оба — обычные объекты Python, то есть медленные; их выбирают осознанно, когда цена ошибки выше цены скорости.

4Корень

Тот же, что в уроке 01: единая объектная модель без примитивов. Здесь видно, как одно это решение окупается и во что обходится.

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

Обходится памятью и скоростью: 28 байт и вызов функции там, где в C одна инструкция над регистром. Именно эта цена сделала неизбежным numpy — не как ускоритель, а как способ вынести однородные числа из объектной модели в непрерывный буфер.

5Аналогия

Выбор представления строки — это то же, что выбор dtype в numpy, только автоматический. Массив не хранит маленькие числа в float64 без нужды; строка не хранит латиницу в четырёх байтах на символ. Идея одна: не платить за худший случай, когда данные его не требуют, и зафиксировать представление один раз, чтобы доступ по индексу оставался арифметикой над смещением.

И следующий шаг аналогии тоже верен: как один float64 в массиве int8 заставит поднять тип всего массива, так один символ вне Latin-1 поднимает ширину всей строки.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему 0.1 + 0.2 != 0.3 — и при чём тут Python?

Ни при чём. Это IEEE 754, тот же самый, что в C, Java, JavaScript и в вашем процессоре.

float в CPython — это double из C, бит в бит. Числа 0.1 и 0.2 не представимы в двоичной дроби конечно, как 1/3 не представима в десятичной. Сумма их ближайших представимых приближений даёт число, чуть большее ближайшего приближения к 0.3.

from decimal import Decimal
print(Decimal(0.1))
# → 0.1000000000000000055511151231257827021181583404541015625

Python здесь честнее многих: repr с версии 3.1 печатает кратчайшую строку, которая round-trip'ится обратно в то же число. Поэтому вы видите 0.1, а не 0.10000000000000001, — и одновременно видите 0.30000000000000004, когда разница вылезла за эту границу.

Что с этим делать. Для денег — decimal.Decimal или целые в минимальных единицах (копейки, центы). Для сравнений — math.isclose. Для накопления сумм — math.fsum, который компенсирует ошибку округления. Универсального «правильного float» не существует ни в одном языке.

Зачем int произвольной точности, если фиксированный быстрее?

Затем, что альтернатива — молчаливое переполнение, а это худший класс ошибок.

В C переполнение знакового целого — вообще неопределённое поведение; на практике число просто становится отрицательным, и программа продолжает работать с мусором. Python выбрал «никогда не терять точность», и это прямое следствие R1: раз число всё равно объект с заголовком в 24 байта, добавить к нему массив цифр переменной длины почти ничего не стоит.

Цена измеримая и нелинейная. Пока число влезает в одну 30-битную цифру, арифметика идёт по быстрому пути. Дальше начинается длинная арифметика:

import timeit
small = "a * b"
print(timeit.timeit(small, "a = 2**20; b = 2**20", number=1_000_000))
print(timeit.timeit(small, "a = 2**5000; b = 2**5000", number=1_000_000))
# разница в сотни раз

Где граница на практике. Криптография, комбинаторика, точные вычисления — там произвольная точность и есть цель. Массивы чисел — там она вредна, и ответ numpy: фиксированная ширина, переполнение по модулю, зато 8 байт вместо 28 и векторные инструкции. Урок 15 про эту границу целиком.

В 3.11 добавили ещё и лимит на длину десятичного представления (4300 цифр по умолчанию) — защита от атаки, где int(строка) на мегабайтном вводе кладёт сервис квадратичным алгоритмом.

Если интернирование экономит память, почему его не применяют ко всему подряд?

Потому что интернирование — это обмен памяти на время, и обмен выгоден далеко не всегда.

Чтобы интернировать строку, её нужно захешировать целиком и найти в глобальной таблице. Для строки, которая встретится один раз, это чистый убыток: заплатили за хеш и поиск, не сэкономили ничего. Плюс таблица сама живёт до конца процесса — интернированная строка не освободится, даже когда перестанет быть нужной.

Поэтому CPython интернирует автоматически только то, где выигрыш почти гарантирован: короткие литералы, похожие на идентификаторы, и имена атрибутов. Что именно попадает под правило, 🔧 менялось между версиями и опираться на это нельзя.

Когда звать sys.intern руками. Когда в память загружается много записей с повторяющимися строковыми полями — категории, коды стран, имена колонок. Замер из этого урока: миллион строк из небольшого словаря, 21.2 МБ против 2.6 МБ. Признак ситуации — мало уникальных значений при большом их количестве. Если уникальных почти столько же, сколько всего, интернирование только замедлит.

Почему у строки нет фиксированной ширины символа — что было до PEP 393?

Был выбор из двух зол, и оба были плохи.

До 3.3 CPython собирался в одном из двух режимов: «узкая» сборка с UTF-16 внутри и «широкая» с UCS-4. Узкая экономила память на латинице, но ломала семантику: символы вне базовой плоскости занимали две позиции, поэтому len("𝄞") возвращал 2, а срез мог разрезать символ пополам. Широкая была корректной, но тратила 4 байта на каждую букву «a».

PEP 393 убрал выбор: строка сама смотрит на свой максимальный кодовый пункт и выбирает 1, 2 или 4 байта на символ. Это видно прямо в заголовке — поле kind, и лаборатория «Что лежит в байтах» показывает его в реальном дампе.

import sys
print(sys.getsizeof("aaaaa"), sys.getsizeof("ааааа"))
# → 54 84    пять латинских против пяти кириллических

Цена решения: одна добавленная эмодзи в строку удваивает или учетверяет её память целиком, потому что представление выбирается на всю строку сразу. Выигрыш: len и индексация остались O(1) и всегда считают символы, а не байты — то, чего до сих пор нет в JavaScript и Java.

Итог. Раз примитивов нет, целое стало объектом произвольной точности — массивом 30-битных цифр без верхней границы и без второго типа для больших чисел, ценой 28 байт и вызова функции на операцию. float остался исключением: это IEEE 754, и переход int → float → int выше 2⁵³ теряет данные молча. Деление округляет вниз, а не к нулю, ради инварианта, из которого следует, что a % n всегда неотрицателен для положительного n — и поэтому в Python не нужна конструкция ((i % n) + n) % n. Строка неизменяема, поэтому выбирает себе ширину символа один раз при создании: индексация остаётся O(1) ценой памяти на текстах со смешанным алфавитом. И та же неизменяемость даёт интернирование, которое из курьёза про is превращается в восьмикратную экономию памяти, если применить его осознанно.
Дальше — по желанию
контрфактуалJava, C, Rust — три разных ответаBigInteger как отдельный класс, усечение к нулю, строка как байты UTF-8
Контрфактуал · те же вопросы у других

Java оставила машинные целые и добавила BigInteger отдельным классом. Результат: переполнение int происходит молча и заворачивается по модулю, а переход на большие числа требует переписать код на методы вместо операторов. Два набора правил, между которыми надо выбирать заранее, — ровно то, чего Python избежал.

C усекает деление к нулю, потому что так делает железо. Python выбрал округление вниз, потому что так полезнее человеку, и заплатил расхождением с привычками половины программистов.

Rust и Go считают строку последовательностью байт UTF-8: индексация по байтам, символ добывается итерацией. В Rust s[0] просто не компилируется. Python выбрал кодпоинты и O(1) индексацию — и заплатил тремя внутренними представлениями и перерасходом памяти на текстах со смешанным алфавитом.

🐛 багfloat для целых идентификаторовВыше 2⁵³ приведение теряет данные молча — фильтр начинает врать на границе
🐛 Баг, который проходит ревью

Идентификаторы из внешней системы прошли через JSON и сравнение перестало работать. Ни исключения, ни предупреждения.

threshold = float(config["min_id"])      # "9007199254740993"
rows = [r for r in rows if r["id"] > threshold]

Всё выглядит нормально: значение из конфига приводим к числу, сравниваем. Но id здесь — снежинка или другой 64-битный идентификатор, а float представляет целые точно только до 2⁵³. Строка «9007199254740993» превращается в 9007199254740992, и записи на границе то попадают в выборку, то нет.

print(int(float("9007199254740993")))
# → 9007199254740992

Ревью пропускает, потому что float() — общепринятый способ «привести к числу», и в тестах используются маленькие идентификаторы, где всё сходится. Проявляется на проде и только на части записей, что делает баг особенно неприятным при расследовании.

Лечится тем, что вытекает из следствия 1: для целых использовать int(), а не float(). У Python нет причины уводить целое в плавающую точку — арифметика произвольной точности бесплатна в смысле корректности. То же правило работает для денег: хранить копейки целыми, а не рубли дробными.

⚡ 8×sys.intern на повторяющихся строках21.2 МБ → 2.6 МБ на трёхстах тысячах одинаковых значений
⚡ Восьмикратная экономия на повторяющихся строках

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

Строки, пришедшие из разбора, не интернируются — парсер собирает их из кусков, а автоматическое интернирование работает только для литералов в коде. Значит триста тысяч одинаковых статусов лежат в памяти тремястами тысячами объектов:

value = sys.intern(parsed_field)     # одна функция
300 000 повторяющихся строкпамять
как есть21.2 МБ
через sys.intern2.6 МБ

Восемь раз, и код меняется на одну функцию. Работает это потому, что интернирование сводит все одинаковые строки к одному объекту — то самое следствие 5, применённое осознанно.

Когда применять: значения из ограниченного набора, повторяющиеся много раз, — статусы, коды стран, имена колонок, ключи словарей. Когда не надо: уникальные значения вроде текстов или идентификаторов — таблица интернирования только вырастет, а выигрыша не будет. И помни, что интернированные строки живут до конца процесса: это кэш без вытеснения.

💻 терминалПроверь в REPLЧетыре фрагмента плюс Decimal, рост строки и рандомизация хеша

Проверь

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

0.1 + 0.2 == 0.3
from decimal import Decimal
Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
# Почему разные ответы и какой ценой достигается второй?

s = "a" * 1000
sys.getsizeof(s)
sys.getsizeof(s + "😀")
# Насколько вырастет и почему именно так?

hash("x") == hash("x")
# запусти это в двух разных процессах — совпадёт?

(-7) // 2, int(-7 / 2)
# Два способа поделить нацело дают разное. Какой когда нужен?

Третий вопрос выводит на тему, которой в уроке нет: рандомизация хеша строк включена по умолчанию, и это защита от атаки на коллизии. Ответ — в уроке про словарь.

исходникиstatistics.meanПочему стандартная библиотека считает среднее через точные дроби

Где это живёт в реальном коде

Lib/statistics.py, функция _sum. Ожидаешь увидеть сложение чисел с плавающей точкой — а там перевод в Fraction, точное сложение рациональных и обратное преобразование в конце.

Сделано это, чтобы statistics.mean не накапливал ошибку округления: сумма миллиона float в наивном цикле уезжает тем сильнее, чем больше разброс величин. Стандартная библиотека решила, что для статистики точность важнее скорости, и позволила себе точную арифметику — потому что в языке нет примитивов, и рациональное число такой же полноправный объект, как целое.

Заметь и обратную сторону: statistics.mean заметно медленнее sum(xs)/len(xs), и это осознанный размен, записанный в исходнике. Хороший пример того, что «медленно» и «неправильно» — разные оси.

L2Как устроены цифры и представления внутри30-битные цифры, три представления строки, кэш хеша в заголовке

Из L1 ты знаешь, что целое — массив цифр, а строка выбирает ширину. Здесь — конкретная раскладка и её цена.

Целое. Заголовок объекта переменной длины (24 байта) плюс массив 32-битных слов, в каждом из которых используется 30 бит. Почему 30, а не 32: остаётся запас на перенос при сложении и умножении, чтобы не выходить за разрядность машинного слова при промежуточных вычислениях. Отсюда арифметика размеров: 28 байт до 2³⁰, 32 до 2⁶⁰, дальше по 4 байта на каждые 30 бит.

Стоимость операций растёт соответственно:

умножение мелких целых     0.006 c
умножение чисел по 5000 бит 3.68 c   (та же серия вызовов)

Разница в шестьсот раз — на школьном алгоритме умножения с квадратичной сложностью по числу цифр. Для очень больших чисел CPython переключается на Карацубу, но константа всё равно велика.

Строка. Заголовок содержит длину, хеш (вычисляется лениво и кэшируется), флаги и поле kind — 1, 2 или 4 байта на символ. Компактная ASCII-форма экономит ещё несколько полей, поэтому пустая ASCII-строка это 49 байт, а не 74.

print(sys.getsizeof(""), sys.getsizeof("a"))       # ASCII
# → 49 50
print(sys.getsizeof("é"), sys.getsizeof("😀"))     # не ASCII
# → 74 80

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

L3Где это в исходниках CPythonlongintrepr.h, longobject.c, unicodeobject.h, PEP 393

Тег v3.11.15.

  • Include/cpython/longintrepr.h — определение PyLongObject и константа PyLong_SHIFT, равная 30. Двадцать строк, объясняющих весь следствие 1.
  • Objects/longobject.c, l_divmod — реализация деления с округлением вниз и коррекцией остатка. Следствие 3 в виде кода.
  • Include/cpython/unicodeobject.h — три представления, флаги compact и ascii, поле kind.
  • PEP 393 — мотивация: до него строка хранилась в фиксированных 2 или 4 байтах на символ, выбранных при сборке интерпретатора.
связиКуда это ведётL01, L03, L05 · контраст с машинными целыми и байтовыми строками

Связи

корень R1 · Всё — объект, имя — ссылка
← основа L01 · Имя, объект, ссылка — неизменяемость чисел и строк объясняется там
→ дальше L03 · Контейнеры — что происходит, когда таких объектов миллион
→ дальше L05 · dict и хешируемость — кэшированный хеш строки и его рандомизация
↔ контраст Машинные целые в C и Java: переполнение молча против произвольной точности
↔ контраст Строка как байты UTF-8 в Rust и Go: индексация по байтам против O(1) по кодпоинтам
чего здесь нет Кодировки, encode и decode — это про границу с внешним миром, а не про модель объектов
источникиЧто почитатьPEP 393 🟥 · про // и % 🟧 · floating point 🟦

Что почитать