Статья 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 (🌍)¶
- Кодовая точка:
0x1F30D=0001 1111 0011 0000 1101(21 бит) - Шаблон 4 байт:
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx - Разбиваем биты:
000|011111|001100|001101 - Собираем:
11110000100111111000110010001101 - 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':
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-endianFE FF— big-endian
Где используется UTF-16?¶
- Windows API (нативно)
- Java
String, JavaScriptString(внутреннее представление) - .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= — иначе используется системная локаль, которая может быть неожиданной: