SOLID interjú kérdések

  • Mi a SOLID, milyen problémákra jött válaszul?
  • Melyek a jellemző problémák egy szoftver karbantarthatóságával kapcsolatban?
  • Mitől lesz egy szoftver rigid, törékeny, immobilis?
  • Mitől lesz egy szoftver viszkózus, szükségtelenül komplex, átláthatatlan?
  • Minek a jele, ha egy program ismétlődő kódrészeket tartalmaz?
  • Hogyan oldja meg a SOLID az előbb említett problémákat?
  • Mi a Single Responsibility lényege? Mi a probléma, ha nem tarják be? Hogyan válik emiatt a szoftver rigiddé/törékennyé/immobilissá?
  • Milyen esetekben nem okoz problémát, ha egy osztályban mégis több felelősség van?
  • Mi az Open/Closed lényege? Milyen problémákat okoz, ha nem tarják be?
  • Mi a kapcsolat az Open/Closed princple és a YAGNI/KISS elvek között? Milyen problémát okozhat, ha valaki szükségtelenül erőlteti az Open/Closed principle-t?
  • Mi a Liskov Subtitution lényege? Milyen problémát okoz, ha nem figyelnek rá?
  • Mi az Interface Segregation lényege? Milyen problémát okoz, ha nem tarják be? Hogyan szokott egy interfész idővel túlhízni? Mi a megoldás rá?
  • Mi a Dependency Inversion lényege? Mi a probléma, ha nem tartják be? Mi a probléma, ha túlerőltetik?
  • Hogyan oldható fel a látszólagos ellentmondás a SOLID elveknek megfelelő, programba bevezetett absztrakciók (interfészek) és a YAGNI/KISS elvek között?
  • Mi a kapcsolat a TDD/SOLID között, hogyan segíti a TDD a SOLID elveknek megfelelő kódok építését?
  • Mi a szerepe a Refactor-nak a SOLID elveknek megfelelő kódok létrehozásában?

Ha már…

…nem okoznak gondot a fenti kérdések, vagy lelkesen kutatsz a válaszok után, gyere hozzánk dolgozni! Az alábbi képre kattintva küldheted el jelentkezésed:

EPAM_Poster_1000

A múlt tapasztalata

Az elmúl egy-két évtized megmutatta, hogy a szoftverek hosszabb – néha rövidebb – távon gyakran karbantarthatatlanná válnak. Ez mai tudásunk alapján már érthető és természetesnek tűnő folyamat, főként abból adódik, hogy a kezdetben sikeres programozási módszerek a programok méretének növekedésével már nem működnek.

A 70-es 80-as évek szélesebb körben elérhető számítógépei nem hogy memóriával, de háttértárral sem bírnák azokat a méretű programkódokat, amelyek manapság születnek. Az akkori programozók gyakran természetes módon álltak neki a szoftverek fejlesztésének, ahogyan azt a mai tanuló vagy kevéssé tapasztalt programozók egy garázscégben teszik.

A korai számítógépek esetén a kódbázis viszonylagosan kicsi méretek miatt átlátható volt egy ember számára, aki így bele tudott nyúlni, ha azt módosítani kellett. Ha valami nagyon félrement, át lehetett írni az egészet – pár ezer vagy egy-két tízezer sor nem több néhány átvirrasztott éjszakánál, esetleg pár megnyomott hétnél.

Ötezer sor vagy ötezer forrásfile?

Az akkori programozók hozzáállását nagyon jól tükrözi a ma már viccesnek és komolytalannak tűnő, de akkor félig komolynak szánt “igazi programozó” leírása.

Aztán a gépek néhányszor 10KByte-os kódok helyett már több száz KByte-os, később megabyte-os kódokat tudtak végrehajtani. A programozási feladatok túlnőttek az egyszerű nyilvántartásokon, és az irodista kisasszony által amúgy számológéppel is elvégezhető számításokon. Ezeket a szoftvereket már nem a kettő fős Neve-Nincs Bt vállalta be három hét munkával. Többször tízes nagyságrendben dolgoztak emberek hónapokig, hogy a szoftver végül képes legyen például egy vállalat folyamatait működtetni.

Sok fejlesztés már ezen az úton megbukott – de ez egy másik, az agilis szoftverfejlesztés története. A jelenlegi történet szempontjából érdekes rész a szoftver üzembeállása után kezdődik.

Változó világ és a karbantartás

Egy szoftvert ugyanis nem mindig elég megírni és leszállítani. Egy cég azért fizet nem kevés pénzt egy nagyobb szoftver elkészítéséért, mert a cég a működését hosszabb távon akarja hatékonyabbá tenni. És a hangsúly a hosszabb távon van. Ezalatt a hosszabb táv alatt rengeteg dolog történhet. Megváltoznak jogi szabályozások, amelyek a cég működését komolyabban befolyásolják – Magyarországon ehhez nem is kell hosszabb táv sem. Konkurens cégek előrukkolnak olyan dolgokkal, amelyet követni kell – és ez a cég működésére, így az azt működtető szoftverekre hatással van. A cég tulajdonosi szerkezete megváltozik, és az új vezetőség új jövőképpel rendelkezik, ami a cég működésére hatással van. Nem érdemes sorolni tovább, a lényeg látszik: a szoftvereket leszállítás után sok esetben frissíteni kell, új képességekkel kiegészíteni, régebbi funkciókat megváltoztatni. A szoftvert tehát karban kell tartani. A karbantartás azt jelenti, hogy a kódba bele kell nyúlni, azt meg kell változtatni olyan módon, hogy a szoftver működése a kívánt irányba változzon. És itt kezdődik a mi történetünk.

A szoftver krízis

A számítógépek teljesítményének növekedése lehetővé tette egyre komplexebb szoftverek fejlesztését. Ezt a folyamatot azonban szoftverfejlesztési gyakorlat nem tudta követni. A szoftverfejlesztésben a 70-es évekre krízis állt elő. Valaki talán azt gondolja, hogy a szoftver krízis, mint kifejezés egyfajta túldramatizálás. De nem. Ez egy létező és súlyos probléma volt, irdatlan mennyiségű pénzt lehúzva a wc-n.

A szoftver krízis több problémából tevődött össze, amely problémáknak egyike volt a kódok karbantarthatatlansága. De nem kell felülről tekinteni a gondokra, a szoftverfejlesztés még ma is gondokkal küzd, és emberi tényezők miatt bizonyos problémák mindig fenn fognak állni. Az emberi tényezőkön kívül nagyon fontos megjegyezni, hogy a “tuti” módszer a szoftverfejlesztésben ma sem ismert, és személy szerint nem is hiszem, hogy létezik.

Sok “tutit” láttunk már.

A korábban említett igazi programozó nem félt a goto-tól. Pedig a 60-as évek kódjai részben a goto hibás használata miatt véreztek. Fontos a “hibás használat” kihangsúlyozása, ugyanis a goto az első pár programozási nyelvet leszámítva már csak egy eszköz volt a többi között, amit sokan rosszul használtak.

A goto-gyűlölettel jött a strukturált és procedurális programozás, ami jó eszköz a karbantartható programok készítéséhez, csak nem elégséges. A szoftver részei a strukturált programoknál keresztbe-kasul tudták hivatkozni egymást. Bárki meghívhatott egy segédfüggvényt, amit a fejlesztője nem publikus használatra szánt, vagy bárki módosíthatott állapotváltozókat, ami mechanizmusok működését befolyásolták. Végül félve kellett hozzányúlni bármihez, mert kiszámíthatatlan függőségek alakultak ki. Persze voltak ökölszabályok a gondok csökkentésére, ennek a kornak a maradványa mára a globális változók kerülése – ha csak nem álcázzuk egy osztály statikus property-jének, vagy valamiféle kontextus változónak.

Az objektum orientált programok részben megoldották a keresztbe hivatkozás problémáját. A láthatóság (public/private) kontrollálásával explicit módon használatra szánt felületeket lehetett adni szoftverrészeknek, elrejtve implementációs részleteket, amelyeket így könnyebb volt módosítani, hiszen elvileg kívülről nem függhettek tőle. Az objektum orientált programozás azonban mást is hozott, az öröklődést. Ennek nagy slágere volt a nyolcvanas évek végén/kilencvenes évek elején, amikorra kiderült, hogy az öröklődés esetenként felesleges függőségeket épít a programokba, így a kétezres évekre a “Kompozíció az öröklődés helyett” lett a mantra.

Az objektum orientált gondolkodás a 80/90-es években annyira erőssé vált, hogy néha elosztott rendszereket sem tudtak másképpen elképzelni. Így született a CORBA, de még a .NET remoting is. Viszonylag soká tisztult ki, hogy az objektum orientált szemlélet elosztott rendszereknél legtöbbször felesleges overhead-et jelent, mint például az objektumok életciklusának a managelése. Erre jöttek válaszként a kérdezek/válaszolsz/elfelejtesz ciklusnak megfelelő protokolok.

Tight Coupling – a visszatérő probléma

Érdekes motívumot figyelhetünk meg az előző szakasz problémáit áttekintve. Mi volt a baj a goto hibás használatával? Amellett, hogy nehezen átláthatóvá vált a kód, könnyű volt összemosni a logikai részeket az ide-oda ugrálgatással. Egy ilyen kódból “kiműteni” valamit, hogy az “ugrálás logika” ne sérüljön, nagy odafigyelést kívánhatott.

Mi volt a baj a strukturált programokkal? Hogy a logikai egységek metódusait vagy állapot változóit bárhonnan el lehetett érni. Emiatt előbb utóbb valaki el is érte bárhonnan, ami jelentősen növelte a munkaidőt és a hibák kockázatát, ha az adott részt állapotváltozóstól, segédfüggvényestől át akarták írni.

Mi a baj az objektum orientált szemlélettel? Hogy az öröklődés túl szoros kapcsolatot vezet be a leszármazási fán. Ez van, hogy kívánatos és pont ez a cél, de van, hogy nem.

Mi a baj a CORBA / .NET remoring jellegű megoldásokkal elosztott rendszereknél? Amellett, hogy odaragaszt egy adott technológiához, túl intim tudással kell bírni a szerver működéséről – illetve a szerveren létrehozandó objektumrendszerről, az objektumok életciklusáról. Ha a szerver kódját módosítják, ez vonzza magával a kliens módosítását is – pedig a kliens sok esetben csak annyit szeretne, hogy “itt az adat, ezt kell csinálni, csináld szerver, mindegy hogyan!”

Összefoglalva tehát, a visszatérő probléma az, hogy az egyik komponens túl sokat tud egy másik komponensről, és abban az esetben, ha a másik komponens módosul, a megváltozott “túl sok tudást” a környező komponensekbe is át kell vezetni. Ha sikerül, ez “csak” plusz munkát jelentett. Ha nem sikerül, akkor programhibákat okoz.

Ha valaki túl okos

A kilencvenes években már egyértelmű volt, hogy egyáltalán nem mindegy a karbantarthatóság szempontjából, hogy milyen egy szoftver felépítése. Ekkor jött divatba architektnek lenni, az UML és abból való kódgenerálás, ekkor jelent meg a Design Patterns könyv más társaival együtt. Némelyik kezdeményezés hasznos volt, némelyik életképtelen. De a fő problémát itt a magukat túlképző architektek jelentették, akik sokszor nem bírtak ellenállni az ahavi DrDobb’s magazinban bemutatott architekturális trükköknek. Ez a jelenség talán ha korlátozott formában is, de máig tart – és egy nevezetes fejlesztési hiba a szoftver túltervezése.

Nevezetes hibák

A SOLID tárgyalásánál jellemzően felvezetnek nevezetes problémákat, amelyek előbb-utóbb fel szoktak merülni egy szoftver fejlesztésénél, és gátat szabnak a szoftver módosíthatóságának. Ezek lényegében a két már említett gond, a Tight Coupling és a túltervezés tünetei.

Rigidity

Egy szoftver rigid, ha egy módosítás végiggyűrűzik a szoftver több részére. Milyen gondokat okoz ez? Egyrészt a fejlesztő rossz becslést ad az elvégzendő munka mennyiségére, mivel azt hiszi, hogy csak az “A” komponenst kell módosítani. Ehelyett menet közben rájön, hogy “B” és “C” komponenst is át kell írnia, mert az olyan mértékben függ “A”-tól.

Másrészt, az ilyen továbbterjedő fejlesztési igények hasonlóan viselkednek, mint a vízbedobott kövek. Ha az eredeti szoftvert a sima víztükörnek tételezzük fel, még könnyű követni, mi történik, ha beledobunk egy követ. A tovaterjedő hullámok képviselik itt a “na akkor most ezt is módosítani kell” mondatok útját a programban.

Viszont a három-négy követ dobunk be, akkor a létrejövő interferenciát már nehéz azonnal átlátni, és ez sajnos így van a programban is. Ha két-három különböző helyről induló módosítás átterjed egy adott szoftverrészre, nehéz lehet átgondolni, hogy milyen működés szolgálja jól ki mind a két-három módosítási igényt.

Fragility

Ez a rigiditás testvére, csak arra a helyzetre, amikor a fejlesztőnek nem sikerült kezelni a módosítási igények továbbterjedését. Ekkor a szoftver működése hibássá válik – ráadásul gyakran egy látszólag független részen, amiről nem lehet érteni, hogy miért.

Immobility

A Tight Coupling egy következménye, amikor egy funkcionalitás véget nem érő csápokkal nő bele az őt körül velő kódba. Ez volt a probléma a goto-nál, a strukturált programoknál, de az objektum orientált programozás sem ad automatikusan megoldást. Mi okoz immobilis kódot? Egyik probléma lehet például egy god object használata. A god object úgy jön létre, hogy egy osztályba túl sok funkciót gyömöszölnek bele, ami funkciók így ugyanolyan szabadon férnek egymás intim területeihez, mint annak idején a strukturált/procedurális programok esetében. Ha a kiemelendő kód a god object implementációjának a része, vagy a god object-et használja, akkor a szükségesnél jóval több funkcionalitást kell mozgatni.

Másik probléma lehet, ha a kiemelendő kódrész olyan konkrét típusokat használ, ami további konkrét típusokat használ, amely további konkrét típusokat használ, és így tovább. Megfogjuk a programot egy ponton, a konkrét típusok láncolatával húzza magával a többi kódot.

Needless complexity

A jelenséget közvetlen leírja a neve: a program kódja túlbonyolított. Ezt jellemzően a fejlesztő/architekt túlzott lelkesedése okozza, de később pont azt éri el vele, amit el akart kerülni – lásd viszkózus program.

Viscosity

A szoftver viszkózzá válását jellemzően két dolog okozhatja. Az egyik a túl bonyolult design – needless complexity. Ha a fejlesztő vagy architekt túl sok mindent akar lefedni előre (“erre majd később szükség lehet”), vagy nagyon akarja használni a kedvenc patternjét, akkor a programot teleszórja felesleges, esetleg nem is oda illő absztrakciókkal.

Egy későbbi módosítási igény esetén a szoftvert úgy kell megváltoztatni, hogy az megfeleljen az új igénynek, és egyben kerek maradjon az előre kidolgozott absztrakciókkal. A módosítás ilyenkor plusz erőfeszítést igényel, hogy a felesleges absztrakciókat megtartsuk/karbantartsuk, esetleg refaktorálást, hogy az új igényt is lefedje.

Ilyenkor jelentkezik a második probléma, ami vagy abból adódik, hogy a fejlesztő nem látja át a túlbonyolított koncepciót, vagy nincs ideje azt kereken tartani – vagy lusta erre. Ilyenkor keresztbe hackeli az architektúrát, ami pár módosítás után azt eredményezi, hogy nem lesz felismerhető architektúra.

Needless Repetition

A copy-paste jelenséget mindenki ismeri, és valószínűleg alkalmanként legtöbben el is követik – ha máshol nem is, egy POC-os kódban jó eséllyel. Kódmásolás akkor történik, amikor az eredeti kód majdnem úgy csinál dolgokat, ahogy nekünk kell, de azért mégsem pontosan. Emiatt egy részét a kódnak módosítani kell, ugyanakkor szükség van a régi működésre is. A kódmásolás fő veszélye a karbantartásnál jön elő. Ekkor esetleg minden egyes másolaton végig kell vinni a módosításokat, ami egyrészt plusz munka, másrészt könnyen kimaradhat egy másolat.

Miben segít a SOLID?

A SOLID alapelvek gyűjteménye, aminek betartása segít enyhíteni a fenti problémákat. Fontos az elején leszögezni, hogy a SOLID nem segít minden helyzetben, és az elveit hibásan és feleslegesen erőltetve pont a szoftver karbantarthatóságát korlátozzuk.

A SOLID szó mindegy egyes betűje egy alapelvet jelöl, ezeket vesszük most sorra:

Single Responsibility Principle

Ez az elv azt mondja ki, hogy egy osztály csak egy dologgal foglalkozzon. Miért fontos ez? Tegyük fel, hogy egy osztályba két elkülönülő funkció van implementálva. Ez a két funkció valószínű szorosan összefügg, vagy legalábbis együtt jönnek szóba, egyébként a fejlesztő nem lenne motiválva arra, hogy azokat egy osztályon belül implementálja. A probléma az, hogy egy osztályon belül nem lehet láthatósági szinteket definiálni. Mivel a két funkció összefügg, várhatólag használják egymás dolgait, és mivel egy osztályban szerepelnek, láthatósági korlátozások hiányában, intim tudásuk és elérhetőségeik vannak a másik funkcióval szemben. Ennek köszönhetően idővel túlságosan összeforrhatnak – vagy már eleve összeforrva lesznek implementálva, akárcsak a procedurális programok idejében.

Ez nem feltétlenül okoz gondot. Ez akkor okoz gondot, ha a két funkció evolúciója más irányba halad. Ekkor az egyik funkciót a másiktól függetlenül kellene módosítani, és itt már a korábban látott módon jönnek elő a problémák. Az összefonódás miatt a kódrész rigid és törékeny.

Az egyik jellemző megoldás az összefonódott funkciók módosításának kezelésére, hogy a kódot egy az egyben lemásolják, majd az összefonódott funkciók közül azt, amelyiknek módosítási igénye van, megváltoztatják. Ekkor lesz két majdnem egyforma kód, a program tehát szükségtelen ismétléseket tartalmaz. A copy-paste-elt kód általában az SRP megtörésének jele.

Újabb probléma, ha a több funkció közül az egyiket végül önállóan akarjuk használni (például másik programban) Ekkor az osztály kliense (egy másik osztály, vagy szoftver modul) kénytelen “látni” a többi funkciót is. Ez helyzettől függően különböző súlyosságú problémákat okozhat. Legegyszerűbb helyzetben csak feleslegesen ott van a másik funkció. Rosszabb esetben a másik funkció hoz magával egyéb felesleges függőségeket, ami miatt érthetetlen konfigurációs vagy installációs lépéseket kell tenni. Legrosszabb esetben az összefonódott egyéb funkciók közül néhány be is indul, különböző mellékhatásokat okozva. Az ilyen kód immobilis.

Hogyan érhető el a sinlge responsibility? Az egyszerűnek hangzó mondás az az, hogy csak egy féle oka lehet annak, hogy az osztály módosítani kell. Ezt azonban nem mindig egyszerű eldönteni. Könnyen előfordulhat, hogy a fejlesztő nem veszi észre, hogy több féle oka lehet az osztály módosításának – és ez nem feltétlenül a fejlesztő hibája. Lehet, hogy a követelményekből vagy a háttérismeretekből úgy látszik. Az nem megoldás, hogy erőltetett helyzeteket kitalálva lelünk különféle módosítási dimenziókra. Ez várhatólag felesleges absztrakciókhoz vezet, ami pedig a szoftver komplexitását növeli, ez pedig hozza magával a needless complexity / viscosity problémáját. Kisebb fájdalommal járhat megvárni az első módosítási igényt, és adott helyzetnek megfelelően refaktorálni a kódot.

Open/Closed Principle

A kimondott elv itt az, hogy a kód legyen nyitott a bővítési igényekre, de zárt a kódmódosításra. Ez így mágikusnak hangzik, a magyarázata azonban könnyen érthető. Ugyanakkor sok SOLID-ról szóló cikk kihagy egy nagyon fontos részletet. Legjobb, ha a klasszikus szemléltető példát nézzük.

Tegyük fel, hogy olyan kódot készítünk, ami billentyűzetről olvas karaktereket, és azt a nyomtatóra továbbítja. Ez a kód körülbelül így néz ki:

while ((ch = Keyboard.ReadChar()) != Keyboard.EOF)
  Printer.WriteChar(ch);

Mi a gond ezzel? A nagykönyv szerint az, hogy konkrét típusokat használ, emiatt tényleg csak arra jó, hogy a billentyűzetről olvasson, a nyomtatóra írjon. Emiatt ha jön egy módosítási igény, hogy fileból vagy hálózatról kell olvasni, akkor a kódhoz hozzá kell nyúlni, ami pedig ellentmond az Open/Closed principle-nek, hiszen szükség van a kód módosítására, anélkül annak funkciója nem bővíthető.

Mi lenne a helyes implementáció? Ha lenne egy absztrakt fogalmunk egy InputStream-ről illetve egy OutputStream-ről, és ezzel szemben implementálnánk a ciklust. Ekkor ez a következő módon nézne ki:

while ((ch = InputStream.ReadChar()) != InputStream.EOF)
  OutputStream.WriteChar(ch);

Ezután az osztály az InputStream és OutputStream példányokat kívülről kapná (például konstruktorban, vagy a metódus paramétereként), és az osztály módosítás nélkül bővíthető olyan értelemben, hogy sokféle jövőbeli eszközzel együtt tud működni. Örülünk? Nem éppen.

Agilitás és a bölcs várakozás

Ezen a ponton egy kis kitérőt kell tennünk más mai divatos módszertanok/elvek irányába. Az agilis fejlesztés egyik alapkövét ugyanis most fontos kiemelni, ez pedig arról szól, hogy ne implementáljunk olyat, amire ebben a pillanatban nincs szükség. Ennek kifejezésére több mozaikszó is létezik, mint YAGNI és KISS. Az agilis fejlesztési módszerek hasonló problémákra adott válaszokból születtek, mint amit a cikk elején sorra vettünk. A lényege, hogy segítse – vagy egyáltalán lehetővé tegye – a fejlesztést változó követelményrendszerben. A szoftver krízis egyik oka ugyanis a változó követelmények voltak a fejlesztés során. A változó követelmények miatt sokszor nehéz kiszámítani, hogy a szoftvert milyen irányban kell majd módosítani a jövőben – akár már a fejlesztés befejezése előtt. Ebből kifolyólag pedig nem érdemes erőltetni azt, hogy felkészülünk olyan dolgokra, amire ma nincs szükség. Ebből viszont némi ellentmondás látszik az Open/Closed Principle-lel szemben.

Vállald be az első golyót

A helyzet az, hogy az OCP-t nem úgy kell használni, ahogy a fenti példa bemutatja. Pont azért nem, mert nem lehet tudni, hogy milyen módosítási igények jönnek. Lehet, hogy jön egy olyan, hogy a karakter továbbítása előtt egy konfigurálható konverziós táblát kell alkalmazni. Lehet, hogy úgy kell módosítani, hogy a karakterek csoportját kell konvertálni (pl “alma”->”körte”). Ezzel szemben lehet, hogy soha nem kell másról olvasni, csak billentyűzetről. Így az eredeti közvetlen billentyűzet olvasós implementáció megfelel a SOLID elveknek, mivel az Open/Closed Principle nem mondja azt, hogy “minden elképzelhető bővítésre legyen nyitott a kód”. Az Agile Principles, Patterns, and Practices in C# könyv ezt a helyzetet úgy írja le, hogy a fejlesztőnek el kell viselni az első golyót. Amikor látszik, hogy honnan lőnek, akkor elég védekezni az abból az irányból jövő lövésekkel szemben. Az első igényre, amelyik a billentyűzettől különböző input eszközt fogalmaz meg, elég bevezetni az InputStream absztrakciót, az OutputStream-et viszont még akkor sem kell. Amit tenni kell tehát, hogy az új követelmények ismeretében refaktoráljuk a kódot, hogy az új követelményt, és a jövőben ahhoz hasonlókat ki tudja szolgálni.

Liskov Subtitution Principle

Ezt az elvet elég elvont módon szokták leírni, aminek az oka az, hogy egy matematikus vezette elő matematikus gondolkodásmóddal. A gyakori példa a kör/ellipszis probléma, ami szintén kevéssé gyakorlatias egy ipari programozó szemszögéből, de azért tanulságos: az objektum orientált programozás eszközei (interfészek/öröklődés) lehetővé teszik azt, hogy biztosítsuk, hogy különböző típusú példányok értsék ugyanazt a nyelvet (pl.: AddNewItem(), GetStatus()), de nem feltétlenül biztosítják, hogy különböző példányok viselkedése egymással konzisztens legyen, hiába mondjuk nekik ugyanazt. Ez a kliens (a példány használója) számára problémát okozhat, ha épít egy viselkedési mintára.

Egy AddNewItem() jellegű metódust például lehet implementálni úgy, hogy ha már létezik a hozzáadandó elem (a listában vagy repozitoriban vagy akárminben), akkor exception-t dob, vagy felülírja a régi elemet, vagy figyelmen kívül hagyja a hívást. Három egymástól különböző működés ugyanamögött az interface mögött, és ez a kliens számára egyáltalán nem mindegy.

Másik példa, ha valamilyen kommunikációs proxy objektumot használnunk. A WCF-es proxy-k jellemzője, hogy egy művelet hívása előtt, ha a csatornát nem nyitottuk meg explicit, akkor ő azt megnyitja maga. Ugyanakkor, ha egy hívás hibába fut, akkor a csatorna faulted állapotba kerül, és onnantól az összes hívás sikertelen lesz. Könnyen elképzelhető egy más típusú proxy, ami vagy nem nyit automatikusan csatornát, vagy képes valamiféle recovery megoldásra egy hiba után. Ha a kliens bármelyik viselkedési jellemzőre épít, akkor problémát fog okozni az implementáció cseréje – annak ellenére, hogy az interfészen vagy absztrakt osztályon keresztüli elérést pont a cserélhetőség miatt vezettük be.

A lényeg tehát, hogy az interfészhez a viselkedést is pontosan meg kell határozni, és implementációnál a leírtakat be kell tartani.

Itt érdemes megemlíteni “Design by Contract” megközelítést, ahol az interfész leírásába beletartoznak a viselkedésre vonatkozó megkötések, formalizált módon, körülbelül úgy, ahogyan teszteknél az Assert-eket leírjuk. C#-ban a hagyományos assertek helyett a Code Contract-okat használhatjuk, azonban fontos megjegyezni, hogy mindez kevés lehet az LSP betartásához. Hogy fejezhető ki például Code Contract-okkal, hogy egy interfész hívással szemben elvárt, hogy többszálú környezetből is használható legyen?

Interface Segregation Principle

Ez az alapelv egy könnyen véthető hibára hívja fel a figyelmet. Hogyan néz ki ez a hiba a gyakorlatban? Tegyük fel, hogy van egy program 1.0-ás verzióval. Ebben van egy funkció, amin két műveletet lehet elvégezni, mondjuk Enni() és Inni(). Ezek egy ISzolgáltató interfészen definiált műveletek, és a jelenlegi implementációja egy Szolgáltató osztályban van, melyet egyébként a Vendég példányok használnak – természetesen az ISzolgáltató interfészen keresztül.

Az 1.1-es verzióban jön egy új kliens és ezzel együtt új követelmény jelenik meg a szolgáltatókkal kapcsolatban. Az új kliens a AngolSuhancVendég osztály, aki pedig a Duhajkodás() műveletet is elvárja az ISzolgáltató-tól. Ez tehát belekerül az ISzolgáltató-ba, sok mindent nem zavar, a Vendég látja ugyan, de nem használja, a Szolgáltató meg majd üres metódusként implementálja – vagy dob egy not supported exception-t. A TalponállóSzolgáltató ezzel szemben megvalósítja a Duhajkodást is, az AngolSuhancVendég számára úgyis ezt az implementációt szánjuk.

Bizonyára feltűnt pár probléma. A kódot olyan helyeken kell módosítani (Szolgaltato), aminek nincs köze az új funkciókhoz. Ez a rigiditás felé vezető út. De van itt más is, tegyük fel, hogy a Vendég implementációjánál a frissen felvett programozó azzal az igénnyel találkozik, hogy a Vendég mostantól mégis szeretne Duhajkodni is alkalmanként. Az új programozó örül, mert látja az ISzolgáltató-ban a megfelelő műveletet, ezt bele is építi a Vendég logikába, majd a program eldurran egy másik helyen – a Szolgáltató-ban – mert az vagy nem csinál semmit, vagy dob egy not supported exception-t. Ez a kód így fragile.

Az ilyen problémák úgy kerülhetőek el, ha figyelünk arra, hogy egy adott kliens – egy osztály, ami egy interfészt használ, például a Vendég, csak olyan műveleteket lásson, ami rá tartozik. A Duhajkodás műveletének igénye hirtelen ránézésből tényleg a szolgáltatóhoz tartozik, de ez csak annyit jelent, hogy túl általános az eredeti absztrakciónk. Ez nem feltétlen a mi hibánk, az eredeti követelmények alapján ez egy logikus választás volt. Most, hogy többet tudunk, itt az ideje a refaktorálásnak. Az ISzolgáltató ezentúl lehet IÉtelItalSzolgáltató, az új műveletet – a duhajkodás – pedig egy IÓcskaHely intefészben kerül definiálásra. A TalponállóSzolgáltató pedig megvalósítja mind az IEtelItalSzolgáltató-t, mind az IÓcskaHely-t is.

Fontos észrevenni, hogy az ISzolgáltató átnevezése a Duhajkodás() bevezetése előtt mechanikusan megoldható egy Find/Replace-szel. Ugyanakkor ha már bevezettük a Duhajkodás()-t az ISzolgáltató-ba, és ekkor refactorálunk, akkor külön meg kell vizsgálni az összes ISzolgáltató klienst, hogy ők a jövőben a módosítás előtti (evős/ivós) vagy a módosítás utáni interfészt használják-e. Ez általánosságban igaz, hogy a refactor halaszgatása exponenciálisan növeli a hátramaradó munkát.

Dependency Inversion Principle

Ezt az elvet többen keverik a dependency injection-nel, pedig teljesen másról van szó – bár a dependency injection használható a dependency inversion figyelembevételével készült programokban. Ez az alapelv a szoftvermodulok mobilitását támogatja. A lényege az, hogy egy magasabb szintű modul – például valami, ami üzleti logikát implementál, de főleg valami, ami az üzleti logikai folyamatok egy magasabb szintű léptékét implementálja, saját intefészein keresztül tartja a kapcsolatot a külvilággal. A saját interfész fontos eleme a Dependency Inversion-nek, ez biztosítja, hogy bárhogyan is változik a környezet, a magas szintű modult nem kell változtatni, mindig a környezet alkalmazkodik a magas szintű modul interfészéhez. Ha a magas szintű modul az OCP-ben bemutatott karakter másoló algoritmus lenne, akkor a mezítlábas implementációval az a baj, hogy az a framework – vagy valamilyen más library felületét használja (Keyboard/Printer osztályok). Ha ezt az üzleti logikát (ami most csak egy ciklus) implementáló osztály át akarjuk mozgatni más környezetbe (vagy azért, mert a logika jól használható más alkalmazásokban, vagy azért, mert a szoftver eredeti környezete megváltozott), akkor hozzá kell nyúlni a logikát implementáló kódhoz, ez pedig azt a veszélyt rejti magában, hogy elrontjuk.

Emiatt a magas szintű moduloknak saját interfészt kell adni. A karaktermásoló modul így egy saját IInputStream/IOutputStream interfészt használ – a saját pedig azt jelenti, hogy más nem épít rá. Ha két magasabb szintű mechanizmus építene ugyanarra az interfészre, akkor különböző evolúciós pályák más módosítási igénnyel lépnének fel az interfészre – így az egyik modul módosítása magával hozná a másik modul módosítását is – ez pedig a rigid kód jellemzője.

Most látszólag visszajutunk ahhoz a vitához, hogy konkrétan a karakter másoló osztálynál kell-e interfész vagy nem. Az OCP-re is ráfoghattuk volna, hogy interfészek kellenek, de ott azt mondtuk, hogy az túlbonyolítás. Most pedig tessék, a DIP is ezt diktálná. Márpedig ez itt is túlbonyolítás. A titok nyitja abban rejlik, hogy “magasabb szintű modul”. Egy while ciklusra nehéz ráragasztani a magas szintű modul címkéjét, és ha ezt tennénk, a programunkat teliszórnánk interfészekkel, hiszen minden kisebb programlogikára rámondhatjuk, hogy ne függjön a környezetétől. Minden kis osztály bevezetne új – saját – interfészeket. Ezután írhatnánk rengeteg adapter osztályt, hogy a két egymással dolgozó modul saját interfészeit egymásba átalakítsa. Ez szükségtelenül komplex programot eredményezne.

Hogy mi az a határ, ahol a Dependency Inversion-t alkalmazni kell, nehéz általánosságban megmondani. Ha van egy kódhalmaz, ami várhatólag együtt mozog, és ez a kódhalmaz egy viszonylag szűk csatornán tartja a kapcsolatot a környezetével, akkor a szűk csatornára érdemes lehet a dependency inversion-t használni – de ez mindig az adott feladattól függ.

Refactor és Pató Pál Úr

A principle-ök tárgyalásánál többször láttuk, hogy nem érdemes előre dolgozni. Amikor látjuk a konkrét igényt, akkor refaktorálni kell a kódot. Nagyon fontos megjegyezni, hogy a “nem érdemes előre dolgozni” nem lustaságra való bátorítás. Miért érdekes ez?

A refactorálás esetén a lustaság nagyon veszélyes. Az Interface Segregation-nál láttuk, hogy ha megvárjuk, hogy az új metódus a régi interfészbe belekerüljön, akkor refactornál már elemezni kell a kódot, ahelyett, hogy egy kicsit korábbi refactor esetén csak mechanikusan átneveznénk dolgokat. A program “elkatyvaszosodása” exponenciális karakterisztikával bír, egy-két lépés után a kód viszkózus lesz, és a programozók inkább már nem nyúlnak a szerkezetéhez, hanem keresztbe hack-elik.

A refactorálás nehézsége abból ered, hogy a programozó azt hiszi, két lépés múlva még ugyanúgy átlátja a folyamatokat, és úgy érzi, hogy adott pillanatban jobban szeretne haladni az új követelmények kielégítésével – hiszen ez igazi haladás, a refactor pedig kívülről nem látszik. Emiatt a mai módszertanok külön kiemelik a refactor fontosságát. Az egyszerű ökölszabály itt az, hogy amint elhangzik az “ezt majd átírom, csak előbb még” mondatrész, a programozó el is követte a hibát. Sok esetben a programozó tényleg át fogja látni két lépés után a dolgokat. De egyrészt van hogy nem, másrészt mindig van látszólag fontosabb, így a refactor tologatására mindig lesz indok. És ez az indok aljasul változik az “ez most fontosabb”-ból a “hát ezt már irdatlan munka átírni”-re, ami végül a refactor végleges elhalasztását jelenti.

A lényeg tehát, hogy igaz, hogy nem érdemes előre erőltetni a SOLID elveket. Amikor azonban nyilvánvalóvá válik a helyzet, nem szabad halogatni sem a SOLID elveknek megfelelő kód kialakítását – a refactort azonnal el kell végezni.

TDD és SOLID

A TDD nem témája ennek az anyagnak, feltesszük, hogy az olvasó ismeri a TDD lényegét. Azért érdemes megemlíteni a SOLID elvekkel együtt, mert a TDD segíti a SOLID elveknek megfelelő kódot készítését – bár nem olyan mértékben és formában, mint az néha elhangzik. Miért segít a TDD?

A közös nevező: interfészek

Ahhoz, hogy ezt lássuk, át kell ismételni röviden, hogy mi volt egy-két SOLID elvnél a megoldás. Az Open/Closed Principle gyakran úgy valósítható meg, hogy konkrét típusok helyett absztrakt osztályokon vagy interfészeken keresztül éri el az osztály/algoritmus/szoftver modul azokat a komponenseket, amelyektől függ. Az Interface Segregation Principle lényege az volt, hogy “szűk” interfészeket használjunk, egy adott kliens csak azt lássa, amire szüksége volt. A Dependency Inversion-ben a komponens a saját maga által tulajdonolt interfészeken keresztül kommunikál.

A három principle közös pontja az interfészeken keresztüli kommunikáció. Hogy jön ez a TDD-hez?

Mert a TDD kierőlteti azt, hogy a tesztelendő egységek interfészeken – vagy absztrakt osztályokon – keresztül érjék el a környezetüket. Enélkül a unit nem lenne izoláltan tesztelhető – ami ekkor már nem unit teszt lenne, pedig a unit teszt a TDD eszköze.

Open/Closed és TDD

Az OCP karaktermásolgatós példájában tehát ha TDD-t használunk, akkor nem tudunk olyan ciklus leírni, ami közvetlenül a billentyűzetet olvassa, mert akkor nem lehet rá unit test-et írni. TDD esetén így mégis csak egy IInputStream vagy hasonló dolog szolgáltatja majd az input karaktereket, és egy IOutputStream jellegű dolog fogadja. Ebből látszik, hogy TDD kierőlteti, hogy a programozó az OCP felé tegyen lépéseket – bár a TDD egyik kritikája pont a túlzottan szétabsztrahált kód.

Dependency Inversion és TDD

Dependency Inversion már jóval korlátozottabb mértékben esik ki külön figyelem nélkül a TDD-ből. A Dependency Inversion akkor teljesülhet könnyen, ha a fejlesztő a magasabb szintű modulokkal kezdi a fejlesztést. Ekkor még nincs – vagy kevés a készen felhasználható elem. A felhasználható elemek hiánya miatt a magasabb rendű modul implementációja közben a programozó a függőségeket pont a fejlesztendő rész szája íze szerint fogja megfogalmazni – ami egy lépés a DIP felé, de nem garancia.

Interface Segregation és TDD

Az Interface Segregation-nel hasonló a helyzet. Maga a TDD nem fogja megakadályozni, hogy ne minden funkció egy adott interface-be kerüljön bele. Ugyanakkor van két dolog a TDD-ben, ami alkalmat ad a programozónak az ISP betartására. Egyrészt a TDD ciklusának megkerülhetetlen része a refactor. Így aki TDD-hez szokott – elvileg – minden ciklus után áttekinti a kódját és szükség szerint refaktorál. Ezt a TDD fejlesztők nem csak azért teszik, mert jó fiúk/lányok. Ez azért is fontos, mert egyébként minden funkció publikus metódusba “hízna” bele, illetve senki nem szüntetné meg a duplikációkat. Nagyon rövid időn belül olyan formát venne fel a programkód, amit még a legelvetemültebb fejlesztők is ritkán vállalnak. Aki TDD-zik, annak tehát a refactor mélyebben beleivódott az eszköztárába, mint esetleg másoknak.

Másrészt, a TDD azt kívánja, hogy a követelményeket egyenként valósítsuk meg tesztekben. Emiatt a fejlesztő egyenként “ízlelget” minden megvalósító funkciót, ez talán segít ráébreszteni arra, hogy melyek összefüggőek és melyek függetlenek. Egyszerűen, a TDD miatt a fejlesztési folyamat kicsit lassabb, több időt kell eltölteni analizálgatással, ez pedig önmagában segíthet hatékonyabban strukturálni a kódot – például megfelelő interfészek mentén.

Single Responsibility és TDD

A TDD-s fejlesztés sok apró kis unit tesztet generál. Ezek csoportokban vannak, jellemzően osztályok szerint. Ugyanakkor egy osztály jó esetben egy funkciót valósít meg – ez lenne a Single Responsibility lényege. Ebből következik, hogy a TDD-s tesztek, ha osztályok szerint vannak csoportosítva, akkor automatikusan funkciók szerint is csoportosítva lesznek – ez persze a valóságban nem ennyire fekete/fehér.

Mindenesetre gyakran, ha egy osztály több funkciót valósít meg, az látszik a hozzá tartozó teszteken. Ekkor a tesztek egyik része nagyban különbözni fog a tesztek másik részétől, két okból is.

Egyrészt a teszt neve más témakörre fog utalni, feltéve, ha a programozó megfelelő és konzisztens névkonvenciót használ. Másrészt, az egy funkcióhoz tartozó követelmények hasonló kontextusban futnak – azaz a teszt “Arrange” része, ami a teszt környezetét felállítja, ezeknél a teszteknél hasonló. Egy másik funkció tesztjéhez viszont más kontextus kell(het). Összességében tehát a TDD-s tesztek, ha nem elég egyneműek, arra utalhatnak, hogy a tesztelt osztály több dologgal foglalkozik, azaz töri az SRP-t.

Összefoglalás

A SOLID elvek a hosszútávon karbantartható szoftverek készítésében segítenek azáltal, hogy betartásuk hátráltatja a tipikus szoftverfejlesztési problémák kialakulását. Az elvek alkalmazásánál fontos szempont, hogy csak akkor kerüljön rá sor, amikor erre már megjelent az igény. Ekkor a kódot egy refaktorálási fázisban a SOLID elveknek megfelelően kell módosítani. A SOLID túlerőltetése plusz komplexitást visz a kódba. A SOLID elvek jelentős részben a gyengén csatolt komponensekről szólnak. Mivel TDD-s fejlesztések gyengén csatolt komponenseket állítanak elő, így ezek könnyebben felelnek meg a SOLID elveknek is.

  1. #1 by Noro on 2013. March 27. - 21:45

    Az utóbbi két írásod hatalmas segítség volt, teljesen érthetően és sok példával foglaltad össze azokat a dolgokat, amiket egyébként csak angolul keresne az ember(én bevallom, sokkal többet jegyzek meg magyar szövegből), közvetlen és a tömör elmélet helyett a gyakorlati alkalmazásokra is rávilágító írás volt!

  2. #3 by regiuskornel on 2013. April 2. - 21:56

    Nagyon jó írás. Az egész téma alapos szemléletváltást igényel azoktól, akik ezt komolyan gondolják alkalmazni. A legnagyobb nehézséget abban látom, hogy egy csapatban ezt vagy mindenki jól csinálja vagy sehogy. Van erre nézve irányelved, metodikád, tanácsod, tapasztalatod, ha be kéne vezetni egy fejlesztői csoportban?

    • #4 by Tóth Viktor on 2013. April 3. - 11:21

      Szia! Nincs sajnos általánosságban használható jó ötletem a bevezetésre. Úgy vettem észre, hogy a programozók egy része lelkes, és folyamatosan keresi a módját, hogyan írjon jobb programokat. Talán náluk nem gond a bevezetés. Egy másik része okos programozó ugyan, de türelmetlen, emiatt hajlamosabb elmellőzni a kezdeti befektetéseket a SOLID irányába. Ez a csoport nem reménytelen, csak plusz energiát igényel a figyelmük. Remélhetőleg hosszabb távon észreveszik, hogy ők is jobban járnak, ha kezdetektől ügyelnek a kód minőségére. Egy másik csoporttal az a gond, hogy “túl lelkesek”, emiatt teliszórják a programot felesleges konstrukciókkal. Ezzel több baj van: egyrészt sokkal tovább tart így nekik megírni bármit (sokat gondolkodnak, sokat piszmognak, hogy mi hogy jó). Másrészt elveszik a még bizonytalanok kedvét a SOLID-tól, mert esetleg félreértik ők is, hogy miről szól az egész. Harmadrész a kód végül ugyanolyan karbantarthatatlan a bonyolultságától. Aztán persze vannak azok a programozók, akiknek amúgy kevéssé való a szakma, erre jelentkeztek, mert hallották, hogy jó a pénz, de egyébként nem érdekli őket. Őket nehéz motiválni bármire is.
      Szóval látszik, hogy alsó hangon van négy programozófajta, akiket másképpen kell kezelni, és nyilván a valóság még összetettebb, mivel sok más tényező (az elvégzendő munka típusa, pillanatnyi hangulat, stb) befolyásolja a programozó viselkedését.
      Nálunk a cégnél is inkább csak próbálkozások vannak. Első lépésként megpróbálunk jó embereket felvenni, talán ezen a ponton lehet a legtöbbet tenni az ügy érdekében. Ha az interjún tud beszélni a felvételiző arról, hogy mikkel foglalkozott az elmúlt időben hobbiból, akkor az jó jel a belső motivációját illetően.
      Aztán a projekteket tapasztaltabb fejlesztő vezeti, akinek dolga a többiek munkáját ügyelni és arról visszajelzést adni – ez változó hatékonysággal működik, azért sok plusz erőforrást igényel folyamatosan átnézegetni és átgondolni más kódját is, és alternatívákat mutatni a fejlesztőnek.
      Néhányan próbálkoznak itt a cégnél coding kata-kal, ami egyébként szerintem az egyik leghatékonyabb módja a tanulásnak, ugyanakkor ennek feltétele, hogy az embereknek legyen maradék ideje és energiája – kedve ezzel foglalkozni.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: