Sorozatok feldarabolása és feldolgozása automatikus szakaszhosszal

Előfordulhat, hogy egy adatfolyamból, egy sorozatból kell bizonyos hosszú szakaszokat kialakítani, és ezen sorozatrészek elemein valamilyen műveletet végezni. Például egy érzékelőből származó mérési adatok három egymást követő értéke tartozik össze, és ezekből szeretnénk kiszámítani valamilyen jellemzőt. Vagy több négyszög oldalai állnak sorban egymás után rendelkezésre, és minden egyes négyszög kerületét kell kiszámolni. Egy másik gyakori példa, amikor egy sorozatban minden egymást követő számpár egy-egy pont x és y koordináta értéke, és ezeket szeretnénk egy-egy kételemű tuple objektumba rendezni vagy egy Point típus példányaként megkapni. Ilyen igény merülhet fel például, ha grafikus felhasználói interfészt készítünk a tkinter modullal. Itt, ha egy vásznon (Canvas) megjelenő rajzelem (vonal, ellipszis, téglalap, sokszög) koordinátáit vagy befoglaló téglalapjának sarokpontjait kérjük le a vászon példányra meghívott coords() illetve bbox() metódusokkal, akkor a pontok koordinátáit x1, y1, x2, y2, … sorozatként kapjuk meg.

Mindegyik említett esetben hasonló a feladat: egy sorozatból egyforma hosszúságú szakaszokat kell képeznünk, majd ezek elemein valamilyen műveletet végrehajtanunk — legyen az számítás, átalakítás vagy egy összetettebb objektum létrehozása. A sorozat elemeinek természetesen a szakaszhossz egész számú többszörösének kell lenni.

A feladat megoldásához első lépésben fel kell tudni darabolni a sorozatot adott hosszúságú szakaszokra. Ehhez a Python 3.12 verziótól rendelkezésre áll a szabványos könyvtár itertools moduljában a batched(iterable, n) iterátor, ahol az első argumentum a sorozatelemeket szolgáltatni képes iterálható objektum. Második argumentumként a kívánt szakaszhosszat kell megadni. A batched() iterátor minden egyes kérésre az iterálható objektum soron következő, szakaszhossznyi számú elemét adja ki egy tuple objektumban.

Ha a 3.12-nél korábbi Python verziót használunk, akkor magunknak kell készíteni egy ilyen sorozatdaraboló függvényt. Ennek egy lehetséges megvalósítását láthatjuk alább:

A zip() beéptett függvény az argumentumként felsorolt iterálható objektumokból az elemeket a felsorolás szerint balról jobb sorrendben veszi, és képezi belőlük a szolgáltatott tuple konténereket. A saját függvényünk működése azon alapul, hogy ha a zip() argumentumai azonos iterátor-objektumok, akkor az első elem kivétele után az iterátorban ez már nem lesz meg, így a következő argumentumból a második elemet lehet kivenni, az ezt követőből a harmadikat, és így tovább.

Az itertools modul által biztosított és a saját készítésű batched() abban tér el, hogy az előbbi akkor is szolgáltatja az utolsó részsorozatot, ha annak hossza kisebb, mint az adott szakaszhossz. Ezzel szemben a saját függvény csak olyan részsorozatokat ad ki, amelyek hossza megegyezik az előírt szakaszhosszal.

Ha rendelkezésre áll egy valamilyen módon megvalósított batched() iterátor, akkor a sorozatszakasz elemein végzett műveletvégzés megvalósítása nem nehéz, mert definiálni kell a megfelelő függvényt a szakasz elemszámával egyező, pozicionálisan megadható argumentumokkal, és azt meghívni az egyes szakaszokra, illetve azok elemeire. E függvényre a továbbiakban transzformáló függvényként hivatkozunk, ami bármilyen hívható objektum lehet.

Ezen az elven működő generátorfüggvény-változatokat mutatunk alább:

Az első kettő a batched() iterátort használja. Ezeknél a szakaszhossz ellenőrzése azért szükséges, mert ahogy említettük az itertools modul batched() a szakaszhossznál rövidebb utolsó részsorozatot is kiadja, ami viszont a szándékolt felhasználási eseteinkben nem megfelelő, hiszen ilyenkor a szakasz elemszáma nem fog egyezni a transzformáló függvény paraméterszámával.

A harmadik változat a saját készítésű batched() függvénynél alkalmazott technikán alapul. Csak itt most az azonos iterátor-objektumokat nem a zip() hanem a map() beépített függvény használja.

A kérdés az, hogy e változatok közül melyiket használjuk?

Ésszerű választási szempont lehet az olvashatóság és a futási idő. Az olvashatóság a kód könnyű áttekinthetőségét, érthetőségét jelenti. Ebből a szempontból az első két változat előnyösebb, mert a harmadiknál némi átgondolás szükséges a megértéshez. Ha a futási időket vizsgáljuk, akkor viszont a következő teszteredményei alapján a harmadik változat bizonyul jobbnak.

Tehát, ha hosszú adatsorokkal kell dolgozni, és a futási idő lényeges, akkor a harmadik változat az ajánlott. Egyébként a másik kettő is megfelelő.

Mindazonáltal e három változatnak van egy közös hátránya. Mégpedig az, hogy híváskor tudni kell, hogy az argumentumként megadott transzformáló függvénynek hány paramétere van, és ennek megfelelően kell a szakaszhosszt megadni. Ez nem csak a függvény rugalmas újrafelhasználási lehetőségét korlátozza, hanem hibalehetősget is hordoz. Ezért jó lenne egy olyan megoldás, ahol nem kell megadni a szakaszhosszt, mert az a transzformáló függvény paraméterszámából kikövetkeztethető.

Ezirányú igényünk kielégítésére van remény, mert ha definiálunk egy függvényt, akkor a létrejött függvényobjektum __code__ speciális attribútumával kikérhető kódobjektum co_argcount attribútumának értéke jelenti a pozicionálisan megadható argumentumok számát.

Ez a módszer működik, de csak a saját definiálású függvényekre és metódusokra. Nincs __code__ attribútuma azonban az osztályobjektumnak és minden olyan beépített hívható objektumnak, amelyek nem Pythonban vannak megvalósítva. Ilyenek például a beépített függvények, a beépített típusok metódusai, és többek között a math és operator modulok függvényei.

Ennek ellenére a célunkat se feladni se korlátozni nem kell, mert az argumentumszám kinyerésében a szabványos könyvtár inspect modulja tud segítséget nyújtani. Bár az inspect modul nem napi használati eszköz, de bizonyos helyzetekben nagyon hasznos lehet. Mint például most, amikor hívható objektumokat kell vizsgálni.

Ha az inspect modul signature() függvényét egy hívható objektummal meghívjuk, akkor az egy Signature típusú objektumot ad vissza. Ez a hívható objektum hívási szignatúráját képviseli. A Signature objektum rendelkezik egy parameters nevű leképezéstípusú konténerrel. Ebben minden paraméternévhez mint kulcshoz egy Parameter típusú objektum mint érték tartozik. A Parameter objektum kind attribútuma pedig megadja, hogy egy argumentum az adott paraméterhez milyen módon társítható, vagyis az argumentum pozícionálisan és kulcsszavasan egyaránt megadható-e, vagy csak pozicionális vagy csak kulcsszavas lehet. Ezeket a változatokat egy-egy modulkonstans jellemzi, amely a kind értéke.

Mindezek ismeretében annyit kell csak tenni, hogy a signature() függvényt meghívjuk a vizsgálni kívánt hívható objektummal, és a visszatérési értéknek ( Signature objektum) kikérjük a parameters konténerét. Ebből lekérdezzük a kulcs-érték párok érték részét (a Parameter példányokat) és megszámoljuk azokat, amelyeknél a kind értéke vagy csak pozicionális vagy pozicionális és kulcszavas társíthatóságra utal. Más szóval, összeszámoljuk azokat, ahol a kind értéke a POSITIONAL_ONLY és POSITIONAL_OR_KEYWORD modulkonstansok egyike.

Az így megvalósított argcount() nevű függvény definíciója a következő:

Ez a függvény tehát egy hívható objektumot fogad és egy egész számot ad vissza, ami a hívható objektumnak pozicionálisan megadható argumentumainak a számát jelenti. A __code__ attribútum alapján történő meghatározást azért hagytuk meg, mert ez gyorsabb, mint az inspect modul igénybevételével, összeszámoláson alapuló módszer. Az argcount() saját definiálású transzformáló függvények esetén a __code__ attribútumot kéri ki és vizsgálja, minden más esetben az inspect modul eszköztárát használja a híváskor átadandó argumentumok számának meghatározására.

Miután rendelkezésre áll az argcount() függvény, ennek felhasználásával most már egyszerűen elkészíthetünk egy olyan generátorfüggvényt, amelynek csak a transzformáló függvényt és a sorozatelemeket szolgáltató iterálható objektumot kell megadni. Ez belül a transzformáló függvény paraméterszámának megfelelő hosszúságú részsorozat szakaszokat automatikusan képezi. Az így előálló apply_to_chunks() nevű függvény ez lesz:

Az apply_to_chunks() függvény, illetve az általa meghívott argcount() függvény sok beépített függvénnyel használható, de előfordul, hogy az argcount() még az inspect modul eszközeivel sem tudja az átadandó argumentumok számát meghatározni, annak ellenére, hogy ezek léteznek. Mert például az argumentumok számától és/vagy típusától függő a viselkedés. Ilyen például a range, a slice vagy a min és max függvény. Ahhoz, hogy ezeket is tudjuk használni az apply_to_chunks() függvényben, egy saját definiálású függvénybe (legyegyszerűbben egy lamdafüggvénybe) burkolva tudjuk mint transzformáló függvényt átadni. A használatot bemutató alábbi tesztsorok között erre is láthatunk példákat.

Ebben a bejegyzésben azt jártuk körül, hogyan bonthatunk egy sorozatot fix hosszúságú részekre, amelyek elemein aztán igény szerinti műveletet végezhetünk egy megfelelő hívható objektum alkalmazásával. Mindezt úgy, hogy a szakaszhosszt automatikusan meghatározzuk a transzformáló függvény paramétereinek számából. Így híváskor nem kell manuálisan illeszteni a paraméterszámot és a szakaszhosszt, a kód egyszerűbbé válik, miközben a sorozatok feldolgozásának folyamata általánosítható.

Ami a nyelvi elemeket illeti, elsődlegesen a saját készítésű és beépített függvények – beleértve a lambda- és generátorfüggvényeket is – szerepeltek. Ezekről részleteiben, példákkal magyarázva a Python tudásépítés lépésről lépésre című e-könyvben az „Egymáshoz rendelve – függvények”, „Beépített függvények”, „Különleges függvénydefiníciók”, „Emlékező függvények létrehozása” és „Kifogyhatatlan sorozatlövők – generátorfüggvények” című fejezetekben lehet olvasni.

É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.