Ugrás a tartalomhoz

Objektum orientált szoftverfejlesztés

Kondorosi Károly, Szirmay-Kalos László, László Zoltán

ComputerBooks Kft.

6.7. Öröklődés

6.7. Öröklődés

Az öröklődés objektumtípusok között fennálló speciális kapcsolat, amely az analízis során akkor kerül felszínre, ha egy osztály egy másik általánosításaként, vagy megfordítva a másik osztály az egyik specializált változataként jelenik meg. A fogalmakat szemléltetendő, tekintsük a következő osztályokat, melyek egy tanulócsoportot kezelő program analízise során bukkanhatnak fel.

6.17. ábra: Egy tanulócsoport objektumtípusai.

6.17. ábra: Egy tanulócsoport objektumtípusai.

Egy oktatási csoportban diákok és tanárok vannak. Közös tulajdonságuk, hogy mindnyájan emberek, azaz a diák és a tanár az ember speciális esetei, vagy fordítva az ember, legalábbis ebben a feladatban, a diák és tanár közös tulajdonságait kiemelő általánosító típus. Szokás ezt a viszonyt "az egy" (IS_A) relációnak is mondani, hiszen ez, beszélt nyelvi eszközökkel tipikusan úgy fogalmazható meg, hogy:

a diák az egy ember, amely még ...

a tanár az (is) egy ember, amely még ...

A három pont helyére a diák esetében az átlageredményt és az évfolyamot, míg a tanár esetében a fizetést és az oktatott tárgyat helyettesíthetjük.

Ha ezekkel az osztályokkal programot kívánunk készíteni, arra alapvetően két eltérő lehetőségünk van.

  • 3 darab független osztályt hozunk létre, ahol az egyik az általános ember fogalomnak, a másik a tanárnak, míg a harmadik a diáknak felel meg. Sajnos ekkor az emberhez tartozó felelősségek, pontosabban a programozás szintjén a tagfüggvények, háromszor szerepelnek a programunkban.

  • A másik lehetőség a közös rész kiemelése, melyet az öröklődéssel (inheritance) történő definíció tesz lehetővé. Ennek lépései:

    1. Ember definíciója. Ez az ún. alaposztály (base class).

    2. A diákot úgy definiáljuk, hogy megmondjuk, hogy az egy ember és csak az ezen felül lévő új dolgokat specifikáljuk külön: Diák = Ember + valami (adatok, műveletek)

    3. Hasonlóképpen járunk el a tanár megadásánál is. Miután tisztázzuk, hogy annak is az Ember az alapja, csak az tanár specialitásaival kell foglalkoznunk: Tanár = Ember + más valami

Ennél a megoldásnál a Diák és a Tanár származtatott osztályok (derived class).

Az öröklődéssel történő megoldásnak számos előnye van:

  • Hasonlóság kiaknázása miatt a végleges programunk egyszerűbb lehet. A felesleges redundanciák kiküszöbölése csökkentheti a programozási hibák számát. A fogalmi modell pontosabb visszatükrözése a programkódban világosabb programstruktúrát eredményezhet.

  • Ha a későbbiekben kiderül, hogy a programunk egyes részein az osztályhoz tartozó objektumok működésén változtatni kell (például olyan tanárok is megjelennek, akik több tárgyat oktatnak), akkor a meglévő osztályokból származtathatunk új, módosított osztályokat. A származtatás átmenti az idáig elvégzett munkát anélkül, hogy egy osztály, vagy a program egyéb részeinek módosítása miatt a változtatások újabb hibákat ültetnének be programba.

  • Lényegében az előző biztonságos programmódosítás "ipari" változata az osztálykönyvtárak felhasználása. A tapasztalat azt mutatja, hogy egy könyvtári elem felhasználásának gyakori gátja az, hogy mindig "csak egy kicsivel" másként működő dologra van szükség mint ami rendelkezésre áll. A függvényekből álló hagyományos könyvtárak esetében ekkor meg is áll a tudomány. Az öröklődésnek köszönhetően az osztálykönyvtárak osztályainak a viselkedése rugalmasan szabályozható, így az osztálykönyvtárak a függvénykönyvtárakhoz képest sokkal sikeresebben alkalmazhatók. Ezen és a megelőző pontot összefoglalva kijelenthetjük, hogy az öröklődésnek, az analízis modelljének a pontos leképzésén túl egy fontos felhasználási területe a programelemek újrafelhasználhatóságának (software reuse) támogatása, ami az objektumorientált programozásnak egyik elismert előnye.

  • Végül, mint látni fogjuk, egy igen hasznos programozástechnikai eszközt, a különböző típusú elemeket egyetlen csoportba szervező és egységesen kezelő heterogén szerkezetet, ugyancsak az öröklődés felhasználásával valósíthatunk meg hatékonyan.

6.7.1. Egyszerű öröklődés

Vegyük először a geometriai alakzatok öröklődési példáját. Nyilván minden geometriai alakzatban van közös, nevezetesen azok a tulajdonságok és műveletek, amelyek a geometriai alakzatokra általában érvényesek. Beszélhetünk a színükről, helyükről és a helyet megváltoztató mozgatásról anélkül, hogy a geometriai tulajdonságokat pontosabban meghatároznánk. Egy ilyen általános geometriai alakzatot definiáljuk a Shape osztállyal. Az egyes tényleges geometriai alakzatok, mint a téglalap (Rect), a szakasz (Line), a kör (Circle) ennek az általános alakzatnak a speciális esetei, azaz kézenfekvő az ezeket szimbolizáló osztályokat a Shape származtatott osztályaiként definiálni. A Shape tulajdonságaihoz képest, a téglalap átellenes sarokponttal, a kör sugárral, a szakasz másik végponttal rendelkezik, és mindegyikhez tartozik egy új, osztályspecifikus rajzoló (Draw) metódus, amely az adott objektumot a konkrét típusnak megfelelően felrajzolja. A mozgatásról (Move) az előbb megjegyeztük, hogy mivel a helyhez kapcsolódik tulajdonképpen az általános alakzat része. A mozgatás megvalósítása során először a régi helyről le kell törölni az objektumot (rajzolás háttérszínnel), majd az új helyen kell megjeleníteni (ismét Draw). Természetesen a Draw nem általános, hanem a konkrét típustól függ így a Move tagfüggvény Shape-ben történő megvalósítása sem látszik járhatónak. Hogy megvalósítható-e vagy sem a közös részben, az egy igen fontos kérdés lesz. Egyelőre azonban tekintsük a Move tagfüggvényt is minden osztályban külön megvalósítandónak.

Az öröklődési gondolatot tovább folytatva felvethetjük, hogy a téglalapnak van egy speciális esete, a négyzet (Square), amit célszerűnek látszik a téglalapból származtatni. Ha meg akarnánk mondani, hogy a négyzet milyen többlet attribútummal és művelettel rendelkezik a téglalaphoz képest, akkor gondban lennénk, hiszen az éppenhogy csökkenti a végrehajtható műveletek számát illetve az attribútumokra pótlólagos korlátozásokat (szemantikai szabályok) tesz. Például egy téglalapnál a két sarokpont független változtatása teljesen természetes művelet, míg ez a négyzetnél csak akkor engedhető meg, ha a függőleges és vízszintes méretek mindig megegyeznek.

6.18. ábra: Geometriai alakzatok öröklési fája.

6.18. ábra: Geometriai alakzatok öröklési fája.

Ezek szerint a négyzet és a téglalap kapcsolata alapvetően más, mint például az alakzat és a téglalap kapcsolata. Az utóbbit analitikus öröklődésnek nevezzük, melyre jellemző, hogy az öröklődés új tulajdonságokat ad az alaposztályból eredő tulajdonságokhoz anélkül, hogy az ott definiáltakat csorbítaná. A négyzet és a téglalap kapcsolata viszont nem analitikus (ún. korlátozó) öröklődés, hiszen ez letiltja, vagy pedig korlátozva módosítja az alaposztály bizonyos műveleteit.

Ha egy adott szituációban kétségeink vannak, hogy milyen öröklődésről van szó, használhatjuk a következő módszert az analitikus öröklődés felismerésére: "Az A osztály analitikusan származtatott osztálya B-nek, ha A típusú objektumot adva egy olyan személynek, aki azt hiszi, hogy B típusút kap, ez a személy úgy fogja találni, hogy az objektum valóban B típusú miután elvégezte a feltételezése alapján végrehajtható teszteket". Kicsit formálisabban fogalmazva: analitikus öröklődés esetén az A típusú objektumok felülről kompatibilisek lesznek a B osztályú objektumokkal, azaz A metódusai B ugyanezen metódusaihoz képest a bemeneti paraméterekre vonatkozó előfeltételeket (prekondíciót) legfeljebb enyhíthetik, míg a kimeneti eredményekre vonatkozó megkötéseket (posztkondíciót) legfeljebb erősíthetik. A nem analitikus öröklődés ilyen kompatibilitást nem biztosít, melynek következtében a programozóra számos veszély leselkedhet az implementáció során. Mint látni fogjuk a C++ biztosít némi lehetőséget ezen veszélyek kivédésére (privát alaposztályok), de ezekkel nyilván csak akkor tudunk élni, ha az ilyen jellegű öröklődést felismerjük. Ezért fontos a fenti fejtegetés. Ennyi filozófia után rögvest felmerül a kérdés, hogy használhatjuk-e a nem analitikus öröklődést az objektumorientált modellezésben és programozásban. Bár a szakma meglehetősen megosztott ebben a kérdésben, mi azt a kompromisszumos véleményt képviseljük, hogy modellezésben lehetőleg ne használjuk, illetve ha szükséges, akkor azt tudatosan, valamilyen explicit jelöléssel tegyük meg. Az implementáció során ezen kompromisszum még inkább az engedékenység felé dől el, egyszerűen azért, mert elsősorban a kód újrafelhasználáskor vannak olyan helyzetek, mikor a nem analitikus öröklődés jelentős programozói munkát takaríthat meg. A kritikus pont most is ezen szituációk felismerése, hiszen ez szükséges ahhoz, hogy élni tudjunk a veszélyek csökkentésére hivatott lehetőségekkel.

A modellezési példák után rátérhetünk az öröklődés C++-beli megvalósítására. Tekintsük először a geometriai alakzatok megvalósításának első kísérletét, melyben egyelőre csak a Shape és a Line osztályok szerepelnek:


class Shape {
protected:
   int x, y, col;
public: 
      Shape( int x0, int y0, int col0 ) 
         { x = x0; y = y0; col = col0; }
   void	SetColor( int c ) { col = c; }
};
class Line : public Shape { // Line = Shape + ...
      int xe, ye;
   public:         
         Line( int x1, int y1, int x2, int y2, int c )
         : Shape( x1, y1, c ) { xe = x2, ye = y2 }
      void	Draw( );
      void	Move( int dx, int dy );
   };

void Line :: Draw( ) {
   _SetColor( col ); // rajz a grafikus könyvtárral
   _MoveTo( x, y ); 
   _LineTo( xe, ye );
}

void Line :: Move( int dx, int dy ) {
   int cl = col; 	// tényleges rajzolási szín elmentése
   col = BACKGROUND;	// rajzolási szín legyen a háttér színe    
   Draw( ); 		// A vonal letörlés az eredeti helyről
   x += dx; y += dy;  // mozgatás: a pozíció változik
   col = cl;		// rajzolási szín a tényleges szín
   Draw( ); 		// A vonal felrajzolása az új pozícióra
}

A programban számos újdonsággal találkozunk:

  • Az első újdonság a protected hozzáférés-módosító szó a Shape osztályban, amely a public és private definíciókhoz hasonlóan az utána következő deklarációkra vonatkozik. Ennek szükségességét megérthetjük, ha ránézünk a származtatott osztály (Line) Move tagfüggvényének implementációjára, amelyben a helyzet információt (x,y) nyilván át kell írni. Egy objektum tagfüggvényéből (mint a Line::Move), ismereteink szerint nem férhetünk hozzá egy másik típus (Shape) privát tagjaihoz. Ezen az öröklődés sem változtat. Érezhető azonban, hogy az öröklődés sokkal közvetlenebb viszonyt létesít két osztály között, ezért szükségesnek látszik a hozzáférés olyan engedélyezése, amely a privát és a publikus hozzáférés között a származtatott osztályok tagfüggvényei számára hozzáférhetővé teszi az adott attribútumokat, míg az idegenek számára nem. Éppen ezt valósítja meg a védett (protected) hozzáférést engedélyező kulcsszó. Igenám, de annakidején a belső részletek eltakarását és védelmét (information hiding) éppen azért vezettük be, hogy ne lehessen egy objektum belső állapotát inkonzisztens módon megváltoztatni. Ezt a szabályt most, igaz csak az öröklődési láncon belül, de mégiscsak felrúgtunk. Általánosan kimondható tanács a következő: egy osztályban csak azokat az attribútumokat szabad védettként (vagy publikusként) deklarálni, melyek független megváltoztatása az objektum állapotának konzisztenciáját nem ronthatja el. Vagy egyszerűbben lehetőleg kerüljük a protected kulcsszó alkalmazását, hiszen ennek szükségessége arra is utal, hogy az attribútumokat esetleg nem megfelelően rendeltük az osztályokhoz.

  • A második újdonság a Line osztály deklarációjában van, ahol a

    class Line : public Shape { ... }

    sor azt fejezi ki, hogy a Line osztályt a Shape osztályból származtattuk. A public öröklődési specifikáció arra utal, hogy az új osztályban minden tagfüggvény és attribútum megtartja a Shape-ben érvényes hozzáférését, azaz a Line típusú objektumok is rendelkeznek publikus SetColor metódussal, míg az örökölt x,y,col attribútumaik továbbra is védett elérésűek maradnak. Nyilván erre az öröklődési fajtára az analitikus öröklődés implementációja esetén van szükség, hiszen ekkor az örökölt osztály objektumainak az alaposztály objektumainak megfelelő funkciókkal is rendelkezniük kell. Nem analitikus öröklődés esetén viszont éppenhogy el kell takarni bizonyos metódusokat és attribútumokat. Például, ha a feladat szerint szükség volna olyan szakaszokra, melyek színe megváltoztathatatlanul piros, akkor kézenfekvő a Line-ból egy RedLine származtatása, amely során a konstruktort úgy valósítjuk meg, hogy az a col mezőt mindig pirosra inicializálja és a SetColor tagfüggvénytől pedig megszabadulunk. Az öröklődés során az öröklött tagfüggvények és attribútumok eltakarására a private öröklődési specifikációt használjuk. A

    class RedLine: private Line { ... };

    az alaposztályban érvényes minden tagot a származtatott osztályban privátnak minősít át. Amit mégis át akarunk menteni, ahhoz a származtatott osztályban egy publikus közvetítő függvényt kell írnunk, amely meghívja a privát tagfüggvényt. Fontos, hogy megjegyezzük, hogy a származtatott osztályban az alaposztály függvényeit újradefiniálhatjuk, amely mindig felülbírálja az alaposztály ugyanilyen nevű tagfüggvényét. Például a Line osztályban a SetColor tagfüggvényt ismét megvalósíthatjuk esetleg más funkcióval, amely ezek után a Line típusú és minden Line-ból származtatott típusú objektumban eltakarja az eredeti Shape::SetColor függvényt.

  • A harmadik újdonságot a Line konstruktorának definíciójában fedezhetjük fel, melynek alakja:

    Line(int x1, int y1, int x2, int y2, int c)
    : Shape(x1,y1,c) {xe = x2; ye = y2}

    Definíció szerint egy származtatott osztály objektumának létrehozásakor, annak konstruktorának meghívása előtt (pontosabban annak első lépéseként, de erről később) az alaposztály konstruktora is automatikusan meghívásra kerül. Az alaposztály konstruktorának argumentumokat átadhatunk át. A fenti példában a szakasz (Line) attribútumainak egy része saját (xe,ye végpontok), míg másik részét a Shape-től örökölte, melyet célszerű a Shape konstruktorával inicializáltatni. Ennek formája szerepel a példában.

Ezek után kíséreljük meg még szebbé tenni a fenti implementációt. Ha gondolatban az öröklődési lépések felhasználásával definiáljuk a kör és téglalap osztályokat is, akkor megállapíthatjuk, hogy azokban a Move függvény implementációja betűről-betűre meg fog egyezni a Line::Move-val. Egy "apró" különbség azért mégis van, hiszen mindegyik más Draw függvényt fog meghívni a törlés és újrarajzolás megvalósításához (emlékezzünk vissza a modellezési kérdésünkre, hogy a Move közös-e vagy sem). Érdemes megfigyelni, hogy a Move kizárólag a Shape attribútumaival dolgozik, így a Shape-ben történő megvalósítása azon túl, hogy szükségtelenné teszi a többszörös definíciót, logikusan illeszkedik az attribútumokhoz kapcsolódó felelősség elvéhez és feleslegessé teszi az elítélt védett hozzáférés (protected) kiskapu alkalmazását is.

Ha létezne egy "manó", aki a Move implementációja során mindig az objektumot definiáló osztálynak megfelelő Draw-t helyettesítené be, akkor a Move-ot a Shape osztályban is megvalósíthatnánk. Ezt a "manót" úgy hívjuk, hogy virtuális tagfüggvény.

Virtuális tagfüggvény felhasználásával az előző programrészlet lényeges elemei, kiegészítve a Rect osztály definíciójával, a következőképpen festenek:


class Shape {
protected:
   int x, y, col;
public: 
         Shape( int x0, int y0, int col0) 
            { x = x0; y = y0; col = col0; }
   void	SetColor( int c ) { col = c; }
   void	Move( int dx, int dy );
   virtual void Draw( ) { }  
};

void Shape :: Move( int dx, int dy ) {
   int cl = col;     // tényleges rajzolási szín elmentése
   col = BACKGROUND; // rajzolási szín legyen a háttér színe    
   Draw( );          // A vonal letörlés az eredeti helyről
   x += dx; y += dy; // mozgatás: a pozíció változik
   col = cl;         // rajzolási szín a tényleges szín
   Draw( );          // A vonal felrajzolása az új pozícióra
}

class Line : public Shape {   // Line = Shape + ...
   int xe, ye;
public:         
         Line( int x1, int y1, int x2, int y2, int c ) 
            : Shape( x1, y1, c ) { xe = x2, ye = y2;}
   void Draw( );
};

class Rect : public Shape {   // Rect = Shape + ...
   int xc, yc;
public:         
         Rect( int x1, int y1, int x2, int y2, int c )
            : Shape( x1, y1, c ) { xc = x2, yc = y2; }
   void Draw( );
};

Mindenekelőtt vegyük észre, hogy a Move változatlan formában átkerült a Shape osztályba. Természetesen a Move tagfüggvény itteni megvalósítása már a Shape osztályban is feltételezi egy Draw tagfüggvény meglétét, hiszen itt még nem lehetünk biztosak abban, hogy a Shape osztályt csak alaposztályként fogjuk használni olyan osztályok származtatására, ahol a Draw már értelmet kap. Mivel "alakzat" esetén a rajzolás nem definiálható, a Draw törzsét üresen hagytuk, de – és itt jön a lényeg – a Draw függvényt az alaposztályban virtuálisként deklaráltuk. Ezzel aktivizáltuk a "manót", hogy gondoskodjon arról, hogy ha a Shape-ből származtatunk egy másik osztályt ahol a Draw új értelmez kap, akkor már a Shape-ben definiált Move tagfüggvényen belül is az új Draw fusson le. A megvalósítás többi része magáért beszél. A Line és Rect osztály definíciójában természetesen újradefiniáljuk az eredeti Draw tagfüggvényt.

Most nézzünk egy egyszerű rajzoló programot, amely a fenti definíciókra épül és próbáljuk megállapítani, hogy az egyes sorok milyen tagfüggvények meghívását eredményezik virtuálisnak és nem virtuálisnak deklarált Shape::Draw esetén:


main ( ) {
   Rect rect( 1, 10, 2, 40, RED );
   Line line( 3, 6, 80, 40, BLUE );
   Shape shape( 3, 4, GREEN );   // :-(
   shape.Move( 3, 4 );           // 2 db Draw hívás :-(   
   line.Draw( );                 // 1 db Draw
   line.Move( 10, 10 );          // 2 db Draw hívás
   Shape * sp[10];
   sp[0] = &line;                // nem kell típuskonverzió
   sp[1] = ▭ 
   for( int i = 0; i < 2; i++ ) 
     sp[i] -> Draw( );           // indirekt Draw()
}        

A fenti program végrehajtása során az egyes utasítások során meghívott Draw függvény osztályát, virtuális és nem virtuális deklaráció esetén a következő táblázatban foglaltuk össze:

Virtuális Shape::Draw

Nem virtuális Shape::Draw

shape.Move()

Shape::Draw

Shape::Draw

line.Draw()

Line::Draw

Line::Draw

line.Move()

Line::Draw

Shape::Draw

sp[0]->Draw(),

mutatótípus Shape *,

de Line objektumra mutat

Line::Draw

Shape::Draw

sp[1]->Draw(),

mutatótípus Shape *,

de Rect objektumra mutat

Rect::Draw

Shape::Draw

A "manó" működésének definíciója szerint virtuális tagfüggvény esetében mindig abban az osztályban definiált tagfüggvény hívjuk meg, amilyen osztállyal definiáltuk az üzenet célobjektumát. Indirekt üzenetküldés esetén ez a szabály azt jelenti, hogy a megcímzett objektum tényleges típusa alapján kell a virtuális függvényt kiválasztani. (Indirekt üzenetküldés a példában az sp[i]->Draw( ) utasításban szerepel.)

Az összehasonlítás végett nem érdektelen a nem virtuális Draw esete sem. Nem virtuális függvények esetén a meghívandó függvényt a fordítóprogram aszerint választja ki, hogy az üzenetet fogadó objektum, illetve az azt megcímző mutató milyen típusú. Felhívjuk a figyelmet arra, hogy lényeges különbség a virtuális és nem virtuális esetek között csak indirekt, azaz mutatón keresztüli címzésben van, hiszen nem virtuális függvénynél a mutató típusa, míg virtuálisnál a megcímzett tényleges objektum típusa a meghatározó. (Ha az objektum saját magának üzen, akkor ezen szabály érvényesítésénél azt úgy kell tekinteni mintha saját magának indirekt módon üzenne.) Ennek megfelelően az sp[0]->Draw(), mivel az sp[0] Shape* típusú, de Line objektumra mutat, virtuális Draw esetében a Line::Draw-t, míg nem virtuális Draw esetében a Shape::Draw-t hívja meg. Ennek a jelenségnek messzemenő következményei vannak. Az a tény, hogy egy mutató ténylegesen milyen típusú objektumra mutat általában nem deríthető ki fordítási időben. A mintaprogramunkban például a bemeneti adatok függvényében rendelhetjük az sp[0]-hoz a &rect-t és az sp[1]-hez a &line-t vagy fordítva, ami azt jelenti, hogy az sp[i]->Draw()-nál a tényleges Draw kiválasztása is a bemeneti adatok függvénye. Ez azt jelenti, hogy a virtuális tagfüggvény kiválasztó mechanizmusnak, azaz a "manónknak", futási időben kell működnie. Ezt késői összerendelésnek (late binding) vagy dinamikus kötésnek (dynamic binding) nevezzük.

Térjünk vissza a nem virtuális esethez. Mint említettük, nem virtuális tagfüggvények esetében is az alaposztályban definiált tagfüggvények a származtatás során átdefiniálhatók. Így a line.Draw ténylegesen a Line::Draw-t jelenti nem virtuális esetben is.

A nem virtuális esetben a line.Move és shape.Move sorok értelmezéséhez elevenítsük fel a C++ nyelvről C-re fordító konverterünket. A Shape::Draw és Shape::Move közönséges tagfüggvények, amelyet a 6.3.3. fejezetben említett szabályok szerint a következő C program szimulál:


struct Shape { int x, y, col };        // Shape adattagjai

void Draw_Shape(struct Shape * this){} // Shape::Draw
   
void Move_Shape(struct Shape * this,   // Shape :: Move
                int dx, int dy ) {  
   int cl = this -> col;   
   this -> col = BACKGROUND;
   Draw_Shape( this );         
   this -> x += dx;  this -> y += dy; 
   this -> col = cl;         
   Draw_Shape( this ); 
}

Tekintve, hogy a származtatás során a Shape::Move-t nem definiáljuk felül, ez marad érvényben a Line osztályban is. Tehát mind a shape.Move, mind pedig a line.Move (nem virtuális Draw esetén) a Shape::Move metódust (azaz a Move_Shape függvényt) hívja meg, amely viszont a Shape::Draw-t (azaz a Draw_Shape-t) aktivizálja.

A virtuális függvények fontossága miatt szánjunk még egy kis időt a működés magyarázatára. Tegyük fel, hogy van egy A alaposztályunk és egy B származtatott osztályunk, amelyben az alaposztály f függvényét újradefiniáltuk.


class A {
   public:
      void f( );  // A::f
   };

   class B : public A {
   public:
      void f( );  // B::f
};

Az objektumorientált programozás alapelve szerint, egy üzenetre lefuttatott metódust az célobjektum típusa és az üzenet neve (valamint az átadott paraméterek típusa) alapján kell kiválasztani. Tehát ha definiálunk egy A típusú a objektumot és egy B típusú b objektumot, és mindkét objektumnak f üzenetet küldünk, akkor azt várnánk el, hogy az a objektum esetében az A::f, míg a b objektumra a B::f tagfüggvény aktivizálódik. Vannak egyértelmű esetek, amikor ezt a kívánságunkat a C++ fordító program minden további nélkül teljesíteni tudja:


{
   A a;
   B b;
	
   a.f( );  // A::f hívás
   b.f( );  // B::f hívás
}

Ebben a példában az a.f() A típusú objektumnak szól, mert az a objektumot az A a; utasítással definiáltuk. Így a fordítónak nem okoz gondot, hogy ide az A::f hívást helyettesítse be.

A C++ nyelvben azonban vannak olyan lehetőségek is, amikor a fordító program nem tudja meghatározni a célobjektum típusát. Ezek a lehetőségek részint az indirekt üzenetküldést, részint a objektumok által saját maguknak küldött üzeneteket foglalják magukban. Nézzük először az indirekt üzenetküldést:


{
   A a;
   B b;
   A *pa;
	
   if ( getchar() == 'i' )  pa = &a;
   else                     pa = &b;
   pa -> f( );     // indirekt üzenetküldés
}

Az indirekt üzenetküldés célobjektuma, attól függően, hogy a program felhasználója az i billentyűt nyomta-e le, lehet az A típusú a objektum vagy a B típusú b objektum. Ebben az esetben fordítási időben nyilván nem dönthető el a célobjektum típusa. Megoldásként két lehetőség kínálkozik:

  1. Kiindulva abból, hogy a pa mutatót A* típusúnak definiáltuk, jelentse ilyen esetben a pa->f() az A::f tagfüggvény meghívását. Ez ugyan téves, ha a pa a b objektumot címzi meg, de ennél többre fordítási időben nincs lehetőségünk.

  2. Bízzuk valamilyen futási időben működő mechanizmusra annak felismerését, hogy pa ténylegesen milyen objektumra mutat, és ennek alapján futási időben válasszunk A::f és B::f tagfüggvények közül.

A C++ nyelv mindkét megoldást felkínálja, melyek közül aszerint választhatunk, hogy az f tagfüggvényt az alaposztályban normál tagfüggvénynek (1. lehetőség), vagy virtuálisnak (2. lehetőség) deklaráltuk.

Hasonló a helyzet az "önmagukban beszélő" objektumok esetében is. Egészítsük ki az A osztályt egy g tagfüggvénnyel, amely meghívja az f tagfüggvényt.


class A {
public:
   void f( );  // A::f
   void g( ) { f( ); }
};

class B : public A {
public:
   void f( );  // B::f
};

A B típusú objektum változtatás nélkül örökli a g tagfüggvényt és újradefiniálja az f-et. Ha most egy B típusú objektumnak küldenénk g üzenetet, akkor az saját magának, azaz az eredeti g üzenet célobjektumának küldene f üzenetet. Mivel az eredeti üzenet célja B típusú, az lenne természetes, ha ekkor a B::f hívódna meg. A tényleges célobjektum típusának felismerése azonban nyilván nem végezhető el fordítási időben. Tehát vagy lemondunk erről a szolgáltatásról és az f tagfüggvényt normálnak deklarálva a fordító a legkézenfekvőbb megoldást választja, miszerint a g törzsében mindig az A::f tagfüggvényt kell aktivizálni. Vagy pedig egy futási időben működő mechanizmusra bízzuk, hogy a g törzsében felismerje az objektum tényleges típusát és a meghívandó f-et ez alapján válassza ki.

A rect, line és shape objektumokat használó kis rajzolóprogram példa lehetőséget ad még egy további érdekesség bemutatására. Miként a programsorok megjegyzéseiben szereplő sírásra görbülő szájú figurák is jelzik, nem túlzottan szerencsés egy Shape típusú objektum (shape) létrehozása, hiszen a Shape osztályt kizárólag azért definiáltuk, hogy különböző geometriai alakzatok közös tulajdonságait "absztrahálja", de ilyen objektum ténylegesen nem létezik. Ezt már az is jelezte, hogy a Draw definíciója során is csak egy üres törzset adhattunk meg. (A Shape osztályban a Draw függvényre a virtuáliskénti deklarációjához és a Move-ban való szerepeltetése miatt volt szükség.) Ha viszont már van ilyen osztály, akkor az ismert lehetőségeinkkel nem akadályozhatjuk meg, hogy azt objektumok "gyártására" is felhasználjuk. Azon felismerésre támaszkodva, hogy az ilyen "absztrahált alaposztályoknál" gyakran a virtuális függvények törzsét nem lehet értelmesen kitölteni, a C++ nyelv bevezette a tisztán virtuális tagfüggvények (pure virtual) fogalmát. A tisztán virtuális tagfüggvényekkel jár az a korlátozást, hogy minden olyan osztály (ún. absztrakt alaposztály), amely tisztán virtuális tagfüggvényt tartalmaz, vagy átdefiniálás nélkül örököl, nem használható objektum definiálására, csupán az öröklődési lánc felépítésére alkalmazható. Ennek megfelelően a Shape osztály javított megvalósítása:


class Shape {    // absztrakt: van tisztán virtuális tagfügg.
protected:
   int x, y, col;
public: 
      Shape( int x0, int y0, int col0 )
         { x = x0; y = y0; col = col0; }
   void	SetColor( int c ) { col = c; }
   void	Move( int dx, int dy );
   virtual void Draw( ) = 0;  // tisztán virtuális függv.  
};

Mivel a C++ nyelv nem engedi meg, hogy absztrakt alaposztályt használjunk fel objektumok definiálására, a javított Shape osztály mellett a kifogásolt

Shape shape;

sor fordítási hibát fog okozni.

6.7.2. Az egyszerű öröklődés implementációja (nincs virtuális függvény)

Idáig az öröklődést mint az újabb tulajdonságok hozzávételét, a virtuális függvényeket pedig mint egy misztikus manót magyaráztuk. Itt a legfőbb ideje, hogy megvizsgáljuk, hogy a C++ fordító miként valósítja meg ezeket az eszközöket.

Először tekintsük a virtuális függvényeket nem tartalmazó esetet. A korábbi C++-ról C-re fordító (6.3.3. fejezet) analógiájával élve, az osztályokból az adattagokat leíró struktúra definíciók, míg a műveletekből globális függvények keletkeznek. Az öröklődés itt csak annyi újdonságot jelent, hogy egy származtatással definiált osztály attribútumaihoz olyan struktúra tartozik, ami az új tagokon kívül a szülőnek megfelelő struktúrát is tartalmazza (az pedig az ő szülőjének az adattagjait, azaz végül is az összes ős adattagjai jelen lesznek). A már meglévő függvényekhez pedig hozzáadódnak az újonnan definiáltak. Ennek egy fontos következménye az, hogy ránézve egy származtatott osztály alapján definiált objektum memóriaképére (pl. Line), annak első része megegyezik az alaposztály objektumainak (Shape) memóriaképével, azaz ahol egy Shape típusú objektumra van szükségünk, ott egy Line objektum is megteszi. Ezt a tulajdonságot nevezzük fizikai kompatibilitásnak. A tagfüggvény újradefiniálás nem okoz név ütközést, mert mint láttuk, a névben azon osztály neve is szerepel ahol a tagfüggvényt definiáltuk.

6.19. ábra: Az öröklés az öröklött és az új adattagokat összefűzi.

6.19. ábra: Az öröklés az öröklött és az új adattagokat összefűzi.

6.7.3. Az egyszerű öröklődés implementációja (van virtuális függvény)

Virtuális függvények esetén az öröklődés kissé bonyolultabb. Abban az osztályban ahol először definiáltuk a virtuális függvényt az adattagok kiegészülnek a virtuális függvényekre mutató pointerrel. Ezt a mutatót az objektum keletkezése során mindig arra a függvényre állítjuk, ami megfelel az adott objektum típusának. Ez a folyamat az objektum konstruktorának a programozó által nem látható részében zajlik le.

Az öröklődés során az új adattagok, esetlegesen új virtuális függvények ugyanúgy egészítik ki az alaposztály struktúráját mint a virtuális tagfüggvényeket nem tartalmazó esetben. Ez azt jelenti, hogy ha egy alaposztály a benne definiált virtuális tagfüggvény miatt tartalmaz egy függvény címet, akkor az összes belőle származtatott osztályban ez a függvény cím adattag megtalálható. Sőt, az adattagok kiegészítéséből az is következik, hogy a származtatott osztályban a szülőtől örökölt adattagok és virtuális tagfüggvény mutatók pontosan ugyanolyan relatív elhelyezkedésűek, azaz a struktúra kezdetétől pontosan ugyanolyan eltolással (offset) érhetők el mint az alaposztályban. A származtatott osztálynak megfelelő struktúra eleje az alaposztályéval megegyező szerkezetű (6.20. ábra).

6.20. ábra: A virtuális függvények címe az adattagok között szerepel.

6.20. ábra: A virtuális függvények címe az adattagok között szerepel.

Alapvető különbség viszont, hogy ha a virtuális függvényt a származtatott osztályban újradefiniáljuk, akkor annak a függvény pointere már az új függvényre fog mutatni minden származtatott típusú objektumban. Ezt a következő mechanizmus biztosítja. Mint említettük a konstruktor láthatatlan feladata, hogy egy objektumban a virtuális függvények pointerét a megfelelő függvényre állítsa. Amikor például egy Line objektumot létrehozunk, az adattagokat és Draw függvény pointert tartalmazó struktúra lefoglalása után meghívódik a Line konstruktora. A Line konstruktora, a saját törzsének futtatása előtt meghívja a szülő (Shape) konstruktorát, amely "láthatatlan" részében a Draw pointert a Shape::Draw-ra állítja és a programozó által definiált módon inicializálja az x,y adattagokat. Ezek után indul a Line konstruktorának érdemi része, amely először a "láthatatlan" részben a Draw mezőt a Line::Draw-ra állítja, majd lefuttatja a programozó által megadott kódrészt, amely értéket ad az xe,ye adattagoknak.

Ezek után világos, hogy egy Shape objektum esetében a Draw tag a Shape::Draw függvényre, egy Line típusú objektumban a Line::Draw-ra, míg egy Rect objektumnál a Rect::Draw függvényre fog mutatni.

A virtuális függvény aktivizálását a fordító egy indirekt függvényhívássá alakítja át. Mivel a függvénycím minden származtatott osztályban ugyanazon a helyen van mint az alaposztályban, ez az indirekt hívás független az objektum tényleges típusától. Ez ad magyarázatot az (sp[0]->Draw()) működésére. Ha tehát a Draw() virtuális függvény mutatója az adatmezőket tartalmazó struktúra kezdőcímétől Drawof távolságra van, az sp[i]->Draw() virtuális függvényhívást a következő C programsor helyettesítheti:

( *((char *)sp[i] + Drawof) ) ( sp[i] );

A paraméterként átadott sp[i] változó a this pointert képviseli.

Most nézzük a line.Move() függvényt. Mivel a Move a Line-ban nincs újradefiniálva a Shape::Move aktivizálódik. A Shape::Move tagfüggvénybe szereplő Draw hívást, amennyiben az virtuális, a fordító this->Draw()-ként értelmezi. A Shape tagfüggvényeiből tehát egy C++ -> C fordító az alábbi sorokat állítaná elő (a Constr_Shape a konstruktorból keletkezett függvény):


struct Shape { int x, y, col; (void * Draw)( ); };

void Draw_Shape( struct Shape * this ) { }

void Move_Shape(struct Shape* this, int dx, int dy ) {
   int cl = this -> col;   
   this -> col = BACKGROUND;         
   this -> Draw( this );         
   this -> x += dx; this -> y += dy; 
   this -> col = cl;         
   this -> Draw( this ); 
}

Constr_Shape(struct Shape * this, int x0,int y0,int col0) {         
   this -> Draw = Draw_Shape;         
   this -> x = x0; 
   this -> y = y0; 
   this -> col = col0; 
}

Mivel a line.Move(x,y) hívásból egy Move_Shape(&line,x,y) utasítás keletkezik, a Move_Shape belsejében a this pointer (&line) Line típusú objektumra fog mutatni, ami azt jelenti, hogy a this->Draw végül is a Line::Draw-t (Draw_Line-t) aktivizálja.

Végezetül meg kell jegyeznünk, hogy a tárgyalt módszer a virtuális függvények implementációjának csak az egyik lehetséges megoldása. A gyakorlatban ezenkívül elterjedten alkalmazzák azt az eljárást is, amikor az objektumok nem tartalmazzák az összes virtuális függvény pointert, csupán egyetlen mutatót, amely az osztály virtuális függvényeinek pointereit tartalmazó táblázatra mutat. Ilyen táblázatból annyi példány van, ahány (virtuális függvénnyel is rendelkező) osztály szerepel a programban. A módszer hátránya az ismertetett megoldáshoz képest, hogy a virtuális függvények aktivizálása kétszeres indirekciót igényel (első a táblázat elérése, második a táblázatban szereplő függvény pointer alapján a függvény hívása). A módszer előnye, hogy alkalmazásával, nagyszámú, sok virtuális függvényt használó objektum esetén, jelentős memória megtakarítás érhető el.

6.7.4. Többszörös öröklődés (Multiple inheritence)

Miként az élőlények esetében is, az öröklődés nem kizárólag egyetlen szálon futó folyamat (egy gyereknek tipikusan egynél több szülője van). Például egy irodai alkalmazottakat kezelő problémában szerepelhetnek alkalmazottak (Employee), menedzserek (Manager), ideiglenes alkalmazottak (Temporary) és ideiglenes menedzserek (Temp_Man) is. A menedzserek és ideiglenes alkalmazottak nyilván egyben alkalmazottak is, ami egy szokványos egyszeres öröklődés. Az ideiglenes menedzserek viszont részint ideiglenes alkalmazottak, részint menedzserek (és ezeken keresztül persze alkalmazottak is), azaz tulajdonságaikat két alaposztályból öröklik.

6.21. ábra: Többszörös öröklés.

6.21. ábra: Többszörös öröklés.

Az ilyen többszörös öröklődést hívjuk idegen szóval "multiple inheritance"-nek.

Most tekintsük a többszörös öröklés C++-beli megvalósítását. A többszörös öröklődés szintaktikailag nem jelent semmi különösebb újdonságot, csupán vesszővel elválasztva több alaposztályt kell a származtatott osztály definíciójában felsorolni. Az öröklődés publikus illetve privát jellegét osztályonként külön lehet megadni. Az irodai hierarchia tehát a következő osztályokkal jellemezhető.


class Employee {            // alaposztály
protected:
   char    name[20];        // név
   long    salary;        	// kereset
public:
   Employee( char * nm, long sl ) 
      { strcpy( name, nm ); salary = sl; } 
};
//=====  Manager = Employee + ... =====
class Manager : public Employee { 
   int     level;
public:
   Manager( char * nam, long sal, int lev ) 
   : Employee( nam, sal ) { level = lev; }
};
//=====  Temporary = Employee + ... =====
class Temporary : public Employee { 
   int emp_time;
public:
   Temporary( char * nam, long sal, int time );
   : Employee( nam, sal ) { emp_time = time; }
};

//=====  Temp_man = Manager + Temporary + ... =====
class Temp_Man : public Manager, public Temporary { 
public:
   Temp_Man(char* nam, long sal, int lev, int time)
   : Manager( nam, sal, lev ), 
      Temporary( nam, sal, time ) { }
};

Valójában ez a megoldás egy időzített bombát rejt magában, melyet könnyen felismerhetünk, ha az egyszeres öröklődésnél megismert, és továbbra is érvényben maradó szabályok alapján megrajzoljuk az osztályok memóriaképét (6.22. ábra)

Az Employee adattagjainak a kiegészítéseként a Manager osztályban a level, a Temporary osztályban pedig az emp_time jelenik meg. A Temp_Man, mivel két osztályból származtattuk (a Manager-ből és Temporary-ból), mindkét osztály adattagjait tartalmazza, melyhez semmi újat sem tesz hozzá. Rögtön feltűnik, hogy a name és salary adattagok a Temp_Man struktúrában kétszer szerepelnek, ami nyilván nem megengedhető, hiszen ha egy ilyen objektum name adattagjára hivatkoznánk, akkor a fordító nem tudná eldönteni, hogy pontosan melyikre gondolunk.

6.22. ábra: Többszörös öröklésnél az alaposztályhoz több úton is eljuthatunk, így az alaposztály adattagjai többször megjelennek a származtatott osztályban.

6.22. ábra: Többszörös öröklésnél az alaposztályhoz több úton is eljuthatunk, így az alaposztály adattagjai többször megjelennek a származtatott osztályban.

A probléma, miként az az ábrán is jól látható, abból fakad, hogy az öröklődési gráfon a Temp_Man osztályból az Employee két úton is elérhető, így annak adattagjai a származtatás végén kétszer szerepelnek.

Felmerülhet a kérdés, hogy a fordító miért nem vonja össze az így keletkezett többszörös adattagokat. Ennek több oka is van. Egyrészt a Temp_Man származtatásánál a Manager és Temporary osztályokra hivatkozunk, nem pedig az Employee osztályra, holott a problémát az okozza. Így az ilyen problémák kiküszöbölése a fordítóra jelentős többlet terhet tenne. Másrészt a nevek ütközése még önmagában nem jelent bajt. Például ha van két teljesen független osztályunk, A és B, amelyek ugyanolyan x mezővel rendelkeznek, azokból még származtathatunk újabb osztályt:


class A {                     class B { 
protected:                    protected:         
   int x;                        int x; 
};                            };

      class C : public A, public B {
         int f( ) { x = 3; x = 5; }  // többértelmű
      };

Természetesen továbbra is gondot jelent, hogy az f függvényben szereplő x tulajdonképpen melyik a kettő közül. A C++ fordítók igen érzékenyek az olyan esetekre, amikor valamit többféleképpen is lehet értelmezni. Ezeket jellemzően sehogyan sem értelmezik, hanem fordítási hibát jeleznek. Így az f függvény fenti definíciója is hibás. A scope operátor felhasználásában azonban a többértelműség megszüntethető, így teljesen szabályos a következő megoldás:

int f( ) { A :: x = 3; B :: x = 5; }

Végére hagytuk az azonos nevű adattagok automatikus összevonása elleni legsúlyosabb ellenvetést. Idáig többször büszkén kijelentettük, hogy az öröklődés során az adatok struktúrája úgy egészül ki, hogy (egyszeres öröklődés esetén) az új struktúra kezdeti része kompatibilis lesz az alaposztálynak megfelelő memóriaképpel. Többszörös öröklődés esetén pedig a származtatott osztályhoz tartozó objektum memóriaképének lesznek olyan részei, melyek az alaposztályoknak megfelelő memóriaképpel rendelkeznek. A kompatibilitás jelentőségét nem lehet eléggé hangsúlyozni. Ennek következménye az, hogy ahol egy alaposztályhoz tartozó objektumot várunk, oda a belőle származtatott osztály objektuma is megfelel (kompatibilitás), és a virtuális függvény hívást feloldó mechanizmus is erre a tulajdonságra épül. A nevek alapján végzett összevonással éppen ezt a kompatibilitást veszítenénk el.

Az adattagok többszöröződési problémájának tényleges megoldása a virtuális bázis osztályok bevezetésében rejlik. Annál az öröklődésnél, ahol fennáll a veszélye annak, hogy az alaposztály a későbbiekben az öröklődési gráfon történő többszörös elérés miatt megsokszorozódik, az öröklődést virtuálisnak kell definiálni (ez némi előregondolkodást igényel). Ennek alapvetően két hatása van. Az alaposztály (Employee) adattagjai nem épülnek be a származtatott osztályok (Manager) adattagjai elé, hanem egy független struktúraként jelennek meg, melyet Manager tagfüggvényeiből egy mutatón keresztül érhetünk el. Természetesen mindebből a C++ programozó semmit sem vesz észre, az adminisztráció minden gondját a fordítóprogram vállalja magára. Másrészt az alaposztály konstruktorát nem az első származtatott osztály konstruktora fogja meghívni, hanem az öröklődés lánc legvégén szereplő osztály konstruktora (így küszöböljük ki azt a nehézséget, hogy a többszörös elérés a konstruktor többszöri hívását is eredményezné).

Az irodai hierarchia korrekt megoldása tehát:


class Manager : virtual public Employee { .... };
class Temporary : virtual public Employee { .... };
class Temp_Man : public Manager, public Temporary { 
public:
   Temp_Man(char* nam, long sal, int lev, int time )
   : Employee(nam, sal), Manager(NULL, 0L, lev), 
     Temporary(NULL, 0L, time) { }
};

Az elmondottak szerint a memóriakép virtuális öröklődés esetében a 6.23. ábrán látható módon alakul.

Természetesen a többszörös öröklődést megvalósító Temp_Man, mivel itt az öröklődés nem virtuális, a korábbihoz teljesen hasonlóan az alaposztályok adatmezőit rakja egymás után. A különálló Employee részt azonban nem ismétli meg, hanem a megduplázódott mutatókat ugyanoda állítja. Ily módon sikerült a memóriakép kompatibilitását garantálni, és azzal, hogy a mutatók többszöröződnek a tényleges adattagok helyett, a name és salary mezők egyértelműségét is biztosítottuk. Az indirekció virtuális függvényekhez hasonló léte magyarázza az elnevezést (virtuális alaposztály).

6.23. ábra: A többszörös öröklés adattag többszörözésének elkerülése virtuális bázis-osztályok alkalmazásával.

6.23. ábra: A többszörös öröklés adattag többszörözésének elkerülése virtuális bázis-osztályok alkalmazásával.

6.7.5. A konstruktor láthatatlan feladatai

A virtuális függvények kezelése során az egyes objektumok inicializálásának ki kell térnie az adattagok közé felvett függvénycímek beállítására is. Szerencsére ebből a programozó semmit sem érzékel. A mutatók beállítását a C++ fordítóprogram vállalja magára, amely szükség esetén az objektumok konstruktoraiba elhelyezi a megfelelő, a programozó számára láthatatlan utasításokat.

Összefoglalva egy konstruktor a következő feladatokat végzi el a megadott sorrendben:

  1. A virtuális alaposztály(ok) konstruktorainak hívása, akkor is, ha a virtuális alaposztály nem közvetlen ős.

  2. A közvetlen, nem-virtuális alaposztály(ok) konstruktorainak hívása.

  3. A saját rész konstruálása, amely az alábbi lépésekből áll:

    • a virtuálisan származtatott osztályok objektumaiban egy mutatót kell beállítani az alaposztály adattagjainak megfelelő részre.

    • ha az objektumosztályban van olyan virtuális függvény, amely itt új értelmet nyer, azaz az osztály a virtuális függvényt újradefiniálja, akkor az annak megfelelő mutatókat a saját megvalósításra kell állítani.

    • A tartalmazott objektumok (komponensek) konstruktorainak meghívása.

  4. A konstruktor programozó által megadott részeinek végrehajtása.

Egy objektum a saját konstruktorának futtatása előtt meghívja az alaposztályának konstruktorát, amely – amennyiben az alaposztályt is származtattuk – a következő alaposztály konstruktorát. Ez azt jelenti, hogy egy öröklési hierarchia esetén a konstruktorok végrehajtási sorrendje megfelel a hierarchia felülről-lefelé történő bejárásának.

6.7.6. A destruktor láthatatlan feladatai

A destruktor a konstruktor inverz műveleteként a konstruktor lépéseit fordított sorrendben "közömbösíti":

  1. A destruktor programozó által megadott részének a végrehajtása.

  2. A komponensek megszüntetése a destruktoraik hívásával.

  3. A közvetlen, nem-virtuális alaposztály(ok) destruktorainak hívása.

  4. A virtuális alaposztály(ok) destruktorainak hívása.

Mivel a destruktorban először a saját törzset futtatjuk, majd ezt követi az alaposztály destruktorának hívása, a destruktorok hívási sorrendje az öröklési hierarchia alulról-felfelé történő bejárását követi. A destruktor programozó által megadott részében akár virtuális tagfüggvényeket is hívhatunk, melyeket a hierarchiában az osztály alatt lévők átdefiniálhattak. Igenám, de ezek az átdefiniált tagfüggvények olyan, az alsóbb szinten definiált attribútumokra hivatkozhatnak, amit az alsóbb szintű destruktorok már "érvénytelenítettek". Ezért a destruktorok láthatatlan feladataihoz tartozik a virtuális függvénymutatók visszaállítása is.

6.7.7. Mutatók típuskonverziója öröklődés esetén

Korábban felhívtuk rá a figyelmet, hogy az öröklődés egyik fontos következménye az alaposztályok és a származtatott osztályok objektumainak egyirányú kompatibilitása. Ez részben azt jelenti, hogy egy származtatott osztály objektumának memóriaképe tartalmaz olyan részt (egyszeres öröklődés esetén az elején), amely az alaposztály objektumainak megfelelő, azaz ránézésre a származtatott osztály objektumai az alaposztály objektumaira hasonlítanak (fizikai kompatibilitás). Ezenkívül az analitikus öröklődés szabályainak alkalmazásával kialakított publikus öröklődés esetén (privátnál nem!) a származtatott osztály objektumai megértik az alaposztály üzeneteit és ahhoz hasonlóan reagálnak ezekre. Vagyis az egyirányú kompatibilitás az objektumok viselkedésére is teljesül (viselkedési kompatibilitás). Az alap és származtatott osztályok objektumai mégsem keverhetők össze közvetlenül, hiszen azok a származtatott osztály új adattagjai illetve új virtuális tagfüggvényei miatt eltérő mérettel (memóriaigénnyel) bírnak. Ezen könnyen túl tudjuk tenni magunkat, ha az objektumokat címeik segítségével, tehát indirekt módon érjük el, hiszen a mutatók fizikailag mindig ugyanannyi helyet foglalnak attól függetlenül, hogy ténylegesen milyen típusú objektumokra mutatnak.

Ezért különösen fontos a mutatók típuskonverziójának a megismerése és korrekt felhasználása öröklődés esetén. A típuskonverzió bevetésével a kompatibilitásból fakadó előnyöket kiaknázhatjuk (lásd 6.7.8. fejezetben tárgyalt heterogén szerkezeteket), de gondatlan alkalmazás mellett időzített bombákat is elhelyezhetünk a programunkban.

Tegyük fel, hogy van három osztályunk: egy alaposztály, egy publikus és egy privát módon származtatott osztály:

class Base { .... };
class PublicDerived : public Base { .... };
class PrivateDerived: private Base { .... };

Vizsgáljuk először az ún. szűkítő irányt, amikor a származtatott osztály típusú mutatóról az alaposztály mutatójára konvertálunk. A memóriakép kompatibilitása nem okoz gondot, mert a származtatott osztály objektumában a memóriakép kezdő része az alaposztályénak megfelelő:

6.24. ábra: Szűkítő típuskonverzió.

6.24. ábra: Szűkítő típuskonverzió.

Publikus öröklődésnél a viselkedés kompatibilitása is rendben van, hiszen miként a teljes objektumra, az alaposztályának megfelelő részére is, az alaposztály üzenetei végrehajthatók. Ezért az ilyen jellegű mutatókonverzió olyannyira természetes, hogy a C++ még explicit konverziós operátor (cast operátor) használatát sem követeli meg:

PublicDerived pubD; 	// pubD kaphatja a Base üzeneteit
Base * pB = &pubD; 	// nem kell explicit típuskonverzió

Privát öröklődésnél a viselkedés kompatibilitása nem áll fenn, hiszen ekkor az alaposztály publikus üzeneteit a származtatott osztályban letiltjuk. A szűkítés után viszont egy alaposztályra hivatkozó címünk van, ami azt jelenti, hogy ily módon mégiscsak elérhetjük az alaposztály letiltott üzeneteit. Ez nyilván veszélyes, hiszen bizonyára nem véletlenül tiltottuk le az alaposztály üzeneteit. A veszély jelzésére az ilyen jellegű átalakításokat csak explicit típuskonverziós operátorral engedélyezi a C++ nyelv:

PrivateDerived priD;// priD nem érti a Base üzeneteit
pB = (Base *)&priD; // mégiscsak érti ->  explicit konverzió!

A konverzió másik iránya a bővítés, mikor az alap osztály objektumára hivatkozó mutatót a származtatott osztály objektumának címére szeretnénk átalakítani:

6.25. ábra: Bővítő típuskonverzió.

6.25. ábra: Bővítő típuskonverzió.

Az ábrát szemügyre véve megállapíthatjuk, hogy a memóriaképek kompatibilitása itt nem áll fenn. Az alaposztályt általában nem használhatjuk a származtatott osztály helyett (ezért mondtuk a kompatibilitást egyirányúnak). A mutatókonverzió után viszont olyan memóriarészeket is el lehet érni (az ábrán csíkozott), melyek nem is tartoznak az objektumhoz, amiből katasztrofális hibák származhatnak. Ezért a bővítő jellegű konverziót csak kivételes esetekben használjunk és csak akkor, ha a származtatott osztály igénybe vett üzenetei csak az alaposztály adattagjait használják. A veszélyek jelzésére, hogy véletlenül se essünk ebbe a hibába, a C++ itt is megköveteli az explicit konverziós operátor használatát:

Base base;
Derived *pD = (Derived *) &base;
// nem létező adattagokat lehet elérni 

Az elmondottak többszörös öröklődés esetén is változatlanul érvényben maradnak, amit a következő osztályokkal demonstráljuk:

class Base1{ .... };
class Base2{ .... };
class MulDer : public Base1, public Base2 {....};

Tekintsük először a szűkítő konverziót!

MulDer md;
Base1 *pb1 = &md;
Base2 *pb2 = &md; // típuskonverzió = mutató módosítás!

6.26. ábra: Szűkíktő típuskonverzió többszörös öröklés esetén.

6.26. ábra: Szűkíktő típuskonverzió többszörös öröklés esetén.

Mint tudjuk, többszörös öröklődés esetén csak az egyik (általában az első) alaposztályra biztosítható az a tulajdonság, hogy az alaposztálynak megfelelő adattagok a származtatott osztálynak megfelelő adattagok kezdő részében találhatók. A többi alaposztályra csak az garantálható, hogy a származtatott osztály objektumaiban lesz olyan rész, ami ezekkel kompatibilis (ez a 6.26. ábrán is jól látható). Tehát amikor a példánkban Base2* típusra konvertálunk a mutató értékét is módosítani kell. Szerencsére a fordítóprogram ezt automatikusan elvégzi, melynek érdekes következménye, hogy C++-ban a mutatókonverzió esetlegesen megváltoztatja a mutató értékét.

Bővítő konverzió esetén, a mutató értékét a fordító szintén korrekt módon átszámítja. Természetesen a nem létező adattagok elérése továbbra is veszélyt jelent, ezért bővítés esetén többszörös öröklődéskor is explicit konverziót kell alkalmazni:

Base2  base2;
MulDer *pmd = (MulDer *) &base2;	// 

6.27. ábra: Bővítő típuskonverzió többszörös öröklés esetén.

6.27. ábra: Bővítő típuskonverzió többszörös öröklés esetén.

6.7.8. Az öröklődés alkalmazásai

Az öröklődés az objektum orientált programozás egyik fontos, bár gyakran túlságosan is előtérbe helyezett eszköze. Az öröklődés használható a fogalmi modellben lévő általánosítás-specializáció jellegű kapcsolatok kifejezésére, és a kód újrafelhasználásának hatékony módszereként is. Mint mindennel, az öröklődéssel is vissza lehet élni, amely áttekinthetetlen, kibogozhatatlan programot és misztikus hibákat eredményezhet. Ezért fontos, hogy az öröklődést fegyelmezetten, és annak tudatában használjuk, hogy pontosan mit akarunk vele elérni és ennek milyen mellékhatásai lehetnek. Az alábbiakban átfogó képet adunk az öröklődés ajánlott és kevésbé ajánlott felhasználási módozatairól.

Analitikus öröklődés

Az analitikus öröklődés, amikor a fogalmi modell szerint két osztály egymás általánosítása, illetve specializációja, a legkézenfekvőbb felhasználási mód. Ez nem csupán a közös részek összefogásával csökkenti a programozói munkát, hanem a fogalmi modell pontosabb visszatükrözésével a kód olvashatóságát is javíthatja.

Az analitikus öröklődést gyakran IS_A (az egy olyan) relációnak mondják, mert az informális specifikációban ilyen igei szerkezetek (illetve ennek rokon értelmű változatai) utalnak erre a kapcsolatra. Például: A menedzser az egy olyan dolgozó, aki saját csoporttal rendelkezik. Bár ez a felismerési módszer gyakran jól használható, vigyázni kell vele, hiszen az analitikus öröklődésbe csak olyan relációk férnek bele, melyek az alaposztály tulajdonságait kiegészítik, de abból semmit nem vesznek el, illetve járulékos megkötéseket nem tesznek. Tekintsük a következő specifikációs részletet:

A piros-vonal az egy olyan vonal, melynek a színe születésétől fogva piros és nem változtatható meg.

Ebben a mondatban is szerepel az "az egy olyan" kifejezés, de ez nem jelent analitikus öröklődést.

Verzió kontroll - Kiterjesztés átdefiniálás nélkül

Az analitikus öröklődéshez kapcsolódik az átdefiniálás nélküli kiterjesztés megvalósítása. Ekkor nem az eredeti modellben, hanem annak időbeli fejlődése során ismerünk fel analitikus öröklődési kapcsolatokat. Például egy hallgatókat nyilvántartó, nevet és jegyet tartalmazó Student osztályt felhasználó program fejlesztése, vagy átalakítása során felmerülhet, hogy bizonyos estekben az ismételt vizsgák nyilvántartására is szükség van. Ehhez egy új hallgató osztályt kell létrehozni, melyet az eredetiből öröklődéssel könnyen definiálhatunk:


class Student {
      String  name;
      int     mark;
   public:
      int	Mark( ) { return mark; }
   };

   class MyStudent : public Student {
      int     repeat_exam;
   public:
      int EffectiveMark( ) {return (repeat_exam ? 1 : Mark());}
   };

Kicsit hasonló ehhez a láncolt listák és más adatszerkezetek kialakításánál felhasznált implementációs osztályok kialakítása. Egy láncolt listaelem a tárolt adatot és a láncoló mutatót tartalmazza. A tárolt adat mutatóval történő kiegészítése öröklődéssel is elvégezhető:


class StudentListElem : public Student {
   StudentListElem * next;
};

Kiterjesztés üzenetek törlésével (nem IS_A kapcsolat)

Az átdefiniálás másik típusa, amikor műveleteket törlünk, már nem az analitikus öröklődés kategóriájába tartozik. Példaként tegyük fel, hogy egy verem (Stack) osztályt kell létrehoznunk. Tételezzük fel továbbá, hogy korábbi munkánkban, vagy egy rendelkezésre álló könyvtárban sikerült egy sor (Queue) adatstruktúrát megvalósító osztály fellelnünk, és az az ötletünk támad, hogy ezt a verem adatstruktúra megvalósításához felhasználjuk. A verem LIFO (last-in-first-out) szervezésű, azaz mindig az utoljára beletett elemet lehet kivenni belőle, szemben a sorral, ami FIFO (first-in-first-out) elven működik, azaz a legrégebben beírt elem olvasható ki belőle. A FIFO-n Put és Get műveletek végezhetők, addig a vermen Push és Pop, melyek értelme eltérő. A verem megvalósításhoz mégis felhasználható a sor, ha felismerjük, hogy a FIFO-ban tárolt elemszám nyilvántartásával, a FIFO stratégia LIFO-ra változtatható, ha egy újabb elem betétele esetén a FIFO-ból a már benn lévő elemeket egymás után kivesszük és a sor végére visszatesszük.

Fontos kiemelnünk, hogy ebben az esetben privát öröklődést kell használnunk, hiszen csak ez takarja el az eredeti publikus tagfüggvényeket. Ellenkező esetben a Stack típusú objektumokra a Put, Get is érvényes művelet lenne, ami nyilván nem értelmezhető egy veremre és felborítaná a stratégiánkat is.


class Queue {
   ....
public:   
   void 	Put( int e );
   int	Get( );
};

class Stack : private Queue { // Ilyenkor privát öröklődés
   int nelem;
public:
      Stack( ) { nelem = 0; }
   void 	Push( int e );
   int  	Pop( ) { nelem--; return Get(); }
};

void Stack :: Push( int e ) {
   Put( e ); 
   for( int i = 0; i < nelem; i++ ) Put( Get( ) );
   nelem++;
}

Ez a megoldás, bár privát öröklődéssel teljesen jó, nem igazán javasolt. Ehelyett jobbnak tűnik az ún. delegáció, amikor a verem tartalmazza azt a sort, melyet a megvalósításában felhasználunk. A Stack osztály delegációval történő megvalósítása:


class Stack { 
   Queue fifo;	// delegált objektum
   int nelem;
public:
Stack( ) { nelem = 0; }
   void  Push( int e ) {
      fifo.Put( e ); 
      for(int i = 0; i < nelem; i++) fifo.Put( fifo.Get());
      nelem++;
   }
   int Pop( ) { nelem--; return fifo.Get(); }
};

Ez a megoldás fogalmilag tisztább és átláthatóbb. Nem merül fel annak veszélye, hogy véletlenül nem privát öröklődést használunk. Továbbá típus konverzióval sem érhetjük el az eltakart Put, Get függvényeket, amire privát öröklődés esetén, igaz csak explicit típuskonverzió alkalmazásával, de lehetőség van.

Variánsok

Az előző két kiterjesztési példa között helyezkedik el a következő, melyet általában variánsnak nevezünk. Egy variánsban a meglévő metódusok értelmét változtatjuk meg. Például, ha a Student osztályban a jegy kiszámítási algoritmusát kell átdefiniálni az ismételt vizsgát is figyelembe véve, a következő öröklődést használhatjuk:


class MyStudent : public Student {
   int  repeat_exam;
public:
   int Mark( ) { return (repeat_exam ? 1 : Student::Mark( );) }
}        

A dolog rejt veszélyeket magában, hiszen ez nem analitikus öröklődés, mert az új diák viselkedése nem lesz kompatibilis az eredetivel, mégis gyakran használt programozói fogás.

Egy nagyobb léptékű példa a variánsok alkalmazására a lemezmellékleten található, ahol a telefonszám átirányítási feladat megoldásán (6.6. fejezet) oly módon javítottunk, hogy a párokat nem tömbben, hanem bináris rendezőfában tároltuk, azaz a tároló felépítése a következőképpen alakult át:

6.28. ábra: A párok bináris fában.

6.28. ábra: A párok bináris fában.

Ezzel a módszerrel, az eredeti programban csupán a legszükségesebb átalakítások elvégzésével, a keresés sebességét (időkomplexitását) lineárisról (O(n)) logaritmikusra (O(log n)) sikerült javítani.

Heterogén kollekció

A heterogén kollekciók olyan adatszerkezetek, melyek különböző típusú és számú objektumokat képesek egységesen kezelni. Megjelenésükben hasonlítanak olyan tömbre vagy láncolt listára, amelynek elemei nem feltétlenül azonos típusúak. Hagyományos programozási nyelvekben az ilyen szerkezetek kezelése, vagy a gyűjtemény homogén szerkezetekre bontását, vagy speciális bit-szintű trükkök bevetését igényli, ami megvalósításukat bonyolulttá és igen veszélyessé teszi.

Az öröklődés azonban most is segítségünkre lehet, hiszen mint tudjuk, az öröklődés a saját hierarchiáján belül egyfajta kompatibilitást biztosít, ami azt jelenti, hogy objektumokat egységesen kezelhetünk. Az egységes kezelésen kívül eső, típus függő feladatokra viszont kiválóan használhatók a virtuális függvények, melyek automatikusan derítik fel, hogy a gyűjteménybe helyezett objektum valójában milyen típusú. (Ilyen heterogén szerkezettel a 6.7.1. fejezetben már találkoztunk, amikor Line és Rect típusú objektumokat egyetlen Shape* tömbbe gyűjtöttük össze.)

Tekintsük a következő, a folyamatirányítás területéről vett feladatot:

Egy folyamat-felügyelő rendszer a nem automatikus beavatkozásokról, mint egy szelep lezárása/kinyitása, alapjel átállítása, szabályozási algoritmus átállítása, új felügyelő személy belépése, stb. folyamatosan értesítést kap. A rendszernek a felügyelő kérésére valódi sorrendben kell visszajátszania az eseményeket, mutatva azt is, hogy mely eseményeket játszottuk vissza ezt megelőzően.

Egyelőre, az egyszerűség kedvéért, csak a szelep zárás/nyitás (Valve) és a felügyelő belépése (Supervisor) eseményeket tekintjük. A feladatanalízis alapján a következő objektummodellt állíthatjuk fel.

6.29. ábra: A folyamat-felügyelő rendszer osztálydiagramja.

6.29. ábra: A folyamat-felügyelő rendszer osztálydiagramja.

Ez a modell kifejezi, hogy a szelepműveletek és felügyelő belépés közös alapja az általános esemény (Event) fogalom. A különböző események között a közös rész csupán annyi, hogy mindegyikre vizsgálni kell, hogy leolvasták-e vagy sem, ezért a leolvasást jelző attribútumot (checked) az általános eseményhez (Event) kell rendelni. Az általános esemény fogalomnak két konkrétabb változata van: a szelep esemény (Valve) és a felügyelő belépése (Supervisor). A többlet az általános eseményhez képest a szelepeseményben a szelep művelet iránya (dir), a felügyelő belépésében a felügyelő neve (name). Ezeket az eseményeket kell a fellépési sorrendben nyilvántartani, melyre az EventList gyűjtemény szolgál (itt a List szó inkább a sorrendhelyes tárolóra, mint a majdani programozástechnikai megvalósításra utal). Az EventList általános eseményekből (Event) áll, melyek képviselhetnek akár szelep eseményt, akár felügyelő belépést. A tartalmazási reláció mellé tett * jelzi a reláció heterogén voltát. A heterogén tulajdonság szerint az EventList tároló bármilyen az Event-ből származtatott osztályból definiált objektumot magába foglalhat. Amikor egy eseményt kiveszünk a tárolóból, akkor szükségünk van arra az információra, hogy az ténylegesen milyen típusú, hiszen különböző típusú eseményeket más módon kell kiíratni a képernyőre. Megfordítva a gondolatmenetet, a kiíratás (Show) az egyetlen művelet, amelyet a konkrét típustól függően kell végrehajtani a heterogén kollekció egyes elemeire. Ha a Show virtuális tagfüggvény, akkor az azonosítást a virtuális függvény hívását feloldó mechanizmus automatikusan elvégzi.

A Show tagfüggvényt a tanultak szerint az alaposztályban (Event) kell virtuálisnak deklarálni. Kérdés az, hogy rendelhetünk-e az Event::Show tagfüggvényhez valamilyen értelmes tartalmat. A specifikáció szerint a leolvasás tényét ki kell íratni és tárolni kell, amelyet az Event-hez tartozó változó (checked) valósít meg. Azaz, ha egy adott objektumra Show hívást adunk ki, az közvetlen vagy közvetett módon az alaposztályhoz tartozó checked változót is átírja. Ezt kétféleképpen valósíthatjuk meg. Vagy a checked változó védett (protected) hozzáférésű, vagy a változtatást az Event valamilyen publikus vagy védett tagfüggvényével érjük el. Adatmezők védettnek (még rosszabb esetben publikusnak) deklarálása mindenképpen kerülendő, hiszen ez kiszolgáltatja a belső implementáció részleteit és lehetőséget teremt a belső állapotot inkonzisztenssé tevő, az interfészt megkerülő változtatás elvégzésére. Tehát itt is az interfészen keresztül történő elérés a követendő. Ezért a leolvasás tényének a kiírását és rögzítését az Event::Show tagfüggvényre bízzuk.

Ezek után tekintsük a feladat megoldását egy leegyszerűsített esetben. A felügyelő eseményben (Supervisor) a név (name) attribútumot a 6.5. fejezetben tárgyalt String osztály segítségével definiáljuk. A különböző típusú elemek eltérő méretéből adódó nehézségeket úgy küszöbölhetjük ki, hogy a tényleges tárolóban csak mutatókat helyezünk el, hiszen ezek mérete független a megcímzett objektum méretétől. A virtuális függvény hívási mechanizmus miatt a mutató típusát az alaposztály (Event) szerint vesszük fel. Feltételezzük, hogy maximum 100 esemény következhet be, így a mutatók tárolására egyszerű tömböt használunk (nem akartuk az olvasót terhelni a dinamikus adatszerkezetekkel, de tulajdonképpen azt kellene itt is használni):


class Event { 
   int  checked;
public:
      Event ( ) { checked = FALSE; }        
   virtual void Show( ) { cout << checked; checked = TRUE; } 
};

class Valve : public Event { 
   int  dir;    // OPEN / CLOSE
public:
         Valve( int d ) { dir = d; }                
   void	Show ( ) {
      if ( dir )  cout << "valve OPEN"; 
      else        cout << "valve CLOSE";
      Event :: Show();
   }
};

class Supervisor : public Event { 
   String	name;
public:
        Supervisor( char * s ) { name = String( s ); }
   void Show ( ) { cout << name; Event::Show( ); }
};

class EventList {
   int 		nevent;
   Event *	events[100];	// mutató tömb
public:        
      EventList( ) { nevent = 0; }
   void	Add(Event& e) { events[ nevent++ ] = &e; }
   void	List( )
      { for(int i = 0; i < nevent; i++) events[i]->Show(); }
};

Felhívjuk a figyelmet a Valve::Show és a Supervisor::Show tagfüggvényekben a Event::Show tagfüggvény hívásra. Itt nem alkalmazhatjuk a rövid Show hivatkozást, hiszen az a Valve::Show esetében ugyancsak a Valve::Show-ra, hasonlóképpen a Supervisor::Show-nál ugyancsak önmagára vonatkozna, amely egy végtelen rekurziót hozna létre.

Annak érdekében, hogy igazán értékelni tudjuk a virtuális függvényekre épülő megoldásunkat oldjuk meg az előző feladatot a C nyelv felhasználásával is. Heterogén szerkezetek kialakítására C-ben az első gondolatunk a union, vagy egy mindent tartalmazó általános struktúra alkalmazása lehetne. Ez azt jelenti, hogy a heterogén szerkezetet homogenizálhatjuk oly módon, hogy mindig maximális méretű adatstruktúrát alkalmazunk, a fennmaradó adattagokat pedig nem használjuk ki. Ezt a megközelítést pazarló jellege miatt elvetjük.

Az igazán járható, de sokkal nehezebb út igen hasonlatos a virtuális függvények alkalmazásához, csakhogy azok hiányában most mindent "kézi erővel" kell megvalósítani. A szelep és felügyelői eseményeket struktúrával (mi mással is tehetnénk?) reprezentáljuk. Ezen struktúrákat kiegészítjük egy taggal, amely azt hivatott tárolni, hogy a heterogén szerkezetben lévő elem ténylegesen milyen típusú. A típusleíró tagot mindig ugyanazon a helyen (ez itt a lényeg!), célszerűen a struktúra első tagjaként valósítjuk meg. A heterogén kollekció központi része most is egy mutatótömb lesz, amely akármilyen típusú mutatókat tartalmazhat, hiszen miután kiderítjük az általa megcímzett memóriaterületen álló típustagból a struktúra tényleges típusát, úgy is típuskonverziót (cast) kell alkalmazni. Éppen az ilyen esetekre találták ki az ANSI C-ben a void mutatót.

Ezek után a C megvalósítás az alábbiakban látható:


struct Valve { 
   int    type;   // VALVE, SUPERVISOR ... 1. helyre
   BOOL   chked, dir;
};

struct Supervisor { 
   int    type;   // VALVE, SUPERVISOR ... u.a. helyre
   BOOL   chked;
   char   name[30];
};

void * events[100]; // mutató tömb
int  nevent = 0;

void AddEvent( void * e ) { events[ nevent++ ] = e; }

void List( ) {
   int i;
   struct Valve * pvalv;
   struct Supervisor * psub;

   for( i = 0; i < nevent; i++ ) {
      switch ( *( (int *)events[i] ) ) {
      case VALVE:	
         pvalv = (struct Valve *) events[i];
         if ( pvalv->dir ) {
            printf("v.OPEN chk %d\n", pvalv->chked );
            pvalv->chked = TRUE;
         } else .... 
         break;
      case SUPERVISOR: 
         psub = (struct Supervisor *)events[i];
         printf("%s chk%d", psub->name, psub->chked );
      }
   }
}

Mennyivel rosszabb ez mint a C++ megoldás? Először is a mutatók konvertálgatása meglehetősen bonyolulttá és veszélyessé teszi a fenti programot. Kritikus pont továbbá, hogy a struktúrákban a type adattag ugyanoda kerüljön. A különbség akkor válik igazán döntővé, ha megnézzük, hogy a program egy későbbi módosítása mennyi fáradsággal és veszéllyel jár. Tegyük fel, hogy egy új eseményt (pl. alapjel állítás, azaz ReferenceSet) kívánunk hozzávenni a kezelt eseményekhez. C++-ban csupán az új eseménynek megfelelő osztályt kell létrehozni és annak Show tagfüggvényét a megfelelő módon kialakítani. Az EventList kezelésével kapcsolatos programrészek változatlanok maradnak. Ezzel szemben a C nyelvű megoldásban először a ReferenceSet struktúrát kell létrehozni vigyázva arra, hogy a type az első helyen álljon. Majd a List függvényt jelentősen át kell gyúrni, melynek során mutató konverziókat kell beiktatni és a switch/case ágakat kiegészíteni. A C++ megvalósítás tehát csak az új osztály megírását jelenti, melyet egy elkülönült helyen megtehetünk, míg a C példa a teljes program átvizsgálásával és megváltoztatásával jár. Egy sok ezer soros, más által írt program esetében a két út különbözősége nem igényel hosszabb magyarázatot.

A C++ nyelvben a heterogén szerkezetben található objektumok típusát azonosító switch/case ágakat a virtuális függvény mechanizmussal válthatjuk ki. Minden olyan függvényt virtuálisnak kell deklarálni, amelyet a heterogén kollekcióba elhelyezett objektumoknak küldünk, ha a válasz típusfüggő. Ekkor maga a virtuális tagfüggvény kezelési mechanizmus fogja az objektum tényleges típusát meghatározni és a megfelelő reakciót végrehajtani.

Egy létező és heterogén kollekcióba helyezett objektumot természetesen meg is semmisíthetünk, melynek hatására egy destruktorhívás jön létre. Adott esetben a destruktor végrehajtása is típusfüggő, például, ha a tárolt objektumoknak dinamikusan allokált adattagjaik is vannak (lásd 6.5.1. fejezetet), vagy ha az előző feladatot úgy módosítjuk, hogy a tárolt események törölhetők, de a törléskor az esemény naplóját automatikusan ki kell írni a nyomtatóra. Értelemszerűen ekkor virtuális destruktort-t kell használni.

Tartalmazás (aggregáció) szimulálása

"Kifejezetten nem ajánlott" kategóriában szerepel az öröklődésnek az aggregáció megvalósításához történő felhasználása, mégis is nap mint nap találkozhatunk vele. Ennek oka elsősorban az, hogy a gépelési munkát jelentősen lerövidítheti, igaz, hogy esetleg olyan gonosz hibák elhelyezésével, melyek a későbbiekben igencsak megbosszulják magukat. Ennek illusztrálására lássunk egy autós példát:

Az autóhoz kerék és motor tartozik, és még neve is van.

Ha ezt a modellezési feladatot tisztességesen, tehát tartalmazással valósítjuk meg, a tartalmazott objektumoknak a teljes autóra vonatkozó szolgáltatásait ki kell vezetni az autó (Car) osztályra is, hiszen egy tartalmazott objektum kívülről közvetlenül nem érhető el. Ez ún. közvetítő függvényekkel történhet. Ilyen közvetítő függvény az motorfogyasztást megadó EngCons és a kerékméretet leolvasó-átíró WheelSize. Mivel ezeket a szolgáltatásokat végső soron a tartalmazott objektumok biztosítják, a közvetítő függvény nem csinál mást, mint üzenetet küld a megfelelő tartalmazott objektumnak:


class Wheel {
   int    size;
public:	
   int&   Size( ) { return size; }        
};

class Engine {
   double   consumption;
public:	
   double&	Consum( ) { return consumption; }        
};

class Car {
   String	name;
   Wheel  wheel;
   Engine engine;
public:
   void 	SetName( String& n ) { name = n; }
   double& EngCons( ) {return engine.Consum();} // közvetítő
   int&	WheelSize( ) {return wheel.Size();}     // közvetítő
};

Ezeket a közvetítő függvényeket lehet megspórolni, ha a Car osztályt többszörös öröklődéssel építjük fel, hiszen publikus öröklődés esetén az alaposztályok metódusai közvetlenül megjelennek a származtatott osztályban:


class Car : public Wheel, public Engine {
      String	name;
   public:
      void 	SetName( String& n ) { name = n; }
};

   Car volvo;
   volvo.Size() = ...      // Ez a kerék mérete :-(

Egy lehetséges következmény az utolsó sorban szerepel. A volvo.Size, mivel az autó a Size függvényt a keréktől örökölte, a kerék méretét adja meg, holott az a programot olvasó számára inkább magának a kocsinak a méretét jelenti. Az autó részeire és magára az autóra vonatkozó műveletek névváltoztatás nélkül összekeverednek, ami különösen más programozók dolgát nehezíti meg, illetve egy későbbi módosítás során könnyen visszaüthet.

Egy osztály működésének a befolyásolása

A következőkben az öröklődés egy nagyon fontos alkalmazási területét, az objektumok belső működésének befolyásolását tekintjük át, amely lehetővé teszi az osztálykönyvtárak rugalmas kialakítását.

Tegyük fel, hogy rendelkezésünkre áll diákok (Student) rendezett listáját képviselő osztály, amely a rendezettséget az új elem felvétele (Insert) során annak sorrendhelyes elhelyezésével biztosítja. A sorrendhelyes elhelyezéshez összehasonlításokat kell tennie a tárolt diákok között, melyeket egy összehasonlító (Compare) tagfüggvény végez el. Ha ezen osztály felhasználásával különböző rendezési szabállyal rendelkező csoportokat kívánunk létrehozni, akkor a Compare tagfüggvényt kell újradefiniálni. Az összehasonlító tagfüggvényt viszont az alaposztály tagfüggvénye (Insert) hívja, így ha az nem lenne virtuális, akkor hiába definiálnánk újra öröklődéssel a Compare-t, az alaposztály tagfüggvényei számára továbbra is az eredeti értelmezés maradna érvényben.

Virtuális összehasonlító tagfüggvény esetén a rendezési szempont, az alaposztálybeli tagfüggvények működésének a befolyásolásával, módosítható:


class StudentList {
   ....
   virtual int Compare(Student s1, Student s2) { return 1; }
public:
   Insert( Student s ) {....; if ( Compare(....) ) ....}
   Get( Student& s ) {....}
};

class MyStudentList : StudentList {
   int Compare( Student s1, Student s2 ) 
      { return s1.Mark( ) > s2.Mark( ); }
};

Eseményvezérelt programozás

Napjaink korszerű felhasználói felületei az ún. ablakos, eseményvezérelt felületek. Az ablakos jelző azt jelenti, hogy a kommunikáció számos egymáshoz képest rugalmasan elrendezhető, de adott esetben igen különböző célú téglalap alakú képernyőterületen, ún. ablakon keresztül történik, amelyek az asztalon szétdobált füzetek, könyvek és más eszközök egyfajta metaforáját képviselik. Az eseményvezéreltség arra utal, hogy a kommunikációs szekvenciát elsősorban nem a program, hanem a felhasználó határozza meg, aki minden elemi beavatkozás után igen sok különböző lehetőség közül választhat (ezzel szemben áll a hagyományos kialakítás, mikor a kommunikáció a program által feltett kérdésekre adott válaszokból áll). Ez azt jelenti, hogy az eseményvezérelt felhasználói felületeket minden pillanatban szinte mindenféle kezelői beavatkozásra fel kell készíteni. Mint említettük, a kommunikáció kerete az ablak, melyből egyszerre több is lehet a képernyőn, de minden pillanatban csak egyetlenegyhez, az aktív ablakhoz, jutnak el a felhasználó beavatkozásai.

A felhasználói beavatkozások az adatbeviteli (input) eszközökön (klaviatúra, egér) keresztül, az operációs rendszer feldolgozása után jutnak el az aktív ablakhoz. Valójában ezt úgy is tekinthetjük, hogy a felhasználó üzeneteket küld a képernyőn lévő aktív ablak objektumnak, ami erre a megfelelő metódus lefuttatásával reagál. Ennek hatására természetesen módosulhatnak az ablak belső állapotváltozói, minek következtében a későbbi beavatkozásokra történő reakció is megváltozhat. Éppen ez a belső állapot az, ami az egyes elemi kezelői beavatkozások között rendet teremt és vagy rögzített szekvenciát erőszakol ki, vagy a kezelő által megadott elemi beavatkozásokhoz a sorrend alapján tartalmat rendel.

Az elemi beavatkozások (mint például egy billentyű- vagy egérgomb lenyomása/elengedése, egér mozgatása, stb.) egy része igen általános reakciót igényel. Az egér mozgatása szinte mindig a kurzor mozgatását igényli, az ablak bal-felső sarkára való dupla kattintás (click) pedig az ablak lezárását, stb. Más beavatkozásokra viszont ablakról ablakra alapvetően eltérően kell reagálni. Ez a tulajdonság az, ami az ablakokat megkülönbözteti egymástól. Egy szövegszerkesztő programban az egérgomb lenyomása az szövegkurzor (caret) áthelyezését, vagy menüből való választást jelenthet, egy rajzoló programban pedig egy egyenes szakasz erre a pontra húzását eredményezheti. A teljesen általános és egészen speciális reakciók, mint extrém esetek között léteznek átmenetek is, amikor ugyan a végső reakció alapvetően eltérő, mégis azok egy része közös. Erre jó példa a menükezelés. Egy főmenüpont kiválasztása az almenü legördülését váltja ki, az almenüben történő bóklászásunk során a kiválasztás jelzése is változik, míg a tényleges választás után a legördülő menük eltűnnek. Ez teljesen általános. Specifikusak viszont az egyes menüpontok által aktivizálható szolgáltatások, a menüelemek száma és az a szöveg ami rajtuk olvasható.

Most fordítsuk meg az információ átvitelének az irányát és tekintsük a program által a felhasználó számára biztosított adatokat, képeket, hangokat, stb. Ezek az output eszközök segítségével jutnak el a felhasználóhoz, melyek közül az ablakok kapcsán a képernyőt kell kiemelnünk (ilyenek még a nyomtató, a hangszóró, stb.). A képernyő kezelése, azon magas szintű szolgáltatások biztosítása (például egy bittérkép kirajzolása, egyeneshúzás, karakterrajzolás, stb.) igen bonyolult művelet, de szerencsére a gyakran igényelt magas szintű szolgáltatások egy viszonylag szűk körből felépíthetők (karakter, egyenes szakasz, ellipszis, téglalap, poligon rajzolása, területkitöltés színnel és mintával), így csak ezen mag egyszeri megvalósítására van szükség.

Objektumorientált megközelítésben az ablakokhoz egy osztályt rendelünk. Az említett közös vonásokat célszerű egy közös alaposztályban (AppWindow) összefoglalni, amely minden egyes felhasználói beavatkozásra valamilyen alapértelmezés szerint reagál, és az összes fontos output funkciót biztosítja. Az alkalmazásokban szereplő specifikus ablakok ennek a közös alapablaknak a származtatott változatai (legyen az osztálynév MyAppWindow). A származtatott ablakokban nyilván csak azon reakciókat megvalósító tagfüggvényeket kell újradefiniálni, melyeknek az alapértelmezéstől eltérő módon kell viselkedniük. Az output funkciókkal nem kell törődni a származtatott ablakban, hiszen azokat az alapablaktól automatikusan örökli.

Az alapablak (AppWindow), az alkalmazásfüggő részt megtestesítő származtatott ablak (MyAppWindow) és az input/output eszközök viszonyát a 6.30. ábra szemlélteti.

Vegyük észre, hogy a kommunikáció az új alkalmazásfüggő rész és az alapablak között kétirányú. Egyrészt az alkalmazásspecifikus reakciók végrehajtása során szükség van az AppWindow-ban definiált magas szintű rajzolási illetve output funkciókra. Másik oldalról viszont, ha egy reakciót az alkalmazás függő rész átdefiniál, akkor a fizikai eszköztől érkező üzenet hatására az annak megfelelő tagfüggvényt kell futtatni. Ez azt jelenti, hogy az alaposztályból meg kell hívni, a származtatott osztályban definiált tagfüggvényeket, melyről tudjuk, hogy csak abban az esetben lehetséges, ha az újradefiniált tagfüggvényt az AppWindow osztályban virtuálisként deklaráltuk. Ez azt jelenti, hogy minden input eseményhez tartozó reakcióhoz virtuális tagfüggvénynek kell tartoznia.

6.30. ábra: A felhasználó és az eseményvezérelt program kapcsolata.

6.30. ábra: A felhasználó és az eseményvezérelt program kapcsolata.

Az AppWindow egy lehetséges vázlatos megvalósítása és felhasználása az alábbiakban látható:


class AppWindow {		// könyvtári objektum
....
         // input funkciók: esemény kezelők
   virtual void    MouseButtonDn( MouseEvt ) {}   
   virtual void    MouseDrag( MouseEvt ) {}
   virtual void    KeyDown( KeyEvt ) {}  
   virtual void    MenuCommand( MenuCommandEvt ) {}
   virtual void    Expose( ExposeEvt ) {}
	
         // output funkciók
   void            Show( void );
   void            Text( char *, Point );
   void            MoveTo( Point );
   void            LineTo( Point );
};


class MyWindow : public AppWindow {
   void   Expose( ExposeEvent e) { .... }
   void   MouseButtonDn(MouseEvt e) { ....; LineTo( ); }
   void   KeyDown( KeyEvt e) { ....; Text( ); .... }
};

Az esemény-reakcióknak megfelelő tagfüggvények argumentumai szintén objektumok, amelyek az esemény paramétereit tartalmazzák. Egy egér gomb lenyomásához tartozó információs objektum (MouseEvt) például tipikusan a következő szolgáltatásokkal rendelkezik:


class MouseEvt {
   ....
   public:
      Point	Where( );         // a lenyomás helye az ablakban
      BOOL  IsLeftPushed( );  // a bal gomb lenyomva-e?
      BOOL  IsRightPushed( ); // a jobb gomb lenyomva-e?
};