Vítejte v prvním díle nového seriálu na mém blogu: chyby v návrhu. Budeme se zabývat častými chybami v návrhu, kterých se dopouští (nejen) začínající programátoři. Dnes si povíme něco o situacích, kdy má třída příliš mnoho odpovědností.
Připravovaná série příspěvků se bude zabývat návrhem softwaru a problémy, kterých se při něm programátoři dopouští. U čtenáře předpokládám, že má základní znalosti co se týče nevhodných konstrukcí v programování, jako jsou problémy, které může způsobovat duplicitní kód, nečitelný kód, apod. Všechny ukázky budou v jazyce C++, ale poskytnuté rady a tipy lze bez větších obtíží aplikovat i u jiných programovacích jazyků. Pro větší srozumitelnost široké veřejnosti budu vše ukazovat na příkladech. Ukázky budou pouze zjednodušené, a to z toho důvodu, že chci poukázat na problémy v návrhu, což by přílišné množství kódu mohlo zatemnit.
Určitě se najde mnoho čtenářů kteří si po přečtení příspěvku řeknou, že všechno moc komplikuji, ale opak je pravdou. To si však hodně lidí uvědomí až v případě, že budou pracovat na projektu, který běží několik let, či se po mnoha letech vrátí ke svému programu, který potřebují rozšířit. Většinou to dopadá tak, že si dotyčný začne trhat vlasy s tím, proč byl v minulosti tak hloupý a nenavrhl své řešení lépe. K tomuto se ale musí každý dopracovat sám. Nestačí, že si někde něco přečtete. Musíte totiž v hloubi duše chápat, proč je kvalitní návrh důležitý. Pokud vám jde jen a pouze o školní projekty, tak u těch je jedno, jak moc je návrh zprzněný. Hlavně, že to funguje, že :). V praxi je ale situace jiná, tedy měl by být.
Dnešním zadáním bude návrh části peer-to-peer aplikace implementující protokol BitTorrent, což je známý protokol pro sdílení dat na Internetu. Tato aplikace má podporovat celou řadu peer-to-peer protokolů. Naším cílem bude navrhnout komponentu pro načítání a reprezentaci torrentů, což jsou soubory obsahující informace o sdílených datech. Také je v nich obsažena adresa tzv. trackeru, což je server asistující v komunikaci mezi uživateli BitTorrent sítě. Důvodem, proč jsem zvolil toto zadání, je, že jsem před šesti roky pracoval na podobné aplikaci a dopustil jsem se mnoha níže popisovaných chyb, takže příspěvek bude vycházet z mé vlastní zkušenosti :).
První, co většinu lidí napadne, je následující řešení. Vytvoříme si třídu Torrent
, která nám bude reprezentovat torrent soubor, a která se bude umět vytvořit z předaného souboru, který je specifikován cestou.
class Torrent { public: Torrent(const std::string &filePath); size_t getLength() const; std::string getPath() const; // ... private: size_t length; std::string path; // ... };
Pokud se nepodaří načíst data či v souboru budou chybět některé informace, tak dojde k vyhození výjimky.
Pojďme se podívat na náš první návrh. Jak již napovídá název dnešního příspěvku, tak většina z níže uvedených problémů vyplývá z toho, že naše třída dělá příliš moc věcí.
Torrent
, tak pro každý test si musíme vytvořit soubor, naplnit jej daty, provést otestování a soubor smazat. To je ale zdlouhavé na psaní, pomalé a neohrabané. Když budete mít 100 testů, tak je třeba vytvořit 100 souborů. Pokud by to takto dělaly testy ke každé komponentě, tak vám brzo budou jednotkové testy trvat neúnosně dlouho. Jedno z pravidel jednotkových testů je, že by tyto testy neměly záviset na prostředí a měly by být deterministické. Co když ale překročíte limit načítaných souborů, či vám někdo soubor omylem vymaže? V neposlední řadě je třeba testovat situace, kdy se soubor nepodaří otevřít či když se z něj nedaří číst. Jak ale sami tušíte, to s určením třídy Torrent příliš nesouvisí.Torrent
, které ale toto rozhodnutí nepřísluší. Má to, konec konců, být jen "hloupá" reprezentace torrent souboru.Pojďme zkusit navrhnout lepší řešení. Jak již bylo řešeno, tak většina problémů vyplývá z toho, že naše třída Torrent
dělá příliš mnoho. Skutečně, naše třída otevírá soubory, čte z nich, zpracovává data a rozhoduje o tom, která data jsou potřebná a která ne. To je porušení principu principu jedné odpovědnosti (SRP - Single responsibility principle). Jak z toho ven?
Použijeme zjednodušenou verzi návrhového vzoru stavitel (angl. builder). Vytvoříme novou třídu TorrentBuilder
, která bude odpovědná za vytvoření Torrent
u ze zadaných dat. Jelikož nás nezajímá, odkud data přišla, bude tato třída mít na vstupu pouze obsah souboru. Otevírání souborů a čtení dat by mělo být na jediném místě.
class TorrentBuilder { public: static Torrent createTorrent(const std::string &rawData); }; class Torrent { friend class TorrentBuilder; public: size_t getLength() const; std::string getPath() const; // ... private: Torrent(); size_t length; std::string path; // ... }
Třída TorrentBuilder
bude jediná, která bude umět vytvářet torrenty. Deklarujeme ji jako friend
ve třídě Torrent
, aby mohla torrent vytvořit a nastavit podle předaných dat. Je to v tomto případě lepší řešení, než do třídy Torrent
přidat setX()
metody, protože pomocí nich by uživatel této třídy mohl uvést objekt do nekonzistentního stavu (např. nastavením neplatné délky či kontrolního součtu torrentu). TorrentBuilder
je tak či tak závislý na Torrent
, takže si ničím neuškodíme. Konstruktor třídy Torrent
pak můžeme zprivatizovat. Výhoda tohoto přístupu je, že můžeme udělat Torrent
tzv. Value object třídu. O tom ale někdy jindy.
Až budeme naše řešení testovat pomocí jednotkových testů, tak nám stačí vytvářet řetězce, které obsahují data, a ty použít. Není třeba vytvářet žádné soubory ani je načítat, což nám usnadní život. Je to také rychlejší a spolehlivější.
Další výhoda je, že funkci TorrentBuilder::createTorrent()
předáváme opravdu jen to, co potřebuje, žádné zbytečnosti. TorrentBuilder
tak může hned začít pracovat.
Pokud v budoucnu bude potřeba načítat data z různých zdrojů, tak to bude mít na starost příslušná komponenta. Ta pak bude využita i u ostatních protokolů. Načítání dat tak bude na jednom místě.
Co se týče rozhodování za uživatele, tak to lze vyřešit několika způsoby. Jedním z nich je, že z dat načteme vše, co půjde, a uživatel si pak sám určí, zda mu načtená data postačují. Nebude pak třeba na úrovni třídy TorrentBuilder
řešit, která data jsou potřebná a která ne. Další výhodou je, že tímto způsobem se můžeme vyhnout použití výjimek, které mohou přinášet zbytečné problémy. Rychlokvíz: jaké jsou úrovně garance bezpečnosti (angl. exception safety) při použití výjimek? Odpověď je zde.
Ve výsledku jsme se dostali do situace, kdy třída Torrent
má jedinou odpovědnost (reprezentace torrent souboru). Stejně tak TorrentBuilder
, jehož odpovědností je sestavení torrentu na základě předaných dat. Jupí :).
Náš příklad byl poněkud zjednodušený, protože neřeší některé záležitosti, které bude třeba řešit, např. kódování (data v torrent souborech jsou zakódována pomocí metody bencoding a řetězce v torrent souborech jsou ve formátu UTF-8). Názorně ale ukazuje problém, který může při návrhu (či nedostatku návrhu) vzniknout a přináší způsob, jak z něho ven.
Věřím, že jste se již s podobným případem setkali. Pokud ano a je to váš kód, vylepšete ho. Ušetříte si do budoucna mnoho problémů a úšklebky kolegů, kteří se budou divit, jak někdo něco takového může napsat.
Závěrem bych chtěl říct, že navržené řešení rozhodně není jediné možné, a že v závislosti na situaci může být vhodných řešení více.
Přidat komentář