Objektumok: jöjjön a Java!

Már többször beszéltem az adattípusokról: általában nem teljesen egyedi, máshoz nem hasonlító létezők vannak a virtuális világunkban, hanem sokszor van dolgunk egymásra nagyon hasonlító lényekkel, amikkel ugyanazokat a dolgokat lehet csinálni. Ilyenek azok, amiket „a valóságos világból” is ismerünk, például az egész számok vagy a betűsorok (stringek), ezeket a programozási nyelvek, rendszerek alapból tudják valahogy kezelni. Másokat, például a kártyalapokat (amikre az jellemző, hogy színük és lapértékük van), mi magunk definiálunk azzal, hogy leírjuk, hogy hogyan akarjuk tárolni őket a memóriában. Ez azt jelenti, hogy minden egyes példányukhoz (instanciájukhoz) ugyanolyan ábrázolás fog tartozni (kártyáknál ez például lehet két egész szám, az egyik a lap színét, a másik a lapértékét kódolja), persze más-más tartalommal. (Jegyezzük meg, hogy más az, hogy két különböző lényről beszélünk, és más az, hogy különböző a tartalmuk: ha több pakli kártyánk van, még az is lehet, hogy két különböző instanciának ugyanaz a színe és lapértéke, mert lehet például két káró bubi is a pakliban.)

Az adattípus tehát egyfajta absztrakció, elvonatkoztatunk a konkrét instanciák konkrét tartalmától, és azt ragadjuk meg, ami közös bennük. Ugyanígy absztrakciók az interfészek, róluk szintén beszéltem már, ezek azt írják le, hogy egy-egy modul konkrét megvalósításaiban, implementációiban mi a közös, azok milyen szolgáltatásokat nyújtanak.

Az absztrakció, az elvonatkoztatás már csak olyan, hogy fokozatai vannak. Például a (valós) szám fogalma (ha tetszik: adattípusa) rettentő elvont, több altípusa van. Leggyakrabban egész és nem-egész számokra, vagy negatív és nem-negatív számokra osztjuk őket, de persze ezek is absztrakciók, csak szűkebbek. És ezeken belül is lehetnek altípusok, például a nem-egész számokon belül nevezetesek a racionális (két egész szám hányadosaként felírható) és az irracionális számok. (A számítástechnikában, mivel a számok leggyakrabban rögzített méretű helyet foglalnak el a memóriában, nem pont ezek a legfontosabb altípusok, így például minden nem-egész számot valahány tizedes jegyre kerekítve, racionális számmal kell ábrázolni.) A különböző altípusoktól viszont élesen megkülönböztetjük a konkrét számokat, ezeket már nem absztrakcióknak, hanem valamelyik típus (altípus) instanciáinak tekintjük.

A másik fontos tulajdonságuk az absztrakcióknak, hogy ami rájuk érvényes (illetve amit velük lehet csinálni), az minden altípusukra is érvényes (illetve azt minden altípusukkal meg lehet csinálni). Például a nem-negatív számoknak van (valós) négyzetgyökük, tehát az altípusaiknak, a nem-negatív egészeknek meg a nem-negatív nem-egészeknek is van. A példából is látszik, hogy a fordítottja nem feltétlenül igaz, mert a számoknak, amiknek a nem-negatívak az altípusai, nem feltétlenül van valós négyzetgyökük (a negatívoknak ugyanis nincs). Ezt a jelenséget úgy szoktuk megfogalmazni, hogy a típusok tulajdonságai öröklődnek, az altípusaik is, meg az instanciák is megöröklik őket.

Tanuljunk absztrahálni!

A számítástudománynak – és így a programozási nyelvek világának – ma nagyon elterjedt, mondhatnám: uralkodó irányzatában a programok fő szervező elve az absztrakció. A típusokat és az altípusokat osztálynak (class) nevezik, az instanciákat pedig objektumnak (object). Ez utóbbi miatt ezt a paradigmát objektumorientált programozásnak nevezik. Ráadásul ennek a paradigmának az a „filozófiája”, ahogy mondani szokták, hogy minden absztrakciót, akár adattípus-, akár moduljellegű, ugyanolyan dologgal: osztállyal lehet leírni. Sőt, a két dolgot nem is kell megkülönböztetni: az adattípusok és az instanciáik magukban hordozhatják azokat a szolgáltatásokat, amik hozzájuk kapcsolódnak. Minden osztály jellemezhető ezekkel a szolgáltatásokkal, amiket hivatalosan a tagjainak (member) nevezünk. Ezek közül a külvilág számára láthatóak alkotják az interfészét; azokat a tagjait, amelyek procedúra- (vagy függvény-) jellegűek, módszereknek (method) nevezik.

Érdemes rákeresni a neten, se szeri, se száma azoknak az írásoknak, amelyek az objektumorientált paradigmát szapulják, elméleti és gyakorlati gyengeségeire rámutatnak, de minket ez most nem kell hogy érdekeljen. A lényeg az, hogy a legtöbb mai programozási nyelv lehetővé teszi, vagy egyenesen előírja, hogy ezt a paradigmát kövessük, ezért legalább felületesen meg kell ismerkednünk vele.

Grafika: Tóth Róbert Jónás

A Java nyelvet fogjuk használni. Ez kötelezően objektumorientált nyelv, vagyis az osztályok használata nem megkerülhető. (Az objektumorientált megközelítést a múltkor használt Python nyelvben is használhatjuk, de ott nem kötelező.) Magáról a nyelvről csak felületesen mondok el annyit, hogy a fő konvenciói megegyeznek mondjuk a C vagy a C++ nyelvben használtakkal. Az adatokat előre deklarálni kell (nem úgy, mint a Python nyelvben). Az utasítások sorozatai (az ún. blokkok) kapcsos zárójelek („{” és „}”) között állnak, a kommentárokat pedig itt „//” jelöli, ettől a jeltől kezdve a sor végéig minden csak kommentár. (Egy másik lehetőség, hogy a kommentünket „/*” és „*/” jelek közé tesszük, vagy ha komolyabb kommentről van szó, amit a program dokumentációjába szánunk, akkor a „/*” helyett a „/**” jelet használjuk.) Minden osztályt egy-egy külön file-ban fogunk leírni, és a file-t az illető osztályról fogjuk elnevezni.

Hozzunk létre például egy Lap.java nevű file-t, és definiáljuk benne a Lap nevű osztályt. A file tartalma valahogy így fog kinézni:

  class Lap
   {
       // ...
   }

Ahogy mondtam, az objektumorientált programozásban az osztályok egyszerre adattípusok is (tudnak lenni), meg a modulok szerepét is játsszák. Az adattípus-jellegüket az kölcsönzi nekik, hogy létre tudunk hozni olyan objektumokat, amik a Lap osztály instanciái (egyes konkrét kártyalapokat ábrázolnak), és amiket az osztályhoz tartozó tulajdonságok szempontjából jellemzünk (szín, lapérték).

Tegyük fel, hogy a színeket is és a lapértékeket is egész számokkal ábrázoljuk. Akkor a Lap osztályba tartozó minden objektumot két egész szám fog jellemezni:

  class Lap
   {
       private int szin;
       private int ertek;
       // ...
   }

A deklarációk elején a private szó azt jelenti, hogy senkinek semmi köze ahhoz, hogy ezekkel az adatokkal jellemzünk minden kártyalapot – csak a Lap osztály tagjai láthatják őket és az egyes instanciákon belül az értéküket.

Annak, hogy az univerzumunkban létrehozzunk egy új kártyalapot, csak úgy van értelme, ha az új lapnak lesz valamilyen színe és lapértéke, mert szín és érték nélküli lapoknak nincs értelmük. (A Java nyelvben véletlenül van alapértelmezésük az egész számokat jelölő szimbólumoknak: mind a szin, mind az ertek szimbólum, ahogy fent deklaráltuk, a 0 értéket kapja, de erre nem illik támaszkodni.) Ezért az a korrekt eljárás, hogy a Lap osztálynak legyen egy olyan tagja (közelebbről módszere), ami megmondja, hogy egy új kártyalap létrehozásakor mi a teendő. Az ilyen eljárásokat konstruktornak nevezzük. A Java nyelvben az a szabály, hogy a konstruktorokat ugyanúgy kell nevezni, mint magát az osztályt, kívülről láthatónak (public) kell lenniük, visszatérési értékük pedig nincs, szóval elég speciálisak:

  class Lap
   {
       private int szin;
       private int ertek;
       // Konstruktor:
       public Lap( int sz, int e )
       {
           szin = sz;
           ertek = e;
       }
       // ...
   }

Ezek után a programunkban valahol máshol leírhatjuk a következő sort:

  Lap lap = new Lap( 0, 0 );

Ez azt jelenti, hogy a lap szimbólumot deklaráljuk, a neve elé írva a típusát (hiszen a Lap mint osztály egyben egy új adattípus), és az egyenlőségjel után az az utasítás áll, ami egy Lap típusú új objektum létrehozásának felel meg, a new szó és a fent definiált konstruktor alkalmazásával. Hogy a 0 milyen színt és milyen lapértéket jelöl, az teljesen rajtunk múlik, később eldönthetjük, hogy hogy számozzuk be a színeket és a lapértékeket.

A Lap osztály belügyei

Amit eddig leírtam, az működik, hiba nincs benne, de egy abszurditás azért van: ha egyszer senkinek semmi köze ahhoz, hogy hogyan kódoljuk a lapokat, akkor miért tudnánk az új lap létrehozásakor, a konstruktor alkalmazásakor, hogy éppen egész számok fogják ezeket jelölni, és pláne, hogy milyen számok milyen színnek meg lapértéknek felelnek meg? Ez így nem maradhat. Azt szeretnénk, ha az új lapok létrehozása így történhetne:

  Lap pikkBubi = new Lap( Lap.PIKK, Lap.BUBI );

Vagyis azt szeretnénk, ha a Lap osztálynak – most modulként tekintve rá – lenne egy olyan szolgáltatása, ami a PIKK, a BUBI stb. szimbólumok használatát lehetővé tenné a színek és a lapértékek jelölésére. Nem akarunk mi belelátni, hogy milyen fajta adatok ezek, az a Lap osztály belügye, de használni akarjuk őket. Hogy ez lehetséges legyen, arról nyilván az osztály definíciójában kell gondoskodnunk.

  class Lap
   {
       public static int TREFF = 0;
       public static int KARO = 1;
       public static int KOR = 2;
       public static int PIKK = 3;
       public static int KETTES = 0;
       // ...
       public static int BUBI = 9;
       // ....
   }

Mit jelent ez? A sorok elején a public azt jelenti, hogy nyilvános, az osztályon kívülről igénybe vehető szolgáltatásokról van szó. A static pedig azt jelenti, hogy ezek magának a Lap osztálynak a szolgáltatásai, nem pedig az egyes kártyalapoknak (vagyis az instanciáknak). Az osztálynak ezek a „statikus” tagjai tehát az osztály modul-jellegének felelnek meg.

Az osztályok kétarcúsága miatt sokszor kétféleképpen is elérhetjük ugyanazt a hatást, mert ugyanazt az eljárást az osztályhoz is, meg az egyes objektumokhoz is társíthatjuk. Például ha olyan eljárást szeretnénk, ami egy Lap típusú objektumból előállítja az illető kártyalapot ábrázoló „nyomtatási képet”, vagyis betűsort (stringet), például a káró dámához a „♢D” stringet rendeli, akkor ezt megtehetjük úgy, hogy minden objektumnak a saját szolgáltatása lesz ez az eljárás, és úgy is, hogy magának az osztálynak lesz a szolgáltatása (a konkrét lapot pedig, aminek a képét meg akarjuk kapni, argumentumként kell megadni). Tehát:

  class Lap
   {
       // ...
       public String kep()
       {
           // ...
       }
       // ...
   }

vagy pedig

  class Lap
   {
       // ...
       public static String kep( Lap lap )
       {
           // ...
       }
       // ...
   }

Az első esetben azt kapjuk, hogy ha van egy l szimbólumunk, ami a Lap osztálynak egy instanciáját jelöli, akkor leírhatjuk azt, hogy l.kep(), és ennek értéke az a string lesz, amit az eljárásunk értékként produkál. Ennek az első eljárásnak a törzsében egyszerűen a szin és az ertek szimbólumok jelölik az illető lap színét és értékét, míg a második esetben ezeket a lap.szin és lap.ertek kifejezésekkel kapjuk meg.

Fejezzük be ezt a részt azzal, hogy a fenti kep függvényt megírjuk. A következő részben pedig majd az egész programot befejezzük.

  class Lap
   {
       // ...
       private static String[] szinek = { "♣", "♢", "♡", "♠" };
       private static String[] ertekek =
           { "2", "3", "4", "5", "6", "7", "8", "9", "10"
             , "B", "D", "K", "A" };
       // ...
       public String kep()
       {
           return( szinek[szin] + ertekek[ertek] );
       }
       // ...
   }

Ebben már nem sok újdonság van, legfeljebb annyi, hogy a szinek és az ertekek szimbólumok, amik típusukra nézve stringekből álló tömbök (ezt a típust jelöli a „String[]”), és hogy az elemeiket „{” és „}” közötti felsorolással is meg lehet adni. A „+” jel pedig, ahogy a Python nyelvű példában is láttuk, a stringek egymás után fűzését is jelölheti.

A szerző nyelvész, az MTA Nyelvtudományi Intézetének főmunkatársa.