Почему проблема порядка байтов есть в UTF-16, но её нет в UTF-8?

Nick Volynkin

Я прочитал, что в UTF-16 два различных порядка байтов (endianness) появились потому, что два различных порядка байтов существуют в архитектуре процессоров:

Систему, совместимую с процессорами x86, называют little endian, а с процессорами m68k и SPARC — big endian.

То есть одно и то же число 0x1234ABCD кодируется последовательностью байтов:

  • little endian: 12 34 56 78
  • big endian: 78 56 34 12

Соответственно, при раскодировании последовательности байт в последовательность чисел (или code point'ов юникода) нужно учитывать использованный при кодировании порядок байтов. (Это несколько дилетантское утверждение, но лучше сформулировать я пока не могу).

Например, если мы кодируем "Привет ?" в UTF-16:

# big endian:
П      р      и      в      е      т      ( )    ?
04 1F  04 40  04 38  04 32  04 35  04 42  00 20  D8 3D DE 03

# little endian:
П      р      и      в      е      т      ( )    ?
1F 04  40 04  38 04  32 04  35 04  42 04  20 00  03 DE D3 D8

Вроде бы всё очевидно. Мы сопоставляем code point'y некоторое число согласно алгоритму кодировки, а потом записываем это число в соответствии с порядком байт, принятым в системе.

Теперь UTF-8:

П      р      и      в      е      т      ( ) ?
D0 9F  D1 80  D0 B8  D0 B2  D0 B5  D1 82  20  F0 9F 98 83

# в двоичной системе счисления:
11010000 10011111 
11010001 10000000 
11010000 10111000 
11010000 10110010 
11010000 10110101 
11010001 10000010 
# по первому биту сразу видно, что этот code point закодирован одним байтом
00100000 
# а здесь первый байт начинается с 4 единиц, значит будет 3 trailing byte'а
11110000 10011111 10011000 10000011

Алгоритм кодировки поменялся, но архитектура процессора осталась прежней! Мы по прежнему получаем число, которое занимает от 1 до 4х байт. Почему с UTF-8 нас не беспокоит, что байты будут записаны вот так?

П      р      и      в      е      т      ( ) ?
9F D0  80 D1  B8 D0  B2 D0  B5 D0  82 D1  20  83 98 9F F0

Дополнение:

Задавая этот вопрос, я уже знал, что UTF-8 использует однобайтовые code unit'ы, а UTF-16 – двухбайтовые. Попробую уточнить, что мне было непонятно.

Есть символ «?». При кодировании его в алгоритме UTF-8 получается последовательность байт F0 9F 98 83. Это тоже число, четырёхбайтовое слово, его можно использовать для сравнения или сортировки строк, закодированных в UTF-8 (правда, толку от такой сортировки немного). В вышеуказанном виде оно имеет порядок big-endian, значит системы с архитектурой big-endian могут получить преимущество в работе с ним. Но что с little-endian? Как там будет происходить сравнение? Для примера, будем сравнивать «?» (F0 9F 98 83) и «?» (F0 9F 98 90). У меня есть два предположения:

  1. Big-endian системы работают с закодированными в UTF-8 символами, как с 1, 2, 3, 4-байтными словами и получают преимущество в скорости операций. То есть, в них достаточно сравнить F09F9883 и F09F9890 как четырехбайтовые слова. Little-endian системы вынуждены сравнивать побайтно или переворачивать слово дважды.
  2. Любая архитектура работает с закодированными в UTF-8 символами строго как с последовательностями байт, не оперируя словами более 1 байта. То есть, сравниваются пары байт: FO и FO, 9F и 9F, 98 и 98, 83 и 90. При этом теряется потенциальное преимущество от сравнения двух слов, зато для любой архитектуры алгоритм работает одинаково.
5 ответов

Nick Volynkin

Дело в том, что UTF-8 и UTF-16 обычно хранится в памяти нераспакованным, в том же виде, как он приходит в потоке (например, в файле). [Ну и если он таки распаковывается, то это рассмотрение играет роль в момент распаковки.]

Само по себе хранение никакой проблемы, понятно, не создаёт. Проблему создаёт обработка, например, сравнение символов.

В UTF-8 вы читаете входной поток по байту, и интерпретируете их последовательно. Соответственно получившееся значение code point получается однозначно и не зависит от порядка байтов машины: результат приведения к code point однозначно определён, и при сравнении используется именно он.

А вот в UTF-16 вы читаете входной поток по два байта, и для сравнения в обычном случае вовсе не нужно вычислять code point. Если у вас есть двухбайтное слово в нативной кодировке, не соответствующее суррогатной паре (а это основной, самый частый случай), то для сравнение можно просто использовать её значение, она равна своему code point. Но если кодировка не нативная, вам понадобится переставить байты.

Если бы в UTF-16 был задан конкретный порядок байт, составляющих двойной байт (и тем самым задана endianness), то платформы, на которых данный порядок не является нативным, оказались бы в проигрыше: они должны были бы совершать дополнительные действия (перестановку байт) при чтении и записи потока. С двумя вариантами кодировки приложения могут пользоваться тем форматом, который нативен на их платформе, получая тем самым выигрыш в скорости.

Держать байты в памяти в ненативном порядке — плохая идея: их получается намного затратнее сортировать и сравнивать. С нативным порядком в обычном случае нужна лишь проверка на суррогатную пару, а с ненативным ещё и перестановка байтов. Например, для сравнения 1C 55 и 1B 77 в big endian-смысле на little endian-системе не обойтись без перестановки байт. Потому что если сравнивать без перестановки, то будут сравниваться 0x551C и 0x771B, и результат будет неверным. То же и для сортировки.

Обновление ответа к обновлению вопроса.

Насколько я понимаю, при обработке UTF-8 мы не знаем наперёд, сколько байт будет занимать тот или иной символ. Поэтому мы вынуждены работать с потоком байт, а не потоком нативных слов. Если бы мы знали, что наш символ всегда кодируется четырьмя байтами, мы могли бы или просто сравнить нативным образом,или для неподходящей байтоориентации скопировать оба четырёхбайтных слова во временные переменные, развернуть их и сравнить нативным сравнением четырёхбайтных слов. Но этому мешает ещё и то, что наши четыре байта находятся на случайной позиции в потоке, и значит, скорее всего не выровнены на границу 4-ёх байт. На многих архитектурах (кроме, однако, x86) такой доступ не разрешён, и придётся «выковыривать» байты по частям. Таким образом, получается проще и эффективнее просто сравнить байты по одному.

В UTF-16, кстати, возможных случаев меньше: там возможен либо символ из одного codepoint'а, который можно сравнивать нативно или с одним разворотом, если не угадали с порядком байт, либо из двух (где наверное лучше снова-таки сравнить два раза по двухбайтному слову).


Nick Volynkin

Почему проблема порядка байтов есть в UTF-16, но её нет в UTF-8?

Потому что code unit равен 8 битам (одному байту) в UTF-8 и 16 битам (двум байтам) в UTF-16. В зависимости от порядка байт внутри code unit, есть utf-16le и utf-16be кодировки, которые могут быть использованы на одном и том же компьютере вне зависимости от endianness CPU):

>>> 'я'.encode('utf-16le').hex()
'4f04'
>>> 'я'.encode('utf-16be').hex()
'044f'

Символ я (U+44f) кодируется в UTF-16 в одно и то же 16-битное число: 1103 == 0x44f, что для utf-16 совпадает с номером (Unicode code point) символа в Юникоде (для BMP символов). Само 16-битное число может быть в памяти представлено в виде двух байт: 4f 04 (от младшего к старшему порядок байт) или 04 4f (от старшего к младшему порядок байт).

>>> 'я'.encode('utf-8').hex()
'd18f'

я (U+44f) кодируется в UTF-8, используя два 8-битных числа 209 == 0xd1 и 143 == 0x8f. В общем случае UTF-8 может использовать от 1 до 4 октетов (8-битных чисел) для каждого символа (Unicode code point).

>>> '?'.encode('utf-16le').hex()
'3dd802de'
>>> '?'.encode('utf-16be').hex()
'd83dde02'
>>> '?'.encode('utf-8').hex()
'f09f9882'

Символ ? (U+1f602) кодируется в utf-16, используя два 16-битных слова (utf-16 code units): 0xd83d и 0xde02 (utf-16 суррогатная пара). Представление слова в виде байт зависит от выбранного порядка байт (le, be), но порядок самих слов не меняется.

? (U+1f602) кодируется в utf-8, используя четыре октета (utf-8 code units): 0xf0, 0x9f, 0x98, 0x82. Представление октета в виде 8-битового байта, очевидно, не зависит от порядка (один октет—один байт).

Последовательность code units (октеты для utf-8 и 16-битные слова для utf-16), используемая для кодирования выбранного символа, однозначно определена выбранной кодировкой—в частности нельзя порядок code units менять как в utf-16 так и в utf-8 кодировках.

Оба пункта из дополнения к вопросу у вас неверны. Не нужно путать как результат в виде байт представляется при обмене с внешним миром или внутри разных частей в программе (при записи на диск, отсылке по сети, вызове API) и какие инструкции CPU использует для работы с данными, выполняя конкретный алгоритм. То что октеты нельзя переставлять в utf-8 результате, ещё не значит, что фактические алгоритмы не могут работать с бо́льшими единицами. К примеру, memcpy() очевидно сохраняет порядок байт, при этом фактическая реализация может работать c целыми словами (например, с 64-битными словами).


Nick Volynkin

Сама по себе проблема с endianness возникает из-за разной общепринятой визуализации значений в памяти в и регистрах процессора:

Вот как обычно нумеруют байты в регистре процессора:

[ 12 34 AB CD ]
разряд    3  2  1  0

Старшие разряды пишутся слева - как традиционно сложилось в математике.

В то же время в той же математике сложилась традиция рисовать оси и отрезки и прочие множества слева направо. И все представляют себе память как длинный бесконечный массив байт.

[ ?? ?? ?? ?? ...]
адрес   0  1  2  3

И у разработчиков процессора есть два решения:

  • сохранять 0-й разряд в 0-й адрес, 1-й в 1-й и т.д, пожертвовав визуализацией, но получив выигрыш в скорости и простоте операций типа "вычитать из памяти 0-й байт в 0-й разряд (little-endian)"
  • разворачивать значение при сохранении, ради удобства отладки

В рамках одной системы нет никаких проблем. Главное писать значения в память так же, как вы их оттуда читаете - и можно не думать об endiannes.

И все идет хорошо, пока вам не надо передать строку на другой компьютер (прямо - по сети, или косвенно - как файл). Создатели сетевых протоколов заранее договорились как передавать отдельные байты. Т.е. если вы передадите с x86 по сети байты 1, 2, 3, 4, 5, 6 - то любая моторолла получит их по сети в порядке 1, 2, 3, 4, 5, 6. Это жестко вбито в стандартах, на всех уровнях, от TCP/IP до Ethernet.

А вот насчет передачи пар байт или четверок байт - никаких договоренностей нет.

UTF-8 работает с потоком байт. Предположим, вы хотите записать на диск или переслать "привет" по сети. Это, с точки зрения энкодера, выглядит так:

  • передать D0
  • передать 9F
  • передать D1
  • передать 80 ...

Получающая сторона (стандарт же!) гарантированно прочитает их в том же порядке - D0 9F D1 80...

Опять же, при записи в память по одному байту никаких разворотов не происходит, и в памяти это же значение представляется в виде

[ D0 9F D1 80 ]

Так происходит только потому, что в русском (английском) языке принято писать буквы направо, что случайно совпадает с общепринятой визуализацией памяти.

Поэтому строку UTF-8 достаточно просто записать в память - и она готова к передаче. Это результат договоренностей на уровне сетевых протоколов / протоколов общения с диском - они тоже работают на уровне байт, а не на уровне бит.

Ок, теперь нам захотелось передать тот же привет, но в UTF-16:

0x041F  0x0440  0x0438  0x0432  0x0435  0x0442

Энкодер в UTF-16 не заморачивается и передает эти двухбайтовые слова по сети/в память/на диск. По слову за раз. И ожидает что первое слово будет передано/записано первым, второе - вторым и т.д.

  • передать 0x041F
  • передать 0x0440
  • передать 0x0438

Как они будут записаны / переданы - зависит от endiansess. Для LE процессор не заморачивается

[ 1F, 04 ] [ 40, 04 ] [ 38, 04]
адрес 0   1      2   3      4   5

Для big endian - он старательно разворачивает каждое слово:

[ 04, 1F ] [ 04, 40 ] [ 04, 38]
адрес 0   1      2   3      4   5

Договоренностей о передаче двухбайтовых слов по сети нет, как и договоренностей о хранении их на диске. Поэтому в UTF-16 требуется BOM.

На самом деле та же проблема существует с битами при использовании UTF-8 (да и вообще при передаче любых байт куда угодно). Например, вы хотите передать по сети байт D0. Он же 11010000. Вы будете передавать его как 0, 0, 0, 0, 1, 0, 1, 1? Или как 1, 1, 0, 1, 0, 0, 0, 0?

Вы с этой проблемой не сталкиваетесь по разным причинам: - Отсутствует необходимость визуализации бит при хранениии. - Закрыт доступ к реальному формату хранения на физическом уровне (память и диск не позволяют адресовать и читать отдельные биты). - Жесткая стандартизация - заранее согласованный порядк передачи бит в рамках байта в конкретном сетевом протоколе позволяет вам работать на уровне байт.

Достаточно взять любой способ передачи, в котором насчет порядка бит виден (например, попробуйте сделать железку, которая через COM-порт) - и проблема себя проявит.


Nick Volynkin

То есть одно и то же число 0x1234ABCD кодируется последовательностью байтов:

  • little endian: 12 34 56 78
  • big endian: 78 56 34 12

Соответственно, при раскодировании ...

Нет никакого раскодирования. Есть два представления числа в памяти.

http://ideone.com/wsOkXK

#include <cstdio>

int main()
{
    volatile int x = 0x1234ABCD;
    const unsigned char *p = (unsigned char *)&x;

    printf("%0*X\n", sizeof(int) << 1, x);

    for (unsigned q=0; q</cstdio>
1234ABCD
CDAB3412

В UTF16 очень хотелось сделать "как записано в файле, так в память и прочитали в массив int16" - вот и получилось 2 варианта. А в UTF8 символы имеют разную длину и на целочисленные типы никак не мапятся - вот и не было причин получать два представления, потому что это просто байты. Да и вообще, парсить последовательность переменной длины с конца - это какое-то извращение :)


Nick Volynkin

В обоих случаях порядок выборки из памяти чисел, кодирующих символ, одинаковый. В случае UTF-8 эти числа однобайтовые и проблемы старшего и младшего байтов, по понятной причине, не возникает. Когда же речь заходит об UTF-16, оказывается, что в разных архитектурах порядок записи байтов в память разный. Например, в архитектуре Intel первым идёт младший байт, а в ARM - старший.

licensed under cc by-sa 3.0 with attribution.