O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8

Dacă sunteți dezvoltator și vă confruntați cu sarcina de a alege o codificare, atunci Unicode va fi aproape întotdeauna soluția potrivită. Metoda de reprezentare specifică depinde de context, dar cel mai adesea există și aici un răspuns universal - UTF-8. Partea bună este că vă permite să utilizați toate caracterele Unicode fără a cheltui prea mult o mulțime de octeți în majoritatea cazurilor. Adevărat, pentru limbile care folosesc mai mult decât alfabetul latin, „nu prea mult” este cel puțin doi octeți pe caracter. Ne putem descurca mai bine fără a reveni la codificări preistorice care ne limitează la doar 256 de caractere disponibile?

Mai jos vă propun să vă familiarizați cu încercarea mea de a răspunde la această întrebare și de a implementa un algoritm relativ simplu care vă permite să stocați linii în majoritatea limbilor lumii fără a adăuga redundanța care este în UTF-8.

Disclaimer. Voi face imediat câteva rezerve importante: soluția descrisă nu este oferită ca înlocuitor universal pentru UTF-8, este potrivit doar într-o listă restrânsă de cazuri (mai multe despre ele mai jos) și în niciun caz nu ar trebui să fie folosit pentru a interacționa cu API-uri terțe (care nici măcar nu știu despre asta). Cel mai adesea, algoritmii de compresie de uz general (de exemplu, deflate) sunt potriviți pentru stocarea compactă a volumelor mari de date text. În plus, deja în procesul de creare a soluției mele, am găsit un standard existent în Unicode în sine, care rezolvă aceeași problemă - este ceva mai complicat (și adesea mai rău), dar totuși este un standard acceptat și nu doar pus împreună pe genunchi. Îți voi spune și despre el.

Despre Unicode și UTF-8

Pentru început, câteva cuvinte despre ce este Unicode и UTF-8.

După cum știți, codificările pe 8 biți erau populare. Cu ele, totul a fost simplu: 256 de caractere pot fi numerotate cu numere de la 0 la 255, iar numerele de la 0 la 255 pot fi, evident, reprezentate ca un octet. Dacă ne întoarcem la început, codificarea ASCII este complet limitată la 7 biți, deci cel mai semnificativ bit din reprezentarea sa de octeți este zero, iar majoritatea codificărilor pe 8 biți sunt compatibile cu aceasta (diferă doar în „sus” parte, unde bitul cel mai semnificativ este unul ).

Cum diferă Unicode de acele codificări și de ce sunt asociate atât de multe reprezentări specifice cu acesta - UTF-8, UTF-16 (BE și LE), UTF-32? Hai să o rezolvăm în ordine.

Standardul Unicode de bază descrie numai corespondența dintre caractere (și, în unele cazuri, componentele individuale ale caracterelor) și numerele acestora. Și există o mulțime de numere posibile în acest standard - de la 0x00 la 0x10FFFF (1 bucăți). Dacă am vrea să punem un număr dintr-un astfel de interval într-o variabilă, nici 114, nici 112 octeți nu ne-ar fi de ajuns. Și din moment ce procesoarele noastre nu sunt foarte proiectate pentru a lucra cu numere de trei octeți, am fi forțați să folosim până la 1 octeți per caracter! Acesta este UTF-2, dar tocmai din cauza acestei „risipiri” acest format nu este popular.

Din fericire, ordinea caracterelor în Unicode nu este aleatorie. Întregul lor set este împărțit în 17"avioane", fiecare dintre ele conține 65536 (0x10000) «puncte de cod" Conceptul de „punct de cod” aici este simplu numărul caracterului, atribuit de Unicode. Dar, așa cum am menționat mai sus, în Unicode nu sunt numerotate doar caracterele individuale, ci și componentele și mărcile de serviciu ale acestora (și uneori nu corespunde absolut nimic numărului - poate deocamdată, dar pentru noi acest lucru nu este atât de important), așa că este mai corect să vorbim întotdeauna în mod specific despre numărul de numere în sine, și nu despre simboluri. Cu toate acestea, în cele ce urmează, de dragul conciziei, voi folosi adesea cuvântul „simbol”, implicând termenul „punct de cod”.

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8
Avioane Unicode. După cum puteți vedea, cea mai mare parte (avioanele 4 până la 13) este încă nefolosită.

Ceea ce este cel mai remarcabil este că toată „pulpa” principală se află în planul zero, se numește „Avion multilingv de bază„. Dacă o linie conține text într-una dintre limbile moderne (inclusiv chineza), nu veți depăși acest plan. Dar nu puteți tăia nici restul Unicode - de exemplu, emoji-urile sunt situate în principal la sfârșitul următorul avion"Avion multilingv suplimentar„(se extinde de la 0x10000 la 0x1FFFF). Deci UTF-16 face asta: toate caracterele care se încadrează în el Avion multilingv de bază, sunt codificate „ca atare” cu un număr corespunzător de doi octeți. Cu toate acestea, unele dintre numerele din acest interval nu indică deloc caractere specifice, dar indică faptul că după această pereche de octeți trebuie să luăm în considerare altul - combinând valorile acestor patru octeți împreună, obținem un număr care acoperă întregul interval Unicode valid. Această idee se numește „cupluri surogat” – poate că ați auzit de ei.

Deci UTF-16 necesită doi sau (în cazuri foarte rare) patru octeți per „punct de cod”. Acest lucru este mai bine decât folosirea a patru octeți tot timpul, dar limba latină (și alte caractere ASCII) atunci când este codificată în acest fel irosește jumătate din spațiu pe zerouri. UTF-8 este conceput pentru a corecta acest lucru: ASCII în el ocupă, ca înainte, doar un octet; coduri de la 0x80 la 0x7FF - doi octeți; din 0x800 la 0xFFFF - trei, și de la 0x10000 la 0x10FFFF - patru. Pe de o parte, alfabetul latin a devenit bun: compatibilitatea cu ASCII a revenit, iar distribuția este mai uniform „împrăștiată” de la 1 la 4 octeți. Dar alte alfabete decât latina, din păcate, nu beneficiază în niciun fel în comparație cu UTF-16 și multe necesită acum trei octeți în loc de doi - intervalul acoperit de o înregistrare de doi octeți s-a restrâns de 32 de ori, cu 0xFFFF la 0x7FF, și nici chineza, nici, de exemplu, georgiana nu sunt incluse în el. Chirilic și alte cinci alfabete - ura - norocos, 2 octeți per caracter.

De ce se întâmplă asta? Să vedem cum UTF-8 reprezintă codurile de caractere:
O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8
Direct pentru a reprezenta numere, aici sunt utilizați biții marcați cu simbolul x. Se poate observa că într-o înregistrare de doi octeți există doar 11 astfel de biți (din 16). Biții conducători au aici doar o funcție auxiliară. În cazul unei înregistrări de patru octeți, 21 din 32 de biți sunt alocați pentru numărul punctului de cod - s-ar părea că trei octeți (care dau un total de 24 de biți) ar fi de ajuns, dar markerii de serviciu mănâncă prea mult.

Este rău? Nu chiar. Pe de o parte, dacă ne pasă mult de spațiu, avem algoritmi de compresie care pot elimina cu ușurință toată entropia și redundanța suplimentară. Pe de altă parte, scopul Unicode a fost să ofere cea mai universală codificare posibilă. De exemplu, putem încredința o linie codificată în UTF-8 unui cod care anterior funcționa numai cu ASCII și să nu ne fie teamă că va vedea un caracter din intervalul ASCII care de fapt nu este acolo (la urma urmei, în UTF-8 toate octeți care încep cu bitul zero - exact asta este ASCII). Și dacă vrem brusc să tăiem o coadă mică dintr-un șir mare fără a o decoda de la bun început (sau să restabilim o parte din informații după o secțiune deteriorată), ne este ușor să găsim offset-ul de unde începe un caracter (este suficient pentru a sări peste octeți care au un prefix de bit 10).

Atunci de ce să inventezi ceva nou?

În același timp, există ocazional situații în care algoritmii de compresie precum deflate sunt slab aplicabili, dar doriți să obțineți o stocare compactă a șirurilor. Personal, am întâlnit această problemă când mă gândeam la construcție arbore de prefix comprimat pentru un dicționar mare care include cuvinte în limbi arbitrare. Pe de o parte, fiecare cuvânt este foarte scurt, așa că comprimarea lui va fi ineficientă. Pe de altă parte, implementarea arborelui pe care am considerat-o a fost concepută astfel încât fiecare octet al șirului stocat să genereze un vârf de arbore separat, așa că minimizarea numărului lor a fost foarte utilă. În biblioteca mea Az.js (Ca în pimorfie2, pe care se bazează) o problemă similară poate fi rezolvată simplu - șiruri împachetate în dawg-dictionar, stocat acolo in CP1251 vechi bun. Dar, așa cum este ușor de înțeles, acest lucru funcționează bine doar pentru un alfabet limitat - o linie în chineză nu poate fi adăugată la un astfel de dicționar.

Separat, aș dori să notez încă o nuanță neplăcută care apare atunci când utilizați UTF-8 într-o astfel de structură de date. Imaginea de mai sus arată că atunci când un caracter este scris ca doi octeți, biții aferenti numărului său nu vin pe rând, ci sunt separați de o pereche de biți 10 În mijloc: 110xxxxx 10xxxxxx. Din acest motiv, atunci când cei 6 biți inferiori ai celui de-al doilea octet depășesc codul de caractere (adică, are loc o tranziție 1011111110000000), apoi se schimbă și primul octet. Se pare că litera „p” este notă cu octeți 0xD0 0xBF, iar următorul „r” este deja 0xD1 0x80. Într-un arbore de prefix, aceasta duce la împărțirea nodului părinte în două - unul pentru prefix 0xD0, și altul pentru 0xD1 (deși întregul alfabet chirilic ar putea fi codificat doar de al doilea octet).

Ce am primit

Confruntat cu această problemă, am decis să exersez jocurile cu biți și, în același timp, să mă familiarizez puțin cu structura Unicode în ansamblu. Rezultatul a fost formatul de codificare UTF-C ("C" pentru compact), care cheltuiește nu mai mult de 3 octeți per punct de cod și de foarte multe ori vă permite să cheltuiți doar un octet suplimentar pentru întreaga linie codificată. Acest lucru duce la faptul că pe multe alfabete non-ASCII o astfel de codificare se dovedește a fi 30-60% mai compact decât UTF-8.

Am prezentat exemple de implementare a algoritmilor de codificare și decodare sub formă Biblioteci JavaScript și Go, le puteți folosi liber în codul dvs. Dar voi sublinia în continuare că, într-un fel, acest format rămâne o „bicicletă” și nu recomand să-l folosești fără să-ți dai seama de ce ai nevoie. Acesta este încă mai mult un experiment decât o „îmbunătățire serioasă a UTF-8”. Cu toate acestea, codul de acolo este scris îngrijit, concis, cu un număr mare de comentarii și acoperire de testare.

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8
Rezultatele testului și comparație cu UTF-8

am facut si eu pagina demo, unde puteți evalua performanța algoritmului, iar apoi vă voi spune mai multe despre principiile și procesul de dezvoltare a acestuia.

Eliminarea biților redundanți

Am luat ca bază UTF-8, desigur. Primul și cel mai evident lucru care poate fi schimbat în el este reducerea numărului de biți de serviciu din fiecare octet. De exemplu, primul octet din UTF-8 începe întotdeauna cu oricare 0, sau cu 11 - un prefix 10 Numai următorii octeți îl au. Să înlocuim prefixul 11 pe 1, iar pentru următorii octeți vom elimina complet prefixele. Ce se va intampla?

0xxxxxxx — 1 octet
10xxxxxx xxxxxxxx - 2 octeți
110xxxxx xxxxxxxx xxxxxxxx - 3 octeți

Stai, unde este înregistrarea de patru octeți? Dar nu mai este necesar - atunci când scriem în trei octeți, acum avem 21 de biți disponibili și acest lucru este suficient pentru toate numerele de până la 0x10FFFF.

Ce am sacrificat aici? Cel mai important lucru este detectarea limitelor de caractere dintr-o locație arbitrară din buffer. Nu putem îndrepta către un octet arbitrar și nu putem găsi începutul următorului caracter din acesta. Aceasta este o limitare a formatului nostru, dar în practică acest lucru este rareori necesar. De obicei, suntem capabili să trecem prin buffer de la bun început (mai ales când vine vorba de linii scurte).

Situația cu acoperirea limbilor cu 2 octeți a devenit, de asemenea, mai bună: acum formatul de doi octeți oferă o gamă de 14 biți, iar acestea sunt coduri de până la 0x3FFF. Chinezii au ghinion (caracterele lor variază în mare parte de la 0x4E00 la 0x9FFF), dar georgienii și multe alte popoare se distrează mai mult - limbile lor se încadrează și în 2 octeți per caracter.

Introduceți starea codificatorului

Să ne gândim acum la proprietățile liniilor în sine. Dicționarul conține cel mai adesea cuvinte scrise cu caractere ale aceluiași alfabet, iar acest lucru este valabil și pentru multe alte texte. Ar fi bine să indicați o dată acest alfabet și apoi să indicați doar numărul literei din interiorul acestuia. Să vedem dacă aranjarea caracterelor din tabelul Unicode ne va ajuta.

După cum am menționat mai sus, Unicode este împărțit în avion 65536 coduri fiecare. Dar aceasta nu este o diviziune foarte utilă (cum s-a spus deja, cel mai adesea suntem în planul zero). Mai interesantă este împărțirea după blocuri. Aceste intervale nu mai au o lungime fixă ​​și sunt mai semnificative - de regulă, fiecare combină caractere din același alfabet.

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8
Un bloc care conține caractere din alfabetul bengali. Din păcate, din motive istorice, acesta este un exemplu de ambalare nu foarte densă - 96 de caractere sunt împrăștiate haotic în 128 de puncte de cod de bloc.

Începuturile blocurilor și dimensiunile lor sunt întotdeauna multipli de 16 - acest lucru se face pur și simplu pentru comoditate. În plus, multe blocuri încep și se termină pe valori care sunt multipli de 128 sau chiar 256 - de exemplu, alfabetul chirilic de bază ocupă 256 de octeți din 0x0400 la 0x04FF. Acest lucru este destul de convenabil: dacă salvăm prefixul o dată 0x04, atunci orice caracter chirilic poate fi scris într-un octet. Adevărat, în acest fel vom pierde ocazia de a reveni la ASCII (și la orice alte personaje în general). Prin urmare, facem asta:

  1. Doi octeți 10yyyyyy yxxxxxxx nu doar desemnează un simbol cu ​​un număr yyyyyy yxxxxxxx, dar și schimbare alfabetul actual pe yyyyyy y0000000 (adică ne amintim toate biții, cu excepția celor mai puțin semnificative Bit 7);
  2. Un octet 0xxxxxxx acesta este caracterul alfabetului actual. Trebuie doar adăugat la offset-ul pe care l-am amintit la pasul 1. Deși nu am schimbat alfabetul, offset-ul este zero, așa că am menținut compatibilitatea cu ASCII.

La fel și pentru codurile care necesită 3 octeți:

  1. Trei octeți 110yyyyy yxxxxxxx xxxxxxxx indica un simbol cu ​​un număr yyyyyy yxxxxxxx xxxxxxxx, Schimbare alfabetul actual pe yyyyyy y0000000 00000000 (mi-am amintit totul, cu excepția celor mai tineri Bit 15), și bifați caseta în care ne aflăm acum lung modul (atunci când schimbăm alfabetul înapoi la unul cu doi octeți, vom reseta acest flag);
  2. Doi octeți 0xxxxxxx xxxxxxxx în modul lung este caracterul alfabetului curent. În mod similar, îl adăugăm cu offset-ul de la pasul 1. Singura diferență este că acum citim doi octeți (pentru că am trecut la acest mod).

Sună bine: acum, deși trebuie să codificăm caractere din același interval Unicode de 7 biți, cheltuim 1 octet suplimentar la început și un total de un octet per caracter.

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8
Lucrează de la una dintre versiunile anterioare. Deja de multe ori bate UTF-8, dar mai este loc de îmbunătățire.

Ce e mai rau? În primul rând, avem o condiție și anume offset alfabetic curent și caseta de selectare modul lung. Acest lucru ne limitează și mai mult: acum aceleași caractere pot fi codificate diferit în contexte diferite. Căutarea subșirurilor, de exemplu, va trebui făcută ținând cont de acest lucru și nu doar prin compararea octeților. În al doilea rând, de îndată ce am schimbat alfabetul, a devenit prost cu codificarea caracterelor ASCII (și acesta nu este doar alfabetul latin, ci și punctuația de bază, inclusiv spațiile) - necesită schimbarea din nou a alfabetului la 0, adică, din nou un octet suplimentar (și apoi încă unul pentru a reveni la punctul nostru principal).

Un alfabet este bun, două sunt mai bune

Să încercăm să ne schimbăm puțin prefixele de biți, strângând încă unul la cele trei descrise mai sus:

0xxxxxxx — 1 octet în modul normal, 2 în modul lung
11xxxxxx — 1 octet
100xxxxx xxxxxxxx - 2 octeți
101xxxxx xxxxxxxx xxxxxxxx - 3 octeți

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8

Acum, într-o înregistrare de doi octeți există un bit mai puțin disponibil - puncte de cod până la 0x1FFFȘi nu 0x3FFF. Cu toate acestea, este încă vizibil mai mare decât în ​​codurile UTF-8 pe dublu octet, majoritatea limbilor comune încă se potrivesc, cea mai vizibilă pierdere a căzut hiragana и katakana, japonezii sunt triști.

Ce este acest cod nou? 11xxxxxx? Acesta este un mic „decor” de 64 de caractere în dimensiune, completează alfabetul nostru principal, așa că l-am numit auxiliar (auxiliar) alfabet. Când schimbăm alfabetul actual, o bucată din vechiul alfabet devine auxiliară. De exemplu, am trecut de la ASCII la Chirilic - stocul conține acum 64 de caractere care conțin Alfabetul latin, cifre, spațiu și virgulă (inserții cele mai frecvente în texte non-ASCII). Treceți înapoi la ASCII - și partea principală a alfabetului chirilic va deveni alfabetul auxiliar.

Datorită accesului la două alfabete, putem gestiona un număr mare de texte cu costuri minime pentru schimbarea alfabetelor (punctuația va duce cel mai adesea la o revenire la ASCII, dar după aceea vom obține multe caractere non-ASCII din alfabetul suplimentar, fără comutare din nou).

Bonus: prefixarea subalfabetului 11xxxxxx și alegând decalajul său inițial să fie 0xC0, obținem compatibilitate parțială cu CP1252. Cu alte cuvinte, multe (dar nu toate) textele vest-europene codificate în CP1252 vor arăta la fel în UTF-C.

Aici, însă, apare o dificultate: cum să obțineți unul auxiliar din alfabetul principal? Puteți lăsa același offset, dar - din păcate - aici structura Unicode joacă deja împotriva noastră. Foarte des, partea principală a alfabetului nu se află la începutul blocului (de exemplu, capitala rusă „A” are codul 0x0410, deși blocul chirilic începe cu 0x0400). Astfel, după ce au luat primele 64 de caractere în depozit, este posibil să pierdem accesul la partea de coadă a alfabetului.

Pentru a rezolva această problemă, am parcurs manual câteva blocuri corespunzătoare diferitelor limbi și am specificat decalajul alfabetului auxiliar în cadrul celui principal pentru ele. Alfabetul latin, ca o excepție, a fost în general reordonat ca baza64.

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8

Retușuri finale

În sfârșit, să ne gândim unde mai putem îmbunătăți ceva.

Rețineți că formatul 101xxxxx xxxxxxxx xxxxxxxx vă permite să codificați numere de până la 0x1FFFFF, iar Unicode se termină mai devreme, la 0x10FFFF. Cu alte cuvinte, ultimul punct de cod va fi reprezentat ca 10110000 11111111 11111111. Prin urmare, putem spune că dacă primul octet este de forma 1011xxxx (Unde xxxx mai mare decât 0), atunci înseamnă altceva. De exemplu, puteți adăuga alte 15 caractere acolo care sunt disponibile în mod constant pentru codare pe un octet, dar am decis să o fac diferit.

Să ne uităm la acele blocuri Unicode care necesită acum trei octeți. Practic, așa cum am menționat deja, acestea sunt caractere chinezești - dar este dificil să faci ceva cu ele, sunt 21 de mii dintre ele. Dar acolo au zburat și hiragana și katakana - și nu mai sunt atât de multe, mai puțin de două sute. Și, din moment ce ne-am amintit de japonezi, există și emoji-uri (de fapt, sunt împrăștiate în multe locuri în Unicode, dar blocurile principale sunt în gamă 0x1F300 - 0x1FBFF). Dacă vă gândiți la faptul că acum există emoji-uri care sunt asamblate din mai multe puncte de cod simultan (de exemplu, emoji-ul ‍‍‍O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8 este format din până la 7 coduri!), atunci devine o rușine să cheltuiți trei octeți pe fiecare (7×3 = 21 de octeți de dragul unei pictograme, un coșmar).

Prin urmare, selectăm câteva intervale selectate corespunzătoare emoji, hiragana și katakana, le renumerotăm într-o singură listă continuă și le codificăm ca doi octeți în loc de trei:

1011xxxx xxxxxxxx

Grozav: emoji-ul menționat mai susO altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8, constând din 7 puncte de cod, ocupă 8 de octeți în UTF-25 și o potrivim 14 (exact doi octeți pentru fiecare punct de cod). Apropo, Habr a refuzat să-l digere (atât în ​​vechiul, cât și în noul editor), așa că a trebuit să-l introduc cu o poză.

Să încercăm să remediam încă o problemă. După cum ne amintim, alfabetul de bază este în esență mare 6 biți, de care ținem cont și lipim de codul fiecărui simbol decodat următor. În cazul caracterelor chinezești care se află în bloc 0x4E00 - 0x9FFF, acesta este fie bit 0, fie 1. Acest lucru nu este foarte convenabil: va trebui să comutăm constant alfabetul între aceste două valori (adică să cheltuim trei octeți). Dar rețineți că, în modul lung, din codul în sine putem scădea numărul de caractere pe care le codificăm folosind modul scurt (după toate trucurile descrise mai sus, acesta este 10240) - atunci intervalul de hieroglife se va schimba la 0x2600 - 0x77FF, iar în acest caz, pe tot acest interval, cei mai semnificativi 6 biți (din 21) vor fi egali cu 0. Astfel, secvențele de hieroglife vor folosi doi octeți per hieroglifă (ceea ce este optim pentru o gamă atât de mare), fără provocând comutări alfabetice.

Soluții alternative: SCSU, BOCU-1

Experții Unicode, după ce tocmai au citit titlul articolului, cel mai probabil se vor grăbi să vă reamintească că direct printre standardele Unicode există Schema de compresie standard pentru Unicode (SCSU), care descrie o metodă de codificare foarte asemănătoare cu cea descrisă în articol.

Recunosc sincer: am aflat despre existența ei abia după ce am fost profund cufundat în scrierea deciziei mele. Dacă aș fi știut despre asta de la început, probabil aș fi încercat să scriu o implementare în loc să vin cu propria mea abordare.

Ceea ce este interesant este că SCSU folosește idei foarte asemănătoare cu cele pe care le-am venit pe cont propriu (în loc de conceptul de „alfabet” folosesc „ferestre”, și sunt mai multe disponibile decât am). În același timp, acest format are și dezavantaje: este puțin mai aproape de algoritmii de compresie decât de cei de codificare. În special, standardul oferă multe metode de reprezentare, dar nu spune cum să o alegeți pe cea optimă - pentru aceasta, codificatorul trebuie să folosească un fel de euristică. Astfel, un encoder SCSU care produce ambalaje bune va fi mai complex și mai greoi decât algoritmul meu.

Pentru comparație, am transferat o implementare relativ simplă a SCSU în JavaScript - în ceea ce privește volumul de cod, s-a dovedit a fi comparabilă cu UTF-C-ul meu, dar în unele cazuri rezultatul a fost cu zeci de procente mai rău (uneori îl poate depăși, dar nu cu mult). De exemplu, textele în ebraică și greacă au fost codificate de UTF-C Cu 60% mai bun decât SCSU (probabil datorită alfabetelor lor compacte).

Separat, voi adăuga că, pe lângă SCSU, există și o altă modalitate de a reprezenta în mod compact Unicode - BOCU-1, dar vizează compatibilitatea MIME (de care nu aveam nevoie) și adoptă o abordare ușor diferită a codificării. Nu i-am evaluat eficacitatea, dar mi se pare că este puțin probabil să fie mai mare decât SCSU.

Posibile îmbunătățiri

Algoritmul pe care l-am prezentat nu este universal prin design (acesta este probabil locul în care obiectivele mele diverg cel mai mult de obiectivele Consorțiului Unicode). Am menționat deja că a fost dezvoltat în primul rând pentru o sarcină (stocarea unui dicționar multilingv într-un arbore de prefix), iar unele dintre caracteristicile sale pot să nu fie potrivite pentru alte sarcini. Dar faptul că nu este un standard poate fi un plus - îl puteți modifica cu ușurință pentru a se potrivi nevoilor dvs.

De exemplu, într-un mod evident, puteți scăpa de prezența stării, puteți face codare fără stat - pur și simplu nu actualizați variabilele offs, auxOffs и is21Bit în codificator și decodor. În acest caz, nu va fi posibilă împachetarea efectivă a secvențelor de caractere ale aceluiași alfabet, dar va exista garanția că același caracter este întotdeauna codificat cu aceiași octeți, indiferent de context.

În plus, puteți adapta codificatorul la o anumită limbă schimbând starea implicită - de exemplu, concentrându-vă pe textele rusești, setați codificatorul și decodorul la început offs = 0x0400 и auxOffs = 0. Acest lucru are sens mai ales în cazul modului apatrid. În general, acest lucru va fi similar cu utilizarea vechii codări pe opt biți, dar fără a elimina capacitatea de a insera caractere din toate Unicode, după cum este necesar.

Un alt dezavantaj menționat mai devreme este că în textul mare codificat în UTF-C nu există o modalitate rapidă de a găsi limita de caractere cea mai apropiată de un octet arbitrar. Dacă tăiați ultimii, să zicem, 100 de octeți din bufferul codificat, riscați să obțineți gunoi cu care nu puteți face nimic. Codificarea nu este concepută pentru stocarea jurnalelor de mai mulți gigaocteți, dar în general acest lucru poate fi corectat. octet 0xBF nu trebuie să apară niciodată ca primul octet (dar poate fi al doilea sau al treilea). Prin urmare, la codificare, puteți introduce secvența 0xBF 0xBF 0xBF fiecare, să zicem, 10 KB - atunci, dacă trebuie să găsiți o limită, va fi suficient să scanați piesa selectată până când se găsește un marker similar. În urma ultimei 0xBF este garantat începutul unui personaj. (La decodare, această secvență de trei octeți va trebui, desigur, ignorată.)

Rezumând

Dacă ați citit până aici, felicitări! Sper că, la fel ca mine, ați învățat ceva nou (sau v-ați împrospătat memoria) despre structura Unicode.

O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8
Pagina demo. Exemplul de ebraică arată avantajele atât față de UTF-8, cât și față de SCSU.

Cercetarea descrisă mai sus nu trebuie considerată o încălcare a standardelor. Cu toate acestea, în general sunt mulțumit de rezultatele muncii mele, așa că sunt mulțumit de ele acțiune: de exemplu, o bibliotecă JS redusă cântărește doar 1710 octeți (și nu are dependențe, desigur). După cum am menționat mai sus, munca ei poate fi găsită la pagina demo (există și un set de texte pe care poate fi comparat cu UTF-8 și SCSU).

În cele din urmă, voi atrage din nou atenția asupra cazurilor în care este utilizat UTF-C nu merita:

  • Dacă rândurile dvs. sunt suficient de lungi (de la 100-200 de caractere). În acest caz, ar trebui să vă gândiți să utilizați algoritmi de compresie precum deflate.
  • Dacă aveți nevoie Transparență ASCII, adică este important pentru tine ca secvențele codificate să nu conțină coduri ASCII care nu erau în șirul original. Necesitatea acestui lucru poate fi evitată dacă, atunci când interacționați cu API-uri terțe (de exemplu, lucrând cu o bază de date), treceți rezultatul codificării ca un set abstract de octeți și nu ca șiruri. În caz contrar, riscați să obțineți vulnerabilități neașteptate.
  • Dacă doriți să puteți găsi rapid granițele de caractere la un offset arbitrar (de exemplu, când o parte a unei linii este deteriorată). Acest lucru se poate face, dar numai prin scanarea liniei de la început (sau aplicând modificarea descrisă în secțiunea anterioară).
  • Dacă trebuie să efectuați rapid operații asupra conținutului șirurilor (sortați-le, căutați subșiruri în ele, concatenați). Acest lucru necesită ca șirurile să fie decodificate mai întâi, astfel încât UTF-C va fi mai lent decât UTF-8 în aceste cazuri (dar mai rapid decât algoritmii de compresie). Deoarece același șir este întotdeauna codificat în același mod, nu este necesară compararea exactă a decodării și poate fi făcută octet cu octet.

Actualizați: utilizator Tyomitch în comentariile de mai jos a postat un grafic care evidențiază limitele de aplicabilitate ale UTF-C. Acesta arată că UTF-C este mai eficient decât un algoritm de compresie de uz general (o variație a LZW) atâta timp cât șirul de caractere este mai scurt. ~140 de caractere (cu toate acestea, observ că comparația a fost efectuată pe un text; pentru alte limbi, rezultatul poate fi diferit).
O altă bicicletă: stocăm șiruri Unicode cu 30-60% mai compacte decât UTF-8

Sursa: www.habr.com

Adauga un comentariu