Ugrás a tartalomhoz

Programozás technika

Kusper Gábor, Radványi Tibor

Kempelen Farkas Hallgatói Információs Központ

Programozási technológiák

Programozási technológiák

Ez a fejezet a „Programozás technológiák” című tárgy tudásanyagát öleli fel. Két nagy programozási paradigmát, az objektum orientált programozást (OOP) és az aspektus orientált programozást mutatjuk be. Az elsőt azért, mert manapság ez a legnépszerűbb, leginkább kiforrott, legjobban támogatott, de ami ezeknél is fontosabb, támogatja a rugalmas forráskód fejlesztését. Erre azért van szükség, mert a programozás technológiák alapelve kimondja, hogy a program kódja állandóan változik. Az aspektus orientált programozással (AOP) azért foglalkozunk, mert van egy terület, amit OOP segítségével csak csúnyán lehet megoldani. Ha minden objektumnak kell naplóznia, jogosultságot ellenőriznie, akkor hova tegyük ezeket a metódusokat? Az AOP erre ad megoldást.

A fejezet két legfontosabb része az objektum orientált tervezés és a tervezési minták. Mindkét technológia azt mutatja meg, hogyan kell könnyen bővíthető, a változásokhoz könnyen alkalmazkodó, újrahasznosítható, egyszóval rugalmas kódot fejleszteni.

Ebben a jegyzetben tárgyalt programozási technológiák akkor hasznosak, ha rugalmas szoftvert szeretnénk fejleszteni, amit könnyű módosítani és bővíteni. Erre azért van szükség, mert a programozás technológia alapelvéből tudjuk, hogy a program kódja állandóan változik. Azaz érdemes felkészülni előre az elkerülhetetlen változásokra. Persze mondhatjuk azt is, hogy ez ránk nem érvényes, mert kicsiben programozunk (programming in small). Ugyanakkor, ha egytől több programozó dolgozik a program fejlesztésén, ami hosszabb, mint néhány ezer sor, azaz nagyban programozunk (programming in large), akkor a változásokat aligha tudjuk elkerülni.

Objektum orientált programozás – OOP

Bevezetés

A szoftverkrízisre a programozási nyelvek azt a választ adták, hogy megjelentek a modulok, illetve a moduláris programozás. A modul a forráskód olyan kis része, amelyet egy programozó képes átlátni. A modulok gyakran fordítási alegységek is, azaz külön állományban találhatók. Az objektum orientált programozás (OOP) esetén a modul az osztály, ami egyben fordítási alegység is.

Az osztály első megközelítésben a valóság egy (megfogható vagy megfoghatatlan) darabkájának absztrakciója. Hogy ez a darabka kicsi vagy nagy, az az osztály felbontását (más szavakkal: granularitását, szemcsézettségét, durvaságát) adja meg. Ugyanakkor az osztály lehet teljesen technikai is, a valóságban semmihez sem kapcsolható. Tervezési minták sok ilyen osztályt tartalmaznak.

Az osztály második megközelítésben egy összetett, inhomogén adattípus. Sokban hasonlít a rekordhoz, ami szintén egy összetett inhomogén adattípus. Ugyanúgy vannak mezői, a mezői bármilyen típusúak lehetnek, a mezőit minősítő jellel (sok nyelvben ez a pont (.)) érjük el. Egy különbség, hogy az osztály tartalmazhat metódusokat (eljárásokat, függvényeket), a rekord nem.

A rekord az eljárást orientált nyelvek (vagy más néven az imperatív nyelvek) kedvenc típusa. Az eljárások rekordokon dolgoznak. Az OOP is ebbe a családba tartozik, csak itt már a rekordokat és a rekordokon dolgozó eljárásokat egybeolvasztjuk, méghozzá osztályokba. Azt mondjuk, hogy az adatokat és a rajtuk végrehajtott műveleteket egységbe zárjuk. Ezeket az egységeket hívjuk osztályoknak.

Az osztály mezőkből, más néven adattagokból, és metódusokból áll. A metódusok az adattagokon értelmezett műveletek.

public class Kutya {

    private String név;

    public Kutya(String name) { this.név = név; }

    public String getNév() { return név; }

}

Osztály példa – Kutya osztály

Az osztálynak lehetnek példányai. A példányokat objektumoknak hívjuk. Ha maradunk annál a megközelítésnél, hogy az osztály a valóság absztrakciója, akkor a Kutya osztály a világ összes lehetséges kutyájának az absztrakciója. Ennek az osztálynak egy példánya, azaz egy Kutya típusú objektum, pedig a valóság egy konkrét kutyájának az absztrakciója. A konkrét kutya nevét az osztály konstruktorában adhatjuk meg, amikor példányosítjuk.

Ez eddig leírtak valószínűleg mindenkinek ismertek voltak. Ugyanakkor van egy másik, programozás technikai, megközelítés is. E szerint az osztálynak két jellemzője van:

  1. felülete, és

  2. viselkedése (vagy implementációja).

Az objektumnak három jellemzője van:

  1. felülete (vagy típusa),

  2. viselkedése, és

  3. belső állapota.

Az osztály felületét a publikus részei adják. Mezőt ritkán teszünk publikussá, jellemzően metódusokon és property-ken keresztül használjuk őket (kivéve talán a statikus konstans mezőket), ezért az osztály felületét a publikus metódusainak feje adja. Az osztály felülete adja meg, hogy milyen szolgáltatásokat nyújt az osztály. A Kutya osztály például vissza tudja adni a kutya nevét a getNév() metódussal.

Az osztály viselkedését a metódusainak (nem csak a publikus, hanem az összes metódusának) implementációja határozza meg. Például a getNév() metódus viselkedése az, hogy visszaadja a név mező értékét. Habár ez a szokásos viselkedése a getNév()-nek, más viselkedést is megadhatnánk.

Kutya kutya = new Kutya("Bodri");

Példányosítás példa – a kutya nevű objektum a Kutya osztály példánya

A fenti példában létrehoztuk a kutya nevű objektumot, ami a Kutya osztály egy példánya. A konkrét kutyánkat Bodrinak hívják. Fontos megjegyezni, hogy a kutya nevű változó Kutya osztály referencia típusú. Tehát a kutya csak egy referencia a példányra, amit a new utasítással hoztunk léte. A példány típusa és a referencia típusa nem feltétlenül egyezik meg, mint az látni fogjuk.

Az objektum felülete megegyezik az osztályának a felületével, azaz a kutya objektum és a Kutya osztály felülete megegyezik. Még pontosabb azt mondani, hogy kutya objektum Kutya típusú, vagy rövidebben, a kutya Kutya típusú. Látni fogjuk, hogy egy objektumnak több típusa is lehet.

Érdekesség: Az erősen típusos nyelveken egy objektumot csak akkor használhatok egy osztály példányaként, ha olyan típusú. Ilyen nyelv pl. a Java és a C#. A gyengén típusos nyelveknél elegendő, ha az objektum felülete bővebb az osztályénál. Ilyen nyelv pl. a Smalltalk.

Az objektum viselkedését a metódusainak implementációja adja. Ez megegyezik annak az osztálynak a viselkedésével, aminek a példány az objektuma. Fontos megjegyezni, hogy az objektum viselkedése a program futása közben változhat, mint azt látni fogjuk.

Az objektum belső állapotát mezőinek pillanatnyi értéke határozza meg. Mivel az osztály metódusai megváltoztathatják a mezők értékeit, ezért a metódusokat tekinthetjük állapot átmeneti operátoroknak is. Az objektum kezdő állapotát a mezőinek kezdő értéke és az őt létrehozó konstruktor hívás határozza meg.

Fontos megjegyezni, hogy az interfésznek csak felülete van, az absztrakt osztálynak felülete és részleges viselkedése. Az absztrakt osztálynak lehet, hogy egyáltalán nincs viselkedése, ha minden metódusa absztrakt.

A fenti fogalmakkal fogalmazzuk meg az objektum orientáltság jól ismert alapelveit. Látni fogjuk, hogy az eddigi kedvencükről, az öröklődésről kiderül, hogy veszélyes. Az új kedvencünk a többalakúság lesz.

Egységbezárás (encapsulation)

Az egységbezárás klasszikus megfogalmazása valahogy így hangzik: Az adattagokat és a rajtuk műveleteket végrehajtó metódusokat egységbe zárjuk, ezt az egységet osztálynak nevezzük. Új fogalmainkkal az egységbezárás azt jelenti, hogy az objektum belső állapotát meg kell védeni, azt csak a saját metódusai változtathatják meg. Ez a két megfogalmazás kiegészíti egymást, mindkettő jogos.

Öröklődés (inheritance)

Az öröklődés a kód újrahasznosítás kényelmes formája. A gyermek osztály az ős osztály minden nem privát mezőjét és metódusát megörökli. Azaz a gyermek osztály örökli az ős osztály felületét és viselkedését. Mint látni fogjuk, az öröklődés a gyermek és az ős osztály között implementációs függőséget okoz, ami kerülendő. Öröklődés helyett, hacsak lehet objektum összetételt ajánlott használni.

Az örökölt absztrakt vagy virtuális metódusokat felülírhatjuk (overriding). Ezt a lehetőséget sokan a többalakúsághoz sorolják.

Többalakúság (polymorphism)

A jegyzetben ismertetett tervezési alapelvek és tervezési minták majd mindegyike a többalakúságon alapszik. Tehát ez egy nagyon fontos alapelv. Maga a többalakúság az öröklődés következménye. Mivel a gyermek osztály örökli az ős felületét, ezért a gyermek osztály példányai megkapják az ős típusát is. Így egy objektum több típusként, azaz több alakban is használható.

public class Vizsla : Kutya { }

Kutya kutya = new Vizsla("Frakk");

Többalakúságra példa – a „Frakk” nevű vizsla példány használható Kutyaként

A fenti példában a Vizsla osztály a Kutya osztály gyermeke. A Vizsla konstruktora segítségével készítünk egy „Frakk” nevű Vizsla példányt. Ennek a példánynak három típusa van: Vizsla, Kutya és Object. Mindhárom típus példányaként használható. Erre rögtön látunk is egy példát, hiszen egy Kutya típusú változónak adjuk át értékül az új példányt.

Egy osztály példányai az öröklődési hierarchián felfelé haladva rendelkeznek az összes típussal. Ennek megfelelően minden objektum Object típusú is, hiszen ha nem adom meg egy osztály ősét, akkor az az Object osztályból származik.

Sok szerző a metódus túlterhelést (overloading) is a többalakúsághoz sorolja, hiszen ezáltal egy metódusnak több alakja lesz. Ebben a jegyzetben mi többalakúságon csak azt értjük, hogy egy objektum több osztály példányaként is használható.

Itt kell megjegyezni, hogy ha egy osztály implementál egy interfészt, akkor a példányai használhatók ilyen interfész típusú objektumként is.

Az OOP hasznos megoldásai

Azt gondolnánk, hogy a fenti három alapelv közül az öröklődés a legerősebb, hiszen ez teszi lehetővé, hogy nagyon egyszerűen újrahasznosítsuk az ős kódját. Lehet, hogy az OOP ettől lett népszerű, de az OOP igazi ereje ezekben a technikákban rejlik:

  1. Automatikus szemét gyűjtés (garbage collection),

  2. Mező, mint lokális-globális változó,

  3. Többalakúság használata osztály behelyettesítésre,

  4. Csatoltság csökkentése objektum-összetétellel.

Automatikus szemét gyűjtés

Az automatikus szemét gyűjtés leveszi a programozó válláról azt a terhet, hogy az általa lefoglalt memória (minden new utasítás memóriát foglal) felszabadításáról gondoskodjon. Ezt a programozó

  1. elfelejtheti,

  2. rosszul oldhatja meg (pl. túl korán szabadítja fel).

Tudjuk, hogy amit lehet rosszul csinálni, azt a programozók általában rosszul is csinálják. Ha ezt az automatikusan is megoldható feladatot a keretrendszer végzi el, az nagyban csökkenti a fejlesztési és a tesztelési időt is. Ugyanakkor ez nem OOP specifikus tulajdonság.

A mező, mint lokális-globális változó

A mező, mint lokális-globális változó egy nagyon hasznos újítás. Tudjuk, hogy sok imperatív nyelvben van globális változó. Ezek gyorsabb és kisebb kód fejlesztését teszik lehetővé, hiszen egy globális változót nem kell paraméterként átadni. Ugyanakkor a globális változók használata mellékhatást okoz.

Mellékhatásnak nevezzük, ha egy alprogram (függvény, eljárás, vagy metódus) megváltoztatja a környezetét, azaz:

  1. globális változóba ír,

  2. kimenetre (képernyőre / nyomtatóra / kimeneti portra) ír,

  3. fájlba ír.

Mellékhatás használatával gyorsíthatjuk a program futását, de használata nehezen megtalálható hibákat eredményez, mivel a hiba a változás helyétől távol lévő programsor hibás működését eredményezheti. Az ilyen hibák megtalálásához nem elég az új funkció részletes nyomkövetése. Gyakran az egész forráskódot muszáj átvizsgálni, ami időrabló feladat. Ezért nem tanácsos mellékhatáshoz folyamodni, azaz globális változót használni.

Mégis, a globális változók használata gyorsítja a programot és kisebb, elegánsabb a forráskód is. Tehát jó lenne, ha lenne globális változó, illetve mégse lenne jó. A mező pont ilyen, hiszen az osztályon belül globális, kívülről elérhetetlen. A mezők használatával tudunk mellékhatást előidézni, de ez az osztályon belül lokális, így az esetleges mellékhatásokból fakadó hibák könnyebben megtalálhatók.

Igazság szerint csinálhatunk teljesen globális változót is. Egy publikus osztályszintű mezőt bárhonnan írhatunk és olvashatunk, tehát az ilyen mező globális. Szerencsére az egységbezárás miatt a publikus mezőket természetellenesnek érezzük, így senki se használ már globális változókat OOP nyelveken.

Többalakúság használata osztály behelyettesítésre

A többalakúság biztosítja, hogy a kódunk rugalmas legyen. Míg az öröklődés nagyon merev struktúrákat hoz létre, addig a többalakúság a rugalmasságot szolgálja. Ennek alapja, hogy egy gyermek osztályú példány használható mindenütt, ahol ős osztály típusú paramétert várok. Ez a többalakúság lényege.

Például könnyen készíthetünk egy pipa gyár osztályt. Hogy konkrétan milyen piát gyártunk, az csak attól függ, hogy a fapipa vagy a tajtékpipa gyermekét példányosítjuk.

Hol van itt a többalakúság, hiszen eddig szinte csak öröklődésről beszéltünk? Helyes megfigyelés, hiszen többalakúság nincs öröklődés nélkül. A gyermek osztály helyettesíthető az ős helyére. A lényeg a helyettesítésen van. Attól függ a programok működése, hogy mely gyermeket helyettesítem be. Ezt a behelyettesítést viszont a többalakúságnak köszönhetjük, ami nem feltétlenül öröklődés útján érhető el, hanem egy interfész implementációjával is. Mikor helyettesíthetünk be egy osztályt a másik helyére? Ha ez az osztály:

  1. a másik osztály gyermeke,

  2. ha megvalósítja a várt interfészt,

  3. vagy megvan minden metódus, amit hívni akarok (csak a gyengén típusos nyelvek esetén).

Hol van lehetőség behelyettesítésre:

  1. Paraméter átadás (ős osztályú példányt várunk, de gyermeket kapunk),

  2. Példányosítás (a referencia ős osztály típusú, de egy gyermek példányra mutat),

  3. Felelősség injektálás (kívülről kapunk egy objektumot, aminek csak a felületét ismerjük).

Látni fogjuk, hogy minden tervezési minta a behelyettesíthetőség lehetőségén alapszik.

Csatoltság csökkentése objektum-összetétellel

A csatoltság (coupling) alatt annak fokát értjük, hogy egy osztály (vagy más modul) milyen mértékben alapszik a többi osztályon. A csatoltságot szokás a kohézió (cohesion) ellentéteként értelmezni. Alacsony fokú csatolás általában magas fokú kohéziót eredményez, illetve ez a másik irányban is igaz. A csatoltság mértékét Larry Constantine csoportjának munkája alapján a következő módon számoljuk.

Definíció: OOP-ben a csatoltság annak mértéke, hogy milyen erős kapcsolatban áll egy osztály a többi osztállyal. A csatolás mértéke két osztály, mondjuk A és B között növekszik, ha:

  1. A-nak van B típusú mezője.

  2. A meghívja B valamelyik metódusát.

  3. A-nak van olyan metódusa, amelynek visszatérési típusa B.

  4. A B-nek leszármazottja, vagy implementálja B-t.

A csatoltság szintjei (legerősebbtől a leggyengébbig):

  1. erősen csatolt (tightly coupled)

  2. gyengén / lazán csatolt (loosly coupled)

  3. réteg (layer)

Az erős csatoltság erős függőséget is jelent. A következő fajta függőségeket szoktuk megkülönböztetni:

  1. Függőség a hardver és szoftver környezettől: Ha a programunk függ egy adott hardvertől vagy szoftvertől (leggyakrabban operációs rendszertől), akkor ez azt jelenti, hogy ezek speciális tulajdonságait kihasználjuk és így a programunk nem vagy csak nehezen portolható át egy másik környezetbe. Ennek egyik nagyszerű megoldása a virtuális gép használata. A forráskódunkat egy virtuális gép utasításaira fordítjuk le. Ha egy adott operációs rendszer felett egy adott hardveren fut a virtuális gép, akkor a mi programunk is futni fog.

  2. Implementációs függőség: Egy osztály függ egy másik implementációjától, azaz ha az egyik osztályt megváltoztatom, akkor meg kell változtatni a másik osztályt is, akkor implementációs függőségről beszélünk. Ez is egyfajta környezeti függés, egy osztály függ a környezetében lévő egy vagy több másik osztálytól, de itt a környezete a program forráskódja. Ha csak a másik osztály felületétől függünk, azaz teljesen mindegy, hogy hogyan implementáltuk a másik osztály metódusait, csak azok helyes eredményt adjanak, akkor nem beszélünk implementációs függőségről. Ezzel a függőséggel még részletesen fogunk foglalkozni.

  3. Algoritmikus függőség: Akkor beszélünk algoritmikus függőségről, ha nehézkes az algoritmusok finomhangolása. Gyakran előfordul, hogy a program egy-egy részét gyorsabbá kell tenni, mondjuk buborékos rendezés helyett gyors rendezést kell alkalmazni. Például ha a rendezés közben szemléltetjük a rendezés folyamatát, akkor nehéz lesz áttérni egyik rendezésről a másikra.

A három függőség közül csak az implementációs függőséggel foglalkozunk, de azzal nagyon részletesen. Már megemlítettük, hogy az öröklődés implementációs függőséget okoz. Nézzünk erre egy példát Java nyelven. A feladat a beépített HashSet osztály bővítése a betett elemek számolásával.

import java.util.*;

public class MyHashSet extends HashSet{

        private int addCount = 0;

        public boolean add(Object o){

                addCount++;

                return super.add(o);

        }

        public boolean addAll(Collection c){

                addCount += c.size();

                return super.addAll(c);

        }

        public int getAddCount(){ return addCount; }

}

Ebben a példában létrehoztuk örökléssel a MyHashSet osztályt. Annyival egészítettük ki az őst, hogy számoljuk, hány elemet adunk hozzá a hasító halmazhoz. Ehhez az addCount mezőt használjuk, ami kezdetben nulla. Két metódussal lehet elemet hozzáadni a halmazhoz, az add és az addAll metódussal, ezért ezeket felülírjuk. Az add megnöveli eggyel az addCount-ot és meghívja az ős add metódusát, hiszen az tudja hogyan kell ezt a feladatot megoldani, mi csak ráültünk a megoldásra. Az addAll hasonlóan működik, de ott több elemet adunk egyszerre hozzá a listához, ezért az addCount értékét az elemek számával növeli meg.

Ezt a feladatot mindenki hasonlóan készítette volna el, hiszen a kód újrahasznosítás legegyszerűbb formája az öröklés. Egy bökkenő van. Ez így nem megfelelően működik!

import java.util.*;

public class Main {

        public static void main(String[] args){

                HashSet s = new MyHashSet();

                String[] abc = {"a","b","c"};

                s.addAll(Arrays.asList(abc));

                System.out.println(s.getAddCount());

        }

}

Ebben a példában létrehoztunk egy 3 elemű tömböt, azt addAll metódussal hozzáadtuk a MyHashSet egyik példányához. Ezután kiíratjuk, hány elemet adtunk hozzá a halmazhoz. Azt várnánk, hogy a program azt írja ki, hogy 3, de e helyett az írja, hogy 6.

Mi történt? Nem tudtuk, hogy az ősben, azaz a HashSet osztályban, úgy van megvalósítva az addAll metódus, hogy az egy ciklusban hívogatja az add metódust, így veszi fel az elemeket. Amikor a gyermek addAll metódusát hívtuk, az hozzáadott 3-mat az addCount-hoz és meghívta az ős addAll metódusát. Az háromszor meghívta az add metódust. A késői kötés miatt nem az ős add metódusát, hanem a gyermek add metódusát hívta, ami szépen mindig 1-gyel növelte az addCount értékét. Így jött ki a 6. Azaz az történt, hogy csúnyán ráfáztunk az öröklődés okozta implementációs függőségre.

A fenti kódot úgy lehet kijavítani, hogy csak az add metódusban növeljük az addCount értékét:

import java.util.*;

public class MyHashSet extends HashSet{

        private int addCount = 0;

        public boolean add(Object o){

                addCount++;

                return super.add(o);

        }

        public int getAddCount(){ return addCount; }

}

Amikor írom a gyerek osztályt, tudnom kell, hogyan van az ős implementálva, különben hasonló nehezen megérthető problémákkal találhatom magam szembe. Ugyanakkor, ha kihasználom, hogy hogyan van implementálva az ős, akkor az ős változása eredményezheti, hogy a gyermeknek is változnia kell. Ez pedig implementációs függés!

Hogyan lehet ezt elkerülni? A megoldás, hogy ne öröklődést, hanem objektum összetételt használjunk. Mondjuk, ha az A osztálynak van egy B osztály típusú mezője, akkor azt mondjuk, hogy objektum összetételt használtunk.

Az öröklődés mindig kiváltható objektum összetétellel, hiszen az alábbi két végletekig leegyszerűsített program ugyanazt csinálja:

class A {

    public void m1() {

       Console.Write("hello");

    }

}

class B : A {

   

    public void m2() {

        m1();

    }

}

class Program {

  static void Main(string[] a)

    {

        B b = new B();

        b.m2();

        Console.ReadLine();

    }

}

class A {

    public void m1() {

       Console.Write("hello");

    }

}

class B {

    A a = new A();

    public void m2() {

        a.m1();

    }

}

class Program {

  static void Main(string[] a)

    {

        B b = new B();

        b.m2();

        Console.ReadLine();

    }

}

Itt a B osztály az A osztály gyermeke, így örökli az A osztályból az m1 metódust, amit m2 metódusban hív meg. A főprogramban meghívjuk az m2 metódust, amely meghívja az ősben lévő m1 metódust, ami kiírja, hogy hello.

Itt a B osztálynak van egy A típusú mezője. Ezt példányosítanunk kell. Az m2 metódus meghívja ezen a mezőn keresztül az m1 metódust. A főprogramban meghívjuk az m2 metódust, amely az objektum összetétel, azaz az „a” referencián keresztül hívja az m1 metódust, ami kiírja, hogy hello.

Az objektum összetétel nagyon rugalmas, hiszen az futási időben történik, szemben az öröklődéssel, ami már fordítási időben ismert. Ugyanakkor az öröklődést sokkal egyszerűbb felfogni, megérteni és elmagyarázni. Ezért objektum összetételt, ami kisebb csatoltságot, kisebb implementációs függőséget, és rugalmasabb kódot biztosít, csak akkor használjunk, ha már sok programozói tapasztalattal bírunk.

Amikor objektum összetételnél egy metódust úgy valósítok meg, hogy annak lényegi része csak az, hogy az összetételt megvalósító referencián keresztül meghívom annak egy metódusát, akkor azt mondjuk, hogy átdelegálom a felelősséget a beágyazott objektumnak. A fenti példában ilyen az m2 metódus, ami csak meghívja az m1 metódust. A felelősség delegálás fogalmának egyik formája a .NET keretrendszer callback mechanizmusa.

Az objektum összetételnél kérdés, hogy hogyan kapjuk meg az összetételben szereplő objektumot. A fenti példában saját példányt készítettünk. Ezzel a kérdéssel részletesen foglalkozunk a felelősség injektálás témakörén belül.

Későbbiekben látni fogjuk, hogy habár az öröklődést mindig ki lehet váltani objektum összetétellel, nem mindig ez a célravezető, hiszen öröklődés nélkül nincs többalakúság. Többalakúság nélkül pedig nem lehet rugalmas kódot írni.

Az objektum orientált tervezés alapelvei

Az objektum orientált tervezés alapelvei (object-oriented design principles) a tervezési mintáknál magasabb absztrakciós szinten írják le, milyen a „jó” program. A tervezési minták ezeket az alapelveket valósítják meg szintén még egy elég magas absztrakciós szinten. Végül a tervezési mintákat realizáló programok az alapelvek megvalósulásai. Az alapelveket természetesen úgy is fel lehet használni, hogy nem követjük a tervezési mintákat.

A tervezési alapelvek abban segítenek, hogy több, általában egyenértékű programozói eszköz, pl. öröklődés és objektum összetétel közül kiválasszuk azt, amely jobb kódot eredményez. Általában jó a kód, ha rugalmasan bővíthető, újrafelhasználható komponensekből áll és könnyen érthető más programozók számára is.

A tervezési alapelvek segítenek, hogy ne essünk például abba a hibába, hogy egy osztályba kódolunk mindent, hogy élvezzük a mezők, mint globális változók programozást gyorsító hatását. A tapasztalat az, hogy lehet programozni az alapelvek ismerete nélkül, vagy akár tudatos megszegésével, csak nem érdemes. Gondoljunk vissza a programozási technológiák alapelvére: „A program kódja állandóan változik!”. Azaz, ha rugalmatlan programot írunk, akkor a jövőben keserítjük meg az életünket, amikor egy változást kell belehegeszteni a programunkba. Inkább érdemes a jelenben több időt rászánni a fejlesztésre és biztosítani, hogy a jövőben könnyű legyen a változások kezelése. Ezt biztosítja számunkra az alapelvek betartása.

A GOF könyv 1. alapelve – GOF1

A GOF1 alapelv a Gang of Four (GOF) könyvben jelent meg 1995-ben. A könyv magyar címe: „Programtervezési minták, Újrahasznosítható elemek objektumközpontú programokhoz.” A könyv angol címe: „Design Patterns: Elements of Reusable Object-Oriented Software”. Az alapelv eredeti angol megfogalmazása: „Program to an interface, not an implementation”, azaz „Programozz felületre implementáció helyett”.

Mit jelent ez a gyakorlatban? Egyáltalán, hogy lehet implementációra programozni? Miért rossz implementációra programozni? Miért jó felületre?

Akkor programozunk implementációra, ha kihasználjuk, hogy egy osztály hogyan lett implementálva. Egy példát a MyHashSet osztályon keresztül már láttunk, amikor tudnunk kellett, hogyan lett az ős implementálva. Egy másik példa:

class NagySzám {

    //maximum ennyi számjegyű nagy szám

    private const int maxHossz = 20;

    //használt számrendszer alapja

    private const int alap = 10;

    //a számjegyek fordított sorrendben vannak

    //pl. 64 esetén: számjegyek[0]=4, számjegyek[1]=6

    private int[] számjegyek = new int[maxHossz];

    public NagySzám(int[] szám) {

        Array.Copy(szám, számjegyek, szám.Length);

    }

    public static NagySzám Összead(NagySzám S1, NagySzám S2) {

        int[] A = S1.számjegyek;

        int[] B = S2.számjegyek;

        int[] C = new int[maxHossz];

        int átvitel = 0;

        for(int i=0; i<maxHossz; i++) {

            C[i] = A[i] + B[i] + átvitel;

            átvitel = C[i] / alap;  C[i] %= alap;

        }

        return new NagySzám(C);

    }

    public long ToLong() {

        int i = maxHossz - 1; long szám = 0;

        while (számjegyek[i] == 0 && i>0) i--;

        for (; i >= 0; i--) {

            szám *= alap; szám += számjegyek[i];

        }

        return szám;

    }

}

A fenti példában készítettünk egy NagySzám osztályt, amely a nagy szám számjegyeit a számjegy tömbben tárolja. A legkisebb helyi értékű szám van a legkisebb indexen. A konstruktor ezt a tömböt tölti fel. Ezen túl két metódust látunk, az egyik összeadás, a másik long típusú számmá alakítja vissza a számjegyek tömbben tárolt számot. A számjegyek tömbben tárolt szám számrendszerének alapja az alap konstansban van eltárolva. Most 10-es számrendszer az alapértelmezett. De mi van, ha az alap megváltozik? Sajnos akkor minden kód, ami feltételezi, hogy 10-es számrendszert használunk, az elromlik. Például az alábbi is:

class Program {

    static void Main(string[] args) {

        int[] a = { 3, 5 }; //53

        int[] b = { 1, 2, 3 }; //321

        NagySzám A = new NagySzám(a);

        NagySzám B = new NagySzám(b);

        NagySzám C = NagySzám.Összead(A, B);

        Console.WriteLine(C.ToLong());

        Console.ReadLine();

    }

}

A fenti kód 374-et ír ki, ha az alap 10-es, 252-öt, ha az alapot átírjuk 8-ra, és így tovább. Tehát a NagySzám belső implementációja befolyásolja az őt használó osztályok működését. A problémát az okozza, hogy a bemeneti szám átalakítását lusták voltunk elvégezni, habár az a NagySzám felelőssége lenne. Az átalakítást a hívóra hagytuk, de rossz megoldás, mint ahogy láttuk.

A megoldás, ha egy olyan konstruktort csinálunk, ami egy long típusú számot vár. A másik konstruktort priváttá kell tenni. Ebben az esetben akármilyen belső alapot használunk, az nem fogja zavarni a többi osztályt. Tehát a jó megoldás (csak a megváltozott és az új kódot mutatjuk):

class NagySzám {

    …

    private NagySzám(int[] szám) { // ez mostmár privát

        Array.Copy(szám, számjegyek, szám.Length);

    }

    public NagySzám(long szám) { //új konstruktor

        int i = 0;

        while (szám > 0) {

            számjegyek[i] = (int)(szám % alap);

            szám /= alap;

            i++;

        }

    }

    …

}

class Program {

    static void Main(string[] args) {

        NagySzám A = new NagySzám(53);

        NagySzám B = new NagySzám(321);

        NagySzám C = NagySzám.Összead(A, B);

        Console.WriteLine(C.ToLong()); //374

        Console.ReadLine();

    }

}

Itt már akármilyen számrendszert használ a NagySzám, mindig 374 lesz az eredmény.

Látható, hogy általában akkor kényszerülünk implementációra programozni, ha az osztály felelősségi körét rosszul határoztuk meg és egy osztály több felelősségi kört is lefed, vagy egy felelősséget nem teljesen fed le, mint a NagySzám. Tehát, ha a kódunkban találunk olyan részeket, amely egy másik osztály implementációjától függ, akkor az hibás tervre utal.

Ha implementációra programozunk, és ha megváltozik az osztály, akkor a vele kapcsolatban álló osztályoknak is változniuk kell. Ezzel szemben, ha felületre programozunk, és megváltozik az implementáció, de a felület nem, akkor nem kell megváltoztatni a többi osztályt.

A GOF könyv 2. alapelve – GOF2

A GOF2 alapelv a Gang of Four (GOF) könyvben jelent meg 1995-ben. Az alapelv eredeti angol megfogalmazása: „Favor object composition over class inheritance”, azaz „Használj objektum összetételt öröklés helyett, ha csak lehet”.

Mit jelent ez a gyakorlatban? Egyáltalán mit jelent az objektum összetétel? Miért jobb az öröklődésnél? Mi a baj az öröklődéssel? Ha jobb az objektum összetétel, akkor miért nem mindig azt használjuk?

Már láttuk, hogy objektum összetétellel mindig ki lehet váltani az öröklődést. Az öröklés azért jó, mert akkor megöröklöm az ős összes szolgáltatását (metódusait), amit használni tudok. Objektum összetételnél ezen osztály egy példányára szerzek egy referenciát és azon keresztül használom a szolgáltatásait. Ez utóbbi futási időben dinamikusan változhat, hiszen, hogy melyik objektumra mutat az a referencia, az futási időben változtatható.

Az öröklődést IS-A kapcsolatnak hívjuk. Ha a Kutya osztály a Gerinces osztály gyermeke, akkor azt mondjuk, hogy „a kutya az egy gerinces”, azaz angolul „the dog ’is a’ vertebrate”. Innen jön az IS-A elnevezés.

Az öröklődést néha átlátszó újrahasznosításnak (white box reuse) is hívjuk. Ezzel arra utalunk, hogy az örökölt metódusokat használhatjuk és azokról sok információnk van, gyakran ismerjük a forráskódjukat is.

Az objektum összetételt HAS-A kapcsolatnak hívjuk. Ha a Kutya osztályban van egy gerinc nevű mező, ami Gerinces osztály típusú, akkor azt mondjuk, hogy „a kutyának van egy gerince”, azaz angolul „the dog ’has a’ backbone”. Innen jön a HAS-A elnevezés.

Az objektum összetételt átlátszatlan újrahasznosításnak (black box reuse) is hívjuk. Ezzel arra utalunk, hogy az összetételt megvalósító mezőn keresztül hívhatunk metódusokat, de azok megvalósításáról nincs információnk.

Az objektum összetételnek több fajtája van. Mindhárom esetben az összetételt megvalósító mezőt becsomagolom egy osztályba, de nem mindegy hogyan:

  1. Aggregáció (aggregation): A becsomagolt példány nem csak az enyém, azt más is használhatja. Példa: A kutyának van gazdija, de a gazdi nem csak a kutyáé.

  2. Kompozíció (composition): A becsomagolt példány csak az enyém, azt más nem is ismerheti. Példa: A kutyának van farka, azt csak ő csóválhatja.

  3. Becsomagolás (wrapping): Ez az átlátszó becsomagolás. Példa: A karácsonyfa karácsonyfa marad, akárhány díszt is teszek rá.

Először vizsgáljuk meg az első két típust. Vegyük a következő esetet, a gitárosnak van egy gitárja. Ugyebár ez egy objektum összetétel, hiszen HAS-A kapcsolat van a gitáros és a gitár között. Hogy melyik fajta összetételt kell választani azt egy egyszerű kérdéssel lehet eldönteni: Ha a gitáros meghal, vele temetik a gitárját is? Ha igen, akkor kompozícióról beszélünk, ha nem aggregációról. Azaz ha senki másnak nincs rá referenciája, és ezért a szemétgyűjtő lapátra teszi, ha már rám sincs szükség, akkor kompozíció. Aggregációra szép példa többek közt a stratégia tervezési minta. Kompozícióra szép példa az állapot tervezési minta.

A harmadik fajta összetétel az átlátszó csomagolás vagy angolul wrapping. Ez általában aggregáció, de lehet kompozíció is. Ilyenkor a becsomagolt osztállyal gyermek és összetétel viszonyban is állok. Az ős gyermeke vagyok, hogy ős típusként használható legyek. Illetve becsomagolom az ősöm egy példányát, hogy azon keresztül használhassam a szolgáltatásait. Erre szép példa a dekorátor tervezési minta.

Nézzünk egy szép példát objektum összetételre.

class Alvaz { /*...*/ }

class Kaszni { /*...*/ }

class Motor { /*...*/ }

class Auto

{

    Alvaz alvaz;

    Kaszni kaszni;

    Motor motor;

    public Auto(Alvaz alvaz, Kaszni kaszni, Motor motor)

    {

        this.alvaz = alvaz;

        this.kaszni = kaszni;

        this.motor = motor;

    }

}

Csatoltság szempontjából az öröklődés a legerősebb, majd jön a kompozíció és az aggregáció. Éppen ez az oka, hogy a GOF2 kimondja, hogy használjunk inkább objektum összetételt öröklődés helyett, hiszen az kisebb csatoltságot eredményez és így rugalmasabb kódot kapunk. Ugyanakkor ki kell emelni, hogy az ilyen kód nehezebben átlátható, ezért nem szabad túlzásba vinni az objektum összetételt.

Egy másik ok, ami miatt nem váltunk ki minden öröklődést objektum összetétellel az az, hogy öröklődés nélkül nincs többalakúság (legalábbis erősen típusos nyelvek esetén). Jól tudjuk, hogy egy osztály hierarchia tetején lévő osztály példánya helyett bármelyik gyermek osztály példányát használhatom. Erre gyakran van szükségem, hiszen így tudok a változásokhoz könnyen alkalmazkodni. Például van egy gyermek osztályom, ami Windows speciális, egy másik Unix speciális, az egyik környezetben az egyiket, a másikban a másikat használom. Hogy mégse szegjem meg a GOF2 ajánlást, azt a trükköt használjuk, hogy a hierarchia tetején lévő ős absztrakt. Ilyenkor azt mondom, hogy absztrakt őst alkalmazok. Ráadásul, ha a kód többi részében a gyermek osztály példányait csak az absztrakt ős felületén keresztül használom, akkor ezzel betartom a GOF1 ajánlást is.

Egy felelősség egy osztály alapelve – SRP (Single Responsibility Principle)

Az egy felelősség egy osztály alapelve (angolul: Single Responsibility Principle – SRP) azt mondja ki, hogy minden osztálynak egyetlen felelősséget kell lefednie, de azt teljes egészében. Eredeti angol megfogalmazása: „A class should have only one reason to change”, azaz „Az osztályoknak csak egy oka legyen a változásra”.

Már a GOF1 elvnél is láttuk, hogy ha egy osztály nem fedi le teljesen a saját felelősségi körét, akkor muszáj implementációra programozni, hogy egy másik osztály megvalósítsa azokat a szolgáltatásokat, amik kimaradtak az osztályból.

Ha egy osztály több felelősségi kört is ellát, például a MacsKuty eszik, alszik, ugat, egerészik, akkor sokkal jobban ki van téve a változásoknak, mintha csak egy felelősséget látna el. A MacsKuty osztályt meg kell változtatni, ha kiderül, hogy a kutyák nem csak a postást ugatják meg, hanem a bicikliseket is, illetve akkor is, ha a macskák viselkedése változik vagy bővül.

Már láttuk, hogy minden módosítás magában hordozza a veszélyt, hogy egy forráskód szörnyet kapjunk, amihez már senki se mer hozzányúlni. Az ilyen kód fejlesztése nagyon drága.

Gyakran szembesülünk azzal, hogy mi szeretnénk, hogy minden osztálynak csak egy oka legyen a változásra, azaz egy felelősségi kört lásson el, de minden osztálynak kell naplóznia vagy a jogosultságokat ellenőriznie. Erre ad megoldást az aspektus orientált programozás (ASP). Ezeket a felelősségeket, mint például a naplózás, kiemeljük egy úgynevezett aspektusba, amit bármely osztályhoz hozzákapcsolhatunk.

Az egy felelősség egy osztály elvére szép példa a felelősséglánc tervezési minta.

Nyitva zárt alapelv – OCP (Open-Closed Principle)

Az Open-Closed Principle (OCP), magyarul a nyitva zárt elv, kimondja, hogy a program forráskódja legyen nyitott a bővítésre, de zárt a módosításra. Eredeti angol megfogalmazása: „Classes should be open for extension, but closed for modification”.

Egy kicsit szűkebb értelmezésben, az osztály hierarchiánk legyen nyitott a bővítésre, de zárt a módosításra. Ez az jelenti, hogy új alosztályt vagy egy új metódust nyugodtan felvehetek, de meglévőt nem írhatok felül. Ennek azért van értelme, mert ha már van egy működő, letesztelt, kiforrott metódusom és azt megváltoztatom, akkor több negatív dolog is történhet:

  1. a változás miatt az eddig működő ágak hibásak lesznek,

  2. a változás miatt a vele implementációs függőségben lévő kódrészeket is változtatni kell,

  3. a változás általában azt jelenti, hogy olyan esetet kezelek le, amit eddig nem, azaz bejön egy új if vagy else, esetleg egy switch, ami csökkenti a kód átláthatóságát, és egy idő után már senki se mer hozzányúlni.

Az OCP elvet meg lehet fogalmazni a szintaxis szintjén is C# nyelv esetén: Ne használd az override kulcsszót, kivéve ha

  1. absztrakt vagy

  2. horog (angolul: hook)

metódust akarsz felülírni.

Megjegyzés: Mivel Java nyelven minden metódus virtuális, így ott nincs is override kulcsszó, ezért ott nem lehet a szintaxis szintjén megadni az OCP elvet.

Ugyebár az absztrakt metódusokat muszáj felülírni, de ez nem az OCP megszegése, hiszen az absztrakt metódusnak nincs törzse, így lényegében a törzzsel bővítem a kódot, nem módosítok semmit. A másik eset, amikor használhatok felülírást, a horog, vagy ismertebb nevükön hook, metódusok felülírása. Akkor beszélek horog metódusokról, ha a metódusnak ugyan van törzse, de az teljesen üres. Ezek felülírása nem kötelező, csak opcionális, így arra használják őket, hogy a gyermek osztályok opcionálisan bővíthessék viselkedésüket. Ezek felülírásával lényegében megint csak bővítem a kódot, nem módosítom, azaz nem szegem meg az OCP elvet.

Az OCP elvet a gyakorlatban nehéz betartani, mert ha csak felülírom C# nyelven a ToString vagy Java nyelven a toString metódust, akkor már meg is szegtem az elvet. Pedig ez egy nagyon gyakori lépés.

A következő rövid példában nem tarjuk be az OCP elvet:

class Alakzat

{

    public const int TEGLALAP = 1;

    public const int KOR = 2;

    int tipus;

    public Alakzat(int tipus) { this.tipus = tipus; }

    public int GetTipus() { return tipus; }

}

class Teglalap : Alakzat{Teglalap():base(Alakzat.TEGLALAP){}}

class Kor : Alakzat{ Kor():base(Alakzat.KOR){} }

class GrafikusSzerkeszto

{

    public void RajzolAlakzat(Alakzat a)

    {

        if (a.GetTipus() == Alakzat.TEGLALAP)RajzolTeglalap(a);

        else if (a.GetTipus() == Alakzat.KOR) RajzolKor(a);

    }

    public void RajzolKor(Kor k) { /* … */ }

    public void RajzolTeglalap(Teglalap t) { /* … */ }

}

Ha egy kódban if – else if szerkezetet látunk, akkor az valószínűleg azt mutatja, hogy nem tartottuk be az OCP elvet. Nem tartottuk be, hiszen, ha új alakzatot akarunk hozzáadni a kódhoz, akkor az if – else if szerkezetet tovább kell bővítenünk. Lássuk, hogy lehet ezt kivédeni:

abstract class Alakzat{ public abstract void Rajzol(); }

class Teglalap : Alakzat

{

    public override void Rajzol() { /* téglalapot rajzol */ }

}

class Kor : Alakzat

{

    public override void Rajzol() { /*kört rajzol */ }

}

class GrafikusSzerkeszto

{

    public void RajzolAlakzat(Alakzat a) { a.Rajzol(); }

}

A fenti példában bevezettünk egy közös őst, az absztrakt Alakzatot. A konkrét alakzatok csak felülírják az ős absztrakt Rajzol metódusát és kész is az új gyermek. Ebből akárhányat hozzáadhatunk, a meglévő kódot nem kell változtatni. Tehát itt betartjuk az OCP elvet.

Az OCP elv alkalmazására nagyon szép példa a stratégia és a sablon metódus tervezési minta. Az utóbbi hook metódusokra is ad példát.

Liskov féle behelyettesítési alapelv – LSP (Liskov Substitutional Principle)

A Liskov féle behelyettesítési elv, rövid nevén LSP, kimondja, hogy a program viselkedése nem változhat meg attól, hogy az ős osztály egy példánya helyett a jövőben valamelyik gyermek osztályának példányát használom. Azaz a program által visszaadott érték nem függ attól, hogy egy Kutya vagy egy Vizsla vagy egy Komondor példány lábainak számát adom vissza. Eredeti angol megfogalmazása: „If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T”.

Nézzünk egy példát, amely nem felel meg az LSP elvnek. A klasszikus ellenpélda az ellipszis – kör illetve a téglalap – négyzet példa. A kör olyan speciális ellipszis, ahol a két sugár egyenlő. A négyzet olyan speciális téglalap, ahol az oldalak egyenlő hosszúak. Szinte adja magát, hogy az kör az ellipszis alosztálya, illetve a négyzet a téglalap alosztálya legyen. Lássuk a téglalap – négyzet példát:

class Téglalap

{

    protected int a, b;

    //@ utófeltétel: a == x és b == \régi(b)

    public virtual void setA(int x) { a = x; }

    public virtual void setB(int x) { b = x; }

    public int Terület() { return a * b; }

}

class Négyzet : Téglalap

{

    // invariáns: a == b;

    // utófeltétel: a == x && b == x;

    public override void setA(int x) { a = x; b = x; }

    public override void setB(int x) { a = x; b = x; }

}

A fenti példába az a és b mezőt használjuk a téglalap oldalhosszainak tárolására. Mindkét mezőhöz tartozik egy szetter metódus. A Négyzet osztályban a két szetter metódust felül kellett írni, mert a négyzet két oldala egyenlő. Azt mondjuk, hogy ez a Négyzet osztály invariánsa, mert minden metódus hívás előtt és után igaznak kell lennie, hogy a két oldal egyenlő. A setA metódusnak megadtuk az utófeltételét is. A gond az, hogy a Négyzet osztályban a setA utófeltétele gyengébb, mint a Téglalap osztályban. Pedig, mint látni fogjuk, a gyermek osztályban az utófeltételeknek erősebbeknek, az előfeltételeknek gyengébbeknek kellene lennie, hogy betartsuk az LSP elvet.

class Program

{

    static void Main(string[] args)

    {

        Random rnd = new Random();

        for (int i = 0; i < 10; i++)

        {

            Téglalap rect;

            if (rnd.Next(2) == 0) rect = new Téglalap();

            else                  rect = new Négyzet();

            rect.setA(10);

            rect.setB(5);

            Console.WriteLine(rect.Terület());

        }

        Console.ReadLine();

    }

}

A fenti főprogram 50%-os valószínűséggel a Téglalap osztályt, 50%-os valószínűséggel ennek gyermek osztályát a Négyzetet példányosítja. Ha az LSP igaz lenne, akkor mindegy lenne, melyik osztály példányán keresztül hívjuk a Terület metódust, de ez nem igaz, mert a setA és a setB teljesen másképp viselkedik a két osztályban. Ennek megfelelően egyszer 50, egyszer 25 lesz a kiírt érték. Azaz a program viselkedése függ attól, melyik példányt használjuk, azaz az LSP elvet megszegtük.

Mi is volt a tényleges probléma a fenti példában. A probléma az, hogy a Négyzet alosztálya a Téglalapnak, de nem altípusa. Az altípus fogalmának megadásához be kell vezetnünk a kontraktus alapú tervezés (design by contract) fogalmait:

  1. előfeltétel,

  2. utófeltétel,

  3. invariáns.

A metódus előfeltétele írja le, hogy milyen bementre működik helyesen a metódus. Az előfeltétel általában a metódus paraméterei és az osztály mezői segítségével írja le ezt a feltételt. Például az Osztás(int osztandó, int osztó) metódus előfeltétele, hogy az osztó ne legyen nulla.

A metódus előfeltétele írja le, hogy milyen feltételnek felel meg a visszaadott érték, illetve milyen állapotátmenet történt, azaz az osztály mezői hogyan változnak a metódus hívás hatására. Például a Maximum(int X, int Y) utófeltétele, hogy a visszatérési érték X, ha X>Y, egyébként Y.

A metódus kontraktusa az, hogy ha a hívó úgy hívja meg a metódust, hogy igaz az előfeltétele, akkor igaz lesz az utófeltétele is a metódus lefutása után. Az előfeltétel és az utófeltétel így két állapot közti átmenetet ír le, a metódus futása előtti és utáni állapotét. Az elő- és utófeltétel párok megadása helyett adhatunk egy úgynevezett állapot átmeneti megszorítást (ez ugyanazt feladatot látja el, mint a Turing-gépek delta függvénye, csak predikátumként megadva), ami leírja az összes lehetséges állapot átmenetet. E helyett a szakirodalom ajánlja még a történeti megszorítást (history constraint) használatát, de erre nem térünk ki részletesen.

Ezen túl még beszélünk osztály invariánsról is. Az osztály invariáns az osztály lehetséges állapotait írja le, azaz az osztály mezőire ad feltételt. Az invariánsnak minden metódus hívás előtt és után igaznak kell lennie.

Tegyük fel, hol hogy az N(égyzet) osztály gyermeke a T(églalap) osztálynak. Azt mondjuk, hogy az N egyben altípusa is a T osztálynak akkor és csak akkor, ha

  1. a T mezői felett az N invariánsából következik a T invariánsa,

  2. T minden metódusára igaz, hogy

  3. a T mezői felett az N állapot átmeneti megszorításából következik a T állapot átmeneti megszorítása.

Az utolsó feltételre azért van szükség, mert a gyermek osztályban lehetnek új metódusok is, és ezeknek is be kell tartaniuk az ős állapot átmeneti megszorítását. Ha az ősben „egyes” állapotból nem lehet közvetlenül elérni a „hármas” állapotot, akkor ezt a gyermekben sem szabad.

A Téglalap – Négyzet példában az invariánsra vonatkozó feltétel igaz, hiszen a Téglalap invariánsa IGAZ, a Négyzeté pedig a == b és a == b ==> IGAZ. Az előfeltételekre vonatkozó feltétel is igaz. Az utófeltételek feltétele viszont hamis, mert a setA metódus esetén az a == x ÉS b == x ==> a == x ÉS b == \régi(b) állítás nem igaz. Ezért a Négyzet nem altípusa a Téglalapnak.

Az altípus definícióját informálisan gyakran így adjuk meg:

  1. az ős mezői felett az altípus invariánsa nem gyengébb, mint az ősé,

  2. az altípusban az előfeltételek nem erősebbek, mint az ősben,

  3. az altípusban az utófeltételek nem gyengébbek, mint az ősben,

  4. az altípus betartja ősének történeti megszorítást (history constraint).

Erősebb feltételt úgy kapok, ha az eredeti feltételhez ÉS-sel veszek hozzá egy plusz feltételt. Gyengébb feltételt úgy kapok, ha az eredeti feltételhez VAGY-gyal veszek hozzá egy plusz feltételt. Egy kicsit könnyebb ezt megérteni, ha halmazokkal fogalmazzuk meg. Mivel a gyengébb feltétel nagyobb halmazt, az erősebb feltétel pedig kisebb halmazt jelent, a fenti definíció így is megadható:

  1. az ős mezői felett a belső állapotok halmaza kisebb vagy egyenlő az altípusban, mint az ősben,

  2. minden metódus értelmezési tartománya nagyobb vagy egyenlő az altípusban, mint az ősben,

  3. minden metódusra a metódus hívása előtti lehetséges belső állapotok halmaza nagyobb vagy egyenlő az altípusban, mint az ősben,

  4. minden metódus érték készlete kisebb vagy egyenlő az altípusban, mint az ősben,

  5. minden metódusra a metódus hívása utáni lehetséges belső állapotok halmaza kisebb vagy egyenlő az altípusban, mint az ősben,

  6. az ős mezői felett a lehetséges állapotátmenetek halmaza kisebb vagy egyenlő az altípusban, mint az ősben.

Ha a Téglalap – Négyzet példában betartottuk volna az OCP elvet, akkor az LSP elvet se sértettük volna meg. Hogy lehet betartani az OCP elvet ebben a példában? Úgy, hogy egyáltalán nem készítünk setA és setB metódust, mert akkor azokat mindenképpen felül kellene írni. Csak konstruktort készítünk és a terület metódust. Az OCP és az LSP általában egymást erősítik.

Interfész szegregációs alapelv – ISP (Interface Segregation Principle)

Az interfész szegregációs alapelv (angolul: Interface Segregation Principle – ISP) azt mondja ki, hogy egy sok szolgáltatást nyújtó osztály fölé el kell helyezni interfészeket, hogy minden kliens, amely használja az osztály szolgáltatásait, csak azokat a metódusokat lássa, amelyeket ténylegesen használ. Eredeti angol megfogalmazása: „No client should be forced to depend on methods it does not use”, azaz „Egy kliens se legyen rászorítva, hogy olyan metódusoktól függjön, amiket nem is használ”.

Ez az elv segít a fordítási függőség visszaszorításában. Képzeljük csak el, hogy minden szolgáltatást, például egy fénymásoló esetén a fénymásolást, nyomtatást, fax küldést, a példányok szétválogatását egy nagy Feladat osztály látna el. Ekkor, ha a fénymásolás rész megváltozik, akkor újra kell fordítani a Feladat osztályt és lényegében az egész alkalmazást, mert mindenki innen hívja a szolgáltatásokat. Ez egy néhány 100 ezer soros forráskód esetén bizony már egy kávészünetnyi idő. Nyilván így nem lehet programot fejleszteni.

A megoldás, hogy minden klienshez (kliensnek nevezzük a forráskód azon részét, ami használja a szóban forgó osztály szolgáltatásait) készítünk egy interfészt, amely csak azokat a metódusokat tartalmazza, amelyeket a kliens ténylegesen használ. Tehát lesz egy fénymásoló, egy nyomtató, egy fax és egy szétválogatás interfész. A Feladat ezen interfészek mindegyikét implementálja. Az egyes kliensek a Feladat osztályt a nekik megfelelő interfészen keresztül fogják csak látni, mert ilyen típusú példányként kapják meg. Ezáltal ha megváltozik a Feladat osztály, akkor az alkalmazásnak csak azt a részét kell újrafordítani, amit érint a változás.

Az ilyen monumentális osztályokat, mint a fenti példában a Feladat, kövér osztályoknak nevezzük. Gyakran előfordul, hogy egy sovány kis néhány száz soros osztály el kezd hízni, egyre több felelősséget lát el, és a végén egy kövér sok ezer soros osztályt kapunk. A kövér osztályokat az egy felelősség egy osztály elv (SRP) kizárja, de ha már van egy ilyen osztályunk, akkor egyszerűbb felé tenni néhány interfészt, mint a kövér osztályt szétszedni kisebbekre. Egy egyszerű példa:

interface IWorkable { void work(); }

interface IFeedable { void eat(); }

interface IWorker : IFeedable, IWorkable {}

class Worker : IWorker

{

    public void work() { /*.dolgozik */ }

    public void eat() { /*.eszik */ }

}

class Program

{

    public static void Main(String[] args)

    {

        IWorkable workable = new Worker();

        IFeedable feedable = new Worker();

        IWorker worker = new Worker();

    }

}

Ha betartjuk az interfész szegregációs elvet, akkor a forráskód kevésbé csatolt lesz és így egyszerűbben változtatható. Erre az elvre szép példa az illesztő tervezési minta.

Függőség megfordításának alapelve – DIP (Dependency Inversion Principle)

A függőség megfordításának elve (angolul: Dependency Inversion Principle – DIP) azt mondja ki, hogy a magas szintű komponensek ne függjenek alacsony szintű implementációs részleteket kidolgozó osztályoktól, hanem épp fordítva, a magas absztrakciós szinten álló komponensektől függjenek az alacsony absztrakciós szinten álló modulok. Eredeti angol megfogalmazása: „High-level modules should not depend on low-level modules. Both should depend on abstractions.” Azaz: „A magas szintű modulok ne függjenek az alacsony szintű moduloktól. Mindkettő függjön az absztrakciótól.” Ezt ennél frappánsabban így szoktuk mondani: „Absztrakciótól függj, ne függj konkrét osztályoktól”.

Az alacsony szintű komponensek újrafelhasználása jól megoldott az úgynevezett osztálykönyvtárak (library) segítségével. Ezekbe gyűjtjük össze azokat a metódusokat, amikre gyakran szükségünk van. A magas szintű komponensek, amik a rendszer logikáját írják le, általában nehezen újrafelhasználhatók. Ezen segít a függőség megfordítása. Vegyük a következő egyszerű leíró nyelven íródott kódot:

public void Copy() { while( (char c = Console.ReadKey()) != EOF) Printer.printChar(c); }

Itt a Copy metódus függ a Console.ReadKey és a Printer.printChar metódustól. A Copy metódus fontos logikát ír le, a forrásból a célra kell másolni karaktereket file vége jelig. Ezt a logikát sok helyen fel lehet használni, hiszen a forrás bármi lehet és a cél is, ami karaktereket tud beolvasni, illetve kiírni. Ha most ezt a kódot újra akarom hasznosítani, akkor két lehetőségem van. Az első, hogy if – else – if szerkezet segítségével megállapítom, hogy most melyik forrásra, illetve célra van szükségem. Ez nagyon csúnya, nehezen átlátható, módosítható kódot eredményez. A másik lehetőség, hogy a forrás és a cél referenciáját kívülről adja meg a hívó felelősség injektálásával (dependency injection).

A felelősség injektálásának több típusa is létezik:

  1. Felelősség injektálása konstruktorral: Ebben az esetben az osztály a konstruktorán keresztül kapja meg azokat a referenciákat, amiken keresztül a neki hasznos szolgáltatásokat meg tudja hívni. Ezt más néven objektum összetételnek is nevezzük és a leggyakrabban épp így programozzuk le.

  2. Felelősség injektálása szetter metódusokkal: Ebben az esetben az osztály szetter metódusokon keresztül kapja meg azokat a referenciákat, amikre szüksége van a működéséhez. Általában ezt csak akkor használjuk, ha opcionális működés megvalósításához kell objektum összetételt alkalmaznunk.

  3. Felelősség injektálása interfész megvalósításával. Ha a példányt a magas szintű komponens is elkészítheti, akkor elegendő megadni a példány interfészét, amit általában maga a magas szintű komponens valósít meg, de paraméter osztály paramétereként is jöhet az interfész.

  4. Felelősség injektálása elnevezési konvenció alapján. Ez általában keretrendszerekre jellemző. A Kutya osztály Csont mezőjébe automatikusan bekerül egy KutyaCsont példány. Illetve ez szabályozható egy XML konfigurációs állománnyal is. Ezeket csak nagyon tapasztalt programozóknak ajánljuk, mert nyomkövetéssel nem lehet megtalálni, hogy honnan jön a példány és ez nagyon zavaró lehet.

A fenti egyszerű Copy metódus a függőség megfordítás elvének megfelelő változata felelősség injektálása konstruktorral megoldással a következőképpen néz ki:

class Source2Sink

{

    private System.IO.Stream source;

    private System.IO.Stream sink;

    public Source2Sink(Stream source, Stream sink)

    {

        this.source = source;

        this.sink = sink;

    }

    public void Copy()

    {

        byte b = source.ReadByte();

        while (b != 26)

        {

            sink.WriteByte(b);

            b = source.ReadByte();}

        }

    }

}

Sokan kritizálják a függőség megfordításának elvét, miszerint az csak az objektum összetétel használatának, azaz a GOF2 elvnek, egy következménye. Mások szerint ez egy önálló tervezési minta. Mindenestre a haszna vitathatatlan, ha rugalmas kód fejlesztésére törekszünk.

További tervezési alapelvek

Itt említjük meg azokat a tervezési alapelveket, amelyek a szakirodalomban kevésbé elfogadottak, ugyanakkor mégis érdemes megismerkedni velük.

Hollywood alapelv – HP (Hollywood Principle)

A Hollywood alapelv eredeti angol megfogalmazása: „Don’t call us, we’ll call you”, azaz „Ne hívj, majd mi hívunk”. A Hollywood alapelvet a következő példával szemléltethetjük: Rómeó szerepére szereplőválogatást hirdetnek. Több száz jelentkező van. A válogatás után mindenki szeretné megtudni, ő kapta-e a hőn áhított szerepet. Két megoldás van:

  1. Mindenki kisebb-nagyobb időközönként érdeklődik, ő kapta-e a szerepet. Ilyenkor a titkár egyre idegesebben válaszol, hogy még nincs döntés, hívjon később. Ez a „busy waiting”.

  2. Következő alkalommal a titkár már jó előre közli minden színésszel, ne hívj, majd mi hívunk. Azaz senki se érdeklődjön, ha majd megvan a döntés, mindenkit értesítünk, hogy megkapta-e a szerepet. Ez a Hollywood elv alkalmazása.

A busy waiting nagyon káros, mert foglalja a processzor időt, lassítja a többi szálat. Tipikus megoldása, hogy egy végtelen ciklusban egy sleep hívással várunk, majd hívjuk a metódust, ami megmondja, hogy várni kell-e még. Ha felébredhetünk egy break utasítással kilépünk a ciklusból.

A busy waiting megoldásnak van létjogosultsága is, de csak nagyon kevés helyzetben. A legismertebb a megfigyelő kutya, angolul watch dog, amikor egy távoli objektumot kérdezgetünk (ping-elgetünk) megadott időközönként, hogy él-e még. Ezt, máshogyan nem tudjuk megoldani, hiszen, ha elmegy az áram, a távoli gép nem tud még egy üzenetet küldeni, hogy mostantól elérhetetlen lesz. Ha a figyelő kutya észreveszi, hogy lehalt a távoli objektum, akkor annak feladatát másra osztják.

A Hollywood elv azt mondja ki, hogy ne az kérdezgessen, aki az eseményre vár, hanem az esemény értesítse a várakozókat. Ezt a megoldást használja például a Java esemény kezelése. Ha lenyomok egy gombot, akkor keletkezik egy esemény, de ezen túl semmi se történik. Ha azt akarom, hogy történjen is valami, akkor fel kell íratnom az eseményre egy figyelőt (Java nyelvhasználattal listener-t). Ha kiváltódik az esemény, akkor az összes feliratkozott figyelő értesítést kap. Pontosan ezt valósítja meg a figyelő tervezési minta.

A Hollywood elv akkor ad nagy segítséget, ha egy-több kapcsolatban vannak az objektumok és a több oldal dinamikusan változik, azaz fel is lehet iratkozni, meg le is. Egyik alternatívája az üzenetsugárzás (angolul: broadcasting), amikor egy üzenet mindenki máshoz eljut. Ekkor az üzenet küldője nem feltétlenül ismeri az üzenet fogadóját, ami előny lehet. Hátránya, hogy olyan objektum is megkaphatja az üzenetet, akit nem érdekel.

Demeter törvénye / a legkisebb tudás elve

Demeter törvénye, vagy más néven a legkisebb tudás elve (angolul: Law of Demeter / Principle of Least Knowledge) kimondja, hogy egy osztály csak a közvetlen ismerőseit hívhatja. Eredeti angol megfogalmazása: „Talk only to your immediate friends”. Azaz: „Csak a közvetlen ismerőseiddel beszélj”.

Praktikusan úgy is megfogalmazhatjuk ezt az elvet, hogy csak annak a példánynak a metódusát hívhatjuk, akire van referenciánk, azaz az A.getB().C() alakú hívások tilosak. Ez az elv azért hasznos, mert ha betartjuk, akkor a változások mindig csak lokális hatásúak lesznek.