Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8

Якщо ви розробник і перед вами стоїть завдання вибору кодування, то завжди правильним рішенням буде Юнікод. Конкретний спосіб уявлення залежить від контексту, але найчастіше тут також є універсальна відповідь — UTF-8. Він хороший тим, що дозволяє використовувати всі символи Юнікоду, не витрачаючи занадто багато байт у більшості випадків. Щоправда, для мов, які використовують не лише латиницю, «не надто багато» — це як мінімум два байти на символ. Чи можна краще, не повертаючись до доісторичних кодувань, які обмежують нас лише 256 доступними символами?

Нижче пропоную ознайомитися з моєю спробою дати відповідь на це питання та реалізацію щодо простого алгоритму, що дозволяє зберігати рядки на більшості мов світу, не додаючи тієї надмірності, яка є в UTF-8.

Дисклеймер. Відразу зроблю кілька важливих застережень: описане рішення не пропонується як універсальна заміна UTF-8, воно підходить лише у вузькому списку випадків (про них нижче), і його в жодному разі не можна використовувати для взаємодії зі сторонніми API (які про нього і знати не знають). Найчастіше для компактного зберігання великих обсягів текстових даних підійдуть алгоритми стиснення загального призначення (наприклад, deflate). Крім того, вже в процесі створення свого рішення я знайшов існуючий стандарт у самому Юнікоді, який вирішує те саме завдання — він дещо складніший (і нерідко гірший), але все-таки є прийнятим стандартом, а не зібраним на колінці. Про нього я також розповім.

Про Unicode та UTF-8

Для початку – кілька слів про те, що взагалі таке Unicode и UTF-8.

Як відомо, раніше мали популярність 8-бітове кодування. З ними все було просто: 256 символів можна занумерувати числами від 0 до 255, а числа від 0 до 255 очевидно представлені у вигляді одного байта. Якщо повертатися до витоків, то кодування ASCII взагалі обмежується 7 бітами, тому найстарший біт у її байтовому поданні дорівнює нулю, і більшість 8-бітових кодувань із нею сумісні (вони різняться лише у «верхній» частини, де старший біт — одиниця ).

Чим від тих кодувань відрізняється Юнікод і чому з ним пов'язана відразу безліч конкретних уявлень - UTF-8, UTF-16 (BE і LE), UTF-32? Розберемося по порядку.

Основний стандарт Юнікод описує лише відповідність між символами (а в деяких випадках - окремими компонентами символів) та їх номерами. І можливих номерів у цьому стандарті дуже багато – від 0x00 до 0x10FFFF (1 штук). Якби ми хотіли покласти число в такому діапазоні змінну, ні 114, ні 112 байт нам би не вистачило. А оскільки під роботу з трибайтовими числами наші процесори не дуже заточені, ми були б змушені використовувати цілих 1 байти на один символ! Це і є UTF-2, але саме через цю «марнотратність» цей формат не користується популярністю.

На щастя, символи всередині Юнікод впорядковані не випадково. Всі їх безліч поділено на 17площин», кожна з яких містить 65536 (0x10000) «кодових точок». Поняття «кодової точки» тут – це просто номер символу, наданий йому Юнікодом. Але, як було сказано вище, в Юнікоді занумеровані не лише окремі символи, але також їх компоненти та службові позначки (а іноді й зовсім номеру нічого не відповідає — можливо, до певного часу, але для нас це не так важливо), тому коректніше завжди говорити саме про кількість самих номерів, а не символів. Однак далі для стислості я часто вживатиму слово «символ», маючи на увазі термін «кодова точка».

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8
Площини Юнікод. Як видно, більшість (площини з 4 по 13) все ще не використана.

Що найпрекрасніше - вся основна «м'якотка» лежить у нульовій площині, вона називається "Основна багатомовна площинаЯкщо рядок містить текст однією з сучасних мов (включаючи китайську), за межі цієї площини ви не вийдете.Supplementary Multilingual Plane(Вона простягається від 0x10000 до 0x1FFFF). Тому UTF-16 надходить так: всі символи, що потрапляють у Основна багатомовна площинакодуються «як є», відповідним їм двобайтовим числом. Однак частина чисел у цьому діапазоні взагалі не позначають конкретні символи, а вказують на те, що слідом за цією парою байт потрібно розглянути ще одну – скомбінувавши значення цих чотирьох байт разом, у нас вийде число, що охоплює весь допустимий діапазон Юнікоду. Таке уявлення називається "сурогатними парами" - можливо, ви про них чули.

Таким чином, UTF-16 вимагає два або (у дуже поодиноких випадках) чотири байти на одну «кодову точку». Це краще, ніж постійно використовувати чотири байти, але латиниця (та інші ASCII-символи) при такому кодуванні витрачає половину місця на нулі. UTF-8 покликаний це виправити: ASCII в ньому займає, як раніше, всього один байт; коди від 0x80 до 0x7FF - Два байти; від 0x800 до 0xFFFF — три, а від 0x10000 до 0x10FFFF - чотири. З одного боку, латиниці стало добре: повернулася сумісність з ASCII, та й розподіл більш рівномірно розмазано від 1 до 4 байт. Але алфавіти, відмінні від латинського, на жаль, ніяк не виграють в порівнянні з UTF-16, а багато і зовсім тепер вимагають трьох байт замість двох - діапазон, що покривається двобайтовим записом, звузився в 32 рази, з 0xFFFF до 0x7FF, і в нього не потрапляє вже ні китайська, ні, наприклад, грузинська. Кирилиці та ще п'яти алфавітам - ура - пощастило, 2 байти на символ.

Чому так виходить? Давайте подивимося, як UTF-8 представляє коди символів:
Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8
Безпосередньо для представлення чисел тут використані біти, позначені символом x. Видно, що у двобайтовому записі таких біт лише 11 (із 16). Провідні біти тут несуть лише службову функцію. У випадку із чотирибайтовим записом і зовсім під номер кодової точки відведено 21 біт із 32 — здавалося б, тут вистачило б і трьох байт (які дають сумарно 24 біти), але службові маркери з'їдають дуже багато.

Чи це погано? Насправді не дуже. З одного боку — якщо ми дуже дбаємо про займаний простір, ми маємо алгоритми стиснення, які легко усунуть всю зайву ентропію та надмірність. З іншого боку, метою Юнікоду було дати максимально універсальне кодування. Наприклад, закодований в UTF-8 рядок ми можемо довірити коду, який раніше працював тільки з ASCII, і не боятися, що він там побачить символ з ASCII-діапазону, якого там насправді немає (адже в UTF-8 всі байти, що починаються з нульового біта – це саме ASCII і є). А якщо ми захочемо раптом відрізати маленький хвіст від великого рядка, не декодуючи її від початку (або відновити частину інформації після пошкодженої ділянки) — нам нескладно знайти те зсув, де починається якийсь символ (достатньо пропустити байти, що мають бітовий префікс) 10).

Навіщо тоді вигадувати щось нове?

У той же час, зрідка бувають ситуації, коли алгоритми стиснення на зразок deflate погано застосовні, а домогтися компактного зберігання рядків хочеться. Особисто я зіткнувся з таким завданням, розмірковуючи про побудову стиснутого префіксного дерева для великого словника, що включає слова довільними мовами. З одного боку, кожне слово дуже коротке, тому стискати його буде неефективно. З іншого — реалізація дерева, яку я розглядав, була розрахована на те, що кожен байт рядка, що зберігається, породжував окрему вершину дерева, так що мінімізувати їх кількість було дуже корисно. У моїй бібліотеці Az.js (як і в pymorphy2, На якому вона заснована) подібна проблема вирішується просто - рядки, упаковані в DAWG-словник, зберігаються там у старої доброї CP1251. Але, як неважко зрозуміти, це добре працює тільки для обмеженого алфавіту — рядок китайською в такий словник уже не скласти.

Окремо зазначу ще один неприємний нюанс, який виникає при використанні UTF-8 у такій структурі даних. На малюнку вище видно, що з запису символу як двох байт, біти, які стосуються його номеру, не йдуть поспіль, а розірвані парою біт 10 посередині: 110xxxxx 10xxxxxx. Через це, коли в коді символу переповнюються молодші 6 біт другого байта (тобто відбувається перехід 1011111110000000), то змінюється і перший байт теж. Виходить, що буква «п» позначається байтами. 0xD0 0xBF, а наступна за нею "р" - вже 0xD1 0x80. У префіксному дереві це призводить до розщеплення батьківської вершини на дві — однієї префіксу. 0xD0, та інший для 0xD1 (хоча вся кирилиця могла б кодуватися лише другим байтом).

Що в мене вийшло

Зіткнувшись із цим завданням я вирішив повправлятися в іграх з бітами, а заразом трохи краще познайомитися зі структурою Юнікоду в цілому. Результатом став формат кодування UTF-C (C від компактний), який витрачає не більше 3 байт на одну кодову точку, а дуже часто дозволяє витрачати лише один зайвий байт на весь кодований рядок. Це призводить до того, що на багатьох не-ASCII алфавітах таке кодування виявляється на 30-60% компактніший за UTF-8.

Я оформив приклади реалізації алгоритмів кодування та декодування у вигляді бібліотек на JavaScript та GoВи можете вільно використовувати їх у своєму коді. Але я все ж таки підкреслю, що в певному сенсі цей формат залишається «велосипедом», і я не рекомендую його використовувати без усвідомлення того, навіщо він вам потрібен. Це все-таки більше експеримент, ніж серйозне покращення UTF-8. Тим не менш, код там написаний акуратно, лаконічно, з великою кількістю коментарів та покриттям тестами.

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8
Результат виконання тестів та порівняння з UTF-8

Ще я зробив демо-сторінку, де можна оцінити роботу алгоритму, а далі я розповім докладніше про його принципи та процес розробки.

Усуваємо надлишкові біти

За основу я взяв, звісно, ​​UTF-8. Перше і найочевидніше, що у ньому можна поміняти — зменшити кількість службових біт у кожному байті. Наприклад, перший байт в UTF-8 завжди починається або з 0, Або з 11 - А префікс 10 є лише у наступних байт. Замінимо префікс 11 на 1, А у наступних байт приберемо префікси зовсім. Що вийде?

0xxxxxxx - 1 байт
10xxxxxx xxxxxxxx - 2 байти
110xxxxx xxxxxxxx xxxxxxxx - 3 байти

Стоп, а де ж чотирибайтовий запис? А вона стала не потрібна - при записі трьома байтами у нас тепер доступний 21 біт і цього із запасом вистачає на всі числа до 0x10FFFF.

Чим ми пожертвували тут? Найголовніше - виявлення меж символів з довільного місця буфера. Ми не можемо тицьнути в довільний байт і від нього знайти початок наступного символу. Це обмеження нашого формату, але на практиці необхідність у такому постає нечасто. Зазвичай ми здатні пробігти буфер із самого початку (особливо коли йдеться про короткі рядки).

Ситуація з покриттям мов 2 байтами теж стала кращою: тепер двобайтовий формат дає діапазон 14 біт, а це коди до 0x3FFF. Китайцям не щастить (їхні ієрогліфи в основному лежать в діапазоні від 0x4E00 до 0x9FFF), але ось грузинам і багатьом іншим народам стало веселіше - їхні мови теж укладаються в 2 байти на символ.

Вводимо стан енкодера

Давайте тепер подумаємо про властивості самих рядків. У словнику найчастіше лежать слова, написані символами одного алфавіту, та й багатьох інших текстів це теж правильно. Було б добре один раз вказати цей алфавіт, а далі вказувати номер літери всередині нього. Подивимося, чи допоможе нам розташування символів у таблиці Юнікод.

Як було сказано вище, Юнікод поділений на площині по 65536 XNUMX кодів кожна. Але це не дуже корисний поділ (як уже сказано, найчастіше ми знаходимося в нульовій площині). Цікавішим є поділ на блоки. Ці діапазони вже не мають фіксованої довжини, і несуть більше сенсу, як правило, кожен поєднує символи одного алфавіту.

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8
Блок містить символи бенгальського алфавіту. На жаль, з історичних причин це приклад не дуже щільної упаковки — 96 символів хаотично розкидані по 128 кодових точках блоку.

Початки блоків та їх розміри завжди кратні 16 – зроблено це просто для зручності. Крім того, багато блоків починаються і закінчуються на значеннях, кратних 128 або навіть 256 - наприклад, основна кирилиця займає 256 байт від 0x0400 до 0x04FF. Це досить зручно: якщо ми один раз збережемо префікс 0x04, то далі будь-який кириличний символ можна записувати одним байтом. Щоправда, так ми втратимо можливість повернутися до ASCII (і будь-яких інших символів взагалі). Тому робимо так:

  1. Два байти 10yyyyyy yxxxxxxx не лише позначають символ із номером yyyyyy yxxxxxxx, але й змінюють поточний алфавіт на yyyyyy y0000000 (тобто запам'ятовуємо всі біти, крім молодших 7 біт);
  2. Один байт 0xxxxxxx це символ поточного абетки. Його потрібно просто скласти з тим усуненням, яке ми запам'ятали на кроці 1. Поки алфавіт ми не змінювали, зсув нулю, так що сумісність з ASCII ми зберегли.

Аналогічно для кодів, що вимагають 3 байт:

  1. Три байти 110yyyyy yxxxxxxx xxxxxxxx позначають символ із номером yyyyyy yxxxxxxx xxxxxxxx, змінюють поточний алфавіт на yyyyyy y0000000 00000000 (запам'ятали все, крім молодших 15 біт), і ставлять прапорець, що ми тепер у довгому режимі (при зміні алфавіту назад на двобайтовий прапорець ми скинемо);
  2. Два байти 0xxxxxxx xxxxxxxx у довгому режимі це символ поточного алфавіту. Аналогічно ми складаємо його зі зміщенням з кроку 1. Вся різниця тільки в тому, що тепер ми читаємо по два байти (тому що ми переключилися в такий режим).

Звучить непогано: тепер поки що нам потрібно кодувати символи з одного і того ж 7-бітного діапазону Юнікоду, ми витрачаємо 1 зайвий байт на початку і всього по байту на кожен символ.

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8
Робота однієї із ранніх версій. Вже часто обходить UTF-8, але ще є що покращувати.

Що стало гірше? По-перше, у нас з'явився стан, а саме усунення поточного алфавіту та прапорець довгого режиму. Це нас додатково обмежує: тепер ті самі символи можуть бути закодовані по-різному в різних контекстах. Пошук підрядків, наприклад, доведеться робити вже з урахуванням цього, а не просто порівнюючи байти. По-друге, як тільки ми змінили алфавіт, стало погано з кодуванням ASCII-символів (а це не тільки латиниця, а й базова пунктуація, включаючи прогалини) — вони вимагають повторної зміни алфавіту в 0, тобто знову зайвого байта (а потім ще одного, щоб повернутися до нашого основного).

Один алфавіт добре, два - краще

Спробуємо трохи змінити наші бітові префікси, втиснувши до трьох вищеописаних ще один:

0xxxxxxx - 1 байт у звичайному режимі, 2 у довгому
11xxxxxx - 1 байт
100xxxxx xxxxxxxx - 2 байти
101xxxxx xxxxxxxx xxxxxxxx - 3 байти

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8

Тепер у двобайтовому записі на один доступний біт поменшало — поміщаються кодові точки аж до 0x1FFF, А не 0x3FFF. Втім, все ще відчутно більше, ніж у двобайтових кодах UTF-8, більшість поширених мов все ще влазить, найпомітніша втрата - випала хірагана и катакана, японці в смутку.

Що ж за новий код 11xxxxxx? Це невеликий «загашник» розміром 64 символи, він доповнює наш основний алфавіт, тому я назвав його допоміжним (допоміжний) алфавітом. Коли ми перемикаємо поточний алфавіт, шматок старого алфавіту стає допоміжним. Наприклад, переключилися з ASCII на кирилицю — у «загашнику» тепер 64 символи, що містять латиницю, цифри, пробіл і кому (найчастіші вставки в не-ASCII текстах). Перейшли назад на ASCII - і допоміжним алфавітом стане основна частина кирилиці.

Завдяки доступу до двох алфавітів, ми можемо впоратися з великою кількістю текстів, маючи мінімальні витрати на перемикання алфавітів (пунктуація найчастіше призводитиме до повернення в ASCII, але після цього багато не-ASCII символи ми діставатимемо вже з додаткового алфавіту, без повторного перемикання ).

Бонус: позначивши допалфавіт префіксом 11xxxxxx і вибравши його початкове усунення рівним 0xC0, у нас виходить часткова сумісність із CP1252. Інакше кажучи, багато (але не всі) західноєвропейські тексти, закодовані в CP1252, будуть виглядати так само і в UTF-C.

Тут, щоправда, виникає складність: як із основного алфавіту отримати допоміжний? Можна залишити те саме зсув, але — на жаль, тут структура Юнікоду вже грає проти нас. Дуже часто основна частина алфавіту знаходиться не на початку блоку (наприклад, російська заголовна «А» має код 0x0410, хоча кириличний блок починається з 0x0400). Таким чином, взявши в "загашник" перші 64 символи, ми, можливо, втратимо доступ до хвостової частини алфавіту.

Для усунення цієї проблеми я вручну пройшовся деякими блоками, що відповідають різним мовам, і вказав для них усунення допоміжного алфавіту всередині основного. Латиницю, як виняток, взагалі переупорядкував на зразок base64.

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8

фінальні штрихи

Давайте насамкінець подумаємо, де ми ще можемо щось допрацювати.

Зауважимо, що формат 101xxxxx xxxxxxxx xxxxxxxx дозволяє закодувати числа аж до 0x1FFFFF, а Юнікод закінчується раніше, на 0x10FFFF. Іншими словами, остання кодова точка буде представлена ​​як 10110000 11111111 11111111. Отже, ми можемо сказати, що якщо перший байт має вигляд 1011xxxx (де xxxx більше 0), він позначає щось ще. Наприклад, можна додати туди ще 15 символів, які постійно доступні для кодування одним байтом, але я вирішив вчинити по-іншому.

Подивимося ті блоки Юнікоду, які вимагають трьох байт зараз. В основному, як уже було сказано, це китайські ієрогліфи — але з ними важко щось зробити, їх 21 тисяча. Але ще туди відлетіла хіраган з катаканою — а їх уже не так багато, менше двохсот. І, коли ми згадали про японців — там же лежать емодзи (насправді вони багато де розкидані в Юнікоді, але основні блоки в діапазоні 0x1F300 - 0x1FBFF). Якщо подумати про те, що зараз існують емодзі, які збираються з одразу кількох кодових точок (наприклад, емодзі ‍‍‍Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8 складається аж із 7 кодів!), то стає дуже шкода витрачати на кожну по три байти (7×3 = 21 байт заради одного значка, жах ж).

Тому вибираємо кілька обраних діапазонів, що відповідають емодзи, хірагані та катакани, перенумеровуємо їх в один безперервний список і кодуємо у вигляді двох байт замість трьох:

1011xxxx xxxxxxxx

Відмінно: вищезгаданий емодзі ‍‍‍Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8, Що складається з 7 кодових точок, в UTF-8 займає 25 байт, а ми помістили його в 14 (Рівно по два байти на кожну кодову точку). До речі, Хабр відмовився його перетравлювати (як у старому, так і в новому редакторі), тому довелося вставити його картинкою.

Спробуємо виправити ще одну проблему. Як ми пам'ятаємо, основний алфавіт це насправді старші 6 біт, які ми тримаємо в розумі, і приклеюємо до коду кожного чергового символу, що декодується. У випадку з китайськими ієрогліфами, які перебувають у блоці 0x4E00 - 0x9FFF, Це або біт 0, або 1. Це не дуже зручно: нам потрібно буде постійно перемикати алфавіт між цими двома значеннями (тобто витрачати по три байти). Але зауважимо, що в довгому режимі від самого коду ми можемо відняти кількість символів, яку ми кодуємо за допомогою короткого режиму (після всіх вищеописаних хитрощів це 10240) — тоді діапазон ієрогліфів зміститься до 0x2600 - 0x77FF, і в цьому випадку на всьому цьому діапазоні старші 6 біт (з 21) дорівнюватимуть 0. Таким чином, послідовності ієрогліфів будуть використовувати по два байти на ієрогліф (що оптимально для такого великого діапазону), не викликаючи перемикань алфавіту.

Альтернативні рішення: SCSU, BOCU-1

Знавці Unicode, тільки прочитавши назву статті, швидше за все, поспішають нагадати, що безпосередньо серед стандартів Юнікоду є Стандартна схема стиснення для Unicode (SCSU), який описує спосіб кодування, дуже подібний до описаного в статті.

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

Що цікаво, SCSU використовує ідеї, дуже схожі на ті, до яких я прийшов самостійно (замість поняття "алфавітів" там використовуються "вікна", і їх доступно більше, ніж у мене). У той же час, цей формат теж має мінуси: він трохи ближче до алгоритмів стиснення, ніж кодування. Зокрема, стандарт дає безліч способів подання, але не каже, як вибрати з них оптимальний – для цього енкодер повинен застосовувати якісь евристики. Таким чином, SCSU-енкодер, що дає хорошу упаковку, буде складнішим і більш громіздким, ніж мій алгоритм.

Для порівняння я переніс відносно просту реалізацію SCSU на JavaScript - за обсягом коду вона виявилася порівнянною з моїм UTF-C, але в ряді випадків показала результат на десятки відсотків гірше (іноді вона може і перевершувати його, але ненабагато). Наприклад, тексти на івриті та грецькому UTF-C закодував аж на 60% краще, ніж SCSU (ймовірно, через їх компактні алфавіти).

Окремо додам, що, крім SCSU, існує також інший спосіб компактного представлення Юнікоду. BOCU-1, але він ставить за мету сумісність з MIME (що мені не потрібно), і використовує дещо інший підхід до кодування. Його ефективність я не оцінював, але мені здається, що вона навряд чи буде вищою, ніж SCSU.

Можливі доопрацювання

Наведений мною алгоритм не універсальний by design (у цьому, напевно, мої цілі найсильніше розходяться з цілями консорціуму Unicode). Я вже згадав, що він розроблявся переважно під одне завдання (зберігання мультимовного словника в префіксному дереві) і деякі його особливості можуть погано підходити для інших завдань. Але той факт, що він не є стандартом, може бути плюсом. ви можете легко доопрацювати його під свої потреби.

Наприклад, очевидно можна позбутися наявності стану, зробити кодування stateless — просто не оновлювати змінні offs, auxOffs и is21Bit в енкодері та декодері. У такому разі не вдасться ефективно упаковувати послідовності символів одного алфавіту, зате буде гарантія, що той самий символ завжди кодується одними і тими ж байтами, незалежно від контексту.

Крім того, можна заточити кодувальник під конкретну мову, змінивши стан за замовчуванням - наприклад, орієнтуючись на російські тексти, виставити на початку енкодера і декодера offs = 0x0400 и auxOffs = 0. Особливо це має сенс саме у разі Stateless режиму. Загалом це буде схоже на використання старого восьмибітного кодування, тільки не позбавляє можливості вставляти символи з усього Юнікоду за потребою.

Ще один недолік, згаданий раніше - в об'ємному тексті, закодованому в UTF-C, немає швидкого способу знайти межу символу, найближчого до довільного байта. Відрізавши від закодованого буфера останні, скажімо, 100 б, ви ризикуєте отримати сміття, з яким нічого не зробити. На зберігання багатогігабайтних логів кодування не розраховане, але в цілому це можна поправити. Байт 0xBF ніколи не повинен зустрічатися як перший байт (але може бути другим або третім). Тому при кодуванні можна вставляти послідовність 0xBF 0xBF 0xBF кожні, скажімо, 10 Кб — тоді при необхідності знайти кордон достатньо просканувати обраний шматок доти, доки не знайдеться подібний маркер. Слідом за останнім 0xBF гарантовано буде початок символу. (При декодуванні цю послідовність із трьох байт, звичайно, потрібно буде ігнорувати.)

Підводячи підсумок

Якщо ви дочитали досі — вітаю! Сподіваюся, ви, як і я, дізналися щось нове (або освіжили в пам'яті старе) про пристрій Юнікод.

Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8
Демонтаційна сторінка. На прикладі івриту помітні переваги як перед UTF-8, так і перед SCSU.

Не варто розглядати вищеописані дослідження як зазіхання на стандарти. Однак я загалом задоволений результатами своїх напрацювань, тому радий ними поділитися: наприклад, JS-бібліотека в мініфікованому вигляді важить лише 1710 байт (і не має залежностей, звичайно). Як я згадував вище, з її роботою можна ознайомитись на демо-сторінці (там же є набір текстів, на яких можна порівняти з UTF-8 і SCSU).

Насамкінець ще раз зверну увагу на кейси, в яких використовувати UTF-C не варто:

  • Якщо рядки досить довгі (від 100-200 символів). У такому випадку варто задуматися про застосування алгоритмів стиснення на зразок deflate.
  • Якщо вам потрібна ASCII transparency, тобто вам важливо, щоб у закодованих послідовностях не виникали ASCII коди, яких не було у вихідному рядку. Потреби в цьому можна уникнути, якщо при взаємодії зі сторонніми API (наприклад, працюючи з БД) ви передаватимете результат кодування як абстрактний набір байт, а не як рядки. В іншому випадку ви ризикуєте отримати непередбачені вразливості.
  • Якщо ви хочете мати можливість швидко знаходити межі символів за довільним зміщенням (наприклад, при пошкодженні частини рядка). Це можна робити, але тільки просканувавши рядок від початку (або застосувавши доопрацювання, описане у попередньому розділі).
  • Якщо вам потрібно швидко робити операції над вмістом рядків (сортувати їх, шукати підстроки, конкатенувати). Для цього рядки потрібно спочатку декодувати, тому UTF-C буде повільніше UTF-8 у цих випадках (але швидше за алгоритми стиснення). Оскільки один і той самий рядок завжди кодується однаково, точне порівняння декодування не вимагає, його можна робити побайтово.

Оновлення: користувач tyomitch у коментарях нижче виклав графік, що підкреслює межу застосування UTF-C. На ньому видно, що UTF-C ефективніший за алгоритм стиснення загального призначення (варіації LZW) доти, поки рядок, що упаковується, коротше ~140 символів (щоправда, зазначу, що порівняння проводилося однією тексті; інших мов результат може відрізнятися).
Ще один велосипед: зберігаємо юнікодні рядки на 30-60% компактніше, ніж UTF-8

Джерело: habr.com

Додати коментар або відгук