Wzorzec projektowy: Adapter

Problem do rozwiązania

Pewnie wiesz, że w różnych krajach gniazdka mogą wyglądać inaczej niż to, co możesz zobaczyć na co dzień. Charakterystyka prądu w takim gniazdku także może być różna. Załóżmy, że jedziesz do Wielkiej Brytanii, albo do Stanów Zjednoczonych. Zabierasz ze sobą laptopa i ładowarkę. Bateria wystarcza Ci na czas lotu. Po przylocie na miejsce chcesz uzupełnić baterię w pierwszym wolnym gniazdku na lotnisku.

Tu pojawia się problem. Wtyczka z Twojej ładowarki nie pasuje do gniazdka. Można powiedzieć, że gniazdko i wtyczka nie są ze sobą kompatybilne. Przypominasz sobie jednak, że przezornie udało Ci się zapakować przejściówkę. Przejściówka sprawi, że możesz podłączyć swoją ładowarkę do gniazdka.

Problem tego typu może także występować w projektach informatycznych. Przejściówka, która pozwala włączyć wtyczkę do innego gniazdka to nic innego jak adapter.

Problemem do rozwiązania jest zatem użycie obiektu, w miejscu gdzie jego interfejs nie jest obsługiwany. Adapter rozwiązuje ten problem “tłumacząc” go na coś zrozumiałego dla klienta.

Błyskawiczny kurs UML

Jeśli chcesz dowiedzieć się więcej o UML, to zapraszam Cię do przeczytania osobnego artykułu na temat podstaw UML’a.

Zanim przejdę do omówienia diagramów, które pokazują powiązania klas i interfejsów w tym wzorcu projektowym musisz dowiedzieć się czegoś o UML’u.

UML (ang. Unified Modeling Language) składa się z kilkunastu rodzajów diagramów. Jest to zestaw, który pozwala na wizualną reprezentację projektu informatycznego. W ramach serii opisującej wzorce projektowe będę korzystał z zupełnych podstaw tej notacji. Będę używał głównie diagramów klas. Chociaż nie jestem wielkim fanem UML’a, to taki sposób prezentacji w tym przypadku wydaje mi się najlepszy.

Do zrozumienia diagramów z tego artykuły wystarczy Ci ten przykład:

Przykładowy diagram UML.

Na tym diagramie możesz zobaczyć:

  • trzy klasy – prostokąty z napisami UserLinkedListObject,
  • dwa interfejsy – prostokąty oznaczone adnotacją <<interfejs>> z napisami ListCollection,
  • dziedziczenie – strzałka z ciągłą linią i z pustym grotem, na przykład pomiędzy LinkedList a Object czy List a Collection,
  • implementację interfejsu – strzałka z przerywaną linią i z pustym grotem pomiędzy LinkedList a List,
  • zależność – strzałkę z ciągłą linią pomiędzy User a LinkedList.

Kod w języku Java zgodny z tym diagramem może wyglądać tak (część diagramu dotycząca elementów biblioteki standardowej nie jest tu widoczna):

public class User extends Object {
    private LinkedList<String> notes;
}

Te podstawy w zupełności wystarczą Ci do zrozumienia poniższych przykładów.

Wzorzec adapter

Diagramy klas

Istnieją dwa sposoby implementacji adaptera. Jeden z nich używa kompozycji, drugi dziedziczenia. Diagramy poniżej pokazują tę subtelną różnicę:

Wzorzec adapter zaimplementowany przy pomocy kompozycji.
Wzorzec adapter zaimplementowany przy pomocy dziedziczenia.

W obu przypadkach klasa DoAdaptacji nie implementuje bezpośrednio interfejsu Zależność. Ten interfejs implementuje klasa Adapter. Także w obu przypadkach Klient reprezentuje klasę, która używa interfejsu Zależność. Zatem użycie klasy Adapter pozwala na pośrednie użycie klasy DoAdaptacji przez klasę Klient.

Zaletą stosowania tego wzorca projektowego jest to, że klasa DoAdaptacji nie musi być modyfikowana, aby spełnić interfejs wymagany przez klasę Klient. Czasami nawet taka modyfikacja nie jest możliwa.

Przykładowa implementacja adaptera

Wyobraź sobie sytuację, w której mamy macierz kwadratową. Macierz reprezentowana jest przez obiekt implementujący interfejs Matrix:

public interface Matrix {
    int get(int x, int y);
    int size();
}

Dodatkowo istnieje klasa MatrixOperations, która definiuje zestaw metod operujących na takich macierzach. Przykład poniżej pokazuje metodę largest, która zwraca największy element z macierzy:

public class MatrixOperations {
    public static int largest(Matrix m) {
        if (m.size() == 0) {
            throw new IllegalArgumentException("Matrix is empty!");
        }
        int largest = m.get(0, 0);
        for (int x = 0; x < m.size(); x++) {
            for (int y = 0; y < m.size(); y++) {
                if (m.get(x, y) > largest) {
                    largest = m.get(x, y);
                }
            }
        }
        return largest;
    }
}

Przekładając to na diagramy, które pokazałem wyżej to:

  • Klient – MatrixOperations,
  • Zależność – Matrix.

Adapter przy użyciu kompozycji

Standardowo macierz można reprezentować przez tablicę dwuwymiarową. ArrayMatrix to adapter, który wykorzystuje kompozycję. W tym przypadku opakowuje on tablicę dwuwymiarową – int[][], udostępniając interfejs Matrix:

public class ArrayMatrix implements Matrix {
    private final int[][] matrix;

    public ArrayMatrix(int[][] matrix) {
        this.matrix = matrix;
    }

    @Override
    public int get(int x, int y) {
        return matrix[y][x];
    }

    @Override
    public int size() {
        return matrix.length;
    }
}

W tym przypadku:

  • Adapter – ArrayMatrix,
  • DoAdaptacji – int[][].

Wszystko ładnie działa. Do czasu. Pojawiło się wymaganie, które zakłada, że musisz przechować bardzo dużą i rzadką macierz. Rzadka macierz to taka, w której większość elementów ma wartość 0. Jest to problem, ponieważ ArrayMatrix wymaga ciągłych obszarów pamięci. Dodatkowo marnuje ją przechowuje wartości 0, które można pominąć.

Z pomocą przychodzi inna implementacja adaptera.

Adapter przy użyciu dziedziczenia

Tym razem adapter wykorzystuje dziedziczenie:

public class MapMatrix extends HashMap<String, Integer> implements Matrix {
    private final int size;

    public MapMatrix(int size) {
        this.size = size;
    }

    @Override
    public int get(int x, int y) {
        assertBoundaries(x, y);
        return this.getOrDefault(key(x, y), 0);
    }
    
    public void set(int x, int y, int value) {
        assertBoundaries(x, y);
        put(key(x, y), value);
    }

    @Override
    public int size() {
        return size;
    }

    private String key(int x, int y) {
        return x + "," + y;
    }

    private void assertBoundaries(int x, int y) {
        if (x < 0 || x > size || y < 0 || y > size) {
            throw new IllegalArgumentException(key(x, y));
        }
    }}

W tym przypadku:

  • Adapter – MapMatrix,
  • DoAdaptacji – HashMap.

 Zastosowanie

 Stosuj klasę Adapter gdy chcesz wykorzystać jakąś istniejącą klasę, ale jej interfejs nie jest kompatybilny z resztą twojego programu.

 Wzorzec Adapter pozwala utworzyć klasę która stanowi warstwę pośredniczącą pomiędzy twoim kodem, a klasą pochodzącą z zewnątrz, lub inną, posiadającą jakiś nietypowy interfejs.

 Stosuj ten wzorzec gdy chcesz wykorzystać ponownie wiele istniejących podklas którym brakuje jakiejś wspólnej funkcjonalności, niedającej się dodać do ich nadklasy.

 Możesz rozszerzyć każdą podklasę i umieścić potrzebną funkcjonalność w nowych klasach pochodnych. Jednak wtedy trzeba by było duplikować kod i umieścić go we wszystkich nowych klasach, a to psuje zapach kodu.

Znacznie bardziej eleganckim rozwiązaniem jest umieszczenie brakującej funkcjonalności w klasie adapter i opakowanie nią obiektów pozbawionych potrzebnych funkcji. Aby to zadziałało, klasy docelowe muszą mieć wspólny interfejs, a pole adaptera musi być z nim zgodne. Podejście to bardzo przypomina wzorzec Dekorator.

 Jak zaimplementować

  1. Upewnij się, że masz przynajmniej dwie klasy o niekompatybilnych interfejsach:
    • Jakąś użyteczną klasę usługową której nie możesz zmienić (innego producenta, przestarzałą, albo ze zbyt wieloma istniejącymi zależnościami)
    • Jedną lub wiele klas klienckich które zyskałyby na możliwości skorzystania z powyższej usługi.
  2. Zadeklaruj interfejs klienta i opisz jak ma wyglądać komunikacja klientów z usługą.
  3. Stwórz klasę adapter zgodną z interfejsem klienckim. Póki co, pozostaw metody pustymi.
  4. Dodaj pole do klasy adapter, które przechowa odniesienie do obiektu usługi. Typową praktyką jest inicjalizacja tego pola za pośrednictwem konstruktora, ale czasem wygodniej jest przekazać usługę adapterowi wywołując jego metody.
  5. Jeden po drugim, zaimplementuj wszystkie metody interfejsu klienta w klasie adapter. Adapter powinien delegować większość faktycznej pracy obiektowi oferującemu usługę i zajmować się wyłącznie pośrednictwem lub konwersją danych.
  6. Klienci powinni stosować adapter za pośrednictwem interfejsu klienta. Pozwoli to zmieniać lub rozszerzać adaptery bez wpływania na kodu kliencki.

 Zalety i wady

  •  Zasada pojedynczej odpowiedzialności. Można oddzielić interfejs lub kod konwertujący dane od głównej logiki biznesowej programu.
  •  Zasada otwarte/zamknięte. Można wprowadzać do programu nowe typy adapterów bez psucia istniejącego kodu klienckiego, o ile będzie on korzystał z adapterów poprzez interfejs kliencki.
  •  Ogólna złożoność kodu zwiększa się, ponieważ trzeba wprowadzić zestaw nowych interfejsów i klas. Czasem łatwiej zmienić klasę udostępniającą jakąś potrzebną usługę, aby pasowała do reszty kodu.

Powiązania z innymi wzorcami

  • Most zazwyczaj wykorzystuje się od początku projektu, by pozwolić na niezależną pracę nad poszczególnymi częściami aplikacji. Z drugiej strony, Adapter jest rozwiązaniem stosowanym w istniejącej aplikacji w celu umożliwienia współpracy pomiędzy niekompatybilnymi klasami.
  • Adapter zmienia interfejs istniejącego obiektu, zaś Dekorator rozszerza go bez zmiany interfejsu. Ponadto Dekorator wspiera rekursywną kompozycję, co nie jest możliwe gdy zastosuje się Adapter.
  • Adapter wyposaża “opakowywany” obiekt w inny interfejs, Pełnomocnik w taki sam, zaś Dekorator wprowadza rozszerzony interfejs.
  • Fasada definiuje nowy interfejs istniejącym obiektom, zaś Adapter zakłada zwiększenie użyteczności zastanego interfejsu. Adapter na ogół opakowuje pojedynczy obiekt, zaś Fasada obejmuje cały podsystem obiektów.
  • MostStanStrategia (i w pewnym stopniu Adapter) mają podobną strukturę. Wszystkie oparte są na kompozycji, co oznacza delegowanie zadań innym obiektom. Jednak każdy z tych wzorców rozwiązuje inne problemy. Wzorzec nie jest bowiem tylko receptą na ustrukturyzowanie kodu w pewien sposób, lecz także informacją dla innych deweloperów o charakterze rozwiązywanego problemu.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *