21 октября 2022 г.

Юникод, внутреннее устройство строк

Глубокое погружение в тему

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

Как мы уже знаем, строки в JavaScript основаны на Юникоде: каждый символ представляет из себя последовательность байтов из 1-4 байтов.

JavaScript позволяет нам вставить символ в строку, указав его шестнадцатеричный Юникод с помощью одной из этих трех нотаций:

  • \xXX

    Вместо XX должны быть указаны две шестнадцатеричные цифры со значением от 00 до FF. В этом случае \xXX – это символ, Юникод которого равен XX.

    Поскольку нотация \xXX поддерживает только две шестнадцатеричные цифры, ее можно использовать только для первых 256 символов Юникода.

    Эти 256 символов включают в себя латинский алфавит, большинство основных синтаксических символов и некоторые другие. Например, "\x7A" – это то же самое, что "z" (Юникод U+007A).

    alert( "\x7A" ); // z
    alert( "\xA9" ); // ©, символ авторского права
  • \uXXXX

    Вместо XXXX должны быть указаны ровно 4 шестнадцатеричные цифры со значением от 0000 до FFFF. В этом случае \uXXXX – это символ, Юникод которого равен XXXX.

    Символы со значениями Юникода, превышающими U+FFFF, также могут быть представлены с помощью этой нотации, но в таком случае нам придется использовать так называемую суррогатную пару (о ней мы поговорим позже в этой главе).

    alert( "\u00A9" ); // ©, то же самое, что \xA9, используя 4-значную шестнадцатеричную нотацию
    alert( "\u044F" ); // я, буква кириллического алфавита
    alert( "\u2191" ); // ↑, символ стрелки вверх
  • \u{X…XXXXXX}

    Вместо X…XXXXXX должно быть шестнадцатеричное значение от 1 до 6 байт от 0 до 10FFFF (максимальная точка кода, определенная стандартом Юникод). Эта нотация позволяет нам легко представлять все существующие символы Юникода.

    alert( "\u{20331}" ); // 佫, редкий китайский иероглиф (длинный Юникод)
    alert( "\u{1F60D}" ); // 😍, символ улыбающегося лица (ещё один длинный Юникод)

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

Все часто используемые символы имеют 2-байтовые коды (4 шестнадцатеричные цифры). В большинстве европейских языков буквы, цифры и основные унифицированные идеографические наборы CJK (CJK – от китайской, японской и корейской систем письма) имеют 2-байтовое представление.

Изначально JavaScript был основан на кодировке UTF-16, которая предусматривала только 2 байта на один символ. Однако 2 байта допускают только 65536 комбинаций, и этого недостаточно для всех возможных символов Юникода.

Поэтому редкие символы, требующие более 2 байт, кодируются парой 2-байтовых символов, которые называются «суррогатной парой».

Побочным эффектом является то, что длина таких символов равна 2:

alert( '𝒳'.length ); // 2, MATHEMATICAL SCRIPT CAPITAL X
alert( '😂'.length ); // 2, FACE WITH TEARS OF JOY
alert( '𩷶'.length ); // 2, редкий китайский иероглиф

Это происходит потому, что суррогатные пары не существовали в то время, когда был создан JavaScript, и поэтому они не обрабатываются языком корректно.

На самом деле в каждой из приведенных строк у нас по одному символу, но свойство length показывает длину 2.

Получить такой символ также может быть непросто, поскольку большинство языковых функций рассматривают суррогатные пары как два символа.

Например, здесь мы видим два странных символа в выводе:

alert( '𝒳'[0] ); // показывает странные символы...
alert( '𝒳'[1] ); // ...части суррогатной пары

Части суррогатной пары не имеют никакого значения друг без друга.

Технически, суррогатные пары также можно определить по их кодам: если символ имеет код в интервале 0xd800...0xdbff, то он является первой частью суррогатной пары. Следующий символ (вторая часть) должен иметь код в интервале 0xdc00...0xdfff. Эти интервалы зарезервированы стандартом исключительно для суррогатных пар.

Поэтому для работы с суррогатными парами в JavaScript были добавлены методы String.fromCodePoint и str.codePointAt.

По сути, они аналогичны String.fromCharCode и str.charCodeAt, но они правильно обрабатывают суррогатные пары.

Здесь можно увидеть разницу:

// charCodeAt не учитывает суррогатные пары, поэтому он выдает коды для 1-й части 𝒳:

alert( '𝒳'.charCodeAt(0).toString(16) ); // d835

// codePointAt учитывает суррогатные пары
alert( '𝒳'.codePointAt(0).toString(16) ); // 1d4b3, считывает обе части суррогатной пары

При этом, если брать с позиции 1 (а это здесь скорее неверно), то они оба возвращают только 2-ю часть пары:

alert( '𝒳'.charCodeAt(1).toString(16) ); // dcb3
alert( '𝒳'.codePointAt(1).toString(16) ); // dcb3
// бессмысленная 2-я половина пары

Другие способы работы с суррогатными парами вы найдете в главе Перебираемые объекты. Возможно, для этого тоже существуют специальные библиотеки, но они не настолько известные, чтобы предлагать их в учебнике.

Разделение строки в случайном месте может быть опасным!

Разделив строку в случайном месте, например, с помощью str.slice(0, 4), мы не можем гарантировать валидность полученного значения. Например:

alert( 'hi 😂'.slice(0, 4) ); //  hi [?]

Здесь мы видим мусорный символ (первая половина суррогатной пары 😂) в выводе.

Просто имейте это в виду, если вы намерены надежно работать с суррогатными парами. Может быть, это не очень большая проблема, но, по крайней мере, вы должны понимать, что происходит.

Диакритические знаки и нормализация

Во многих языках есть символы, состоящие из основного символа и знака над/под ним.

Например, буква a может быть основой для этих символов: àáâäãåā.

Большинство распространенных «составных» символов имеют свой собственный код в таблице Юникода. Но не все, потому что существует слишком большое количество возможных комбинаций.

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

Например, если за S следует специальный символ «точка сверху» (код \u0307), то он отобразится как Ṡ.

alert( 'S\u0307' ); // Ṡ

Если нам нужен дополнительный знак над буквой (или под ней) – нет проблем, просто добавляем соответствующий символ.

Например, если мы добавим символ «точка снизу» (код \u0323), то получим «S с точками сверху и снизу»: .

Вот, как это будет выглядеть:

alert( 'S\u0307\u0323' ); // Ṩ

Это обеспечивает большую гибкость, но при этом возникает определенная проблема: два символа могут визуально выглядеть одинаково, но при этом они будут представлены разными комбинациями Юникода.

Например:

let s1 = 'S\u0307\u0323'; // Ṩ, S + точка сверху + точка снизу
let s2 = 'S\u0323\u0307'; // Ṩ, S + точка снизу + точка сверху

alert( `s1: ${s1}, s2: ${s2}` );

alert( s1 == s2 ); // false, хотя символы выглядят одинаково (?!)

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

Его реализует метод str.normalize().

alert( "S\u0307\u0323".normalize() == "S\u0323\u0307".normalize() ); // true

Забавно, но в нашем случае normalize() «схлопывает» последовательность из трёх символов в один: \u1e68 — S с двумя точками.

alert( "S\u0307\u0323".normalize().length ); // 1

alert( "S\u0307\u0323".normalize() == "\u1e68" ); // true

В действительности это не всегда так. Причина в том, что символ является «достаточно распространенным», поэтому создатели стандарта Юникод включили его в основную таблицу и присвоили ему код.

Если вы хотите узнать больше о правилах и вариантах нормализации – они описаны в дополнении к стандарту Юникод: Unicode Normalization Forms, но для большинства практических целей достаточно информации из этого раздела.

Карта учебника