neděle 6. února 2011

Liskovové princip zaměnitelnosti - podmínky kladené na dědičnost

Úvod, definice

Liskovové princip zaměnitelnosti (Liskov Substitution Principle - LSP) je třetím základním principem objektové návrhové metodologie SOLID. Je definován takto:

Funkce (metody), které používají ukazatele nebo reference na bázovou třídu, musí být schopny použít objekty jejich podtříd bez jejich znalostí.

Tento princip nám klade zásadní podmínky na vlastnosti bázových tříd a jejich podtříd (potomků). Definuje, za jakých podmínek je možné vytvářet podtřídy bázových tříd a využívat výhody plynoucí z dědičnosti. Při porušení principu LSP se dostáváme do problémů, jak bude ukázáno v dalším textu.

Porušení principu LSP - nutná znalost podtříd

Navrhujeme reprezentaci základních geometrických tvarů. Bázovou třídou je třída Tvar, jejími potomky jsou třídy Ctverec a Kruznice. Metoda VykreslitTvar() zajišťuje vykreslení tvarů různého typu:

class Tvar { }

class Ctverec : Tvar { }

class Kruznice : Tvar { }

...

void VykreslitTvar(Tvar tvar)
{
    if (tvar is Ctverec)
    {
        VykreslitCtverec(tvar as Ctverec);
    }
    else if (tvar is Kruznice)
    {
        VykreslitKruznici(tvar as Kruznice);
    }
}

Takto definová metoda VykreslitTvar() porušuje LSP, neboť není splněna druhá část definice. Metodě nestačí znalost pouze bázové třídy, ale musí znát i její potomky. Musí vědět, že v systému existují třídy Ctverec a Kruznice a musí je umět přetypovat. Metoda zároveň porušuje princip otevřenosti a uzavřenosti OCP. Pokud vznikne další potomek třídy Tvar například Trojuhelnik, je nutné metodu otevřít a doplnit znalost nové podtřídy.

Porušení principu LSP - nezaměnitelnost podtříd

Navrhujeme reprezentaci základních geometrických tvarů. Vznikne bázová třída Tvar a její podtřída pro obdélníky Obdelnik. U obdélníku evidujeme rozměry pro výšku a šířku, které zpřístupníme přes veřejné settery:

class Tvar { }

class Obdelnik : Tvar
{
    private double sirka;
    private double vyska;

    public double Sirka 
    {
        get { return sirka; }
        set { sirka = value; } 
    }

    public double Vyska 
    {
        get { return vyska; }
        set { vyska = value; } 
    }
}

Požadujeme také třídu Ctverec. Vyjdeme z předpokladu, že čtverec je speciální případ obdélníka a třídu Ctverec nadefinujeme jako podtřídu třídy Obdelnik. Musíme zajistit, aby vždy platila rovnost rozměrů šířky a výšky čtverce. Nabízí se využít settery pro Vysku a Sirku a přidat nastavení i druhé vlastnosti. Dostáváme definici třídy Ctverec:

class Ctverec : Obdelnik
{
    public double Sirka
    {
        set
        {
            base.Sirka = value;
            base.Vyska = value;
        }
    }

    public double Vyska
    {
        set
        {
            base.Vyska = value;
            base.Sirka = value;
        }
    }
}

Na první pohled vypadají naše třídy v pořádku a dostavuje se pocit uspokojení. Zkušenější návrhář však začíná pochybovat a hledá na řešení problémová místa.

Toto přiřazení ještě dopadne podle očekávání:

Ctverec ctverec = new Ctverec();
ctverec.Vyska = 1;  // Vyska == 1, Sirka == 1

... ovšem, pokud logiku nastavení vlastnosti Vyska umístíme do třídy, která předpokládá na vstupu obecnější třídu Obdelnik, dostáváme "neočekávané" chování:

void F(Obdelnik obdelnik)
{
    obdelnik.Vyska = 1;
}

Obdelnik obdelnik = new Obdelnik();
F(obdelnik);     // Vyska == 1, Sirka == 0, ok
Ctverec ctverec = new Ctverec();
F(ctverec);     // Vyska == 1, Sirka == 0 (předpokládali jsme, že bude 1)

K čemu tady došlo? Proč je šířka čtverce 0, přestože v setteru Ctverec.Vyska je i nastavení šířky na stejnou hodnotu? Metoda F() předpokládá na vstupu objekt typu Obdelnik a volání setteru vlastnosti Vyska se tedy omezí na úroveň této třídy. Setter Ctverec.Vyska se vůbec nevyvolá.

V C# je v tomto případě možné použít modifikátor override, kterým označíme metodu nebo vlastnost podtřídy jako přepsanou. Přepisovat můžeme pouze metody a vlastnosti, které jsou v bázové třídě označené jako virtuální (virtual), abstraktní (abstract) nebo přepsané (override). Přepsané metody nebo vlastnosti budou prováděny až na úroveň příslušné podtřídy i přesto, že se pracuje s bázovou třídou. V našem případě přepíšeme definice tříd Obdelnik a Ctverec takto:

class Obdelnik : Tvar
{
    private double sirka;
    private double vyska;

    public virtual double Sirka 
    {
        get { return sirka; }
        set { sirka = value; } 
    }

    public virtual double Vyska 
    {
        get { return vyska; }
        set { vyska = value; } 
    }
}

class Ctverec : Obdelnik
{
    public override double Sirka
    {
        set
        {
            base.Sirka = value;
            base.Vyska = value;
        }
    }

    public override double Vyska
    {
        set
        {
            base.Vyska = value;
            base.Sirka = value;
        }
    }
}

... a metoda F() se bude chovat očekávaným způsobem i pro čtverce:

Ctverec ctverec = new Ctverec();
F(ctverec);     // Vyska == 1, Sirka == 1, ok

Pokud si v tento okamžik pomyslíte, že veškeré problémy s návrhem jsou vyřešeny, zkuste si napsat třeba tuto metodu:

void G(Obdelnik obdelnik)
{
    obdelnik.Sirka = 4;
    obdelnik.Vyska = 5;

    if (obdelnik.Sirka * obdelnik.Vyska != 20)
    {
        // Chyba
    }
}

Pokud je na vstupu objekt typu Ctverec, nastane chyba. Přiřazení obdelnik.Vyska = 5 totiž způsobí (pro autora metody G()) nechtěné nastavení šířky čtverce na 5 a podmínka nebude splněna (5 * 5 == 25). Autor metody G() omezil správně funkcionalitu pouze na objekty typu obdélník. To že nadefinujeme podtřídu Ctverec jako potomka bázové třídy Obdelnik a využijeme tuto metodu, nemohl předpokládat. Při chybném návrhu třídy Ctverec jsme porušili Liskovové princip zaměnitelnosti.

Tento příklad by Vás měl varovat. Vždy pečlivě zvažte, zda-li vztah mezi dvěma třídami opravdu splňuje podmínky kladené na dědičnost. Problémy se mohou projevit později, kdy je pracné chybný návrh přepracovat.

Podmínky typu předpoklady, následné podmínky a invarianty

Omezující podmínky jsou základními stavebními kameny v návrhové metodologii nazvané Návrh řízený podmínkami (dohodami) (Design by Contract - DbC). Většina vyšších programovacích jazyků obsahuje syntaktické konstrukty pro zajištění DbC. Např. v .Net Fx 4.0 jsou k dispozici třídy ve jmenném prostoru System.Diagnostics.Contracts.

Podmínky mohou být trojího typu:

  • Předpoklady (Preconditions) - podmínky, které musí platit před provedením kódu metody nebo vlastnosti.
  • Následné podmínky (Postconditions) - podmínky, které musí platit po provedení kódu metody nebo vlastnosti.
  • Invarianty (Invariants) - podmínky, které musí platit po celou dobu existence objektu.

Princip LSP definuje požadavky na chování, které musí podtřídy splňovat:

  • Předpoklady nesmí být zesilovány v podtřídách.
  • Následné podmínky nesmí být oslabovány v podtřídách.
  • Invarianty nadřízené třídy musí být zachovány v podtřídách.

Kovariance a kontravariance

Pro pochopení vzájemné zaměnitelnosti objektových typů, které jsou v hierarchii dědičnosti, je nutná znalost pojmů kovariance a kontravariance.

Kovariance (covariance) vyjadřuje možnost do návratové proměnné typu bázové třídy vložit objekt libovolné podtřídy. Třída DemoKovarinace má metody Funkce1() a Funkce2(), které pro delegáta funkce s návratovým typem Obdelnik splňují podmínku kovariance. Funkce2() vrací objekt typu Ctverec, který je podtřídou požadované třídy Obdelnik.

class DemoKovariance
{
    public delegate Obdelnik VratitObdelnik();

    public static Obdelnik Funkce1()
    {
        return new Obdelnik();
    }

    public static Ctverec Funkce2()
    {
        return new Ctverec();
    }

    static void Main()
    {
        VratitObdelnik funkce1 = Funkce1;
        Obdelnik obdelnik1 = funkce1();
        VratitObdelnik funkce2 = Funkce2;
        Obdelnik obdelnik2 = funkce2();
    }
}

Kontravariance (contravariance) vyjadřuje možnost objektovou hodnotu předávanou jako parametr vložit do objektového typu bázové třídy (nadtřídy). Jak ukazuje příklad, delegát funkce PredatCtverec() má v signatuře parametr typu Ctverec. Funkce Funkce2() má na vstupu parametr typu Obdelnik, který je bázovou třídou třídy Ctverec. Kontravariance je tedy splněna, program bude fungovat korektně.

class DemoKontravariance
{
    public delegate void PredatCtverec(Ctverec ctverec);

    public static void Funkce1(Ctverec ctverec)
    {
    }

    public static void Funkce2(Obdelnik obdelnik)
    {
    }

    static void Main()
    {
        PredatCtverec funkce1 = Funkce1;
        funkce1(new Ctverec());
        PredatCtverec funkce2 = Funkce2;
        funkce2(new Ctverec());
    }
}

Princip LSP definuje požadavky na metody podtříd:

  • Kontravariance argumentů metod v podtřídách.
  • Kovariance návratových typů metod v podtřídách.
  • Podtřídy nemohou vyvolat jiné výjimky než výjimky použité v bázové třídě nebo podtřídy těchto výjimek.

Další zdroje a ukázky

Žádné komentáře:

Okomentovat