SOLID v JavaScripte: Liskov Substitution Principle

11.02.2024

Liskovej princíp zameniteľnosti je jediný zo SOLID princípov, ktorý je pomenovaný po nejakej osobe. Princíp formalizovala a prvýkrát o ňom hovorila americká vedkyňa Barbara Liskov a to ešte v roku 1987. Hoci sa všeobecne verí, že tento princíp popisuje dedičnosť v objektovo orientovanom programovaní, nie je to tak a venuje sa skôr podtypom všeobecne. Preto je platný aj pre iné, než len OOP jazyky, takže z jeho dodržiavania čerpáme benefity aj v JavaScripte a TypeScripte.

Liskov Substitution Principle

Definícia LSP je najpresnejšie vyjadrená matematicky, ale dala by sa prerozprávať zhruba takto:

Podtyp musí byť náhradou svojho rodičovského typu.

Keď si to rozmeníme na situácie, ktorých sa to môže týkať v JavaScripte a TypeScripte, tak dostaneme zhruba takúto množinu:

  • odvodená trieda musí byť náhradou svojej základnej triedy
  • odvodená trieda musí byť náhradou svojho interface-u
  • objekt musí byť náhradou svojho interface-u
  • implementácia typu musí byť náhradou svojho typového predpisu

Cieľom princípu je zabezpečiť, aby ste mohli vymeniť implementáciu nejakého rodičovského typu za implementáciu potomka a kód vám bude ďalej bez problémov fungovať.

Aby nejaký potomok alebo implementácia mohli o sebe povedať, že sú dobrými náhradami svojich rodičov, tak to pre nich znamená, že:

  • musia poskytovať implementáciu pre všetky metódy, ktoré predpisuje rodič, interface alebo typ
  • musia mať rovnaké typy návratových hodnôt z funkcií a metód
  • nesmú zvoľňovať typ návratovej hodnoty funkcií a metód
  • nesmú sprísňovať požiadavky na argumenty funkcií a metód
  • nesmú sprísňovať kontrakt (formálny aj neformálny)
  • nesmú obchádzať kontrakt v kóde

Pokiaľ neviete, čo je to kontrakt, tak sú to všetky "dohody" o tom, čo daný kus kódu má robiť. Časť kontraktu sa dá vynútiť prostriedkami programovacieho jazyka (typy parametrov a návratovej hodnoty), ale časť nie a tá je zapísaná len vo forme komentáru, názvov premenných alebo sa dokonca jedná len o predpoklady (napríklad o funkcii getUserId predpokladáme, že vracia ID používateľa).

Ako refaktorovať

Výhodou LSP oproti ostatným SOLID princípom je to, že veľkú časť jeho porušení vieme odchytiť statickou analýzou kódu a to dokonca už v čase písania. Samozrejme, závisí to od vami používaného jazyka a v čistom JavaScripte sú možnosti značne horšie, ako v TypeScripte.

Statická analýza v TypeScripte nám vie pomôcť najmä s chýbajúcimi metódami, sprísnenými typmi pre parametre funkcií a metódy alebo nesprávnymi návratovými hodnotami.

V JavaScripte si sprísnené požiadavky na argumenty musíme postrážiť sami. V podstate si potrebujeme sledovať, či funkcia, ktorú chceme použiť na mieste nejakej jej nadradenej, nemá striktnejšie požiadavky. Ak sme mali napríklad funkciu, ktorá očakáva v nejakom parametri číslo, tak na jej miesto nemôžeme dať funkciu, ktorá čaká iba číslo z nejakého intervalu. A už vôbec nemôžeme vyhodiť výnimku, ak dané číslo nie je z očakávaného intervalu.

Podobne je to s návratovými hodnotami. Akurát, že tam nemôžeme návratovú hodnotu zvoľniť. Ak nám teda napríklad nejaký predpis, komentár alebo predpoklad hovoria, že funkcia vracia číslo z intervalu, tak nemôžeme vrátiť číslo mimo neho alebo úplne iný typ (napr. string).

Nevýhodou LSP oproti ostatným SOLID princípom je to, že ku každej situácii musíme pristupovať individuálne a nie je možné spísať nejaký algoritmus s konkrétnejšími krokmi refaktoringu. Je však veľmi nápomocné si uvedomiť, že problémy často pramenia z nesprávne zadefinovanej základnej triedy alebo typu. Vtedy je potrebné typy a predpisy upraviť.

Príklad

Ako príklad si môžeme uviesť situáciu, ktorú vídam pomerne často. Väčšina webstránok má dva typy prihlásených používateľov: bežných návštevníkov a potom administrátorov. Administrátori majú často pridelené rôzne práva, podľa toho, ktorú časť funkcionality môžu obsluhovať.

Definícia typu pre používateľa takéhoto webu by mohla vyzerať aj takto:

interface User {
    getId: () => number;
    getName: () => string;
    getType: () => UserType;
    getFavouriteTvShowId: () => number;
    getAdminRights: () => AdminRight[];
}

V takomto nastavení máme problém minimálne na dvoch miestach našej webovej aplikácie. Na prvom z nich vytvárame inštanciu reprezentujúcu návštevníka:

const user: User = {
    getId: () => 11,
    getName: () => 'Ferko Mrkvička',
    getType: () => 'visitor',
    getFavouriteTvShowId: () => 42,
    //getAdminRights: () => ??? // čo máme vrátiť tu?
};

Na riadku 6 sa musíme rozhodnúť, aké administrátorské práva má mať bežný návštevník. Z technického hľadiska to nie je problém, lebo môžeme vrátiť prázdne pole. Ale už len to, že je možné takúto otázku ("aké admin práva má mať návštevník?") položiť, je vidieť, že niečo nie je v poriadku.

Druhé problémové miesto je vytváranie inštancie pre administrátora.

const admin: User = {
    getId: () => 101,
    getName: () => 'Neo',
    getType: () => 'admin',
    //getFavouriteTvShowId: () => ???,
    getAdminRights: () => ['ban-user', 'edit-user', 'view-user'],
};

Tu sa na piatom riadku dostávame do ešte komplikovanejšej situácie. Administrátor nášho webu nie je bežný návštevník a z hľadiska aplikácie nás jeho obľúbený seriál vôbec nezaujíma. Typ User nám však predpisuje, že ako ID obľúbeného televízneho seriálu musíme vrátiť číslo. My tak nemôžeme vrátiť ani null, ani undefined, ale musí to byť číslo. Môžeme sa dohodnúť, že niektoré čísla (napríklad 0, alebo záporné) budú brané ako špeciálne a ich hodnoty sa nebudú používať na prácu so seriálmi. To by však vyžadovalo zmeny na každom mieste, kde sa pracuje s ID televízneho seriálu. A takýchto miest môže byť veľa a ak zabudneme čo i len na jedno, tak kód môže byť nestabilný. Navyše je to len záplata na problém, ktorý bude naďalej existovať a jedného dňa nevyhnutne prebuble na povrch.

Tým problémom je v skutočnosti to, že potomkovia (inštancia user a admin) nie sú dobrými potomkami svojho rodiča (typový predpis User). A ani nemôžu nikdy byť, pretože samotný rodič je zadefinovaný tak, že to neumožňuje.

Úprava kódu tak, aby mohol spĺňať LSP znamená, že musíme zmeniť typový predpis rodiča takým spôsobom, aby obsahoval iba tie položky, ktoré sú spoločné pre každého používateľa webstránky. Zároveň potrebujeme zadefinovať nové typy pre návštevníka a pre administrátora a tam, kde je potrebné medzi nimi rozlišovať, musíme nastaviť typehinty podľa toho, akého typu inštanciu očakávame.

interface User {
    getId: () => number;
    getName: () => string;
    getType: () => UserType;
}

interface Visitor extends User {
    getFavouriteTvShowId: () => number;
    getType: () => 'visitor';
}

interface AdminUser extends User {
    getAdminRights: () => AdminRight[];
    getType: () => 'admin';
}

Zhrnutie

LSP vraví, že pokiaľ máme v kóde nejakú hierarchiu (objektov, typov), tak ju treba stavať tak, aby sme inštancie rodičov mohli vymieňať za inštancie potomkov bez hrozby spadnutia kódu. Hoci sa táto požiadavka môže javiť triviálna, tak to tak nemusí vždy byť a to najmä v situáciách, kde si nevieme pomôcť statickou analýzou kódu.

Aby mohli byť potomkovia dobrými náhradami svojich rodičov, tak musia poskytovať implementáciu pre všetky predpísané metódy, nemôžu zvoľňovať typ návratových hodnôt, nesmú sprísňovať kontrakt a požiadavky na argumenty a ani obchádzať kontrakt.

Pokiaľ sa tohto držíme, tak zistíme, že časti nášho kódu sú navzájom bezpečne vymeniteľné a úplne sme eliminovali chyby spôsobené nedodržiavaním kontraktov.