Learning Platform
Глоссарий Troubleshooting
Урок 02.04 · 25 мин
Продвинутый
PEP 393UnicodeCodepointASCII fast pathBMP
Требуемые знания:
  • 02-pylong-internals

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

PEP 393: 'a' vs 'я' vs '😀' (sys.getsizeof из baseline)
'' (empty)kind=1, 41 байтПустая строка — header только (PyASCIIObject), без payload. sys.getsizeof('') = 41 байт. Эта 41 — фиксированная overhead для compact ASCII представления.
'a' (ASCII)kind=1, 42 байтASCII fast path: PyASCIIObject + 1 байт payload (буква 'a' = 0x61) + null terminator. sys.getsizeof('a') = 42 байт. Каждый дополнительный ASCII-символ добавляет ровно 1 байт.
'я' (Cyrillic, BMP)kind=2, 60 байтКодпоинт U+044F вне Latin-1, поэтому kind=2: PyCompactUnicodeObject (header больше) + 2 байта на символ + null. sys.getsizeof('я') = 60 байт.
'😀' (emoji, non-BMP)kind=4, 64 байтКодпоинт U+1F600 вне BMP — требует UCS-4: PyCompactUnicodeObject + 4 байта на символ + null. sys.getsizeof('😀') = 64 байт. Одна emoji 'разогнала' kind всей строки.
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)
WARNING

Если ваше приложение обрабатывает массивные тексты с 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

Ключевые выводы

  1. PEP 393 = string выбирает 1/2/4 байта на codepoint в зависимости от максимального символа. Обновлено в Python 3.3 (2012). См. Objects/unicodeobject.c.
  2. ASCII fast path (PyASCIIObject) — самая компактная структура, активна для строк где все codepoints < 0x80. Pure-ASCII string из N символов весит ≈ 49 + N байт.
  3. Одна non-BMP char inflates всю строку до 4 bytes/char. При обработке огромных текстов с emoji учитывайте этот эффект.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что описывает PEP 393?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7