1. NHF kiadás – ezen a héten

A házi szabadon választott

  • Ötletek a honlapon – hasonló nehézségű, saját feladat is lehet, sőt javasolt
  • A választást a laborvezető jóvá kell hagyja!
  • Egyénileg elkészítve! Központi plágiumteszt.

Admin portál:
NHF 1-2-3-4.

Beadandók (elektronikusan)

  1. Mindent a portálon
  2. Feladat választása: 7. hétig (végleges!)
  3. Pontosított specifikáció: 8. hétig
  4. Félkész program: 10. hétig
  5. Végleges változat: 12. hétig; laboron bemutatva!
    Kód + felhasználói és programozói dokumentáció

2. NZH – 7. héten

Számonkérés

  • Nagy ZH: október 14. hétfő, 18:00 - 19:30 HSZK (beosztás később)
  • A portálon majd jelentkezni kell!
  • Terembeosztás is majd ott lesz.
  • ~90 perc, három feladat

NZH anyaga

  • Ami a ZH-ig szerepelt előadáson és laboron.
  • Gyakorolni kell! Sokat!
  • Megajánlott jegy: az első megírt NZH számít!

Függvények

4. Prímszámok 2-től 1000-ig

Feladat: számoljuk meg, hány prímszám van 2 és 1000 között!

db = 0
for sz in range(2, 1000+1):
   if … sz egy prím …:  !
      db += 1

Számlálás tétele

vanoszto = False
for oszto in range(2, sz):
    if sz % oszto == 0:
        vanoszto = True
        break

Eldöntés tétele

db = 0
for sz in range(2, 1000+1):
    vanoszto = False
    for oszto in range(2, sz):
        if sz % oszto == 0:
            vanoszto = True
            break
    if not vanoszto:
        db += 1


print(db, "db prímszám.")

Teljes megoldás

A két egymástól független programrész összedolgozása által egy nehezen értelmezhető program keletkezett. Figyeljük meg: nagyban rontotta az áttekinthetőségét az, hogy az egyik algoritmust bele kellett építenünk a másikba, kettévágva azt. Megírni is nehezebb egy ilyet, hiszen egyszerre több dolgon kell gondolkozni.

Figyeljünk meg a fenti kódrészleten még egy dolgot. A vizsgált szám prím voltát ellenőrző, kék színű programrész tulajdonképpen önállóan is megállná a helyét: van bemenete (a vizsgált sz szám) és kimenete (prím-e vagy nem). Ez emlékeztethet minket a matematikai függvényekre: egy f(x) = x² függvény is értelmes önmagában, akár egy másik képlet részeként, és ennek is van bemenete (az x szám) és kimenete (annak négyzete, szorzata önmagával).

5. Alprogramok = szubrutinok = függvények

A részfeladatokat külön függvényben írhatjuk meg. Így egy nagyobb program áttekinthetőbb, kezelhetőbb lehet. Sőt a gyakran ismétlődő programrészeket is így csak egyszer kell majd megírnunk. A programok így kisebbek, hatékonyabbak lehetnek.

Függvények (function) a Python nyelvben

Hasonlóak a matematikai függvényekhez:

y = f(x)
y = x²
def negyzet(x):
    return x*x

print(negyzet(2.3))

Tegyük fel, hogy van egy programrész, amely megmondja egy adott számról, hogy prím-e, vagy nem. A fenti f(x) = x² mintájára képzeljünk el egy prim_e(x) függvényt! Ez a kapott számnak nem a négyzetét fogja visszaadni (pl. negyzet(1.5)2.25), hanem a kapott számról megadja majd, hogy prímszám-e vagy nem (pl. prim_e(37)True és prim_e(25)False). Ha van egy ilyen függvényünk, akkor a prímek számlálása feladat nagyon egyszerűvé válik: a kékkel jelölt „… sz egy prím …” programrész helyére csak annyit kell írnunk, hogy prim_e(sz).

if … sz egy prím …:
   db += 1
if prim_e(sz):
   db += 1

Az egész programunk így együttműködő alprogramokból épülhet fel! Ezekhez is tartozik specifikáció: bemenet → kimenet összefüggése.

Vegyük észre: azáltal, hogy a függvénynek neve van, a programban akár több helyen is hivatkozhatunk rá: több helyről indulva elvégezhetjük ugyanazt a részfeladatot. Egy bonyolult részfeladat így elemi lépésként is kezelhető. Ha megvan a prim_e(x) függvényünk, onnantól kezdve ugyanolyan könnyen tudjuk ellenőrizni egy szám prím/nem prím voltát egy if prim_e(sz) sorral, mintha csak annyit kellene ellenőrizni, hogy nulla vagy nem nulla! Azáltal pedig, hogy a függvényeknek paraméterei is lehetnek, ezeket a részfeladatokat tudjuk „konfigurálni”. A print() függvényt is egyszer megírta valaki, és mindenhol használhatjuk.

6. Függvény példa: prímek 2-től 1000-ig

Főprogram

def main():
    db = 0
    for sz in range(2, 1001):
        if prim_e(sz):
            db += 1
    print(db, "db prímszám.")

main()

Alprogram

def prim_e(szam):
    vanoszto = False
    for o in range(2, szam):
        if szam % o == 0:
            vanoszto = True
            break

    return not vanoszto

Elnevezések: fejléc, függvénytörzs, paraméter, visszatérési érték, hívás, visszatérés, lokális változó, láthatóság, élettartam.

A prim() függvény olyan, mint a teljes programunk: van bemenete és kimenete is. Csak ezek nem a képernyő és a billentyűzet, hanem a főprogrammal történő kommunikáció által valósulnak meg:

Főprogram és alprogram kommunikációja

Függvények definiálása: szintaktika

def függvénynév(paraméterlista):
   ... függvénytörzs ...

   return visszatérési_érték

A fejléc (header) meghatározza a függvény nevét, a paraméterei (parameter) számát. A függvénytörzs (function body) tartalmazza azt a programrészt, amely a függvény feladatát elvégzi.

Visszatérés a függvényből

A függvény törzsében elhelyezett return utasítással visszatérhetünk (return) a függvény hívásának (call) helyére. Ezzel egyben megadjuk a visszatérési értéket is, amelyet egyébként a függvény értékének (function value) is nevezünk. Fontos, hogy a return utasítás ezt a két szerepet elválaszthatatlanul összeköti! Ami a return után van, az már nem hajtódik végre. (Viszont egy függvényben lehet több helyen is return utasítás.)

Lokális változók

A függvényen belül is létrehozhatunk változókat. Ezek:

  • Szintén értékadáskor jönnek létre
  • A függvényből visszatérve megszűnnek → értékük elveszik
  • Minden függvény csak a sajátjait látja! (láthatóság, scope)

Mi az előnyük? Például az, hogy minden függvénynek lehetnek saját lokális változói, amelyekben olyan értékeket tárolnak, amelyekre csak a futásuk idején van szükség. A fenti példában az osztó változóra nincsen már szükség, amint meghatároztuk, hogy a szám prím-e. (A van változó is lokális, és az is megszűnik, azonban az értéke lemásolódik, és átadódik a hívónak.) Ezek a változók csak a függvényen belül látszanak (a láthatóságuk (scope) csak a függvényen belülre terjed ki), és így a nevük csak azon belül értelmezett. Másik függvényeknek lehetnek ugyanolyan nevű lokális változóik, a nevek mégsem fognak ütközni. További előny, hogy a változó nem foglal memóriát, csak akkor, ha az azt definiáló függvény belsejében vagyunk. Vagyis a változó élettartama (storage duration, lifetime és extent szavak is használatosak az angol szakirodalomban) is csak arra az időre terjed ki, amíg a függvény végrehajtása tart.

7. A függvényhívás menete

A lenti animáció a függvényhívás menetét és lokális változók élettartamát mutatja be.

def faktorialis(mie):
    szorzat = 1    
    i = 2    
    while i <= mie:    
        szorzat *= i    
        i += 1    
    return szorzat    

def main():
    sz = int(input("sz = "))    
    eredmeny = faktorialis(sz)    
    print(f"{sz}! = {eredmeny}")    
    return    

main()


main()

sz:    
eredm: 

A függvényhívás a következőképpen történik. A main() függvény meghívja a faktorialis() függvényt. A függvény paramétere, a mie változó úgy viselkedik, mint egy lokális változó. De kívülről inicializálva lesz, méghozzá azzal az értékkel, amelyet a hívás helyén, a main()-ben adunk neki (tehát amelyet a felhasználó adott meg). Így a három lokális változó közül az egyik, a paraméter mie már létezik, a másik kettő pedig – szorzat és i – később jönnek létre.

A függvényhívás a return szorzat utasítás hatására fejeződik be. Ekkor a függvény lokális változói megszűnnek, de a return utasításnál megadott kifejezés értéke (ami most a szorzat-ba került szám) visszaadódik a hívónak. Ez az érték lesz a main() kódrészletben a faktorialis(sz) részkifejezés értéke. Innen folytatódik a main() végrehajtása.

8. Függvények pozicionális paraméterei

A függvény paraméterét és visszatérési értékét a Python nyelvben tetszőleges kifejezés értékével inicializálhatjuk. Tehát a fenti függvény hívható lenne akár így is: faktorialis(5), vagy így is: faktorialis(sz+6), amikor is az 5! és az (sz+6)! értékeket számítaná ki. Továbbá egy függvény visszatérését kiváltó return utasítás után is tetszőlegesen bonyolult kifejezés állhat: egy függvény befejeződhet akár egy return x, akár egy return 5, de akár egy return sin(x)*3 + 1.2 utasítással is. Lássunk egy másik példát erre!

def teglalap_kerulet(a, b):
    return 2 * (a+b)

def main():
    print(teglalap_kerulet(2, 3.4)) # a=2, b=3.4

main()

Formális paraméter (parameter): a függvény fejlécében definiáltak.

  • Szimbolikus paraméternek is nevezik (symbolic parameter)
  • A függvényen belüli szerep szerint kell elnevezni

Aktuális paraméter, argumentum (argument): a híváskor adott érték.

  • Híváskor a megadás sorrendje számít
  • Nem csak változó lehet, hanem konstans is

A függvény valójában nem tudja, hogy a neki átadott értékek honnan származnak. Lehet változóból: teglalap_kerulet(x, y), konstansból: teglalap_kerulet(2, 3.4) vagy esetleg egy kifejezés adta őket: teglalap_kerulet(p+2, 3.4*q). Bárhogy is történt, belül olyan néven látja az értékeket, ahogyan a paramétereket elnevezte a fejléc.

A függvények paraméterei úgy viselkednek, mintha lokális változók lennének. A függvénybe belépéskor létrejönnek, és megkapják azokat az értékeket, amelyeket a hívás helyén megadott a hívó. A függvényből visszatérve pedig megszűnnek.

A hívás helyén nem kell megadni a paraméterek neveit: az első értékből lesz az első paraméter (itt: a), a másodikból a második (b) és így tovább. Ezért nevezzük ezeket pozicionális paramétereknek is. Elvileg bármennyi lehet, de ha túl sok van belőlük, az problémát jelez, érdemes a programot átgondolni. (Erről később lesz szó.)

9. Függvények opcionális paraméterei

def lista_kiir(lista, elvalaszt=", "):  # alapértelmezett
    for i in range(0, len(lista)):
        if i > 0:
            print(elvalaszt, end="")
        print(lista[i], end="")
    print()

def main():
    lista_kiir([1, 2, 3], " * ")
    lista_kiir(["alma", "körte", "barack"])

main()
1 * 2 * 3
alma, körte, barack

Ha a függvény fejlécében alapértelmezett értéket (default value) adunk egy paraméternek, akkor azt a hívás helyén elhagyhatjuk. Így a fenti függvény hívható egy és kettő paraméterrel is. Ha a kiírandó lista és az elválasztó is meg van adva, akkor a kiírt elemek közé azt az elválasztót fogja írni, mint a számok esetében. Ha viszont csak egy, akkor az elválasztó egy vessző és egy szóköz lesz, mint az alma, körte, barack esetben.

Az alapértelmezett paramétereknek a paraméterlista végén kell lenniük. Az értékek megadásánál egyébként az egyenlőség két oldalára nem szokás szóközt tenni. Ez különbözteti meg a szemünk számára az értékadástól a kicsit eltérő értelemben használt egyenlőségjelet.

10. Tetszőlegesen sok paraméter

Hogy működik a beépített min() függvény? Annak tetszőlegesen sok paramétere lehet. Nem csak két szám közül tudja kiválasztani a kisebbet, bár gyakran így használjuk: min(3, 2). Valójában kaphat bármennyit, pl. min(3, 4, 2, -8, 6) értéke -8 lesz.

Bármennyi paramétert át tud venni:

def legkisebb(elso, *tobbi): # *args néven szokás
    acc = elso
    for x in tobbi:
        if x < acc:
            acc = x
    return acc

def main():
    print(legkisebb(3, 4, 2, -8, 6)) # -8

main()

A függvényben:

elso  = 3
tobbi = (4, 2, -8, 6)

Ha a függvény fejlécében szerepel egy *args alakban definiált paraméter, akkor az a függvény tetszőlegesen sok argumentumot át tud venni. A fenti függvénynek egytől végtelenig bármennyi paramétere lehet (elméletben). Az első paraméter be fog kerülni az elso nevű lokális változóba, az összes többi pedig a tobbi nevű változóba kerül. (Ennek típusa tuple, amiről később lesz szó; most elég annyit tudni róla, hogy listaként használható: len(tobbi), tobbi[i].) Vagyis a függvény belsejében két változónk van, csak az utóbbi egy tároló.

A minimumkeresésnek egyébként csak akkor van értelme, ha legalább egy szám közül kell kiválasztani a legkisebbet. (Üres számsornak nincs minimuma.) Ezért lett a függvény fejléce legkisebb(elso, *tobbi) alakú, nem pedig legkisebb(*szamok) alakú. Az utóbbi hívható lenne nulla paraméterrel is, de az értelmetlen lenne. Így viszont előírjuk, hogy legalább egy paraméternek kell lennie.

A tetszőlegesen sok paramétert átvevő függvényeket variadikus (variadic) függvényeknek nevezzük. A sok paramétert becsomagoló változót általában *args-nak szokás elnevezni. Bár ez nem törvényszerű, a szokás mégis ez. (Fent csak azért szerepel *tobbi néven, mert a változónevek mind magyar nyelvűek.)

Egyébként nem a min() és a max() az első variadikus függvény, amit megismerünk. A print() pontosan ilyen! Annak első paramétere print(*objects) alakú, ezért vehet át tetszőlegesen sok számot, sztringet, bármi egyebet, amit megjelenít a kimeneten. Látszik, hogy ezeket könnyű kezelni a függvény belsejéből, mert mindet megkapjuk egy indexelhető tárolóban.

11. Név szerinti paraméterátadás

def teglalap_kiir(w=0, h=0, x=0, y=0):
    print("Téglalap")
    print(f"- méret: {w}×{h}")
    print(f"- pozíció: ({x};{y})")

def main():
    teglalap_kiir(20, 30, 40, 50)
    teglalap_kiir(x=10, w=19, h=25)

main()
Téglalap
- méret: 20×30
- pozíció: (40;50)
Téglalap
- méret: 19×25
- pozíció: (10;0)

Az alapértelmezett értékekkel ellátott paramétereknek van egy másik szerepe is a Pythonban. Ezeket a hívás helyén megadhatjuk név szerint is. A fenti példában az első hívásnál nem használtunk neveket, így a megadott értékek, 20, 30, 40 és 50, rendre a w, h, x és y paramétereket töltötték fel, mert ilyen sorrendben szerepelnek. A második esetben viszont megadtuk, hogy x legyen 10 (ez amúgy csak a harmadik paraméter lenne), w legyen 19 és h legyen 25. Mivel y-ról nem mondtunk semmit, ezért az megkapta az alapértelmezett 0-s értéket.

A pozicionális paraméterekből nem szeretjük, ha sok van, mert nem lehet tudni egy idő után, mit jelentenek. A fenti függvénynél ugyanez a helyzet: teglalap_kiir(20, 30, 40, 50) vajon mit jelent? 20×30-as téglalap a (40;50) pozícióban, vagy 40×50-es téglalap a (20;30)-as pozícióban? Nem lehet tudni, ha nem emlékszünk a paraméterek sorrendjére. A név szerint átadott paraméterekből viszont bármennyi lehet, az nem rontja a programkód olvashatóságát. A Python nyelv beépített függvényei is alkalmazzák ezt a technikát. Tulajdonképpen a print() is ilyen, az is név szerint átadott paraméterekkel dolgozik:

print(*objects, sep=" ", end="\n", file=sys.stdout, flush=False)

Pozicionálisan adjuk meg a kiírandó elemeket, amelyek mind-mind bekerülnek az objects nevű listába. Ezen felül pedig név szerint adjuk meg (ennél nem is lehet máshogy) az elválasztó és a lezáró elemet:

print("alma", "körte", "barack", sep="+", end=".\n")
alma+körte+barack.

Itt teljesen egyértelmű a hívásnál is, hogy a + lesz az elválasztó (sep, separator), és egy pont, továbbá egy újsor karakter fejezi be a kiírást (end). Mivel a print() esetében az alapértelmezett értékekkel rendelkező paramétereket megelőzte a *objects nevű, tetszőlegesen sok argumentumot „elfogyasztó” lista, ezért az elválasztót és a lezáró sztringet nem is lehet másképp átadni, csak név szerint.

12. A main() függvény

Fontos!

Mostantól ne írjunk kódot függvényen kívülre!


def lista_kiir(lista, elvalaszt=", "):
    for i in range(0, len(lista)):
        if i > 0:
            print(elvalaszt, end="")
        print(lista[i], end="")
    print()
 
def main():                 # főprogram
    lista_kiir([1, 2, 3], " * ")
    lista_kiir(["alma", "körte", "barack"])
 
main()                      # program indítása

A Pythonban elvileg tetszőlegesen írhatunk függvényen kívül is végrehajtandó utasításokat. Ezt meg is szoktuk tenni, de csak nagyon kicsi programoknál. Egy bizonyos határ felett (ez kb. egy képernyőnyi kódot jelent) elkezdjük függvényekre bontani a programot, és ettől a ponttól kezdve már nem írunk kódot függvényen kívül.

Bár a nyelv nem várja el, hogy minden programsorunkat függvénybe tegyük, mégis jobban járunk, ha így teszünk. Tisztább, áttekinthetőbb a program felépítése, és követni tudjuk azt is, hogy hol indul a program végrehajtása. Tudjuk azt is, hogy pontosan hogyan kommunikálnak ezek a függvények egymással, mert mindegyiknek a paraméterei adják a bemenetet, és a visszatérési értékük adja a kimenetet. Tudjuk azt is, melyik változónkat melyik függvények látják: mindenki csak a sajátját. Így jobban követhető az információ áramlása.

További előnyökről is lesz szó a modulok kapcsán a félév második felében; egyelőre ennyit mondunk csak erről a témáról.

A main() függvény a ProgAlap tárgyban

A fentiek szellemében innentől kezdve a tárgyban elvárás a main() függvény használata. Függvényeken kívül csak egyetlen egy sor szerepelhet, annak meghívása: main(). (Később ezt még kicsit finomítani fogjuk.)

13. Függvények dokumentációja: Docstring

A függvényeket a forráskódban dokumentáljuk:

Docstring
def reciprok(x):
    """
    Visszaadja egy szám reciprokát, 1/x-et.
    Paraméterek:
    x: amely számnak a reciproka kell. Nem lehet 0.
    """
    return 1/x

A függvények olyan kis programrészek, amelyek egy jól elhatárolt részfeladatot hajtanak végre. Ezért egy függvény dokumentálásakor pontosan meg kell határozni, hogy mire való, milyen feladatot hajt végre. A programokhoz hasonlóan rögzíteni kell azt is, hogy milyen bemenetet vár és milyen kimenetet állít elő a futása során. A bemenet dokumentálásához hozzátartozik a bemeneti tartomány leírása is (pl. a reciprokot kiszámító függvény nem kaphat nullát paraméterként). A működés leírásához pedig a hibalehetőségek rögzítése.

Mindezeket a függvényben, ún. Docstring formájában adjuk meg, hogy ezáltal a kód kezelhetővé, karbantarthatóvá váljon. Ahol van hely, ezt meg fogjuk tenni (sajnos az előadás diákra nem mindenhol fér oda ez). A szintaxis egyszerű: három darab idézőjel nyitja a Docstringet, és újabb három idézőjel zárja; e kettő közé kell tenni a szöveget. Vagy három aposztróf közé, mindkettő egyformán jó: """ és ''' is.

A Docstring azért előnyös, mert a fejlesztőkörnyezetek megtalálják, felismerik azt, és így a függvény nevének beírásakor segítséget tudnak mutatni egy ablakban. De maga a Python Shell is ilyen: a beépített help() felismeri a saját függvényeinkhez írt Docstring-et, és azt fogja mutatni:


>>> help(reciprok)

Help on function reciprok in module __main__:

reciprok(x)
    Visszaadja egy szám reciprokát, 1/x-et.
    Paraméterek:
    x: amely számnak a reciproka kell. Nem lehet 0.

Docstringek a ProgAlap tárgyban

Ez hasonló, mint a fent említett main() függvény. Mivel az egész világon így csinálják, mi is így fogjuk. A nagy háziban is elvárás lesz a Docstring segítségével dokumentált függvények írása.

14. Érték és mellékhatás

A függvényeknek értéke és mellékhatása lehet.

print(faktorialis(6))
print(faktorialis(6))
print(faktorialis(6))
720
720
720

Érték: a kifejezés értéke.

print(random.randint(1,100));
print(random.randint(1,100));
print(random.randint(1,100));
23
59
65

Mellékhatás: valahol változást okoz.

Az érték azt a számot, sztringet, listát... jelenti, amit a függvény a return utasítással visszaadott. Ezért szoktuk a függvény hívását a függvény kiértékelésének is nevezni: a függvény lefut, és a hívás helyén lévő kifejezésbe a visszatérési értéke behelyettesíthető.

Ezen felül a függvénynek lehet mellékhatása is, ami azt jelenti, hogy valahol valamit megváltoztatott. Például kiírt valamit a képernyőre, letörölt egy fájlt és így tovább.

Ha egy függvénynek nincs mellékhatása, akkor ugyanazokra a paraméterekre mindig ugyanazt az eredményt adja. Ha van mellékhatása, ez nem biztos, hogy így lesz! Valahol valaminek történnie kell, hogy a random.randint(1, 100) mindig más számot ad vissza. Ez a függvény kell rendelkezzen valamiféle belső állapottal. Láthatóan a kimenete nem csak a bemenő paramétereitől függ.

Vannak olyan függvények, amiknek csak értékük van, mellékhatásuk nincs. Például a math.sqrt(2) kiértékelésével megkapjuk az 1.414 értéket, de biztosak lehetünk abban, hogy semmilyen változás nem történt. Vannak olyan függvények is, amelyeknek nincs értékük, csak mellékhatásuk. Ilyen a print(): ez kiír valamit a képernyőre, de nem ad vissza semmit. Az ilyenek matematikai értelemben véve nem függvények már, de ennek ellenére programozásban így hívjuk őket.


Fontos: ha a specifikáció nem kéri a kiírást, akkor kifejezetten hibának számít, ha a függvény mégis ilyet tesz! Például kiírja a képernyőre az eredményt ahelyett, hogy visszatérne vele. Hadd döntse el a hívó, mit szeretne csinálni az eredménnyel!

Általában igyekszünk olyan függvényeket írni, amelyeknek csak értéke, vagy csak mellékhatása van. Ennek az elvnek neve is van: command-query separation. Eszerint kétféle függvény van. Az egyik fajta a parancsfüggvény (command), amelyet azért használunk, hogy hatása legyen. A másik fajtának kérdéseket teszünk fel (query), amely kiszámol valamit, de mellékhatása nincs. Ha ez a kettő keveredik, az könnyen összevisszasághoz, átláthatatlan programfelépítéshez és nehezen megtalálható hibákhoz vezet.

Látszik persze, hogy a random modul randint() függvénye kilóg a sorból. De ez kivétel kell legyen: kell legyen értéke is, a generált véletlenszám; és mellékhatása is, hogy a következő véletlenszám más lehessen. Különben értelmetlen lenne.

15. Procedurális/hierarchikus programozás

A funkcionális dekompozíció (functional decomposition) egy tervezési elv. A másik neve a felülről lefelé (top-down) elv. Lényege, hogy a problémát részfeladatokra bontjuk. Az egész rendszert, programot úgy tervezzük meg, hogy közben a részfeladatokat megoldottnak tekintjük. Az egyes részfeladatok megoldása közben így nem kell a többi részleteivel bajlódni. A részfeladatok ugyanúgy specifikálhatóak, mintha egy teljes programról lenne szó.

„Az egyik dolog, amit a programozásban meg kell tanulnunk, az az, hogyan hagyjuk figyelmen kívül a részleteket.”
– Gerald J. Sussman

A funkcionális dekompozíció céljai:

  • Felülről lefelé tervezés (top-down design)
  • Tervezés egyszerűsítése: „oszd meg és uralkodj”
  • A programrészek közötti csatolások csökkentése

Érdekesség: Gerald Jay Sussman amerikai programozó, matematikus, aki legtöbbet a mesterséges intelligenciával foglalkozik. Az ő nevéhez is fűződik a Structure and Interpretation of Computer Programs című könyv megírása is, amelyhez egy programozás alapjait bemutató tárgy is tartozott az MIT egyetemen. A fenti idézet az egyik előadásáról származik. A tárgyat egyébként a saját maguk által kifejlesztett programozási nyelvvel tanították, amelynek a neve Scheme.

A top-down tervezést „wishful thinking” néven mutatta be (kb. ábrándozó gondolkodás). Hiszen éppen ez a lényege: „Bárcsak lenne egy olyan függvény, amelyik megmondja egy számról, hogy prímszám-e... Mert ha igen, akkor milyen egyszerű lenne a feladatunk!” Sokszor ezzel a gondolkodásmóddal tudjuk szétválasztani a részfeladatokat.

16. Dekompozíció példa: Cesàro és a π

A következő feladat a felülről lefelé (top-down) tervezést szemlélteti. Azt kell látni, hogy minden lépésben csak egy kicsit lépünk a megoldás felé; a következő lépést pedig mindig megoldottnak tekintjük.

A megoldás
egyben:
cesaro.py

Ernesto Cesàro (olasz matematikus): válasszunk ki véletlenszerűen két egész számot. Annak a valószínűsége, hogy ezek relatív prímek, 6/π².


Feladat: írjunk programot, amely megbecsüli a π értékét!


Ez a következőképpen nézhet ki. Először is, rendezzük az egyenletet! Tegyük fel, hogy a keresett valószínűséget majd a később megírandó cesaro_valoszinuseg() függvény megmondja.

A főprogram:

import math

def main():
    pi = math.sqrt(6.0 / cesaro_valoszinuseg())
    print(f"pi = {pi:5.5}")
    
main()

Hogyan írjuk meg ezt a függvényt? Kérdés, hogyan számoljuk ki a valószínűséget. Tegyük fel, hogy adott egy cesaro_kiserlet() függvényünk, amely elvégzi a kísérletet (két véletlenszerűen választott...) A részleteivel ne foglalkozzunk, csak ennyit mondjunk egyelőre: térjen ez a függvény vissza igazzal, ha a kísérlet sikerült. Végezzük el ezerszer! A sikeres kísérletek számát 1000-rel osztva megkapjuk a becsült valószínűséget.

A valószínűség becslése:

Monte-Carlo
módszer
def cesaro_valoszinuseg():
    """PI meghatározása kísérletezéssel"""
    db = 0;
    for i in range(0, 1000):
        if cesaro_kiserlet():   # elvégzi a kísérletet
            db += 1
    return db / 1000

Mi a kísérlet? Az, hogy két véletlenszám relatív prím párost alkot. Gyártsunk ehhez 1 és 1000 között véletlenszámokat, és hasonlítsuk a legnagyobb közös osztójukat 1-hez. Mert ha 1, akkor ezek relatív prímek, tehát sikerült a kísérlet, és ezért igazzal kell visszatérnünk.

A kísérlet:

def cesaro_kiserlet():
    """A kísérlet: a legnagyobb közös osztójuk 1?"""
    return lnko(random.randint(1, 1000),
                random.randint(1, 1000)) == 1

Már csak annyi a dolgunk, hogy két szám legnagyobb közös osztóját meghatározzuk. Ehhez Euklidész módszerét megnézhetjük a Wikipédián is. A pszeudokódot csak át kell írni Pythonba.

Legnagyobb közös osztó:

def lnko(a, b):
    """Visszatér két szám legnagyobb közös osztójával."""
    while b != 0:
        t = b
        b = a%b
        a = t
    return a

Egyébként ezt nem kell külön megírni: import math után és math.gcd() néven elérhető.

Érdekesség: Donald Knuth amerikai programozó. Leghíresebb műve a „Számítógépprogramozás művészete” (The Art of Computer Programming) című többkötetes könyv. Az ő nevéhez fűződik a TeX nevű szövegszedő program kifejlesztése is, amelynek különféle változatait most is használják könyvek, folyóiratok szerkesztéséhez, szedéséhez. Az elterjedt irodai programcsomagokkal készített dokumentumok külleme meg sem közelíti azt, ami a TeX segítségével elérhető. Ő mondta erről az algoritmusról: „Az euklidészi algoritmus minden algoritmusok nagyapja. Ez a legrégebbi nemtriviális algoritmus, amelyet mindmáig használunk.”


Az eredmény

... és kész. A programot lefuttatva megkapjuk a π közelítő értékét.

pi = 3.1363

Visszatérés a függvényből és kivételek dobása

18. Egysoros függvények

Néha egy függvény olyan rövid, hogy egy sorban leírható.


Ha logikai kifejezés értékével térünk vissza:

def paros_e(x):
    if x % 2 == 0:
        return True
    else:
        return False
def paros_e(x):
    return x % 2 == 0


Itt azt vehetjük észre, hogy az első változatban a vizsgálat eredménye (nulla-e a maradék vagy más: igaz vagy hamis) éppen a visszatérési értéket adja. Amikor a maradék nulla, akkor az x % 2 == 0 kifejezés értéke igaz, és ilyenkor a return True-hoz visz a vezérlési szerkezet. Ha a maradék nem nulla, akkor az x % 2 == 0 értéke hamis, és a return False-hoz. Vagyis a kifejezés értéke pont ugyanaz mindkét esetben, mint ami a return utasítások után van, így az esetszétválasztás felesleges.


Ha csak egyszer írjuk és olvassuk a változót:

def teglalap_terulet(a, b):
    terulet = a * b
    return terulet
def teglalap_terulet(a, b):
    return a * b

Ebben a példában azt vehetjük észre, hogy egy egyszer írt, egyszer olvasott változónk van. Az első, hosszabb változatban a terulet változóba előbb beírjuk a * b értékét, aztán kiolvassuk onnan. A kiolvasott szám mi más lenne, mint a * b – ugyanezt akár a return utasításhoz is írhatjuk, elhagyva a szükségtelen lokális változót.

Egyik esetben sem hiba a hosszabb megoldást használni; egyszerűen csak felesleges és hosszabb.

19. Előjelfüggvény

Feladat: írjunk előjelfüggvényt! Ez pozitív számra +1-et, negatív számra –1-et ad, 0-ra pedig 0-t.


def signum(x):
    if x > 0:
        sgn = +1
    elif x < 0:
        sgn = -1
    else:
        sgn = 0
    return sgn    ───┐
◂────────────────────┘
def signum(x):
    if x > 0:
        return +1 ───┐
    if x < 0:        │
        return -1 ───┤
    return 0      ───┤
                     │
                     │
◂────────────────────┘

A feladat megoldásakor gondolkozhatunk úgy, hogy egy változóba különböző értékeket teszünk. Az esetszétválasztás során az sgn változó +1, -1 vagy 0 értéket kap. Bármely ágon halad is a végrehajtás, a változó biztosan létrejön, és végül visszatérünk az értékével.

Valójában a változó felesleges. Mivel egy függvény belsejében több return utasítás is szerepelhet, a visszaadott számok elé rögtön return-t írhatunk. Sőt, mivel a return utasításhoz érve a függvény futása megszakad, ott helyben véget ér. Ezért az else-ek el is maradhatnak. Így jutunk a második kódhoz, amely pontosan ugyanazt az eredményt adja, mint az első változat.

A return amellett, hogy meghatározza a függvény értékét, tulajdonképpen egy vezérlésátadó utasítás is. Bármilyen vezérlési szerkezetben is vagyunk, a függvényből azonnal vissza tudunk térni vele. Ez sok algoritmust le tud egyszerűsíteni; sőt kifejezetten ajánlott is minél előbb visszatérni a függvényből. Erre látunk néhány példát a következőkben.

20. Eldöntés és keresés függvényben

Az eldöntés algoritmusának megvalósítása függvényben

Feladat: írjunk függvényt, amely megmondja egy számról, hogy prímszám-e!


Ehhez azt kell megvizsgálnunk, van-e valódi (1-től és saját magától különböző) osztója. Ha van ilyen, akkor nem prímszám. Emlékezzünk vissza, az előadás elején az eldöntés tételét alkalmaztuk:

def prim_e(szam):
    vanoszto = False
    oszto = 2
    while oszto < szam:
        if szam % oszto == 0:
            vanoszto = True
            break
        oszto += 1
    return not vanoszto

Hogy tudnánk ezt egyszerűsíteni?

Vegyük észre, hogy amint találtunk egy osztót, a vanoszto változót igaz értékűre állítottuk, és meg is állítjuk a ciklust. Vagyis amint vanoszto = True-t írunk, már tudjuk, mi lesz a függvény visszatérési értéke. Alakítsuk át a kódot ennek megfelelően!

félkész
def prim_e(szam):
    vanoszto = False
    oszto = 2
    while oszto < szam:
        if szam % oszto == 0:
            return False
        oszto += 1
    return not vanoszto

A return így megállítja a ciklust, és megadja a függvény visszatérési értékét is. Most azt vehetjük észre, hogy a függvény alján lévő return utasításhoz úgy juthatunk csak, ha a ciklus végigment, vagyis ha nem találtunk osztót. Ilyenkor a visszatérési érték True kell legyen. De az is feltűnhet, hogy a vanoszto változó most csak egyszer kap értéket, False lett az első sorban, és ezt sehol nem írtuk felül. A not False pedig biztosan True, azt fixen beírhatnánk az alsó sorba. A változóra egyáltalán nincsen szükség!

def prim_e(szam):
    oszto = 2
    while oszto < szam:
        if szam % oszto == 0:
            return False
        oszto += 1
    return True

A legrövidebb megoldáshoz egyébként akkor jutunk, ha a számlálásos while ciklust a szokásoknak megfelelően for ... in range(...) ciklusra cseréljük:

def prim_e(szam):
    for oszto in range(2, szam):
        if szam % oszto == 0:
            return False
    return True

Lineáris keresés megvalósítása függvényben

Feladat: keressük meg egy listában egy szó első előfordulását! Ha nincs a listában a keresett szó, adjon a függvény –1-et!


def hol_van(lista, szo):
    for i in range(len(lista)):
        if lista[i] == szo:
            return i    # megvan!

    return -1   # hogy jut ide?

A függvény közepéből visszatérve nagyon egyszerűen meg tudjuk adni a megoldást. A ciklus végigmegy a listán. Ha megtalálta a szót, visszatér az indexével – a visszatérési érték is megvan, és a ciklus végrehajtása is megszakad. Ez egyébként az első előfordulás lesz, ahogy a feladat is kérte, mert a lista elejéről indultunk.

Ha végigment a ciklus, akkor pedig az összes elemet megvizsgáltuk, és egyik sem a keresett volt, ezért -1 a visszatérési érték. Úgy is eljuthatunk ehhez az utasításhoz, hogy a lista üres, és a ciklus törzse egyszer sem futott. De a válasz ilyenkor is -1, mert az üres listában nincs benne a keresett elem.

21. Nem szám, próbáld újra!

Feladat: kérjünk egy számot. Ha nem szám, próbáljuk újra!


Az előző előadásban szerepelt ez a kódrészlet:

while True:
    try:
        sor = input("Írj be egy számot: ")
        szam = int(sor)
        break
    except ValueError:
        print("Nem szám, próbáld újra!")
 
print(f"A beírt szám: {szam}.")

Ezt a kódrészletet eléggé körülményes lenne bemásolni mindenhova, ahol egy egész számot szeretnénk beolvasni. Tegyük át egy függvénybe!

def szamot_beolvas():
    while True:
        try:
            sor = input("Írj be egy számot: ")
            return int(sor) # !
        except ValueError:
            print("Nem szám, próbáld újra!")


def main():
    szam = szamot_beolvas()
    print(f"A beírt szám: {szam}.")


main()

Így már sokkal kezelhetőbb! Vegyük észre, hogy a változót és a break utasítást is ki lehetett törölni. Mivel az int-té alakított szám lenne a függvény visszatérési értéke, fölösleges a függvény közepén szam = int(sor)-t, a végén return szam-ot írni. Helyette mehet a függvénybe return int(sor), amely egyben a ciklus megállítását is elvégzi.

22. Kivételek fogalma

Mi történik, ha egy függvény nem tud visszatérési értéket adni?

def lnko(a, b):
    """Visszatér két szám legnagyobb közös osztójával."""
    while b != 0:
        t = b
        b = a%b
        a = t
    return a
def legkisebb(lista):
    """Visszaadja a lista legkisebb elemét."""
    acc = lista[0]
    for elem in lista[1:]:
        if elem < acc:
            acc = elem
    return acc

A fenti függvények jól vannak megírva. Csak van egy kis gond velük: nem minden bemenetre tudnak eredményt adni. De ez nem a függvények hibája: fogalmazzunk inkább úgy, hogy vannak olyan hibás, értelmetlen bemenetek, amikre a függvényeknek nem kell tudniuk működni.

Legnagyobb közös osztója például csak pozitív egész számoknak lehet. Nem várhatjuk el az lnko(a, b) függvénytől, hogy nullára vagy negatív értékekre választ tudjon adni, mert matematikailag azokra nem definiált a legnagyobb közös osztó fogalma. Ugyanez a helyzet a legkisebb(lista) függvénynél: üres listának nincs minimuma, mert nem a minimum definíció szerint a sorozat (lista) egyik eleme.

Mit tehet ilyenkor a függvény? A rossz megoldás az, ha visszatér egy hamis értékkel. A jó megoldás pedig az, hogy visszatérési érték előállítása nélkül jelzi a hibát a hívónak, méghozzá kivételt dob.

A lista[0]-val kivesszük a lista legelején álló elemet. Ha csak az lenne, biztosan az lenne a minimum. A ciklusban pedig a lista fennmaradó részét vizsgáljuk: lista[1:]. Ez azért kényelmes, mert így a lista egy részletén is használható a for ... in ... ciklus.

Kivételek: emlékeztető

A beépített függvények és operátorok dobtak kivételt:

def main():
    try:
        szam = int(input())     # kivétel keletkezhet
        print("Ezt a számot írtad be:", szam)
    except ValueError as e:     # kivétel elkapása
        print("Nem szám!", e)
def main():
    szamlalo = int(input())
    nevezo = int(input())
    try:
        r = szamlalo/nevezo         # keletkezés
        print("Hányados:", r)
    except ZeroDivisionError as e:  # elkapás
        print("Nullával osztás!")

A múltkori előadáson előkerült a kivételek fogalma. Például az int-té alakító int() függvény, és az osztás operátor dobtak kivételt akkor, ha nem tudták elvégezni a feladatukat: olyan sztringből kelett kivenniük a számot, amiben nem az van, vagy nullával kellett volna osztaniuk.

A kivétel keletkezésének hatására az aktuális kódsor végrehajtása megszakad, és vezérlésátadás történik az except blokkhoz, ahol a hibát kezelhetjük. Maga a kivétel egy objektum, amelynek típusa van (jelen esetben ValueError és ZeroDivisionError típusúak a kivételek), és betehetjük egy változóba is. Ez az objektum típusával jelzi a hiba típusát, és további hibaüzenetet is tartalmazhat.

A legnagyobb közös osztó és a minimumkeresés problémájának megoldásához éppen erre van szükségünk. Mivel az lnko(0, 0) és a minimum([]) függvényhívásoknak nem lehet értéke – ahogyan az int("alma") és az 1/0 kifejezéseknek sincs –, azokból a függvényekből is kivételt kell dobnunk. Kérdés az, hogyan hozunk létre egy kivétel objektumot, és hogyan dobjuk el azt. Vagyis hogyan jelezzük, hogy a függvényhívás sikertelen, „abnormálisan” tér vissza.

23. Kivételek dobása

def lnko(a, b):
    if a < 1 or b < 1:
        raise ValueError("lnko() csak pozitív számok") ───┐
    while b != 0:                                         │
        t = b                                             │
        b = a%b                                           │
        a = t                                             │
    return a         ────┐                                │
                         │                                │
def main():              │                                │
    p = int(input())     │                                │
    q = int(input())     │                                │
    try:                 │                                │
        l = lnko(p, q) ◂─┘                                │
        print("Legnagyobb közös osztó:", l)               │
    except ValueError as e:                             ◂─┘
        print("Sikertelen:", e)

main()

Kivételt létrehozni a kivételtípusa("üzenet") függvényhívással lehet. Például ValueError("lnko() csak pozitív számok") létrehoz egy ValueError típusú kivételt. Ezt el is kell dobni (angolul: to raise an exception; gyakran a throw igét is használják, más programozási nyelvekben az az elterjedt), erre a raise kulcsszó való.

A kivétel dobása, azaz a raise hatására a vezérlés átadódik a legközelebbi except blokkhoz, amely az adott típusú kivételt kezelni képes. Mivel az lnko() függvényben nincs ilyen, ezért a függvény végrehajtása is azonnal véget ér; a raise alatt lévő kódsorok már nem hajtódnak végre. Helyette a program végrehajtása folytatódik az őt hívó main() függvényben, mivel ott volt a legközelebbi alkalmas except blokk. (Ha nem volt gond, akkor végigfut a függvény, és az l változó létrehozásával folytatódik a program végrehajtása.)

Látszik, hogy a raise segítségével a hibakezeléshez olyan vezérlésátadást tudunk csinálni, ami függvényeken átível. Sőt, ez legtöbbször így is szokott lenni: a raise és az except blokkok általában nem ugyanabban a függvényben vannak. Ha ugyanott tudnánk kezelni a hibát, ahol észleltük, akkor egy sima if-fel megoldhattuk volna a problémát: kiírhattuk volna a hibaüzenetet. De nem ez a feladat! Az lnko() függvénynek nem dolga üzeneteket írni a képernyőre, hanem egy számot kell visszaadnia. És ha ez nem megy, akkor a hibát kivétellel tudja jelezni a hívójának – akiről pedig nem tudja, hogy az osztóra miért volt szüksége.

A Python által definiált hibatípusok közül a ValueError volt a legalkalmasabb. Arról ezt írja a dokumentáció: „Raised when an operation or function receives an argument that has the right type but an inappropriate value”, a függvény számot kapott, de olyat, amivel dolgozni nem tud.

Hasonlóan oldhatnánk meg a minimumkeresés problémáját is:

def legkisebb(lista):
    """Visszaadja a lista legkisebb elemét."""
    if lista == []:
        raise ValueError("Üres listának nincs minimuma")
    acc = lista[0]
    for elem in lista[1:]:
        if elem < acc:
            acc = elem
    return acc

Egyébként ebben az esetben tulajdonképp a lista[0] kifejezésbe be van építve annak ellenőrzése, hogy nem üres-e a lista. Ha igen, akkor már eleve a lista[0] kivételt dob, méghozzá IndexError típusút. A külön ellenőrzés azért jobb, mert így a kivételbe csomagolt hibaüzenet jobban körülírja a problémát.

24. Hibák követése, hívási lánc

Tekintsük az alábbi kódot:

def legkisebb(lista):
    if lista == []:
        raise ValueError("Üres listának nincs minimuma")
    # ...

def main():
    legkisebb([])

main()
Traceback (most recent call last):
  File "legkisebb.py", line 13, in <module>
    main()
  File "legkisebb.py", line 11, in main
    legkisebb([])
  File "legkisebb.py", line 3, in legkisebb
    raise ValueError("Üres listának nincs minimuma")
ValueError: Üres listának nincs minimuma

Ebben a függvényen kívülről meghívtuk a main() függvényt, abból pedig a legkisebb() függvényt. Vagyis a hívási láncunk (call stack) a következőképpen fest:

modul → main() → legkisebb()

A legkisebb() függvényben keletkező hibát nem kapjuk el, ezért visszajut a végrehajtás a main() függvénybe. De mivel ott se, visszajutunk a globális kódba, és végül a programunkat futtató Python környezet lesz az, amelyik ezt a kivételt elkapja.

A futtató környezet (runtime environment) ilyenkor kiírja a kivétel típusát és az abban tárolt üzenetet. De emellett megjeleníti a hívási láncot is. Vagyis azt, hogy hogyan jutottunk ahhoz a kódsorhoz, ahol a hiba keletkezett. Ennek neve angolul trace vagy traceback.

A hívási láncot alulról fölfelé kell olvasni. A legalsó sor mutatja a konkrét hibát. Felette az első elem, hogy a hiba konkrétan hol keletkezett: a legkisebb() függvényben. A következő elem pedig azt, hogy ez honnan lett hívva: a main()-ből, és így tovább. Nagyobb programoknál nem ritka a 30-40 függvény mélységű hívási lánc sem, de ezeknél is általában a lista alja az érdekes, mert az mutatja, hogy a konkrét hibajelzés hol, hogyan keletkezett.

Ezen a példán is látszik, hogy a kivétel eldobása és elkapása (vagy el nem kapása) akármilyen távol lehetnek egymástól, akárhány függvényhívásnyi mélységben. Ezt később sokszor ki fogjuk használni, mert ez az egyik legfontosabb tulajdonsága a kivételkezelésnek.

Függvények és referenciák

26. Mi történik itt?!

Futtassuk le az alábbi programot!

def szamot_novel(szam):
    szam += 1
    print("Belül:", szam)

def main():
    x = 3
    print("Kívül:", x)
    szamot_novel(x)
    print("Kívül:", x)
Kívül: 3
Belül: 4
Kívül: 3   !
def listat_bovit(lista):
    lista.append(4)
    print("Belül:", lista)

def main():
    l = [1, 2, 3]
    print("Kívül:", l)
    listat_bovit(l)
    print("Kívül:", l)
Kívül: [1, 2, 3]
Belül: [1, 2, 3, 4]
Kívül: [1, 2, 3, 4]   !

Megpróbáltunk írni egy függvényt, amelyik megnövel egy számot: a paraméterként adott változóhoz lenne a dolga hozzáadni egyet. Hiába, nem működik. Bár a függvényen belül úgy tűnik, a változó megnőtt eggyel, valójában viszont a főprogram x-ének értéke a függvényhívás után még mindig 3.

Ezek után van egy függvényünk, amelyik egy listához hozzáfűz egy számot. Az [1, 2, 3] listát tartalmazó változót adjuk neki paraméterként. A függvényen belül látjuk a megnyújtott, négy elemű listát. Visszatérve azt tapasztaljuk, hogy a main() függvényből is látjuk a változást.

A teljesség kedvéért említsünk meg még egy típust, a sztringeket. Azzal sem működik:

def sztringhez_hozzafuz(sztring):
    sztring += '!'
    print("Belül:", sztring)

def main():
    s = "hello"
    sztringhez_hozzafuz(s)
    print("Kívül:", s)
Belül: hello!
Kívül: hello

Hiába tűnik úgy, hogy a függvényben hozzáfűztünk egy felkiáltójelet a sztringhez, a main() függvényünk s változója még mindig a felkiáltójel nélküli változatot tartalmazza.

Hogy lehet ez, mi ennek az oka? Miért van az, hogy ha egy számunk vagy sztringünk van a változóban, azt nem tudja megnövelni a függvény, listával viszont működik a dolog?

27. Emlékeztető: a referenciák fogalma

Ezek szerint tehát itt egy darab lista van és két darab változó. Emlékezzünk vissza a referenciákra, azok alapján rögtön meg tudjuk értni, mi itt a helyzet!

A listát, mint memóriában tárolt adatot, objektumnak (object) nevezzük. A lista objektumot a szögletes zárójelekkel [] hoztuk létre, és itt az append() függvénnyel módosítjuk.

Az objektumra hivatkozhatunk, ahogy az ábra nyilai is mutatják. Az objektumokra hivatkozást referenciának (reference) neveztük. A létrehozott változók (variable) azok, amelyek ezeket a referenciákat tárolják, azaz hozzájuk kötöttünk egy objektumot (binding). Így lehetett több referenciánk ugyanarra az objektumra: több változó tárolja ugyanazt a referenciát, több változóhoz van hozzákötve egy objektum.

a = [1, 2, 3]
b = a
b.append(4)

def lista_bovit(b):
    b.append(4)

a = [1, 2, 3]
lista_bovit(a)

A referenciák fogalma

Amikor egy változónak értéket adunk, akkor egy referenciát állítunk be egy meglévő objektumra. Ugyanez játszódik le a függvényhívás esetében is (lásd a második kódrészletet). Kezdetben van egy listánk, az [1, 2, 3] kifejezéssel létrehozott objektum. Azt hivatkozza (referálja) az a nevű változó. Ezek után meghívjuk a függvényt, paraméterként adva neki az a változót. A függvény paramétere, azaz b is csak egy ugyanolyan változó, mint a többi, és mivel azt a értékével inicializáltuk, ezért b is ugyanannak a listának a referenciája lett. Ugyanaz a memóriakép állt elő, mint az első kódrészletben. Emiatt ha a b-n keresztül látott lista végéhez fűzünk hozzá 4-et, az pont olyan, mintha a hívás helyén a.append(4)-et írtunk volna: az a változón keresztül is elérhető lista egy elemmel hosszabb lesz.

28. Mutábilis és immutábilis objektumok

A lista mutábilis típus

a = [1, 2, 3, 4, 5]
a[2] = -3
print(a)


[1, 2, -3, 4, 5]
b = a
b = list(a) # nem mindegy!

A lista objektumok értéke, tartalma változhat: az egyes elemeik módosíthatóak, kitörölhetőek, és így tovább. Az ilyen típusokat módosíthatónak, mutábilisnak (mutable) nevezzük.

Mivel maga az objektum változhat, nem mindegy, hogy csak a referenciáját másoljuk, vagy magát az objektumot is. Ha csak a referenciáját, akkor hiába van több nevünk, ugyanazzal az objektummal dolgozunk mindvégig. Persze sokszor éppen ez a célunk, de sosem tudhatjuk, ki látja még az objektumot, és ki módosíthatja azt – ezt tisztázni kell a programban, annak dokumentációjában.

A sztring immutábilis típus

a = "almafa"
a[0] = "A"
TypeError: 'str' object does not support item assignment
b = a # sosem lehet gond
Két változó, ugyanaz a sztring

Vannak olyan objektumok, amelyek tartalma, értéke nem változhat. Ezeket immutábilisnak (immutable) hívjuk. A számok is ilyenek, de a sztringeknél még szembetűnőbb a dolog. A listákkal ellentétben ezeknek nem tudjuk módosítani az egyes karaktereit külön-külön, legfeljebb új sztringet tudunk létrehozni.

Ha egy objektum immutábilis, akkor mindegy, hogy hány referenciánk van rá. Nincs haszna annak sem, ha lemásoljuk (nem csak a referenciáját, hanem a tartalmát is). Ha több változó tárolja ugyanazt a szöveget, akkor is elég a karaktersorozatot egyszer eltárolni a memóriában, osztozhatnak rajta a változók. Mivel a tartalom nem változhat, nem lényeges, hogy egy vagy több példány van belőle, mert nem történhet olyan, hogy egyik változón keresztül beleírunk az objektumba, és közben egy másik változónk értéke „elromlik”.

A += operátor nem módosítja a sztringet?

Itt meg kell említeni egy látszólagos ellentmondást, a += operátort és társait. Bár úgy tűnhet, hogy ez módosítja egy változó tartalmát, valójában viszont nem így történik! Az x += y csak az x = x + y rövidítése. Ami azt jelenti, hogy kiértékelődik az x + y kifejezés, és létrejön egy új objektum; aztán az x változó ennek az objektumnak a referenciáját fogja tárolni.

Lássuk ezt sztringekre! Lentebb a b = a értékadás hatására a b változó ugyanazt a referenciát tartalmazza, mint az a. Ezek után b-hez hozzáfűztünk egy szót. De valójában b = b + "fa" értékadásnak kezeli ezt a Python. Ekkor pedig új objektum keletkezik, és a b már nem mutat az a-val közös szövegre, hanem az új objektum hivatkozása lesz.

a = "alma"
b = a

b += "fa"
Hozzáfűzés a sztringhez: valójában új sztring objektum keletkezik

29. Szóval mi is történt?

Vizsgáljuk meg még egyszer a kódrészleteket, a változók, objektumok és referenciák működését szem előtt tartva!

def szamot_novel(b):
    b += 1
    print(b)

def main():
    a = 3
    print(a)
    szamot_novel(a)
    print(a)
3
4
3 !
Paraméterként átadott szám: nem változik, mert a paramétert, mint változót, más objektumra állítjuk át, de az eredeti a régire mutat
def listat_bovit(b):
    b.append(4)
    print(b)

def main():
    a = [1, 2, 3]
    print(a)
    listat_bovit(a)
    print(a)
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3, 4] !
Paraméterként átadott lista: változik, mert az objektumot változtatjuk

Az első esetben tehát a függvényhívás pillanatában létrejön egy új referencia. Ez a b nevű változó, a paraméter, amelyik ugyanarra az objektumra mutat, mint az a nevű: a 3-as egész számra. A b += 1 értékadás valójában b = b + 1, amivel a nevű változót állítjuk át egy újonnan létrejött objektumra, a 4-es egész számra. És ami a lényeg: itt a b nevű változót állítottuk át, és ez semmiféle hatással nincsen a főprogram a nevű változójára. A függvényből visszatérve a b nevű változó meg is szűnik.

Nem így a listás példában. Kezdetben ott is létrejön egy referencia ugyanarra az objektumra (a listára). A függvény belsejében viszont ne ma referenciát, hanem az általa hivatkozott objektumot módosítjuk a b.append() függvényhívással. Mivel az objektum módosult, a főprogramban érzékelni fogjuk a változást – annak ellenére is, hogy mire visszatértünk a függvényből, a b nevű változó már megszűnt.

30. Beugrató kérdések

Hogy írunk számot megnövelő függvényt?

def novel(szam):
    szam += 1

x = 5
novel(x)
print(x)    # 5
def kovetkezo(szam):
    return szam + 1

x = 5
x = kovetkezo(x)    # !
print(x)    # 6
Megnöveli a változót?

Hogyan írjuk meg tehát azt a függvényt, amely megnöveli a neki paraméterként átadott változót? Leginkább sehogy. Mivel a függvényhívásnál a paraméterátadás új változót hoz létre, az eredeti változóra nem tudunk hatással lenni, az int típusú objektum pedig immutábilis. Legfeljebb olyan függvényt írhatunk, amelyik visszatér egy másik objektummal, amivel felülírjuk az eredeti változót.

Megcseréli-e a függvény két lista tartalmát?

A listák mutábilis objektumok. Megcseréli-e ez a függvény két lista tartalmát?

def lista_csere(l1, l2):
    temp = l1
    l1 = l2
    l2 = temp

a = [1, 2, 3]
b = [4, 5, 6]
lista_csere(a, b)
print(a, b, sep="\n")   # mit ír ki?
Megcseréli a listák tartalmát?
[1, 2, 3]
[4, 5, 6]

Hiába mutábilisak a listák, az értékadással a változókat módosítjuk, nem pedig a listákat. A függvényhíváskor létrejött két új változó, l1 és l2. Ezek a hívás helyén látott a és b változók másolatai; ugyanazok a listák érhetők el rajtuk keresztül, de mint változók, függetlenek az eredeti a-tól és b-től. Vagyis ha l1-nek és l2-nek adunk értéket, akkor sem a, sem b nem fog módosulni; az értékadások nem módosították sem az eredeti változókat, sem a listákat.

A függvény nem cseréli meg a listákat

31. A None érték: semmi

Van a Pythonban egy speciális objektum, a None. Ez azt hivatott jelképezni, hogy egy változó már létezik, de még üres. Méghozzá így:

a = None
if a is None:
    print("A változó üres")

A változók vizsgálatára az is és az is not operátorokat szoktuk használni: xyz is None igaz lesz, ha a változó None értékű, azaz üres.

Mire jó ez? Létrehozhatunk úgy egy változót, hogy nem adunk neki értéket. Vagy írhatunk vele olyan függvényt, amelyik néha nem ad vissza semmit. Fentebb például szerepelt egy olyan függvény, amelyik egy szó első előfordulását kereste meg egy listában, és annak indexét adta vissza; ha nem volt találat, akkor pedig -1-et. Ez a -1 egy hasraütésszerűen választott érték. Jobb ehelyett a None-t használni, azzal éppen azt tudjuk kifejezni, hogy a függvény rendben lefutott, de a keresés nem járt sikerrel, nincs találat.

Megkeresi az elem első előfordulását, vagy None-t ad vissza:

def hol_van(lista, elem):
    for i in range(0, len(lista)):
        if lista[i] == elem:
            return i
    return None

def main():
    szamok = [78, 95, 37, 81]

    mit = int(input())
    idx = hol_van(szamok, mit)
    if idx is not None:
        print("Az első előfordulás helye:", idx)
    else:
        print("Nincs benne.")

A None érték használatára a félév későbbi részében több példát is fogunk még látni.

32. Összefoglalás

Az előadáson megismertek alapján:

  • Tudni kell függvényeket definiálni és hívni.
  • Ismerni kell a pozicionális, opcionális és tetszőlegesen sok paraméter valamint a név szerinti paraméterátadás fogalmát.
  • Használni kell a docstringet.
  • Tudni kell kivételeket dobni (raise)
  • Tisztában kell lenni mutábilis/inmutábilis objektumok fogalmával.