Janoszen

Tiszta kód, 5. rész – A S.O.L.I.D. alapelvek

Tiszta kód, 5. rész – A S.O.L.I.D. alapelvek

Ma a programozás terén szinte mindenki hallott már a Model-View-Controller kódszervezési elvről. Annak ellenére, hogy ez rengeteget segített abban, hogy a kód karbantarthatóbb legyen, a hosszú távú kód tisztaság továbbra is probléma. Éppen ezért ebben a cikkünkben a S.O.L.I.D. alapelveket mutatjuk be.

A S.O.L.I.D. alapelvek szülőatyja Robert C. Martin, aki nem csak a clean code mozgalom vezérszónoka, hanem többek között az Agile Manifesto egyik eredeti megfogalmazója is.

Az elvek a következőképpen szólnak. (Ha nem érted, ne aggódj, elsőre én sem. Mindjárt magyarázok.)

Single Responsibility Principle (Egy felelősség elve)
Egy osztály vagy modul egy, és csak egy felelősséggel rendelkezzen (azaz: egy oka legyen a változásra).
Open/Closed Principle (Nyílt/zárt elv)
Egy osztály, vagy modul, legyen nyílt a kiterjesztésre, de zárt a módosításra.
Liskov substitution principle (Liskov helyettesítési elv)
Minden osztály legyen helyettesíthető a leszármazott osztályával anélkül, hogy a program helyes működése megváltozna.
Interface segregation principle (Interface elválasztási elv)
Több specifikus interface jobb, mint egy általános.
Dependency inversion principle (Függőség megfordítási elv)
A kódod függjön absztrakcióktól, ne konkrét implementációktól.

Na ez eddig olyan, mintha egy matek tételt olvasnánk. Akkor se értettük, most sem értjük, megjegyezni meg senki nem fogja. Helyette inkább nézzünk konkrét példákat.

Egy felelősség elve

Egy osztály, vagy modul, egy, és csak egy felelősséggel rendelkezzen (azaz: egy oka legyen a változásra).

Az, hogy egy modulnak egy felelőssége legyen, tök jó hangzatos szlogen, de hogy a pékbe valósítjuk ezt meg? Egyáltalán mi az a felelősség a programozás szempontjából?

Nézzünk egy példát. Legyen adott egy iskolai/egyetemi nyilvántartó rendszer, aminek van egy Excel exportja. A delikvens megnyomja a gombot, és ebből kijön egy Excel file a diákok listájával. Egy szép napon odajön hozzánk az iskola/egyetem igazgatója, hogy változtassuk meg az oszlopok sorrendjét.

Meg is tesszük, és nagyjából 2 órával a módosítás élesítése után jön egy olyan levél a pénzügyi igazgatótól, amit nem teszünk ki a kirakatba: kiderül, hogy az adatok további feldolgozására Excel makrókat használt, és az oszlop sorrend változással eltörtek a cellahivatkozások.

Ismerősen hangzik? Mi is történt itt? Ha jobban megnézzük, az Excel export funkciónak két felelőssége volt. Két potenciális forrás módosítási kérésekre: az igazgató, és a pénzügyi igazgató. Ennek a két felelősségnek nem szabadott volna egy funkcióban egyesülnie.

De nézzünk egy másik példát ugyan ebben a rendszerben. Adott egy Student osztály a következő függvényekkel:

1
2
3
4
class Student {
    public void addGrade(Subject subject, int grade) { }
    public void setName(string name) { }
}

Ez elsőre nem is tűnik olyan elvetemültnek, azonban ha jobban megnézzük, a jegy beírást a tanár végzi, amíg a név változtatást az iskola titkárság vagy tanulmányi osztály. Magyarán egy osztályban folyik össze két felelősség. Egy osztályt kell adott esetben módosítani két külön helyről érkező kérések alapján.

Itt hatalmas a veszély, hogy az egyik helyről érkező kéréssel elrontjuk a másik működését, hiszen osztályon belül sokszor nyúlunk közös adathoz. Arról nem is beszélve, hogy a fenti leírás alapján a Student osztály még az adatbázishoz is hozzányúl, ezért ott további felelősségek halmozódnak.

Szervezzük át tehát a Student osztályt úgy, hogy önmagában csak egy adattároló legyen, és a különböző feladatoknak külön osztályai legyenek:

1
2
3
4
5
6
7
8
class Student {
}
class GradeBook {
    public void addGrade(Student student, int grade) { }
}
class StudentRecords {
    public void changeStudentName(Student student, string newName) { }
}

Ezekből levonva a következtetést, a felelősség lehet például:

  • Egy felhasználó vagy felhasználói csoport
  • Egy külső szolgáltatás (pl. adatbázis, API, fileba írás stb)
  • A felhasználói felület vagy azok elemei
  • … és még sok minden más.

Tudtad? Az ORM vagy ActiveRecord pattern megsérti ezt az elvet. Ugyan segít gyorsan összerakni a kívánt alkalmazást, később azonban feltétlenül érdemes megfontolni a lecserélését. (Itt nagy a veszély, hogy ez soha nem történik meg.)

Nyílt/zárt elv

Egy osztály, vagy modul, legyen nyílt a kiterjesztésre, de zárt a módosításra.

Az előző példánál maradva, nézzük az alábbi példát:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
abstract class Employee {
    const TYPE_TEACHER = 0;
    const TYPE_CLEANER = 1;
    public void setType(int type) { }
    public int getType() { }
}

class Teacher extends Employee {
    public Teacher() {
        setType(TYPE_TEACHER);
    }
}

class Cleaner extends Employee {
    public Cleaner() {
        setType(TYPE_CLEANER);
    }
}

Miért nem jó ez? Fogalmazzuk meg a kérdést: hogyan adnánk hozzá egy új alkalmazott típust? Leginkább sehogy, ahhoz, hogy egy új alkalmazott osztályt hozzá tudjunk adni, kénytelenek vagyunk hozzányúlni a meglevő kódhoz.

Mit kellene helyette csinálni? A jelen példában nemes egyszerűséggel ki kell venni a tipus ID-kat, és ahol igény van megkülönböztetésre, ott magát az objektum típusát kell ellenőrizni. Általánosságban szólva, azt vizsgáljuk meg, hogy vajon az osztályunk kiterjeszthető-e egy leszármaztatott osztállyal, anélkül, hogy az eredeti osztályon módosítani kellene?

Lehetne például így:

1
2
3
4
5
6
7
8
interface Employee {
}

class Teacher implements Employee {
}

class Cleaner implements Employee {
}

Azaz van egy Employee interface, ami leírja a szükséges közös működést, az implementációt azonban rábízzuk az interfacet használó osztályokra. Azoknak a függvények, amik Employee típust várnak, teljesen fölösleges arról tudniuk, hogy létezik Teacher vagy Cleaner osztály is, hiszen ők csak a közös működést használják.

Tipp: ha működést írsz le, használj interface-t! Ettől függetlenül készíthetsz absztrakt osztályt is, de hagyd meg a lehetőségét, hogy valaki a Te implementációdtól függetlenül valósítsa meg a kívánt működést! (Az olyan nyelvekben, mint a C++, nincsenek interfacek. Helyettük érdemes csak absztrakt függvényekkel rendelkező absztrakt osztályokat használni.)

Liskov helyettesítési elv

Minden osztály legyen helyettesíthető a leszármazott osztályával anélkül, hogy a program helyes működése megváltozna.

Nézzünk egy példát a web világából, konkrétan egy blogmotort.

1
2
3
4
5
6
class BlogPost {
    public void setContent(string content) { }
    public string getContent() { }
}
class VideoBlogPost extends BlogPost {
}

Ez eddig szép és jó, de mi történik akkor, ha a VideoBlogPost osztály getContent függvényét erre változtatjuk:

1
2
3
4
5
class VideoBlogPost extends BlogPost {
    public string getContent() {
        return "<iframe src=\"https://www.youtube.com/embed/" + videoId + "\"></iframe>"
    }
}

Teljesen jó megoldás lenne, ha nem kellene figyelembe vennünk a BlogPostból származó kötöttségeket. Nézzük ezt a példát:

1
2
3
4
5
class AdvertisementPlugin {
    public void processBlogPost(BlogPost post) {
        post.setContent(post.getContent() + "<script ...></script>";
    }
}

Mint látható, ez a plugin módosítja a blogpost tartalmát. Ezt teheti például közvetlenül azelőtt, hogy a HTML templatek értelmeződnek. A VideoBlogPost esetén ez viszont nem működne, hiszen ott a setContent függvény semmilyen hatással nem bír. Magyarán a VideoBlogPost megsérti ezt az elvet.

Hogyan lehetne ezt helyre hozni? Mi sem egyszerűbb:

1
2
3
4
5
class VideoBlogPost extends BlogPost {
    public string VideoBlogPost(string videoId) {
        setContent("<iframe src=\"https://www.youtube.com/embed/" + videoId + "\"></iframe>");
    }
}

Mint látható, a konstruktorból írtuk bele a tartalmat, így a BlogPosttól elvárt működés megmarad.

Interface elválasztási elv

Több specifikus interface jobb, mint egy általános.

Ez az elv viszonylag egyszerű. Ne csinálj egy gigantikus interfacet, ami mindenre jó, helyette inkább sok kisebb, célra szabott interfacet. Gondoljunk csak bele, egy interface egy működést ír le, és arra mások megvalósításokat írnak. Ha egy interface 50 kötelező függvénnyel rendelkezik, mi az esélye annak, hogy ezt helyesen fogja implementálni valaki? Esetleg arra, hogy van benne egy függvény, amiben semmi más nincs, csak egy kivétel eldobása, mondván hogy not implemented.

Klasszikus példa erre az, ha olyan programot írunk, ami nyomtatókat kezel. A manapság kapható nyomtatóra a következő interface illene:

1
2
3
4
interface PrinterInterface {
    public void print(Document document);
    public Document scan();
}

Ez tök jó, hiszen minden egyben van. Tud nyomtatni, és tud scannelni. De mi történik akkor, ha az ősrégi nyomtatóm megvalósítását írom? Ez fog történni:

1
2
3
4
5
6
7
8
class MyReallyOldPrinter implements PrinterInterface {
    public void print(Document document) {
        //print document here
    }
    public Document scan() {
        throw new NotImplementedException();
    }
}

Célravezető? Hát kevésbé, mindenféle fura hibákat okozhat, ha látszólag megmagyarázhatatlan hibaüzenetek jelennek meg a felhasználónak. Helyette érdemesebb szétválasztani a két interfacet:

1
2
3
4
5
6
interface PrinterInterface {
    public void print(Document document);
}
interface ScannerInterface {
    public Document scan();
}

Természetesen túlzásba is lehet vinni, de ha látható, hogy nem minden esetben lehet minden függvényt implementálni, válasszuk szét az interfaceinket.

Függőség megfordítási elv

A kódod függjön absztrakcióktól, ne konkrét implementációktól.

Na, még egy ilyen hangzatos szlogen. Ki vagyunk vele segítve. Nézzünk megint egy példát.

Klasszikusan, ha programot írunk, egyik függvény hívja a másikat. Tehát például a controller függvénye hívja az üzleti logika függvényeit, az üzleti logika függvénye pedig hívja az adatbázis függvényeket.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyController {
    public void myAction() {
        BusinessLogic myBusinessLogic = new BusinessLogic();
        Data myData = myBusinessLogic.getSomeData();
        //...
    }
}

class BusinessLogic {
    public Data getSomeData() {
        DatabaseConnection db = new DatabaseConnection();
        DatabaseQueryResult result = db.query('SELECT ...');
        //...
    }
}

Első olvasatra ez teljesen szép és átlátható. Ha valahol hiba van, nagyon kevés helyet kell végig néznünk. Azonban mi történik akkor, ha valamelyik elemét tesztelni akarjuk? Le tudjuk választani az alatta levő rétegeket és tudjuk őket helyettesíteni? Vagy még extrémebb eset: az alatta levő réteg gyártóját beperelik és eltávolítja a csomagot, amire építkeztünk? Le tudjuk könnyen cserélni egy másikra?

Jó eséllyel nem. Éppen ezért ez az elv arra szólít fel, hogy alkossunk absztrakciókat, vagy ha úgy tetszik, interfaceket a rétegek közé. A fenti programot átírva, nézhetne ki így:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MyController {
    private BusinessLogicInterface businessLogic;

    <ins>public MyController(BusinessLogicInterface businessLogic) {</ins>
        this.businessLogic = businessLogic;
    }

    public void myAction() {
        Data myData = businessLogic.getSomeData();
        //...
    }
}

class BusinessLogic {
    private DatabaseConnectionInterface databaseConnection;

    <ins>public BusinessLogic(DatabaseConnectionInterface databaseConnection) {</ins>
        this.databaseConnection = databaseConnection;
    }

    public Data getSomeData() {
        DatabaseQueryResult result = databaseConnection.query('SELECT ...');
        //...
    }
}

Az összes osztályunk kizárólag absztrakcióktól függ (interfacek), és ráadásul cserélhető is, mert az osztály nem saját maga hozza létre azok példányait, hanem a konstruktorban elvárja paraméterként az adott típusú objektumot.

Ezen a ponton jogosan küldenél el a fenébe, hiszen ez hatalmas macera, de szerencsére erre is van megoldás. A különböző Dependency Injection Container megoldások képesek észlelni az egyes modulok igényeit, és a konfiguráció alapján képesek automatikusan létrehozni ezeket.

Zárszó

A S.O.L.I.D. elvek nem eredményezik automatikusan azt, hogy a kód mindörökké karbantartható lesz. Ahhoz emberi erőt is kell beletenni.

Egy módosítási kérésnél meg kell állni, hogy gyorsan összedrótozzuk működőre. Ha már ott az összedrótozás, venni kell a fáradtságot, és szét kell szedni.

Ha olyan ügyfél kérés jön, amire nem számítottunk, és hirtelen szét kell szednünk valamit, ami eddig együtt volt, nem ússzuk meg a refaktorálást. Ebben azonban rengeteget segít, ha a korábbi cikkekben bemutatott tesztelést, tesztvezérelt fejlesztést használjuk.

comments powered by Disqus