UTF-16: кодовые единицы, суррогатные пары, BOM и отличия от UTF-8

UTF-16: кодовые единицы, суррогатные пары, BOM и отличия от UTF-8 Полезное

UTF-16 — это кодировка стандарта Unicode, которая записывает номер кодовой точки последовательностью 16-битных блоков (кодовых единиц): один блок на кодовые точки из основной части таблицы и два блока (суррогатную пару) на редкие. Важно не путать два понятия: Unicode — это набор кодовых точек, где у каждого знака есть номер (вида U+042F), а UTF-16 — это одно из правил, как записать этот номер байтами. Ниже я разберу устройство UTF-16, суррогатные пары, порядок байтов и BOM, сравню кодировку с UTF-8 и UTF-32 и покажу все на запускаемых примерах в Python.

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

  • Кодовая точка (code point) — номер знака в таблице Unicode, вида U+042F. Диапазон U+0000-U+10FFFF.
  • Кодовая единица (code unit) — минимальный блок конкретной кодировки. В UTF-16 это 16-битный (2-байтовый) элемент; одна кодовая точка записывается одной или двумя такими единицами.
  • Графемный кластер (grapheme cluster) — видимый пользователем знак. Он может состоять из нескольких кодовых точек: буква плюс комбинируемый знак, emoji плюс модификатор, ZWJ-последовательность.

То, что человек называет «одним символом» на экране, — это графемный кластер; то, что считает len в Python, — кодовые точки; то, что считает length в Java и JavaScript, — кодовые единицы UTF-16. Это три разных числа.

UTF-16: 16-битные кодовые единицы и суррогатные пары

Базовая единица UTF-16 — это 16-битный блок, то есть 2 байта. Все пространство номеров Unicode делится на плоскости, и первая из них — основная многоязычная плоскость (BMP, Basic Multilingual Plane) с номерами от U+0000 до U+FFFF. Это 65 536 позиций: латиница, кириллица, основные европейские и азиатские письменности, знаки пунктуации. Большинство кодовых точек BMP, кроме зарезервированного суррогатного диапазона U+D800-U+DFFF, UTF-16 записывает одной 16-битной единицей — ровно 2 байта. Сами суррогаты не являются самостоятельными кодовыми точками (Unicode scalar values) и в тексте отдельно не встречаются.

Проблема в том, что кодовых точек в Unicode больше, чем помещается в 16 бит. Все, что выше U+FFFF (emoji, редкие иероглифы, исторические письменности, математические знаки), кодируется двумя кодовыми единицами — это и есть суррогатная пара. Диапазон D800-DFFF в Unicode специально не занят: он зарезервирован под суррогаты. Первый блок пары (ведущий, high surrogate) лежит в диапазоне D800-DBFF, второй (замыкающий, low surrogate) — в DC00-DFFF.

Алгоритм разбиения кодовой точки выше U+FFFF на суррогатную пару такой:

  1. Из номера кодовой точки вычесть 0x10000. Останется значение от 0 до 0xFFFFF (20 бит).
  2. Старшие 10 бит сложить с 0xD800 — это ведущий суррогат.
  3. Младшие 10 бит сложить с 0xDC00 — это замыкающий суррогат.

Проверим это на кодовой точке «🐍» (U+1F40D). Посчитаем суррогатную пару вручную и сверим с тем, что даст сам Python.

ch = "🐍"                     # U+1F40D
cp = ord(ch)
print(hex(cp), cp)           # номер кодовой точки

v = cp - 0x10000             # переносим в 20-битное пространство
high = 0xD800 + (v >> 10)    # старшие 10 бит -> ведущий суррогат
low = 0xDC00 + (v & 0x3FF)   # младшие 10 бит -> замыкающий суррогат
print(hex(high), hex(low))
0x1f40d 128013
0xd83d 0xdc0d

Теперь сверим ручной расчет с фактической записью в UTF-16 (порядок байтов big endian, чтобы читались сами кодовые единицы):

b = "🐍".encode("utf-16-be")
print(b.hex())               # четыре байта = две кодовые единицы
print(len(b), "байта")
u1 = int.from_bytes(b[0:2], "big")
u2 = int.from_bytes(b[2:4], "big")
print(hex(u1), hex(u2))      # ведущий и замыкающий суррогаты
d83ddc0d
4 байта
0xd83d 0xdc0d

Расчет совпал: кодовая точка «🐍» занимает 4 байта (две кодовые единицы D83D и DC0D). Отсюда важное свойство UTF-16 — это кодировка переменной длины: кодовая точка занимает либо 2 байта (BMP), либо 4 байта (суррогатная пара). Часто встречается упрощение «UTF-16 — это всегда 2 байта на символ», но оно верно только для BMP; для кодовых точек выше U+FFFF это не так.

UTF-16 vs UTF-8 vs UTF-32

UTF-16 — не единственная кодировка Unicode. У одного и того же набора кодовых точек есть несколько правил записи, и выбор между ними — это компромисс между размером и удобством обработки.

Свойство UTF-8 UTF-16 UTF-32
Размер единицы 1 байт 2 байта 4 байта
Длина кодовой точки 1-4 байта 2 или 4 байта всегда 4 байта
ASCII-совместимость да нет нет
Зависит от порядка байтов нет да (LE/BE) да (LE/BE)
Фиксированная длина нет нет (пары) да
Типичное применение веб, файлы, сеть Windows, Java, JS внутри обработка в памяти

Ключевые различия по пунктам:

  • UTF-8 — переменная длина от 1 до 4 байтов, обратно совместима с ASCII (кодовые точки 0-127 занимают 1 байт). Доминирует в вебе, файлах и сетевых протоколах.
  • UTF-16 — переменная длина 2 или 4 байта. ASCII-текст в ней вдвое тяжелее, чем в UTF-8, зато кодовые точки BMP всегда занимают ровно 2 байта, что упрощает часть операций.
  • UTF-32 — дает одну 32-битную кодовую единицу на кодовую точку и упрощает индексирование по кодовым точкам, но не по графемным кластерам: видимый знак может состоять из нескольких кодовых точек (буква плюс комбинируемый знак, emoji плюс модификатор, ZWJ-последовательность). Памяти при этом тратится больше всего, поэтому в файлах и сети UTF-32 почти не используется.

Сравним размер одних и тех же кодовых точек в трех кодировках. Возьмем латинскую «A», кириллическую «Я», знак евро «€» и emoji «🐍».

for c in ["A", "Я", "€", "🐍"]:
    print(c,
          "utf-8:",  len(c.encode("utf-8")),
          "utf-16:", len(c.encode("utf-16-le")),
          "utf-32:", len(c.encode("utf-32-le")))
A utf-8: 1 utf-16: 2 utf-32: 4
Я utf-8: 2 utf-16: 2 utf-32: 4
€ utf-8: 3 utf-16: 2 utf-32: 4
🐍 utf-8: 4 utf-16: 4 utf-32: 4

Видно правило выбора: для текста с большой долей ASCII (исходный код, HTML, английский текст) экономнее UTF-8. Для текста, где преобладают кодовые точки BMP вне латиницы (например, ряд азиатских письменностей), UTF-16 может дать более компактную запись, потому что там, где UTF-8 тратит 3 байта, UTF-16 обходится 2 байтами. Одна кодовая точка вне BMP, например U+1F40D, занимает 4 байта и в UTF-8, и в UTF-16. При этом многие emoji на экране — это последовательности из нескольких кодовых точек (базовый знак плюс модификатор цвета кожи, флаги, ZWJ-склейки), и такой видимый знак занимает заметно больше 4 байтов.

BOM и порядок байтов (LE/BE)

Раз кодовая единица UTF-16 занимает 2 байта, встает вопрос, в каком порядке эти два байта писать. Есть два варианта:

  • Little endian (LE) — сначала младший байт. Этот порядок используют процессоры семейства x86, а значит и Windows.
  • Big endian (BE) — сначала старший байт. Исторически так делали процессоры SPARC, m68k; этот же порядок принят как «сетевой».

Одна и та же строка в LE и BE даст разную последовательность байтов. Посмотрим на строку «Яz» (кириллическая «Я» = U+042F, латинская «z» = U+007A):

s = "Яz"
print("utf-16-le:", s.encode("utf-16-le").hex())
print("utf-16-be:", s.encode("utf-16-be").hex())
utf-16-le: 2f047a00
utf-16-be: 042f007a

В LE байты внутри каждой пары переставлены (2f04 против 042f). Чтобы получатель понял, какой порядок использован, в начало потока добавляют BOM (byte order mark) — невидимую метку с кодовой точкой U+FEFF. В начале потока UTF-16 байты FE FF обозначают big endian, а FF FE — little endian: сама метка U+FEFF одна, а различает порядок именно то, какой байтовой последовательностью она записалась. Кодовая точка U+FFFE в Unicode специально не отдана ни одному знаку, поэтому спутать метку с реальным текстом нельзя.

Когда вы указываете кодировку просто как utf-16 (без уточнения LE или BE), Python сам добавляет BOM при записи и читает его при чтении. Порядок байтов при этом не фиксирован: кодек utf-16 использует нативный порядок байтов платформы. На little-endian машине (x86, типичный ПК) метка запишется как FF FE, на big-endian — как FE FF. Поэтому нельзя считать какой-то один hex универсальным; проверить порядок своей платформы можно так:

import sys, codecs
print(sys.byteorder, codecs.BOM_UTF16, "Hi".encode("utf-16").hex())
little b'\xff\xfe' fffe48006900

Здесь sys.byteorder показывает порядок платформы, codecs.BOM_UTF16 — метку в этом порядке, а hex — результат кодирования (на этой little-endian машине сначала идет BOM FF FE, затем сами кодовые единицы). На big-endian машине первые байты были бы FE FF.

Тот же кодек с BOM удобно посмотреть на строке подлиннее (пример снят на little-endian машине):

raw = "Otus".encode("utf-16")   # кодировка с BOM, нативный порядок платформы
print(raw.hex())
print("BOM little endian:", raw[:2] == b"\xff\xfe")
print(raw.decode("utf-16"))     # BOM распознан и убран
fffe4f00740075007300
BOM little endian: True
Otus

Первые два байта FF FE — это BOM в порядке little endian (на этой машине), дальше идут сами кодовые единицы. При декодировании через utf-16 метка считывается и в результат не попадает. А вот варианты utf-16-le и utf-16-be BOM не добавляют и не ожидают — порядок байтов вы фиксируете сами. Это стоит помнить: если прочитать файл с BOM как utf-16-le, метка останется в тексте лишней первой кодовой точкой.

Где применяется UTF-16

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

  • Windows. Unicode-интерфейсы Windows используют UTF-16: на нем работают «широкие» (wide) версии функций WinAPI. На современных Windows это UTF-16LE на уровне кодовых единиц API. Поэтому при работе с системными вызовами Windows часто приходится иметь дело именно с UTF-16 (не обобщая это на все внутренние форматы системы).
  • Java. В модели Java String индексы и length() выражены в кодовых единицах UTF-16 (char — это 16-битная единица). Из-за этого одна кодовая точка выше BMP (например, U+1F40D) занимает две единицы, и length() вернет для нее 2, а не 1 — типичный источник ошибок при работе со строками. Это модель API, а не утверждение о физическом представлении строк внутри конкретной JVM.
  • JavaScript. Строки в JavaScript тоже адресуются в кодовых единицах UTF-16, поэтому "🐍".length равен 2 (две кодовые единицы), хотя кодовая точка одна. Чтобы посчитать именно кодовые точки, берут Array.from(text).length (итератор строки идет по кодовым точкам). Но и это не число графемных кластеров: составной emoji или буква с комбинируемым знаком дадут несколько кодовых точек на один видимый знак.

Здесь важно различие с Python. В Python 3 len(str) считает кодовые точки (элементы строки), а не единицы UTF-16. Для U+1F40D это 1, но для составного emoji или буквы с комбинируемым знаком значение будет больше одного — там несколько кодовых точек на один видимый знак. Сравним:

ch = "🐍"
print("len(str):", len(ch))                        # Python: 1 кодовая точка
print("units:", len(ch.encode("utf-16-le")) // 2)  # UTF-16: 2 кодовые единицы
len(str): 1
units: 2

То есть в Python «🐍» — это одна кодовая точка (len = 1), но в модели UTF-16 (а значит, в Java и JavaScript) она занимает две кодовые единицы. Одно и то же значение по-разному считается в зависимости от того, чем мы измеряем: кодовыми точками или кодовыми единицами UTF-16. А число видимых знаков (графемных кластеров) — это третья, еще меньшая величина.

Смежные темы: KOI-8 и ASCII: как устроены кодировки символов, Кодировка данных: кириллица, Кодирование информации: основы.

FAQ

Сколько байтов занимает кодовая точка в UTF-16?
Кодовые точки основной плоскости BMP (U+0000-U+FFFF) — 2 байта, кодовые точки выше нее (emoji, редкие письменности) — 4 байта в виде суррогатной пары. Утверждение «UTF-16 — это всегда 2 байта» верно только для BMP. Видимый знак (графемный кластер) при этом может состоять из нескольких кодовых точек и занимать больше.

Что такое суррогатная пара?
Это две 16-битные кодовые единицы, которыми UTF-16 записывает одну кодовую точку выше U+FFFF. Ведущая единица лежит в диапазоне D800-DBFF, замыкающая — в DC00-DFFF. Сами эти диапазоны в Unicode не отданы никаким знакам.

Чем UTF-16BE отличается от UTF-16LE?
Только порядком двух байтов внутри каждой кодовой единицы: BE пишет сначала старший байт, LE — младший. Windows и процессоры x86 используют LE. Различить их при чтении помогает метка BOM (байты FE FF или FF FE) в начале данных.

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

Выводы

  • UTF-16 — это кодировка Unicode с 16-битной кодовой единицей; Unicode задает номера кодовых точек, а UTF-16 — правило записи этих номеров байтами.
  • Кодовые точки BMP (до U+FFFF, кроме суррогатного диапазона) занимают 2 байта, кодовые точки выше — 4 байта в виде суррогатной пары из диапазона D800-DFFF. Это кодировка переменной длины, а не «всегда 2 байта».
  • В отличие от UTF-8, UTF-16 не совместима с ASCII и зависит от порядка байтов (LE или BE), который помогает распознать метка BOM (кодовая точка U+FEFF, байты FE FF или FF FE).
  • UTF-16 — внутренний формат Windows, Java и JavaScript; из-за этого кодовая точка выше BMP там считается за две кодовые единицы (length = 2), тогда как в Python это одна кодовая точка (len = 1). Число видимых знаков (графемных кластеров) может быть еще меньше — один знак иногда состоит из нескольких кодовых точек.

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

Хотите уверенно работать со строками, байтами и кодировками на практике и не спотыкаться о суррогатные пары и «кракозябры» в реальном коде? Разберите тему на онлайн-курсе Python Basic в Otus — от основ языка до работы с текстом и файлами.

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

А чтобы познакомиться с форматом обучения и преподавателями, загляните на бесплатные вебинары и открытые уроки Otus.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)