C#: Návrhový vzor Builder s dědičností

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.

Buy me a coffeeBuy me a coffee

2 thoughts on “C#: Návrhový vzor Builder s dědičností”

  1. Super článek. Jen takový detail mě napadl. Nebylo by lepší nastavit BuilderInstance přímo v konstruktoru třídy GameBuilder? Třeba takhle:

    BuilderInstance = (TBuilder) this;

    1. To je pravda, tím by se to ještě zlepšilo 🙂 ! Protože je určitě typ `TBuilder`, tak je přetypování bezpečné a zděděné třídy nemusí zodpovídat za to, aby skutečně vracely `this` a ne třeba úplně novou instanci.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.