Wzorce Projektowe: Adapter

Cover Image for Wzorce Projektowe: Adapter
Paweł
Paweł

Czy zdarzyło Ci się kiedyś na wyjeździe, że chcąc skorzystać z ładowarki, spotkała Cię niemiła niespodzianka w postaci niepasującego gniazdka? Jeżeli tak to pewnie na drugi raz w Twojej torbie znalazła się odpowiednia przejściówka, pozwalająca naładować rozładowany sprzęt elektroniczny. Również w świecie programowania potrzebujemy czasem skorzystać z czegoś, co pozwoli zaadaptować nasze obiekty do nowych sytuacji w projekcie. Wtedy z pomocą przychodzi nam wzorzec Adapter.

Adaptacja do nowych warunków

Pamiętasz fabrykę samochodów z artykułu o wzorcu Factory? Jeżeli nie to znajdziesz go pod tym linkiem.

Firma postanowiła zainwestować w rozwój i dobudować dodatkowe laboratorium do badania osiągów wyprodukowanych pojazdów. Żeby uzyskać jak najdokładniejsze wyniki, zakupiono najnowocześniejszy sprzęt badający osiągi samochodów.

Dla lepszego zobrazowania spójrzmy na poniższy kod:

interface Car extends PerformanceMeasurement { name: string; productionYear: number; } interface PerformanceMeasurement { power: number; maxSpeed: number; } class PerformanceMeasurementResult { constructor(private measurementObject: PerformanceMeasurement) {} measurePerformance(): void { console.log(`Measuring performance for ${this.measurementObject.name}...`); // logika pomiarów // … // rezultaty pomiarów console.log(`Power: ${this.measurementObject.power} HP`); console.log(`Max Speed: ${this.measurementObject.maxSpeed} km/h`); } } // Przykładowe użycie const suv: Car = { name: "SUV", productionYear: 2023, power: 200, maxSpeed: 250, }; const measurement = new PerformanceMeasurementResult(suv); measurement.measurePerformance();

Jak widać mamy tutaj dwa interfejsy Car oraz PerformanceMeasurement. Żeby móc skorzystać z pomiarów w PerformanceMeasurementResult przekazywany tam obiekt, musi rozszerzać interfejs PerformanceMeasurement.

W głowach zarządu pojawiła się jednak myśl, żeby poszerzyć profil firmy o produkcję motocykli. W wyniku tego pojawił się dodatkowy interfejs:

interface Motorbike { name: string; productionYear: number; maximumSpeed: number; enginePower: number }

Problem w tym, że zakupione maszyny nie były przystosowane do badania osiągów jednośladów. Jak widzisz powyższy interfejs nie rozszerza PerformanceMeasurement.

Być może czytelniku zastanawiasz się, czy nie wystarczyłoby dodać takiego rozszerzenia i po problemie. Niestety nie zawsze mamy taką możliwość, czasami może to być np. obiekt z jakiejś zewnętrznej biblioteki. Tak było również w tym przypadku, dodatkowo mogłoby to być naruszenie zasady Open/Close. Ponownie zespół inżynierów wspiął się na wyżyny swoich możliwości. Postanowiono wdrożyć wzorzec Adapter, który miał pomóc dostosować sprzęt do wymaganych pomiarów.

Adapter

Adapter należy do rodziny wzorców strukturalnych, czyli takich, które pozwalają łączyć klasy i obiekty w większe struktury. Pozwala obiektom o różnych interfejsach współpracować ze sobą. Adapter konwertuje interfejs jednego obiektu na interfejs oczekiwany przez drugi obiekt. Czyli jak w prawdziwym życiu, np. adapter do gniazdka sieciowego pozwala nam używać standardowej wtyczki w gniazdku w Wielkiej Brytanii:

diagram

Spójrzmy na kod, w którym zastosowaliśmy wzorzec adapter:

class MotorbikeAdapter implements PerformanceMeasurement { constructor(private motorbike: Motorbike) {} get name(): string { return this.motorbike.name; } get productionYear(): number { return this.motorbike.productionYear; } get power(): number { // dopasowanie logiki pobierania mocy dla motocykla return this.motorbike.enginePower; } get maxSpeed(): number { // dopasowanie logiki pobierania maksymalnej prędkości return this.motorbike.maximumSpeed; } }

Klasa MotorbikeAdapter jako parametr w konstruktorze przyjmuje obiekt typu Motorbike, dodatkowo implementuje interfejs PerformanceMeasurement , co umożliwia użycie jej obiektów z klasą PerformanceMeasurementResult.

A tak wygląda podpięcie naszego adaptera do istniejącego już kodu:

const motorbike: Motorbike = { name: "Motorbike", productionYear: 2023, maximumSpeed: 280, enginePower: 150, }; const motorbikeAdapter = new MotorbikeAdapter(motorbike); const measurement = new PerformanceMeasurementResult(motorbikeAdapter); measurement.measurePerformance();

Jako wynik na konsoli powinniśmy zobaczyć:

'Measuring performance for Motorbike...' 'Power: 150 HP' 'Max Speed: 280 km/h'

Powyższy kod przekłada się na następujący diagram:

diagram

Klasa PerformanceMeasurementResult to klient, który zawiera w sobie zależność, jaką jest interfejs PerformanceMeasurement. Zostaje on zaimplementowany przez nasz adapter MotorbikeAdapter, który zawiera w sobie pole typu Motorbike.

W tym momencie MotorbikeAdapter stał się naszą przejściówką do badania osiągów motocykli. Zastosowaliśmy tutaj wariant wzorca adapter bazujący na kompozycji. Alternatywnie można użyć innej wariacji tego wzorca bazującej na dziedziczeniu.

Diagram dla takiego wariantu wygląda następująco:

diagram

Jak widać, różnica pomiędzy oba wariantami jest bardzo subtelna. W przypadku wersji opierającej się na dziedziczeniu Adapter dziedziczy z klasy obiektu, który ma zostać zaadaptowany.

Wróćmy ponownie do naszego kodu liczącego osiągi, ale tym razem niech Motorbike będzie klasą, a nie interfejsem. W uproszczeniu mogłaby ona wyglądać w taki sposób:

class Motorbike { name: string; productionYear: number; enginePower: number; private hiddenFactor: number; constructor(name: string, productionYear: number, enginePower: number) { this.name = name; this.productionYear = productionYear; this.enginePower = enginePower; this.hiddenFactor = 1.1; // Ukryty współczynnik dla obliczenia maksymalnej prędkości } protected calculateMaximumSpeed(): number { // obliczanie maksymalnej prędkości // uwzględniając ukryty czynnik return this.enginePower * this.hiddenFactor; } }

Chcielibyśmy teraz skorzystać z calculateMaximumSpeed do wyliczenia maksymalnej prędkości. W adapterze opartym na kompozycji taka metoda będzie dla nas niedostępna, ponieważ jest oznaczona jako protected. Musimy zastosować rozwiązanie oparte o dziedziczenie.

Spójrzmy:

class MotorbikeAdapter extends Motorbike implements PerformanceMeasurement { constructor(motorbike: Motorbike) { super(motorbike.name, motorbike.productionYear, motorbike.enginePower); } get power(): number { return motorbike.enginePower; } get maxSpeed(): number { return motorbike.calculateMaximumSpeed(); } private calculateMaximumSpeed(): number { // Wykorzystujemy dziedziczoną metodę z klasy Motorbike return this.calculateMaximumSpeed(); } }

Przykład użycia:

const motorbike = new Motorbike("Motorbike", 2023, 150); const motorbikeAdapter = new MotorbikeAdapter(motorbike); const measurement = new PerformanceMeasurementResult(motorbikeAdapter); measurement.measurePerformance();

W konsoli powinniśmy zobaczyć:

'Measuring performance for Motorbike...' 'Power: 150 HP' 'Max Speed: 165 km/h'

Który wariant powinieneś wybrać w Twoim kodzie?

Damy Ci kilka wskazówek, które wesprą Cię podczas podejmowania decyzji:

Kompozycja:

Jeżeli:

  • klasa, którą chcemy dostosować do działania w naszym kodzie za pomocą adaptera, nie umożliwia nam dziedziczenia z żadnego powodu

  • potrzebujemy dopasować interfejs wielu różnych klas do interfejsu docelowego

  • nie potrzebujesz dziedziczenia, nie używaj go na siłę. Na ogół staraj się kierować zasadą "Composition over inheritance", czyli "Kompozycja ponad dziedziczenie".

Dziedziczenie:

Gdy potrzebujemy:

  • skorzystać z pól i metod klasy, które są dostępne tylko dla klas dziedziczących
  • dopasować interfejs jednej konkretnej klasy do interfejsu docelowego.

Na koniec spójrzmy sobie jeszcze na zestawienie wad i zalet zastosowania tego wzorca:

Zalety:

  • Ułatwienie integracji: pozwala na łatwą integrację niekompatybilnych komponentów. Dzięki niemu możemy używać kodu z innych modułów, bibliotek itp. nawet jeśli nie jest on do końca zgodny z istniejącymi interfejsami.
  • Zgodność z zasadą open/close, rozbudowa nie wymaga wprowadzania żadnych zmian w istniejących komponentach
  • Zachowanie pojedynczej odpowiedzialności, logika konwertowania obiektów jest zamknięta w klasie adaptera

Wady:

  • Dodatkowa warstwa abstrakcji: wprowadzamy dodatkową warstwę pomiędzy klientem, a konwertowanym obiektem. Może to prowadzić do zwiększonej złożoności kodu i utrudnienia w zrozumieniu działania systemu.

Podsumowanie

Wzorzec adapter jest przydatnym narzędziem do integracji niekompatybilnych interfejsów. Dodatkowo tak jak wspomnieliśmy, integracja istniejących komponentów odbywa się bez konieczności modyfikacji ich kodu. Ma to wartość, zwłaszcza gdy chcemy wykorzystać istniejący kod w nowym kontekście lub kiedy musimy połączyć różne biblioteki. Mamy nadzieję, że po tym wpisie nie będzie miał on przed Tobą żadnych tajemnic.