Zobrazují se příspěvky se štítkemSOLID. Zobrazit všechny příspěvky
Zobrazují se příspěvky se štítkemSOLID. Zobrazit všechny příspěvky

pondělí 14. března 2011

Princip obrácení závislostí - oddělte rozhraní od implementace

  • Špatný návrh systému
  • Princip obrácení závislostí - definice
  • Příklad aplikace DIP
  • DIP a Separated Interface Pattern
  • DIP a Dependency Injection
  • DIP a související problematiky
  • Další zdroje a ukázky
  • Špatný návrh systému

    Abychom pochopili důležitost principu popisovaného v tomto příspěvku, pokusme se nejdříve zadefinovat charakteristiky špatně navrženého systému. V případě, že rozpoznáme typické chyby v návrhu, budeme schopni těmto chybám lépe předcházet. Vyjdeme z definice Roberta C. Martina, který poukazuje na tři základní charakteristiky chybného návrhu:

    • Nepružnost (rigidity). Je obtížné provést změnu, neboť ta se projeví na spoustě jiných míst v systému. Jednoduchou změnu není možné provést bez nutnosti upravit závislé části systému. To může vynutit kaskádový efekt změn. Důsledky změny je pak těžké odhadnout a je tedy těžké odhadnout i skutečné náklady na změnu. Pokud systém netrpí nepružností je flexibilní.
    • Křehkost (fragility). Při provedení změny se nepředpokládaně projeví problémy na jiných místech systému. Změny se mohou projevit i na místech, které nemají přímou souvislost s měněnou oblastí. Uživatelé a manažeři systému nejsou schopni predikovat kvalitu systému. Opravy takových problémů mohou zanést další problémy. Pokud systém netrpí křehkostí je robustní.
    • Imobilita (immobility). Je obtížné část systému osamostatnit a použít v jiném systému. Důvodem je vysoká závislost a provázanost mezi částmi systému. Návrháři jsou zastrašeni množstvím práce, které obnáší osamostatnění části systému a uchylují se k duplicitám a znovuvyvíjení podobných částí. Pokud systém netrpí imobilitou je znovupoužitelný.

    Co je příčinou takto definovaného chybného návrhu? Je to vysoký stupeň závislostí mezi částmi (komponentami) systému. Pokud se nám podaří snížit stupeň provázanosti jednotlivých částí systému, náš systém se stane flexibilnějším, robustnějším a znovupoužitelnějším. Princip obrácení závislostí nám dává doporučení, pomocí kterých je tento cíl lépe dosažitelný.

    Princip obrácení závislostí - definice

    Princip obrácení závislostí (DIP - Dependency Inversion Principle) je pátým základním principem objektové návrhové metodologie SOLID. Jeho definice zní:

    A. Moduly vyšší úrovně by neměly být závislé na modulech nižších úrovní. Obojí by měly být závislé na abstrakci.
    B. Abstrakce by neměla záviset na detailech. Detaily by měly záviset na abstrakcích.

    V "tradiční" (naivní) architektuře mohou být komponenty na různých vrstvách ve vztahu závislostí jak je uvedeno na obrázku. Komponenta A je na vyšší úrovni než komponenta B a je na ní závislá. Komponenta B je závislá na komponentě C. Transitivita v tomto případě neplatí, tzn. že komponenta A nezávisí na komponentě C.

    DIP nám radí odstranit přímou závislost komponent tím, že použijeme abstrakci (v tomto případě rozhraní). Konkrétně komponenta A zadefinuje rozhraní, které požaduje pro svoji funkcionalitu ("Požadované rozhraní komponentou A"). Toto rozhraní je ve veřejné části komponenty A. Komponenta A nic neví o komponentě B. Komponenta B zná z balíčku komponenty A pouze veřejné rozhraní "Požadované rozhraní komponentou A" a toto rozhraní realizuje. Tím se stane komponenta B "kompatibilní" pro komponentu A, aniž by se implementace obou komponent "znaly". Rozhraní "Požadované rozhraní komponentou A" reprezentuje funkční dohodu mezi komponentou A a komponentou B a obecně mezi dalšími komponentami, které rozhraní realizují. Situace by měla být zřejmá z obrázku:

    Někdy se můžete setkat s UML notací (viz. další obrázek), ve které jsou zachycena "vystavená" rozhraní komponent. Komponenta A vyžaduje rozhraní (otevřený půlkruh) "Požadované rozhraní komponentou A". Komponenta B stejné rozhraní nabízí (uzavřený kruh). Proto jsou schopny vzájemně komunikovat.

    Z předchozích obrázků je zřejmé, proč se princip nazývá principem "obrácení závislostí". Komponenta A byla původně závislá na komponentě B. Po aplikování principu již komponenta A nezávisí na komponentě B, ale ta závisí na veřejném rozhraní komponenty A. Závislost se obrátila.

    Příklad aplikace DIP

    Je požadován systém, který bude kopírovat znaky zadané z klávesnice na tiskárnu. Program by mohl vypadat třeba takto:

    namespace KopirovaniZnaku
    {
        public class SpravceKopirovani
        {
            void KopirovatZnaky()
            {
                char znak;
                while ((znak = NacistZKlavesnice()) >= 0)
                {
                    VypsatNaTiskarnu(znak);
                }
            }
            
            ...
        }
    }
    

    Základem je metoda KopirovatZnaky(), která postupně načítá znaky z klávesnice a vypisuje je na tiskárnu. Implementace privátních metod NacistZKlavesnice() a VypsatNaTiskarnu() není důležitá. Důležitá je však závislost třídy SpravceKopirovani na implementaci čtení z klávesnice a zápisu na tiskárnu.

    Problém našeho řešení nastane v momentě, kdy se objeví nový požadavek, aby bylo možné znaky vypisovat nejen na tiskárnu, ale také na monitor. V tento okamžik musíme přidat rozhodování na místo v programu, kde se implementuje výpis výstupu. Přidáme také výčtový typ VystupniZarizeni pro reprezentaci typu výstupního zařízení.

    namespace KopirovaniZnaku
    {
        public enum VystupniZarizeni
        {
            Tiskarna,
            Monitor
        }
    
        public class SpravceKopirovani
        {
            void KopirovatZnaky(VystupniZarizeni vystup)
            {
                char znak;
                while ((znak = NacistZKlavesnice()) >= 0)
                {
                    switch (vystup)
                    {
                        case VystupniZarizeni.Tiskarna:
                            VypsatNaTiskarnu(znak);
                            break;
                        case VystupniZarizeni.Monitor:
                            VypsatNaMonitor(znak);
                            break;
                    }
                }
            }
            
            ...
        }
    }
    

    Program se nám komplikuje a začínáme tušit, že narůstá počet přímých závislostí mezi třídou SpravceKopirovani a implementací čtení a zápisu pro různá vstupně-výstupní zařízení. Vzpomeňme si na princip obrácení závislostí a odstraňme tyto přímé vazby. Zadefinujeme rozhraní IVstupniZarizeni, které nám bude reprezentovat vstupní zařízení a bude nabízet metodu pro čtení znaku. Podobné rozhraní bude potřeba pro výstupní zařízení (IVystupniZarizeni), které bude nabízet metodu pro zápis znaku.

    namespace KopirovaniZnaku
    {
        interface IVstupniZarizeni 
        {
            char NacistZnak();
        }
    
        interface IVystupniZarizeni 
        {
            void VypsatZnak(char znak);
        }
    
        public class SpravceKopirovani
        {
            void KopirovatZnaky(IVstupniZarizeni vstup, IVystupniZarizeni vystup)
            {
                char znak;
                while ((znak = vstup.NacistZnak()) >= 0)
                {
                    vystup.VypsatZnak(znak);
                }
            }
        }
    }
    
    namespace Klavesnice
    {
        public class Klavesnice : KopirovaniZnaku.IVstupniZarizeni
        {
            public char NacistZnak()
            {
                // Implementace načítání znaků z klávesnice
            }
        }
    }
    
    namespace Tiskarna
    {
        public class Tiskarna : KopirovaniZnaku.IVystupniZarizeni
        {
            public void VypsatZnak(char znak)
            {
                // Implementace výpisu znaku na tiskárnu
            }
        }
    }
    

    Vytvořili jsme programové komponenty KopirovaniZnaku, Klavesnice a Tiskarna. Ty spolu komunikují přes rozhraní IVstupniZarizeni a IVystupniZarizeni, která zadefinovala komponenta KopirovaniZnaku. Tato komponenta je na vyšší úrovni než komponenty Klavesnice a Tiskarna. Podle principu DIP nemáme v systému žádnou přímou závislost mezi implementacemi. Závislost je pouze na rozhraní. Systém nyní zřejmě splňuje požadavky na dobrý návrh, viz. úvodní kapitola příspěvku.

    DIP a Separated Interface Pattern

    Z principu DIP plyne, že bychom se měli ve fázi návrhu systému soustředit spíše na definici rozhraní mezi komponentami než na úvahy o vlastní implementaci. A vlastní rozhraní pak logicky oddělit od implementačních detailů. Tato programovací technika bývá označována jako "Programování vůči rozhraní, ne vůči implementaci".

    Jeden z návrhových vzorů je mírnou modifikací původního DIP a řeší způsob uložení veřejného rozhraní komponenty. Vzor se nazývá Separated Interface Pattern a doporučuje oddělit veřejné rozhraní komponenty a její vlastní implementaci do samostatných balíčků. Toto oddělení přináší výhody při referencování mezi balíčky. V případě DIP bylo rozhraní součástí balíčku komponenty vyšší úrovně. Bylo proto úzce spjato s klientskou stranou rozhraní, která nesla za rozhraní zodpovědnost. V případě SIP je rozhraní odděleno i od této komponenty a tím je zajištěno, že za vývoj rozhraní není zodpovědná pouze klientská strana.

    Na obrázku je vidět, že komponenta A závisí na rozhraní komponenty B. Rozhraní komponenty B je uloženo v samostatném balíčku. Implementace komponenty B je v samostatném balíčku a realizuje toto rozhraní.

    DIP a Dependency Injection

    Dependency Injection (DI) je množina návrhových technik souvisejících s DIP. Řeší zodpovědnost za zajištění závislostí komponent na externích zdrojích. Cílem Dependency Injection je oddělit problematiku získání závislostí od vlastních komponent.

    Jedna z technik DI, nazývaná Constructor Injection, definuje závislosti mezi komponentami s využitím předávání závislých komponent pomocí argumentů konstruktorů. Tzn. závislosti jsou ustanoveny v době vzniku komponenty.

    Pro uplatnění principů DI se používají různé frameworky označované jako Inversion of Control frameworks.

    DIP a související problematiky

    Jak je patrné z předchozího textu, princip DIP vnáší do návrhu systému prvky flexibility a snižuje vzájemné propojení komponent. Díky těmto vlastnostem mohou být systémy otevřené pro použití zásuvných modulů - plug-inů (návrhový vzor Plug-in). Jedná se o externí implementace (třetích stran), které respektují a realizují požadované rozhraní.

    Můžete se také seznámit s návrhovým vzorem Service Locator, který je určen pro registraci a lokalizaci služeb za běhu programu. Není zodpovědný za vznik instance služby, proto se často kombinuje se vzory typu Factory Pattern a (nebo) Dependency Injection.

    Příjemným důsledkem oddělení rozhraní a implementace je možnost použití mockovacích frameworků při testování komponent. S využitím rozhraní můžete snadno vytvořit příslušnou mock implementaci. Vytvoříte falešnou implementaci, která provádí to, co v dané situaci vyžadujete. Více o použití mocků v některém z budoucích příspěvků.

    Další zdroje a ukázky

    Máte nápad, připomínku, našli jste chybu? Přidejte prosím komentář k tomuto článku.

    neděle 20. února 2011

    Princip oddělení rozhraní - méně je více

  • Úvod, definice
  • Ukázka porušení principu ISP
  • Návrh s využitím oddělených rozhraní
  • Příklad oddělení rozhraní v .Net frameworku
  • Další zdroje a ukázky
  • Úvod a definice

    Princip oddělení rozhraní (ISP - Interface Segregation Principle) je čtvrtým základním principem objektové návrhové metodologie SOLID. Jeho definice zní:

    Klienti by neměli být nuceni do závislostí na rozhraních, která nepoužívají.

    Princip ISP definuje požadavky kladené na veřejné rozhraní tříd. Doporučuje vyhýbat se návrhu příliš "tlustých" rozhraní. Třída poskytující "tlusté" rozhraní obvykle není soudržná. Třída samozřejmě může nabízet rozhraní pro více typů klientských přístupů, neměli byste však nutit klienta využívat rozsáhlá rozhraní, která jsou pro něj užitečná pouze z části. Rozsáhlá rozhraní raději rozdělte do více menších rozhraní podle typu obsluhovaných klientů.

    Ukázka porušení principu ISP

    Navrhujeme rozhraní abstraktní třídy reprezentující zámek (například u dveří). Zámek můžeme odemykat, zamykat a zjišťovat jeho stav.

    abstract class Zamek
    {
        public abstract void Zamknout();
        public abstract void Odemknout();
        public abstract Boolean JeOdemceno { get; }
    }
    

    V systému máme implementován časovač - třída Casovac a jeho klientské rozhraní - třídu KlientCasovace.

    class Casovac
    {
        public void Registrovat(int casovyLimitVMilisekundach, KlientCasovace klient) 
        { 
            // Implementace časovače.
        }
    }
    
    abstract class KlientCasovace
    {
        public abstract void ObslouzitVyprseniCasovehoLimitu();
    }
    

    Nyní chceme implementovat časový zámek - třída CasovyZamek. Vytvoříme podtřídu abstraktní třídy Zamek. Potřebujeme však využít funkcionalitu třídy KlientCasovace. Zkusíme třídu Zamek nadefinovat jako podtřídu třídy KlientCasovace. Výsledná dědičnost vypadá takto:

    Jak zřejmě tušíte, něco tady nehraje. Tato dědičnost je problematická. Třída KlientCasovace je bázovou třídou třídy Zamek. Všechny podtřídy bázové třídy Zamek jsou v tomto případě nuceni používat rozhraní, vynucené třídou KlientCasovace. Rozhraní třídy Zamek je "znečištěno" rozhraním třídy KlientCasovace. Oddělení funkcionality do samostatných tříd (Zamek, KlientCasovace) ztrácí v tomto případě na užitečnosti.

    Dalším problémem je to, že abstraktní třída Zamek nepotřebuje podporu časovače, přesto je jí vnucena. Měla by tedy implementovat abstraktní metodu ObslouzitVyprseniCasovehoLimitu(). To je samo o sobě problematické. Nemůže totiž implementovat funkcionalitu, kterou potřebuje až její podtřída CasovyZamek. Došlo by k porušení Liskovové principu zaměnitelnosti se všemi problémy, které tento prohřešek přináší.

    Návrh s využitím oddělených rozhraní

    Naším úkolem bude navrhnout třídy tak, abychom se zbavili přímé závislosti mezi třídou Zamek a třídou KlientCasovace. Pomůžeme si návrhovým vzorem Adapter. Vytvoříme třídu CasovacZamkuAdapter, která nám bude adaptovat funkcionalitu abstraktní třídy KlientCasovace. Zároveň bude mít referenci na instanci třídy CasovyZamek. Třída CasovyZamek si vytvoří instanci adaptéru CasovacZamkuAdapter, který převezme zodpovědnost za komunikaci s rozhraním třídy KlientCasovace. Názornější by měl být obrázek:

    Pokud dojde ke změně rozhraní třídy KlientCasovace, bude ovlivněna pouze třída CasovacZamkuAdapter a možná i třída CasovyZamek. Ale nebude ovlivněna třída Zamek ani její případné podtřídy (s vyjímkou třídy CasovyZamek). Implementace tříd by pak mohla vypadat například takto:

    abstract class Zamek
    {
        public abstract void Zamknout();
        public abstract void Odemknout();
        public abstract Boolean JeOdemceno { get; }
    }
    
    class Casovac
    {
        public void Registrovat(int casovyLimitVMilisekundach, KlientCasovace klient) 
        { 
            // Implementace funkcionality časovače.
        }
    }
    
    abstract class KlientCasovace
    {
        public abstract void ObslouzitVyprseniCasovehoLimitu();
    }
    
    class CasovacZamkuAdapter : KlientCasovace
    {
        private CasovyZamek casovyZamek;
    
        public CasovacZamkuAdapter(CasovyZamek casovyZamek)
        {
            this.casovyZamek = casovyZamek;
        }
    
        public void ZapnoutCasovac(int casovyLimitVMilisekundach)
        {
            Casovac casovac = new Casovac();
            casovac.Registrovat(casovyLimitVMilisekundach, this);
        }
    
        public override void ObslouzitVyprseniCasovehoLimitu()
        {
            casovyZamek.Zamknout();
        }
    }
    
    class CasovyZamek : Zamek 
    {
        private Boolean jeOdemceno;
        private int casovyLimitVMilisekundach;
        private CasovacZamkuAdapter casovac;
    
        public CasovyZamek(int casovyLimitVMilisekundach)
        {
            this.casovyLimitVMilisekundach = casovyLimitVMilisekundach;
            casovac = new CasovacZamkuAdapter(this);
        }
    
        public override void Zamknout()
        {
            jeOdemceno = false;
        }
    
        public override void Odemknout()
        {
            jeOdemceno = true;
            casovac.ZapnoutCasovac(casovyLimitVMilisekundach);
        }
    
        public override bool JeOdemceno
        {
            get { return jeOdemceno; }
        }
    }
    

    Třída CasovacZamkuAdapter si v metodě ZapnoutCasovac() vytvoří instanci třídy Casovac a zaregistruje se v něm. Časovač po uplynutí časového limitu vyvolá metodu ObslouzitVyprseniCasovehoLimitu(). V této metodě je vyvolána metoda Zamknout() instance třídy CasovyZamek.

    Alternativním řešením je použít vícenásobnou dědičnost, která však v programovacích jazycích typu C#, VB nebo Java není z objektivních důvodů podporována. V tomto případě by třída CasovyZamek byla podtřídou třídy Zamek a zárověň podtřídou třídy KlientCasovace. Před vícenásobnou dědičností dostává přednost použití více rozhraní, tak jak je to ukázáno na dalším příkladě z .Net frameworku.

    Příklad oddělení rozhraní v .Net frameworku

    Na mnoha místech .Net frameworku je použito oddělení funkcionality do malých soudržných rozhraní. Pokud se například podíváme na deklaraci třídy System.Collections.Hashtable, zjistíme, že implementuje několik relativně malých rozhraní:

    public class Hashtable : IDictionary, ICollection, IEnumerable, 
        ISerializable, IDeserializationCallback, ICloneable
    

    Každé rozhraní je určeno pro klienty s jinou potřebou přístupu k třídě Hashtable. Pro sekvenční procházení příkazem foreach je nutné rozhraní typu IEnumerable, pro serializaci objektu zase ISerializable, pro vytvoření identické kopie instance využijete IClonable. Tento způsob rozložení funkcionality do malých soudržných rozhraní je určitě výhodný a umožňuje třídy používat v různých souvislostech vždy správným způsobem (přes správné rozhraní).

    Další zdroje a ukázky

    Máte nápad, připomínku, našli jste chybu? Přidejte prosím komentář k tomuto článku.

    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

    úterý 1. února 2011

    Princip otevřenosti a uzavřenosti - Open Closed Principle

    Otevřený nebo uzavřený?

    Princip otevřenosti a uzavřenosti (Open Closed Principle - OCP) je druhým základním principem definovaným objektovou návrhovou metodologií SOLID. Jeho definice zní:

    Programové entity (třídy, moduly, funkce, apod.) by měly být otevřeny pro rozšiřování, ale uzavřeny pro modifikaci.

    Pojďme si definici rozložit na jednotlivé podčásti:

    • Otevřený pro rozšiřování. Chování entity může být dále rozšiřováno. Tím je zajištěna reakce na změnu požadavků v aplikaci.
    • Uzavřený pro modifikaci. Zdrojový kód entity je nedotknutelný. Nikdo nemá povoleno kód změnit.
    Možná se Vám teď začínají protáčet mozkošrouby a snažíte se přijít na to, jak tento, na první pohled protichůdný princip, aplikovat v praxi. Nebojte se, rozumím Vám. Vydržte a čtěte dál.

    Klíčem je abstrakce

    Pokud použijeme přímou vazbu mezi konkrétními třídami, které jsou ve vztahu klient - server, stává se klientská třída přímo závislá na serverové. Pokud potřebujeme použít jinou serverou třídu, musíme otevřít klientskou třídu a změnit název serverové třídy. Takový klient je uzavřený vůči změnám, viz. Obrázek 1. Tento návrh porušuje princip OCP.

    Na Obrázku 2. je návrh pozměněn tak, že mezi klientskou a serverovou třídu je vložena abstraktní třída. Klient nepracuje přímo se serverovou třídou, ale s abstrakcí. Za abstraktní třídou je ukryta konkrétní podtřída. Případnou změnou podtřídy není ovlivněn klient, není jej potřeba otevírat a měnit. Klient je otevřený. Návrh respektuje princip OCP.

    V programovacích jazycích se pro zajištění abstrakce využívá buď abstraktní třída (abstract class) nebo rozhraní (interface). Velké množství návrhových vzorů také vzniklo s primární motivací zajištění principu OCP.

    Zkušený objektový návrhář dokáže predikovat chování systému natolik, že odhadne pevné body systému a pro ně navrhne dostatečně abstraktní entity. U abstraktních entit se nepředpokládají časté změny v budoucnu (uzavřenost), naopak se nechává prostor pro různé implementace ukryté za abstraktní vrstvou (otevřenost).

    Příklad - ověřování uživatelů

    Vývojář řeší požadavek na vytvoření variabilního mechanismu ověřování uživatelů. Sledujme jeho myšlenkové pochody a následné prozření. Není úplným nováčkem v objektovém návrhu, proto se rozhodne každý typ ověření uživatele naimplementovat do samostatné třídy. Aktuálně je požadováno ověřování vůči databázi a vůči Active Directory. Vytvoří tedy třídy OvereniUzivateleVuciDatabazi a OvereniUzivateleActiveDirectory:

    public class OvereniUzivateleVuciDatabazi
    {
        public void Overit(String pripojovaciRetezec, String uzivatelskeJmeno, String heslo)
        {
            // ... implementace databázového ověření
        }
    }
    
    public class OvereniUzivateleActiveDirectory
    {
        public void Overit(String nazevDomeny, String uzivatelskeJmeno, String heslo)
        {
            // ... implementace doménového ověření přes Active Directory
        }
    }
    

    Tyto ověřovací moduly budou využívány ve třídě SpravceUzivatelskychOvereni. SpravceUzivatelskychOvereni je klientskou třídou vůči třídám OvereniUzivateleVuciDatabazi a OvereniUzivateleActiveDirectory. Třída pro správu uživatelských ověření zvěřejňuje metodu OveritUzivatele(), která vytváří instanci příslušné ověřovací třídy a následně vyvolá její metodu pro ověření:

    public class SpravceUzivatelskychOvereni
    {
        public void OveritUzivatele(String uzivatelskeJmeno, String heslo)
        {
            TypOvereniUzivatele typOverovani = 
                (TypOvereniUzivatele)Aplikace.Nastaveni["TypOvereniUzivatele"];
    
            switch (typOverovani)
            {
                case TypOvereniUzivatele.VuciDatabazi:
                    OvereniUzivateleVuciDatabazi overeniDatabaze = new OvereniUzivateleVuciDatabazi();
                    overeniDatabaze.Overit(Aplikace.Nastaveni["PripojovaciRetezec"] as String, 
                        uzivatelskeJmeno, heslo);
                    break;
    
                case TypOvereniUzivatele.ActiveDirectory:
                    OvereniUzivateleActiveDirectory overeniActiveDirectory = new OvereniUzivateleActiveDirectory();
                    overeniActiveDirectory.Overit(Aplikace.Nastaveni["NazevDomeny"] as String, 
                        uzivatelskeJmeno, heslo);
                    break;
            }
        }
    }
    
    public enum TypOvereniUzivatele 
    {
        VuciDatabazi,
        ActiveDirectory
    }
    

    Jak asi tušíte, došlo tady k porušení principu OCP. Problematická je metoda OveritUzivatele() třídy SpravceUzivatelskychOvereni, která používá konkrétní třídy OvereniUzivateleVuciDatabazi a OvereniUzivateleActiveDirectory. Je pravděpodobné, že v budoucnu vyvstane požadavek na další typ ověřovacího modulu. Třídu SpravceUzivatelskychOvereni bude nutné otevřít a dopsat další větev příkazu switch. Každá podobná změna vynutí modifikaci kódu, který ovlivní i kód související s klientským použitím třídy OvereniUzivateleVuciDatabazi.

    Příkaz switch je obvykle indikátorem chybného návrhu. Na jednom místě se autor pokouší sloučit rozhodovací a výkonnou logiku. Výkonná logika by měla být řešena v příslušné podtřídě a rozhodovací při vzniku objektu příslušné třídy.

    Příklad - ověřování uživatelů s dodržením OCP

    Pojďme se podívat na návrh očima návrháře, který zná a respektuje OCP. Musíme si uvědomit, co jsou v případě ověřování uživatelů pevné body a co variabilní části. Jak jsme uvedli výše, pro zajištění principu OCP se využívá přidání vrstvy s abstrakcí. Pevným bodem je to, že každý ověřovací modul musí mít metodu, která provede vlastní ověření. Pro tyto účely vytvoříme abstraktní třídu, která bude obsahovat pouze jednu abstraktní metodu Overit():

    public abstract class OvereniUzivateleAbstract
    {
        public abstract void Overit(IParametryOvereniUzivatele parametry);
    }
    

    Metoda Overit() je sice nezávislá na způsobu ověřování, ale ve své implementaci vyžaduje různé vstupní argumenty. Pro ověření vůči databázi potřebuje připojovací řetězec, uživatelské jméno a heslo. Pro ověření vůči Active Directory zase název domény, uživatelské jméno a heslo. Musíme proto využít obecnější mechanismus pro předávání variabilních vstupů. Potřebujeme další abstrakci. Tentokrát zvolíme rozhraní, pojmenujeme jej IParametryOvereniUzivatele a nadefinujeme jednoduše takto:

    public interface IParametryOvereniUzivatele
    {
    }
    

    Parametry ověřování vůči databázi mohou být implementovány například:

    public class ParametryOvereniUzivateleVuciDatabazi : IParametryOvereniUzivatele
    {
        private String pripojovaciRetezec;
        private String uzivatelskeJmeno;
        private String heslo;
    
        public ParametryOvereniUzivateleVuciDatabazi(String pripojovaciRetezec, String uzivatelskeJmeno, String heslo)
        {
            this.pripojovaciRetezec = pripojovaciRetezec;
            this.uzivatelskeJmeno = uzivatelskeJmeno;
            this.heslo = heslo;
        }
    
        public string PripojovaciRetezec
        {
            get { return pripojovaciRetezec; }
        }
    
        public string UzivatelskeJmeno
        {
            get { return uzivatelskeJmeno; }
        }
    
        public string Heslo
        {
            get { return heslo; }
        }
    }
    

    Pokud máme abstraktní třídu OvereniUzivateleAbstract a implementaci rozhraní s parametry pro ověřování vůči databázi, můžeme naimplementovat vlastní ověřování vůči databázi:

    public class OvereniUzivateleVuciDatabazi : OvereniUzivateleAbstract
    {
        public override void Overit(IParametryOvereniUzivatele parametry)
        {
            if (!(parametry is ParametryOvereniUzivateleVuciDatabazi))
            {
                throw new ArgumentException("Parametr není typu ParametryOvereniUzivateleVuciDatabazi.");
            }
    
            ParametryOvereniUzivateleVuciDatabazi parametryOvereni = 
                parametry as ParametryOvereniUzivateleVuciDatabazi;
    
            // ... implementace databázového ověření
        }
    }
    

    Všimněte si, že na vstupu metody Overit() je kvůli abstrakci argument typu IParametryOvereniUzivatele, který si ihned po validaci přetypujeme na ParametryOvereniUzivateleVuciDatabazi. V tomto okamžiku máme vše potřebné pro ověření uživatele přes databázi. Podobným způsobem by se naimplementovalo ověření vůči ActiveDirectory nebo jiné, které má vlastní specifika.

    Nakonec se dostáváme ke třídě SpravceUzivatelskychOvereni, která nám vynutila tuto refaktorizaci. Třída se v našem případě zjednodušila na absolutní minimum. Metoda OveritUzivatele() dostane dvě abstrakce - první pro ověřovací třídu a druhou pro parametry příslušného ověření. Nad těmito abstrakcemi provede vyvolání ověření:

    public class SpravceUzivatelskychOvereni
    {
        public void OveritUzivatele(OvereniUzivateleAbstract overeniUzivatele, IParametryOvereniUzivatele parametry)
        {
            overeniUzivatele.Overit(parametry);
        }
    }
    

    Přidání dalšího ověřovacího modulu v tomto návrhu znamená, že není ovlivněn žádný z existujících modulů ani třída SpravceUzivatelskychOvereni.

    Můžete se ptát, kde a jak vznikají instance ověřovacích tříd a jejich parametrů? Je vhodné využít některý z návrhových vzorů pro vytváření objektů. Konfigurace výběru třídy pro ověření uživatelů může být například v konfiguračním souboru aplikace.

    Princip OCP jako základ pro další mechanismy

    Princip OCP má ve světě objektového návrhu zcela zásadní význam. Velké množství souvisejících mechanismů bylo motivováno právě tímto principem:

    • Princip jednotného přístupu (Uniform Access Principle). Všechny služby poskytované modulem (třídou) by měly být dostupné přes jednotnou notaci. Notace je nezávislá na tom, zda-li je služba implementována nad úložištěm nebo jako vypočítavaná hodnota. V notaci C# se jedná o zpřístupnění obsahu objektu přes property.
    • Návrhy řízené daty (Data-Driven Designs). Pokrývají širokou rodinu technik jako je parametrizace systému z externích zdrojů, metadata pro mapování ORM, skiny oken, apod. Systém je chráněn proti vlivu dat, metadat a deklarativních variant tím, že se odloučí variabilní části ven ze systému a samotný systém je uzavřený.
    • Interpretem řízený návrh (Interpreter-Driven Design). V návrzích tohoto typu čte interpreter pravidla z externího zdroje (skriptu) a provádí je. Používá se v souvislosti s virtuálními stroji, neuronovými sítěmi, množinami omezujících podmínek, apod. Takto lze parametrizovat systém pomocí externí logiky. Systém je opět chráněn externalizací logiky a čtením skrze interpret.

    Někdy se pro OCP používá ekvivalent Protected Variations.

    Další zdroje a ukázky

    Generická modifikace příkladu

    Kolega Radek mi navrhl, abych zkusil použít v příkladě generiku. Upravený kód přikládám. Konstrukce ve třídě SpravceUzivatelskychOvereni nyní vypadají méně přehledně, ale zůstává otázkou, zda-li nově tuto třídu vůbec potřebujeme. Ale to už je předmětem diskuze nad rámec principu OCP.

    public interface IParametryOvereniUzivatele
    {
    }
    
    public abstract class OvereniUzivateleAbstract<TParametryOvereniUzivatele>
        where TParametryOvereniUzivatele : IParametryOvereniUzivatele
    {
        public abstract void Overit(TParametryOvereniUzivatele parametry);
    }
    
    public class ParametryOvereniUzivateleVuciDatabazi : IParametryOvereniUzivatele
    {
        private String pripojovaciRetezec;
        private String uzivatelskeJmeno;
        private String heslo;
    
        public ParametryOvereniUzivateleVuciDatabazi(String pripojovaciRetezec, String uzivatelskeJmeno, String heslo)
        {
            this.pripojovaciRetezec = pripojovaciRetezec;
            this.uzivatelskeJmeno = uzivatelskeJmeno;
            this.heslo = heslo;
        }
    
        public string PripojovaciRetezec
        {
            get { return pripojovaciRetezec; }
        }
    
        public string UzivatelskeJmeno
        {
            get { return uzivatelskeJmeno; }
        }
    
        public string Heslo
        {
            get { return heslo; }
        }
    }
    
    public class OvereniUzivateleVuciDatabazi : OvereniUzivateleAbstract<ParametryOvereniUzivateleVuciDatabazi>
    {
        public override void Overit(ParametryOvereniUzivateleVuciDatabazi parametry)
        {            
            // ... implementace databázového ověření
        }
    }
    
    public class SpravceUzivatelskychOvereni
    {
        public void OveritUzivatele<TParametryOvereniUzivatele>(
            OvereniUzivateleAbstract<TParametryOvereniUzivatele> overeniUzivatele, TParametryOvereniUzivatele parametry)
            where TParametryOvereniUzivatele : IParametryOvereniUzivatele
        {
            overeniUzivatele.Overit(parametry);
        }
    }