Кодировки символов и UTF-8: как символ превращается в байты

Кодировки символов и UTF-8: как символ превращается в байты Полезное

Кодировка символов — это правило, по которому текстовый символ представляется набором байтов для хранения и передачи. Важно сразу развести два понятия: набор символов (character set) отвечает на вопрос «какой номер у символа», а кодировка (encoding) — «какими байтами этот номер записать». Unicode — это набор символов, а UTF-8 — одна из его кодировок.

Ниже разберем сквозную цепочку «символ -> кодовая точка -> байты» на живых примерах, сравним однобайтовые кодировки с Unicode, детально посмотрим UTF-8 и типичную ошибку с «кракозябрами». Весь код в статье запущен, вывод в блоках — реальный.

Сквозная цепочка: символ, кодовая точка, байты

Любое кодирование текста проходит три уровня. Возьмем букву Я:

  • символ — то, что видит человек: Я;
  • кодовая точка (code point) — номер символа в Unicode: U+042F (это 1071 в десятичной);
  • байты — как этот номер записан в конкретной кодировке: в UTF-8 это два байта D0 AF.

Проследим цепочку сразу для нескольких символов в трех кодировках Unicode:

for ch in ['A', 'Я', '中', '😀']:
    cp = ord(ch)
    b8 = ch.encode('utf-8')
    b16 = ch.encode('utf-16-be')
    b32 = ch.encode('utf-32-be')
    print(f"{ch}  U+{cp:04X}  utf-8={b8.hex(' ')} ({len(b8)}б)  "
          f"utf-16={b16.hex(' ')} ({len(b16)}б)  utf-32={b32.hex(' ')} ({len(b32)}б)")

Вывод программы:

A  U+0041  utf-8=41 (1б)  utf-16=00 41 (2б)  utf-32=00 00 00 41 (4б)
Я  U+042F  utf-8=d0 af (2б)  utf-16=04 2f (2б)  utf-32=00 00 04 2f (4б)
中  U+4E2D  utf-8=e4 b8 ad (3б)  utf-16=4e 2d (2б)  utf-32=00 00 4e 2d (4б)
😀  U+1F600  utf-8=f0 9f 98 80 (4б)  utf-16=d8 3d de 00 (4б)  utf-32=00 01 f6 00 (4б)

Видно главное: кодовая точка у символа одна и та же, а вот число байтов зависит от кодировки. В UTF-8 латинская A заняла 1 байт, кириллическая Я — 2, иероглиф — 3, эмодзи — 4. В UTF-32 любой символ — ровно 4 байта. Здесь взят вариант с явным порядком байтов (utf-16-be/utf-32-be), чтобы показать чистые байты без служебной метки.

Набор символов и кодировка: не путать

Из цепочки следует важное разграничение. Unicode — это огромная таблица, которая назначает каждому символу номер (кодовую точку) в диапазоне от U+0000 до U+10FFFF. Сам по себе Unicode не говорит, сколькими байтами записать номер.

Как именно превратить номер в байты — решает кодировка. UTF-8, UTF-16 и UTF-32 — это три разные кодировки одного и того же набора Unicode. Поэтому фраза «сохранить в Unicode» неточна: файл сохраняют в конкретной кодировке (чаще всего в UTF-8).

Отдельно стоят старые однобайтовые кодировки. В них набор символов и способ записи слиты: один байт (0-255) сразу задает и номер, и представление символа.

Однобайтовые кодировки: ASCII, Windows-1251 и родня

Исторически первой была ASCII — 7-битная кодировка на 128 символов (номера 0-127): латиница, цифры, знаки препинания и управляющие коды. Кириллицы в ней нет.

Один байт вмещает 256 значений, поэтому на базе ASCII выросли однобайтовые кодировки, где вторая половина (128-255) отдана под национальные алфавиты. Для русского языка их несколько:

Кодировка Байт на символ Где встречается
ASCII 1 (7 бит) латиница, основа для остальных
Windows-1251 1 русский текст в старых системах Windows
KOI8-R 1 Unix-среды, письма 1990-2000-х
CP866 1 DOS и консоль Windows

Проблема однобайтовых кодировок одна: 256 значений мало. Одновременно поддержать русский, греческий и китайский в одном файле нельзя, а один и тот же байт в Windows-1251 и KOI8-R означает разные буквы. Именно из-за этого текст, прочитанный не в той кодировке, превращается в «кракозябры». Эту задачу и решил Unicode.

Unicode и его формы: UTF-8, UTF-16, UTF-32

Unicode задает единый номер каждому символу всех письменностей, эмодзи и множества служебных знаков. Всего под символы отведен диапазон U+0000—U+10FFFF, это чуть больше миллиона возможных позиций; реально назначено больше 150 тысяч символов. Кодировки семейства UTF отличаются тем, как они пакуют эти номера в байты.

Кодировка Байт на символ Особенность
UTF-8 1-4 (переменная) латиница = 1 байт, совместима с ASCII
UTF-16 2 или 4 (переменная) базовые символы — 2 байта, редкие — суррогатная пара из 4
UTF-32 4 (фиксированная) любой символ ровно 4 байта, прямая индексация

UTF-32 прост и позволяет обращаться к символу по индексу, но расходует память: даже латинская буква занимает 4 байта. UTF-16 экономнее на основном диапазоне, но тоже переменной длины — символы вне базовой плоскости (например, эмодзи) кодируются парой из четырех байтов. Самой распространенной в вебе стала UTF-8.

UTF-8 подробно: почему до 4 байтов

UTF-8 — кодировка переменной длины: символ занимает от 1 до 4 байтов. Название с цифрой 8 означает, что минимальная единица — 8 бит (один байт), а не то, что символ всегда однобайтовый.

Число байтов определяется диапазоном кодовой точки, а старшие биты первого байта сообщают декодеру, сколько байтов читать:

Диапазон кодовых точек Байтов Шаблон битов
U+0000 — U+007F 1 0xxxxxxx
U+0080 — U+07FF 2 110xxxxx 10xxxxxx
U+0800 — U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 — U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Потолок в 4 байта не случаен. Первая схема UTF-8 (RFC 2279) допускала последовательности до 6 байтов, но стандарт RFC 3629 в 2003 году ограничил ее 4 байтами — ровно настолько, чтобы покрыть весь диапазон Unicode до U+10FFFF. Поэтому утверждение «UTF-8 кодирует символ вплоть до 6 байтов» устарело: сегодня максимум — 4 байта.

Ключевое достоинство UTF-8 — обратная совместимость с ASCII. Байты 0-127 совпадают с ASCII один в один, поэтому старый ASCII-текст является корректным UTF-8 без изменений. У латиницы это дает и экономию: английский текст в UTF-8 весит столько же, сколько в ASCII, а не вдвое больше, как в UTF-16.

Кодовая точка и видимый символ — разные вещи

Легко решить, что один видимый знак — это всегда одна кодовая точка. Это упрощение, и оно неверно: видимый символ (его называют графемой) может складываться из нескольких кодовых точек.

accent = 'е́'
print('строка выглядит как одна буква:', accent)
print('а кодовых точек в ней:', len(accent))
print('точки:', [f'U+{ord(c):04X}' for c in accent])

family = '👨‍👩‍👧'
print('emoji-семья, кодовых точек:', len(family))
print('в utf-8 байт:', len(family.encode('utf-8')))

Вывод программы:

строка выглядит как одна буква: е́
а кодовых точек в ней: 2
точки: ['U+0435', 'U+0301']
emoji-семья, кодовых точек: 5
в utf-8 байт: 18

Буква с ударением е́ — это база е (U+0435) плюс комбинируемый акут (U+0301), то есть две кодовые точки на один видимый знак. Эмодзи-семья 👨‍👩‍👧 собрана из трех фигурок, склеенных невидимым соединителем ZWJ (U+200D), — пять кодовых точек и 18 байтов в UTF-8. Поэтому подсчет «длины» текста зависит от того, что считать: байты, кодовые точки или графемы.

Кракозябры: чтение не в той кодировке

Самый частый практический сбой — прочитать байты не в той кодировке, в которой их записали. Байты Я в UTF-8 (D0 AF) не являются корректным ASCII, и попытка разобрать их как ASCII падает с ошибкой:

data = 'Я'.encode('utf-8')
print('байты Я в utf-8:', data)
try:
    data.decode('ascii')
except UnicodeDecodeError as e:
    print('ошибка:', e)
print('правильно:', data.decode('utf-8'))

Вывод программы:

байты Я в utf-8: b'\xd0\xaf'
ошибка: 'ascii' codec can't decode byte 0xd0 in position 0: ordinal not in range(128)
правильно: Я

Здесь виден правильный рабочий процесс: те же байты, что записаны в UTF-8, надо и читать как UTF-8. А теперь посмотрим, сколько места занимает одна строка в разных кодировках и что бывает, когда символ не помещается:

s = 'Привет, мир! 😀'
for enc in ['utf-8', 'utf-16', 'utf-32', 'cp1251']:
    try:
        n = len(s.encode(enc))
        print(f'{enc:8}: {n} байт')
    except UnicodeEncodeError as e:
        print(f'{enc:8}: не кодируется ({e.reason})')

Вывод программы:

utf-8   : 26 байт
utf-16  : 32 байт
utf-32  : 60 байт
cp1251  : не кодируется (character maps to <undefined>)

Числа для utf-16 и utf-32 включают служебную метку порядка байтов (BOM) в начале — 2 и 4 байта соответственно, поэтому они чуть больше «чистого» размера. Windows-1251 споткнулась на эмодзи: в ее таблице такого символа просто нет. Это и есть корень «кракозябр» — однобайтовая кодировка физически не вмещает символ, а при неверном чтении подставляет что попало. Практический вывод: держите весь конвейер (файлы, HTTP-заголовок charset=utf-8, базу данных) в UTF-8.

Выводы

  • Кодировка символов — правило превращения символа в байты; отдельно живут набор символов (номер символа) и кодировка (запись номера байтами).
  • Сквозная цепочка: символ Я -> кодовая точка U+042F -> байты D0 AF в UTF-8; кодовая точка одна, а число байтов зависит от кодировки.
  • Однобайтовые кодировки (ASCII, Windows-1251, KOI8-R, CP866) вмещают лишь 256 значений и несовместимы между собой — отсюда «кракозябры».
  • UTF-8 — переменная длина от 1 до 4 байтов (не 6), совместима с ASCII и стала стандартом де-факто в вебе.
  • Видимый символ (графема) может состоять из нескольких кодовых точек, поэтому «длина строки» неоднозначна.

Где применяется и что учить дальше

Понимание кодировок нужно везде, где программа читает или пишет текст: разбор файлов и CSV, работа с HTTP и JSON, обращения к базе данных, парсинг логов. Классические баги новичка — «кракозябры» в консоли, UnicodeDecodeError при открытии файла, обрезанная посередине многобайтовая буква — решаются именно знанием цепочки «символ -> кодовая точка -> байты».

Освойте тему на практике

В Python вся эта механика встроена в тип str и методы encode/decode, и разобраться в ней проще всего на практике. Системно освоить строки, байты и работу с файлами помогает курс Основы программирования на Python. Оценить формат и уровень до старта можно на бесплатных открытых уроках Otus — это живые занятия с преподавателями.

Смежные темы: Формат ISO и стандарты представления данных.

FAQ

Что выбрать для нового проекта — UTF-8 или UTF-16? Для файлов, веба и обмена данными по умолчанию берут UTF-8: она компактна на латинице и совместима с ASCII. UTF-16 остается внутренним форматом строк в некоторых платформах (например, в Java и Windows API), но для хранения и передачи чаще выбирают UTF-8.

Почему при подсчете символов число иногда не совпадает с ожидаемым? Потому что можно считать разное: байты, кодовые точки или видимые графемы. Эмодзи или буква с комбинируемым знаком — один видимый символ, но несколько кодовых точек и несколько байтов.

Что такое BOM и нужен ли он? BOM (byte order mark) — невидимая метка в начале файла, задающая порядок байтов и кодировку. Для UTF-16 и UTF-32 он указывает порядок байтов; для UTF-8 он не обязателен и часто мешает, поэтому файлы обычно сохраняют в UTF-8 без BOM.

OTUS Журнал