Otwórz plik zapisany w UTF-8 w narzędziu, które zakłada inne kodowanie, i café zmienia się w café. Bajty na dysku się nie zmieniły. Zmieniła się tylko reguła odczytu, czyli sposób zamiany bajtów z powrotem na znaki, a te dwie reguły różnie rozumieją, co znaczy bajt 0xC3.

Większość błędów z “dziwnymi znakami” ma to samo źródło: połamane nazwy plików, zmasakrowane formularze, emoji, które wywala insert do bazy danych, dwa stringi wyglądające identycznie, ale różne w porównaniu. Za każdym z nich stoi ta sama pomyłka: mylenie Unicode z UTF-8, dwóch zupełnie różnych rzeczy.

Unicode a UTF-8: punkty kodowe to nie bajty

Unicode to jedna tabela, która przypisuje każdemu znakowi numer, zwany punktem kodowym, zapisywany jako U+ i liczba szesnastkowa: A to U+0041, é to U+00E9, 😀 to U+1F600. Tabela sięga od U+0000 do U+10FFFF. Obejmuje każde pismo używane obecnie lub historycznie, plus symbole, emoji i zakresy zarezerwowane na użytek prywatny.

Unicode nic nie mówi o bajtach. Odpowiada na jedno pytanie: jaki numer ma ten znak? Zamiana tego numeru na bajty, które można zapisać w pliku albo wysłać przez socket, to osobne zadanie, wykonywane przez kodowanie. UTF-8, UTF-16 i UTF-32 to trzy kodowania tej samej tabeli. Zapisują te same punkty kodowe jako różne sekwencje bajtów.

To ten sam rodzaj rozjazdu, który sprawia, że 0.1 + 0.2 zwraca coś innego niż 0.3: wartość abstrakcyjna i bity w pamięci to dwie różne warstwy, a błąd wychodzi w chwili, gdy zaczynasz je traktować jako wymienne.

Jak UTF-8 zamienia punkt kodowy na bajty

UTF-8 ma zmienną szerokość. Każdy punkt kodowy zamienia się na 1 do 4 bajtów, używając tylko tylu, ile potrzebuje.

Zakres punktu kodowegoUżyte bajtyWzorzec bajtów
U+0000–U+007F10xxxxxxx
U+0080–U+07FF2110xxxxx 10xxxxxx
U+0800–U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000–U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Każdy bajt zaczynający się od 10 jest bajtem kontynuacji: fragmentem znaku wielobajtowego, nigdy znakiem samym w sobie. Dzięki temu kodowanie jest samosynchronizujące. Wejdź w środek strumienia UTF-8, przeskanuj wstecz przez bajty kontynuacji, i trafisz na początek znaku.

Pierwszy wiersz wyjaśnia, czemu UTF-8 wygrało. Punkty kodowe U+0000–U+007F to dokładnie ASCII, zapisane tym samym jednym bajtem co w ASCII, więc każdy czysto ASCII-owy plik jest już poprawnym UTF-8, bajt po bajcie. Wszystko poza tym zakresem kosztuje dodatkowe bajty. é to U+00E9, w drugim wierszu, więc zajmuje dwa:

1
2
>>> "é".encode("utf-8")
b'\xc3\xa9'

😀 to U+1F600, w czwartym wierszu, więc zajmuje cztery:

1
2
>>> "😀".encode("utf-8")
b'\xf0\x9f\x98\x80'

Ta para 0xC3 0xA9 to winowajca z przykładu na początku. Zdekoduj ją jako UTF-8 i dostajesz é. Zdekoduj jako Latin-1, które mapuje każdy bajt na jeden znak, i 0xC3 staje się Ã, a 0xA9 staje się ©. Nic w surowym strumieniu bajtów nie zapisuje, jakie kodowanie go wyprodukowało. Bajty nie są błędne. Błąd tkwi w założeniu tego, kto je odczytuje.

Czemu .length nie liczy znaków

“Jak długi jest ten string” ma trzy różne odpowiedzi, bo “znak” może znaczyć trzy różne jednostki.

len() w Pythonie liczy punkty kodowe:

1
2
3
4
>>> len("😀")
1
>>> len("😀".encode("utf-8"))
4

Jeden punkt kodowy na str, cztery bajty na zakodowanym bytes. Dwa różne pytania, dwie różne odpowiedzi.

Stringi w JavaScripcie są wewnętrznie UTF-16, a .length liczy 16-bitowe jednostki kodowe, nie punkty kodowe. Punkty kodowe powyżej U+FFFF, co obejmuje każde emoji, potrzebują dwóch jednostek UTF-16, zwanych parą zastępczą:

1
2
"😀".length;              // 2  (para zastępcza, nie jeden znak)
[..."😀"].length;         // 1  (spread iteruje po punktach kodowych)

Emoji rodziny "👨‍👩‍👧‍👦" robi to jeszcze gorzej. To 7 punktów kodowych: 4 emoji osób połączone 3 łącznikami zero-width joiner. Każde z 4 emoji jest parą zastępczą, więc .length zgłasza 11. To, co czytelnik widzi jako jeden znak, jest grapheme clusterem, i może rozciągać się na dowolną liczbę punktów kodowych. Przecięcie lub odwrócenie stringa po jednostce kodowej albo po punkcie kodowym może rozerwać to emoji na pół. Intl.Segmenter liczy tak, jak zrobiłby to człowiek:

1
2
const graphemes = [...new Intl.Segmenter().segment("👨‍👩‍👧‍👦")];
graphemes.length;  // 1

Zmienna szerokość ma też swój koszt. W buforze UTF-8 znalezienie 50. punktu kodowego oznacza przejście od początku i liczenie, więc indeksowanie po pozycji znaku jest O(n), a nie odczytem w czasie stałym, jaki dostajesz z tablicy o stałej szerokości. Języki radzą sobie z tym różnie. Rust i Go przechowują stringi jako UTF-8 i indeksują je po bajtach, więc iterujesz, gdy chcesz znaków. CPython przechowuje każdy str ze stałą szerokością znaku, ale ustalaną osobno dla każdego stringa (1, 2 lub 4 bajty na punkt kodowy, zależnie od najszerszego znaku, jaki zawiera), co trzyma s[i] w czasie stałym za cenę dodatkowej pamięci, gdy tylko pojawi się jedno emoji.

Częste błędy kodowania UTF-8 w produkcji

Obcinanie po liczbie bajtów. Ucięcie stringa UTF-8 na równo 100 bajtach czasem trafia w środek znaku wielobajtowego, zostawiając częściową sekwencję, która nie da się zdekodować. Obcinaj po liczbie punktów kodowych, albo obcinaj bajty i dekoduj z errors="ignore", akceptując, że ostatni częściowy znak zostanie odrzucony. Żadne z tych podejść nie respektuje grapheme clusterów: cięcie po punkcie kodowym wciąż może oddzielić e od jego kombinującego akcentu albo rozerwać emoji rodziny. W tekście, który zobaczy użytkownik, przecinaj na granicach grafemów.

Zostawienie kodowania domyślnego przy operacjach na plikach. open("data.txt") bez argumentu encoding= używa lokalnego kodowania systemu operacyjnego. Na Linuksie i macOS to zwykle UTF-8. Na Windows historycznie była to strona kodowa cp1252. Czytanie pliku UTF-8 ze złym domyślnym kodowaniem daje UnicodeDecodeError albo ciche mojibake, zależnie od bajtów. PEP 686 robi z UTF-8 domyślne kodowanie wszędzie od Pythona 3.15, ale wiele kodu wciąż działa na starszych wersjach albo ustawia PYTHONUTF8=0. Podaj kodowanie wprost, i problem przestaje istnieć:

1
2
with open("data.txt", encoding="utf-8") as f:
    text = f.read()

Kolumna MySQL, która jest UTF-8 tylko z nazwy. Zestaw znaków utf8 w MySQL (dziś alias dla utf8mb3) przechowuje maksymalnie 3 bajty na znak. Nie może zawierać punktów kodowych powyżej U+FFFF, więc żadnych emoji i niczego poza Basic Multilingual Plane. Wstaw 😀 do kolumny utf8 i dostajesz Incorrect string value. Rozwiązaniem jest utf8mb4, pełna implementacja UTF-8 w MySQL i domyślne kodowanie od MySQL 8.0. Konwersja tabeli to ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4, wykonywana per tabela, a zestaw znaków połączenia też musi być utf8mb4, inaczej klient uszkodzi emoji, zanim dotrą do kolumny. Na starszych row formatach InnoDB uważaj na limit 767 bajtów prefiksu indeksu: zaindeksowany VARCHAR(255) zmieści się w utf8mb3 (765 bajtów), ale nie w utf8mb4 (1020 bajtów).

Dwie sekwencje bajtów dla tego samego widocznego znaku. é może być pojedynczym, złożonym punktem kodowym U+00E9 (forma NFC), albo literą e (U+0065) z kombinującym akcentem ostrym U+0301 (forma NFD). Wyglądają identycznie, ale różnią się bajt po bajcie, więc dwa stringi café mogą się okazać różne przy porównaniu, zależnie od tego, gdzie każdy był wpisany albo wklejony. Nazwy plików na macOS i tekst skopiowany z niektórych PDF-ów często przychodzą w NFD. Słownik zaindeksowany jedną formą może po cichu nie znaleźć wartości zapisanej w drugiej. Normalizuj przed porównaniem albo zapisem:

1
2
3
4
5
6
7
>>> import unicodedata
>>> a = "café"      # złożone é (NFC)
>>> b = "café"     # e + kombinujący akcent ostry (NFD)
>>> a == b
False
>>> unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b)
True

Niechciany znacznik kolejności bajtów. Plik UTF-8 może zaczynać się 3 bajtami EF BB BF, czyli BOM-em, który zapisują niektóre edytory (między innymi stary Notatnik Windows), żeby oznaczyć kodowanie. UTF-8 nie ma kolejności bajtów do oznaczania, więc ten BOM nie niesie żadnej informacji. Jest niewidoczny w większości edytorów, ale zdekodowanie go zwykłym utf-8 zostawia znak  na początku stringa, co wywala parsery JSON w trybie strict i sprawia, że interpreter nie rozpozna shebanga #!. Gdy oczekujesz BOM-u, dekoduj Pythonowym kodekiem utf-8-sig, a sam nigdy go nie zapisuj.

Jak zatrzymać błędy kodowania na granicy

Wybierz UTF-8 i deklaruj to wprost wszędzie, gdzie tekst wchodzi do programu albo z niego wychodzi: odczyty plików, połączenia z bazą danych, ciała żądań HTTP, potoki do podprocesów. Dekoduj do stringa, jak tylko bajty dotrą, pracuj wewnętrznie na zdekodowanych stringach, i koduj z powrotem do bajtów tylko na kolejnej granicy. Trzymaj errors="strict" (domyślne w Pythonie), żeby złamana sekwencja bajtów rzucała wyjątek, a nie po cichu zamieniała się w U+FFFD albo zupełnie znikała.

Potem testuj na danych, które nie są ASCII. Fixture zawierające tylko U+0000–U+007F przepuszczają błędy kodowania do produkcji, bo każda ścieżka kodu działa przypadkowo przy jednym bajcie na znak, aż imię prawdziwego użytkownika złamie to założenie. Dodaj dwa przypadki do każdego poziomu testów, który już piszesz dla każdego pola tekstowego, pola wyszukiwania, obsługi nazw plików albo eksportu: string z literą z akcentem, zapisaną w NFC i w NFD, oraz jeden z emoji rodziny. Jeśli przetrwają podróż tam i z powrotem przez twoje magazynowanie danych i wrócą identyczne, granice robią swoją pracę.

Powiązane artykuły