Jste zde

Chyby v návrhu: když jeden dělá příliš mnoho

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í.

O čem seriál bude

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í zadání

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í návrh řešení

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.

Analýza prvotního návrhu a vytipování problémů

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í.

  • Duplicitní kód pro načítání dat. Asi si teď říkáte něco ve smyslu: "Cože? Kde? Vždyť jsme ještě nic nenapsali!" Je třeba myslet v širších souvislostech. Naše komponenta určitě nebude jediné místo, kde se budou v aplikaci načítat soubory. Aplikace bude v budoucnu podporovat i další peer-to-peer protokoly a soubory bude třeba načítat i na mnoha dalších místech. S ohledem na správu a hlášení chyb by v aplikaci mělo být jediné místo, kde se budou načítat soubory. Všechny úpravy načítání souborů tak mohou být provedeny na jediném místě a nebude se zbytečně duplikovat kód.
  • Ztížené testování pomocí jednotkových testů. O jednotkovém testování (angl. unit testing) jste již určitě slyšeli. Je to způsob, jak otestovat jednotlivé třídy a moduly v izolaci. Pokud nevíte, o co se jedná, tak doporučuji nastudovat. Problémem je, že abychom otestovali naši třídu 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í.
  • Konstruktoru se předávají zbytečné informace. Co konstruktor třídy udělá, tak je, že otevře soubor a načte z něj data. S těmi pak pracuje. Pokud ve svém kódu narazíte na podobnou situaci, tak zbystřete. Může to být znak toho, že to, co metoda či konstruktor bere jako parametry, ve skutečnosti potřebuje pouze k tomu, aby se dostal k něčemu, co potřebuje. Proč tedy metodě či konstruktoru nepředat tato data přímo? Co náš konstruktor zajímá, tak jsou data souboru, nikoliv cesta k souboru či soubor jako takový.
  • Příliš úzce zaměřené řešení. Naše navržené řešení je příliš úzce zaměřené. Co když budeme chtít načítat torrent ze síťového disku, ze kterého nelze v daném systému číst klasickým způsobem? Nebo co když budeme chtít načíst soubor z Internetu podle zadaného URL? Buď by se muselo v konstruktoru rozlišovat, z jakého zdroje mají data pocházet a podle toho se rozhodovat, nebo si udělat konstruktorů více. A hle, při každém dalším způsobu načítání musíme měnit třídu, které je ve skutečnosti jedno, odkud data jsou.
  • Dělání rozhodnutí za uživatele. Během zpracování dat v souboru si musíme určit, která data jsou potřebná a která volitelná. Pokud bude v souboru chybět informace, o které se domníváme, že je potřebná, tak vyhodíme výjimku. Co když je ale tato informace pro danou situaci nepotřebná? Co když ji uživatel naší třídy nebude potřebovat? V našem případě musí toto rozhodnutí učinit třída Torrent, které ale toto rozhodnutí nepřísluší. Má to, konec konců, být jen "hloupá" reprezentace torrent souboru.

Návrh lepšího řešení

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í Torrentu 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í :).

Závěrem

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ář