Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8

Se sei uno sviluppatore e hai il compito di scegliere una codifica, quasi sempre la scelta giusta sarà Unicode. Il modo specifico di rappresentazione dipende dal contesto, ma più spesso c'è anche una risposta universale: UTF-8. È utile perché consente di utilizzare tutti i caratteri di Unicode, senza sprecare troppi byte nella maggior parte dei casi. Tuttavia, per le lingue che utilizzano non solo l'alfabeto latino, "non troppi" significa almeno due byte per carattere. È possibile fare di meglio, senza tornare a codifiche preistoriche che ci limitano a sole 256 simboli disponibili?

Di seguito, ti propongo di conoscere il mio tentativo di rispondere a questa domanda e implementare un algoritmo relativamente semplice che consente di memorizzare stringhe nella maggior parte delle lingue del mondo, senza aggiungere quel ridondanza che è presente in UTF-8.

Disclaimer. Innanzitutto, farò alcune importanti precisazioni: la soluzione descritta non è proposta come una sostituzione universale di UTF-8, si adatta solo a un elenco ristretto di casi (di cui parlerò più avanti), e non deve in alcun modo essere utilizzata per interagire con API esterne (che non ne sanno nulla). Spesso, per un'archiviazione compatta di grandi volumi di dati testuali, sono adatti algoritmi di compressione generali (per esempio, deflate). Inoltre, già durante la creazione della mia soluzione, ho trovato uno standard esistente all'interno di Unicode, che risolve lo stesso problema: è un po' più complesso (e spesso peggiore), ma comunque è uno standard accettato e non assemblato alla rinfusa. Di questo parlerò anch'io.

Su Unicode e UTF-8

Per iniziare, alcune parole su cos'è Unicode e UTF-8.

Come è noto, in passato erano popolari le codifiche a 8 bit. Con esse era tutto semplice: 256 simboli possono essere numerati da 0 a 255, e i numeri da 0 a 255 sono evidentemente rappresentabili in un byte. Se torniamo alle origini, la codifica ASCII è addirittura limitata a 7 bit, quindi il bit più significativo nella sua rappresentazione byte è zero, e la maggior parte delle codifiche a 8 bit sono compatibili con essa (si differenziano solo nella parte "superiore", dove il bit più significativo è uno).

Qual è la differenza tra Unicode e quelle codifiche e perché con esso sono associate numerose rappresentazioni specifiche — UTF-8, UTF-16 (BE e LE), UTF-32? Procediamo con ordine.

Lo standard principale di Unicode descrive solo la corrispondenza tra i caratteri (e in alcuni casi — le singole componenti dei caratteri) e i loro numeri. E i numeri possibili in questo standard sono davvero tanti — da 0x00 fino a 0x10FFFF (1.114.112). Se volessimo memorizzare un numero in un intervallo simile in una variabile, né 1 né 2 byte sarebbero sufficienti. E poiché i nostri processori non sono particolarmente predisposti a gestire numeri a tre byte, saremmo costretti a utilizzare ben 4 byte per ogni simbolo! Questo è UTF-32, ma proprio a causa di questa «spreco» questo formato non è molto popolare.

Fortunatamente, i caratteri all'interno di Unicode non sono ordinati a caso. Tutti quelli presenti sono divisi in 17 «piani», ognuno dei quali contiene 65.536 (0x10000) «punti di codice». Il concetto di «punto di codice» qui è semplicemente il numero del carattere, assegnato a esso da Unicode. Ma, come detto prima, in Unicode non sono numerati solo i singoli caratteri, ma anche le loro componenti e le note di servizio (e a volte non corrisponde nemmeno a un numero — forse, per un certo periodo, ma per noi non è così importante), quindi è più corretto parlare sempre del numero totale dei numeri, piuttosto che dei caratteri. Tuttavia, per brevità, in seguito utilizzerò spesso la parola «carattere», riferendomi al termine «punto di codice».

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8
Piani di Unicode. Come si può notare, la maggior parte (i piani dal 4 al 13) sono ancora inutilizzati.

La cosa più sorprendente è che tutta la «parte centrale» si trova nel piano zero, chiamato "Piano Multilingue di Base". Se una stringa contiene testo in una delle lingue moderne (compreso il cinese), non supererai i confini di questo piano. Ma non puoi nemmeno escludere il resto di Unicode — ad esempio, le emoji si trovano principalmente alla fine del piano successivo, "Piano Multilingue Supplementare" (che si estende da 0x10000 fino a 0x1FFFF). Pertanto, UTF-16 si comporta così: tutti i caratteri che rientrano in Piano Multilingue di Base, codificati «così come sono», con il corrispondente numero a due byte. Tuttavia, parte dei numeri in questo intervallo non rappresenta affatto caratteri specifici, ma indica che dopo questa coppia di byte è necessario considerare un'altra — combinando i valori di questi quattro byte insieme, si ottiene un numero che copre l'intero intervallo consentito di Unicode. Questa rappresentazione è chiamata «coppie surrogate» — potreste averne sentito parlare.

Pertanto, UTF-16 richiede due o (in rari casi) quattro byte per un singolo «punto di codice». È meglio che usare costantemente quattro byte, ma l'alfabeto latino (e altri caratteri ASCII) in questa codifica occupa metà dello spazio disponibile in zeri. UTF-8 è stato progettato per risolvere questo problema: ASCII occupa, come prima, solo un byte; i codici da 0x80 fino a 0x7FF — due byte; da 0x800 fino a 0xFFFF — tre, e da 0x10000 fino a 0x10FFFF — quattro. Da un lato, l'alfabeto latino ne trae giovamento: è tornata la compatibilità con ASCII, e la distribuzione è più uniformemente «spalmata» da 1 a 4 byte. Ma gli alfabeti diversi da quello latino, purtroppo, non traggono vantaggio rispetto a UTF-16, e molti richiedono addirittura tre byte invece di due — l'intervallo coperto dalla registrazione a due byte si è ridotto di 32 volte, da 0xFFFF fino a 0x7FF, e non include più né il cinese né, per esempio, il georgiano. L'alfabeto cirillico e altri cinque alfabeti — evviva — hanno avuto fortuna, 2 byte per carattere.

Perché succede questo? Vediamo come UTF-8 rappresenta i codici dei caratteri:
Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8
Direttamente per la rappresentazione dei numeri qui sono stati utilizzati i bit, contrassegnati dal simbolo x. È evidente che nella registrazione a due byte ci sono solo 11 bit (su 16). I bit più significativi hanno solo una funzione di servizio. Nel caso della registrazione a quattro byte, addirittura 21 bit su 32 sono riservati al numero del punto di codice — sembrerebbe che tre byte (che danno un totale di 24 bit) sarebbero sufficienti, ma i marcatori di servizio consumano troppi.

È un male? In realtà, non molto. Da un lato, se ci preoccupiamo molto dello spazio occupato, abbiamo algoritmi di compressione che eliminano facilmente tutta l'entropia e la ridondanza in eccesso. Dall'altro, l'obiettivo di Unicode era fornire una codifica il più universale possibile. Ad esempio, una stringa codificata in UTF-8 può essere gestita da un codice che prima lavorava solo con ASCII, senza temere che possa imbattersi in un simbolo dell'intervallo ASCII che in realtà non esiste (perché in UTF-8 tutti i byte che iniziano con il bit zero sono proprio ASCII). E se volessimo improvvisamente tagliare una piccola coda da una grande stringa, senza decodificarla dall'inizio (o ripristinare parte delle informazioni dopo un'area danneggiata) - non è difficile trovare l'offset dove inizia un simbolo (è sufficiente saltare i byte che hanno un prefisso di bit. 10).

Perché allora inventare qualcosa di nuovo?

Allo stesso tempo, ci sono occasioni in cui algoritmi di compressione come il deflate sono mal applicabili, ma si desidera ottenere una memorizzazione compatta delle stringhe. Personalmente, mi sono trovato ad affrontare tale compito, riflettendo sulla costruzione di un albero dei prefissi compresso per un grande dizionario che includa parole in lingue arbitrarie. Da un lato, ogni parola è molto corta, quindi comprimerla non sarebbe efficace. Dall'altro, l'implementazione dell'albero che stavo considerando era progettata affinché ogni byte della stringa memorizzata generasse un nodo separato dell'albero, quindi minimizzare il loro numero sarebbe stato molto utile. Nella mia libreria Az.js (così come in pymorphy2, su cui si basa) un problema simile viene risolto in modo semplice: le stringhe imballate in un dizionario DAWGvengono memorizzate lì in buon vecchio CP1251. Ma, come è facile capire, funziona bene solo per un alfabeto limitato: una stringa in cinese non può essere inserita in tale dizionario.

Sottolineo anche un altro fastidioso aspetto che si verifica quando si utilizza UTF-8 in una tale struttura dati. Nella figura sopra si può vedere che, quando il simbolo viene scritto come due byte, i bit che si riferiscono al suo numero non sono contigui, ma sono interrotti da un paio di bit 10 nel mezzo: 110xxxxx 10xxxxxx. Di conseguenza, quando nel codice del simbolo si sovraccaricano i 6 bit inferiori del secondo byte (cioè si verifica un passaggio 1011111110000000), anche il primo byte cambia. Risultato, la lettera «п» è rappresentata dai byte 0xD0 0xBF, e la successiva «r» — già 0xD1 0x80. Nell'albero dei prefissi ciò porta alla separazione del nodo genitore in due: uno per il prefisso 0xD0, e l'altro per 0xD1 (anche se tutto il cirillico potrebbe essere codificato solo con il secondo byte).

Cosa ho ottenuto

Affrontando questa sfida, ho deciso di esercitarmi con i giochi di bit, e nel contempo di conoscere meglio la struttura di Unicode in generale. Il risultato è stato il formato di codifica UTF-C («C» da compatto), che utilizza al massimo 3 byte per ogni punto di codice, e molto spesso consente di utilizzare solo un byte extra per l'intera stringa codificata. Questo porta al fatto che in molti alfabeti non ASCII tale codifica risulta dal 30 al 60% più compatta rispetto a UTF-8.

Ho preparato esempi di implementazione degli algoritmi di codifica e decodifica sotto forma di librerie in JavaScript e Go, puoi usarle liberamente nel tuo codice. Tuttavia, sottolineo che in un certo senso questo formato rimane un «marchio», e non consiglio di usarlo senza renderti conto di cosa ti serve veramente. Si tratta comunque di più un esperimento che un serio «miglioramento di UTF-8». Tuttavia, il codice è scritto in modo ordinato, conciso, con un gran numero di commenti e copertura di test.

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8
Risultato dell'esecuzione dei test e confronto con UTF-8

Inoltre, ho creato una pagina demo, dove puoi valutare il funzionamento dell'algoritmo, e poi parlerò più dettagliatamente dei suoi principi e del processo di sviluppo.

Eliminare i bit ridondanti

Come base ho utilizzato, ovviamente, UTF-8. La prima e più ovvia cosa che si può cambiare è ridurre il numero di bit di servizio in ogni byte. Ad esempio, il primo byte in UTF-8 inizia sempre o con 0, o con 11 — e il prefisso 10 è presente solo nei byte successivi. Sostituiamo il prefisso 11 in 1, e per i byte successivi eliminiamo completamente i prefissi. Cosa otteniamo?

0xxxxxxx — 1 byte
10xxxxxx xxxxxxxx — 2 byte
110xxxxx xxxxxxxx xxxxxxxx — 3 byte

Fermati un attimo, e dov'è la codifica a quattro byte? Non è più necessaria: con la codifica di tre byte abbiamo ora accesso a 21 bit e questo è più che sufficiente per tutti i numeri fino a 0x10FFFF.

Cosa abbiamo sacrificato qui? La cosa più importante è la scoperta dei confini dei simboli da un punto qualsiasi del buffer. Non possiamo fare clic su un byte casuale e da lì trovare l'inizio del simbolo successivo. Questa è una limitazione del nostro formato, ma nella pratica la necessità di questo non si presenta spesso. Di solito siamo in grado di scorrere il buffer dall'inizio (soprattutto quando si parla di stringhe corte).

La situazione riguardante la copertura dei linguaggi con 2 byte è migliorata: ora il formato a due byte offre un intervallo di 14 bit, il che corrisponde ai codici fino a 0x3FFF. Ai cinesi non va bene (i loro ideogrammi si trovano principalmente nell'intervallo da 0x4E00 fino a 0x9FFF), ma ai georgiani e a molti altri popoli è andata meglio: le loro lingue si adattano anch'esse a 2 byte per simbolo.

Introduciamo lo stato dell'encoder

Ora pensiamo alle proprietà delle righe stesse. Nel dizionario ci sono per lo più parole scritte con simboli di un solo alfabeto, e questo è vero anche per molti altri testi. Sarebbe utile indicare una sola volta questo alfabeto e poi indicare solo il numero della lettera al suo interno. Vediamo se la disposizione dei simboli nella tabella Unicode ci aiuta.

Come già detto, l'Unicode è suddiviso in piani di 65536 codici ciascuno. Ma questa suddivisione non è molto utile (come già detto, di solito ci troviamo nel piano zero). È molto più interessante la suddivisione in blocchi. Questi intervalli non hanno già lunghezze fisse e portano un significato maggiore: di solito, ognuno riunisce simboli di un alfabeto.

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8
Blocco che contiene simboli dell'alfabeto bengalese. Sfortunatamente, per ragioni storiche, questo è un esempio di imballaggio non molto denso: 96 simboli sono sparsi in modo caotico su 128 punti di codice del blocco.

L'inizio dei blocchi e le loro dimensioni sono sempre un multiplo di 16: questo è stato fatto semplicemente per comodità. Inoltre, molti blocchi iniziano e finiscono con valori multipli di 128 o addirittura 256; ad esempio, la principale scrittura in cirillico occupa 256 byte da 0x0400 fino a 0x04FF. È piuttosto comodo: se una volta memorizziamo il prefisso 0x04, allora in seguito qualsiasi simbolo cirillico può essere registrato in un byte. Tuttavia, in questo modo perderemo la possibilità di tornare all'ASCII (e a qualsiasi altro simbolo in generale). Quindi procediamo così:

  1. Due byte 10yyyyyy yxxxxxxx non solo rappresentano un simbolo con numero yyyyyy yxxxxxxx, ma cambiano l'alfabeto corrente in yyyyyy y0000000 (ossia memorizziamo tutti i bit tranne i più bassi 7 bit);
  2. Un byte 0xxxxxxx è il simbolo dell'alfabeto corrente. Deve semplicemente essere sommato a quell'offset che abbiamo memorizzato nel passo 1. Finché non abbiamo cambiato alfabeto, l'offset è zero, quindi abbiamo mantenuto la compatibilità con l'ASCII.

Analogamente per i codici che richiedono 3 byte:

  1. Tre byte 110yyyyy yxxxxxxx xxxxxxxx rappresentano un simbolo con numero yyyyyy yxxxxxxx xxxxxxxx, cambiano l'alfabeto corrente in yyyyyy y0000000 00000000 (memorizziamo tutto, tranne i più bassi 15 bit), e pongono un flag, che ora siamo in lungo modalità (quando cambiamo nuovamente l'alfabeto su questo flag lo ripristineremo);
  2. Due byte 0xxxxxxx xxxxxxxx in modalità lunga questo è il simbolo dell'alfabeto attuale. Analogamente, lo sommiamo con lo spostamento del passo 1. L'unica differenza è che ora leggiamo due byte (perché ci siamo switchati in tale modalità).

Suona bene: ora, finché dobbiamo codificare simboli dello stesso intervallo Unicode a 7 bit, spendiamo 1 byte extra all'inizio e solo 1 byte per ogni simbolo.

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8
Funziona una delle prime versioni. Già spesso supera UTF-8, ma c'è ancora margine di miglioramento.

Cosa è peggiorato? In primo luogo, abbiamo uno stato, ovvero lo spostamento dell'alfabeto attuale e il flag di modalità lunga. Questo ci limita ulteriormente: ora gli stessi simboli possono essere codificati in modo diverso in contesti differenti. La ricerca di sottostringhe, ad esempio, dovrà tenerne conto, invece di confrontare semplicemente i byte. In secondo luogo, non appena abbiamo cambiato l'alfabeto, è diventato difficile codificare i simboli ASCII (e questo non riguarda solo l'alfabeto latino, ma anche la punteggiatura di base, inclusi gli spazi) — richiedono un cambio di alfabeto a 0, cioè ancora un byte extra (e poi un altro per tornare al nostro principale).

Un alfabeto è buono, due sono meglio

Proviamo a cambiare un po' i nostri prefissi bit, inserendo un altro ai tre sopra descritti:

0xxxxxxx — 1 byte in modalità normale, 2 in modalità lunga
11xxxxxx — 1 byte
100xxxxx xxxxxxxx — 2 byte
101xxxxx xxxxxxxx xxxxxxxx — 3 byte

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8

Ora nella codifica a due byte è diventato disponibile un byte in meno — si possono includere punti di codice fino a 0x1FFF, e non 0x3FFF. Tuttavia, è ancora sensibilmente più dell'UTF-8 a due byte, la maggior parte delle lingue comuni ci sta ancora, la perdita più evidente è stata hiragana e katakana, i giapponesi sono tristi.

Qual è il nuovo codice 11xxxxxx? Это небольшой «загашник» размером в 64 символа, он дополняет наш основной алфавит, поэтому я назвал его вспомогательным (ausiliario) alfabeto. Quando cambiamo l'alfabeto attuale, un pezzo del vecchio alfabeto diventa ausiliario. Ad esempio, se passiamo da ASCII a cirillico — ora abbiamo 64 simboli in serbatoio, contenenti alfabeto latino, numeri, spazio e virgola (le inserzioni più comuni nei testi non-ASCII). Se torniamo su ASCII — l'alfabeto ausiliario diventa la parte principale del cirillico.

Grazie all'accesso a due alfabeti, possiamo gestire un gran numero di testi, avendo costi minimi per il passaggio tra alfabeti (la punteggiatura porterà più spesso a tornare all'ASCII, ma dopo di ciò molti simboli non ASCII verranno già estratti dall'alfabeto supplementare, senza un nuovo passaggio).

Bonus: designando l'alfabeto supplementare con un prefisso 11xxxxxx e scegliendo il suo offset iniziale uguale a 0xC0, otteniamo una parziale compatibilità con CP1252. In altre parole, molti (ma non tutti) i testi dell'Europa occidentale codificati in CP1252 appariranno allo stesso modo anche in UTF-C.

Qui, in effetti, si presenta una difficoltà: come ottenere l'alfabeto ausiliario dall'alfabeto principale? Possiamo mantenere lo stesso offset, ma—ahimè—qui la struttura di Unicode gioca contro di noi. Molto spesso, la parte principale dell'alfabeto non si trova all'inizio del blocco (ad esempio, la maiuscola russa "A" ha il codice 0x0410, mentre il blocco cirillico inizia con 0x0400). Così, prendendo in prestito i primi 64 simboli, potremmo perdere l'accesso alla parte finale dell'alfabeto.

Per risolvere questo problema, ho esaminato manualmente alcuni blocchi corrispondenti a diverse lingue e ho specificato per essi l'offset dell'alfabeto ausiliario all'interno dell'alfabeto principale. Ho riordinato l'alfabeto latino, in via eccezionale, come se fosse base64.

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8

Finiture finali

Infine, riflettiamo su dove possiamo apportare ulteriori miglioramenti.

Notiamo che il formato 101xxxxx xxxxxxxx xxxxxxxx permette di codificare numeri fino a 0x1FFFFF, mentre Unicode termina prima, a 0x10FFFF. In altre parole, l'ultimo punto di codice sarà rappresentato come 10110000 11111111 11111111. Pertanto, possiamo dire che se il primo byte ha l'aspetto 1011xxxx (dove xxxx maggiore di 0), allora significa qualcos'altro. Ad esempio, possiamo aggiungere altri 15 simboli, costantemente disponibili per la codifica in un byte, ma ho deciso di procedere diversamente.

Guardiamo ai blocchi di Unicode che richiedono tre byte ora. In massima parte, come già detto, sono ideogrammi cinesi—ma con essi è difficile fare qualcosa, sono 21 mila. Ma ci sono anche hiragana e katakana—e sono già meno di duecento. E, visto che abbiamo menzionato i giapponesi—lì ci sono anche le emoji (in realtà sono sparse in molte parti di Unicode, ma i blocchi principali sono nell'intervallo 0x1F3000x1FBFF). Se ci pensiamo, ci sono emoji che sono composte da più punti di codice (ad esempio, emoji ‍‍‍Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8 che consistono in 7 codici!), ci viene davvero da piangere a pensare di sprecare tre byte per ognuna (7×3 = 21 byte per un singolo simbolo, è un incubo).

Perciò, scegliamo alcuni intervalli selezionati, corrispondenti a emoji, hiragana e katakana, li rinumeriamo in un'unica lista continua e li codifichiamo in due byte invece di tre:

1011xxxx xxxxxxxx

Ottimo: l'emoji sopra citato ‍‍‍Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8, che consiste in 7 punti di codice, in UTF-8 occupa 25 byte, mentre noi lo abbiamo ridotto a 14 (proprio due byte per ogni punto di codice). A proposito, Habr ha rifiutato di elaborarlo (sia nella vecchia che nella nuova editor), quindi abbiamo dovuto inserirlo come immagine.

Proviamo a risolvere un'altra questione. Come ricordiamo, l'alfabeto principale è essenzialmente i 6 bit superiori, che teniamo a mente, e li applichiamo al codice di ogni simbolo decodificato. Nel caso dei caratteri cinesi, che si trovano nel blocco 0x4E000x9FFF, questo corrisponde a 0 o 1. Non è molto comodo: ci toccherà continuamente cambiare l'alfabeto tra questi due valori (ossia utilizzare tre byte). Ma notiamo che nella modalità lunga possiamo sottrarre dal codice il numero di simboli che stiamo codificando con la modalità corta (dopo tutti i trucchi sopracitati, sono 10240) — in quel caso, l'intervallo dei caratteri si sposterà a 0x26000x77FF, e in questo caso in tutto questo intervallo i 6 bit superiori (su 21) saranno uguali a 0. In questo modo, le sequenze di caratteri utilizzeranno due byte per carattere (che è ottimale per un intervallo così ampio), senza richiedere cambi di alfabeto.

Soluzioni alternative: SCSU, BOCU-1

Gli esperti di Unicode, appena leggono il titolo dell'articolo, probabilmente si affretteranno a ricordare che tra gli standard Unicode c'è Standard Compression Scheme for Unicode (SCSU), che descrive un modo di codifica molto simile a quanto descritto nell'articolo.

Confesso francamente: ho scoperto la sua esistenza solo dopo essermi immerso completamente nella scrittura della mia soluzione. Se l'avessi conosciuto fin dall'inizio, probabilmente avrei provato a scrivere la sua implementazione invece di inventare il mio approccio.

Ciò che è interessante, SCSU utilizza idee molto simili a quelle a cui sono arrivato autonomamente (anziché il concetto di "alfabeti", usano "finestre", e ne hanno più di quante ne abbia io). Allo stesso tempo, questo formato ha anche dei difetti: è un po' più vicino agli algoritmi di compressione piuttosto che alla codifica. In particolare, lo standard offre moltissimi modi di rappresentazione, ma non detto come scegliere il migliore — per questo, l'encoder deve applicare alcune euristiche. In questo modo, l'encoder SCSU, che fornisce un buon imballaggio, sarà più complesso e ingombrante rispetto al mio algoritmo.

A titolo di confronto, ho trasferito una realizzazione relativamente semplice di SCSU in JavaScript — per quantità di codice è risultata comparabile al mio UTF-C, ma in alcuni casi ha mostrato risultati peggiori di decine di punti percentuali (a volte può anche eccederlo, ma non di molto). Ad esempio, i testi in ebraico e greco sono stati codificati da UTF-C il 60% meglio rispetto a SCSU (probabilmente a causa dei loro alfabeti compatti).

Aggiungo separatamente che, oltre a SCSU, c'è anche un altro modo di rappresentare in modo compatto Unicode — BOCU-1, ma questo è mirato alla compatibilità con MIME (cosa di cui non avevo bisogno), e utilizza un approccio di codifica leggermente diverso. Non ho valutato la sua efficacia, ma mi sembra improbabile che sia migliore di SCSU.

Possibili miglioramenti

L'algoritmo che ho presentato non è universale per progetto (qui, probabilmente, i miei obiettivi divergono di più da quelli del consorzio Unicode). Ho già menzionato che è stato sviluppato principalmente per un compito (memorizzare un dizionario multilingue in un albero di prefissi), e alcune delle sue caratteristiche potrebbero non adattarsi bene ad altre esigenze. Tuttavia, il fatto che non sia uno standard può essere un vantaggio — puoi facilmente adattarlo alle tue necessità.

Ad esempio, è ovvio che è possibile eliminare lo stato, rendere la codifica stateless — semplicemente non aggiornando le variabili fuori, ausOffs e is21Bit nell'encoder e nel decoder. In questo caso, non sarà possibile imballare in modo efficiente sequenze di caratteri dello stesso alfabeto, ma ci sarà la garanzia che lo stesso carattere sarà sempre codificato con gli stessi byte, indipendentemente dal contesto.

Inoltre, è possibile affinare l'encoder per una lingua specifica, cambiando lo stato predefinito — ad esempio, orientandosi su testi russi, impostare all'inizio dell'encoder e del decoder. offset = 0x0400 e auxOffs = 0. In particolare, questo ha senso nel caso della modalità stateless. In generale, sarà simile all'uso di una vecchia codifica a otto bit, ma non priva della possibilità di inserire caratteri da tutto il Unicode secondo necessità.

Un altro svantaggio, menzionato in precedenza, è che nel testo voluminoso codificato in UTF-C non esiste un modo veloce per trovare il confine del carattere più vicino a un byte arbitrario. Tagliando dal buffer codificato gli ultimi, diciamo, 100 byte, è possibile ottenere spazzatura con cui non si può fare nulla. La codifica non è progettata per memorizzare log di diversi gigabyte, ma in generale questo può essere corretto. Un byte 0xBF non dovrebbe mai apparire come primo byte (ma può essere secondo o terzo). Pertanto, durante la codifica, è possibile inserire una sequenza 0xBF 0xBF 0xBF ogni, ad esempio, 10 Kb: allora, se necessario, sarà sufficiente scansionare il pezzo selezionato fino a quando non viene trovato un simile marcatore. Dopo l'ultimo 0xBF ci sarà garantito l'inizio di un carattere. (Durante la decodifica, questa sequenza di tre byte, ovviamente, dovrà essere ignorata.)

In sintesi

Se sei arrivato fin qui, congratulazioni! Spero che, come me, tu abbia imparato qualcosa di nuovo (o rinfrescato la memoria su qualcosa di vecchio) sul funzionamento di Unicode.

Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8
Pagina dimostrativa. L'esempio dell'ebraico mostra i vantaggi sia rispetto all'UTF-8 che all'SCSU.

Non bisogna considerare le ricerche sopra descritte come un attacco agli standard. Tuttavia, sono complessivamente soddisfatto dei risultati del mio lavoro, quindi sono felice di condividerli: ad esempio, la libreria JS in forma minificata pesa solo 1710 byte (e non ha dipendenze, ovviamente). Come ho accennato prima, è possibile familiarizzare con il suo funzionamento sulla pagina demo (là puoi trovare anche un insieme di testi con cui confrontarla con UTF-8 e SCSU).

Infine, vorrei richiamare ancora l'attenzione sui casi in cui utilizzare UTF-C non è conveniente:

  • Se le tue stringhe sono abbastanza lunghe (da 100-200 caratteri). In tal caso, vale la pena considerare l'uso di algoritmi di compressione come deflate.
  • Se hai bisogno di una trasparenza ASCII, ossia è importante per voi che nelle sequenze codificate non compaiano codici ASCII che non erano nella stringa originale. Potete evitare questa necessità se, interagendo con API esterne (ad esempio, lavorando con database), trasmettete il risultato della codifica come un insieme astratto di byte e non come stringhe. Altrimenti, rischiate di ottenere vulnerabilità impreviste.
  • Se desiderate avere la possibilità di trovare rapidamente i confini dei caratteri a partire da uno spostamento arbitrario (ad esempio, in caso di danneggiamento di una parte della stringa). Questo è possibile, ma solo scansionando la stringa dall'inizio (o applicando le modifiche descritte nella sezione precedente).
  • Se avete bisogno di eseguire rapidamente operazioni sul contenuto delle stringhe (ordinarle, cercare sottostringhe, concatenarle). Per questo le stringhe devono prima essere decodificate, quindi UTF-C sarà più lento di UTF-8 in questi casi (ma più veloce degli algoritmi di compressione). Poiché la stessa stringa è sempre codificata nello stesso modo, il confronto esatto della decodifica non è necessario, può essere effettuato byte per byte.

Aggiornamento: utente tyomitch nei commenti qui sotto ha pubblicato un grafico che evidenzia il confine di applicabilità di UTF-C. Si può vedere che UTF-C è più efficiente di un algoritmo di compressione generico (varianti LZW) fino a quando la stringa compressa è più corta ~140 caratteri (in verità, segnalo che il confronto è stato effettuato su un unico testo; per altre lingue il risultato potrebbe differire).
Un'altra bicicletta: memorizziamo le stringhe Unicode da 30-60% più compatte rispetto a UTF-8

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster