(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
, csakTrue
ésFalse
- Üres:
NoneType
, csakNone
- 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ó.
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ó.
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.
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
ö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.
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.
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.
É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á.
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, aself
az újonnan létrejött, még üres objektum referenciája lesz. A további paraméterei pedig azok az értékek, amelyeket aPont()
kapott, jelen esetbenx = 2
ésy = 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.
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
!
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
- 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.
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.
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.
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
)
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.
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)
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.)
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. azint()
-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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
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.
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.