1. Emlékeztető: típusok

Ismétlés
(1–2. előadás)

Típus: értékkészlet és hozzá tartozó műveletek.


Egyszerű, beépített típusok:

  • Számok: int, float, pl. 5, 3.14
  • Logikai: bool, csak True és False
  • Üres: NoneType, csak None
  • Szövegek: str, pl. "helló"

Mint láttuk az előző előadáson, a Pythonban ez a határ nem éles; például az egyszerű egész számok is objektumok.


Összetett, származtatott típusok:

  • Lista: (általában) egyforma típusú elemek sorszámozott tárolója
  • Osztály: összetartozó adatok, egy dolog tulajdonságai – erről lesz most szó.

2. A type() függvény

A type() függvénnyel lekérdezhető egy érték típusa:

print(type(5))
print(type("alma"))
print(type([1, 2, 3]))
<class 'int'>
<class 'str'>
<class 'list'>

Emlékezzünk vissza: minden értéknek van egy típusa, amely meghatározza az értékkészletet (ábrázolható értékek halmaza), és a műveleteket (pl. számnál összeadás, sztringnél összefűzés, listánál elem kiválasztása). Ugyanez igaz a változókra is, bár ott valójában nem a változó az, aminek típusa van, hanem a változó egy referenciát tartalmaz egy értékre (objektumra), és annak van típusa.

Egy érték típusa lekérdezhető a type() függvénnyel. Ez a függvény egy másik értéket ad: egy olyan objektumot, amely az adott típust reprezentálja. Például a type(5) kifejezés értéke int, ahol az int egy globálisan (minden függvényből) látható érték. Ugyanígy, type("alma") értéke str.


A típusokat létező objektumok reprezentálják, így:

print(type(5) is int)
print(type(5) is str)
True
False

Rövidebben:

print(isinstance(5, int))
print(isinstance(5, str))
True
False

Mivel minden típust reprezentáló objektumból egyetlen egy van, ezért az is és az is not operátorral meg tudjuk vizsgálni annak identitását. Így végeredményben egy érték típusát könnyen ellenőrizhetjük: a type(5) is int értéke igaz, type(5) is str értéke hamis, mert az 5 egy egész szám, nem pedig egy sztring. A type(érték) is típusneve kifejezést rövidíteni is lehet, az isinstance() függvénnyel: isinstance(érték, típus) akkor igaz, ha az első paraméter típusa megegyezik a második paraméterként megadott típussal. Az „instance” szó példányt jelent: tulajdonképpen azt nézzük meg a fenti példányban, hogy az 5, mint érték, az int típus egy példánya-e (int típusú objektum-e), vagy nem.

Megjegyzés: az isinstance(x, y) valójában nem teljesen ugyanaz, mint a type(x) is y, mert altípusok (subtype) esetén is igazat ad. De ez a második félév témája, ebben a félévben erről egyáltalán nem lesz szó.

3. Függvények többféle paraméterezéssel

Mire jó a típus vizsgálata? Például esetszétválasztást tudunk csinálni, eltérő típusú értékekre másképp megoldani valamilyen feladatot.

Feladat: írjunk függvényt, amelyik kiírja a paraméterként kapott adatot! Lista esetén legyenek vesszővel elválasztva az elemek, de szögletes zárójel nélkül!

print(5)
print([1, 2, 3])
5
1, 2, 3

Esetszétválasztás típus szerint:

def kiir(mit):
    if type(mit) is list:   # lista?
        print(*mit, sep=", ")
    else:                   # nem lista
        print(mit)
    
def main():
    kiir(5)
    kiir([1, 2, 3])

A feladat nehezebbik része a szöveg második mondatában van „elrejtve”: lista esetén ne legyen szögletes zárójel. Első olvasásra azt gondolnánk, egy print(paraméter) elég lesz, de pont ez az a része a specifikációnak, ami miatt nem.

Itt jön kapóra, hogy meg tudjuk vizsgálni egy változóban tárolt érték típusát. Mert mondhatjuk azt, hogy „ha a paraméter lista típusú, akkor csináljuk ezt, amúgy csináljuk azt”. Az type(mit) is list lista típusú paraméter esetén lesz igaz.

Nem tartozik szorosan a témához, de érdemes itt szót ejteni a * operátorról. Emlékezzünk vissza, függvények fejlécében ez a tetszőlegesen sok paramétert jelentette. Függvényhívásnál viszont lista kicsomagolására használható: ha egy lista elé írjuk, akkor olyan, mintha a hívott függvény egyesével megkapta volna paraméterként a listában tárolt adatokat. Vagyis például ez a két függvényhívás egyenértékű:

print(1, 2, 3)

lista = [1, 2, 3]
print(*lista)

Mivel a feladatban tudjuk, hogy egy listáról van szó, akár ki is csomagolhatjuk azokat a print() paramétereiként, jelezve azt is, hogy vesszőt kell használni elválasztónak: print(*mit, sep=", "). Persze ha írtunk volna egy ciklust erre, ugyanolyan jó lenne az is.

Osztályok

5. Hogyan tároljunk törteket?

Racionális számok

Tegyük fel, hogy egy olyan programot kell készítenünk, amely racionális számokkal dolgozik. Hogy ezeket pontosan tudjuk tárolni, a lebegőpontos tárolás ötletét elvetjük: mindig külön tároljuk a számlálót és a nevezőt, két egész típusú változóban. Ahogy írjuk a programot, azonban egyre bonyolultabb kifejezéseink lesznek; egyre nehezebb lesz követni, hogy melyik törtes műveletet mely változókon kell elvégezni. Pl. az alábbi művelet:

┌ a   c ┐   ┌ e   g ┐   ad+cb   eh+gf   (ad+cb)(eh+gf)
│ ─ + ─ │ · │ ─ + ─ │ = ───── · ───── = ──────────────
└ b   d ┘   └ f   h ┘     bd     fh         bdfh
Mi tartozik
össze?!

Kódban ez így nézne ki:

i = (a*d+c*b)*(e*h+g*f)
j = b*d*f*h

Még ha be is vezetünk valami konvenciót a jelölésre (pl. asz és an az a tört számlálója és nevezője), akkor is elég reménytelennek tűnik a helyzet. Két tört számlálója és nevezője – ez se sokkal jobb:

asz = 2     # a számlálója és nevezője
an = 5
bsz = 4     # b számlálója és nevezője
bn = 9

Mi hiányzik nekünk? Az adat absztrakciója! Az hiányzik, hogy az adattípusokból ugyanúgy tudjunk építkezni, ahogyan az algoritmusoknál a függvényekkel is tettük. Legyen olyan nyelvi elem, amely segítségével több összetartozó adatot egységként kezelhetünk, és néven is nevezhetjük az így kialakult adatcsomagot. Ja, és persze ez a nyelvi elem nem a lista! Egy törtnek nem „nulladik” és „első” eleme van, hanem számlálója és nevezője. Szeretnénk, ha név szerint lehetne ezekre hivatkozni.

6. Összetett típus: osztályok

Az osztályok fogalma

Az osztályok arra jók, hogy egy új típust hozzunk vele létre. Ez a típus egyedi lesz, saját névvel fog rendelkezni a programban. Az osztályok segítségével összetett dolgokat (pl. racionális számokat) tudunk egyszerűbb, meglévő típusokkal (két egész szám, számláló és nevező) reprezentálni. Másképp megközelítve, egy bizonyos dolog (racionális szám) több tulajdonságát (számlálója, nevezője) tudjuk összefogni, egységként kezelni.

  • Összetartozó adatok egységként
  • Absztrakt fogalom reprezentációja egyszerűbb típusokkal
  • Új típus létrehozása

A törteket egységként kezelve, osztállyal:

class Tort:
    # ... erről lesz szó ma, hogy ide mit írunk

a = Tort(2, 5)
b = Tort(4, 9)
x = a + b
print("a+b =", x)

A fenti kódot tesszük majd működőképessé az óra végére. Látszik, hogy ebben az a és a b változó egy-egy tört, amelyek magukba foglalják a számlálót és a nevezőt is.

A Pythonban lehetséges még az is, hogy a saját típusainkra megadjuk, mit csináljanak az egyes beépített operátorok. Tehát pl. meg tudjuk oldani azt, hogy a törtjeinkre az összeadás elvégezze a közös nevezőre hozást, és a többi szükséges műveletet. Ez nem tartozik szorosan az osztályok témájához, de ha már tudja a nyelv, ezen a példán bemutatjuk ezt is.

7. Osztályok definíciója és példányosítása

Tekintsünk előbb egy egyszerűbb példát: egy pontot a síkon, amelynek helyét az x és y koordinátái határozzák meg.

Definíció szintaxisa

class Pont:
    pass
    # ide majd írunk mást is

Példányosítás

p1 = Pont()
p2 = Pont()
print(p1 is p2)   # False

Az osztály definícióját a class szóval kell bevezetni. A neve lehet bármi, ami még nem foglalt. A definíció belsejébe majd írunk egyéb dolgokat, egyelőre most ez maradjon üres (pass).

Az osztályból létrehozott példányokat objektumnak nevezzük. A példányosítás úgy történik, hogy az osztály nevét függvényként használjuk: Pont(). Ezt konstruktornak is szokták nevezni, mert létrehozott egy új objektumot. Az objektum referenciáját az értékadással eltároljuk egy változóban. Újabb Pont() hívással újabb pontot hozunk létre. A p1 is p2 kifejezés értéke hamis, mert egymástól független objektumokról van szó, kétszer hívtuk a Pont() konstruktort.

A változók és típusok elnevezésénél egyébként érdemes figyelni a következetességre, mert az megkönnyíti a programok írását és megértését. Sok helyen szabályokat is alkotnak erre, amelyeket egy adott cégnél vagy programozói közösségnél szigorúan betartanak. Egyik ilyen elterjedt szokás az, hogy a saját típusok neveit nagybetűvel kezdik, a változókat pedig kicsivel. Ezért lett a pont osztály neve a fenti példákban a nagybetűs Pont, az egyes példányok neve pedig a kisbetűs p1 és p2.


Az attribútumok elérése

p = Pont()

p.x = 3  # az x koordinátája legyen 3
p.y = 6

print("p pont: ({};{})".format(p.x, p.y))
p pont: (3;6)

Az üresen létrehozott objektum kezdetben semmilyen adatot nem tartalmaz még. De létrehozhatunk benne attribútumokat, más néven mezőket vagy adattagokat (attribute, field, data member). Ezeket a . mezőkiválasztó operátorral (pont operátorral, angolul: dot operator) érjük el. Például p.x jelentése: a p pont x koordinátája. Ennek értéket adhatunk, eltárolva a vízszintes koordinátát. Ugyanígy, a p.y-nak értéket adva eltárolhatjuk a pont függőleges koordinátáját is.

Az így létrehozott adattagok ugyanúgy viselkednek, mint bármelyik másik változó: értéket kaphatnak, kifejezésekben szerepelhetnek, és így tovább. Ha olyan adattagot próbálunk meg kiolvasni, ami még nem létezik (pl. p.abc), akkor AttributeError típusú hibát fogunk kapni.

8. Objektum változóban és függvényben

Értékadás: referenciát másol

p1 = Pont()
p2 = p1
p1.x = 3
print(p2.x) # 3

A Pont típusú objektumot a Pont() konstruktor hozza létre. És mint azt láttuk a múltkori előadáson, az értékadás referenciát állít be. Ez azt jelenti, hogy most egy pontunk van, két referenciával. Ha módosítjuk p1.x-et, akkor p2.x is módosul, mert a kettő egy és ugyanaz.


Függvény paramétere, visszatérési értéke

def origo_tavolsag(p):
    """Megadja a pont origótól mért távolságát."""
    return math.sqrt(p.x ** 2 + p.y ** 2)

def szakaszfelezo(p1, p2):
    """Megadja a szakaszfelező helyét."""
    f = Pont()
    f.x = (p1.x + p2.x) / 2
    f.y = (p1.y + p2.y) / 2
    return f

Objektum lehet függvény paramétere és visszatérési értéke is. A paraméterátadás szabályai ugyanazok, mint amit eddig is láttuk: a függvény a neki átadott objektumok referenciáját kapja meg. Így bár a paraméterként átvett változó nem változtatható meg, a referencián keresztül látott objektum igen: a függvény a pont x és y koordinátáját akár módosítani is tudná.

9. A konstruktor szerepe: adattagok beállítása

Objektumok inicializálása: a konstruktor

Látjuk, hogy az attribútumok az értékadással jönnek csak létre. Pedig egy osztálybeli összes objektumnak ugyanolyan tulajdonságai szoktak lenni. Minden racionális számnak számlálója és nevezője van. Ez a kettő mindig kell, több tulajdonsága pedig nincs. Hasonlóképp, a síkbeli pontoknak x és y koordinátájuk van. Ha könyvtárprogramot írunk, a könyvek címét, szerzőjét, esetleg kiadási évét és terjedelmét tároljuk: egységesen az összes könyvre, aminek az adatait a program ismeri.

Érezzük azt is, hogy sokszor jó lenne az attribútumokat már az objektum létrehozásának pillanatában beállítani. Például a pont esetében megadhatnánk a koordinátákat, a tört esetében a számlálót és a nevezőt; nem kellene ezeket utólag, külön kódsorban beállítani.

Az objektumot inicializáló (initialize; adattagjait kezdetben létrehozó, értéküket beállító) függvényt nevezzük konstruktornak. Pythonban ezt az __init__ nevű függvénnyel adjuk meg.

class Pont:
    def __init__(self, x, y): # konstruktor
        self.x = x
        self.y = y

def szakaszfelezo(p1, p2):
    """Megadja a szakaszfelező helyét."""
    return Pont((p1.x+p2.x) / 2, (p1.y+p2.y) / 2)

def main():
    p1 = Pont(2, 5)
    p2 = Pont(3, 10)
    f = szakaszfelezo(p1, p2)
    print("f pont: ({};{})".format(f.x, f.y))   # (2.5;7.5)

main()

A konstruktor működése

A konstruktort onnan ismeri meg a nyelv, hogy __init__-nek nevezzük el. A függvény neve két alulvonással (underscore) kezdődik, és két alulvonással fejeződik be. A dupla alulvonásnak külön nevet is adott a Python közösség, ez a dunder; angol nyelvű szakirodalomban gyakran találkozni ezzel.

Ezt a függvényt kötelező az osztály belsejében definiálni. Ezen felül van egy kötelező első paramétere, amit pedig self-nek szokás elnevezni (self = én magam, angolul). Ez az a paraméter, amelyen keresztül az inicializált objektumot látjuk. További paraméterek is lehetnek, jelen esetben ezek az x és y nevű paraméterek. A konstruktor itt nem csinál mást, csak beállítja az objektum x és y attribútumát a paraméterként megadott értékekre. Hol melyik x-ről és y-ról van szó? Ez egyértelműen eldől a forráskódból. A sima x a függvény paramétere, a self.x az objektum attribútuma.

A konstruktor hívásának pillanatában, pl. a p1 = Pont(2, 5) kifejezés kiértékelésekor a következők történnek:

  • Létrejön egy Pont típusú objektum, egyelőre üresen.
  • Ezek után meghívódik az __init__ függvény. Az első paramétere, a self az újonnan létrejött, még üres objektum referenciája lesz. A további paraméterei pedig azok az értékek, amelyeket a Pont() kapott, jelen esetben x = 2 és y = 5.
  • Lefut a függvény törzse, és beállítja az objektum attribútumait.

Végül pedig, az egész kifejezés értéke szintén az újonnan létrehozott objektum referenciája lesz. Vagyis a p1 változóba ugyanannak a pontnak a referenciáját tesszük, mint ahova az __init__ végrehajtása közben a self is mutatott.

Látszik, hogy így sokkal egyszerűbb lett a szakaszfelező helyét meghatározó függvény. Létrehoz egy pontot, amelyet annak konstruktora egyből inicializál is; és ennek a pontnak a referenciája a visszatérési érték.

10. Üres adattagok? Legyen None!

A Python nyelv dinamikus, az objektumok attribútumait tekintve is. Mint láttuk, bármikor hozzáadhatunk egy attribútumot, ami addig nem létezett, vagy kitörölhetünk egy attribútumot (ilyen a del operátorral lehetséges). De az ilyesmi nagyon-nagyon ritka.

Az viszont gyakran előfordul, hogy egy objektum adattagjainak értéke csak később derül ki, a létrehozása után. Például egy weboldalon a kapcsolatfelvételi űrlap adatait a billentyűzetről olvassuk be. Vagy ha adatbázisban tároljuk a régebben beküldött üzeneteket, akkor az adatbázisból kikereséskor adunk értéket a mezőknek.

Kapcsolat

Név
       ──────────────
E-mail
       ──────────────
Üzenet


       ──────────────
class Uzenet:
    """Űrlap adatai.
    nev: str, a feladó neve
    email: str, a feladó címe
    uzenet: str, üzenet szövege"""
    def __init__(self):
        self.nev = None
        self.email = None
        self.uzenet = None

def main():
    u1 = Uzenet()
    u1.nev = input("Név: ")
    u1.email = input("E-mail: ")
    u1.uzenet = input("Üzenet: ")

Ilyenkor jobb, ha az adattagokat már előre létrehozzuk – akár úgy is, hogy None értéket adunk nekik –, mert a programunk követhetőbb, áttekinthetőbb lesz. Ha belenézünk az osztályba, egyből látjuk, hogy milyen attribútumai vannak, és nem csak a program többi részét átrágva derül ez ki.

Vannak viszont olyan objektumok, ahol egyszerűen értelmetlen, ha nincs értékük. Ilyen a már említett racionális szám, amit mindjárt kidolgozunk részletesen. De ilyen egy egyszerű int objektum is: mindig tárol valamilyen számot.

Adattagok létrehozása a ProgAlap tárgyban

A ProgAlap tárgyban mindig így kérjük: hozzuk létre a konstruktorban az összes adattagot, akkor is, ha azoknak egyelőre még nincs értéke, csak None!

A törtes példa kidolgozása

12. Törtes példa: a feladat

Racionális számok

Feladat: hozzunk létre típust, amelyik racionális számot tárol. Írjuk meg az ezeket összeadni, szorozni, kiírni tudó függvényeket!


A törtek osztálya

Az első
megoldás:
tort1.py
  • Ez új típus! Saját értékkészlet és műveletek.
  • Összetartozó adatok is. Ezért ez egy osztály lesz!
  • A műveletek pedig függvények.
class Tort:
    def __init__(self, szaml, nev):
        self.szaml = szaml
        self.nev = nev

def main():
    x = Tort(4, 5)     # 4/5

Mivel az osztályt több függvény is használja, globálisan definiáljuk.

13. Tört kiírása, azaz sztringgé alakítása

A print() nem ismeri a tört típust:

x = Tort(2, 3)
print("x =", x)
x = <__main__.Tort object at 0x7fbed3f60a20>

Írjunk függvényt, amelyik sztringgé alakítja!

def tort_sztringkent(t):
    """szaml/nev alakú sztringgé alakítja a törtet."""
    return "{}/{}".format(t.szaml, t.nev)


x = Tort(4, 5)
print("x =", tort_sztringkent(x))     # x = 4/5

A sztringek format() függvénye önmagában is használható, sztringet ad vissza. Itt a tort_sztringkent() függvényben a formázás eredményét nem írtuk ki egyből, hanem egyszerűen visszaadtuk azt a hívónak.

14. Tört lebegőpontos értéke

Szükségünk lehet a tizedestörtre is:

def tort_valoskent(t):
    """Visszatér a tört lebegőpontos értékével."""
    return t.szaml / t.nev


t1 = Tort(4, 5)
print("t1 =", tort_valoskent(t1))
print("sqrt(t1) =", math.sqrt(tort_valoskent(t1)))
t1 = 0.8
sqrt(t1) = 0.8944271909999159

A függvény egy törtből csinál float típusú lebegőpontos számot. Így már odaadhatnánk azt a math.sin() vagy a math.sqrt() függvénynek. Hogy ezt miért nevezzük lebegőpontosnak, arról később lesz szó.

Python 2.x

Nagyon apró betűs megjegyzés: a Python 2-es verziójában az int/int művelet még egész osztást végzett (mint most, a 3-as verzióban a // operátor). Így a fenti kód a régebbi Pythonban nem adott volna helyes eredményt, helyette pl. float(t.szaml) / float(t.nev)-t lehetett volna írni. De az elavult 2-es verzióval ebben a tárgyban már nem foglalkozunk.

15. Törtek összege, szorzata

A nevezők szorzata lehet közös nevező. Két törtet összegző függvény:

a c ad+cb ─ + ─ = ───── b d bd
def tort_osszeg(t1, t2):
    """Megadja a két tört összegét."""
    return Tort(
        t1.szaml * t2.nev + t2.szaml * t1.nev,
        t1.nev * t2.nev
    )
z = tort_osszeg(x, y)

A szorzat ugyanígy:

a c ac ─ · ─ = ── b d bd
def tort_szorzat(t1, t2):
    """Megadja a két tört szorzatát."""
    return Tort(
        t1.szaml * t2.szaml,
        t1.nev * t2.nev
    )

16. Törtek egyszerűsítése

Próbáljuk ki az eddigi függvényeinket!

x = Tort(1, 2)
y = Tort(1, 4)
print("t1 =", tort_sztringkent(tort_osszeg(x, y)))

A program futási eredménye:

6/8

Ez helyes is, és nem is. Helyes, mert 6/8 az 3/4, és az összeg tényleg annyi. De lehetne jobb is, ha a program egyszerűsíteni is tudna.


Az egyszerűsítéshez meg kell határoznunk a számláló és a nevező legnagyobb közös osztóját. És ezt a konstruktorban kell megtennünk:

class Tort:
    def __init__(self, szaml, nev):
        lnko = math.gcd(szaml, nev)
        self.szaml = szaml // lnko
        self.nev = nev // lnko

Nagyon fontos itt a függvény filozófiája. A két egész szám összerakva nem csak egyszerűen két egész szám együtt, hanem egy tört. Speciálisabb, mint egy sima számpár. Ezért amikor egy törtet „építünk”, azaz létrehozunk két egész számból, akkor el kell végeznünk egy egyszerűsítést rajta. Tehát az egyszerűsítésnek a konstruktorban van a helye. Mivel törtet létrehozni csak a konstruktorral tudunk, minden törtünk egyszerűsítve lesz! A számlálónak és a nevezőnek történő „kézi” értékadást pedig kerülni fogjuk – immutábilisnak tekintjük a tört objektumot.

A math.gcd függvény megkeresi két szám legnagyobb közös osztóját. Ezzel osztva a számlálót és a nevezőt megkapjuk az egyszerűsített törtet. Itt most szándékosan a // operátort használtuk a / helyett: a // egész számot ad, az osztás egészrészét. Így a számlálóba és a nevezőbe nem float, hanem int típusú értékek kerülnek. Mivel pont a legnagyobb közös osztóval osztottunk, biztosak lehetünk benne, hogy amúgy is egész számokat kaptunk, mert nincs maradék.

17. Tört beolvasása – létrehozás sztringből

Alakítsunk egy szaml/nev alakú sztringet törtté:

Írd be a törtet:
6/8_
print("Írj be egy törtet:")
t = sztring_tortte(input())

A sztringek split() függvénye a megadott határolónál darabol:

def sztring_tortte(s):
    """szaml/nev alakú sztringből törtet csinál."""
    elemek = s.split("/")
    szaml = int(elemek[0])
    nev = int(elemek[1])
    return Tort(szaml, nev)

18. Tört beolvasása – hibakezelés

Mi történik, ha nem számot kapunk? Ha „1/2/3” van a sztringben? Ha 0 a nevező?

A programban sok hibalehetőségre fel kell készülnünk. Bármikor előfordulhat, hogy egy törtet véletlenül – programozási hiba miatt, hibás bemenet miatt – 0 értékű nevezővel próbálunk meg inicializálni. Vagy épp olyan sztringből akarunk törtet kiolvasni, amiben nem tört van, vagy nem a megfelelő formátumban.

Mi történjen ilyenkor? A helyes megoldás az, ha kivételt dobunk! Lássuk előbb a sztringet törtté alakító függvényt:

def sztring_tortte(s):
    """szaml/nev alakú sztringből törtet csinál."""
    elemek = s.split("/")
    if len(elemek) != 2:
        raise ValueError("Törthöz száml/nev alakú sztring!")
    szaml = int(elemek[0])
    nev = int(elemek[1])
    return Tort(szaml, nev)

Itt feltörjük a sztringet a / karakterek mentén. Pontosan kettő elemű kell legyen a lista, amit kaptunk, mert pontosan egy számlálónak és egy nevezőnek kell benne lennie. Ha ez nem teljesül, kivételt dobunk. Ezek után számmá alakítjuk mindkét sztringdarabot; ha bármelyikben nem szám volt, az int() függvény kivételt dobott. Ha számok voltak, akkor innentől a további teendőket a tört konstruktora elvégzik.

A split() függvénynek egyébként van egy második, opcionális paramétere is, hogy hány darabot vágjon le a sztring elejéről. Pl. "a|b|c|d".split("|", 1) értéke egy kételemű lista: ["a", "b|c|d"], mert levágott a split() egy darab elemet, és a többi megmaradt egyben. Így a „két darabból áll-e a sztring” ellenőrzése tulajdonképpen így is elvégezhető:

def sztring_tortte(s):
    """szaml/nev alakú sztringből törtet csinál."""
    elemek = s.split("/", 1)
    szaml = int(elemek[0])
    nev = int(elemek[1])
    return Tort(szaml, nev)

Ez azért van, mert ha több darabból állt volna, akkor a második int() dobott volna kivételt; ha kevesebb, akkor pedig valamelyik indexelés. Ez egyébként hatékonyabb megoldás, bár kevésbé érthető a kód.

A számláló és a nevező ellenőrzését pedig a konstruktorban végezzük el. Először is, a két kapott paraméter, a számláló és a nevező egész számok kell, hogy legyenek. Másodszor pedig, a számláló nem lehet negatív, és a nevező nem lehet 1-nél kisebb (0 sem lehet). Ha az említett problémák közül bármelyik fennállna, akkor a megfelelő típusú kivételt dobjuk.

class Tort:
    def __init__(self, szaml, nev):
        if type(szaml) is not int or type(nev) is not int:
            raise TypeError("Számláló és nevező int legyen")
        if szaml < 0 or nev < 1:
            raise ValueError("Számláló>=0, nevező>0 kell")
        lnko = math.gcd(szaml, nev)
        self.szaml = szaml // lnko
        self.nev = nev // lnko

Itt most feltételeztük, hogy csak nemnegatív törtekkel dolgozunk. (Ez amúgy már eddig is így volt, különben a közös nevezőre hozásnál figyelni kellene erre külön.)

19. Tort(3, 4), Tort("3/4") és Tort()

A második
megoldás:
tort2.py
class Tort:
    def __init__(self, p1=None, p2=None):
        if type(p1) is int and type(p2) is int: # int, int
            szaml = p1
            nev = p2
        elif type(p1) is str and p2 is None: # str
            elemek = p1.split("/", 1)
            szaml = int(elemek[0])
            nev = int(elemek[1])
        elif p1 is None and p2 is None: # 0 param
            szaml = 0
            nev = 1
        else:
            raise TypeError("Tört: rossz konstruktorhívás")

        if szaml < 0 or nev < 1:
            raise ValueError("Számláló>=0, nevező>0 kell")

        lnko = math.gcd(szaml, nev)
        self.szaml = szaml // lnko
        self.nev = nev // lnko

A konstruktor eltérő paraméterezhetőségéhez az előadás elején bemutatott technikát alkalmazzuk. Három formát szeretnénk:

  • Két egész szám, pl. Tort(3, 4), hogy úgy használhassuk, mint eddig.
  • Sztringből, pl. Tort("3/4"), hogy úgy használhassuk, mint pl. az int()-et: Tort(input()).
  • Paraméter nélküli konstruktor, ami 0-t állít be.

Legegyszerűbben ezt úgy oldhatjuk meg, ha csinálunk egy kétparaméterű konstruktort, amelynél mindkét paraméter alapértelmezett értéke None. Ezek miatt a konstruktor hívható lesz 0, 1 és 2 paraméterrel is. Utána pedig a függvényen belül esetszétválasztást végzünk:

  • Ha mindkét paraméter típusa int, akkor felhasználjuk azokat számlálónak és nevezőnek.
  • Ha az első paraméter sztring, akkor a második None kell legyen. Ha ez volt a helyzet, feltörjük a sztringet a / mentén, és a két kapott szót egész számmá alakítjuk, így megkapva a számlálót és a nevezőt.
  • Ha mindkettő None, akkor 0-t és 1-et állítunk be.
  • Ha ezek közül egyik sem teljesült, akkor rossz a konstruktor paraméterezése, és dobunk egy kivételt.

Mire mindezen túljutottunk, addigra a paramétereket megfelelően értelmezve betettük a szaml és nev lokális változókba, így „csatornázva be” az értékeket a függvény alján lévő egyszerűsítéshez és az attribútumok beállításához. Onnantól kezdve a működés olyan, mint eddig.

20. Objektumorientált programozás (OOP)

Típus ≈ osztály

  • Adatok
  • Műveletek

Észrevehetjük, hogy a programunk egy objektuma, annak reprezentációja (adatai, tulajdonságai) és műveletei (milyen függvényekkel használható) mindig összetartoznak. Ezt láttuk már a típus definíciójánál, de ez a helyzet az osztályok esetén is. A két fogalom összemosódik, eltérő programozási nyelvek kapcsán néha kicsit mást jelentenek. Gyakran típus néven a beépített típusokra, osztály néven a programozó által definiált típusokra hivatkozunk. De valójában ez is csak mesterséges megkülönböztetés, történelmi okokból.


Példák:

Racionális szám

  • Számláló, nevező
  • Összeadás
  • Kivonás
  • Szorzás
  • ...

Kör

  • Középpont, sugár
  • Kirajzolás
  • Eltüntetés
  • Területszámítás
  • ...

Ablak

  • Felirat, pozíció
  • Kicsinyítés
  • Maximalizálás
  • Mozgatás
  • ...

Az objektumorientált programozás egy programozási paradigma (object oriented programming, OOP). Ebben egységként kezeljük az adatokat és a műveleteket, amelyek hozzájuk tartoznak. A modell a program működését az objektumok kommunikációjaként képzeli el: melyik objektum milyen feladatot képes ellátni, és hogyan üzen más objektumoknak (hogyan hívja a más objektumokhoz tartozó függvényeket). A Python is támogatja ezt a paradigmát; az egységbe zárást fentebb a racionális szám operátorain keresztül mutattuk be, de tetszőleges további függvényeket is írhatnánk más osztályoknak.

A második félévhez tartozó programozás tárgy kifejezetten erről fog szólni, így az első félévben ebbe a témakörbe a fentieknél jobban nem mélyedünk bele. Helyette maradunk az algoritmusok és az adatszerkezetek tanulmányozásánál.

21. Függvényhívások szintaxisai

Az OOP elvek miatt a függvényeket és a metódusokat eltérő szintaxissal kell használni:

# Létrehozás: visszatérési érték
szamok = szamsort_beolvas()

# Módosítás: sokszor tagfüggvény, de lehet globális is
szamok.append(123)
szamsort_duplaz(szamok)

# Miért nem szamok.len()? → Leginkább történelmi baleset
print(len(szamok))

A fenti példához definiált függvények:

def szamsort_beolvas():
    lista = []
    # ...
    return lista

def szamsort_duplaz(lista):
    for i in range(len(lista)):
        lista[i] *= 2

A függvényhívásoknak többféle szintaxisa lehet. Vannak globális függvények, amelyeket f(paraméterek) formában használunk. És az objektumorientált programozásból adódóan az osztályoknak vannak tagfüggvényei (metódusai), amelyeket objektum.f(paraméterek) formában kell hívni. A félévben az utóbbi formát nem fogjuk használni a saját osztályainknál (lásd az előző pontban), de a Python nyelvben elég sok beépített osztály van, ahol ezek előkerülnek.

Ilyen például a listák .append() vagy .pop() függvénye. Ha mutábilis típusról van szó, akkor ezek gyakran magát az objektumot is módosítják – az .append()-től eggyel hosszabb, míg a .pop()-tól eggyel rövidebb lesz a lista. Ilyen esetben egyébként bátran írhatunk olyan függvényt is, amelyik módosítja a listát, mégis globális függvényhívás szintaxissal használandó: a fenti szamsort_duplaz() függvény éppen ilyen.

Tipikusan azok a függvények, amelyek létrehoznak valamilyen objektumot, visszatérési értékben adják azt, mint például a fenti szamsort_beolvas() is. A konstruktorhívások is így jelennek meg szintaktikailag: int("123"), str(123) vagy Tort(4, 5) éppen ilyenek. Ezekben az esetekben a kifejezés értéke az új objektum, amihez utána egy változó értékadásával referenciát kell kötni, különben elveszik. Ez egyébként nem is lehet másképp; mivel egy függvény nem tudja módosítani a neki paraméterként adott változó tartalmát, paraméterbe nem tudna új objektumot tenni.

Végül pedig, vannak azok az esetek, amikor egy meglévő objektummal dolgozunk, annak egy műveletét használjuk, és ezért úgy érezzük, tagfüggvényről lenne szó – de mégis globális szintaxist kell használnunk. Ilyen például a len(), amelyik egy lista vagy egy sztring hosszát adja. Mi ennek az oka? Történelmi balesetről van szó – így alakult ki. Meg kell szokni, fejben kell tartani. A program jelentését, futási eredményét ez nem módosítja amúgy, egyszerűen csak így van, és kész.

22. Operátorok definiálása

Az eddig megírt kódunk jól működik, de elég körülményes használni. A törtek számok, és a számok összeadását, szorzását stb. leginkább a műveleti jelekkel, operátorokkal szeretnénk elvégezni, nem pedig függvényhívásokkal. Ugyanez a helyzet a kiírásnál: a tört kiírásához sztringgé kell azt alakítani. Tehát ha valahogy meg tudnánk mondani, hogy egy Tort típusú adat hogyan alakítható sztringgé (str típusú adattá), és ezt a Python nyelv tudomására is hoznánk, akkor nem kellene a tort_sztringkent() függvénnyel bíbelődnünk, hanem magától rájönne a gép.

Ezt kell írjuk:

print("összeg =", tort_sztringkent(tort_osszeg(a, b)))

Ez jobban tetszene:

print("összeg =", a + b)

Amit itt el szeretnénk érni, az az, hogy a saját típusunkra operátorokat definiálhassunk: azaz megadhassuk, hogy a meglévő operátorok mit csináljanak a saját típusunkra. Ezt angolul operator overload technikának nevezik. Ezt „túlterhelésnek” szokás fordítani magyarul, sajnos nem a legjobb név.


Pythonban ez lehetséges. Egyes szintaktikai elemek jól meghatározott nevű függvényeket hívnak:

Melyik függvényMinek felelt megMi hívódik
sztring_tortte(s) Tort(s) __init__
tort_sztringkent(t) str(t) t.__str__()
tort_valoskent(t) float(t) t.__float__()
tort_osszeg(a, b) a + b a.__add__(b)
tort_szorzat(a, b) a * b a.__mul__(b)

Ezek az ún. speciális metódusok (special methods), de az angol szakirodalomban több másik nevük is van (dunder method, magic method). Ahhoz, hogy működjenek, semmi egyéb teendőnk nincsen, mint definiálni kell a megadott nevű függvényeket. Ha írunk a tört osztályban egy __str__() nevű függvényt, akkor az automatikusan meg fog hívódni, amikor str(t)-t (vagy print(t)-t) írunk. Ha definiálunk __add__() függvényt is, akkor az a + b kifejezés hatására – törtek esetén – az a.__add__(b) függvény fog meghívódni.

Tulajdonképpen a konstruktor, tehát az __init__() függvény is ilyen. Annak speciális szintaxisa az osztályneve(paraméterek...) volt. A fenti táblázat szerint azt szeretnénk, hogy Tort(sztring)-et is lehessen írni a Tort(számláló, nevező) mellett; ezt a paraméterek vizsgálatával kell majd megoldanunk.

Vigyázat: a törtes példa különleges, mert a racionális számoknál pont olyan műveleteink vannak, amelyekhez operátor is van a nyelvben: összeadás, kivonás és így tovább. Ha egy másik példával állnánk szemben, mondjuk Könyv osztállyal, Ember osztállyal, Téglalap osztállyal, akkor azokhoz semmiképpen sem definiálnánk operátorokat. Nincs értelme! Nem lehet két könyvet kivonni egymásból, vagy két téglalapot összeszorozni. Ne erőltessük az ilyesmit! A következő témakör csak a racionális szám miatt érdekes.

23. __str__() és __float__()

A speciális függvényeket az osztály belsejében kell definiálnunk:

class Tort:
    def __init__(self, szaml, nev):
        ...
    
    def __str__(self):      # self
        return "{}/{}".format(self.szaml, self.nev)

    def __float__(self):    # self
        return self.szaml / self.nev

def main():
    x = Tort(1, 2)
    print("x =", x)         # str(x)
    print("sqrt(x) =", math.sqrt(x))    # float(x)

Az összes speciális függvény az __init__-hez hasonlóan működik. Az osztály törzsében kell definiálnunk őket. Innen tudja a Python, hogy milyen típusú objektumokon használható, mert amúgy a nevéből nem derül ki. Ezen felül, mindegyiknek az első paramétere az az objektum, jelen esetben a tört, amelyre meghívták. Ezt az összes függvénynél self-nek hívjuk.

A sztringé és lebegőpontos számmá konvertáló függvények maguktól hívódnak. A kiírás esetén maga a print() függvény hívja majd meg az str(valami)-t, ha látja, hogy nem sztring a paraméter típusa. A math.sqrt()-nél is ez a helyzet: ha nem lebegőpontos típusú a paraméter, akkor megpróbálja float(valami) függvényhívással azzá alakítani; mivel a törtnek most már van __float__() függvénye, ez sikerülni is fog.

24. __add__() és __mul__()

A kétoperandusú operátorok paraméterezése:

class Tort:
    def __init__(self, szaml, nev):
        ...
    
    def __add__(self, jobb):
        return Tort(
            self.szaml * jobb.nev + jobb.szaml * self.nev,
            self.nev * jobb.nev
        )

    def __mul__(self, jobb):
        return Tort(
            self.szaml * jobb.szaml,
            self.nev * jobb.nev
        )

Kétoperandusú operátorok esetén megjelenik egy újabb paraméter.

Az a + b kifejezés hatására az a.__add__(b) függvényhívás történik meg. Ez pedig azt jelenti, hogy a self mellett lesz egy további paraméter, a b értéke. Vagyis az operátor bal oldalán álló tört lesz az első, a jobb oldalon álló tört a második paraméter: self = a és jobb = b. A példában ezért kapta a második paraméter a jobb nevet. Angol nyelvű kódban ezt rhs-nek szokás elnevezni (right hand side).

Van sok más speciális függvény még. Például __lt__() felel meg a kisebb operátornak (less than), __ge__() a nagyobb vagy egyenlőnek (greater or equal). Az r betűvel jelölt operátorokkal, pl. az __radd__() függvénnyel azokat az eseteket lehet kezelni, amikor az összeadás bal oldalán nem a saját osztályunk van, mondjuk mint a 4 + Tort(5, 6) kifejezésben. Ugyanígy van __rmul__() is. De ezeket most nem soroljuk fel mind; aki szeretne ebben jobban el mélyedni, érdemes a Python dokumentáció vonatkozó fejezetét tanulmányoznia.

(Többszörösen) összetett adatok

26. Adatok tárolása: osztályok vs. listák

Könyv: osztály

Osztályba egy dolog összetartozó adatait tesszük.

  • Különálló, új típus, saját műveletekkel
  • Pl. egy könyv adatai: cím, szerző, oldalszám

Könyvek: listában tárolva

Listába több egyforma dolog adatait tároljuk.

  • Ez csak egy tároló azonos szerepű dolgok számára.
  • Pl. könyvek katalógusa

Ne feledjük: a típus egy értékkészlet és műveletek együttese. Egy dátum osztály létrehozásával egy új típust hozunk létre, amelyen új műveletek értelmezhetőek. Pl. ki lehet számolni két dátum között a különbséget napokban. Ez kizárólag csak a dátumokon értelmezett művelet (év, hónap, nap), nem pedig az összes háromelemű, egészekből álló listán!

Ha az összetartozó adatok különböző típusúak (pl. a név karaktersor, a dátum pedig egész számokból áll), akkor biztosan osztályról van szó. Ha egyformák a típusok, gyakran akkor is. Balgaság a tört számlálóját és nevezőjét nem osztállyal, hanem egy kételemű listával megadni. Úgyszint egy év, hónap, napból álló dátum is osztály, bár mindegyik eleme egész szám. A lista választása azt is éreztetné, hogy az év, hónap, nap felcserélhetőek, ami nem igaz. Egy névsor elemei, amelyet listában tárolunk, viszont igen: sorba rendezhetőek az emberek név, születési évszám, magasság stb. szerint is.

És még egy dolog, amit ne felejtsünk el: nem azért használunk listát vagy osztályt, mert sok adattal dolgozunk, hanem azért, mert az adatoknak közük van egymáshoz! A tört számlálóját és nevezőjét is betettük egy osztályba, pedig csak két elemről van szó. Mondhatjuk, hogy ha valamilyen adatoknak a listába vagy osztályba tevése által megszűnik a programkódban a „sorminta” (pl. a1 = b1, a2 = b2, a3 = b3 helyett a = b lesz az értékadás által), akkor jó úton járunk. Ha „sorminta” van a programunkban, akkor pedig valószínűleg rossz úton. Az összetett típusokban az adataink közötti összefüggéseket rögzítjük, és ez kihatással van a programkód felépítésére is: annak áttekinthetőségére, egyszerűségére és legfőképp minőségére.

27. Többszörös összetétel: objektumok listája

class Konyv:
    def __init__(self, szerzo, cim, kiadaseve): # ...
    def __str__(self): # ...
konyvtar = []
konyvtar.append(Konyv("J. K. Rowling",
                      "Harry Potter és a bölcsek köve",
                      1997))
konyvtar.append(Konyv("J. K. Rowling",
                      "Harry Potter és a Titkok Kamrája",
                      1998))
print(konyvtar[0])
print(konyvtar[1].kiadaseve)
print(konyvtar[1].szerzo[0])

J. K. Rowling: Harry Potter és a bölcsek köve [1997]
1998
J

Az adatok összetétele terén természetesen többszörös összetétel is elképzelhető. A könyvtár példájából kiindulva, könyveket (mint osztály) tehetünk listába (mint tároló). Így a listában könyvek vannak, és az egyes könyveknek mind van szerzője, címe, kiadás éve, meg egyéb adatok, amiket tárolni szeretnénk.

Itt a listában vannak a könyvek (és nem a könyvekben a listák). Ezért a könyvtár a lista: konyvtar, amit indexelni tudunk: konyvtar[0]. Ezzel egy könyv objektumhoz jutunk. Mivel ennek a fenti példában van __str__() függvénye, odaadhatjuk egy print()-nek. De további részletet is kiválaszthatunk: a konyvtar[1].kiadaseve kiválasztja a könyvtárból: konyvtar az 1-es indexű könyvet: [1], és az így kapott könyv objektumnak is az évszámát: .kiadaseve.

A könyv szerzője és címe sztringek, így tulajdonképp önmagukban is összetett adatok: karaktersorozatok. Tehát a könyv kiválasztása: [1] és az adattag megnevezése: szerzo akár egy újabb indexelést végezhetünk: 0, megkapva a szerző nevének kezdőbetűjét.

28. Többszörös összetétel: ProgAlap eredmény

class ProgAlapEredmeny:
    def __init__(self, neptun):
        self.neptun = neptun
        self.labjelenlet = [None]*28
        self.kzh = [None]*7
        self.nzh = None
        self.pzh = None
        self.ppzh = None
        self.nhfpont = None 
        self.pothfpont = None 

def van_e_alairas(eredmeny):
    return # ...

h1 = ProgAlapEredmeny("B4TM4N")
h1.kzh[0] = 9

Ahogy listában is lehetett objektum, úgy objektumban is lehet lista. Ebben a példában a jelenlétek és a ZH eredmények vannak listába szervezve.

Egy újonnan létrehozott ProgAlapEredmeny objektum rögtön tartalmazza az összes mezőt, ami létezhet. Itt jól jön a None érték, mert az mutatja, hogy valaki egy adott ZH-t nem írt meg (vagy még nem írt meg). A félév elején az objektum üres, aztán később kerülnek bele az adatok. Ha e1 egy ilyen objektum, akkor az e1.kzh[0] = 9 értékadás az első kis ZH megírását jelenti.

A listákon egyébként ugyanúgy használható a * egész szám többszörözés művelete, mint a sztringeknél. Ez jobb is így: [None]*7-en látszik, hogy hány elemről van szó, viszont a [None, None, None, None, None, None, None] kifejezésből ez nem derül ki első ránézésre.

29. Többszörös összetétel példa: geometria

class Pont:
    def __init__(self, x=0.0, y=0.0):
        self.x = x
        self.y = y

class Kor:
    def __init__(self, k=None, r=0.0):
        if k is None: k = Pont()
        self.k = k
        self.r = r

class Szakasz:
    def __init__(self, p1=None, p2=None):
        if p1 is None: p1 = Pont()
        if p2 is None: p2 = Pont()
        self.p1 = p1
        self.p2 = p2
print(k1.r)       # a kör sugara
sz1.p1.y = 5.19   # a szakasz kezdőpontjának y koordinátája

Az előzőhöz hasonlóan saját osztályokból is építhethetünk. Az x és y koordinátát tároló pont osztály definiálása után felhasználhatjuk azt a szakasz és a kör osztályban. A kör egy pontot tartalmaz (a középpontja), egy szakasz objektumhoz viszont két pontra is szükség van: a kezdőpontra és végpontra. Az értékek kiválasztásánál itt is kívülről befelé haladunk: sz1.p1.y, azaz az sz1 szakasznak a kezdőpontja, annak is az y koordinátája.

30. Miért nem __init__(self, k=Pont(), r=0.0)?

A geometriás példában a függvények None paraméterei szándékosak. Mind a körnek, mind a szakasznak a konstruktorban megadhatók az adatai. De ezek alapértelmezett értéke None lett, és ha ezen keresztül azt látják a függvények, hogy nem kaptak adatot, akkor új Pont objektumot hoznak létre. Erre azért van szükség, mert bár írhatnánk ilyet:

Rossz megoldás
class Kor:
    def __init__(self, k=Pont(), r=0.0):
        self.k = k
        self.r = r
Hibásan: közös pont objektum a körök középpontjához

Ez valójában kicsit mást jelentene. Ugyanis a függvényparaméterek alapértelmezett értékei csak egyetlen egyszer jönnek létre, a program indulásakor, és így ugyanaz a pont objektum lenne mindegyik kör középpontja. Viszont a Pont objektumok változtathatóak (ahogy fent is változott a szakasz kezdőpontjának egyik koordinátája), és így váratlan hatással lenne a pont módosítása a többi körben.

Ha a függvény hozza létre, akkor viszont minden egyes függvényhíváskor új pont fog létrejönni:

Jó megoldás
class Kor:
    def __init__(self, k=None, r=0.0):
        if k is None: k = Pont()
        self.k = k
        self.r = r
Hibásan: közös pont objektum a körök középpontjához

Ha a pont nem lenne mutábilis (vagy nem annak kezelnénk), akkor persze meg lehetne osztani az egyforma pont objektumokat a körök között.