# Összetett adatok: osztályok

Czirkos Zoltán · 2025.10.22.

Típusok kezelése a type() függvénnyel. Osztályok fogalma, definíciója. Operátorok kezelése, konverziós operátorok. Többszörösen összetett adatszerkezetek.

## 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(f"p pont: ({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"f pont: ({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 f"{t.szaml}/{t.nev}"

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

Itt a `tort_sztringkent()` függvényben az f sztringet 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ény | Minek felelt meg | Mi 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 f"{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 elmé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

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

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én 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]*6
            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]*6`-en látszik, hogy hány elemről van szó, viszont a `[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

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

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.

## 31. Összefoglalás

Az előadáson megismertek alapján:

- Tisztában kell lenni típus fogalmával és a type() függvénnyel.
- Ismerni kell az esetszétválasztást típus alapján és a kicsomagoló * operátort.
- Tisztában kell lenni az osztály fogalmával, a definíció és a példányosítás szintaktikájával.
- Kell tudni írni speciális (dunder) függvényeket.
- Tisztában kell lenni a többszörös összetétellel.