Megbízható értékkeresés float elemeket tartalmazó konténerekben

Az, hogy float típusú számok egyenlőségvizsgálatánál körültekintően kell eljárni általában ismert. A véges számú biten történő ábrázolás miatt ugyanis a számítási műveletek eredménye nem biztos, hogy meg fog egyezni a matematikailag várható egzakt értékkel.  Például elvben a 0.1+0.2 == 0.3 kifejezés igaz (True) értékű kellene, hogy legyen, de lefuttatva mégis hamis (False) értéket kapunk. Éppen ezért, ha olyan két szám egyenlőségét vizsgáljuk, amelyek közül legalább az egyik float típusú, akkor az egyenlőséget csak adott – az alkalmazásunk számítási pontosságának igényéhez igazodó – hibahatáron belül tudjuk értelmezni. Hasonló a helyzet olyan komplex számok egyenlőségvizsgálatánál, amelyek valós vagy képzetes része float típusú.

Ilyen esetekben a tényeles egyenlőséget ellenőrző  == operátor helyett általában a szabványos könyvtár math moduljában rendelkezésre álló isclose() függvénnyel végezzük az értékek összehasonlítását. Ha komplex számok is szóba jöhetnek, akkor a cmath modul isclose() függvényét lehet alkalmazni.

A float típusú számok egyenlőségének problematikája nem csak két szám explicit összevetésénél merül fel, hanem implicit módon konténerobjektumok értékkeresésen alapuló metódusainál is. Ilyen például az in operátor alkalmazásakor meghívott __contains__ metódus, valamint változtatható konténereknél a remove(). Ezeken felül sorozattípusú konténereknél (list, tuple, deque, array) ilyen a count() és az index() metódus is. Ezek mindegyikénél azt tapasztaljuk, hogy ha van a sorozatban 0.3 értékű elem, akkor ha a metódusnak argumentumként a 0.1+0.2 számított értéket adjuk át, hibás eredményt kapunk, mert a metódusokon belül egzakt egyenlőségvizsgálat történik.

Ezt két módon tudjuk kiküszübölni:

  1. a metódushívások előtt az argumentumokat megfelelően előkészítjük. Pl. kerekítéssel: round(0.1+0.2, 9).
  2. saját készítésű konténerosztályokat hozunk létre, amelyekben az értékkeresésen alapuló metódusokat úgy írjuk felül, hogy azok float értékkel vagy float komponensű komplex számokkal meghívva helyes eredményt adjanak.

A két lehetőség közül választási szempont lehet a futási idő. Ugyanis a saját készítésű osztályokban felülírt metódusok lassabban fognak futni, mint ha azokat a beépített típusokon közvetlenül hívnánk meg. Éppen ezért, ha nagyméretű adathalmazzal kell dolgozni és a futási idő szempont, akkor inkább az argumentumok előkészítése a jobb megközelítés. Egyéb esetben a metódusok felülírása, mert azt csak egyszer kell elvégezni és utána az egyéni típus ugyanúgy és ugyanott használható, mint a beépített, és az argumentumként átadandó keresett értékekkel nem kell előzetesen külön foglalkozni.

A továbbiakban a második megoldás ismertetjük, már csak azért is, mert alkalmat teremt számos Python nyelvi konstukció mélyebb megértésére vagy gyakorlására. Sorozattípusú konténerek (list, tuple, deque, array) egyéni változatát fogjuk elkészíteni, mert ezekkel mindegyik, értékkeresésen alapuló metódus felülírása bemutatható.

Az egyéni típusok elkészítésének két mozzanata van:

  • alosztály létrehozása a beépített típus alapján
  • metódusok felülírása

Vegyük elsőként a list beépített típust. Legyen a saját osztályunk neve List. Ezt mint alosztályt örökléssel egyszerűen létre tudjuk hozni, és ebben az értékkeresésen alapuló metódusokat egyszerűen felülírjuk. Ezt láthajuk az alábbi kódban.

A felülírt metódusokban a helyes eredményt az _are_equal() metódus biztosítja. Ez az egyenlőség megállapításához a == operátort használja kivéve, ha az argumentumok számok és bármelyik float típusú vagy olyan komplex szám, amelynek valós vagy képzetes része float típusú. Ekkor az egyenlőséget adott hibahatáron belül értelmezi a cmath modul isclose() függvényének segítségével. Azért nem a math modul isclose() függvényét használjuk, mert a sorozatelemek komplex számok is lehetnek, és kihasználtuk, hogy a cmath. isclose() mind float, mind complex típusú számokat tud fogadni. A feltételellenőrzésnél pedig azt használtuk ki, hogy minden számtípus rendelkezik a valós és képzetes részeket visszaadó real és imag attribútumokkal. (Valós számok esetében az imag természetesen zérus értékű.)

Ha hasonló módon készítjük el a többi sorozattípusú konténer alosztályát, akkor hamar rájövünk, hogy a metódusok felülírása mindegyikben ugyanaz, mint a List esetében. Kivételt képez a tuple-ból örökölt Tuple, amelyben a remove() nem értelmezett, minthogy a tuple (és így a Tuple) konténer változtathatatlan objektum. Ezért tehát a remove() metódust nem kell felülírni.

Ha programíráskor forráskódmásolás merül fel, akkor mindig érdemes átgondolni, hogy lehet-e úgy átalakítani (refaktorálni) a kódot, hogy kiküszöböljük a másolást. Ugyanis ez egy esetleges módosításakor potenciális hibaforrás lehet, például ha nem minden másolt kódrészben történik meg a változtatás.

Jelen esetben a metódusdefiníciókat ki tudjuk szervezni két mixin osztályba. Az egyikbe, amelynek neve legyen ImmutableLookupMixin, azokat a metódusokat szerepeltetjük, amelyek változtathatatlan objektumokon is érvényesek, vagyis a __contains__, count() és index() metódusokat. És ide helyezzük az _are_equal() metódust is. A másik, MutableLookupMixin nevű mixin osztály pedig örökli az ImmutableLookupMixin osztályt, és tartalmazza a remove() definícióját. A MutableLookupMixin osztály tehát a változtatható példányokat eredményező típusokhoz használható. Ezek, valamint a megfelelő beépített típusok öröklésével a List, Tuple, Deque és Array egyéni osztályok egyszerűen létrehozhatók. Mindez alább látható.

Ennek a megoldásnak azonban van egy hátránya, mégpedig az, hogy az isclose() függvénynek az alapértelmezettől eltérő tolerancia értékeket nem lehet meghatározni. Ezt az __init__ metódusban lehetne megadni például rel_tol és abs_tol nevű attribútumok létrehozásával. Azonban az ImmutableLookupMixin osztályban nem lehet definiálni az __init__ metódust, mert változtathatatlan objektumokhoz nem tudunk ilyen módon utólag adatattribútumot rendelni.

Lehetne a __new__ metódus felülírásával célt érni, de erre nincs szükség, ha nem ragaszkodunk a mixin osztályokkal történő megoldáshoz. E helyett egy osztálydekorátort készítünk, amely paraméterezhető a relatív és abszolút eltérés kívánt értékével. Ezt a dekorátort és használatát mutatjuk alább. Az osztályok dekorálását mind a @ szintaxissal, mind függvényhívással láthatjuk. Ez utóbbit normál módon (class kulcsszóval) és dinamikus módon (type() függvénnyel) definiált osztályokra is alkalmaztuk.

A List, Tuple, Deque és Array osztályok elvárt működésének ellenőrzéséhez minden metódushoz készítünk egy tesztelő függvényt:

A teszteket mind a beépített típusok, mind a saját készítésű osztályok példányaira elvégezzük, hogy össze lehessen vetni az eltérő működést. Ennek érdekében minden osztály példányát azonos sorozatelemekkel hozzuk létre, és a tesztargumentumok sorozata is azonos. Ez utóbbi elemeit karakterláncként adjuk meg, hogy a tesztfüggvényekben egyszerű módon ki tudjuk írni, hogy mely esetekben kapunk helytelen eredményt.

A tesztek kiírt eredményét metódusonként alább láthatjuk. Ezek visszaigazolják, hogy a List, Tuple, Deque és Array osztályok a vártnak megfelelően működnek.

Ebben a bejegyzésben érintettük a sorozattípusú konténereket és metódusait, a mixin osztályok alkalmazását, a változtathatatlan (immutable) objektumokat eredményező típusokból való öröklést, az osztálydekorátorok létrehozását és osztályok normál és dinamikus módon történő definiálását. Ezekről a a Python tudásépítés lépésről lépésre című e-könyv következő fejezeteiben lehet részletesen olvasni: „Beépített konténerobjektumok”, „Beépített típusok nyilvános metódusai”, „Osztályok dekorálása”, a „Készétel fogyasztás – a szabványos könyvtár moduljainak használata” fejezeten belül a „Speciális konténer típusok” alfejezet, a „Mágikus metódusok és speciális attribútumok egyéni osztályokban” fejezeten belül „A példányosítási folyamat befolyásolása” alfejezet, a „Különleges osztálydefiníciók” fejezet „Dinamikus típusdefiniálás” alfejezete.

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