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.
Klasy SubclassX
i SubclassY
dziedziczą metody doY()
i 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()
i 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.
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 JSONAnalyticsReport
i YAMLAnalyticsReport
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
- Design Patterns: Elements of Reusable Object-Oriented Software – Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (1994) str. 360
- Template Method (Metoda Szablonowa) – Koddlo
- SourceMaking – Template Method Design Pattern
- Refactoring Guru – Template Method
- Composition vs Inheritance
- Wzorzec projektowy Strategia
Fajny przykład. Dzięki za lekturę
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
Tutaj faktycznie ma to sens – header, body i footer są integralną częścią dokumentu, więc jest sens testować je jako całość. Po drugie
createDocument
jedynie wywołuje implementowane metody i „nie dodaje nic od siebie”. Fajny case 👍