Lekce 1 - Návrhové vzory GRASP
Vítejte u komplexního článku, který vám osvětlí návrhové vzory ze skupiny GRASP. Ty sestavil Craig Larman, populární autor a programátor zabývající se návrhem a procesem vývoje softwaru. GRASP je akronym z General Responsibility Assignment Software Patterns, česky Obecné návrhové vzory přiřazení odpovědnosti. Otázka přidělení odpovědnosti je v OOP aplikacích stále přítomným problémem a jedním z nejdůležitějších pilířů kvalitní architektury. O významu přidělení odpovědnosti jsme hovořili také v kurzu Softwarové architektury a depencency injection. Na rozdíl např. od návrhových vzorů ze skupiny GOF se nejedná o konkrétní vzory implementace, ale spíše o dobré praktiky, tedy poučky. Z tohoto důvodu můžeme bez problému všechny vzory z GRASP popsat dnes v jediné lekci.
Obsah
GRASP obsahuje následující vzory:
- Controller
- Creator
- High cohesion
- Indirection
- Information expert
- Low coupling
- Polymorphism
- Protected variations
- Pure fabrication
Pojďme si je popsat.
Controller
Pojem kontroler byste jakožto programátoři se zájmem o návrh softwaru měli dobře znát, minimálně v pojetí MVC architektury. Česky bychom jej mohli přeložit jako "ovladač". Jedná se o komponentu, jejíž rolí je komunikace s uživatelem. Kontroler nalezneme v určité podobě v podstatě ve všech dobře napsaných aplikacích. Např. ve formulářích v C# .NET se mu říká Code Behind, ale stále se jedná o kontroler. Když komunikaci s uživatelem zprostředkovává oddělená řídící třída, aplikace se rázem rozděluje do vrstev a logika je plně odstíněna od prezentace. Takové aplikace jsou přehledné a dobře udržitelné.
Ukažme si jednoduchý příklad. Předpokládejme, že programujeme kalkulačku. Odstrašující příklad monolitické aplikace by vypadal asi takto (jako jazyk použijme C#):
public int Secti() { Console.WriteLine("Zadej 1. číslo"); int a = int.Parse(Console.ReadLine()); Console.WriteLine("Zadej 2. číslo"); int b = int.Parse(Console.ReadLine()); return a + b; }
V metodě výše je smíchána komunikace s uživatelem (výpis a čtení z konzole) s aplikační logikou (samotným výpočtem). Metoda by v praxi samozřejmě počítala něco složitějšího, aby se ji vyplatilo napsat, představte si, že místo sčítání je nějaká složitější operace. Někdy také říkáme, že metoda má side effects, není tedy univerzální a její zavolání vyvolá i komunikaci s konzolí, která není na první pohled patrná. Tento problém je zde možná ještě dobře viditelný a aplikaci by vás nenapadlo takto napsat.
Méně viditelný může být problém v případě, když logiku píšeme přímo do obslužných metod ovládacích prvků formuláře. Určitě jste již někdy programovali formulářovou aplikaci. Možná jste viděli i takovýto kód:
public void SectiTlacitko_Click(Object sender) { int a = int.Parse(cislo1.Text); int b = int.Parse(cislo2.Text); vysledekLabel.Text = (a + b).ToString(); }
Zde kontroler, onu řídící třídu, znečišťujeme logikou (naším výpočtem). Ve všech aplikacích by vždy měla být jedna vrstva, která slouží pouze pro komunikaci s uživatelem, ať již lidským nebo třeba pomocí API. Tato vrstva by neměla chybět (první chybný kód) nebo by neměla dělat něco jiného (druhý chybný kód).
Správná podoba kódu konzolové kalkulačky by byla např. tato:
public static function main() { Kalkulacka kalkulacka = new Kalkulacka(); Console.WriteLine("Zadej 1. číslo"); int a = int.Parse(Console.ReadLine()); Console.WriteLine("Zadej 2. číslo"); int b = int.Parse(Console.ReadLine()); Console.WriteLine(kalkulacka.Secti(a, b)); }
Metoda main()
je v tomto případě součástí kontroleru,
který pouze komunikuje s uživatelem. Veškerá logika je zapouzdřena v
třídách logické vrstvy, zde ve třídě Kalkulacka
. Ta
neobsahuje již žádnou práci s konzolí.
Oprava druhého řešení by vypadala stejně:
class KalkulackaKontroler { private Kalkulacka kalkulacka = new Kalkulacka(); public void SectiTlacitko_Click(sender: Object) { int a = int.Parse(cislo1.Text); int b = int.Parse(cislo2.Text); vysledekLabel.Text = (kalkulacka.Secti(a, b)).ToString(); } }
A UML diagram:
Vidíme, že parsování je stále role kontroleru, protože jde o
zpracování vstupu. Stejně tak i změna hodnoty labelu
vysledekLabel
, což je zas výstup. Nicméně samotný výpočet je
opět ve třídě Kalkulacka
, která o formulářích vůbec
neví.
Abychom měli ukázky univerzální, ukažme si ještě, jak se např. v PHP vypisuje stránka bez kontroleru:
<?php $databaze = new PDO('mysql:host=localhost;dbname=testdb;charset=utf8mb4', 'jmeno', 'heslo'); $auta = $databaze->query("SELECT * FROM auta")->fetchAll(); ?> <table> <?php foreach ($auta as $auto) : ?> <tr> <td><?= htmlspecialchars($auto['spz']) ?></td> <td><?= htmlspecialchars($auto['barva']) ?></td> </tr> <?php endforeach ?> </table>
A s kontrolerem:
class AutaKontroler { private $spravceAut; public function __construct() { $this->spravceAut = new SpravceAut(); } public function vsechna() { $auta = $this->spravceAut->vratAuta(); // Proměnná pro šablonu require('Sablony/auta.phtml'); // Načtení šablony } }
Šablona "auta.phtml" by vypadala např. takto:
<table border="1"> <?php foreach ($auta as $auto) : ?> <tr> <td><?= htmlspecialchars($auto['spz']) ?></td> <td><?= htmlspecialchars($auto['barva']) ?></td> </tr> <?php endforeach ?> </table>
Kontrolerem jsme oddělili logiku a prezentaci do 2 souborů a snížili počet vazeb.
Creator
Vzor Creator
řeší do které třídy bychom měli
umístit kód k vytvoření instance nějaké jiné třídy. Craig
říká, že třída B
instanciuje třídu A
pokud:
1. Je A její částí
Příkladem by mohly být třídy Faktura
a
PolozkaFaktury
. Jednotlivé instance položek faktury dává smysl
vytvářet ve třídě Faktura
, jelikož je její součástí.
Třída Faktura
zde má za položky odpovědnost.
2. Je A její závislostí
Třída B
si vytvoří A
, pokud na ni závisí.
Příkladem by mohla být např. databáze podpisů, jejíž instanci si
vytvoří třída Faktura
, aby mohla na vygenerované faktuře
zobrazit podpis. Pokud je daná závislost použita ještě jinde, je
výhodnější nevytvářet stále nové instance závislosti, ale použít vzor
Dependency
Injection.
3. Má pro instanciaci dostatek informací
Typicky je více možností, kam by vytvoření instance třídy logicky patřilo. Měli bychom jej ovšem umístit pouze tam, kde jsou již dostupné všechny informace, tedy proměnné nebo instance, které k vytvoření potřebujeme. Nedává smysl zbytečně natahovat další data do třídy, když je vše potřebné již někde k dispozici.
Jako příklad si uveďme rozhodování, zda třídu
SeznamFaktur
s fakturami zákazníka instanciujeme ve třídě
SpravceFaktur
nebo SpravceZakazniku
. Podíváme se,
která z tříd má všechny informace, které SeznamFaktur
potřebuje. Pokud zde budeme potřebovat např. všechny faktury a z nich vybrat
ty určitého zákazníka, instanciujeme SeznamFaktur
ve
SpravceFaktur
, jelikož v něm se faktury nacházejí.
4. B obsahuje A
Pokud je A
vnořená třída ve třídě B
, měla
by být také její instance vytvářena třídou B
. Nicméně
vnořené třídy se nestaly příliš populárními.
High cohesion
Vysoká soudržnost znamená, že se naše aplikace skládá z rozumně velkých kusů kódu, přičemž se každý tento kód zaměřuje na jednu věc. To je i jeden ze základních principů samotného OOP. Vysoká soudržnost úzce souvisí s nízkou provázaností (viz dále), jelikož když sdružujeme související kód na jedno místo, snižuje se nutnost vazeb do dalších částí aplikace. Dalším souvisejícím vzorem je Law of Deméter, který v podstatě říká, že objekt by neměl "hovořit" s cizími objekty.
Příkladem vysoké soudržnosti je např. soustředění funkcionality okolo
uživatelů do třídy SpravceUzivatelu
. Když by se přihlášení
uživatele řešilo např. ve třídě SpravceFaktur
, kde je
přihlášení třeba pro zobrazení faktur, a zrušení uživatelského účtu
by se řešilo ve třídě Uklizec
, která promazává neaktivní
účty, porušovali bychom právě High cohesion. Kód, který má být pospolu
ve třídě SpravceUzivatelu
, by byl rozházený různě po
aplikaci, podle toho kde je zrovna potřeba. Proto sdružujeme související
kód na jedno místo a to i když se tyto metody používají v aplikaci třeba
jen jednou.
Indirection
Indirection je velmi zajímavý princip, se kterým jsme se již setkali u controlleru. Říká, že když vytvoříme někde v aplikaci umělého prostředníka, tedy třídu "navíc", může naši aplikaci paradoxně výrazně zjednodušit. U kontroleru jasně vidíme, že sníží počet vazeb mezi objekty a tak za cenu pár řádek kódu navíc podporuje znovupoužitelnost a lepší čitelnost kódu. Indirection je jeden ze způsobů, jak dosáhnout Low coupling. Příklad jsme si již ukazovali u vzoru Controller.
Information expert
Informační expert je další poučka, které nám pomáhá se rozhodnout do jaké třídy přidáme metodu, atribut a podobně. Odpovědnost má vždy ta třída, která má nejvíce informací. Takové třídě potom říkáme informační expert a právě do ní přidáváme další funkcionalitu a data. O podobném principu jsme již mluvili u vzoru Creator.
Low coupling
Low coupling popisu v podstatě totéž jako High cohesion, ale z jiného pohledu. V aplikaci bychom měli vytvářet co nejmenší počet vazeb mezi objekty, čehož dosáhneme chytrým rozdělením zodpovědnosti.
Jako odstrašující příklad si uveďme třídu Manager
, ve
které je umístěna logika pro práci se zákazníky, s fakturami, s
logistikou, zkrátka se vším. Takovýmto objektům se někdy říká
"božské" (god objects), které mají příliš velkou odpovědnost a tím
pádem vytvářejí příliš mnoho vazeb (takový Manager
bude
typicky používat velkou spoustu tříd, aby mohl fungovat takto obecně). V
aplikaci není důležitý celkový počet vazeb, ale počet vazeb mezi dvěma
objekty. Vždy se snažíme, aby třída komunikovala s co nejmenším počtem
dalších tříd, proto bychom měli uvést třídy
SpravceUzivatelu
, SpravceFaktur
,
SpravceLogistiky
a podobně. Asi vás již napadlo, že takový
manažer by pravděpodobně nešlo znovupoužít v jiné aplikaci.
Odstrašující příklad božského objektu při nedodržování Low coupling
A nemusíme zůstávat jen u tříd. Low coupling souvisí také např. s
dalšími praktikami ohledně pojmenovávání metod ("Metodu bychom měli
pojmenovávat co nejméně slovy a bez spojky A"). Metody delej()
nebo naparsujAZpracujAVypis()
signalizují, že toho dělají
příliš.
Pozn.: Když jsme již zmínili božské objekty, uveďme si i opačný problém, který je tzv. Yoyo problém (problém joja). Při příliš drobné struktuře programu, příliš vysoké granularitě, často i nadužívání dědičnosti, je v programu tolik tříd, že programátor se musí stále přepínat dovnitř nějaké třídy, zjistit jak pracuje a vrátit se zpět. Tato akce může připomínat vrhání joja dolů a nahoru, znovu a znovu. Před dědičností se proto často preferuje skládání objektů.
Co se týká vazeb mezi objekty, měli bychom se také vyvarovat
cyklickým vazbám, které jsou všeobecně považované jako špatná
praktika. To jsou případy, kdy třída A
odkazuje na třídu
B
a ta odkazuje zpět na třídu A
. Zde je někde v
návrhu něco špatně. Cyklická vazba může být i přes více tříd.
Polymorphism
Ano, i polymorfismus je návrhovým vzorem. I když by vám měl být princip
polymorfismu dobře známý, zopakujme pro úplnost, že se jedná nejčastěji
o případ, kdy potomek upravuje funkcionalitu svého předka, ale
zachovává jeho rozhraní. Z programátorského hlediska se jedná o
přepisování (override) metod předka. S objekty poté můžeme pracovat
pomocí obecného rozhraní, ale každý objekt si funkcionalitu zděděnou od
předka upravuje po svém. Polymorfismus nemusí být omezený jen na
dědičnost, ale obecně na práci s objekty různých typů pomocí nějakého
společného rozhraní, které implementují. Ukažme si pověstný příklad se
zvířaty, které mají každé metodu mluv()
, ale přepisují si
ji od předka Zvire
, aby vydávala jejich specifický zvuk:
Pokud chcete reálnější ukázku polymorfismu, nabízí se např. předek
pro formulářové ovládací prvky, kdy každý prvek poté přepisuje metody
předka jako vykresli()
, vratVelikost()
a podobně
podle toho, jak konkrétní potomci fungují.
Protected variations
Protected variations bychom mohli přeložit jako chráněné změny.
Praktika hovoří o vytvoření stabilního rozhraní na klíčových
místech aplikace, kde by změna rozhraní způsobila nutnost přepsat větší
část aplikace. Uveďme si opět reálný příklad. V systému
ITnetwork používáme princip Protected variations, konkrétně pomocí
návrhového vzoru Adapter
a tím se bráníme proti změnám, které neustále provádí Facebook ve svém
API. Přihlašování přes Facebook a podobné další integrace mají za
následek zvýšení počtu a aktivity uživatelů, bohužel ovšem za cenu
přepisování aplikace každých několik měsíců. Pomocí rozhraní
FacebookManagerInterface
se systém již nemusí nikdy měnit.
Když vyjde nová verze, kdy Facebook zas vše předělá, pouze se toto
rozhraní implementuje v jiné třídě (např. FacebookManagerXX
,
kde XX je verze Facebook API) a v systému se změní instance, která toto
rozhraní implementuje. Rozhraní je samozřejmě možné definovat i pomocí
polymorfismu a abstraktní třídy.
Pure fabrication
O Pure fabrication jsme již dnes také mluvili. Volně přeloženo jako "čistý výmysl" se jedná právě o třídy, které slouží pouze pro zjednodušení systému z hlediska návrhu. Tak jako byl controller případ indirection, tak je indirection případem Pure fabrication. Servisní třídy mimo funkcionalitu aplikace snižují závislosti a zvyšují soudržnost.
To by bylo z GRASP vše, avšak v tomto kurzu si ukážeme i další návrhové vzory
V další lekci, Servant (Služebník), si ukážeme návrhový vzor Služebník, který se používá v situaci, kdy chceme skupině tříd přidat určitou funkčnost, aniž bychom ji do nich přímo zabudovali.