str и PEP 393: flexible string representation
До Python 3.3 строки хранились как UCS-2 (16 бит на codepoint, narrow build) или UCS-4 (32 бита, wide build) — выбор делался при компиляции CPython. Это создавало проблемы: narrow build не мог корректно обработать non-BMP символы (emoji ломались на surrogate pairs), wide build тратил 4 байта на символ даже для чистого ASCII.
PEP 393 (Python 3.3, 2012) — Flexible String Representation — решил это так: каждая строка во время создания выбирает наименьшую ширину codepoint (1, 2 или 4 байта), которая помещает все её символы. ASCII-only строка занимает 1 байт на символ. Строка из кириллицы — 2 байта. Строка с emoji — 4 байта на каждый символ, включая ASCII-часть.
PEP 393 layout
Каждый PyUnicodeObject имеет поле kind — одно из трёх значений (см. Include/cpython/unicodeobject.h):
PyUnicode_1BYTE_KIND= 1 — Latin-1 (codepoints U+0000..U+00FF, что покрывает ASCII + Latin-1 supplement). 1 байт на символ.PyUnicode_2BYTE_KIND= 2 — UCS-2 / BMP (codepoints U+0000..U+FFFF). 2 байта на символ.PyUnicode_4BYTE_KIND= 4 — UCS-4 / full Unicode (codepoints U+0000..U+10FFFF). 4 байта на символ.
Реализация в Objects/unicodeobject.c. Структуры объектов: PyASCIIObject (для compact ASCII fast path), PyCompactUnicodeObject (для compact non-ASCII), PyUnicodeObject (для legacy/non-compact).
import sys
print(sys.getsizeof('')) # 41 — пустая строка, ASCII-header
print(sys.getsizeof('a')) # 42 — +1 байт на ASCII-символ
print(sys.getsizeof('я')) # 60 — kind=2 для BMP
print(sys.getsizeof('😀')) # 64 — kind=4 для non-BMP
Значения сверены с baseline cpython-3.12.7. На разных build’ах CPython и в Pyodide WASM абсолютные числа могут отличаться, но ranking всегда тот же: ASCII < BMP < non-BMP по байтам на символ.
ASCII fast path
Для pure-ASCII строк CPython использует особо компактную структуру PyASCIIObject — без отдельного UTF-8 cache, без дополнительных полей для kind/maxchar. Это дополнительная оптимизация: проверки на ASCII в коде CPython очень частые (re-модуль, JSON parser, сравнение со словарными ключами), и наличие отдельного fast path позволяет инлайнить hot loops без condition’ов на kind.
import sys
ascii_str = 'hello world'
unicode_str = 'hello мир'
print(sys.getsizeof(ascii_str)) # 60 (header_ascii + 11 bytes + null)
print(sys.getsizeof(unicode_str)) # ≈92 (header_compact + 9 chars × 2 bytes)
Заметьте: 'hello мир' хранится с kind=2 для всей строки — несмотря на то, что 5 первых символов ASCII. Внутри одной строки kind не варьируется; либо все символы помещаются в N бит, либо kind поднимается до следующего уровня.
BMP и non-BMP
BMP (Basic Multilingual Plane) — Unicode-плоскость U+0000..U+FFFF. Содержит все основные алфавиты: latin, cyrillic, греческий, иврит, арабский, китайские иероглифы CJK Unified до U+9FFF. UCS-2 = 2 байта на символ.
Non-BMP — supplementary planes U+10000..U+10FFFF. Содержит:
- Emoji (U+1F300..U+1F9FF и др.).
- Математические символы (U+1D400..U+1D7FF).
- Исторические письменности (хеттский U+14400, египетский U+13000).
- CJK Unified Ideographs Extension B и далее (U+20000+).
Когда строка содержит хотя бы один non-BMP codepoint, kind поднимается до 4 для всей строки. Одна emoji в длинной ASCII-логине inflates её ×4.
import sys
ascii_log = 'INFO: user logged in successfully' # 32 ASCII chars
emoji_log = 'INFO: user logged in 🎉' # 21 ASCII + 1 emoji = 22 codepoints
print(sys.getsizeof(ascii_log)) # ≈81 байт (32 ASCII × 1)
print(sys.getsizeof(emoji_log)) # ≈160 байт (22 codepoints × 4 bytes)
Если ваше приложение обрабатывает массивные тексты с occasional emoji (логи, чат-сообщения), помните: одна emoji инфлейтит всю строку до 4 байт на символ. Для huge text-storage рассмотрите segment-based хранение или отдельное emoji-хранилище. Альтернативно — храните в UTF-8 (bytes), декодируйте только когда нужно отрендерить.
Iteration по str — codepoints, не bytes
for c in s итерируется по codepoints, а не по байтам. len(s) возвращает число codepoints. Чтобы получить число байт, нужно явно encode:
s = 'я'
print(len(s)) # 1 (один codepoint)
print(len(s.encode('utf-8'))) # 2 (два байта в UTF-8: 0xD1 0x8F)
s = '😀'
print(len(s)) # 1 (один codepoint U+1F600)
print(len(s.encode('utf-8'))) # 4 (UTF-8: 0xF0 0x9F 0x98 0x80)
Эта ясная codepoint-семантика — одна из главных побед Python 3 над Python 2 (где str = bytes, и iteration давала байты, что ломалось на любой не-ASCII).
Полезный приём: str.encode('utf-8') не зависит от kind строки внутри Python — UTF-8 это внешнее representation. PEP 393 определяет только внутреннее.
Cross-course context
Строковые функции и кодировки в ClickHouseКлючевые выводы
- PEP 393 = string выбирает 1/2/4 байта на codepoint в зависимости от максимального символа. Обновлено в Python 3.3 (2012). См.
Objects/unicodeobject.c. - ASCII fast path (
PyASCIIObject) — самая компактная структура, активна для строк где все codepoints < 0x80. Pure-ASCII string из N символов весит ≈ 49 + N байт. - Одна non-BMP char inflates всю строку до 4 bytes/char. При обработке огромных текстов с emoji учитывайте этот эффект.