Návrhový vzor Builder je velmi užitečný v případě, že potřebujeme zapouzdřit a zjednodušit konstrukci složitějšího objektu. Spolu s návrhovým vzorem fluent interface nám umožní vytvořit praktické API, které může být součástí knihovny a je okamžitě pochopitelné pro ostatní vývojáře. Co když však přidáme dědičnost?
Builder s dědičností
Představme si, že chceme vytvořit fluent builder pro dva zděděné typy abstraktní třídy Game - LocalGame a OnlineGame . Oba typy her mají některé vlastnosti společné (jako velikost hrací plochy nebo level), ale každý má určité chování navíc (lokální hra může přidat nastavení obtížnosti umělé inteligence, on-line hra zase URL serveru, na kterém chceme hrát). Abstraktní GameBuilder by mohl vypadat takto:
abstract class GameBuilder { protected int _boardSize = 8; protected int _level = 1; public GameBuilder BoardSize(int boardSize) { _boardSize = boardSize; return this; } public GameBuilder Level(int level) { _level = level; return this; } public abstract Game Build(); }
A jeho zděděné verze LocalGameBuilder a OnlineGameBuilder:
class LocalGameBuilder : GameBuilder { private int _aiStrength = 3; public LocalGameBuilder AiStrength(int aiStrength) { _aiStrength = aiStrength; return this; } public override Game Build() => new LocalGame(_aiStrength, _boardSize, _level); }
class OnlineGameBuilder : GameBuilder { private string _serverUrl = "http://example.com/"; public OnlineGameBuilder ServerUrl( string serverUrl) { _serverUrl = serverUrl; return this; } public override Game Build() => new OnlineGame(_serverUrl, _boardSize, _level); }
Okamžitě můžeme vidět jeden jeden nedostatek. Metoda Build , definovaná v GameBuilderu jako abstraktní, vrací základní typ Game . To je náchylné k problémům. LocalGameBuilder například nyní může vyprodukovat jakýkoliv druh hry:
//LocalGameBuilder class public Game Build() => new OnlineGame("INVALID", _boardSize, _level); //ajta!
Musíme najít způsob, jak zajistit, aby byl návratový typ metody Build pouze ten konkrétní typ, který daný builder má vytvářet.
Generický builder
Generické typy jsou součástí .NETu od verze 2.0 a přichází nás zachránit! Můžeme abstraktní třídu GameBuilder udělat generickou tak, aby typ, který metoda Build vrací, byl konkrétním zděděným typem hry.
abstract class GameBuilder<TGame> where TGame : Game { protected int _boardSize = 8; protected int _level = 1; public GameBuilder<TGame> BoardSize(int boardSize) { _boardSize = boardSize; return this; } public GameBuilder<TGame> Level(int level) { _level = level; return this; } public abstract TGame Build(); }
Všimněme si, že pomocí klíčového slova where vytváříme omezení generického typu, abychom se ujistili, že parametr bude skutečně zděděným typem třídy Game . Konkrétní builder může nyní specifikovat typ:
class LocalGameBuilder : GameBuilder<LocalGame> { private int _aiStrength = 3; public LocalGameBuilder AiStrength(int aiStrength) { _aiStrength = aiStrength; return this; } //now only LocalGame can be built by LocalGameBuilder public override LocalGame Build() => new LocalGame( _aiStrength, _boardSize, _level); }
Aktuální verze vypadá na první pohled dobře. Dokonce funguje i naše fluent API:
OnlineGame onlineGame = new OnlineGameBuilder(). ServerUrl("http://new.com"). Level(12). Build();
Ale vše růžové není. Co když změníme pořadí volání metod v řetězovém volání?
OnlineGame anotherOnlineGame = new OnlineGameBuilder(). Lavel(2). ServerUrl("http://myserver.com"). Build();
Dotaneme chybu na třetí řádce - 'GameBuilder<OnlineGame>' does not contain a definition for 'ServerUrl' . Důvod problému je fakt, že metoda Level nevrací OnlineGameBuilder , ale jen základní GameBuilder<OnlineGame> , který vlastnost ServerUrl nemá vůbec k dispozici.
Ještě více generický builder
Vraťme se tedy k abstraktnímu builderu a pokusme se zajistit, aby fluent metody vracely konkrétní zděděný typ builderu. Použijeme návrhový vzor Curiously Recurring Template, který pochází z C++, ale lze jej analogicky použít i v C#. Na předání konkrétního typu builderu musíme GameBuilderu přidat nový typový parametr, který dědí od něho samotného:
abstract class GameBuilder<TGame, TBuilder> where TGame : Game where TBuilder : GameBuilder<TGame, TBuilder>
Tento koncept není jednoduché myšlenkově pojmout. Zjednodušeně řečeno chceme, aby typový parametr TBuilder byl implementací GameBuilderu , která má daný typ hry jako první typový parametr a sebe sama jako typový parametr TBuilder . Samotná definice vypadá poměrně zamotaně, ale při použití je již čitelnější:
class OnlineGameBuilder : GameBuilder<OnlineGame, OnlineGameBuilder>
To nám umožní přesně to, co jsme potřebovali - poskytnout abstraktní třídě typ konkrétního builderu na použití. Nyní se ještě musíme vypořádat s jedním dalším problémem. Fluent metody vrací aktuální instaci třídy, aby bylo možné volání řetězit:
public TBuilder BoardSize(int boardSize) { _boardSize = boardSize; return this; }
Ale to nyní nefunguje! Na čtvrtém řádku se pokoušíme vrátit this , což je pouze GameBuilder<TGame, TBuilder> , ne konkrétní builder (TBuilder ). Pomůžeme si malým trikem. Přidáme novou read-only vlastnost do abstraktního GameBuilderu : protected abstract TBuilder BuilderInstance { get; } Tato vlastnost bude implementována konkrétními buildery a umožní jim vrátit jejich vlastní instanci (this).
class OnlineGameBuilder : GameBuilder<OnlineGame, OnlineGameBuilder> { protected override OnlineGameBuilder BuilderInstance => this; //... }
Takže nyní má abstraktní builder přístup nejen k zděděnému typu ve forme typového parametru TBuilder, ale i k samotné instanci pomocí vlastnosti BuilderInstance bez potřeby přetypování. Fluent metody budou nyní mít následující podobu:
public TBuilder Level(int level) { _level = level; return BuilderInstance; }
Když teď použijeme naše API k vytvoření hry, vidíme, že vždy dostaneme zpět instanci konkrétního builderu nezávisle na pořadí volání metod.
//now it works butter smooth OnlineGame anotherOnlineGame = new OnlineGameBuilder(). Level(2). ServerUrl("http://myserver.com"). Build();
Finální podoba
Zde je finální podoba našich builderů:
abstract class GameBuilder<TGame, TBuilder> where TGame : Game where TBuilder : GameBuilder<TGame, TBuilder> { protected int _boardSize = 8; protected int _level = 1; protected abstract TBuilder BuilderInstance { get; } public TBuilder BoardSize(int boardSize) { _boardSize = boardSize; return BuilderInstance; } public TBuilder Level(int level) { _level = level; return BuilderInstance; } public abstract TGame Build(); } class LocalGameBuilder : GameBuilder<LocalGame, LocalGameBuilder> { private int _aiStrength = 3; protected override LocalGameBuilder BuilderInstance => this; public LocalGameBuilder AiStrength(int aiStrength) { _aiStrength = aiStrength; return this; } public override LocalGame Build() => new LocalGame(_aiStrength, _boardSize, _level); } class OnlineGameBuilder : GameBuilder<OnlineGame, OnlineGameBuilder> { private string _serverUrl = "http://example.com/"; protected override OnlineGameBuilder BuilderInstance => this; public OnlineGameBuilder ServerUrl(string serverUrl) { _serverUrl = serverUrl; return this; } public override OnlineGame Build() => new OnlineGame(_serverUrl, _boardSize, _level); }
Aktualizováno: Vylepšení od Ondřeje Kunce
Finální verzi lze ještě vylepšit, na což mě upozornil v komentářích tohoto článku Ondřej Kunc a moc mu tímto děkuji za skvělý tip! Ukázalo se, že jsem zbytečně komplikoval zpracování this pomocí abstraktní vlastnosti BuilderInstance . Problém lze vyřešit mnohem jednodušeji a to bez nutnosti, aby zděděné verze builderu vracely vlastní instanci (což může být i nebezepečné, protože by například mohly vrátit pokaždé úplně novou instanci, čímž by byla způsobena nefunkčnost builderu). Místo toho můžeme do abstraktního builderu přidat readonly položku, do které si v konstruktoru uložíme this přetypované na typ TBuilder . Máme garanci, že přetypování proběhne úspěšně díky omezením, které jsme na typový parametr stanovili.
abstract class GameBuilder<TGame, TBuilder> where TGame : Game where TBuilder : GameBuilder<TGame, TBuilder> { private readonly TBuilder _builderInstance = null; protected int _boardSize = 8; protected int _level = 1; public GameBuilder() { //store the concrete builder instance _builderInstance = (TBuilder)this; } public TBuilder BoardSize(int boardSize) { _boardSize = boardSize; return _builderInstance; } public TBuilder Level(int level) { _level = level; return _builderInstance; } public abstract TGame Build(); }
Shrnutí
Začali jsme s jednocuchou negenerickou, k chybám náchylnou, podobou návrhového vzoru builder. Ten jsme rozšířili o generiku, abychom se ujistili, že buildery skutečně vytvářejí instance typu, který slibují. Nakonec jsme použili C# podobu návrhového vzoru curiously recurring template, který nám zajistil plnou funkčnost fluent API. Ukázkový zdrojový kód si můžete prohlédnout a stáhnout na mém GitHubu.