Š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
- Dependency inversion principle na Wikipedii
- Robert C. Martin: The Dependency Inversion Principle
- The Service Locator Pattern
- Dependency Injection
Máte nápad, připomínku, našli jste chybu? Přidejte prosím komentář k tomuto článku.