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 на суррогатную пару такой:
- Из номера кодовой точки вычесть 0x10000. Останется значение от 0 до 0xFFFFF (20 бит).
- Старшие 10 бит сложить с 0xD800 — это ведущий суррогат.
- Младшие 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.



