Ugrás a tartalomhoz

Programozási technológiák – Jegyzet

Kollár Lajos, Sterbinszky Nóra (2014)

Debreceni Egyetem

1. fejezet - Bevezetés

1. fejezet - Bevezetés

Mitől válhat egy kezdő programozó jó programozóvá? Attól, hogy fejből fújja egy adott programozási nyelv szintaktikai szabályait? Aligha. Attól, hogy nagy részletességgel ismeri különböző programkönyvtárak alkalmazásprogramozói interfészének (vagyis API-jának) az interfészeit, osztályait, metódusait? Nem valószínű, hogy mindez elegendő volna a jó programozóvá váláshoz.

Egy (természetesen nagyon fontos) dolog ugyanis egy programozási nyelv szintakszisának az elsajátítása, de csak attól még, hogy lefordítható programokat ír valaki, nem válik automatikusan jó programozóvá. Ahhoz, hogy a jó programozó válás útján elinduljon valaki, mindenképpen szükség van némi elhivatottságra, hogy jó programozóvá akarjon válni az illető! Ez talán a legfontosabb összetevő. Ha ez megvan, már csak rengeteg gyakorlásra és tanulásra van szükség, de hát egy elhivatott ember számára ez persze nem okoz gondot.

A tanulási folyamatot már gyerekkorban is minta alapon végezzük: szüleinktől, környezetünktől ellessük a legjobb(nak vélt) fogásokat azért, hogy a későbbiekben ezt a megszerzett tudást újrahasznosítva a legkülönbözőbb élethelyzetek leküzdésében segítségünkre legyenek. A mintákon keresztül mintegy szemléletmódot is tanulunk, amelyet aztán sokszor mélyen és hosszú távon magunkkal hordozunk..

A programozó tanulási folyamata szintén nagymértékben minta alapú: az évek során felhalmazódott tudást és legjobb gyakorlatokat követve készítjük programjainkat.Egy kezdő, de eléggé elhivatott programozó számára persze nagyon fontos, hogy megismerje ezeket a mintákat.

E jegyzet elsősorban a Debreceni Egyetem Informatikai Karának másodéves programtervező informatikus alapszakos hallgatói számára íródott, akik ekkorra már remélhetőleg megismerkedtek legalább két programozási nyelv alapelemeivel, amelyek közül az egyik a Java. Egy bevezető programozási kurzus célja általában a nyelvi alapelemek megismertetése és begyakoroltatása, az alapvető vezérlési szerkezetek és algoritmusok, valamint az API legfontosabb elemeinek a bemutatása, azonban ennél több nem nagyon fér a szűkös időkeretbe. Holott, mint említettük, fontos a legjobb gyakorlatok, a minták, a megfelelő szemléletmód kialakítása. Talán fontosabb is, mint a konkrét eszközrendszer.

Éppen ezért a jegyzet olvasója vissza-visszatérően különféle alapelvekbe és mintákba fog botlani, amelyek megismerése, megértése és alkalmazása lehetőleg segítségére lesz az úton. Jó utat!

Objektumorientált tervezési alapelvek

Az objektumorientált tervezés alapelvei (object-oriented design principles) a későbbiekben tárgyalásra kerülő 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 (éppen ezért a későbbiekben ezen elvek egyikére-másikára vissza is fogunk utalni). A tervezési mintákat megvalósító programokat az alapelvek manifesztálódásaként tekinthetjük.

Az ebben a szakaszban leírt alapelveket természetesen úgy is alkalmazthatjuk, hogy nem ismerjük (vagy csak egyszerűen nem alkalmazzuk) a tervezési mintákat. Az objektumorientált tervezési alapelvek abban nyújtanak segítséget, hogy több, általában egyenértékű programozói eszköz (például öröklődés és objektum-összetétel) közül kiválasszuk azt, amely jobb kódot eredményez. A jóság természetesen relatív fogalom, azonban az elmúlt évtizedekben kialakultak olyan általános jellemzők, amelyek alapján egyik-másik megoldásra rámondható, hogy jobb a többinél. Ilyen általános jósági jellemző, ha a kód 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 abban 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 ezen alapelvek ismerete nélkül, vagy akár tudatos megszegésével, csak nem érdemes. Ha rugalmatlan, nehezen változtatható, karbantartható programot írunk, akkor a jövőbeli énünk (és kollégáink) életét keserítjük meg, hiszen ha egy változtatást kell elvégezni, az ezáltal nehézkessé válhat. Inkább érdemes a jelenben több időt rászánni a fejlesztésre, és biztosítani, hogy a jövőben könnyebb legyen a változások kezelése. Ezt biztosítja számunkra az alapelvek betartása.

A további alszakaszokban néhány széles körben ismert és elterjedt programozási, programtervezési alapelvet mutatunk röviden be. Ezek között természetesen vannak egymásra hasonlító elvek is, de hát egy jó alapelv jó alapelv marad.

Ne ismételd önmagad (Don't Repeat Yourself, DRY)

A Ne ismételd önmagad! alapelv először Andy Hunt és Dave Thomas [PRAGPROG1999] könyvében jelent meg, ahol elég széles körben alkalmazandó irányelvként határozták meg. Alkalmazandó nem csak a programkódra, de az adatbázissémákra, teszttervekre, sőt, még a dokumentációra is. Az alapelv röviden úgy fogalmazható meg, hogy egy rendszeren belül a tudás minden darabkájának egyetlen, egyértelmű és megbízható reprezentációval kell rendelkeznie.[1]A DRY alapelv sikeres alkalmazása esetén a rendszer egy elemének a módosítása nem igényli a rendszer más, a módosított elemmel kapcsolatan nem lévő részek megváltoztatását. Fontos, hogy fel tudjuk ismerni az ismétlődő részeket, és valamilyen (az ismétlődés jellegétől függő) absztrakció alkalmazásával szüntessük meg azokat. A legegyszerűbb programozási példa erre az ismétlődő kódrészletek önálló metódusba történő kiemelése (procedurális absztrakció), majd az ismétlődő kódrészek metódushívásra történő lecserélése. A DRY alapelv megsértését angol betűszóval gyakran WET-nek (Write Everything Twice, vagy We Enjoy Typing) nevezik.

Kerüljük a felesleges bonyodalmakat (Keep It Simple Stupid, KISS)

Ez az irányelv az 1960-as években az amerikai haditengerészetnél született meg, és lényege, hogy tiszta, könnyen érthető megoldásokra törekedjünk. Albert Einstein szavaival élve: egyszerűsítsük a dolgokat, amennyire csak lehet, de ne jobban. [2] A KISS alapelvet programozói tevékenységre értve azt mondhatnánk, hogy tartsd a kódodat pofonegyszerű állapotban, vagyis, éppen annyit valósítsunk meg, amennyire szükség van, és ne bonyolítsuk el feleslegesen a dolgokat.

Demeter törvénye (Law of Demeter)

Demeter törvénye röviden úgy fogalmazható meg, hogy ne beszélgess idegenekkel! Ennek a törvénynek a betartásával könnyebben karbantartható és adaptálható szoftverhez jutunk, hiszen az objektumok kevésbé függnek más objektumok belső felépítésétől, ezért az objektumok felépítése sokkal könnyebben módosítható, akár a hívó szerkezetének módosítása nélkül is.

Tegyük fel, hogy az A objektum igénybe veheti a B objektum egy szolgáltatását (meghívja egy metódusát), de az A objektum nem érheti el a B objektumon keresztül egy C objektum szolgáltatásait. Ez azt jelentené, hogy az A objektumnak implicit módon a szükségesnél jobban kell ismernie a B objektum belső felépítését. A megoldás a B objektum felépítésének módosítása oly módon, hogy az A objektum közvetlenül hívja B objektumot, és a B objektum intézi a szükséges hívásokat a megfelelő alkomponensekhez. Ha a törvényt követjük, kizárólag B objektum ismeri saját belső felépítését.

Formálisan ezt azt jelenti, hogy a törvény betartása esetén egy o objektum egy m metódusa csak az alábbi objektumok metódusait hívhatja:

  • magáét o-ét,

  • m paramétereiét,

  • bármely, m-en belül létrehozott/példányosított objektumét,

  • o közvetlen kompnensobjektumaiét, illetve

  • az o által az m hatáskörében hozzáférhető globális változóiét.

Másképpen ezt egypontszabályként is nevezhetnénk, hiszen amíg az o.m() hívás megfelel a törvénynek, az o.a.p() vagy éppen az o.m().p() nem (hiszen ezek az o szempontjából idegenek). Ezt a szemléletmódot fejezi ki a kutyasétáltatás analógiája is: ha sétáltatni vinnénk a kutyát, nem közvetlenül a lábainak mondjuk meg, hogy sétáljanak, hanem magának a kutyának, amely a saját felépítésének ismeretében utasítja erre a lábait. Vagyis itt egy delegáció történik.

Vonatkozások szétválasztása (Separation of concerns)

A vonatkozások szétválasztásánk alapelve szerint egy programot lehetőleg úgy bontsunk fel különféle részekre, hogy az egyes részek külön-külön vonatkozásokat fedjenek le. Egy vonatkozás (concern) olyan információk összessége, amelyek befolyásolják a programkódot. Ez alatt olyan felelősségi köröket értünk, amelyek akár az alkalmazás funkciójához is kötődhetnek, de attól függetlenek is lehetnek. Utóbbira példa lehet egy gyorsítótár kezelése, vagy akár a naplózás, amelyre, mint feladatra, különféle funkciókat megvalósító programelemeknek is szüksége van, de az, hogy maga a naplózás miként, és legfőképpen hová történjen, teljesen független a funkciótól (ezért egy önálló vonatkozást alkot).

A vonatkozások megfelelő szétválasztásával szoftverelemeinket úgy tudjuk kialakítani, hogy a lehető legkisebb átfedés alakuljon ki közöttük. Ez kapcsolatba hozható a DRY alapelvvel is, hiszen ezáltal az egyes vonatkozások elemei különállóan kezelhetőek, és például a kívánt naplózási szint beállítása is egy helyen elvégezhető az egész alkalmazás vonatkozásában, ahelyett, hogy az egyes funkciók naplózásánál kelljen azt rendre szabályozni.

Ráadásul, ha egy darab kódnak nincs világosan meghatározott feladata, akkor nehéz lesz megérteni, használni és adott esetben javítani vagy bővíteni, ezért ennek az elvnek az alkalmazása segíthet egy letisztult gondolkodás kialakításában is.

Az aspektusorientált programozás is a vonatkozások különválasztásának elvén épül fel. Az úgynevezett keresztező vonatkozásokat (vagyis a több osztályt is érintő vonatkozásokat, mint amilyen a fenti példában a naplózás), kiemeljük egy úgynevezett aspektusba, amely bármely osztályhoz hozzákapcsolható.

A felelősségek hozzárendelésének általános mintái (General Responsibility Assignment Software Patterns, GRASP)

A GRASP alapelvek (ahogyan azt az angol nyelvű elnevezés is mutatja) a felelősségek hozzárendelésének általános mintáit határozzák meg. Először leírásra Craig Larman könyvében [LARMAN2004] került, és tulajdonképpen minták egy gyűjteményéről van szó. Az ide tartozó mintákat és rövid leírásukat az alábbi táblázat foglalja össze:

1.1. táblázat - Felelősségek hozzárendelésének általános mintái

MintaLeírás
Információs szakértő (Information expert)A felelősségeket mindig az információs szakértőhöz rendeljük,vagyis ahhoz az osztályhoz, amely birtokában van a felelősség megvalósításához szükséges információknak.
Létrehozó (Creator)Az A osztály egy példányának létrehozását bízzuk a B osztályra, ha az alábbiak valamelyike igaz:
  1. B tartalmazza A-t

  2. B aggregálja A-t

  3. B tartalmazza az A inicializálásához szükséges adatokat (vagyis B az A létrehozása szempontjából információs szakértő)

  4. B nyilvántartja A példányait

  5. B szorosan használja A-t

Természetesen a létrehozás szempontjából a későbbiekben tárgyalásra kerülő létrehozási minták is jó alternatívát biztosítanak.

Vezérlő (Controller)A rendszer eseményeinek kezelésének felelősségét egy olyan osztályhoz rendeljük, amely vagy a teljes rendszert/alrendszert/eszközt reprezentálja (ez tulajdonképpen a később tárgyalandó Homlokzat (Façade) tervezési minta alkalmazása), vagy egy olyan forgatókönyvet reprezentál, amelyben az esemény bekövetkezett.
Laza csatolás (Low/loose coupling)A csatolás annak mértéke, hogy az egyes komponensek mennyire szorosan kötődnek más komponensekhez, illetve mennyi információ birtokában vannak más komponensekről.

A felelősségeket úgy rendeljük az objektumainkhoz, hogy továbbra is lazán csatoltak maradjanak.

Nagyfokú kohézió (High cohesion)

A kohézió annak mértéke, hogy egyetlen komponens felelősségei mennyire szorosan kapcsolódnak egymáshoz.

A felelősségeket úgy rendeljük az objektumainkhoz, hogy fenntartsuk a magas kohéziót.
Polimorfizmus (Polymorphism)Amennyiben összetartozó viselkedések típustól (vagyis osztálytól) függően változnak, a viselkedést leíró felelősséget polimorf műveletek segítségével rendeljük azon típusokhoz, amelyeknél a viselkedés változik.
Pusztán gyártás (Pure fabrication)Néha az információs szakértő alkalmazása a kohézió csökkenését, illetve a kapcsolódás szorosabbá válását vonja magával, ami nem szerencsés. Ilyenkor hozzunk létre egy mesterséges osztályt, amely nem a problématér valamely fogalmát tükrözi, hanem a célja csupán a magas kohézió és a laza kapcsolódás fenntartása és az újrafelhasználhatóság elősegítése.
Indirekció (Indirection)A kapcsolódáson lazítani úgy tudunk, hogy két (túlságosan) erősen kapcsolódó objektum közé bevezetünk egy köztes objektumot, amely indirekció segítségével az objektumok között mediátor szerepet tölt be, így azok nem közvetlenül állnak majd kapcsolatban egymással.
Védett változatok (Protected variations)Azonosítsuk az előre látható változások által érintett elemeket, és csomagoljuk be őket egy stabil interfész mögé, így a polimorfizmus alkalmazásával az interfészhez különféle implementációkat társíthatunk.


GoF alapelvek

A GoF alapelvek a nevüket annak a könyvnek a szerzőiről kapták, amelyben először leírták őket. Ezt a könyvet [GOF2004] a Gang Of Four (GoF) néven elhíresült szerzőnégyes írta, és sok úgynevezett tervezési minta mellett két alapvető objektorientált tervezési alapelvet is megfogalmaztak.

Interfészre programozzunk, ne pedig implementációra!

Az osztályok közötti öröklődés alapjában véve csak egy módszer, amivel a szülőosztály szolgáltatásait kibővítjük. Segítségével gyorsan hozhatunk létre új objektumokat egy régi alapján. Különösebb munka nélkül kaphatunk új megvalósításokat, egyszerűen leszármaztatva a létező osztályokból, amire szükségünk van. Mindazonáltal az implementáció újrafelhasználása még nem minden. Az öröklődés azon tulajdonsága, hogy az azonos interfésszel rendelkező objektumok egy családját képes meghatározni (általában egy absztrakt osztályból örökölve, vagy egy interfészt implementálva) szintén fontos, hiszen ez a polimorf működés alapja.

Öröklődés során minden, az absztrakt műveletek konkretizálását végző osztály osztozik az interfészen, vagyis az interfészen változtatni az alosztályok illetve implementációs osztályok nem tudnak (még elrejteni sem tudják az öröklött/kapott műveleteket!). A konkrét osztályok „mindössze” új műveleteket adhatnak hozzá, illetve a meglévőket felüldefiniálhatják. Így viszont minden alosztály tud majd válaszolni az interfésznek megfelelő kérelmekre, amik lehetővé teszik, hogy a kliensek függetlenek legyenek az általuk felhasznált objektumok tényleges típusától (nem is kell őket ismerniük, így ez a lazán csatolás irányába tett nagy lépésként is értelmezhető), amíg az interfész megfelel a kliens által vártnak. A kliens így csak az interfésztől függ, és nem az implementációtól, vagyis anélkül cserélhetőek le a szolgáltatást nyújtó konkrét osztályok az interfész változatlanul hagyása mellett, hogy az a kliensekre bármiféle kihatással lenne.

Használjunk objektum-összetételt öröklődés helyett, ha lehet!

Az objektumorientált rendszerekben az újrafelhasználás két leggyakrabban használt módszere az öröklődés és az objektum-összetétel (objektumkompozíció).

Megjegyzés

Az objektumkompozíció egy olyan viszony, amely két objektum között egy nagyon szoros rész–egész kapcsolatot ír le. Az egész lesz a felelős a rész létrehozásáért (lásd a Létrehozó GRASP mintát) és megszüntetéséért is, ugyanis egy kompozíciós kapcsolatban a rész élettartama függ az egészétől: ha az egész megszűnik létezni, törlődik a rész is. Példa erre egy számla és számlatételeinek viszonya: egy számla számlatételeket tartalmaz (rész–egész viszony), azonban egy számlatétel csak egy számlához kötődően létezik, vagyis ha egy számla törlésre kerül, a részeit alkotó számlatételek szintén megszűnnek létezni.

Ahogy azt már említettük, az öröklődés arra ad módot, hogy egy osztály megvalósítását egy másik osztály segítségével határozzuk meg. Az alosztályokon keresztül történő újrafelhasználást fehérdobozos újrafelhasználásnak nevezzük. A „fehér doboz” itt a láthatóságra utal: az öröklődéssel az alosztályok gyakran látják a szülőosztály belső részeit.

Az objektum-összetétel az öröklődés alternatívája. Itt az új szolgáltatások úgy jönnek létre, hogy kisebb részekből építünk fel objektumokat, hogy több szolgáltatással rendelkezzenek. Az objektum-összetételnél az összeépített objektumoknak jól meghatározott interfésszel kell rendelkezniük. Az ilyen újrafelhasználást feketedobozos újrafelhasználásnak nevezzük, mert az objektumok belső részei láthatatlanok. Az objektumok „fekete dobozokként” jelennek meg.

Az öröklődésnek és az összetételnek egyaránt megvannak a maga előnyei és hátrányai. Az öröklődés statikusan, fordítási időben történik, és használata egyértelmű, mivel közvetlenül a programnyelv támogatja; továbbá az öröklődés könnyebbé teszi az újrahasznosított megvalósítás módosítását is. Ha egy alosztály felülírja a műveletek némelyikét, de nem mindet, akkor a leszármazottak műveleteit is megváltoztathatja, feltéve, hogy azok a felüldefiniált műveleteket hívják.

De az öröklődésnek vannak hátrányai is. Először is, a szülőosztályoktól örökölt implementációt futásidőben nem változtathatjuk meg, mivel az öröklődés már fordításkor eldől. Másodszor – és ez sokkal rosszabb –, a szülőosztályok gyakran alosztályaik fizikai megjelenését is meghatározzák, legalább részben. Mivel az öröklődés megengedi, hogy egy alosztály betekintést nyerjen szülője megvalósításába, gyakran mondják, hogy „az öröklődés megszegi az egységbe zárás szabályát”. Az alosztály megvalósítása annyira kötődik a szülőosztály megvalósításához (szoros kapcsolat van lazán csatoltság helyett), hogy a szülő megvalósításában a legkisebb változtatás is az alosztály változását vonja maga után.

Az implementációs függőségek gondot okozhatnak az alosztályok újrafelhasználásánál. Ha az örökölt megvalósítás bármely szempontból nem felel meg az új feladatnak, arra kényszerülünk, hogy újraírjuk, vagy valami megfelelőbbel helyettesítsük a szülőosztályt. Ez a függőség korlátozza a rugalmasságot, és végül az újrafelhasználhatóságot. Ezt úgy orvosolhatjuk, ha csak absztrakt osztályoktól öröklünk, mivel azok általában egyáltalán nem tartalmaznak megvalósításra vonatkozó részeket (vagy ha mégis, akkor csak keveset).

Az objektum-összetétel dinamikusan, futásidőben történik, olyan objektumokon keresztül, amelyek hivatkozásokat szereznek más objektumokra. Az összetételhez szükséges, hogy az objektumok figyelembe vegyék egymás interfészét, amihez gondosan megtervezett interfészekre van szükség, amelyek lehetővé teszik, hogy az objektumokat sok másikkal együtt használjuk. A módszer előnye viszont, hogy mivel az objektumokat csak interfészükön keresztül érhetjük el, nem szegjük meg az egységbe zárás elvét. Bármely objektumot lecserélhetünk egy másikra futásidőben, amíg a típusaik egyeznek. Továbbá, mivel az objektumok megvalósítása interfészek segítségével épül fel, sokkal kevesebb lesz a megvalósítási függőség.

Az objektum-összetételnek még egy hatása van a rendszer szerkezetére: az öröklődéssel szemben segít az osztályok egységbe zárásában és abban, hogy azok egy feladatra összpontosíthassanak (egyszeres felelősség elve). Az osztályok és osztályhierarchiák kicsik maradnak, és kevésbé valószínű, hogy kezelhetetlen szörnyekké duzzadnak. Másrészről az objektum-összetételen alapuló tervezés alkalmazása során több objektumunk lesz (még ha osztályunk kevesebb is), és a rendszer viselkedése ezek kapcsolataitól függ majd, nem pedig egyetlen osztály határozza meg.

SOLID alapelvek

A SOLID alapelvek egy angol nyelvű betűszó nyomán kapták a nevüket. Öt alapelvről van itt szó:

  • Egyszeres felelősség elve (Single Responsibility Principle, SRP)

    Az egyszeres felelősség elve 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 egy osztálynak csak egy oka legyen a változásra. Ha egy kódnak több oka is van arra, hogy megváltozzon, az arra utal, hogy a felelősségek és vonatkozások szétválasztása nem megfelelően történt meg. Ilyenkor addig kell alakítanunk az osztályunkat – azonosítva és kimozgatva belőle a „felesleges” felelősségeket, hozzárendelve azokat más osztályokhoz, amelyeket talán épp emiatt hozunk létre –, amíg el nem érjük, hogy csak egyetlen felelősséget tartalmazzon.

    A vonatkozások szétválasztásának elve és az egyszeres felelősség elve szorosan összefügg. Így a felelősségek befoglaló halmazát alkotják a vonatkozások. Ideális esetben minden vonatkozás egy felelősségből áll, mégpedig a fő funkció felelősségéből. Azonban egy felelősségben gyakran több vonatkozás is keveredik. A vonatkozások szétválasztásának elve azt nem mondja ki, hogy egy felelősség csak egy vonatkozásból állhat, hanem csak annyit követel meg, hogy a vonatkozásokat el kell különíteni egymástól, vagyis tisztán felismerhetőnek kell lennie, ha több vonatkozás is jelen van.

    Az egyszeres felelősség elvének megfelelő alkalmazásával olyan kódhoz jutunk, amelyet tesztelni is könnyebb, ráadásul a hibakeresés is egyszerűbbé válik.

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

    A nyitva zárt elvet eredetileg Bertrand Meyer fogalmazta meg, kimondva, 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 úgy fogalmazhatnánk, hogy az osztályhierarchiá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 felvehetünk, de meglévőt nem írhatunk felül. Ennek azért van értelme, mert ha már van egy működő, letesztelt, kiforrott metódusunk és azt megváltoztatjuk, akkor több hátrányos dolog is történhet: a változás miatt az eddig működő ágak hibássá válhatnak, illetve a változás miatt a tőle implementációs függőségben lévő kódrészek megváltoztatására is szükség lehet.

    Kódunkban az if ... else if szerkezet jelenléte gyakran arra utalhat, hogy nem tartottuk be ezt az elvet, ezért a változtatást úgy vezettük be a kódunkba, hogy újabb ágat adtunk a meglévők mellé (vagyis megsértettük a módosításra vonatkozó zártság követelményét). Ez például egy árak számítását végző program esetében fordulhat elő, ahol különféle feltételektől függően eltérő árképzési stratégiára van szükség. Ha új árszámítási módszert kell megvalósítanunk, akkor egy újabb ág helyett a Védett változatok nevű GRASP minta alkalmazásával, absztrakt osztály segítségével, egy interfészt hozhatnánk létre az árképzés miatt, és különböző alosztályok segítségével a polimorf viselkedést kihasználva implementálhatóak a konkrét árképzési stratégiák.

  • Liskov-féle helyettesíthetőségi alapelv (Liskov's Substitution Principle, LSP)

    A Liskov-féle helyettesíthetőségi alapelv (nevét kidolgozója, Barbara Liskov nyomán kapta) azt írja elő, hogy a leszármazott osztályok példányainak úgy kell viselkedniük, mint az ősosztály példányainak, vagyis a program viselkedése nem változhat meg attól, hogy az ősosztály egy példánya helyett a jövőben valamelyik gyermekosztályának egy példányát használjuk. Ez elsőre meglehetősen banálisan hangzik. A kivételek példáján keresztül azonban rögtön érthetővé válik, milyen problémák léphetnek fel, ha ezt az elvet megsértjük. Amennyiben az ősosztály egy metódusának végrehajtásakor nem vált ki kivételt, akkor az összes alosztálynak is tartania kell magát ehhez a szabályhoz. Amennyiben az egyik alosztály eljárása mégis kivételt váltana ki, akkor ez gondot okozna minden olyan helyen, ahol egy ősosztály típusú objektumot használunk, mert ott a kliens nincs erre felkészülve.

    Általánosabban úgy is ki lehetne fejezni ezt az elvet, hogy az alosztálynak csak kibővítenie szabad az ős funkcionalitását, de korlátoznia nem. Amennyiben például egy metódus az ősosztályban egy adott értéktartományon operál, akkor az altípus öröklött metódusa legalább ezen az értéktartományon kell, hogy működjön. Az értéktartomány kibővítése engedélyezett, de semmiképpen sem szabad korlátozni azt!

    A Liskov-féle helyettesíthetőségi alapelv tehát elsősorban arra hívja fel a figyelmünket, hogy alaposan gondoljuk át az öröklődést. Hacsak lehet, érdemes lehet a kompozíciót előtérbe helyezni az öröklődéssel szemben (lásd a megfelelő GoF alapelvet is). Az öröklődésnél tehát mindenképpen el kell gondolkodni a viselkedésről is, nem csak a struktúráról, vagyis amikor arról döntünk, hogy két osztály között fennáll-e az öröklődési viszony, azt is vizsgáljuk meg, hogy a szerkezeten túlmenően a viselkedésről is minden esetben elmondható-e, hogy az alosztály példánya szuperosztályának példánya helyett helyt tud állni.

  • Interfészek szétválasztásának elve (Interface Segregation Principle, ISP)

    Az interfészek szétválasztásának elve 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 „Egyetlen kliens se legyen rákényszerítve arra, hogy olyan metódusoktól függjön, amelyeket nem is használ”. Minél kevesebb dolog található az interfészben, annál lazább a csatolás (coupling) a két komponens között.

    Gondoljunk csak bele, mit tennénk, ha egy olyan dugaszt kellene terveznünk, amelyikkel egy monitort egy számítógépre lehet csatlakoztatni. Például úgy dönthetnénk, hogy minden jelet, amely egy számítógépben felléphet, egy dugaszon keresztül rendelkezésre bocsátunk. Ennek ugyan lesz pár száz lába, de maximálisan rugalmas lesz. Sajnálatos módon ezzel a csatolás is maximálissá válik (ugyanis egy jelfajta megjelenésekor az egész dugaszt újratervezhetjük, még akkor is, ha a monitor ilyen típusú jelet nem is bocsát ki).

    A dugasz példáján keresztül nyilvánvaló, hogy egy monitor-összeköttetésnek csak azokat a jeleket kell tartalmaznia, amelyek egy kép ábrázolásához szükségesek. Ugyanez a helyzet a szoftverinterfészeknél is. Ezeknek is a lehető legkisebbnek kellene lenniük, hogy elkerüljük a felesleges csatolást. Ráadásul, a monitordugaszhoz hasonlóan az interfésznek kohézívnek kell lennie. Csak olyan dolgokat kellene tartalmaznia, amelyek szorosan összefüggnek. Ha meg is változik például az egér jeleinek átvitelére szolgáló csatoló, az csak azokat a klienseket érinti, amelyek ezt használják (jelen esetben csak az egeret).

  • Függőséginverzió alapelve (Dependency Inversion Principle, DIP)

    A függőséginverzió elve 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ő absztrakcióktól függjön.

    Amennyiben egy magas szintű osztály közvetlenül használ fel egy alacsony szintűt, akkor kettejük közt egy erős csatolás jön létre. Legkésőbb akkor ütközünk nehézségekbe, amikor megpróbáljuk tesztelni a magas szintű osztályt. Emiatt a magas szintű osztálynak egy interfésztől kellene függenie, amit aztán az alacsony szintű osztály implementál.

    Az alacsony szintű komponensek újrafelhasználása az úgynevezett osztálykönyvtárak (library) segítségével jól megoldott. Azokat a metódusokat illetve osztályokat szokás osztálykönyvtárba gyűjteni, amelyekre gyakran szükségünk van. A rendszer logikáját leíró magas szintű komponensek azonban általában nehézkesen újrafelhasználhatók, mert sok függőséggel rendelkeznek. Ezen segít a függőség megfordítása. Tekintsük a következő kódot, amely a szabványos bemeneten olvasott karaktersorozatot egy kimenő szöveges állományba írja:

    import java.io.FileWriter;
    import java.io.IOException;
    
    class CopyCharacters {
    	private FileWriter writer;
    
    	public void copy() throws IOException {
    		writer = new FileWriter("out.txt");
    		int c;
    		while ((c = System.in.read()) != -1) {
    			writer.append((char) c);
    		}
    		writer.close();
    	}
    }
    
    public class Main {	
    	public static void main(String[] args) throws IOException {
    		CopyCharacters cc = new CopyCharacters();
    		cc.copy();
    	}
    }

    Itt a copy metódus függ a System.in.read és a FileWriter.append metódustól. A copy metódus fontos logikát ír le, a forrásból a célra kell másolni karaktereket állományvégjelig. Ezt a logikát elviekben sok helyen fel lehetne használni, hiszen a forrás és a cél bármi lehet, ami karaktereket tud beolvasni, illetve kiírni. Ez a kód azonban konkrét (alacsony szintű) megvalósításoktól függ (System.in, FileWriter). Ha ezt a kódot szeretnénk újrafelhasználni, akkor vagy if ... else if szerkezet segítségével kell megállapítani, hogy éppen aktuálisan melyik forrásra, illetve célra van szükség. Ekkor például a szabványos bemenet helyett egy állományból olvashatnánk, vagy akár egy sztringből, esetleg karaktertömbből, stb. Ez persze nagyon csúnya, nehezen átlátható és módosítható kódot eredményezne, épp ezért az if szerkezet használata helyett azt kellene biztosítanunk, hogy az alacsony szintű konstrukció helyett magas szintű absztrakcióktól függjünk. Ennek egyik módja, hogy a forrás és a cél referenciáját kívülről adjuk meg úgynevezett függőséginjekció (dependency injection) segítségével. A függőséginjekciónak több fajtája is létezik:

    1. Függőséginjekció konstruktor segítségével: Ebben az esetben az osztály a konstruktorán keresztül kapja meg azokat a referenciákat, amelyeken 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.

      import java.io.FileWriter;
      import java.io.IOException;
      import java.io.InputStreamReader;
      import java.io.Reader;
      import java.io.Writer;
      
      class CopyCharacters {
      	private Reader reader;
      	private Writer writer;
      
      	public CopyCharacters(Reader r, Writer w) {
      		reader = r;
      		writer = w;
      	}
      
      	public void copy() throws IOException {
      		int c;
      		while ((c = reader.read()) != -1) {
      			writer.append((char) c);
      		}
      	}
      }
      
      public class Main {
      	public static void main(String[] args) throws IOException {
      		try (FileWriter fw = new FileWriter("out.txt")) {
      			CopyCharacters cc = new CopyCharacters(new InputStreamReader(
      					System.in), fw);
      			cc.copy();
      		}
      	}
      }

      Látható, hogy a copy metódus törzse megváltozott, az egész CopyCharacters osztály függetlenné vált a forrástól és a céltól is, most már absztrakcióktól (a java.io.Reader és java.io.Writer interfészektől) függ csupán. Természetesen a hívó is változott, hiszen immár az ő felelőssége, hogy azon objektumokat előállítsa, amelyek a konkrét forrást és célt megvalósítják.

    2. Függőséginjekció beállító (setter) metódusokkal: Ebben az esetben az osztály beállító 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. Ez alapjában véve nem nagyon különbözik a konstruktor segítségével végzett függőséginjekciótól, leszámítva, hogy a függőségek dinamikusan változtathatók, hiszen nem rögzülnek az objektum példányosításakor.

      import java.io.FileWriter;
      import java.io.IOException;
      import java.io.InputStreamReader;
      import java.io.OutputStreamWriter;
      import java.io.Reader;
      import java.io.StringReader;
      import java.io.Writer;
      
      class CopyCharacters {
      	private Reader reader;
      	private Writer writer;
      
      	public void setReader(Reader reader) {
      		this.reader = reader;
      	}
      
      	public void setWriter(Writer writer) {
      		this.writer = writer;
      	}
      
      	public void copy() throws IOException {
      		if (reader == null)
      			throw new IllegalStateException("Source not set.");
      		if (writer == null)
      			throw new IllegalStateException("Destination not set.");
      		int c;
      		while ((c = reader.read()) != -1) {
      			writer.append((char) c);
      		}
      	}
      }
      
      public class Main {
      	public static void main(String[] args) throws IOException {
      		try (FileWriter fw = new FileWriter("out.txt")) {
      			CopyCharacters cc = new CopyCharacters();
      			cc.setReader(new InputStreamReader(System.in));
      			cc.setWriter(fw);
      			cc.copy();
      			cc.setReader(new StringReader("Test string."));
      			cc.setWriter(new OutputStreamWriter(System.out));
      			cc.copy();
      		}
      	}
      }

      Figyeljük meg, hogy amennyiben nem opcionális működést valósítunk meg (mint példánkban), akkor a copy metódus törzse újfent változik, hiszen – szemben a konstruktor segítségével végzett függőséginjekcióval – ez esetben nem tudjuk kikényszeríteni, hogy minden függőség még azelőtt beinjektálásra kerüljön, mielőtt felhasználásra kerülne (például ha a Main-ben a copy-t úgy hívnák meg, hogy a setReader vagy setWriter hívás elmarad). Ezért elképzelhető, hogy a művelet nem hajtható végre, mert a CopyCharacters objektum nincs megfelelő állapotban az injekció hiánya miatt. Azt is láthatjuk a főprogramban, hogy a hívó sokkal rugalmasabban injektálhat, mint az előző esetben, hiszen a függőségek nem rögzülnek a CopyCharacters objektum példányosításakor, hanem dinamikusan, futásidőben újabb függőségek megadására is lehetőség van, két copy metódushívás között.

    3. Függőséginjekció interfész megvalósításával: Ez a megoldás az injekció céljaira létrehozott interfész használatát takarja. Először egy interfészt kell készítenünk, amelyen keresztül a függőséginjekciót elvégezzük majd.

      import java.io.Reader;
      import java.io.Writer;
      
      public interface SourceDestination {
      	void setSource(Reader in);
      	void setDestination(Writer out);
      }

      Ezt követően az interfészt használjuk a függőségek beinjektálására:

      import java.io.FileWriter;
      import java.io.IOException;
      import java.io.InputStreamReader;
      import java.io.Reader;
      import java.io.Writer;
      
      class CopyCharacters implements SourceDestination {
      	private Reader reader;
      	private Writer writer;
      
      	@Override
      	public void setSource(Reader in) {
      		reader = in;
      	}
      
      	@Override
      	public void setDestination(Writer out) {
      		writer = out;
      	}
      
      	public void copy() throws IOException {
      		if (reader == null)
      			throw new IllegalStateException("Source not set.");
      		if (writer == null)
      			throw new IllegalStateException("Destination not set.");
      		int c;
      		while ((c = reader.read()) != -1) {
      			writer.append((char) c);
      		}
      	}
      }
      
      public class Main {
      	public static void main(String[] args) throws IOException {
      		try (FileWriter fw = new FileWriter("out.txt")) {
      			CopyCharacters cc = new CopyCharacters();
      			cc.setSource(new InputStreamReader(System.in));
      			cc.setDestination(fw);
      			cc.copy();
      		}
      	}
      }

      Az interfészt általában maga a magas szintű komponens valósítja meg, de lehetőség van arra is, hogy az előző módszerek valamelyikével (konstruktor vagy beállító metódus segítségével) paramétereként adjuk át a függőségeket meghatározó interfészt.

    4. Függőséginjekció elnevezési konvenció alapján: Ez általában keretrendszerekre jellemző, amelyek zömében egy (XML) konfigurációs állománnyal szabályozzák, hogy mely objektumhoz jöjjön létre a függőség. Ezt a megoldást elsősorban 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 nagyban megnehezíti a hibakeresést, de úgy általában, a megértést is.



[1] Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

[2] Make things as simple as possible, but not simpler.