S.O.L.I.D. – Objektum orientált tervezési elvek 3. LSP

A sorozat részei:

SRP, OCP, LSP, ISP, DIP

Kétségkívül a legérdekesebb elv következik: a Liskov helyettesítés (Liskov Substitution Principle). Az állításunk a következő:

Ha S altípusa T-nek, akkor minden olyan helyen ahol T-t felhasználjuk S-t is minden gond nélkül behelyettesíthetjük anélkül, hogy a programrész tulajdonságai megváltoznának.

Fordítsuk le ezt kevésbé hivatalos nyelvre:

Ha S osztály T osztály leszármazottja, akkor S szabadon behelyettesíthető minden olyan helyre (paraméter, változó, stb…), ahol T típust várunk.

Ez nagyon úgy hangzik, mintha a polimorfizmusról beszélnénk, egy apróságtól eltekintve: a második definícióból direkt kihagytam a programrész tulajdonságairól szóló részt, ugyanis épp ez lesz a lényeg.

Nézzünk egy egyszerű, ám annál híresebb példát: Kör-Ellipszis probléma:

class Shape { }

class Ellipse : Shape
{
    public double MajorAxis { getset; }
    public double MinorAxis { getset; }
}

class Circle : Ellipse
{

}

Az osztályhierarchiában a Kör az Ellipszisből származik, lévén annak egy speciális esete. Na most, ha a fenti állításokat figyelembe vesszük, akkor egy rendkívül érdekes problémával nézünk szembe: tegyük fel, hogy egy olyan függvényt készítünk, amely egy ellipszis tengelyeit is módosítja. Ugye fönt kimondtuk, hogy ilyenkor ezt a függvényt bármely Kör objektumra is meghívhatjuk és most jön a lényeg: mi történik, ha egy Kör objektum tengelyei (az Ellipszisből származik, szóval nincs szigorúan vett sugár) megváltoznak?

Azt bátran elmondhatjuk, hogy onnantól megszűnik Kör lenni, elméletileg legalábbis, de maga a Kör objektum – immár hibásan – még ugyanúgy létezik.

Van-e megoldás? Gyors ötlet: legyenek a tengelyekhez tartozó propertyk virtuálisak, a Kör osztályban pedig takarjuk el őket. Kár, hogy ezzel ugyanúgy ellentmondunk a szabálynak, hiszen ekkor az előbbi függvényt meg sem hívhatjuk. Sakk-matt.

A probléma ott van, hogy azt hisszük, hogy a Kör valójában egy Ellipszis. Ez az állítás matematikailag valóban megállja a helyét, de most programot írunk, így más szempontból is meg kell néznünk a tényeket. Az igazság az, hogy bár a Körnek vannak Ellipszisre emlékeztető tulajdonságai, de valójában máshogy viselkedik, nem tudjuk vele betartatni az Ellipszisre vonatkozó szabályokat.

A megoldás az, hogy nincs “igazi” megoldás, de vannak lehetőségeink. A legegyszerűbb, hogy – mivel egyszerű típusról van szó – legyenek az objektumaink immutábilisek, vagyis minden olyan esetben amikor az objektumot megváltoztatnánk, új objektum keletkezik, ekkor nyilván lehetőségünk van szabályozni, hogy milyen típussal szeretnénk dolgozni.

Egy másik “megoldás”, hogy egyáltalán nem vesszük bele a Kört a hierarchiába, helyette mindig Ellipszist használunk (pl. nézzük meg a .NET grafikus felülethez tartozó osztályait, nem fogunk speciálisan Kör osztályt találni).

Itt most tegyünk egy kis kitérőt! Van az informatikának egy területe, amelyet helyességbizonyításnak hívnak, ami azzal foglalkozik, hogy matematikai eszközökkel bizonyítja, hogy egy program helyesen működik vagy sem. Ennek a tudományágnak az egyik alapját a Hoare logika képezi, innen fogunk néhány dolgot kölcsönvenni. Vegyük az alábbi kifejezést:

{P}C{Q}

Legyen P a kiinduló állapotra alkalmazott kikötés (nevezzük előfeltételnek, precondition), C egy művelet, Q pedig a művelet hatására létrejött állapot irányában támasztott feltétel (utófeltétel, postcondition). Mit jelent ez a gyakorlatban?

Nézzünk egy példát! Tegyük fel, hogy a műveletünk az osztás, ugye azt mindenki tudja, hogy nullával nem osztunk, vagyis az előfeltételben ki kell kötnünk, hogy az osztónak szánt paraméter értéke nem lehet nulla. Tehát az előfeltételnek igaznak kell lennie, különben az adott programrész viselkedése instabil lesz.

Ugyanígy működik az utófeltétel is, de ő a művelet végrehajtása utáni állapotra vonatkozik, például ha faktoriálist számolunk, akkor tudjuk, hogy az eredmény minden esetben nagyobb vagy egyenlő eggyel, vagyis ezt a feltételt tesszük az utófeltételbe.

Végezetül az invariáns a kettő kombinációjának nevezhető, vagyis olyan állítás, amely a művelet előtt és után is érvényes. Tökéletes példa egy Kör objektumokon végzett művelet, ekkor az invariánsban megmondhatjuk, hogy a Kör tengelyei (ugye még mindig egy Ellipszis leszármazottról van szó) egyenlők kell legyenek a művelet előtt és után is.

Ez eddig nagyon érdekes, de mi köze van a Liskov helyettesítéshez? Nagyon is sok, ezt az elvet a következő szabályokon keresztül lehet betartani:

– Az előfeltétel nem lehet erősebb a leszármazott osztályokban

– Az utófeltétel nem lehet gyengébb a leszármazott osztályokban

Nézzük meg, hogy ez hogy működik a gyakorlatban! Térjünk vissza az eredeti Kör-Ellipszis példára: az Ellipszis osztály egy ki nem mondott utófeltétele, hogy a tengelyeket tetszés szerint módosíthatjuk. Ha a Kör osztályban megmondjuk, hogy ezt talán mégsem szeretnénk, azzal meggyengítjük az eredeti utófeltételt, vagyis a Kör osztályt ilyen módon nem használhatjuk Ellipszisként.

Az előfeltétel/utófeltétel/invariáns trió és társaik egyébként a Design By Contract nevű fejlesztési “módszertanban” is megtalálhatóak, sőt a .NET beépítve tartalmazza a szükséges eszközöket is.

Rendben, eddig elég nyilvánvaló volt a probléma, hoztam egy életszerűbb példát is. Vegyük a következő metódust:

public void SaveEntities(IEnumerable<IEntity> entityList)
{
    foreach (IEntity entity in entityList)
    {
           DBconnection.Save(entity);
    }
}

Ugye vannak mindenféle entitásaink, amelyeket ugyanabban a metódusban mentünk el az adatbázisba. Na most, tegyük fel, hogy megkérnek minket, hogy egy adott entitástípus esetében a mentés előtt végezzünk el egy másik műveletet, ekkor az eredmény:

public void SaveEntities(IEnumerable<IEntity> entityList) {     foreach (IEntity entity in entityList)     {         if (null != entity as SomeEntity)         {             DoSomeJob(entity);         }         DBconnection.Save(entity);     } }
public void SaveEntities(IEnumerable<IEntity> entityList)
{
    foreach (IEntity entity in entityList)
    {
        if (entity is SomeEntity)
        {
            DoSomeJob(entity);
        }

        DBconnection.Save(entity);
    }
}

Most lapozzunk vissza a poszt elejére és nézzük meg, hogy mi a szabály vége: “anélkül, hogy a programrész tulajdonságai megváltoznának.” Az eredeti szövegben ezt úgy fogalmazták meg, hogy az adott programrész nem tudja, hogy egy leszármazottal van dolga.

Nyilvánvaló, hogy ezt a metódusunkban megszegtük, hiszen minden új funkcióhoz típusellenőrzésre van szükség, ami se nem elegáns, se nem hatékony. Tehát az ökölszabályunk a következő: ha egy adott programrész típusellenőrzésre épül akkor jó eséllyel valamit rosszul csinálunk. Egyúttal vegyük észre, hogy a fenti kód alapelve nagyon hasonlít arra amit az Open-Closed elvnél elkövettünk, és valóban; a Liskov helyettesítés annak egy speciális esete.

Mi legyen a megoldás? Ehhez meg kell néznünk, hogy miért alakult ki egyáltalán a probléma: ugyanúgy akartuk kezelni az összes IEntity-t megvalósító osztályt. Ötlet: ne tegyük. Vagyis megtehetjük, hogy az egyes altípusokat külön-külön metódussal/osztállyal dolgozzuk fel, pl. a Visitor vagy a Repository tervminták alapján.

Tagged with:
Nincs kategorizálva kategória
18 comments on “S.O.L.I.D. – Objektum orientált tervezési elvek 3. LSP
  1. pret83 szerint:

    Tehat mi is a megoldas ilyen esetben?

  2. Szerintem egy halom metódus esetleg override-dal.

  3. reiteristvan szerint:

    Nézzétek meg például az Entity Framework/LINQ To SQL megoldását: ugye van egy objectcontext/datacontext objektumod, amin keresztül el tudod érni az entitásokat, ezeket pedig külön-külön listában kezeli, Ha menteni akarom a változásokat, akkor ezen a kontextuson hívok egy SaveChanges (vagy ami éppen van) metódust, ami szépen végigmegy (végiglátogat, ez lenne a Visitor minta) az egyes listákon és elvégzi az esetleges típusspecifikus feladatokat is.

    Tehát ahelyett, hogy mindent egy menetben intéznék, inkább húzok még egy réteget az adatbázis és a program közé.

    Természetesen ezeket a látogató osztályokat hierarchiába is rendezhetem, ekkor jöhet az override-os megvalósítás.

  4. Gabor Mezo szerint:

    Arcitect vagyok egy BP-i cégnél, és épp most ajánlottam a SOLID cikkeidet a pulyáknak Skype-on elolvasásra, mert nagyon jók. Egyszerű, tömör, érthető mind, várjuk a uccsó részt!

    Ajánlottam, azzal a kikötéssel, hogy, idézem:

    “if (null != entity as SomeEntity)

    ha ilyet meglátok bárki kódjában, akkor az illetőt pofánvágom 🙂

    if (!(entity is SomeEntity)) .NET-ül

    meg ez nem C++. egyenesen ronda, ha a feltételben előre vesszük a konstans”

    😉

  5. szjani szerint:

    Hello, lenne egy kérdésem. Nem C#, de igazából lényegtelen.

    Perzisztensen tárolok entityket, amik tartalmaznak bizonyos konfigurációs beállításokat. Szeretnék egy ilyen entity collection-ön végigmenni, és mindegyiket feldolgozni úgy, ahogy azt kell.

    A feldolgozást service típusú osztályokba tenném, és minden entity tárolja, hogy milyen service tudja őt feldolgozni. És lényegében minden iterációban lekérem az entitytől a service azonosítót, azt kiszedem mondjuk DI containerből, átadom neki az entity-t és jön a következő iteráció. Egy pluginozható, könnyen bővíthető rendszerre lenne szükség.

    PHP-ben egy pár sorban:

    class AdapterClass {
    public function run(Entity $entity) {}
    }

    class Adapter1 extends AdapterClass {
    public function run(Entity1 $entity) {}
    }

    class Adapter2 extends AdapterClass {
    public function run(Entity2 $entity) {}
    }

    class Entity {}

    class Entity1 extends Entity {}

    class Entity2 extends Entity {}

    A probléma az, hogy az AdapterClass és az AdapterClass::run() is lehetne abstract, de akkor a származtatott osztályokban nem jó a metódus deklaráció. Jelen esetben persze működik a kód, csak ugye pont egy AdapterClass objektumot nem feltétlenül tudok helyettesíteni valamelyik leszármazottjával. Interfésszel ugyanez a helyzet.

    Erre tudnál valami okosat mondani?

    • reiteristvan szerint:

      Hello,

      Nem biztos, hogy jó ötlet közvetlenül az entitásokban tárolni, hogy mi tudja őt feldolgozni, pl. ha bevezetsz egy új servicet (vagy ha egy entitást több is fogadhat), akkor ezt módosítanod kell.
      Viszont, ha van egy listád, ami egy alaposztály szerint tárolja az objektumokat, akkor előbb-utóbb muszáj típusellenőrzést végezni, szóval ezt inkább a feldolgozó cikluson belül tenném meg, nem pedig az entitásba drótoznám.

      Az AdapterClass megvalósítása nem feltétlenül szerencsés, mivel minden entitás-típushoz külön osztályt készítesz. Ha lehetséges az egyes entitástípusoknak közös felületet készíteni, akkor bevezetnék egy interfészt, pl (C#, de szerintem érthető):

      interface IEntity
      {
      int ServiceID { get; set; }
      string ConfigData { get; set; }
      }

      Itt beleraktam a service azonosítót, ezáltal egyetlen metódussal feldolgozható az összes entitás, típustól függetlenül (és megspóroltuk a típusazonosítást is), valahogy így:

      public void Process(IEntity entity)
      {
      //előszedjük a servicet
      //és feldolgozzuk az adatokat
      }

      Tehát gyakorlatilag ugyanúgy tudjuk kezelni az összes entitást, az adatok sajátosságától függetlenül, azt majd rábízhatjuk a megfelelő servicera.

      Ez csak egy lehetséges megoldás, nem biztos, hogy be tudod illeszteni a saját kódodba, ha így van akkor kitalálunk valami mást 🙂

  6. szjani szerint:

    Szia!

    Köszönöm a választ.

    Az általad írt Process(IEntity) metódust ha jól értem a cikluson belül hívogatom minden egyes IEntity példányra. A metóduson belül pedig lekéred az entity-től az őt feldolgozni tudó service azonosítót, annak segítségével kiszeded a service-t egy containerből és átadod neki az entityt feldolgozásra.

    Én is ilyet próbáltam volna. A probléma az, hogy adjam át a service-nek az entityt. A Process metódusban valamiféle interfész alapján kellene kezelnem a service-ket, de ez csak akkor tehető meg, ha mindegyik fel tud dolgozni minden féle entity-t, ami nem megfelelő.

    • reiteristvan szerint:

      Az alapötletem az volt, hogy nem adod át a teljes entitást, hanem csak a szükséges adatokat és a service majd ellenőrzi, hogy megfelelő formában van-e, sőt eleve feltételezhetjük ezt, hiszen megadtuk, hogy milyen service kell az entitásnak. Jó, egy kicsit fapados megoldás ezt elismerem.

      Igazából az a kérdés, hogy mindent oda kell adnunk a servicenak, vagy az entitás mást is csinál? Mert ha az utóbbi akkor tovább lehetne bontani a felületét és egy külön interfész/osztály lenne a belső adatok megvalósításában.

      interface IConfigData
      {
      string ConfigData;
      }

      class Entity : IConfigData { }

      Ebben az esetben az IConfigData interfészen keresztül kommunikálhatunk, de persze ugyanaz a probléma, hogy minden service ezt az interfészt használná.

      Igazából a legegyszerűbb megoldás, hogy a feldolgozó ciklusban típus szerint szétválogatod az entitásokat és meghívod rájuk a megfelelő servicet. Vagy eleve külön listában tartod a különböző entitásokat.
      Ugye az alapvető probléma, hogy visszafelé kellene “kerekíteni” vagyis specializált osztályt akarsz használni, de azért jó lenne ősosztályként is működnie.

  7. inf3rno szerint:

    A példa többek között az SRP-t is megszegi, hiszen 2 dolgot csinálunk egy metódusban. Az egyik fele a domSomeJob-ot csinálja, a másik meg a mentést. A metódus nevében pedig nincs benne, hogy doSomeJob-ot is csinál, tehát a név sem beszélő, amiből pedig szintén kiderülne, hogy gond van, mert DoSomeJobAndSaveEntities nevet kapná, amiben ugye ott van az “and”, tehát két dolgot csinál… Amikor ilyenbe futunk, akkor biztosak lehetünk benne, hogy nincs a megfelelő absztrakciós szinten a kódrészlet.

    A visitor tényleg szép helyette, én eseménykezelősen csinálnám meg; betennék egy Observer-t a mentés elé, ami a Visitorokat hívja a listára, de persze a kevésbé általános megoldások is ugyanúgy jók.

    Nekem nem jött le teljesen ebből a cikkből, hogy mi ez az LSP. Mármint ezt a két szabályt:
    “- Az előfeltétel nem lehet erősebb/tágabb a leszármazott osztályokban
    – Az utófeltétel nem lehet gyengébb/szűkebb a leszármazott osztályokban”
    értem, hogy micsodák, de azt nem értem, hogy ez miért olyan fontos, mi történik hosszú távon, ha nem tartjuk be, stb… Mondjuk mi történik, ha az ellipszis leszármazottja lesz a kör, és beteszünk a tengelyek megváltoztatásához egy kivétel dobást arra az esetre, ha eltérő méretű tengelyeket állítanának be a körön?

    • inf3rno szerint:

      “Amikor ilyenbe futunk, akkor biztosak lehetünk benne, hogy nincs a megfelelő absztrakciós szinten a kódrészlet.” – Ezzel kapcsolatban tévedtem, nem minden esetben sérti meg az ilyen az SRP-t, van amikor ugyanannak az osztálynak egy másik metódusába kiemelhető az adott rész. (A jelen esetben megsérti, ezért kell Visitor-ba tenni a doSomeJob-ot…)

      • reiteristvan szerint:

        “A példa többek között az SRP-t is megszegi”

        Így van, ez kifejezetten egy “anti-példa” példa volt, tehát ezt szántam a rossz megoldásnak (más kérdés, hogy előfordul ilyesmi, főleg ha közeleg a határidő 🙂 ).

        “Mondjuk mi történik, ha az ellipszis leszármazottja lesz a kör, és beteszünk a tengelyek megváltoztatásához egy kivétel dobást arra az esetre, ha eltérő méretű tengelyeket állítanának be a körön?”

        Az már régen rossz, ha olyasmi miatt dobunk kivételt amit nem is lenne szabad megtenni. Ez nagyjából olyasmi, mintha nem azt mondanám a gyerekemnek, hogy nézz szét mielőtt kilépsz az úttestre, hanem akkor szólnák rá, amikor a kamion előtt ugrál. Az LSP lényege, hogy elkerüljük azt, hogy a forráskód tele legyen mindenféle ellenőrzésekkel: ha fogok egy objektumot akkor pontosan udni akarom, hogy mit tehetek vele és mit nem.

  8. H.Z. szerint:

    Van egy kis gond a tagekkel! Az LSP hibás címre visz, az ISP és a DIP pedig nem linkek, így nehéz megtalálni a cikksorozat egyes részeit.
    Egyébként köszi, talán magyarul egyszerűbb lesz felfogni, mert egyelőre teljes bennem a zűrzavar S.O.L.I.D. témában (is) 😦

  9. Margit Fawal szerint:

    Szia!
    Az Eiffel, ami egy (DbC) nyelv, úgy működik, hogy amikor a leszármazott osztályban felüldefiniálunk egy featuret (metódust) akkor annak az
    előfeltételében “vagy” operátort használhatunk, ami azt jelenti hogy gyengítjük a feltételt a leszármazottban. Utófeltételnél pedig csak “és” operátort – ekkor a feltételeknek (ős és leszármazottban lévő utófeltételek) egyszerre kell teljesülniük, ami az Ős Utófeltételéhez képest “szűkebb” igazság halmazt eredményezhet. Az előfeltételnél pedig ugyanez “tágabbat”.

    Te pedig így fogalmaztál:

    “- Az előfeltétel nem lehet erősebb/tágabb a leszármazott osztályokban

    – Az utófeltétel nem lehet gyengébb/szűkebb a leszármazott osztályokban”

    Szeretném megkérdezni hogy mit értesz az alatt hogy az előfeltétel nem lehet tágabb illetve az utófeltétel nem lehet szűkebb?

    most tekintsünk el az erősebb és gyengébb szavaktól.

    • reiteristvan szerint:

      Szia,

      A leghalványabb fogalmam sincs. Amikor írtam biztos volt értelme a fejemben, de most csak ellentmondást látok, valószínűleg te is ezen akadtál fent. Ha fordítva lennének lenne értelme, de ilyen módon felesleges a szinonimákkal játszani, kiveszem őket.
      Köszönöm, hogy szóltál!

Hozzászólás