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

Development General

7 years ago

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.