Karakterszám meghatározása UTF-8 kódolt bájtsorozatból

Tegyük fel, hogy adott egy UTF-8 kódolású szöveg bájtsorozata – például egy olvasásra megnyitott bináris fájlobjektum áll rendelkezésre – és ebből kívánjuk a kódolt karakterek (kódpontok) számát meghatározni.

Ezt a feladatot könnyen megoldhatjuk akkor, ha a teljes bájtsorozatot el tudjuk tárolni a számítógép memóriájában egy bytes típusú sorozatban. Ilyenkor egyszerűen meghívjuk e sorozat decode() metódusát amivel karakterlánccá konvertáljuk a bájtsorozatot, majd a len() függvénnyel meghatározzuk a karakterlánc hosszát.

Más a helyzet, ha az UTF-8 bájtsorozat nem fér be egyszerre a memóriába. Ilyenkor csak előre meghatározott méretű blokkokban tudjuk feldolgozni a bájtsorozatot, és valamilyen módszerrel következtetni a karakterek számára.

Két elvi megközelítést alkalmazhatunk ilyen esetben.

Az egyik az egyes blokkok dekódolásán és visszakódolásán alapul, vagyis ilyenkor a bytes.decode() és str.encode() metódusokat alkalmazzuk blokkszinten. Ennek fő lépései a következők:

  1. Dekódoljuk a blokk bájtjait úgy, hogy ha a végén esetleg csonka, azaz nem dekódolható karakter van, azt figyelmen kívül hagyjuk.
  2. A karakterszámlálót a dekódolt szöveg hosszával növeljük.
  3. Ezután meghatározzuk, hogy a blokkban sikeresen dekódolt szöveg hány bájton volt kódolva.
  4. A blokk maradék bájtjait a következő blokk elejéhez fűzzük, hogy ott már dekódolható legyen.

A blokkonkénti dekódolás és kódolás nem tűnik futási idő szempontjából hatékonynak, ezért más megoldást is meg kell vizsgálni. Ekkor azonban, mivel elvetjük a dekódolást, már csak bájtszinten tudunk dolgozni. Ehhez viszont bizonyos mértékig szükséges elmélyedni az UTF-8 kódolás részleteiben, mert a bájtok jellemzői alapján lehet majd következtetni a kódolt karakterekre, illetve azok számára. Az UTF-8 kódolás azon alapvető jellemzőit, amelyeket fel fogunk használni, a következő ábra foglalja össze.

Ennek alapján első menetben két segédfüggvényt definiálunk, amelyek egyikével meg lehet állapítani egy érvényes UTF-8 bájtról, hogy az karakterkezdőbájt, azaz vezetőbájt, a másikkal pedig azt, hogy egy megadott vezetőbájt után hány követőbájtnak kell lenni. E függvények definícióit láthajuk alább. (A protolkoll osztályt és az importált modulokat később fogjuk használni)

Mindezek után nekiláthatunk a feladatunk megoldásához eltérő logikával működő, és ezért különböző nyelvi eszközket használó függvények készítéséhez.

Az egyes függvénydefiníciók láthatók a következőkben. Az első függvény a bytes.decode() és str.encode() metódusokat használó megoldás, a többi az UTF-8 kódolás jellemzőire épül. A működési alapelvük kommentben olvasható.

A futási időket az alábbi tesztkóddal határoztuk meg. A mérések két blokkmérettel és 1, 10, 100 és 1000 MB méretű fájlokkal történtek, amelyekben a karakterek száma a fájlmérettel azonos arányban növekszik.

A futásiidő-mérések eredményeit a következő ábra táblázata foglalja össze.

Az eredményekből látható, hogy az egyes megoldások időigénye jelentősen eltér egymástól, de jól megfigyelhető a megoldások lineáris skálázódása. A fájlméret tízszeresére növelése a futási idő hozzávetőleg tízszeres növekedését eredményezi, ami O(n) időkomplexitást jelent. A jelentős futásiidő-különbségek tehát a megvalósításuk eltérő végrehajtási idejéből adódnak.

A vizsgált esetekben egyértelműen a bytes.translate() metóduson alapuló 5. megoldás bizonyul a leggyorsabbnak, amelyet – a várthoz képest meglepő módon – a blokkok dekódolására és újrakódolására épülő 1. megoldás követ. A többi változat futási ideje ezekénél egy-két nagyságrenddel hosszabb.

A jelentős különbség oka, hogy a bytes.translate(), valamint a bytes.decode() és str.encode() metódusok a CPython implementációjában optimalizált, natív kódban végrehajtott műveletekre támaszkodnak. Ezek a megoldások a feldolgozás jelentős részét Python-szintű ciklusok és az azokban végrehajtott műveletek többletköltsége nélkül végzik el.

Ezzel szemben azok a változatok, amelyek a bájtokat egyenként, Python-szintű ciklusban dolgozzák fel, jelentős többletterhelést viselnek, ami a mérési eredményekben is megmutatkozik. Az is megfigyelhető, hogy ezeknek a megoldásoknak a futási ideje az újabb Python-verziókban csökken, ami a Python utasítás-végrehajtási mechanizmusának és a futtatókörnyezet optimalizációinak fejlődését tükrözheti.

A 6. és 7. megoldás összehasonlítása egy további érdekes különbségre világít rá. A követőbájtok számának meghatározása függvényhívás helyett egy előre összeállított konténerből történő indexeléssel jelentősen csökkenti a futási időt.

Ennek oka, hogy egy tuple-ből történő indexeléssel végrehajtott elemkiolvasás lényegesen kisebb idővel járhat, mint egy külön Python-függvény meghívása. Egy függvényhívás során ugyanis az interpreternek többek között fel kell dolgoznia a hívást, elő kell készítenie az argumentumokat, kezelnie kell a függvény végrehajtási környezetét, majd végre kell hajtania a függvény törzsében található utasításokat, és végül, át kell adnia a visszatérési értéket a hívónak. A Python aktuális implementációjától és verziójától függően ezek a feladatok különböző mértékű többletmunkát jelenthetnek.

Ezzel szemben egy tuple elemének indexeléssel történő lekérése lényegesen egyszerűbb művelet: az interpreter ellenőrzi az index érvényességét, majd a tuple belső adatszerkezetében megkeresi a megfelelő pozícióhoz tartozó objektumot, és visszaadja annak referenciáját.

A két megközelítés közötti különbség különösen akkor válik jelentőssé, ha a műveletet a bemenet minden egyes bájtjára vagy annak nagy részére el kell végezni. Ilyenkor a függvényhívások többletköltsége minden iterációban újra jelentkezhet, így az egyenként csekély különbség a teljes feldolgozás során jelentős futásidő-különbséggé halmozódhat.

A bemutatott függvények megírásához számos Python nyelvi ismeret szükséges, amelyek magyarázatokkal és példákkal mind megtalálhatók a Python tudásépítés lépésről lépésre az alapoktól az első asztali alkalmazásig című e-könyvben.

Érdekel a Python tudásépítés lépésről lépésre az alapoktól az első asztali alkalmazásig című e-könyv.