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:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
from typing import Callable, Iterable, Iterator, Generator try: from itertools import batched except ImportError: def batched(iterable: Iterable, n: int) -> Iterator: """Olyan iterátort ad vissza, amely az első argumentum elemeiből n hosszúságú szakaszokat képez, és ezeket egy-egy tuple konténerben szolgáltatja. """ return zip(*[iter(iterable)] * n) |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# A következő generátorfüggvény-változatok a megadott iterálható objektum elemeiből n hosszúságú # szakaszokat képeznek, és e szakaszok elemeire meghívják a func hívható objektumot. Ennek eredményét # szolgáltatja a generátor minden egyes kéréskor. def apply_to_chunks1(func: Callable, iterable: Iterable, n: int) -> Generator: for chunk in batched(iterable, n): if len(chunk) == n: yield func(*chunk) def apply_to_chunks2(func: Callable, iterable: Iterable, n: int) -> Generator: yield from (func(*chunk) for chunk in batched(iterable, n) if len(chunk) == n) def apply_to_chunks3(func: Callable, iterable: Iterable, n: int) -> Generator: yield from map(func, *[iter(iterable)] * n) |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
from typing import Callable, Iterable, Iterator, Generator from timeit import timeit try: from itertools import batched except ImportError: def batched(iterable: Iterable, n: int) -> Iterator: """Olyan iterátort ad vissza, amely az első argumentum elemeiből n hosszúságú szakaszokat képez, és ezeket egy-egy tuple konténerben szolgáltatja. """ return zip(*[iter(iterable)] * n) def apply_to_chunks1(func: Callable, iterable: Iterable, n: int) -> Generator: for chunk in batched(iterable, n): if len(chunk) == n: yield func(*chunk) def apply_to_chunks2(func: Callable, iterable: Iterable, n: int) -> Generator: yield from (func(*chunk) for chunk in batched(iterable, n) if len(chunk) == n) def apply_to_chunks3(func: Callable, iterable: Iterable, n: int) -> Generator: yield from map(func, *[iter(iterable)] * n) def fn2(x, y): return x, y def fn4(x, y, z, u): return x, y, z, u def fn8(x, y, z, u, v, t, q, r): return x, y, z, u, v, t, q, r for fn, chunk_length in zip((fn2, fn4, fn8), (2, 4, 8)): print(fn.__name__, 'argumentumok száma:', chunk_length) for apply_to_chunks in (apply_to_chunks1, apply_to_chunks2, apply_to_chunks3): print('\t{} -> {}'.format(apply_to_chunks.__name__, timeit('list(apply_to_chunks(fn, range(335), chunk_length))', globals=globals(), number=100_000))) # Eredmények: # fn2 argumentumok száma: 2 # apply_to_chunks1 -> 2.368328399999882 # apply_to_chunks2 -> 2.591035200000988 # apply_to_chunks3 -> 1.7714092000005621 # fn4 argumentumok száma: 4 # apply_to_chunks1 -> 1.3381306999999651 # apply_to_chunks2 -> 1.5055953999999474 # apply_to_chunks3 -> 1.071758399999453 # fn8 argumentumok száma: 8 # apply_to_chunks1 -> 0.8416001000005053 # apply_to_chunks2 -> 0.9277134000003571 # apply_to_chunks3 -> 0.7751106999985495 |
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ő:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from typing import Callable, Iterable, Generator import inspect def argcount(callable_obj: Callable) -> int: """Visszaadja a megadott hívható objektum pozicionálisan megadható argumentumainak számát. Ha ez nem határozható meg, akkor a visszatérési értéke -1. """ try: try: # Ha saját definiálású a hívható objektum, vagyis van __code__ attribútuma, akkor ez az ág fut le. return callable_obj.__code__.co_argcount except AttributeError: return sum(1 for param in inspect.signature(callable_obj).parameters.values() if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY)) except (ValueError, TypeError): return -1 |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def apply_to_chunks(func: Callable, iterable: Iterable) -> Generator: """Olyan generátort ad vissza, amely a megadott iterálható objektum elemeiből a func pozicionálisan megadható argumentumainak számával egyező hosszúságú szakaszokat képez, és e szakaszok elemeire meghívja a func hívható objektumot. Ennek eredményét szolgáltatja a generátor minden egyes kéréskor. Hibajelzést ad, ha az adott hivható objektum argumentumainak száma 0 vagy nem határozható meg. """ n = argcount(func) if n == -1: raise ValueError(f'Nem határozható meg a "{func.__name__}" mint hívható objektum pozicionálisan ' f'megadható argumentumainak száma.') if n == 0: raise ValueError('A hívható objektum legalább egy argumentumot kell, hogy tudjon fogadni.') yield from map(func, *[iter(iterable)] * n) |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
import operator from collections import namedtuple from dataclasses import dataclass from typing import NamedTuple from auto_chunk_length import apply_to_chunks # Meghatározható számú pozicionális argumentummal rendelkező hívható objektumokkal meghívva. print(*apply_to_chunks(lambda x, y, z, u, v: x + y + z + u + v, range(37))) print(*apply_to_chunks(operator.add, range(37))) print(*apply_to_chunks(pow, range(23))) print(*apply_to_chunks(complex, range(23))) print(*apply_to_chunks(format, ['abc', '*^7', 123.456, '+.1f'])) print(*apply_to_chunks(isinstance, ['abc', str, 123, int, 3 / 2, (int, float), {0}, list])) # Eredmények: # 10 35 60 85 110 135 160 # 1 5 9 13 17 21 25 29 33 37 41 45 49 53 57 61 65 69 # 0 1 0 1 12 1 12 # 1j (2+3j) (4+5j) (6+7j) (8+9j) (10+11j) (12+13j) (14+15j) (16+17j) (18+19j) (20+21j) # **abc** +123.5 # True True True False # Ha a pozicionális argumentumok száma automatikusan nem meghatározható, de ezek mégis léteznek, akkor # a hívható objektumot egy saját definiálású függvénybe burkolva tudjuk használni. print(*apply_to_chunks(lambda start, stop, step: range(start, stop, step), range(18))) print(*apply_to_chunks(lambda start, stop: slice(start, stop, 1), range(15))) print(*apply_to_chunks(lambda t: min(t), [(1, 2, 3), (4, 5, 6), (7, 8, 9)])) print(*apply_to_chunks(lambda p, q: min(p, q), [1, 2, 3, 4, 5, 6, 7, 8, 9])) # Eredmények: # range(0, 1, 2) range(3, 4, 5) range(6, 7, 8) range(9, 10, 11) range(12, 13, 14) range(15, 16, 17) # slice(0, 1, 1) slice(2, 3, 1) slice(4, 5, 1) slice(6, 7, 1) slice(8, 9, 1) slice(10, 11, 1) slice(12, 13, 1) # 1 4 7 # 1 3 5 7 # Koordináták sorozatát pontokká többféle módon definiált Pont osztályokkal gond nélkül konvertálhatunk. Point2D = namedtuple('Point2D', 'x y') @dataclass class Point3D: x: int | float y: int | float z: int | float class Point4D(NamedTuple): x: int | float y: int | float z: int | float t: int | float print(*apply_to_chunks(Point2D, range(12))) print(*apply_to_chunks(Point3D, range(12))) print(*apply_to_chunks(Point4D, range(12))) # Eredmények: # Point2D(x=0, y=1) Point2D(x=2, y=3) Point2D(x=4, y=5) Point2D(x=6, y=7) Point2D(x=8, y=9) Point2D(x=10, y=11) # Point3D(x=0, y=1, z=2) Point3D(x=3, y=4, z=5) Point3D(x=6, y=7, z=8) Point3D(x=9, y=10, z=11) # Point4D(x=0, y=1, z=2, t=3) Point4D(x=4, y=5, z=6, t=7) Point4D(x=8, y=9, z=10, t=11) |
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.