Úgy alakult, hogy a munkahelyen Python tréninget kezdtem tartani a kollégáknak. Ez adta az apropóját a mai bejegyzésnek: mivel a tréninghez már megírtam az anyagot, így adva volt, hogy ide is feltegyem. Mint már említettem lusta ember vagyok.
Pythonban alapvetően két1 fajta számot szokás használni: int-t (egész számot) és float-ot (lebegőpontos számot). Olyan szempontból kényelmes szituációban vagyunk, hogy a fordító eldönti futási időben melyik változó értéke milyen kategória, így nem kell típusdefinícióval foglalkoznunk. Tehát pl:
a = 3.14 # ez egy float b = -2.1 # ez egy float szintén c = a*b # ez egy float lesz d = 8.02e+12 # ez szintén float e = -21 # egy int
Rendelkezésünkre állnak ezen kívül az alap műveletek, mint pl:
a + b # meglepő modon a+b a % b # az a/b maradéka a / b # a/b oszás értéke pontosan, vagyis ebben az esetben kb. -1,42... a // b # az a/b osztás lefelé kerekitett értéke vagyis most -2
Ez eddig elég egyszerű, de sajnos van néhány probléma:
- a számítógép bináris törtek formában tárolja az számokat, és korlátozott a számjegyek száma, amit tárolni képes
- végtelen kicsi és végtelen nagy számok tárolása
- komplex számok kérdése
Bináris törtek reprezentációs problémája
Az első probléma lényegében a forrása a másodiknak, így tárgyaljuk őket együtt. Amit látni kell, hogy a számítógépek nem tudnak mit kezdeni helyzettel, hogy a számrendszer folytonos. Ez abból az egyszerű tényből fakad, hogy véges számú számjegyet tudnak felhasználni egy szám leírására, és ugye tudjuk, hogy végtelen mennyiségű számunk van, amit nem lehet így leírni.2
A Python bináris törtek formájában tárolja a számokat. Például a 0.001-et mint 0/2+0/4+1/8. Persze a 2,4 vagy 8 helyett azok bináris formája szerepel. De a CPU architektúrája meghatározza, hogy hány bit-et használ az egyes számok tárolására.3 Ez pedig korlátozza, hogy mi lehet a legnagyobb osztó a fenti törtben. Ami ahhoz vezet, hogy minden egyes számot kerekítenünk kell a hozzá legközelebbi bináris tört értékéhez. Ezen még az se változtat, hogy a print() parancs mást mond nekünk. Nézzünk egy példát, mondjuk a 0,1 és a 0,75 számokat. Ez elég egyszerű számoknak tűnnek:
from decimal import Decimal a = 0.1 print(Decimal(a)) # 0.1000000000000000055511151231257827021181583404541015625 print(a) # 0.1 b = 0.75 print(Decimal(b)) # 0.75 print(b) # 0.75
Mint fentebb látható, míg a 0,75 memóriában lévő értéke az amit definiáltunk, addig a 0,1-nél ez nem áll fent. És itt kezdődnek a problémák. Nézzünk egy egyszerű példát:
0.1+0.1+0.1 == 0.3 # False
??? Vagyis 0,1+0,1+0,1 az nem egyenlő 0,3-al. “Ülj le! Egyes.” mondhatnánk.
Ilyenkor kell, hogy eszünkbe jusson, hogy két érték egyenlőségének jelentése nem feltétlen egyértelmű.4 A Python az egyenlőségjelet annak naiv Cauchy előtti definíciója szerint értelmezi. Vagyis két érték akkor egyenlő, ha pontosan egyenlő. Ezzel szemben az egyenlőséget mi úgy is felfoghatnánk, hogy egyenlő ha bizonyos értéken belül egyenlő. Vagyis vezessük be a határérték fogalmát. Ha így teszünk akkor már tudjuk kezelni a bináris törtre való kerekítésből származó problémát.
Hogy tudjuk ezt megvalósítani a gyakorlatban? Két lehetőségünk is van. Egyrészt magunk is írhatunk olyan ellenőrzést, amire igaz, másrészt használhatjuk a math könyvtár isclose() 5 függvényét. Nézzünk példát mindkettőre:
# a probléma a = (19/155)*(155/19) a == 1.0 # False # megoldás saját magunk definiált epsilon értékkel epsilon = 1e-10 abs(a-1) <= epsilon # True # megoldás a mat könyvtárral import math math.isclose(a,1.0) # True
Ha a math.isclose() használjunk, akkor figyeljünk arra, hogy két távolság van definiálva a függvényben: a relatív és az abszolút. A második ugyanaz, amit mi is használtunk a saját megvalósításunkban, a relatív ezzel szemben egy százalékos érték a két szám különbségére. Például relatív 0.05 az 5% különbség lehet a két szám között. Az alapértelmezett teszt a függvény meghívásakor relatív távolság 1e-09 értéken. Ez jól is működik amíg nem kezdünk a 0-hoz nagyon közeli értékekkel dolgozni. Ekkor viszont jobban járunk, ha abszolút távolságra váltunk. Nézzünk egy példát:
a = 0.2 + 0.4 - 0.6 # Probléma math.isclose(a,0) # False # Megoldás math.isclose(a, 0, abs_tol=1e-09, rel_tol=0.0) # True
Végtelen számok kérdése
Könnyű belátni, hogy a reprezentációs probléma mellett a bináris törtek végtelen nagy és végtelen kicsi számok megjelenítésére se alkalmasak. Szerencsére a Python-ban van egy erre használt lebegőpontos érték, a math.inf 6. Ezt a mínusz végtelent kifejezésére is használhatjuk -math.inf formában. Most már csak az a kérdés, hogyan dolgozhatunk ezzel a végtelen értékkel. Erre szintén a math könyvtárból használhatunk két függvényt, a math.isfinite() és a math.isinf(). Az első értelemszerűen akkor igaz, ha a a tesztelt érték nem végtelen, míg a második ennek az ellentéte.
Komplex számok
Most, hogy tisztáztuk a bináris kerekítésből származó problémát, nézzük meg a komplex számok helyzetét. Az általánosan elterjed lebegőpontos és (egyszerű) egész számok mellett létezik egy szám formátum komplex számok kezelésére. Meglepő módon ezt complex-nek nevezik. Ez lényegében egy olyan lista, aminek két értéke van: a komplex szám valós és képzeletbeli része. Bár a Python fordító elég intelligens, hogy észrevegye ha komplex számmal van dolga, azért a reprezentációs problémától ez se mentes. Nézzünk például a -t:
(-2)**(0.5) # (8.659560562354934e-17+1.4142135623730951j)
Ez nem teljesen jó. A valós tartomány értéknek 0-nak kellene lennie 8.659560562354934e-17 helyett. Erre a problémára született a cmath könyvtár. Ez a math könyvtár komplex számokra átírt változata. A fenti számítást például így lehet megvalósítani a segítségével:
cmath.sqrt(-2) # 1.4142135623730951j
Értelemszerűen komplex számok összehasonlítása során mind a valós, mind a képzeletbeli résznek egyeznie kell
a = (-4)**(0.5) # (1.2246467991473532e-16+2j) b = cmath.sqrt(-4) # 2j complex(0,2) == a # False complex(0,2) == b # True cmath.isclose(a,b) # True a.imag == b.imag # True a.real == b.real # False
Decimális és Racinális számok
Mint látható, a reprezentációs probléma alapvetően a számítógépek működéséből fakad és sok mindent nem lehet tenni vele, a jelenlegi technikai keretek között. Ez arra késztette a fejlesztőket, hogy megpróbálják megkerülni és olyan típusokat létrehozni, amik megfelelnek a mindennapi életben szembejövő problémáknak és nem korlátozza őket a bináris törtek pontos értéke. Így született még két újabb az alaptelepítésben is elérhető modul: a decimal (tizedes szám) és a fractions (racionális számok).
Mind a kettő pont arra szolgál, amire a nevükből is következtethetünk. Ezek közül talán a Decimal az érdekesebb. A modul azt ígéri, hogy az emberek gondolkodásához alkalmazkodva lényegében megszünteti a reprezentációs problémát. Ez elég jól hangzik, de sajnos csak fenntartásokkal lehet elfogadni. Lássunk néhány példát a használatára:
from decimal import * from decimal import * Decimal("0.1") # 0.1 Decimal(0.1) # 0.1000000000000000055511151231257827021181583404541015625 Decimal("0.1")+Decimal("0.1")+Decimal("0.1") == Decimal("0.3") # True Decimal("0.1")+Decimal("0.1")+Decimal(0.1) == Decimal("0.3") # False
Mint fentebb is látható a modulban definiált Decimal típus használatával a 0,1+0,1+0,1 végre egyenlő 0,3-al. Viszont ennek előfeltétele, hogy az értékét szövegként definiáljuk. Persze nincs ingyen ebéd. Két fő problémával is számolnunk kell: egyrészt a lassabb mint a float, másrészt a numpy (és sok más egyéb könyvtár) nem kompatibilis vele. Hogy számszerűsítsük, az elsőt teszteljük:
%time a = [ x/10 for x in range(int(1e+6))] # CPU times: user 189 ms, sys: 32.1 ms, total: 221 ms # Wall time: 218 ms %time b = [ str(x/10) for x in range(int(1e+6))] # CPU times: user 901 ms, sys: 42.7 ms, total: 944 ms # Wall time: 942 ms %time b = [ Decimal(str(x/10)) for x in range(int(1e+6))] # CPU times: user 1.06 s, sys: 46.9 ms, total: 1.11 s # Wall time: 1.1 s %time a = [ a[i]+a[i+1] for i in range(len(a)-1) ] # CPU times: user 172 ms, sys: 17 ms, total: 189 ms # Wall time: 187 ms %time b = [ b[i]+b[i+1] for i in range(len(b)-1) ] # CPU times: user 248 ms, sys: 45.1 ms, total: 293 ms # Wall time: 291 ms
Egyszerű összeadást 44% lassabban végzünk. De ha float-ból kell előállítanunk a Decimal-t akkor nagyságrendekkel lassabbak vagyunk.
Amúgy a Decimal lényegében nem más mint egy speciális tuple köré épített osztály, amit az is mutat, hogy annak segítségével is tudjuk definiálni:
# Decimal tuple: # 0 -- bool,-- negativ szám vagy nem # 1 -- tuple -- az egyes tizedesjegyek # 2 -- int -- a tizedespont eltolása x = decimal.Decimal((1, (1, 3, 0, 4, 2), -3)) print(x) # -13.042
Ez nagyjából magyarázza is, hogyan tudja elkerülni a reprecenziós problémát.
Amúgy sok hasznos eljárása van a modulnak, ami megkönnyíti a Decimal tipikussal való munkát, amit itt nem részletezek.
Ahogy fentebb említettem a tört számok számára is született egy modul. Az alapötlet lényegében ugyanaz, mint a decimal-nál, a különbség, hogy itt a tuple a háttérben csak két elemből áll: a nevezőből és a számlálóból. Minden más tekintetben lényegében ugyanaz történik, szóval ezt talán nem is kell részletezni, csak lássunk egy példát:
from fractions import Fraction Fraction(16, -10) # Fraction(-8, 5) Fraction(123) # Fraction(123, 1) Fraction('7e-6') # Fraction(7, 1000000)
Lábjegyzet
- A Python 2.7-ben két fajta egész számot használt: int és long. A 3.x Pythonban az int lényegében a 2.7-es long-nak felel meg. Még létezik a complex típus is, amiről lentebb lesz szó.
- Gondoljunk csak a végtelen tizedes törtekre.
- Majd minden modern számítógép napjainkban az IEEE-754 szabványt használja, ami 64-bites CPU esetén 53 bitet engedélyez a számjegyek tárolására, vagyis az folytonos számrendszert olyan diszkrét számrendszerre bontja, ahol
a távolság az egyes számok között.
- Bővebben: Mr. 1 Csodaországban.
- Python 3.5-től
- Python 3.5-től. előtte használhatjuk a float(‘inf’) értéket.