Перейти к содержанию

Статья 3. Кодировки: UTF-8, UTF-16, UTF-32

Кодировка ≠ кодовое пространство

Важнейшее различие:

  • Кодовые точки — абстрактные числа (U+0041, U+1F30D). Это математика.
  • Кодировка — способ представить эти числа в виде байтов. Это инженерия.

Unicode определяет кодовое пространство. Способов записать его в байты несколько: UTF-8, UTF-16, UTF-32. Все они кодируют одно и то же пространство, но по-разному.


1. UTF-8

UTF-8 — самая распространённая кодировка в интернете. Разработана Кеном Томпсоном и Робом Пайком (авторы Unix) в 1992 году.

Принцип

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

x — биты кодовой точки.

Байты в разных кодировках

for ch in ['A', 'é', '中', '🌍']:
    b = ch.encode('utf-8')
    print(f"'{ch}'  U+{ord(ch):05X}  utf-8: {b.hex(' ').upper():15s} ({len(b)} байт)")
'A'  U+00041  utf-8: 41              (1 байт)
'é'  U+000E9  utf-8: C3 A9          (2 байт)
'中'  U+04E2D  utf-8: E4 B8 AD       (3 байт)
'🌍'  U+1F30D  utf-8: F0 9F 8C 8D   (4 байт)

Пример: кодирование U+1F30D (🌍)

  1. Кодовая точка: 0x1F30D = 0001 1111 0011 0000 1101 (21 бит)
  2. Шаблон 4 байт: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
  3. Разбиваем биты: 000 | 011111 | 001100 | 001101
  4. Собираем: 11110000 10011111 10001100 10001101
  5. Hex: F0 9F 8C 8D

Почему UTF-8 хорош?

  • Байты 0x00..0x7F — это чистый ASCII. Любой ASCII-текст — валидный UTF-8.
  • Начало символа легко отличить от продолжения (10xxxxxx — продолжение).
  • Нет нулевых байтов внутри многобайтовых символов (кроме U+0000 — сам ноль).
  • Самосинхронизирующийся: после повреждения легко найти начало следующего символа.

UTF-8 BOM (EF BB BF)

Для UTF-8 BOM не нужен: в однобайтовой кодировке нет понятия «порядок байт». Тем не менее старый и Windows-ориентированный софт может его добавлять — это три байта EF BB BF в начале файла. Если вы встретили такой файл, просто имейте в виду его происхождение и при необходимости удалите BOM перед обработкой.

Что не так?

  • Переменная ширина: нельзя взять i-й символ за O(1) (нужна итерация).
  • CJK символы занимают 3 байта вместо 2 в UTF-16.
  • Индексы в байтах vs символах — частый источник ошибок.

2. UTF-16

Принцип

  • Символы U+0000..U+FFFF (BMP): 2 байта, равные коду символа.
  • Символы U+10000..U+10FFFF: 4 байта через суррогатные пары.

Суррогатные пары

Диапазон U+D800..U+DFFF зарезервирован в Unicode специально для суррогатов. Это не реальные символы — только маркеры пар.

  • High surrogate: U+D800..U+DBFF
  • Low surrogate: U+DC00..U+DFFF

Как кодируется символ вне BMP (cp ≥ U+10000):

Идея: нужно упаковать 20 бит (диапазон 0x00000..0xFFFFF) в два 16-битных слова, используя зарезервированные диапазоны суррогатов.

Шаг 1. Вычесть 0x10000, чтобы получить 20-битное смещение cp':

cp' = cp - 0x10000
Это превращает диапазон U+10000..U+10FFFF в 0x00000..0xFFFFF — ровно 20 бит.

Шаг 2. Разделить 20 бит пополам: старшие 10 → в high surrogate, младшие 10 → в low surrogate:

high = 0xD800 + (cp' >> 10)     # старшие 10 бит, результат в U+D800..U+DBFF
low  = 0xDC00 + (cp' & 0x3FF)   # младшие 10 бит, результат в U+DC00..U+DFFF
Базы 0xD800 и 0xDC00 — это начала диапазонов high/low суррогатов. Прибавляя к ним биты, мы попадаем точно в зарезервированный диапазон.


Разбор примера: U+1F30D (🌍)

Шаг 1:  cp' = 0x1F30D - 0x10000 = 0xF30D
        В двоичном: 0000 1111 0011 0000 1101
                    [старшие 10 бит] [младшие 10 бит]
                     00 0011 1100      11 0000 1101
                     = 0x03C           = 0x30D

Шаг 2:  high = 0xD800 + 0x03C = 0xD83C   → байты LE: 3C D8
        low  = 0xDC00 + 0x30D = 0xDF0D   → байты LE: 0D DF

Итого в памяти (UTF-16 LE): 3C D8 0D DF

Декодирование — обратная операция: по наличию D8xx и DFxx рядом декодер понимает, что это пара, и восстанавливает cp = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00).

BOM (Byte Order Mark)

UTF-16 существует в двух вариантах: UTF-16-LE (little-endian) и UTF-16-BE. BOM — U+FEFF в начале файла — сигнализирует о порядке байт:

  • FF FE — little-endian
  • FE FF — big-endian

Где используется UTF-16?

  • Windows API (нативно)
  • Java String, JavaScript String (внутреннее представление)
  • .NET System.String
  • Файлы .docx, .xlsx и другие Office-форматы

Ловушка JS и Java

JavaScript хранит строки как UTF-16. Это значит:

// length считает UTF-16 code units, а не символы
console.log('🌍'.length);          // 2, а не 1 — суррогатная пара!
console.log([...'🌍'].length);     // 1 — правильно, через spread
console.log('🌍'[0] === '\uD83C'); // true — первый "символ" — high surrogate

// Безопасный способ перебора:
for (const ch of '🌍🎉') {
  console.log('U+' + ch.codePointAt(0).toString(16).toUpperCase());
}

3. UTF-32

Самый простой вариант: каждый символ — ровно 4 байта (32 бита). Значение байтов = кодовая точка (с учётом порядка байт).

Плюсы:

  • O(1) доступ к i-му символу.
  • Тривиальная обработка.

Минусы:

  • Английский текст занимает в 4 раза больше памяти, чем в UTF-8.
  • Практически не используется для хранения/передачи данных.
  • Тоже зависит от порядка байт (LE/BE), тоже есть BOM.

Где используется?

  • Python 3 использует UCS-4 (4 байта) внутри, если есть символы > U+FFFF.
  • Некоторые Unix-системы: wchar_t = 4 байта, mbstowcs.

4. Сравнение кодировок

UTF-8 UTF-16 UTF-32
Мин. байт на символ 1 2 4
Макс. байт на символ 4 4 4
ASCII-совместимость
Null-байты в ASCII
Суррогаты нужны ✓ (для SMP)
O(1) индексация ✗ (суррогаты)
BOM не нужен нужен/рекомендован нужен/рекомендован
Применение Web, Linux, файлы Windows API, JS, Java Внутри Python, C wchar_t

5. Графемные кластеры

Даже зная кодировку, «длина строки» — неоднозначное понятие: байты, кодовые точки и графемные кластеры дают разные числа. Графемный кластер — это то, что пользователь воспринимает как один «символ»: буква с диакритикой, эмодзи из нескольких кодовых точек, флаг из двух Regional Indicators.

Этой теме посвящена отдельная статья 4 — Графемные кластеры: виды кластеров, файл GraphemeBreakProperty.txt, алгоритм UAX #29, Variation Selectors, примеры в Python и JavaScript.


6. Кодировки на Linux

# Определить кодировку файла
file -i text.txt

# Конвертировать
iconv -f windows-1251 -t utf-8 input.txt > output.txt
iconv -l | grep -i utf   # список поддерживаемых кодировок

# Посмотреть байты
hexdump -C text.txt | head -20
xxd text.txt | head -20

# Текущая локаль
locale
echo $LANG  # обычно ru_RU.UTF-8

7. Практика: байты, кодировки и ловушки

Mojibake — «кракозябры»

Mojibake (文字化け) — японский термин, обозначающий нечитаемый текст, возникший в результате декодирования байтов с использованием неправильной кодировки. Дословно: «превращение символов».

Классический пример — файл сохранён в Windows-1251 (русский), а открыт как UTF-8 или Latin-1:

s = "Привет"
encoded = s.encode("windows-1251")  # b'\xcf\xf0\xe8\xe2\xe5\xf2'

print(encoded.decode("utf-8", errors="replace"))  # ???????? — замены
print(encoded.decode("latin-1"))    # ÏðèâåÒ   — мусор без ошибок
print(encoded.decode("windows-1251"))  # Привет  — правильно

Опасность Latin-1 в том, что она принимает любой байт (0x00..0xFF), поэтому декодирование всегда «успешно» — но результат бессмысленный. Программа не знает, что что-то пошло не так.

Аналогично одни и те же байты CF F0 E8 E2 E5 F2 в разных кодировках дают:

Кодировка Результат
windows-1251 Привет ✓
latin-1 ÏðèâåÒ
UTF-8 UnicodeDecodeError
CP866 (DOS) ╧ЁЁЁ╓

Байты не хранят свою кодировку

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

Откуда программа узнаёт кодировку:

  • HTTP-заголовок: Content-Type: text/html; charset=utf-8
  • HTML мета-тег: <meta charset="utf-8">
  • XML декларация: <?xml version="1.0" encoding="utf-8"?>
  • BOM (U+FEFF в начале файла) — для UTF-8, UTF-16, UTF-32
  • Договорённость — «этот API всегда возвращает UTF-8»
  • Файловая система — например, исходники Python 3 по умолчанию UTF-8

Если кодировка неизвестна, можно попробовать угадать через статистический анализ:

import chardet

with open("unknown.txt", "rb") as f:
    raw = f.read()

result = chardet.detect(raw)
print(result)  # {'encoding': 'windows-1251', 'confidence': 0.73, 'language': 'Russian'}

Но это именно угадываниеchardet даёт вероятность, а не гарантию. Короткие строки или чистый ASCII угадываются плохо.

Unicode Sandwich

«Unicode Sandwich» — паттерн правильной работы с текстом, описанный Ned Batchelder (Pragmatic Unicode, PyCon 2012):

[ bytes ] → decode → [ unicode ] → encode → [ bytes ]
  вход      сразу     внутри       перед       выход
            на краю   всегда       выходом

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

# ПЛОХО: смешиваем bytes и str
def process(filename):
    data = open(filename, "rb").read()      # bytes
    result = data.replace(b"foo", b"bar")  # работаем с bytes
    open("out.txt", "wb").write(result)    # bytes на выход

# ХОРОШО: Unicode Sandwich
def process(filename):
    # Вход — декодируем сразу
    text = open(filename, encoding="utf-8").read()   # str (unicode)

    # Внутри — только str
    result = text.replace("foo", "bar")

    # Выход — кодируем в последний момент
    open("out.txt", "w", encoding="utf-8").write(result)

В Python 3 это легко: open() в текстовом режиме делает decode/encode автоматически. Важно всегда явно указывать encoding= — иначе используется системная локаль, которая может быть неожиданной:

# Явно лучше неявного
open("file.txt", encoding="utf-8")           # правильно
open("file.txt", encoding="windows-1251")    # правильно (если знаете)
open("file.txt")  # использует locale.getpreferredencoding() — опасно