Általános szoftver ismeretek
  A polimorfizmusról általában

Miért hasznos a polimorfizmus?



A címben feltett kérdés megválaszolásához jól jöhet egy történeti áttekintés: Nézzük meg először is, milyen hátrányai voltak a polimorfizmus hiányának.

Egy kis történeti áttekintés

Kezdetben valának a monolitikus programok, melyek modularizálását egyedül a függvényhívások segítették. Az ilyen programoknál a tudás legnagyobb része egyetlen központi részbe van koncentrálva, ennek a résznek kell megőriznie változókban az állapotokat és ennek kell tudnia, hogy mikor kell felhívni.

A monolitikus programot úgy képzelhetjük el, mint egy ezermestert, aki mindenféle munkát elvégez; a függvényei 'buta szerszámok', melyek nem rendelkeznek memóriával, az égvilágon mindent a mesternek kell észben tartania.



A programok törzsfejlődésének következő állomásához akkor érkeztünk el, amikor megjelentek a Földön az objektumok. Ezek képesek a működésükhöz szükséges állapotokat ezáltal a tudás részben decentralizálódhatott. Már nem mindent a központi programrésznek kellett nyilvántartania.

Az ezermesternek a szerszámokon kívül most már vannak értelmes kollégái is (az objektumok), akik nemcsak, hogy értenek egy-egy munkafajtához, de az avval szorosan kapcsolatos dolgokat meg is jegyzik. Nem a mesternek kell például észben tartania, hogy a gép nem indítható el, amíg a védőburkolat nincs lecsukva – ő mindössze annyit mond a gép kezelőjének, hogy 'Indítsd el!' – és az illető öntevékenyen leellenőrzi, hogy zárva van-e a burkolat, sőt, szükség esetén be is csukja.

Ám még ekkor is túl sok mindent kellett a központi modulnak tudnia az egyes objektumok belső működéséről, illetve a különféle objektumoknak egymásról.

A fő gondot az jelentette, hogy az ezermesternek végig minden pillanatban tisztában kellett avval lennie, hogy pontosan milyen fajta szakemberekből áll a gárdája – azaz, hogy milyen típusú objektumoknak ad utasításokat. Ezt a problémát érdemes egy kicsit részletesebben is végiggondolni.

Tegyük fel, hogy létezik egy Esztergályos_1 típusú szakember, minden alkalommal őt szólítja meg a műhelyfőnök, amikor esztergálásra van szükség:

  void Munka1(Esztergályos_1 E){
    ...
    E.Dolgozz();
    ...
  }

  void Munka2(Esztergályos_1 E){
    ...
    E.Dolgozz();
    ...
  }
  ...

  Esztergályos_1 e = new Esztergályos_1();
  ...
  Munka1(e);
  ...
  Munka2(e);

Amikor kiderül, hogy létezik egy korszerűbb ismeretekkel rendelkező, gyorsabban dolgozó Esztergályos_2 is, ezentúl őt szeretnénk munkába állítani:

  void Munka1(Esztergályos_2 E){
    ...
    E.Dolgozz();
    ...
  }

  void Munka2(Esztergályos_2 E){
    ...
    E.Dolgozz();
    ...
  }
  ...
  Esztergályos_2 e = new Esztergályos_2();
  ...
  Munka1(e);
  ...
  Munka2(e);

Az új objektumtípusra való áttéréshez az egész programnak minden olyan modulját változtatnunk és újrafordítanunk kell, ahol előfordul esztergálás. Vagyis minden ponton 'látszik' a munkavégző objektum típusa. A példában ez úgy jelentkezett, hogy mindkét függvény paraméterének meg kellett változtatni a típusát – és nyilván másfajta objektumot is kellett létrehozni.

Ami itt hiányzik: hogy a működtető függvények (Munka1,Munka2) mindig egyforma típusú paramétert láthassanak, amely mögött mintegy elbújik a valóságos típus.



Végül beköszöntött a polimorfizmus kora, azon eszközé, mely segít a tudást sokkal erőteljesebben szétválasztani, partícionálni. Ügyes szervezéssel elérhetjük, hogy adott ponton (például a központi modulban) csak azt kelljen eldöntenünk, hogy mit szeretnénk csinálni; azzal itt nem kell törődnünk, hogy végzi el a műveletet.

Elnevezéssel ismerkedünk:
Kliensek és kiszolgálók

Amikor egy objektumnak felhívjuk valamely metódusát, a programot gondolatban mindig két alapvető részre oszthatjuk: A függvényt felhívó felet általában kliensnek (ügyfélnek) szokták nevezni, a hívást ténylegesen végrehajtó objektumot pedig kiszolgálónak, mert úgy is lehet fogalmazni, hogy a hívó igénybe veszi az objektum valamely szolgáltatását.



A műhelyfőnök számára ezentúl az esztergályos kolléga mindig egy és ugyanazon ÁltalánosEsztergályos típusú. Amikor újfajta szakember áll munkába, ez a tény egyetlen helyen vehető mindössze észre, az objektumkészítésnél:

  void Munka1(ÁltalánosEsztergályos E){
    ...
    E.Dolgozz();
    ...
  }

  void Munka2(ÁltalánosEsztergályos E){
    ...
    E.Dolgozz();
    ...
  }
  ...

  ÁltalánosEsztergályos e = new Esztergályos_1();
  ...
  Munka1(e);
  ...
  Munka2(e);

Minden modul (függvény) csak ÁltalánosEsztergályos típust lát. Ennek persze az a feltétele, hogy mindegyik esztergályos-osztály egyfajta ÁltalánosEsztergályos legyen, pl. így:

  class Esztergályos_1 extends ÁltalánosEsztergályos{
    void Dolgozz();
    ...
  }

  class Esztergályos_2 extends ÁltalánosEsztergályos{
    void Dolgozz();
    ...
  }

Ha ez a feltétel teljesül, valóban behelyettesíthetővé válik mindkét típus ÁltalánosEsztergályos helyére. És ezáltal érvényesülni tud a polimorfizmusnak az az áldásos hatása, hogy több objektum
ugyanolyannak látszódhat:

A polimorfizmus lehetővé teszi a változások koncentrálását, segítségével változtatható a program

a kliens-oldali kód módosítása nélkül,

mivel az onnan látható kiszolgáló típus állandó, a tényleges típus a kliens elől el van rejtve.

A műhelyvezető (a kliens) munkaleírását nem kell megváltoztatni, amikor más módon kiképzett kolléga áll munkába; az ő szempontjából nem változik semmi, őt nem kell semmiféle új dologra betanítani. Más szóval: csak a személyzetisnek kell tudnia, milyen szakembert állít munkába, a műhelyfőnöknek nem.

A tényleges típus csak az objektum létrehozásánál jelenik meg. Fontos, hogy ez a művelet egyetlen helyre legyen koncentrálva! A fenti programban mindössze egy sort kell csak megváltoztatnunk, ha Esztergályos_2 típusra szeretnénk áttérni:

  ...
  ÁltalánosEsztergályos e = new Esztergályos_2();
  ...

Ezt azért érdemes ennyire kiemelni, mert gyakran megfeledkeznek arról a programozók, hogy ha nincs egyetlen helyre koncentrálva az objektumlétrehozás, hanem tele van szórva a program new utasításokkal, akkor gyakorlatilag nem igazán lehet érvényesíteni a polimorfizmus kínálta előnyöket.

A polimorfizmus hatékony alkalmazásának feltétele:
  • az objektumkészítés és
  • az objektumfelhasználás
éles szétválasztása és az objektumkészítés koncentrálása.

A változások mérséklését, koncentrálását megpróbáljuk két szines ábrával is érzékeltetni (mert nagyon fontosnak tartjuk). Az első ábrán a nem polimorf változata látszik a fenti mintapéldának, itt pirossal jelöltük meg azokat a sorokat, melyeket más objektumtípusra (Esztergályos_2) való áttéréskor módosítani kell, kékkel pedig azokat, melyeket újra kell fordítani:


Hát ez bizony olyan csíkos, mint egy piperkőc zebra – pedig a példaprogram nagyon kicsi. Na de nézzük csak meg a polimorf megoldást (koncentrált objektum-létrehozással!):


Mintegy varázsütésre eltűntek a csíkok, a program nagy része nem érzékel semmit a típus-váltásból.

Az objektumok típusa a program futása közben változtatható

A polimorfizmus lehetővé teszi – a program elbonyolítása nélkül – hogy egyes objektumok típusát akár a program működése közben jelöljük ki. Tegyük fel, hogy adott ponton a program a következő kérdést intézi a felhasználóhoz:

  Esztergályos_1-et (1) vagy Esztergályos_2-t (2) hozzak létre?

A következő programrész pedig így néz ki:

  ÁltalánosEsztergályos e;

  if(válasz == 1)
    e = new Esztergályos_1();
  else
    e = new Esztergályos_2();
  ...
  Munka1(e);

Attól függően hozunk létre különböző típusú objektumokat, hogy éppen mire van szükség, amit itt azzal szimuláltunk, hogy a felhasználó dönti el a kérdést. Valójában ez sem olyan légbőlkapott és mesterkélt példa, mint amilyennek elsőre látszik – gondoljunk csak egy számítógépes játékra, melyben a felhasználó válaszhatja meg menet közben, hogy milyen típusú karakterrel szeretne játszani.

A polimorfizmus a függvény-mechanizmus továbbfejlesztése

A polimorfizmust joggal tekinthetjük a függvényhívás továbbfejlesztésének. Függvények definiálásával az a célunk, hogy a hívás helyén ne kelljen tudni, hogyan hajtódik végre egy parancs, csak azt, hogy mit akarunk végehajtani.

A polimorfizmus segítségével egy új szabadsági fokot nyerünk: A hívás helyén nem tudja a kliens azt sem, hogy melyik függvény hívódik meg, mert a parancsot végrehajtó objektum típusát sem ismerjük pontosan.

A polimorfizmus megszüntethet kliens-oldali elágazásokat

A polimorfizmus képes egyszerűsíteni a kliens oldali kódot, megszüntetni egyes elágazásokat.

A polimorfizmus lehetővé teszi egyes kliens-oldali program-elágazások áthelyezését több kiszolgáló objektumba. A kliens oldalán megszűnik a döntés, programelágazás.

A talán a leggyakoribb szituáció, amikor ilyen változtatásra lehetőségünk nyílik: amikor valami statikus, ritkán (vagy egyáltalán nem) változó dolog: függvényében történik elágazás.

Elágazás kiszolgáló objektum típusának függvényében

Egy vázlatos mintapéldával szemléltetjük, hogy miről is van itt szó. Legyen egy Autó osztályunk és annak két alfaja: automata és 'normál'. Az Autó elindítását valahogy így programoztuk be:

  void AutótIndít(Autó A){
    if(A.automata()){
      A.SebességbeTesz();
    }
    else{
      A.KuplungotLenyom();
      A.SebességbeTesz();
      A.GáztAd();
      A.KuplungotFelenged();
    }
  }

Azaz: a kliens-oldalon készítettünk egy elágazást aszerint, hogy milyen típusú a kiszolgáló objektum (automata vagy nem).

Sokkal jobb megoldás, ha készítünk egy Indít() metódust, melyet mindegyik autó-fajta másképp valósít meg. A kliens-oldalon eltűnik az elágazás:

  void AutótIndít(Autó A){
    A.Indít();
  }

mert a különböző megvalósítások átkerültek a különböző kiszolgáló-osztályokba:

  abstract class Autó{
    void Indít();
    //...
  }

  class AutomataAutó extends Autó{
    void Indít(){
      SebességbeTesz();
    }
    //...
  }

  class NormálAutó extends Autó{
    void Indít(){
      KuplungotLenyom();
      SebességbeTesz();
      GáztAd();
      KuplungotFelenged();
    }
    //...
  }



Az objektum típusa nagyon statikus jellegű, egyáltalán nem változik meg az objektum élete során – éppen ezért az esetek többségében nem is túl jó ötlet a típust lekérdezni és aszerint a kliens oldalon elágazni, hanem sokkal jobb a különböző fajta objektumokba elrejteni a különböző fajta viselkedést. Nem érdemes valami ilyen módon vezérelni a működést a kliens oldalon:

"Ha ez az objektum A típusú, akkor viselkedjen A típusúként, ha pedig B típusú, akkor viselkedjen B típusúként."

Így csak mintegy vizet hordunk a Dunába, hiszen a polimorfizmus segítségével ez magától meg tud valósulni, a kliens mindenféle közreműködése nélkül!

Szinte minden, a polimorfizmusban még járatlan programozó eleinte a kliens-oldalra próbálja meg beépíteni az összes vezérlést, olyan elágazásokat is, mint a fentebb bemutatott. Ez a hozzáállás természetes, hiszen a polimorfizmus nélkül nincs is más megoldás – és az első programjait nyilván mindenki még ezen eszköz ismerete nélkül írja meg. Ráadásul szeretjük látni, hogy minden lépésben mi történik, márpedig a polimorf megoldásnál a kliens oldalon ez nem teljesül, ami eleinte, az új módszerrel való ismerkedéskor kifejezetten idegesítő. Éppen ezért fontos hangsúlyozni:

Ha objektum-típus szerinti elágazást látunk, mindig tegyük fel a kérdést magunknak: Nem lehetne-e ezt az elágazást a polimorfizmusra bízni?

Az objektum-típus szerinti elágazások túlnyomó többsége
  • felesleges, sőt
  • kifejezetten káros!

Azért mondható károsnak, mert így a változó részeket a kliens oldalra tesszük, ahelyett, hogy a különböző fajta kiszolgáló osztályokba 'dobnánk szét'.

Gondoljunk csak bele, hogy mi történik, amikor egy újabb autótípust is modelleznünk kell! Ha a kliens oldalon van az elágazás, akkor az elágazásokat mindenhol ki kell egészítenünk. Tegyük fel például, hogy az eddig használt automata modell magától lassan elindult, amikor sebességbe tettük, az újabb fajta viszont csak akkor, ha gázt adunk:

  void AutótIndít(Autó A){
    if(A.automata()){
      A.SebességbeTesz();
    }
    else
    if(A.automata_2()){ //új
      A.SebességbeTesz();
      A.GáztAd();
    }
    else{
      A.KuplungotLenyom();
      A.SebességbeTesz();
      A.GáztAd();
      A.KuplungotFelenged();
    }

Amennyiben viszont a polimorfizmust kihasználva oldjuk meg a feladatot, akkor csak egy újabb kiszolgáló-típus keletkezik (Automata_2) és a kliens oldalon semmi nem változik! Márpedig ez hosszú távon nyilván óriási előny, mert:
  1. nem kell végigkeresgélnünk minden programmodult az elágazás után kutatva és
  2. nem áll fenn a veszély, hogy valamelyik helyen elmarad a kiegészítés, vagy hibásan történik meg.
Kliens-oldali elágazással így nézett ki a program:


Átalakítás után viszont így:


A kliens oldala hála a polimorfizmus elágazás-eltüntető bűvésztudományának.

Elágazás statikus jellegű állapot függvényében

A 'statikus jellegű' azt jelenti, hogy az állapot előre tudhatóan huzamosan (akár a program teljes futása alatt) fennáll. Azaz tulajdonképpen gyakorlatilag ugyanarról van szó, mint a típus szerinti elágazással kapcsolatban: A kliens oldali elágazás egyes ágait áthelyezzük különböző fajta objektumokba és a polimorfizmusra bízzuk a választást.

Első példaként képzeljünk el egy csavar-lazító gépet, mellyel nagy hidegben is lehet dolgozni, de akkor először jégoldót kell alkalmazni és csak azután lehet elvégezni a tényleges kicsavarozást:

  Csavarozó cs = new Csavarozó();

  if(HidegVan){
    cs.JégoldótKen();
    cs.Kicsavar();
  }
  else{
    cs.Kicsavar();
  }

Ez a megoldás azért nem szép, mert a csavarozáshoz szükséges tudás egy része átkerült a kliens oldalára. Az, hogy hideg van, nyilván viszonylag hosszabb ideig fennáll, ezért itt szóba jöhet a következő megoldás: Kétféle géptípust definiálunk, az egyik A hidegben használandó típust most a normál gépből örököltetjük:

  class Csavarozó{
    public void KiCsavar(){
      System.out.println("Kihajtom a csavart");
    }
  }
  
  class HidegbenCsavarozó extends Csavarozó{
    public void KiCsavar(){
      System.out.println("Jégoldót kenek");
      super.KiCsavar();
    }
  }

A kliens oldalon most aszerint, hogy hideg van-e vagy az egyik, vagy a másik típusú szerszámot hozzuk létre:

  Csavarozó cs;

  if(HidegVan){
    cs = new HidegbenCsavarozó();
  }
  else{
    cs = new Csavarozó();
  }

és a program többi részében viszont már nem törődünk avval, milyen konkrét típusú eszközünk van, hanem mindig csak ennyit mondunk:

  cs.KiCsavar();

Tehát most is ugyanazt az eljárást használtuk, ami már az objektum típusa szerinti elágazásoknál bevált: az objektum alkalmazásának helyeiről a kliens-oldali döntést, elágazást az objektumkészítés helyére tettük át.



Nézzünk meg egy másik, a gyakorlathoz közelebb álló mintapéldát is, a nyomkövetés esetét.

Van egy Valami típusú objektumunk, melynek számos metódusát sok helyen igénybe vesszük. Az egyszerűség kedvéért most csak egyetlen metódust és annak egyetlen hívását mutatjuk:

  class Valami{
    public void Metódus_1(){
      System.out.println("Metódus_1 működik");
    }
  }
  //...

  Valami v = new Valami();
  //...
  v.Metódus_1();

A program használata közben kiderül, hogy valahol valami nehezen felderíthető hiba van. Szükségünk van a metódushívások helyén információkat írni egy fájlba, hogy a program lefutása után a fájl tartalmát kielemezve rájöhessünk a hiba okára. Persze csak akkor akarunk a fájlba írni, amikor nyomkövető üzemmódban indítjuk a programot.

Első (nem nagyon ügyes) megoldásunknál úgy járunk el, hogy magukat a metódusokat egészítjük ki a naplózással:

  class Valami{
    public void Metódus_1(){
      if(nyomkövetés){
        /* fájlba naplózunk */ 
      }
      System.out.println("Metódus_1 működik");
    }
  }

Nyilvánvalóan, ha később meg akarjuk ismételni a kísérletet, akkor vagy benne kell hagyni a programban a feltételes utasításokat (ami által igen rosszul olvasható lesz) – vagy újra be kell illesztenünk őket, ha közben töröltük.

A polimorfizmus segítségével elkerülhetjük a problémákat: Valami-ből örököltetünk egy osztályt és ennek a metódusaiba írjuk bele a naplózást. A feltételes elágazás ismét csak egyetlen helyen fog jelentkezni, ott, ahol létrehozzuk az objektumot:

  class Valami{     // nem változtatjuk!
    public void Metódus_1(){
      System.out.println("Metódus_1 működik");
    }
  }
  
  class NyomkövetőValami extends Valami{
    public void Metódus_1(){
      /* fájlba naplózunk */
      super.Metódus_1();
    }
  }
  //...
  Valami v = new NyomkövetőValami();
  
  v.Metódus_1();  //itt megtörténik a naplózás

Az összes olyan helyen, ahol használjuk az objektumot semmit nem kell változtatnunk, ráadásul az 'üzemszerűen' használt osztályhoz sem kellett hozzányúlnunk. A polimorfizmus ismét mintegy becsempészett a programunkba egy láthatatlan elágazást.

Kiegészítő megjegyzés: Ezt az örököltetős módszert akkor is alkalmazni tudjuk, amikor a Valami osztályt hiába is akarnánk, nem vagyunk képesek változtatni, mert a forráskódja nem áll rendelkezésünkre.

És végül, jutalmul azoknak, akik egész idáig végigolvasták ezt a hosszú okfejtést:

Nyúlfarknyi mese a polimorfizmusról

Kliens úr (Polimorfia grófja) kiadja az utasítást, hogy jöjjön a borbély és nyírja meg. Azzal nem foglalkozik, miként hajtja ezt végre a szakember, az az ő dolga - és a polimorfizmus miatt azzal sem kell foglalkoznia, hogy milyen minősítésű szakember az. Ha holnaptól egy mesterfodrász áll rendelkezésre, akkor majd az jön; Kliens úr ezentúl is csak annyit mond: "Nyiratkozni szeretnék!". És élvezi a kiváló új mester munkáját.

Ám egy napon ellenségei összeesküdnek ellene, felbérelnek egy orgyilkost, hogy implementálja a megfelelő I_Borbély interfészt és becsempészik a gróf házába. Amikor legközelebb Kliens úr nyiratkozni kíván, hívására az új borbély jelenik meg, akit egy igazitól nem lehet megkülönböztetni az azonos interfész miatt. Ám az új borbély ugyanazon függvényhívás hatására egészen máshol nyiszál, mint a régi...

S itt bizony nemcsak a mesének, de Kliens úrnak is vége.


A polimorfizmusról általában
  Általános szoftver ismeretek