Coś o Unicode

Tym razem coś o kwestii obsługi znaków i stringów. Na początek zagadka: co wypisze ten program?

RegExp

To oczywiście poprawny program Go, w pliku z kodowaniem UTF-8 i zgodnie ze specyfikacją języka nawet w nazewnictwie identyfikatorów nie trzeba się ograniczać do ASCII, zatem literał łańcucha znaków to już w ogóle odjazd.

Program jest prosty, ale dla pewności: budujemy wyrażenie regularne z jednym znakiem “🇵” (dlatego program przedstawiam w postaci zrzutu ekranu edytora – pojęcia nie mam jak to zobaczy czytelnik) i sprawdzamy, czy do wyrażenia tego pasuje ciąg “łąka 🇵🇱️ polska”.

Jeśli MatchString zwraca true, to wypisywany jest tekst “pasuje”, a w przeciwnym wypadku – “nie pasuje”.

Dzięki Unicode zarówno 🇵 jak i 🇵🇱️ są “literkami” i mogą być częścią stringów.

Okazuje się, że tekst z flagą pasuje do wyrażenia regularnego, co w sumie stanie się dość oczywiste, gdy sprawdzimy czym dokładnie są emoji użyte w obu literałach.

Ale do tego inny program – tym razem w C, żeby było bardziej różnorodnie (i nie wydawało się, że go jest jakiś specjalny w zakresie obsługi Unicode'a).

Iteracja

Program kompiluję za pomocą GCC wersji 9.3.0 i jego domyślnym standardem c jest C11. Uruchomiony daje następujący wynik:

Iteracja-out

Ciekawostka, prawda?

Jak widać w linii 29, nasz napis to "ał€🍌🇵🇱️". Składa się on z pięciu znaków: * litery a, * litery ł, * banana 🍌, oraz * flagi 🇵🇱️.

Wrzuciłem zrzut ekranu konsoli (gnome-terminal), bo jeśli wkleję ten wynik na stronę, to mam

C Standard Version 201710, MB_CUR_MAX/MB_LEN_MAX=6/16
locale pl_PL.UTF-8
Napis (21 bajtów): 'ał€🍌🇵🇱️'
U-00000061 (1) a
U-00000142 (2) ł
U-000020ac (3) €
U-0001f34c (4) 🍌
U-0001f1f5 (4) 🇵
U-0001f1f1 (4) 🇱
U-0000fe0f (3) ️

Ale dalej iterowanie po znakach po bananie (🍌), zamiast flag (🇵🇱️) daje nam dwie emotki: 🇵 i 🇱.

To jeszcze, co robi ten program: * (linia 29) inicjuje treść zmiennej napis, * (linia 30) ustawia “locale” programu (jeśli tu nic nie ustawimy, to domyślnie mamy w C lokalizację en_EN.C – to po kropce to kodowanie znaków) * (linie 31,32) wyświetlenie kilku informacji * (linia 33) Wypisanie rozmiaru tablicy “napis” oraz jej treści jako łańcucha znaków)

I tam zamiast flagi wyświetla nam się 🇵 🇱, ale też – na co należy zwrócić uwagę – nie są to dwie osobne ikonki, ale te litery P i L są dość blisko siebie (jakby połączone).

Funkcja iterate – jak nazwa wskazuje – iteruje sobie po znakach (literach?) napisu i dla każdej pozycji wyświetla nam Code point znaku (w formacie U-xxxxxxxx), długość reprezentacji UTF-8 tego znaku oraz sam znak.

Tu parę wyjaśnień:

Code point – jest to identyfikator numeryczny znaku (albo litery, ale żadne z tych słów nie jest poprawne i stąd pojęcie “code point”). W ASCII codepointy był dość jednoznaczne – znaki wyrażamy na pojedynczym bajcie, z czego 7 bitów jest zdefiniowanych w ASCII i oznaczają konkretne znaki/litery (albo i nie, bo te poniżej spacji znakami niejednokrotnie nie są). Taka litera 'a' ma heksadecymalnie wartość 61, a spacja 20. W unicode znaków jest obecnie ponad 140.000 więc na jednym bajcie się nie mieszczą. Żeby wyrazić każdy dopuszczalny od 0 do 10FFFF potrzebne są cztery bajty ale litery z alfabetu łacińskiego (albo angielskiego) mają dalej te same pozycje, które miały w ASCII i w przypadku testów angielskich stosowanie czterech bajtów na znak jest nieprzyzwoicie nadmiarowe.

Stąd mamy różne możliwe formy kodowania znaków Unicode:

UTF-32 (inna nazwa UCS-4) – cztery bajty na code point i można w nim wyrazić wszystko.

UTF-16 (który wyewoluował z UCS-2) – dwa bajty (16 bitów) na znak. Wcześniejszy USC-2 był kodowaniem o stałej liczbie bajtów na znak (właśnie 2 bajty) i pozwalał wyrazić 2¹⁶ (2^16, jeśli nie widać) wartości. W latach '90 to był już cały Unicode (i stąd pomysły typu wchar, i char w Javie, który ma rozmiar dwóch bajtów), ale potem Unicode się rozrósł i to przestało wystarczać. Jest więc metoda, aby w sytuacji, gdy code point wymaga więcej niż 16 bitów (Supplementary Plane :) ) zakodować go na dwóch “wchar'ach”. Chwilowo nie ma znaczenia jak to się robi, ale faktem jest, że wide chary'y to obecnie porażka i wyrażenie jakis_string[3] w Javie może trafiać w pół znaku i nie zwracać niczego sensownego.

UTF-16 jest rozwiązaniem zrobionym trochę na wzór wymyślonego na potrzeby systemu Plan 9 kodowania UTF-8. W tym kodowaniu od początku wymyślono, że stosowana będzie zmienna liczba bajtów na code point i UTF-8 ma być kompatybilny z ASCII.

Metoda kodowania UTF-8 jest dość prosta:

Oryginalnie UTF-8 mógł maksymalnie kodować wartości na sześciu bajtach ale Unicode zrównało go z UTF-16 i maksem jest 4 bajty (i podobno więcej code pointów już nie będzie, więc większe liczby nie są potrzebne).

Przy okazji – standard stanowi, że należy kodować znaki na najmniejszej możliwej liczbie bajtów – zakodowanie litery “A” na czterech bajtach jest niepoprawne.

Co z tego wynika?

Otóż, jeśli trafimy na bajt, którego najstarszym bitem jest zero, to bajt ten opisuje kompletny code point (i jest to jakaś litera/znak ASCII).

Jeśli bajt zaczyna się od bitów 10, to jesteśmy gdzieś w środku znaku – powinniśmy przesunąć się w którąś stronę (o ile się w ogóle da), żeby odszukać początek tego znaku, albo następnego, i dopiero wtedy przeczytać całość.

W każdym innym wypadku liczba najstarszych bitów o wartości 1, po których następuje bit o wartości 0 mówi nam z ilu bajtów składa się znak (przy czym zgodnie ze standardem znak składa się maksymalnie z 4 bajtów).

Jedynym znakiem o wartości zero jest znak zakodowany jako 00000000. Dzięki czemu nie może się zdarzyć, że na skutek jakiegoś kodowania, gdzieś w środku stringu mamy bajt zerowy i funkcja strlen w C zwróci nam niepoprawną (bo za małą) wartość.

Znaki łatwo czyta się ze strumienia – pierwszy bajt od razu powie nam ile bajtów musimy doczytać, żeby uzyskać kompletny znak.

Jeśli strumień się urwie, to jesteśmy w stanie stwierdzić, czy mamy kompletny znak, czy nie.

Znaki zakodowane w UTF-8 da się sortować tak samo jak ich wartości wyrażone wprost jako liczby 32-bitowe:

Ciąg zaczynający się od znaku 01110000 wystąpi przed ciągiem rozpoczynającym się od znaku 1101000 10001111.

Nie ma problemu z big endian/little endian (jak w przypadku UTF-16).

Jedyne o czym trzeba pamiętać, to fakt, że znaki mogą mieć różną długość i indeksowanie tablicy bajtów (lub short-ów jak to ma miejsce w Javie, .NET, czy C/C++ z użyciem wchar) nie działa i nie może działać.

Stąd iteracja napisu w przedstawionym wyżej programiku C jest zrobiona w jedyny poprawny sposób, jeśli program ma obsługiwać pełny Unicode.

Wracając do wyniku tego programu:

C Standard Version 201710, MB_CUR_MAX/MB_LEN_MAX=6/16
locale pl_PL.UTF-8
Napis (21 bajtów): 'ał€🍌🇵🇱️'
U-00000061 (1) a
U-00000142 (2) ł
U-000020ac (3) €
U-0001f34c (4) 🍌
U-0001f1f5 (4) 🇵
U-0001f1f1 (4) 🇱
U-0000fe0f (3) ️

MB_LEN_MAX jest stałą i opisuje absolutne maksimum liczby bajtów, na których kodowane są znaki (wartość 16 raczej jest nieosiągalna w żadnym sensownym kodowaniu – to po prostu rozmiar jakiegoś bufora biblioteki libc).

MB_CUR_MAX to pseudo stała, która na podstawie ustawionych locale (setlocale) mówi jaki jest faktyczny max. Ponieważ locale na moim Linuxie są ustawione jako pl_PL.UTF-8, to jej wartość jest 6 (jak pisałem wcześniej UTF-8 był projektowany tak, żeby kodować wartości maksymalnie na 6 bajtach).

Nasz napis zajmuje 21 bajtów (z czego ostatni do dodawany do literałów C znak \x00).

A potem mamy opisany każdy ze znaków:

Te trzy ostatnie znaki to właśnie flaga Polski.

Zgodnie z definicją:

The Flag: Poland emoji is a flag sequence combining 🇵 Regional Indicator Symbol Letter P and 🇱 Regional Indicator Symbol Letter L. These display as a single emoji on supported platforms.

Flagi to swego rodzaju ligatury, czyli zbitki kilku liter występujące jako jedna (np. æ – a + e, albo fi – czyli f oraz i połączone tak, żeby kropka od i nie popsuła końcówki f). To dlatego reprezentacja flagi na konsoli była widoczna jako emoji P oraz L stojące blisko siebie.

Trzeci (ostatni) znak, to U-FE0F, którego znaczenie jest następujące:

An invisible codepoint which specifies that the preceding character should be displayed with emoji presentation. Only required if the preceding character defaults to text presentation.

To – jak definicja wskazuje – pozwala zdecydować, czy chcemy mieć graficzną reprezentację code pointa zamiast tekstowej (przy czym tekstowa oznacza “monochromatyczną”, bo fonty do czasów emoji były monochromatyczne – albo jakiś punkt świeci, albo nie. Emoji w fonatach wprowadziło nietrywialny aspekt dodania kolorków). Nie gwarantuje jednak, że zostanie zawsze wybrana oczekiwana reprezentacja – to już zależy od fontów i oprogramowania, które w przypadku zestawu znaków, musi rozumieć ich intencję (rendering konsoli – jak widać nie rozumie, ale edyto gedit, oraz przeglądarka firefox – owszem).

Ogólnie rzecz biorąc, wyświetlanie znaków obecnie to temat znacznie bardziej skomplikowany, niż w czasach gdy zmianę wielka/mała litera realizowało się modyfikacją trzeciego najstarszego bitu...

Ale całym tym zagadnieniem zainteresowałem się w sumie z tego powodu, że dość zaskakujące wydało mi się, że poważne konsorcjum Unicode kolekcjonuje te wszystkie pierdołowate emoji.

Tymczasem przyczyna jest prosta – nikomu by się nie chciało zgłębiać problematyki obsługi znaków spoza Basic Multilingual Plane (pierwsze 64K code pointów) po to, żeby wyświetlić staroegipskie hieroglify albo nuty chorałów gregoriańskich.

Ale żywotną potrzebą społeczeństwa jest wyświetlanie tego: 💩.

I właśnie dzięki tej kupie cały praktycznie świat obsługuje całościowo Unicode!

Kontakt ze mną: @ark_r@mastodon.social