20 серпня 2023 р.

Юнікод, внутрішня будова рядків

Передові знання

В цьому розділі ми зануримося у внутрішню будову рядків. Ці знання знадобляться вам, якщо ви плануєте мати справу з емодзі, рідкісними математичними чи ієрогліфічними символами та іншими винятковими символами.

Як вже відомо, рядки в 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, математичний символ, велика X
alert( '😂'.length ); // 2, обличчя зі сльозами радості
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, 8) і очікувати, що це буде дійсний рядок, наприклад:

alert( 'Привіт 😂'.slice(0, 8) ); // Привіт [?]

Тут ми можемо побачити незрозумілий символ (першу половину сурогатної пари посмішки) у виведених даних.

Просто пам’ятайте про це, якщо ви хочете надійно працювати із сурогатними парами. Можливо, це не велика проблема, але принаймні ви повинні розуміти, що відбувається.

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

У багатьох мовах є символи, які складаються з основного символу та знаку над/під ним.

Наприклад, літера 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() фактично об’єднує послідовність із 3 символів в один: \u1e68 (S з двома крапками).

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

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

Насправді, це не завжди так. Причина в тому, що символ є “достатньо поширеним”, тому творці Юнікоду включили його в основну таблицю та дали йому окремий код.

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

Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)