Polimorficzne łączenie metod we wzorcu Budowniczy

Wzorzec Budowniczy najczęściej realizowany jest na zasadzie łańcuchowego wywołania metod. Na przykładzie klasy StringBuilder z biblioteki języka C#:

StringBuilder builder = new StringBuilder();
builder.Append("Ala").Append(" ").Append("ma").Append(" ").Append("kota"); //=> Ala ma kota

Cała sztuczka z łańcuchowym łączeniem wywołań polega na tym, że każda metoda takiego obiektu zwraca sam obiekt, na którym można wywołać kolejną metodę. Wyobraźmy sobie na chwilkę, że klasa StringBuilder jest zaimplementowana w C++. Jej kod wyglądałby mniej więcej tak:

class StringBuilder
{
public:
    //...
    StringBuilder& Append(char* str)
    {
        // dołącz napis do bufora
        return *this; // dzięki temu można dołączyć kolejne wywołanie.
    }
    //...
};

Co jednak zrobić, kiedy mamy kilka klas budowniczych, którzy współdzielą pewne metody? Chcemy rzecz jasna posłużyć się dziedziczeniem - zrobić nadklasę, zawierającą te wspólne metody i odziedziczyć po niej w konkretnych budowniczych.

Początek - osobne klasy - zarys problemu.

Przedstawię problem na minimalnym przykładzie. Wyobraźmy sobie, że mamy w swojej aplikacji budowniczych dla kodu dokumentów pokazujących kod źródłowy oraz dla dokumentów tekstowych. Oba mają wspólną logikę do ustawiania kodowania znaków, korzystającą z typu wyliczeniowego Encoding. Wyglądałoby to mniej więcej tak:

class CodeDocumentBuilder
{
public:
    CodeDocumentBuilder& encoding(Encoding newEncoding)
    {
        // logika wybierania i zapisywania kodowania znaków.
        return *this;
    }
 
    CodeDocumentBuilder& syntaxHighlightMode(SyntaxHighlightMode documentMode)
    {
        // logika wybierania i zapisywania trybu podświetlania składni.
        return *this;
    }
 
    CodeDocument build()
    {
        // tworzenie obiektu i zwracanie go
    }
};
 
class TextDocumentBuilder
{
public:
    TextDocumentBuilder& encoding(Encoding newEncoding)
    {
        // logika wybierania i zapisywania kodowania znaków.
        return *this;
    }
 
    TextDocumentBuilder& leftMargin(int mm)
    {
        // logika wybierania i zapisywania lewego marginesu.
        return *this;
    }
 
    TextDocumentBuilder& rightMargin(int mm)
    {
        // logika wybierania i zapisywania prawego marginesu.
        return *this;
    }
 
    TextDocument build()
    {
        // tworzenie obiektu i zwracanie go
    }
};

Jak widać, jest jedna wspólna metoda, która mogłaby być wyodrębniona do osobnej nadklasy (normalnie może ich być dużo więcej, ale ten przykład jest minimalny, więc jest tylko jedna).

Podejście drugie - zwykłe dziedziczenie.

Załóżmy, że chcemy rozwiązać problem, stosując zwykłe dziedziczenie i wprowadzając nadklasę:

class GenericDocumentBuilder
{
public:
    GenericDocumentBuilder& encoding(Encoding newEncoding)
    {
        // logika wybierania i zapisywania kodowania znaków.
        return *this;
    }
};
 
class CodeDocumentBuilder : public GenericDocumentBuilder
{
public:
    CodeDocumentBuilder& syntaxHighlightMode(SyntaxHighlightMode documentMode)
    {
        // logika wybierania i zapisywania trybu podświetlania składni.
        return *this;
    }
 
    CodeDocument build()
    {
        // tworzenie obiektu i zwracanie go
    }
};
 
class TextDocumentBuilder : public GenericDocumentBuilder
{
public:
    TextDocumentBuilder& leftMargin(int mm)
    {
        // logika wybierania i zapisywania lewego marginesu.
        return *this;
    }
 
    TextDocumentBuilder& rightMargin(int mm)
    {
        // logika wybierania i zapisywania prawego marginesu.
        return *this;
    }
 
    TextDocument build()
    {
        // tworzenie obiektu i zwracanie go
    }
};

Podejście to ma jedną poważną wadę - o ile spokojnie można zrobić tak:

TextDocumentBuilder().leftMargin(10).rightMargin(25).encoding(UTF_8).build();

to niemożliwe jest wykonanie kodu:

TextDocumentBuilder().encoding(UTF_8).leftMargin(10).rightMargin(25).build();

Dzieje się tak dlatego, że metoda encoding() zwraca nam nasz obiekt jako obiekt nadklasy, który nic nie wie o operacjach leftMargin() i rightMargin() oraz build(). Dlatego po wykonaniu pierwszej metody z klasy GenericDocumentBuilder tracimy dostęp do metod klasy TextDocumentBuilder.

Podejście trzecie - dziedziczenie z szablonami

Rozwiązanie tego problemu, które od razu się narzuca, to szablony. Możemy spróbować sprytnie "wstrzyknąć" typ podklasy do nadklasy i kazać jej zwracać siebie jako typ podklasy.

Pierwsze naiwne podejście może wyglądać tak:

template<typename T> class GenericDocumentBuilder
{
public:
    T& encoding(Encoding newEncoding)
    {
        // logika wybierania i zapisywania kodowania znaków.
        return *this;
    }
};
 
class CodeDocumentBuilder : public GenericDocumentBuilder<CodeDocumentBuilder> { ... };
class TextDocumentBuilder : public GenericDocumentBuilder<CodeDocumentBuilder> { ... };

Problem jest jednak taki, że nie można niejawnie przekonwertować obiektu nadklasy do obiektu podklasy, zatem zwrócenie *this jako CodeDocumentBuilder& bądź TextDocumentBuilder& owocuje błędem kompilacji.

Możemy to na szczęście naprawić, dokonując dynamicznego rzutowania:

T& encoding(Encoding newEncoding)
{
    // logika wybierania i zapisywania kodowania znaków.
    return *(dynamic_cast<T*>(this));
}

To, co teraz osiągnęliśmy, działa. Za cenę dynamicznego rzutowania w dół, ale działa. Można na tym poprzestać, bądź też pozmieniać parę rzeczy, żeby tego rzutowania się pozbyć (jak wiadomo, rzutowanie w trakcie wykonania programu bywa kosztowne).

Ostatecznie rozwiązanie - metoda getInstance().

Klasę GenericBuilder zmieńmy następująco:

template<typename T> class GenericDocumentBuilder
{
public:
 
    T& encoding(Encoding newEncoding)
    {
        // logika wybierania i zapisywania kodowania znaków.
        return getInstance();
    }
protected:
    virtual T& getInstance() = 0;
};

Wprowadzając metodę getInstance() przerzucamy odpowiedzialność za zwrócenie odpowiedniego typu na podklasę, która go doskonale zna, zatem zbędne jest jakiekolwiek rzutowanie.

Przykładowa implementacja jednej z klas mogłaby wyglądać tak:

class CodeDocumentBuilder : public GenericDocumentBuilder<CodeDocumentBuilder> 
{
public:
    // Implementacja metody getInstance()!
    TextDocumentBuilder& getInstance()
    {
        return *this;
    }
 
    TextDocumentBuilder& leftMargin(int mm)
    {
        // logika wybierania i zapisywania lewego marginesu.
        return *this;
    }
 
    TextDocumentBuilder& rightMargin(int mm)
    {
        // logika wybierania i zapisywania prawego marginesu.
        return *this;
    }
 
    TextDocument build()
    {
        // tworzenie obiektu i zwracanie go
    }
};

Treaz już można łączyć wywołania metod nadklasy z metodami podklasy, np.

TextDocumentBuilder().leftMargin(10).encoding(UTF_8).rightMargin(25).build();
O ile nie zaznaczono inaczej, treść tej strony objęta jest licencją Creative Commons Attribution-Share Alike 2.5 License.