2016. november 20., vasárnap

11 - Az objektumorientált programozás - 1 - Az első osztály

Ma nagy témába kezdünk bele: az objektumorientált programozásba. Ez is egy úgynevezett programozási paradigma, úgy mint a procedurális programozás, amit a függvényekről szóló bejegyzésben ismertettem. Ahhoz hasonlóan, tegyük fel most is a legfontosabb kérdéseket:

Mi az az objektumorientált programozás?

Az objektumorientált programozás (továbbiakban OOP) lényege igen egyszerű: összezárjuk az adatot, és a rajta végezhető műveleteket (encapsulation). Sok objektummal találkoztunk már az ezt megelőző részekben, úgyhogy innen vennék egy példát.

Nézzünk egy list-et:

array = [1, 2, 3, 4]

Ha hozzá akarunk adni egy elemet a tömbhöz akkor azt így tehettük meg:

array.append(5)

Ez után ha printeljük az array-t akkor a [1, 2, 3, 4, 5] jelenik meg a kimeneten. Tehát az .append() metódussal az array-ben található adaton végeztünk egy műveletet: hozzáadtunk egy elemet. Látható: az adat, és a rajta elvégzett művelet mind ott van az array-ben.

Ez a gondolatmenet konkrétan annyira átjárja a Python programozási nyelvet, hogy a szintaxis elemein kívül minden amit csak le tudunk írni az valójából egy objektum.

Az OOP persze ennél sokkal több; több félévnyi előadást lehet belőlük tartani az egyetemen, és könyvtárakat lehet megtölteni a szakirodalommal. Mi most rövidre fogjuk a témát, mert a lényeg igazából egyáltalán nem bonyolult.

A szakirodalom és az egyetem meg foglalkozzon a speciális és tudományos esetekkel

Miért akarunk objektumorientáltan programozni?

Az egységbe zárás nyilvánvaló előnye tehát az, hogy kérdés nélkül tudjuk hogy milyen műveleteket végezhetünk az adaton, hisz ezek a műveletek ott vannak az adattal együtt. Például tudjuk, hogy a list típusú objektumoknak nincs .keys() metódusuk, mert az csak a dict objektumoknak van.

Ez az összezárás növeli a forráskód hordozhatóságát is: egy objektumban rengeteg adat és metódus elhelyezhető. Ha a függvények törzsében a forráskód egy kis izolált környezetben futott, akkor ahhoz képest egy objektumban egy konkrét izolált világ létezik.

Másik fontos dolog, hogy az objektumok működését leíró osztályok képesek a rá hogy tulajdonságokat örököljenek (inheritance) más osztályoktól. Ezt mindig valamilyen állattal szokták szemléltetni, úgyhogy vegyünk egy macskát.

A macska egy emlős állat, így tehát minden igaz rá ami úgy általában az emlősökre. Ugyan így az ember is egy emlős, tehát ha van egy rendszerünk amiben macska és ember osztályok is léteznek, akkor érdemes ezeknek az emlősök tulajdonságait örökölniük. Ennek több gyakorlati haszna van:

  • Csökkentjük a forráskód redundanciáját azzal, hogy a mindkét fajra jellemző tulajdonságokat egy ősosztályból származtatjuk, amit aztán a származtatott osztályok automatikusan meg is kapnak.
  • Ahogy a természetben, úgy a programban is, mind a macska és az ember objektumok emlősökként is fognak viselkedni. Így tehát ha a programban amit egy emlős objektummal meg lehet tenni, azt egy macskával vagy egy emberrel is lehetséges.

Sokan azt magyarázzák, hogy az OOP szemlélet közel áll a természet működéséhez, és végülis ezt beláthatjuk a fenti példákon.

A Python és az OOP kapcsolata

Bár a tudtunkon kívül eddig is folyamatosan objektumokat hoztunk létre és léptünk velük kapcsolatba a forráskódban, ettől még nem programoztunk objektumorientáltan, csak objektumokat használtunk. A Python lehetőséget nyújt többféle paradigma szerinti programozásra: lehet írni a kódot csak úgy fentről-lefelé, ahogy tetszik; vagy függvényekben, és nemsokára osztályokban is. Ezek egymás mellett jól elférnek, a Python nem kényszerít rá semmilyen szemlélet használatára.

Az OOP annyira egy sikeres szemlélet lett az évek során, hogy léteznek olyan programozási nyelvek, amikben csak és kizárólag objektumorientáltan lehet fejleszteni. Ilyen a Java vagy a C#.

Ezt elolvasva úgy tűnhet, hogy az OOP az evolúció csúcsa, de minimum az a pont a programozásba, ahova minden fejlesztő megérkezik. Mint amikor nagykorú lesz az ember. Én ezt nem teljesen gondolom így, de tény hogy ma az OOP az alap amire az iparban szükség van.

Az első osztályunk Pythonban

Osztály? Nem objektumokról volt idáig szó?

Ahhoz hogy elkerüljük a félreértéseket be kell vezetnünk néhány új fogalmat, úgyhogy jól tessék figyelni!

Az objektumok működésének leírását egy osztályban (class) végezzük. A leírás alapján gyárthatunk egy halom különböző példányt (instance). Ezeket az élő példányok hívjuk objektumoknak is (objects). Egy objektum osztálya egyben az objektum típusa is. Ahogy a [1, 2, 3] típusa list, úgy a lenti példában a my_cat típusa majd Cat osztály lesz.

Éppen ezért, na meg a szóismétlések kerülése végett, a macska objektumokat hívom majd Cat osztályúaknak vagy Cat típusúaknak is.

Elég a pofázásból, lássuk az első osztályt!

class Cat:
    def __init__(self, name):
        self.name = name

    def meow(self):
        print('Meow')


my_cat = Cat('Narancs')
print(my_cat.name)
my_cat.meow()

Ezzel most nagyjából annyi új információt zúdítottam rátok, mintha 0-ról kezdenénk a kódolást.

Bonyolultnak tűnhet, de nem kell aggódni. Ezt a fenti kódot két részre szedhetjük: a class alatti blokk az osztály definíciója. Az utolsó néhány sor a példányosítás (instantiation), és pár egyszerű interakció az objektummal.

Az osztály

Vegyük az osztály definíciót még egyszer:

class Cat:
    def __init__(self, name):
        self.name = name

    def meow(self):
        print('Meow')

Python nyelvben az osztályt a class szóval kell kezdeni, amit az osztály neve követ. A példában a macskák leírására alkalmas osztályt Cat-nek neveztem el.

Pythonban az osztályok nevét szokás szerint camel case stílusban írják.

Ez azt jelenti, hogy az osztály nagy kezdőbetűvel kezdődik, és ha több szó alkotja az elnevezést, akkor a szavak közt nincs elválasztás, hanem minden szó nagy kezdőbetűt kap és egybe írják az összeset.
Példával sokkal egyszerűbb: Cat, PhoneNumber, ValueError.

Csak hogy teljes legyen a kép: ha rövidítést használunk, és a rövidítéssel együtt négy vagy több nagybetű követné egymást, akkor csak a rövidítés első kezdőbetűje lesz nagy.
Tehát: XmlWriter, StringIO, HtmlReader

Az osztály definíció blokkjában végre ismerős kulcsszó látható, a def. Azt is mondhatnánk, hogy ez egy függvény, de mivel az osztályon bellül van ezért őt metódusnak (method) hívjuk. Ettől még ugyan úgy visszatérhetnek bármilyen értékkel, mint a függvények.

A fenti példában tehát két metódus látható, az __init__() és a meow().

Az __init__()

Az __init__() név egy különleges metódust takar az osztályon belül: ez az úgynevezett konstruktor (constructor). Ez azt jelenti, hogy a metódus automatikusan fut akkor, amikor példányosítjuk az osztályt, azaz amikor egy új objektumot készítünk az osztály alapján. Ebben a metódusban lehet megadni, hogy a létrejövő objektum milyen kezdeti értékekkel rendelkezzen.

A konstruktorból nem lehet semmilyen értékkel se visszatérni.

Arra hogy a metódus pontosan mikor és hogyan fog lefutni kicsit később térünk vissza.

Pythonban azokat a metódusokat, amik __-el kezdődnek- és végződnek, magic method-nak hívják, és rengeteg létezik belőlük.

A self

Az __init__ két paramétert vár, a self-et és a name-et. Az osztályon belül a metódusok legelső paramétere, amit itt most self-nek hívok, mindig különleges jelentőséggel bír: ez a paraméter impliciten (“magától”) értéket kap, méghozzá az objektumot magát, majd akkor, amikor példányosítottuk az osztályt. Tehát ha egyetlen paramétert se vár a metódusunk, a selfet akkor is ki kell írni, máskülönben hibát jelez a Python.

A paramétert szokás szerint self-nek hívják, de igazából akárhogy el lehet nevezni, viszont nagyon hülyén fognak rátok nézni ha nem így hívjátok.

A valóságban van egy olyan eset, amikor a legelső paraméter mégsem képvisel különleges értéket, de erről majd később írok egyszer.

A self arra való, hogy ezen keresztül tudják majd az objektum metódusai egymást és az adattagokat elérni.

Mivel a self mindig ott áll a metódus paramétersorában, ezért nem is szokás külön említeni. Így a jövőben nem azt fogom írni, hogy a konstruktor két paramétert vár, a selfet és a name-et hanem hogy a konstruktor egy paramétert vár, a name-et.

Ahogy tovább haladunk a self mellett, ugyan azok a szabályok lesznek érvényesek, amik a közönséges függvények esetében is, úgyhogy ezen nem is agyalunk tovább.

A konstruktor törzsében egy utasítás áll, a self.name = name. Itt egyszerűen a paraméterül kapott name értékét eltároljuk a self.name név alatt. Láthatólag a self.name nem létezett korábban, de ez nem probléma: a self-hez bármikor hozzáadhatunk egy új adattagot (property).

Ezek után a a meow() már egy igen egyszerű metódus: ha meghívjuk akkor kiírjuk a konzolba hogy nyávog a macsek. Itt jól látható, hogy a self-et kötelező volt kiírni, de azon kívül nincs más paramétere, azaz összességében nem vár semmilyen paramétert ez a metódus.

Nagyon-nagyon fontos megérteni, hogy a self az objektumra mutató paraméter (a self jelentése önmagam). Ebbe a self-be a példányosításkor bekerülnek az osztályban definiált metódusok, amik példányosított entitásra lesznek érvényesek, de ezen kívül kvázi üres a self objektum.

A példányosítás

Feljebb definiáltuk az osztályt. Ahogy (sokszor) írtam, ez csak egy tervrajz, az objektum működését írja le. Amikor elindul a program a Python értelmezi az osztályt de ettől még nem jön létre semmi objektum. Hasonlóan mint amikor a függvényt definiáltuk, az sem futott le addig amíg meg nem hívtuk meg.

A fenti példa utolsó két sorában készítünk egy élő példányt, egy objektumot az osztály alapján. Lássuk a kódot újra:

my_cat = Cat('Narancs')
print(my_cat.name)
my_cat.meow()

Az első sorban a Cat('Narancs') kifejezéssel hozunk létre egy objektumot. Kinézetre ez pont olyan mint ha egy közönséges függvény hívnánk meg, de tudjuk hogy a Cat most egy osztály.

Az osztály példányosítása során egy argumentumot adtunk meg, a 'Narancs' stringet. A példányosításkor a konstruktor automatikusan (impliciten) meghívásra kerül, így a Cat osztály __init__() metódusába kerül a program futása, és ahogy korábban írtam, itt eltároljuk a name paraméterben megkapott értéket ('Narancs') az objektumban önmagában, a self segítségével.

Ezzel véget is ért a konstruktor futása, elkészült az objektum amit my_cat névvel tároltunk el.

A fenti példa 2. sorában kiírjuk az objektum name property-jének értékét. Ez ugye az adattag a konstruktor lefutásával létrejön, így minden gond nélkül megjelenik a macska neve a kimeneten.

Az utolsó sorban meghívjuk az objektum meow() metódusát, és simán megjelenik a Meow a konzolban.

Ha ugyan úgy kell egy osztály példányosítani mint egy függvényt meghívni, akkor honnan tudjuk hogy mikor-melyiket hívjuk meg? Mi van akkor, ha valaki nem az ajánlás szerint nevezi el az osztályát? A válasz egyszerű: nem tudjuk. De igazából az aztán tök mindegy hogy függvényt hívogatok-e ami visszatér egy objektummal, vagy egy osztályt példányosítottam, csak menjen amit akarok.

A metódusok definiálásának sorrendje

Azt már észrevehettük hogy egyáltalán nem mindegy a forráskódban, hogy az utasítások milyen sorrendben követik egymást. Például nem hivatkozhatunk egy változóra addig, amíg azt nem definiáltuk.

Az osztály esetében egy kicsit lazul ez a szabály: az osztályban a metódusok tetszőleges sorrendben kerülhetnek definiálásra. A metódusok hívásának a sorrendje lesz az, ami számít.

Macskák a dobozban

Mivel a macskák imádják a dobozokat, ezért gond nélkül belepakolhatjuk őket a Python kódban is. Egy tömbnyi macskát eltárolhatunk például egy list-ben, így:

box = [Cat('Narancs'), Cat('Omlás'), Cat('Don Gatto'), Cat('Vörös Harry'), Cat('Félszemű Babylon')]

Ez után ha kiírjuk a tömb elemeit egymás alá:

for cat in box:
    print(cat)

Akkor (elsőre meglepően) ezt az izét látjuk:

<__main__.Cat object at 0x7ff7daec1160>
<__main__.Cat object at 0x7ff7daec16d8>
<__main__.Cat object at 0x7ff7daec1710>
<__main__.Cat object at 0x7ff7daec1748>
<__main__.Cat object at 0x7ff7daec1780>

Ha közvetlenül kiírunk egy objektumot a print()-el akkor, mivel senki se mondta meg a Pythonnak, hogy a Cat kiírása alatt mint értünk, jobb híján az objektum osztályának a nevét (“teljes nevét”, modullal együtt) és a memória címét kapjuk eredményül, amivel itt most nem megyünk sokra.

Kitérő: az objektumok kiírása

Azt, hogy az objektum hogyan írható ki, a __repr__() magic methodban tudjuk megadni. A print() ezt a metódust automatikusan meghívja majd a kiírás pillanatában.

Ez egy elég rövid fejlesztés, de azért jobb ha látjuk az egész osztályt:

class Cat:
    def __init__(self, name):
        self.name = name

    def meow(self):
        print('Meow')

    def __repr__(self):
        return '<Cat object. Name: {0}>'.format(self.name)

Az utolsó két sorban látható az újdonság. A metódus neve fix, és paramétereket se kell neki adni (ez szabály). A metódus törzsében tetszőleges utasítások állhatnak, de kötelező a végén visszatérni egy str-el. Esetünkben ez egy egyszerű kis format string.

Ezzel a kis módosítással ha újra futtatjuk a kódot akkor már ez jelenik meg a kimeneten:

<Cat object. Name: Narancs>
<Cat object. Name: Omlás>
<Cat object. Name: Don Gatto>
<Cat object. Name: Vörös Harry>
<Cat object. Name: Félszemű Babylon>

Azért ez elég fasza nem? A beépített magic methodok között rengeteg ilyen érdekes metódus van még, de erről külön bejegyzések fognak majd szólni.

Természetesen ahogy a print() meghívta a mi metódusunkat, úgy ezt mi is megtehetjük. Erre használható a repr() függvény, de akár ilyet is írhatunk: print(cat.__repr__())

A második osztályunk Pythonban

Nem kétség, hogy a list kiválóan őrzi a benne felsorolt objektumokat, mivel az egy általános tároló: bármit belerakhatunk, bármit kivehetünk belőle.

A valóságban nem biztos hogy jó ötlet lenne az amúgy is zsúfolt dobozba véletlenül egy blökit is belerakni. Szóval lehet érdemes lenne írni valami tároló objektumot, amibe csak a megadott típusú objektumok kerülhetnek bele, és a zsúfoltság elkerülése végett korlátozva van a mérete is. Ilyet egyik beépített Python tároló se tud, úgyhogy írjunk egy sajátot!

Tervezés

Nem kell aggódni, attól hogy közvetlenül a list-et nem tudjuk használni a probléma megoldására, még nem kell újra feltalálnunk a tömböt. Egy olyan osztályt írunk, ami elfed magában egy list-et. Ez azt jelenti, hogy az osztályunkban ugyan lesz egy tömb, de ahhoz csak mi saját metódusainkkal nyúlhatunk hozzá. Ide fogjuk elhelyezni az olyan ellenőrzéseket, mint hogy:

  • Az osztályhoz hozzáadott objektum Cat típusú-e?
  • Nincs-e tele az objektumunk macskákkal?

Lássuk akkor szép sorban hogy mit-és-hogyan tudjon majd az osztály:

  1. Paraméterül lehessen megadni neki, hogy mekkora legyen a tároló mérete. Az alapértelmezett érték legyen mondjuk 3.
  2. A konstruktorban hozzunk létre egy üres tömböt
  3. Legyen az osztálynak egy add_cat() metódusa. Itt mindig ellenőrizzük, hogy a paraméter Cat típusú-e, és hogy nincs-e tele a tároló
    • Ha minden rendben akkor hozzáadjuk a konstruktorban létrehozott tömbhöz a paraméterül kapott objektumot
    • Ha valami gond van, akkor dobunk egy hibát
  4. Lehessen kiírni a tároló objektumunkat, hogy lássuk hogy mi van benne

Oké, egyetlen fontos kérdés maradt: mi lesz az osztály neve? CatBox, természetesen.

Megvalósítás

class Cat:
    def __init__(self, name):
        self.name = name

    def meow(self):
        print('Meow')

    def __repr__(self):
        return '<Cat object. Name: {0}>'.format(self.name)


class CatBox:
    def __init__(self, size=3):
        self.size = size
        self.cats = []

    def add_cat(self, cat):
        if isinstance(cat, Cat):
            if len(self.cats) <= self.size:
                self.cats.append(cat)
            else:
                raise MemoryError('The box is full (it has {0} cats}.'.format(self.size))
        else:
            raise TypeError('Expected a Cat instance, got {0} instead'.format(type(cat)))

    def __repr__(self):
        return repr(self.cats)

Csak úgy ömlik az új információ, mi?

A kód tetején a Cat-ben semmi változás nincs, így ezzel nem is foglalkozunk.

A CatBox.__init__() metódusnak egy paramétere van, a size, azaz a macskás doboz mérete. A konstruktoron belül eltároljuk a kapott argumentumot azért, hogy később a macskák hozzáadásánál elvégezhessük vele szemben az ellenőrzést. Ugyan itt létrehozunk egy tömböt is cats néven. Ide kerülnek majd be a macsekok. Ezzel meg is vagyunk.

Az add_cat() metódus már combosabb, úgyhogy nézzük még egyszer:

def add_cat(self, cat):
    if isinstance(cat, Cat):
        if len(self.cats) <= self.size:
            self.cats.append(cat)
        else:
            raise MemoryError('The box is full (it has {0} cats}.'.format(self.size))
    else:
        raise TypeError('Expected a Cat instance, got {0} instead'.format(type(cat)))

Egy paramétert vár, aminek cat lesz a neve.

A 2. sorban az isinstance() segítségével történik a típus ellenőrzése. Ez a függvény két paramétert vár: az első egy objektum a második pedig egy osztály (illetve általánosabbat: egy tetszőleges típus). Válaszul azt kapjuk meg, hogy az objektum osztálya az-e ami a második paraméterében szerepel.

Ez így elsőre furcsának tűnhet, de igen, magával az osztállyal is végezhetünk műveleteket (itt például egy feltételben használtuk), ugyan úgy, mint ahogy a függvényeknél is tehettük.

Ha ez a feltétel teljesül, akkor a paraméter biztosan Cat típusú, szóval haladhatunk lefelé, viszont ha nem megfelelő osztályú az objektum, akkor egy TypeError exceptiont dobunk el.

Egy pillanatra álljunk meg az exception szövegénél, mert van ott is egy új függvény:

'Expected a Cat instance, got {0} instead'.format(type(cat))

A type() függvénnyel megkapjuk a cat objektum típusát. Hangsúlyozom, az eredmény egy típus lesz, ami a cat objektum esetén maga a Cat osztály. Az osztály kiírásával az osztály nevét kapjuk egyébként (többé-kevésbé). Így lesz egy tök csinos hibaüzenetünk, amiben látszódik hogy milyen típusú objektumot vártunk, és mit kaptunk helyette.

Lejjebb, a 3. sorban ellenőrizzük, hogy nincs-e tele a tároló. Egyszerűen csak összehasonlítjuk, hogy a konstruktorban létrehozott tömb mérete kisebb vagy egyenlő-e a konstruktorban megadott limitnél. Ha itt is tovább tudunk haladni, akkor hozzáadjuk a self.cats tömbhöz a paraméterül kapott objektumot, és készen is vagyunk.

Ha a self.cats mérete túl nagy, akkor a másik ágban eldobunk egy MemoryError exceptiont, ami paraméterül kap egy kis szöveget, csak hogy tudjuk hogy mi történt.

A MemoryError, mint a TypeError is, a Python egy-egy beépített osztálya, ezért nincsenek korábban definiálva vagy importálva sehol sem.

Azok után amit idáig tanultunk remélem feltűnt, hogy a MemoryError vagy TypeError elnevezése és használata pont olyan, mint a közönséges osztályoké. Ez nem túl meglepő: ők tényleg egyszerű, sima osztályok.

Zárásként az osztály aljára odakerült a __repr__(), amivel az objektum kiírása lehetséges lesz majd. Ezen belül az objektum saját cats tömbjére meghívjuk a repr() függvényt, és visszatérünk az így kapott értékkel. Ez a függvényhívás végigjárja a tömb összes objektumát, és meghívja azoknak a __repr__() metódusait, amit kidolgoztunk a Cat osztályban. Itt aztán jó mélyen belementünk a függvény hívogatásba, de a vége tök jó lesz, nyugi.

Ezzel nagyjából készen is vagyunk. Akkor példányosítsunk pár macskát és lássuk mi történik!

Próba

my_box = CatBox()
my_box.add_cat(Cat('Narancs'))
my_box.add_cat(Cat('Omlás'))
my_box.add_cat(Cat('Don Gatto'))

Itt bizony már nincs semmi újdonság. Három macska objektum került a my_box-ba, úgyhogy tudjuk hogy tele a tároló. Írjuk ki az objektumot még mielőtt tovább haladnánk:

print(my_box)

Ennek kell megjelennie:

[<Cat object. Name: Narancs>, <Cat object. Name: Omlás>, <Cat object. Name: Don Gatto>]

Nem mondom, elég szexi.

Próbáljuk ki, hogy jól működnek-e az ellenőrzéseink. Rakjunk be egy negyedik macskát a dobozba:

my_box.add_cat(Cat('Vörös Harry'))

Meg is kapjuk a jól megérdemelt exceptiont:

MemoryError: The box is full (it has 3 cats).

Ennek az értelmezésébe nem megyünk bele, mert a hibakezelésről szóló bejegyzésemben már meséltem ezekről a hibaüzenetekről.

Ha gyorsan összedobunk egy Dog osztályt, monduk így:

class Dog:
    def __init__(self, name):
        self.name = name

Igen, ez az osztály nem csinál semmi hasznosat se, de nem Cat, és most csak ez a lényeg.

És megpróbáljuk berakni a tárolóba:

my_box.add_cat(Dog('Scooby'))

Akkor ezt a szépséget kapjuk:

TypeError: Expected a Cat instance, got <class '__main__.Dog'> instead

Elég világos az üzenet szerintem.

Pihenő

Sikeresen elindultunk az OOP ösvény hosszú útján, de most már ideje egy kis pihenőt venni. Ezen a témán egy pár bejegyzés erejéig még csámcsogunk majd, mert igen sok elég fasza lehetőséget kínál ez a nyelvi elem, meg úgy az egész szemlélet. A következő bejegyzésben elővesszük a öröklődés fogalmát, meg a generikusságot is … talán.

Ez a bejegyzés a Python tutorialom egyik része. Az összes rész listája itt fellelhető.

-slp

3 megjegyzés: