Zobrazují se příspěvky se štítkemObjektový návrh. Zobrazit všechny příspěvky
Zobrazují se příspěvky se štítkemObjektový návrh. Zobrazit všechny příspěvky

pondělí 30. července 2012

Tell, Don't Ask

... aneb snižujte závislosti mezi třídami

Jedna z možných definic návrhového principu Tell, Don't Ask (TDA) zní takto:

"Každé rozhodování zcela závislé na vnitřním stavu objektu by mělo být prováděno uvnitř tohoto objektu."

Princip hovoří o tom, že bychom měli navrhovat třídy tak, aby se volající strana nemusela dotazovat na záležitosti týkající se vnitřního stavu volané třídy.

Ukázka porušení TDA

Mějme zjednodušený autobusový rezervační systém:

class Bus
{
    private int numberOfSeats;
    private IList<Passenger> passengers = new List<Passenger>();

    public Bus(int numberOfSeats)
    {
        this.numberOfSeats = numberOfSeats;
    }

    public bool HasFreeSeat
    {
        get
        {
            return numberOfSeats > passengers.Count;
        }
    }

    public void AddPassenger(Passenger passenger)
    {
        passengers.Add(passenger);
    }
}

class BusReservation
{
    public void AddPassengerToBus(Bus bus, Passenger passenger)
    {
        if (bus.HasFreeSeat)
        {
            bus.AddPassenger(passenger);
        }
    }
}

Proč je uvedené řešení problematické:

  • Základním principem OOP je zapouzdřenost interních dat objektu a jeho chování. Třída Bus však zbytečně zveřejňuje informaci o volných sedadlech a nutí volající stranu, aby s touto informací pracovala a zohledňovala ji před voláním metody AddPassenger.
  • Třída BusReservation je závislá na třídě Bus na dvou místech. Dotazováním na vlastnost HasFreeSeat a voláním metody AddPassenger. Každá nadbytečná závislost mezi třídami zvyšuje komplexitu návrhu a zhoršuje vlastnosti systému.
  • Třída Bus nutí volající stranu, aby měla znalost o tom, že před zavoláním AddPassenger si musí nejprve sama ověřit splnění kontraktů přidání cestujícího. S tím souvisí i závislost na správném pořadí volání. Samostatné volání HasFreeSeat nedává smysl.
  • Co když se v budoucnu změní podmínky, za kterých je možné přidat cestujícího? Nyní je přidání závislé pouze na volném sedadle, ale nově může přibýt podmínka typu "je autobus pojízdný". Pak bude nutné doplnit test na vlastnost IsMobile na všechna místa volání AddPassenger.
  • Každá nadbytečná závislost zvyšuje komplexitu jednotkových testů. Pokud chceme testovat třídu BusReservation v izolaci, musíme mockovat třídu Bus (nebo lépe rozhraní). V našem problematickém případě musíte přidat chování mocku pro vlastnost HasFreeSeats a metodu AddPassenger.

Vhodnější řešení

Odstraníme závislost volající třídy na dotazování se na volné sedadlo. Veškeré testy na proveditelnost akce přidání cestujícího jsou zapouzdřeny uvnitř metody AddPassenger. Pokud některá z podmínek není splněna, je vrácena výjimka, kterou zpracuje volající strana. Volající straně se situace zjednoduší.

class Bus
{
    private int numberOfSeats;
    private IList<Passenger> passengers = new List<Passenger>();

    public Bus(int numberOfSeats)
    {
        this.numberOfSeats = numberOfSeats;
    }

    private bool HasFreeSeat
    {
        get
        {
            return numberOfSeats > passengers.Count;
        }
    }

    public void AddPassenger(Passenger passenger)
    {
        bool isFull = !HasFreeSeat;

        if (isFull)
        {
            throw new Exception("Bus is full.");
        }

        passengers.Add(passenger);
    }
}

class BusReservation
{
    public void AddPassengerToBus(Bus bus, Passenger passenger)
    {
        bus.AddPassenger(passenger);
    }
}

Vezměte si na pomoc Adapter

Pokud využíváte rozhraní, které nemůžete měnit (komponenta třetí strany) a které porušuje TDA, můžete si pomoci návrhovým vzorem Adapter (Wrapper). Např. v jazyce C# je za příkazem foreach schován adaptér, který zjednodušuje práci se vším, co zveřejňuje metodu IEnumerator GetEnumerator(). Z IEnumerator pak postupně volá metodu bool MoveNext() a dotazuje se na vlastnost object Current { get; }. Více na stackoverflow.com.

Závěrem

Tak jako většina principů a doporučení objektového návrhu, tak i TDA není možné bez rozmyslu aplikovat dogmaticky ve všech situacích. Záleží na zodpovědnosti navrhované třídy, zda-li poskytuje příkazy (commands) k vykonání nějaké akce nebo dotazy (queries) vracející vnitřní stavy objektu. V případě příkazů byste však měli o dodržování principu TDA usilovat.

úterý 26. dubna 2011

Vícevrstvé architektury aplikací

Vrstvy ve světě programových systémů

Vrstvení (layering) je jednou ze základních technik, používanou návrháři systému, pro rozložení složitosti komplexních programových systémů. Je pravděpodobné, že pokud čtete tento příspěvek, používáte jeden z několika webových prohlížečů. Prohlížeč je pro Vás jednou z aplikací, která řeší požadovanou funkcionalitu. Pokud se však pokusíte dekomponovat jednotlivé využívané softwarové vrstvy, začnete se zanořovat takto (zjednodušeně):

  • aplikační vrstva (webový prohlížeč Chrome, Firefox, IE, ...)
  • vrstva operačního systému (Windows, Linux, Mac OS, Android, iOS, ...)
  • vrstva ovladačů zařízení (ovladač klávesnice, grafické karty, zvukové karty, ...)
  • vrstva instrukcí CPU, GPU, ... (instrukce na nejnižší hardwarové úrovni)

Webový prohlížeč načítá webový obsah z webových serverů. Využívá k tomu TCP/IP komunikační protokol, který má ve zjednodušené definici tyto čtyři vrstvy:

  • aplikační vrstva (programy nebo služby, které využívají přenos dat po síti ke konkrétním službám pro uživatele - FTP, HTTP, DNS, ...)
  • transportní vrstva (poskytuje transportní služby a je implementována v koncových zařízeních (počítačích))
  • síťová vrstva (zajišťuje síťovou adresaci, směrování a předávání datagramů, je implementována ve všech prvcích sítě - směrovače i počítače)
  • vrstva síťového rozhraní (umožňuje přístup k fyzickému přenosovému médiu, implementace je závislá na typu sítě)

Vrstvy lze uspořádat podle úrovně, na které se vyskytují. V našich příkladech je nejvyšší vrstvou aplikační vrstva (webový prohlížeč) a nejnižší vrstva instrukcí CPU. Toto uspořádání je důležité z pohledu závislostí jednotlivých vrstev. Vždy platí pravidlo, že vyšší vrstva využívá služeb pouze nižších vrstev a obvykle pouze o jednu úroveň nižší. Nižší vrstva tedy nic neví o vyšších vrstvách, pouze nabízí rozhraní, které je známé vyšší vrstvě.

Výhody a nevýhody použití vrstev

Rozdělení systémů do vrstev má několik významných výhod:

  • Zjednodušení složitých systémů. Stačí pochopit rozhraní a pravidla použití vrstvy, kterou chcete využívat a dokážete využít celý (jinak složitý) podsystém. Např. pokud se naučíte pracovat s FTP službou, začnete využívat celý TCP/IP komunikační protokol. Pokud byste se chtěli naučit celý TCP/IP protokol, dostanete se na úroveň optických vláken a jiných fyzických přenosových médií na nejnižší vrstvě, kterou nikdy přímo využívat nebudete. Takové znalosti jsou samozřejmě vítané, ale ne nezbytně nutné pro práci s FTP.
  • Možnost záměny implementace jedné z vrstev. Například na nejnižší síťové vrstvě TCP/IP protokolu mohou být data přenášena přes technologie PPP, Enhernet, ISDN, DSL nebo další. Každá technologie má svou implementaci a poskytuje služby, které daná vrstva poskytuje. Vyšší vrstvy záměnu implementace nižší vrstvy nepoznají a funkcionalita zůstane zachována.
  • Minimalizace závislostí mezi vrstvami. Souvisí se záměnou implementace vrstvy, viz. předchozí výhoda.
  • Standardizace funkcionality. Příkladem může být náš oblíbený TCP/IP protokol, který je v současnosti standardem pro síťovou komunikaci. Je využíván přes většinu operačních systémů a síťových zařízení. Pro vývojáře programových systémů je přítomnost standardizovaných vrstev dobrá zpráva. Jsou snižovány náklady na vývoj a jsou k dispozici komponenty, které umí standardizované vrstvy využívat.
  • Efektivnější testovatelnost. Pokud složitější funkcionalitu rozdělíte do menších částí (vrstev), můžete efektivněji pokrýt funkcionalitu testy. Můžete také některou z vrstev nahradit falešným mock objektem, tzn. testujete-li například vyšší vrstvu a potřebujete vnutit určité chování nižší vrstvě, tuto nižší vrstvu "namockujete".

Používání vrstev může samozřejmě přinášet i nevýhody:

  • Kaskádové změny. Jedná se o problém typický pro všechny závislé entity. Pokud dojde v nižší vrstvě ke změně, která vynutí změnu veřejného rozhraní této vrstvy, musí se změnit také vrstva vyšší. Konkrétně místo, kde se volá funkcionalita změněné nižší vrstvy. U podnikových aplikací je tato situace typická v případě přidání databázového sloupce. Musí se upravit datová vrstva, doménová logika i přidat zobrazení nového sloupce (viz. Základní aplikační vrstvy podnikových aplikací).
  • Snížení výkonu systému. Přidání každé další vrstvy může způsobit snížení celkového výkonu systému. Každá vrstva má vlastní reprezentaci datových entit. Během předávání datových entit mezi vrstvami musí být tyto entity transformovány. Transformace vyžaduje určitý výpočetní výkon a dojde tedy ke zpomalení. Zpomalení způsobená transformací mohou být kompenzována optimalizací na úrovni jednotlivých vrstev.

Evoluce aplikačních vrstev podnikových systémů

Než se dopracujeme k doporučovanému rozdělení aplikace do logických vrstev, podívejme se krátce na historický proces evoluce podnikových aplikačních systémů. Pro některé z nás to bude ještě stále aktuální téma.

V devadesátých letech byla populární architektura klient-server systémů. Jednalo se o dvouvrstvou architekturu, ve které klient obsahoval kromě uživatelského rozhraní i aplikační logiku a na serveru byla pouze relační databáze. Jako vývojová prostředí pro klientské aplikace se používaly VisualBasic, PowerBuilder a Delphi. V těchto prostředích bylo snadné a rychlé napsat aplikace intenzivně pracující s databázovými daty. Byly k dispozici vizuální uživatelské prvky (např. Datawindows v PowerBuilderu), které obsahovaly přímé propojení na databázi a pomocí SQL manipulovaly s relačními daty.

Pokud aplikace obsahovala pouze zobrazování a jednoduchou aktualizaci dat, bylo řešení přes databázové UI prvky poměrně efektivní. Postupně však docházelo ke změnám požadavků kladeným na tyto aplikace. Začala se více implementovat doménová logika a její pravidla při aktualizaci dat, validační pravidla a výpočty. Bylo běžné, že se kód doménové logiky přidával do UI prvků. Znovupoužitelnost kódu míchající doménovou a prezentační logiku začala být problematická a docházelo k duplikování kódu. Zásahy a změny v kódu se stávaly problematickými.

Jako alternativa k uložení doménové logiky se začaly používat uložené databázové procedury. Možnosti vložených procedur jsou však oproti programovacím jazykům značně omezené. Vývoj, ladění a testování databázových procedur není příliš efektivní. Navíc podnikové aplikace jsou schopny pracovat nad více typy databázových serverů (Oracle, MS SQL Server, Informix). To znamená, že jedna procedura se musí napsat, odladit a udržovat ve více verzích pro cílový databázový systém.

Odpovědí na výše popsané problémy architektury klient-server se stal nástup objektově orientovaného návrhu. Objektový přístup oddělil doménovou logiku od UI do samostatné vrstvy a zadefinoval třívrstvou architekturu (viz. další kapitola).

Martin Fowler označil nástup webových technologií za seizmický šok. Začalo být moderní i účelné distribuovat podnikové aplikace přes webový prohlížeč. V případě klient-server aplikací nebylo snadné jejich část zpřístupnit přes webový prohlížeč (UI a doménová logika byly úzce propojeny). Naproti tomu u vhodně navržených třívrstvých aplikací bylo možné zaměnit prezentační UI vrstvu za prezentační vrstvu tenkého webového klienta. To byl rozhodující argument, který posadil třívrstvou architekturu na koně a ta se následně v efektivitě rychlým tempem vzdalovala pokulhávající dvouvrstvé klient-server architektuře.

Základní aplikační vrstvy podnikových systémů

V předchozím textu jsem uvedl argumenty, proč se na podnikovou aplikaci dívat jako na soubor logických vrstev. Uvedli jsme si také důvody, proč je třívrstvá aplikace považována za efektivní způsob návrhu. Podívejme se nyní podrobněji na jednotlivé vrstvy:

Vrstva Zodpovědnost
Prezentační Zajišťuje služby týkající se uživatelského rozhraní, zobrazování informací, zachycení uživatelských událostí (myš, klávesnice, hlas), zachycení HTTP požadavků, vyvolání přes příkazový řádek, dávkové API.
Doménová Logika, která odpovídá povaze řešené problematiky. Má předobraz v reálném světě.
Datová Komunikace s databází (obecně perzistentní úložiště), systémy pro zasílání zpráv, správa transakcí a další balíčky.

Datová vrstva zajišťuje komunikaci s databází, řídí připojení a odpojení od databázového zdroje, spravuje databázové relace (sessions), zajišťuje podporu transakcí, poskytuje DAO (Data Access Object) objekty realizující CRUD (Create Read Update Delete) funkcionalitu pro jednotlivé třídy objektů. Zajišťuje dále mapování databázových struktur na objekty příslušných tříd. S výhodou lze využít některý z perzistentních frameworků typu NHibernate (v Java světě Hibernate), který řeší záležitosti typu Unit of Work, Identity Map a Lazy Load, které byste jinak museli implementovat sami.

Doménová vrstva nebo také vrstva obchodní logiky (business logic). Tato vrstva obsahuje třídy reprezentující entity řešené domény a třídy zajišťující doménová pravidla. Provádí výpočty a datové manipulace na základě vstupů a uložených dat.

Prezentační vrstva zajišťuje služby týkající se uživatelského rozhraní. Uživatelské rozhraní (UI - User Interface) je závislé na platformě, na které se aplikace provozuje. Může se jednat o WinForm aplikaci, webového klienta, Silverlight, Android klienta, konzolovou aplikaci, dávkové spouštění, apod. Uživatelské rozhraní je tvořeno ovládacími prvky, které nabízejí uživatelskou přívětivost závislou na cílové platformě. Podle požadavků na přívětivost UI je vybrána cílová platforma. Obvykle platí to, že čím bohatší ovládací prvky, tím větší provázanost na konkrétní platformu. Např. webové rozhraní je co do ovládacích prvků "chudobné", ale zobrazitelné na všech platformách, na kterých je k dispozici webový prohlížeč.

Je běžné, že aplikace má jednu datovou vrstvu, jednu doménovou vrstvu a jednu nebo více prezentačních vrstev. Podniková aplikace může nabízet pro uživatele, kteří navádí data a zpracovávají denní agendy, komfortnější rozhraní ve formě uživatelsky bohatého (rich) klienta. Jiná skupina uživatelů může prohlížet výstupy (sestavy) přes webové rozhraní. K dispozici může také být manažerská nádstavba přístupná přes chytré telefony jako Android nebo iPad aplikace. Aplikace může také nabízet programové API, například ve formě webových služeb, pro využití jinými podnikovými systémy.

Třívrstvá architektura je vhodná pro aplikace různých velikostí a složitostí. I u jednoduchých aplikací sestávajících např. z načtení údajů z příkazové řádky, z přepočtu a následného výpisu na obrazovku, je vhodné oddělit logiku odpovídající vrstvám do samostatných metod v rámci jedné třídy. U aplikací složitějších je vhodné oddělení logiky vrstev do samostatných tříd. U komplexnějších aplikací do samostatných balíčků nebo skupin balíčků. Jinak řečeno, žádná aplikace není natolik malá, aby při jejím návrhu nestálo za to uvažovat o rozdělení funkcionality do vrstev.

Třívrstvost je pouze základní logický koncept dnešních architektur. Určitě je možné některou z vrstev rozdělit na více specializovaných vrstev. Můžete také například mezi doménovou vrstvu a prezenční vrstvu vložit vrstvu služeb, která zajistí komunikaci mezi oběma vrstvami a navíc Vám připraví rozhraní pro zpřístupnění Vaší doménové logiky programovým systémům třetích stran.

Fyzické umístění vrstev

Třívrstvá architektura popsaná v předchozí kapitole je logickým pohledem na systém. Neméně důležité rozhodnutí při návrhu systému se týká použití fyzických prvků architektury pro provozování jednotlivých vrstev.

Nejjednodušší formou fyzického modelu je umístění všech vrstev na jeden server a klientský přístup přes webové rozhraní ("tenký" klient). Klient přistupuje k aplikaci přes webové GUI a každá uživatelská interakce může vynutit odeslání požadavku na server, který požadavek zpracuje a zašle odpověď. Tato architekura vyžaduje trvalé připojení k webovému serveru a proto není vhodná pro aplikace, u kterých je požadováno offline použití a následná synchronizace.

Pokud je na klientské straně vyžadováno uživatelsky "bohatší" GUI (rich client), je nutno přesunout minimálně část prezentační vrstvy na klientský počítač. V některých případech je přesunuta i doménová vrstva a také část nebo celá datová vrstva. Takový klient je označován jako "tlustý" nebo "tučný". Distribuce tučného klienta je obvykle problémovější než serverová instalace. Na každé klientské stanici musí být navíc připraveno prostředí vyžadované pro spuštění aplikace - databázový klient, .Net FW, JRE, apod.

Programátorské nešvary při (ne)použití architektury vrstev

  • Umístění funkcionality do nesprávné vrstvy. Častý nešvar, který se obvykle projevuje tak, že do prezenční vrstvy umístíte pravidlo nebo logiku z doménové vrstvy. Např. mějme požadavek, aby ve výpisu přehledu hospodaření za jednotlivé roky byly červeně vyznačeny ty hodnoty položky, které jsou oproti loňsku alespoň o 10% lepší. Chybným postupem je nastavit formátování položky přímo v grafickém ovládacím prvku. Správně je na doménové vrstvě zadefinovat vyhodnocení tohoto pravidla, které zavolá prezenční vrstva a výsledek aplikuje na příslušnou vlastnost (barvu) položky.
  • Nedodržení závislostí pouze z vyšší vrstvy na nižší. Víme, že prezentační vrstva může být závislá na doménové a ta zase pouze na datové. Pokud se stane, že datová vrstva vytvoří závislost na doménové, může nejen dojít k zacyklení závislostí, ale především se stává takovýto systém problematicky použitelným.
  • Prezentační vrstva je závislá na datové. Některé ovládací prvky mohou přímo využívat datový zdroj, který je určen např. SQL příkazem. Taková funkcionalita začne být problematická v situaci, kdy chcete na výběr aplikovat doménovou logiku, např. autorizovat přístup uživatele k datům. Vhodnější je adresovat nějakou třídu v doménové vrstvě, která potřebné omezení aplikuje a vrátí požadovaný výsledek.
  • Nerespektování vrstev. Ať už z neznalosti nebo lenosti se programátoři pravidlem rozdělení do vrstev neřídí vůbec. Systém pak připomíná dort pejska a kočičky, ve kterém je všechno smícháno dohromady. Udržovatelnost systému a navigace v kódu je problematická.

Informační zdroje

  • Martin Fowler: Patterns of Enterprise Application Architecture

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

    čtvrtek 27. ledna 2011

    Princip jedné odpovědnosti

    Princip jedné odpovědnosti (SRP - Single Responsibility Principle) je jeden z pěti základních principů metodologie SOLID objektového návrhu. Jedna z jeho nejčastějších definic zní:

    Třída by měla mít právě jeden důvod ke změně.

    Třída by tedy měla mít pouze jednu odpovědnost - pouze jednu logickou funkcionalitu. Svou definicí patří SRP mezi nejjednodušší principy objektového návrhu, nezkušení objektoví návrháři však tento princip často ve svých programech porušují.

    Vysvětleme si problém na příkladě třídy Uzivatel:

    public class Uzivatel
    {
        public String Id { get; set; }
        public String Heslo { get; set; }
    
        public void Ulozit(IUloziste uloziste)
        {
            uloziste.Ulozit<Uzivatel>(this);
        }
    }
    

    Třída nese informace o uživateli (Id a Heslo) a zajišťuje jeho uložení do úložiště, např. databáze. Zkušenější čtenáři ihned rozpoznali porušení princip jedné odpovědnosti. První odpovědností třídy je reprezentace entity typu uživatel v systému (veřejné atributy Id a Heslo). Druhou odpovědností je uložení stavu objektu do úložiště - metoda Ulozit. Provedeme refaktorizaci do dvou samostatných tříd:

    public class Uzivatel
    {
        public String Id { get; set; }
        public String Heslo { get; set; }
    }
    
    public class UzivatelDao
    {
        private IUloziste uloziste;
    
        public UzivatelDao(IUloziste uloziste)
        {
            this.uloziste = uloziste;
        }
    
        public void Ulozit(Uzivatel uzivatel) 
        {
            uloziste.Ulozit<Uzivatel>(uzivatel);
        }
    }
    

    Ze třídy Uzivatel jsme odstranili metodu Ulozit. Vznikla nová třída UzivatelDao, jejíž odpovědností je zpřístupňovat funkcionalitu související s úložištěm entit Uzivatel, tedy například i uložení.

    Pro úplnost zjednodušená definice rozhraní IUloziste:

    interface IUloziste
    {
        void Ulozit<T>(T objekt);
    }
    

    Proč je nutné princip dodržovat

    • Čitelnost kódu. Někdy dochází k obavám, že rozdělením větší třídy na více menších jednoúčelových tříd se zvýší složitost systému a bude problematické porozumět celku. Opak je pravdou. Navigace ve třídách, které dělají to, co jejich název deklaruje, je mnohem efektivnější. Budete-li procházet velké třídy s více odpovědnostmi, budete zatěžování další funkcionalitou, která Vám bude překážet v nalezení toho, co hledáte. Robert C. Martin přirovnává třídy mající více odpovědností k velké krabici v garáži, ve které máte naházeno všechno různé nářadí. Jednoúčelové třídy naopak působí jako úhledně urovnané malé krabičky obsahující pouze související věci jednoho typu.
    • Robustnost. Rozdělením tříd podle odpovědnosti docílíme toho, že případné pozdější požadavky na změnu ovlivní pouze třídu, které by se měly týkat. Např. začnete evidovat nový atribut bool PovolenPristup třídy Uzivatel. Rozšíříte třídu Uzivatel, ale třída UzivatelDao tím nebude nijak zasažena.
    • Snížení vzájemné provázanosti tříd. Velké třídy budou potřebovat vazby na více jiných tříd. Např. v našem příkladě problematická třída Uzivatel odkazovala rozhraní IUloziste. Ale entita typu uživatel je přeci v systému na business vrstvě nezávislá na způsobu jeho uložení.
    • Testovatelnost. Třída mající pouze jednu odpovědnost je lépe testovatelná. Souvisí to také se snížením provázanosti tříd. Test bude omezen pouze na jednu funkcionalitu. Např. pro otestování třídy Uzivatel nebudete potřebovat instanci IUloziste.
    • Soudržnost. Malé třídy mají větší soudržnost, která je v OO návrhu doporučovaná. Více o soudržnosti v některém z dalších příspěvků.

    Jak odhalit porušení principu jedné zodpovědnosti

    Důležité je samozřejmě získat zkušenosti s objektovým návrhem. Měli byste být také schopni se kriticky ohlédnout za dokončenou prací a zhodnotit dodržování SRP. V případě potřeby třídy refaktorovat.

    Jedna poučka říká, že pokud při popisu významu třídy použijete spojku "a", měli byste zbystřit a prověřit dodržování SRP. Podezřelé jsou však i souvětí jiného typu. Pozor na třídy typu "reprezentace auta a výpočet jeho spotřeby", "sestava se zpracováním parametrů", "kreditní karta, která si umí sama strhnout poplatek", apod.

    Dejte si pozor na slučování reprezentace entity a funkcionality jejího vytvoření. V těchto případech lze refaktorovat funkcionalitu vzniku instance třídy podle některého z návrhových vzorů pro vytváření objektů.

    Další zdroje a ukázky