O špatně chápaném principu jedné odpovědnosti třídy (SRP) a o zneužívání myšlenek Domain driven designu (DDD)
Dnes na twitteru David Grudl odkázal na debatu, která se týká vlastností v PHP. O vlastnostech v PHP mluvit nechci, ale v tomto příspěvku se chci dotknout některých “dogmat”, které se ozývají stále častěji a které byly použity jako univerzální kladivo na oponenty i v odkazované diskuzi.
Jedno zvláštní dogma se týká principu jedné odpovědnosti třídy (Single responsibility principle). Tento princip říká, že třída by měla mít jednu přesně vymezenou odpovědnost, která je v souladu s jejím názvem. I když na první přečtění se tento princip zdá neproblematický, dá se zneužít jako univerzální kladivo. Dogmatici mi říkali, že jedna odpovědnost si vynucuje, aby třída vždy měla právě jednu metodu, která tuto odpovědnost realizuje. Není nad přehledný svět objektových dogmatiků, kde objekt je jen stupidní kontajner na jednu (de facto globální?) funkci.
Dogmatiky tohoto zvláštního ražení zanedbejme jako ztracené případy a SRP obohaťme o další vysvětlení, které říká, že třída by měla mít jen jeden důvod ke změně. Tento princip je užitečný v tom, se snaží z aplikací odstranit všemocné božské (God) objekty, které mnohdy už svým názvem signalizují, že řeší spoustu věcí. UniversalOrderAndInvoiceProcessor oznamuje, že se bude měnit nejen, když se změní zpracování objednávek, ale také když se změní zpracování faktur. Jednoduché, což? Proč o tomhle jednoduchém principu vůbec dále mluvit?
V diskuzi se o SRP mluví (viz i příspěvky níže), ale diskutující tam ve své argumentaci používají něco, čemu na kurzech u SRP říkám falešné alternativy.
Mějme stejně jako v diskusi svou třídu Image, která nese informace o obrázku. Obrázek chceme uložit.
Varianta 1, kdy obrázek nese informace a současně nabízí metodu Save, ve které uloží data do souboru.
Co je v diskuzi vyčítáno této třídě? Porušuje princip jedné odpovědnosti, protože podle některých (Jiří Knesl, Ondřej Mirteš) řeší dvě věci najednou – nese data o obrázku a současně data ukládá. Souhlasím, že jde o porušení SRP, ale hlavním důvodem je to, že metoda Save je napsána tak, že třídu Image ukládáme vždy do souboru. Co když budeme chtít třídu Image uložit do nějakého “response” streamu na webovém serveru, nebo uložit přímo do databáze? Tuto třídu skutečně budeme měnit ze dvou důvodů – jednou, když přidáme nebo odebereme informace o obrázku a také, když budeme chtít ukládat obrázek do databáze, musíme rozšířit stávající metodu Save, což povede k tomu, že metoda bude mít v sobě nějaký podivný switch a bude trpět smíšenou odpovědností, protože bude dělat několik věcí najednou, nebo můžeme přidat novou samostatnou metodu SaveToDb.
Jedinou (!?) alternativou v diskuzi k tomuto postupu je vyvedení odpovědnosti za ukládání do různých úložišť do samostatných objektů, které mohou být skryty za jednotným rozhraním.
Toto řešení důsledně separuje odpovědnosti, navíc je velmi snadné přidat další implementaci rozhraní IImagePersistor, např. DbPersistor, který data uloží do databáze. Už v diskuzi Jakub Vrána ale upozorňuje na to, že se mu nelíbí, jak se řešení komplikuje pro uživatele-vývojáře, který s třídami bude pracovat, protože tento vývojář musí vědět, že existuje nějaký IImagePersistor/FilePersistor odpovědný za uložení dat. Třída Image nestačí k tomu, abyste dokázali vygenerovat data obrázku a uložit je, což může být ve vaší knihovně častý scénář. Také bych rád poprvé v tomto článku připomněl princip OOP, ke kterému se za chvíli vrátím, že objekt představuje jednotu svého stavu a chování, které je pro tento stav definováno.
Psal jsem o falešných alternativách, můžeme najít i jiná řešení. Co ponechat metodu Save ve třídě Image, ale z třídy Image udělat tzv kompozitor - objekt, který skládá své chování tak, že využívá další pomocné objekty, na kterých závisí, a nabízí intuitivní rozhraní pro klienty.
Odpovědnosti jsou stále separovány a dokonce třída Image, náš kompozitor, dodržuje pravidlo, které říká, že kompozitor by měl být jednodušší než suma funkcí jeho pomocných objektů. Klient třídy Image nemusí pracovat přímo s třídou FilePersistor, a přitom nemáme kód pro ukládání do souboru přímo ve třídě Image. Problém je, že metoda Save třídy Image vždy vytváří FilePersistor. Klient třídy Image si nemůže vyžádat to námi dříve zmiňované ukládání obrázku do databáze, a navíc třída Image závisí na jedné konkrétní třídě FilePersistor, u níž přímo volá konstruktor. V třídě Image mixujeme vytváření grafu spolupracujících objektů se samotným použitím pomocných objektů. Opět jde o dvě odpovědnosti, které bychom měli oddělit – SRP, nezapomeňme.
Nejprve ale zkusme vyřešit problém s tím, že klient nemůže ukládat data do databáze, protože třída Image ukládá data vždy do souboru.
Jednoduše přidáme další variantu metody, která přijímá odkaz na IImagePersistor, v našem případě třeba na DbPersistor. Původní metoda Save bez argumentů řeší ukládání do souboru. Ukládání do souboru je nejčastější scénář, který je zvolen jako výchozí. Stále ale tady máme problém s tím, že v metodě Save konstruujeme "natvrdo" FilePersistor. A navíc naše API klientům trochu lže. V podtextu klientovi sděluje, že výchozí metoda Save nemá žádné další závislosti, i když z implementace, !a jen z implementace!, je zřejmé, že jsme závislí na přítomnosti třídy FilePersistor. Poznámka: V C# 4 můžeme použít volitelné argumenty u jedné metody, ale na principu této varianty řešení se moc nemění.
Zkusme naše prozatím ulhané API vylepšit a dodržet SRP. Oddělme nyní konstrukci pomocných objektů, na kterých závisíme, od jejich použití v metodě Save.
Objekt Image si nyní v konstruktoru vynucuje předání IIMagePersitoru. Když klient IImagePersistor nepředá, objekt nezvznikne – sám konstruktor garantuje, že buď objekt Image má vyplněny všechny závislosti, nebo vůbec nevznikne. Vytvořili jsme konstruktor, který může použít a automaticky naplnit DI kontajner, nebo různé abstraktní továrny registrované v DI kontajneru apod. DI kontajner je přesně tím objektem, který by měl být v aplikaci odpovědný za konstrukci grafu objektů, v metodě Save objektu Image injektovaný IImagePersistor jen používáme. SRP v praxi.
Možný že ale v tomto případě je injektování závislostí přes konstruktor moc striktní. Co když nám skutečně vyhovuje, že můžeme bez DI kontajneru vytvořit objekt Image, který bude data ukládat do souboru. Pak můžeme využít injektování přes vlastnosti, kdy příslušnou vlastnost po vzniku objektu vyplníme rozumnou výchozí hodnotou – v našem případě instancí FilePersistoru. Poté ale platí, že třídu Image stále částečně zatěžujete konstrukcí objektů…
U většiny DI kontajnerů je preferováno injektování závislostí přes konstruktory, všechny, které znám, si ale poradí ale i s injektováním závislostí přes vlastností a u MEFu bych řekl, že injektování závislostí pomocí vlastností hrají prim.
Všechny tyto varianty mají své výhody a nevýhody a asi nemusím zdůrazňovat, že ani jedna není univerzálním kladivem. Varianty s injektováním závislostí (konstruktor, metoda, vlastnost) jsou samozřejmě mnohem lépe testovatelné.
Dokážu přidat i další příklady, ale chtěl jsem, abyste viděli, že SRP není ani nesmysl, ale ani princip, který by, podobně jako to zaznělo v diskuzi, sděloval – existují jen dvě alternativy, jak rozdělovat odpovědnosti, a ZROVNA TA TVOJE JE ŠPATNĚ.
A poslední poznámka:
Jiřé Knesl také v diskuzi uvedl: “ objekt buďto data reprezentuje (pak má settery/gettery), nebo vykonává činnost (pak dostane data parametry)”. Tohle je podle mě postoj blízký hlavně některým Javistům, o čemž svědčí i podle mého soudu schematický a nevěrohodný článek, který se zabývá vlastnostmi v Javě a na který se J. Knesl odkazuje. Znovu připomínám, že objekt představuje jednotu svého stavu a chování, které je pro tento stav definováno. Objekt, který má jen gettery a settery, je ”krabičkou na data”, pouhou strukturou známou i z neobjektových jazyků, a když má objekt jen metody, tak jde o (v mnoha případech skutečně globální) funkce/procedury, které prefixujeme názvem proměnné/třídy. V diskuzi to myslím nezaznělo, ale když někdo razí tuto drastickou separaci chování od samotných dat, často dodává, že takto je to přece definováno Evansem, tedy autoritou, v kanonické knize o Domain Driven Designu. Když se ptám, kde o tom Evans mluví, dozvím se, že Evans má objekty, které mají svůj stav (vlastnosti) a s objekty pracují speciální business-doménové služby (chování). I když mám vůči DDD spoustu výhrad, zde Evanse špatně interpretují – Evans by model, kde objekty mají jen stav a nemají žádné chování, nazval anemickým modelem – izolovaná data podepřená berličkami nesouvisejících globálních funkcí. Business služby jsou, zjednodušeně řečeno, určeny pro zapsání složitější business logiky, na které spolupracuje více objektů a žádný participující objekt není sám o sobě přirozeným kandidátem, do kterého by bylo vhodné logiku situovat.
Zde bych mohl pokračovat dále k rozdělení objektů v DDD, ke skutečnému významu vlastností u objektu, co říká princip “tell, don't ask”, ale už teď mi původně krátký komentář k SRP a DDD až moc nabobtnal. Když budete mít zájem o další naznačená témata, napište prosím komentář k článku.
Sunday, 05 June 2011 21:13:04 (Central Europe Standard Time, UTC+01:00)
Analytické drobky | C# | Návrhové vzory | UML