7.5 Szövegkódolás, locale és UTF-8 C-ben

Egy hálózatról érkező adatfolyam oktettekből áll; attól válik szöveggé, hogy egy kódolási szerződés szerint értelmezzük. A C char típusa önmagában nem fejez ki ilyen szerződést: tárolhat szövegrészletet, terminátort vagy bináris adatot is.

A Nullterminált sztring és a hossz szerződése rész alapján már tudjuk, hogy a nullterminált sztring és a pointer–hossz pár eltérő interfész. Most ehhez hozzáadjuk a kódolást: egy tartomány lehet memóriahatár szempontjából érvényes, miközben hibás UTF-8-at tartalmaz.

7.5.1 Bájt, kódpont és megjelenített karakter

Az Egész típusok és adatmodellek jelölését követve egy C bájt CHAR_BIT bitből áll. Az oktett pontosan nyolcbites egység. Az UTF-8 oktettsorozatokkal definiált kódolás; a fejezet konkrét példáiban ezért CHAR_BIT == 8 az előfeltétel.

A Unicode-kódpont egy azonosító a U+0000–U+10FFFF tartományban. A Unicode skalárérték olyan kódpont, amely nem tartozik a U+D800–U+DFFF surrogate tartományba. Érvényes UTF-8 csak skalárértékeket kódol.

A felhasználó által egyetlen karakternek érzékelt egységet gyakran grafémaklaszterként (grapheme cluster) közelítjük. Ez több kódpontból is állhat, és nem azonos sem a kirajzolt glyph-ek számával, sem a termináloszlopszélességgel.

Szöveg UTF-8 oktettek hexadecimálisan Kódpontok száma Grafémaklaszterek száma
A 41 1 1
á C3 A1 1 1
a és utána kombináló ékezet 61 CC 81 2 1

Az utolsó két sor megjelenése lehet azonos, a bájtsorozat mégsem az. A bájtonkénti összehasonlítás ezért nem általános nyelvi szöveg-összehasonlítás. A normalizálás és a grafémák teljes kezelése külön Unicode-feldolgozást igényel.

7.5.1.1 Forráskódolás és végrehajtási kódolás

ISO C17: a forrásfájl fizikai kódolása és a közönséges sztringliterálok végrehajtási kódolása (execution encoding) külön kérdés. Egy UTF-8-ban mentett forrásfájl önmagában nem garantálja, hogy a benne szereplő "á" literál UTF-8 oktetteket eredményez.

A "\u00E1" univerzális karakterneve egyértelműsíti a szándékolt karaktert, de a közönséges literál továbbra is a végrehajtási karakterkészlet szabályait követi. Ezzel szemben a C17 u8"\u00E1" UTF-8 sztringliterál; nyolcbites C bájt mellett tartalma C3 A1 00.

C17-ben az ilyen literál is char elemtípusú tömb. A típus tehát nem különbözteti meg az UTF-8 szöveget más char adatoktól. A kódolás az API dokumentált szerződésének része marad.

7.5.2 char, karakterosztályozás és locale

7.5.2.1 A <ctype.h> előfeltétele

ISO C17: a sima char előjelessége implementációfüggő. Ha előjeles, egy UTF-8 oktett char értékként negatív lehet.

Az isspace, isalpha, tolower és a többi <ctype.h> függvény argumentuma csak:

lehet. Más argumentum nem definiált viselkedést okoz.

Egy érvényes text[i] tömbelem osztályozásának megfelelő alakja ezért isspace((unsigned char)text[i]). A konverzió helyreállítja a bájt nemnegatív értékét az egészpromóció előtt.

Ez nem Unicode-dekódolás. Egy többoktettes UTF-8 kódolás minden oktettjére külön meghívott isalpha nem dönti el, hogy a kódpont betű-e; a bájtonkénti tolower pedig nem általános Unicode kisbetűsítés.

Az EOF külön figyelmet igényel: például az fgetc eredményét először int változóban kell megvizsgálni. Az unsigned char konverzió után már nem különböztethető meg megbízhatóan a fájlvég egy adatbájttól.

7.5.2.2 Az LC_CTYPE globális szerződése

ISO C17: a <locale.h> setlocale függvénye módosítja a program locale-beállítását. Az LC_CTYPE kategória többek között a karakterosztályozásra és a multibájtos konverziókra hat.

Induláskor a szabványos környezet a "C" locale-nak felel meg. A setlocale(LC_CTYPE, "") a környezet által meghatározott beállítást kéri; sikertelenségkor null pointert ad vissza.

A siker nem bizonyítja, hogy a kiválasztott kódolás UTF-8. Az olyan nevek, mint a "C.UTF-8", nem ISO C által garantált locale-nevek; elérhetőségük az adott libc-től és telepítéstől függ.

A globális állapot problémája szolgáltatásokban különösen fontos. Egy könyvtár locale-váltása más komponensek értelmezését is megváltoztathatja. Jó alapelv, hogy az alkalmazás inicializáláskor állítja be a szükséges locale-t, és feldolgozás közben nem változtatja. Egy rögzítetten UTF-8 hálózati protokollhoz gyakran jobb locale-független dekódert választani.

7.5.3 Széles karakterek és állapotot hordozó konverziók

7.5.3.1 A wchar_t nem univerzális Unicode-típus

ISO C17: a wchar_t széles karakterek tárolására szolgáló egész típus, de mérete és értelmezése implementációfüggő. Sem a neve, sem a sizeof(wchar_t) értéke nem bizonyítja, hogy minden eleme egy Unicode-kódpont.

Linux/glibc környezetben gyakori a 32 bites, Unicode-hoz illeszkedő megvalósítás, de ez implementációs tulajdonság, nem hordozható nyelvi garancia.

Header Konverziós függvények Kimeneti/bemeneti egység
<wchar.h> mbrtowc, wcrtomb wchar_t
<uchar.h> mbrtoc16, c16rtomb char16_t
<uchar.h> mbrtoc32, c32rtomb char32_t

A multibájtos oldal ezeknél is az aktuális LC_CTYPE szerinti kódolás. A mbrtoc32 tehát nem automatikusan „UTF-8-dekóder”.

C17-ben a char16_t és char32_t Unicode-értelmezéséhez is ellenőrizni kell az implementáció garanciáit; erre szolgálnak például a __STDC_UTF_16__ és __STDC_UTF_32__ makrók. UTF-16 esetén egy nem BMP-beli skalárérték két kódegységet igényel.

7.5.3.2 Az mbstate_t a feldolgozás része

Az újraindítható konverziók konverziós állapotot tárolnak egy mbstate_t objektumban. A mbstate_t state = {0}; kezdeti konverziós állapotot hoz létre.

Az állapotnak túl kell élnie az összetartozó bemeneti darabok feldolgozását. Külön adatfolyamokhoz külön állapot tartozik. Null állapotpointer helyett célszerű saját objektumot átadni, így nem támaszkodunk a függvény belső, megosztott állapotára.

A bemeneti konverziók fontos eredményei:

Eredmény Jelentés és teendő
Pozitív hossz Siker; ennyi C bájtot fogyasztott az aktuális bemenetből
0 Null karakter; UTF-8 esetén egy oktettet fogyasztott
(size_t)-2 Hiányos szekvencia; az állapot már rögzíti a feldolgozott részt
(size_t)-1 Kódolási hiba; az állapot nem használható tovább megalapozatlanul
(size_t)-3 Az ezt támogató konverziónál további kimeneti egység, új bemenet fogyasztása nélkül

Az utolsó eset például mbrtoc16 esetén fontos: egy UTF-16 surrogate pár második egysége későbbi hívásban jelenhet meg.

Kidolgozott lépés: UTF-8 locale mellett a C3 A1 szekvenciát két darabban kapjuk. Az első, egyoktettes mbrtoc32 hívás eredménye (size_t)-2. Ugyanazzal az állapottal ezután csak az új A1 oktettet adjuk át. A C3 ismételt átadása megkettőzné a már feldolgozott bemenetet.

Hibánál az alkalmazásnak kell döntenie elutasításról vagy helyreállításról. Az állapot nullázása önmagában nem határozza meg, melyik bemeneti pozíción biztonságos folytatni.

7.5.4 Locale-független UTF-8 határellenőrzés

Az UTF-8 önszinkronizáló, változó hosszúságú kódolás: egy skalárérték egy–négy oktett. Nem elég azonban a kezdőbájt alapján megszámolni a folytatóbájtokat. El kell utasítani:

Az alábbi program egyetlen skalárérték kódolását ellenőrzi, majd végigjár egy explicit hosszúságú puffert.

Fordítási profil: ISO C17, GNU bővítések nélkül; választott laborfordító GCC 14.2, Linux x86-64 System V, LP64, glibc 2.40. A kód nem függ a locale-tól; a nyolcbites C bájtot fordításkor ellenőrzi. Az alábbi eredmények elvárások, nem dokumentált futtatási eredmények.

#include <limits.h>
#include <stddef.h>
#include <stdio.h>

_Static_assert(CHAR_BIT == 8, "Eight-bit bytes required");

/* 1: valid unit; 0: incomplete; -1: invalid.
   *used changes only on success. */
static int utf8_unit(const unsigned char *p, size_t n,
                     size_t *used)
{
    if (n == 0)
        return 0;

    unsigned char b = p[0];
    size_t k;

    if (b <= 0x7F) {
        *used = 1;
        return 1;
    }

    if (b >= 0xC2 && b <= 0xDF)
        k = 2;
    else if (b >= 0xE0 && b <= 0xEF)
        k = 3;
    else if (b >= 0xF0 && b <= 0xF4)
        k = 4;
    else
        return -1;

    for (size_t i = 1; i < k; ++i) {
        if (i == n)
            return 0;

        unsigned char c = p[i];
        if (c < 0x80 || c > 0xBF)
            return -1;

        if (i == 1 &&
            ((b == 0xE0 && c < 0xA0) ||
             (b == 0xED && c > 0x9F) ||
             (b == 0xF0 && c < 0x90) ||
             (b == 0xF4 && c > 0x8F)))
            return -1;
    }

    *used = k;
    return 1;
}

int main(void)
{
    const unsigned char data[] = {
        0x41, 0xC3, 0xA1, 0x00, 0x42
    };
    size_t offset = 0;
    size_t count = 0;

    while (offset < sizeof data) {
        size_t used = 0;
        int status = utf8_unit(data + offset,
                               sizeof data - offset, &used);
        if (status != 1) {
            fprintf(stderr, "UTF-8 error at %zu: %d\n",
                    offset, status);
            return 1;
        }
        offset += used;
        ++count;
    }

    printf("octets=%zu, scalar_values=%zu\n",
           sizeof data, count);
    return 0;
}

Fordítás a külön tanulói laborban:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion \
    -O2 utf8_units.c -o utf8_units

7.5.4.1 Memória- és hibaszerződés

Szerep Szerződés
Tulajdonos és tároló A main automatikus data tömbje
Kölcsönzött nézet A p, n pár; a függvény nem tárolja el
Előfeltétel n > 0 esetén p legalább n olvasható C bájtot jelöl
Eredménytároló used élő, írható size_t objektumra mutat, a bemeneten kívül
Érvénytelenítés A tulajdonos blokkjának elhagyása; közben nincs tárolócsere
Hibakimenet Hiányos vagy hibás kódolásnál nincs előrelépés és nincs új used érték

A várt számlálás öt oktett és négy skalárérték. A beágyazott 00 érvényes UTF-8, az U+0000 kódolása. Nullterminált sztringként ugyanez a tartalom az első három C bájt után véget érne.

Bizonyításvázlat: az ASCII-ág egyetlen oktettet fogad el. A további ágak kizárják az illegális kezdőértékeket, ellenőrzik a folytatóoktetteket, majd a második oktett speciális korlátaival kizárják az overlong alakokat, surrogate értékeket és a felső határ átlépését. A p[i] olvasását mindig megelőzi a hosszellenőrzés.

A teljes bejárás O(n)O(n) idejű, O(1)O(1) kiegészítő tárolót használ, nem allokál, és sorfolytonosan olvas. A segédfüggvény hívásonként legfeljebb négy oktettet vizsgál.

7.5.4.2 Vágás és hálózati darabolás

A C3 A1 két oktettje között elvágott szöveg első része hiányos UTF-8. Hálózati olvasás végén ez még nem feltétlenül hiba: a következő olvasás hozhatja a folytatást. A logikai üzenet végén viszont már elutasítandó.

A segédfüggvény nem őriz állapotot. Hiányos eredménynél ezért a hívónak kell megtartania a teljes befejezetlen szekvenciát. Ez szándékosan eltér az mbstate_t-alapú konverzió szerződésétől.

A skalárérték-határon végzett vágás érvényes UTF-8-at hagyhat, mégis szétszakíthat egy grafémaklasztert. A kódolási érvényesség és a megjelenítési helyesség két külön követelmény.

7.5.5 Feladatok

  1. Értelmezés: a 61 CC 81 00 42 sorozat hány oktett, skalárérték és első null előtti C bájt? Hány grafémaklaszterből áll a null előtti szöveg?

  2. Kódolás: a fenti segédfüggvénnyel készíts utf8_prefix függvényt, amely előzetesen teljes egészében validált bemenetből megadja a legfeljebb limit oktettes, skalárérték-határon végződő prefix hosszát. Ne másoljon és ne allokáljon.

  3. Diagnosztika: UTF-8 locale mellett egy program a C3 oktettre (size_t)-2 eredményt kap. Ugyanazzal az állapottal ezután a teljes C3 A1 sorozatot adja át újra. Emellett minden bemeneti char értékre közvetlenül isalpha-t hív. Mi a két külön hiba?

7.5.6 Megoldások

7.5.6.1 Értelmezés

Öt oktett és négy skalárérték található a sorozatban: U+0061, U+0301, U+0000, U+0042. Az első null előtti hossz három C bájt. Az a és a kombináló ékezet együtt egy grafémaklaszter.

Ellenőrzési feltétel: a bájthossz nem számolható a megjelenített karakterek megszámlálásával.

7.5.6.2 Skalárérték-határon végződő prefix

C17-részlet, a fenti utf8_unit definíciója után illeszthető:

static size_t utf8_prefix(const unsigned char *p, size_t n,
                          size_t limit)
{
    size_t offset = 0;

    if (limit > n)
        limit = n;

    while (offset < limit) {
        size_t used = 0;
        if (utf8_unit(p + offset, n - offset, &used) != 1)
            break;
        if (used > limit - offset)
            break;
        offset += used;
    }
    return offset;
}

A bemeneti tároló tulajdonosa a hívó; a függvény nem tart meg pointert. Az előzetes validálás a szerződés része: az eredmény önmagában nem jelzi, hogy a kihagyott utótag hibás-e.

A 41 C3 A1 42 bemeneten a 2 oktettes korlát eredménye 1, a 3 oktettesé 3. A limit - offset kivonás biztonságos, mert a ciklusban offset < limit.

7.5.6.3 Állapot és osztályozás

Az első hiba a bemenet megkettőzése: az állapot már feldolgozta a C3 oktettet, ezért csak az új A1 oktettet kell átadni.

A második az isalpha argumentumtartománya: negatív, EOF-tól különböző char érték átadása UB. Az unsigned char konverzió ezt javítja, de Unicode-betűosztályozást nem valósít meg.

Ellenőrzési feltétel: próbáljuk a szekvenciát minden oktetthatáron darabolni, és külön vizsgáljuk a karakterosztályozás típushelyességét.

7.5.7 Összefoglaló

A szövegkezelési szerződésnek legalább a tároló élettartamát, a bájthosszt, a kódolást és a hibapolitikát rögzítenie kell. A char nem graféma, a wchar_t nem hordozható Unicode-kódponttípus, a locale pedig rejtett globális bemenet lehet. Hálózati UTF-8 feldolgozásakor a memóriahatár, a skalárértékhatár és a felhasználó által érzékelt karakterhatár külön ellenőrzési szint.