Wzorzec projektowy Template Method - okładka

Wzorzec projektowy Template Method

Opublikowano Kategorie Czysty kodCzas czytania 5min

Wzorzec projektowy Metody Szablonowej, bardziej znany pod oryginalną nazwą Template Method to jeden z behawioralnych wzorców projektowych opisanych przez Gang of Four. W tym artykule poznasz zasadę działania tego wzorca oraz jego przykładowe zastosowania. Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych.

Charakterystyka wzorca

Template Method jak sama nazwa wskazuje, pozwala wpisać określone zachowanie w pewien szablon bez modyfikacji samego szablonu. Szablonem jest klasa bazowa, a zachowanie definiowane jest w klasach dziedziczących po niej. Takie zachowanie jest pożądane w sytuacjach, gdy mamy klasę definiującą jakieś  zachowanie wspólne dla wszystkich podklas a podklasy różnią się w drobnych detalach np. implementacją jednej funkcji. Banda Czworga definiuje cel wzorca Template Method następująco:

Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.

Bazując na przedstawionych opisach wzorca, ilustracja graficzna wzorca mogłaby wyglądać następująco.

Template Method - diagram klas

Klasy SubclassXSubclassY dziedziczą metody doY()doZ() a implementują jedynie metodę doX(). Klasa BaseClass jest klasą abstrakcyjną, podobnie jak metoda doX(). Klasa BaseClass jedynie sygnalizuje, że oczekiwana jest implementacja metody doX() w podklasach. Opisane podejście pozwala na zredukowanie duplikacji kodu w aplikacji zgodnie z podejściem DRY. Dzięki Template Method nie ma konieczności powielania kodu metod doY()doZ().

Praktyczny przykład

Przykładem, na którym pokażę zastosowanie wzorca w praktyce, będzie zestaw klas służący generowaniu raportów analitycznych. Wspólny dla wszystkich klas będzie mechanizm generowania raportów. Klasa bazowa AnalyticsReport będzie implementować metodę prepareReport(), za pomocą której będzie przygotowywany raport. Dane  z raportu będą zapisywane wewnątrz klasy w polu reportData typu protected. Eksportowanie przygotowanego reportu będzie zadaniem metody export(), która będzie musiała być zaimplementowana w klasach potomnych. Podklasy będą się różnić formatem zwracanych danych. Dane mogą być zwracane np. w formatach JSON, YAML, XML, CSV czy jakimkolwiek innym. Przekładając opisany przypadek na strukturę wzorca przedstawioną wcześniej, struktura klas mogłaby wyglądać następująco.

Template Method - praktyczny przykład wykorzystania wzorca

Szkielet klas i zależności między nimi w kodzie mogłyby wyglądać następująco. Zdecydowałem się pominąć szczegóły implementacyjne poszczególnych klas, by nie zaciemniały niepotrzebnie przykładu.


abstract class AnalyticsReport {
  protected reportData: IReportData;

  public abstract export(): string;

  public prepareReport( params: IReportParams ): void {
    // ... implementation code
  }
}

class JSONAnalyticsReport extends AnalyticsReport {
  public export(): string {
    // ... parse report to stringified JSON
  }
}

class YAMLAnalyticsReport extends AnalyticsReport {
  public export(): string {
    // ... parse report to YAML
  }
}

Problemy i alternatywy

Osobiście uważam Template Method za jeden z gorszych wzorców projektowych, jakie miałem okazję opisać na łamach bloga. Problemów z Template Method widzę kilka. Pierwszym z nich jest problem z przetestowaniem zachowania klasy bazowej. Aby przetestować zachowanie klasy bazowej, potrzebujesz albo wykorzystać jedną z klas potomnych z kodu produkcyjnego lub stworzyć dedykowaną klasę potomną na potrzeby testów. W przedstawionym przykładzie, tworząc testy dla klas JSONAnalyticsReportYAMLAnalyticsReport testowane byłyby zarówno mechanizmy konwersji odpowiedzi, jak i logika tworzenia raportów. Nie da się przetestować eksportu do poszczególnych formatów bez wygenerowania raportu.

Drugim problemem, jaki widzę, jest ogólna sztywność tworzonego kodu. Przez wykorzystanie dziedziczenia klasy potomne są wrażliwe na zmiany w klasie bazowej. Zmiany w klasie bazowej mogą również powodować konieczność poprawy wielu testów dla klas potomnych.

Rozwiązaniem tych problemów może być podejście „composition over inheritance”. Kompozycję możesz kojarzyć z matematyki z następującego zapisu h(x) = g(f(x)). W programowaniu wygląda to podobnie. Zamiast korzystać z dziedziczenia, można skomponować w kodzie efekt docelowy z kilku komponentów. Moją propozycją jest wydelegowanie logiki związanej z parsowaniem danych do określonych formatów do dedykowanych funkcji, a następnie wstrzyknięcie docelowej funkcji w momencie inicjalizacji instancji klasy AnalyticsReport (która w tym momencie nie jest już abstrakcyjna). Struktura kodu po zmianach mogłaby wyglądać następująco.


class AnalyticsReport {
  private _report: IReportData;

  public constructor(
    private readonly _exportReport: ( data: IReportData ) => string
  ) {}

  public prepareReport( params: IReportParams ): void {
    // ... implementation code
  }

  public export(): string {
    return this._exportReport( this._report );
  }
}

function jsonExporter( data: IParseable ): string {
    // ... parse report to stringified JSON
}

function yamlExporter( data: IParseable ): string {
    // ... parse report to YAML
}

const yamlAnalyticsReport = new AnalyticsReport( yamlExporter );
const jsonAnalyticsReport = new AnalyticsReport( jsonExporter );

Dzięki takiemu zabiegowi możliwe jest osobne przetestowanie eksporterów do poszczególnych formatów i kodu związanego z przygotowaniem raportu. Pozwala to np. na dokładniejsze przetestowanie przypadków brzegowych dla eksporterów. Również same testy prawdopodobnie wymagałyby mniej rozbudowanej aranżacji. Ewentualne zmiany w dowolnym z komponentów nie wpłyną na testy dla pozostałych klas i funkcji. Dodatkowo eksportery są teraz reużywalne w innych częściach systemu. Świetnie sprawdziłby się tutaj również wzorzec Strategia.

Podsumowanie

Jestem ciekaw przypadków, gdzie zastosowanie Template Method jest najlepszym lub jedynym wyborem. Osobiście nie przypominam sobie sytuacji, gdzie nie dałoby się takiego kodu zrefaktoryzować do postaci niewykorzystującej dziedziczenia z zapewnieniem DRY. A jak to wygląda u Ciebie? Podziel się swoimi doświadczeniami w komentarzu!

Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić klikając w TEN link. Kupując z tego linku, wspierasz rozwój bloga.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Zapisując się na mój mailing, otrzymasz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

3 komentarzy
oceniany
najnowszy najstarszy
Inline Feedbacks
View all comments
Kromek
Kromek
14 dni temu

Fajny przykład. Dzięki za lekturę

PiotrekT
PiotrekT
11 dni temu

Ciekawy artykuł i spostrzeżenia. Niemniej czasami zastosowanie Template Method aż się prosi żeby użyć. Weźmy np. system do tworzenia dokumentów:

1) Abstrakcyjna klasa bazowa DocumentCreator:

public abstract class DocumentCreator {

  // Template method
  public final void createDocument() {
    createHeader();
    createBody();
    createFooter();
  }

  protected abstract void createHeader();
  protected abstract void createBody();
  protected abstract void createFooter();
}

2) Klasa konkretna dla tworzenia raportów:

public class ReportCreator extends DocumentCreator {

  @Override
  protected void createHeader() {
    // Implementacja….
  }

  @Override
  protected void createBody() {
    // Implementacja….
  }

  @Override
  protected void createFooter() {
    // Implementacja….
  }
}

3) Klasa konkretna dla tworzenia artykułów:

public class ArticleCreator extends DocumentCreator {

  @Override
  protected void createHeader() {
       // Implementacja….
  }

  @Override
  protected void createBody() {
        // Implementacja….
  }

  @Override
  protected void createFooter() {
        // Implementacja….
  }
}

4) Klasa konkretna dla tworzenia notatek:

public class NoteCreator extends DocumentCreator {

  @Override
  protected void createHeader() {
        // Implementacja….
  }

  @Override
  protected void createBody() {
        // Implementacja….
  }

  @Override
  protected void createFooter() {
        // Implementacja….
  }
}

No i testujemy poszczególne metody klas dziedziczących w łatwy sposób.

W takich przypadkach Template Method po prostu wydaje mi się najbardziej intuicyjne, ale może czas wyjść po za strefę komfortu xD