Wzorce projektowe #2 – Adapter

adapter
Każdy programista czasem staje przed koniecznością skorzystania z kodu napisanego przez kogoś innego. Nierzadko zdarza się, że taki kod nie jest kompatybilny z naszym. W tym momencie rozpoczyna się żmudny proces modyfikacji kodu, aby poprawnie współpracował z naszym programem. Jest jednak lepsze rozwiązanie tego problemu – zastosowanie wzorca Adapter.

Trochę teorii

Adapter to wzorzec umożliwiający współpracę klas o niezgodnych interfejsach. Wzorzec ten dostosowuje interfejs klas, z których chcemy skorzystać, do naszego interfejsu.
Działanie wzorca adapter można zobrazować sobie w następujący sposób: jesteśmy za wakacjach w Wielkiej Brytanii. Chcemy sobie naładować baterię w telefonie. Jednak nasza ładowarka ma inną wtyczkę niż gniazdo zasilania w UK (w domyśle: interfejsy ładowarki i gniazda nie są ze sobą kompatybilne). Na szczęście mamy przejściówkę, dzięki której możemy podłączyć ładowarkę do gniazda. Nastąpiło tutaj dostosowanie interfejsów. Wzorzec Adapter jest właśnie taką „przejściówką” do dostosowywania interfejsów.
Wzorzec Adapter należy do grupy wzorców strukturalnych, opisujących w jaki sposób są powiązane współpracujące ze sobą obiekty. Adapter jest wykorzystywany w przypadkach, gdy zewnętrzny kod jest niekompatybilny z naszym programem, a jego modyfikacja mogłaby zaburzyć działanie tego kodu lub jego modyfikacja jest zbyt czasochłonna.

Problem projektowy – stacja pogodowa

Załóżmy, że napisałeś program pogodowy, który wyświetla średnią temperaturę powietrza w Europie na podstawie danych z czujników ulokowanych na terenie poszczególnych krajów. Na potrzeby tego przykładu sposób przetwarzania danych z czujników i przekazywania ich do aplikacji został tu pominięty. Kod aplikacji wygląda następująco:
public abstract class Country {
    protected float temp1, temp2, temp3;

    public Country(float temp1, float temp2, float temp3) {
        this.temp1 = temp1;
        this.temp2 = temp2;
        this.temp3 = temp3;
    }

    public int getTemp() {
        float averageTemp = (temp1 + temp2 + temp3) / 3;

        int averageTempRounded = Math.round(averageTemp);

        return averageTempRounded;
    };

    public abstract String getName();
} 
Klasa Country to klasa abstrakcyjna, po której będą dziedziczyć klasy poszczególnych krajów. Pola temp1 do temp3 przechowują wartości temperatur z czujników rozlokowanych na terenie danego kraju. Konstruktor przypisuje wartości temperatur do obiektu. Metoda getTemp oblicza temperaturę średnią i zaokrągla ją do liczby całkowitej. Natomiast abstrakcyjna metoda getName zwraca nazwę kraju i będzie definiowana w klasach poszczególnych krajów.
public class Poland extends Country {
    public Poland(float temp1, float temp2, float temp3) {
        super(temp1, temp2, temp3);
    }

    @Override
    public String getName() {
        return "Polska";
    }
}

public class Germany extends Country {
    public Germany(float temp1, float temp2, float temp3) {
        super(temp1, temp2, temp3);
    }

    @Override
    public String getName() {
        return "Niemcy";
    }
}

public class France extends Country {
    public France(float temp1, float temp2, float temp3) {
        super(temp1, temp2, temp3);
    }

    @Override
    public String getName() {
        return "Francja";
    }
} 
Klasy poszczególnych krajów przyjmują w konstruktorze jako parametry wartości temperatur, a następnie wywołują konstruktor klasy nadrzędnej Country w celu zapisania temperatur do obiektu.
public class WeatherStation {
    public static void main(String[] args) {
        Country countryPoland = new Poland(11, 13, 9);
        Country countryGermany = new Germany(13, 8, 11);
        Country countryFrance = new France(16, 13, 16);

        System.out.println(countryPoland.getName() + ": " + countryPoland.getTemp() + " st. C");
        System.out.println(countryGermany.getName() + ": " + countryGermany.getTemp() + " st. C");
        System.out.println(countryFrance.getName() + ": " + countryFrance.getTemp() + " st. C");
    }
} 
result
Aplikacja działa poprawnie i ma szerokie grono zadowolonych użytkowników. Z czasem zostałeś poproszony przez użytkowników amerykańskich o rozszerzenie wyświetlania pogody o poszczególne stany USA. Ze względu na inne działanie czujników temperatury w USA musiałeś skorzystać z oprogramowania amerykańskiej firmy. Kod źródłowy wygląda tak:
public abstract class State {
    protected float temp1, temp2, temp3;

    public State(float temp1, float temp2, float temp3) {
        this.temp1 = temp1;
        this.temp2 = temp2;
        this.temp3 = temp3;
    }

    public float getTemperature() {
        float averageTemp = (temp1 + temp2 + temp3) / 3;

        float averageTempRounded = Math.round((averageTemp * 100)) / 100f;

        return averageTempRounded;
    };

    public abstract String getName();
}

public class Washington extends State {
    public Washington(float temp1, float temp2, float temp3) {
        super(temp1, temp2, temp3);
    }

    @Override
    public String getName() {
        return "Waszyngton";
    }
}

public class Texas extends State {
    public Texas(float temp1, float temp2, float temp3) {
        super(temp1, temp2, temp3);
    }

    @Override
    public String getName() {
        return "Teksas";
    }
}

public class Florida extends State {
    public Florida(float temp1, float temp2, float temp3) {
        super(temp1, temp2, temp3);
    }

    @Override
    public String getName() {
        return "Floryda";
    }
} 
Na pierwszy rzut oka wydaje się, że wszystko jest w porządku. Jednak po dokładnym przyjrzeniu się, możemy stwierdzić, że występują tu pewne różnice w stosunku do kodu naszej aplikacji:
  • inne nazwy metod: w klasie Country mamy getTemp, a w klasie State getTemperature,
  • inne typy zwracanych wartości w metodach: w klasie Country jest to int, a w klasie State mamy float,
  • aby dodatkowo uatrakcyjnić nasz problem projektowy, załóżmy, że w amerykańskim oprogramowaniu wynik jest zwracany w stopniach Fahrenheita (wszak w USA temperaturę podaje się właśnie w tej skali).
Jednym ze sposobów na poradzenie sobie z tym problemem byłaby modyfikacja oprogramowania amerykańskiego. Jednak czasem próba modyfikacji jakiegoś kodu może spowodować błędy, które uniemożliwią wykorzystanie takiego kodu. Nie mówiąc już o tym jak żmudna może być to praca. Wzorzec Adapter umożliwi nam dostosowanie interfejsu kodu amerykańskiego do interfejsu naszej aplikacji. W tym przykładzie interfejs nie jest tu rozumiany jako typ abstrakcyjny Interface, lecz jako ogólny zbiór reguł określających interakcję pomiędzy poszczególnymi komponentami programu – API.

Zastosowanie wzorca Adapter w praktyce

Strukturę wzorca Adapter najprościej będzie wyjaśnić na przykładzie diagramu UML:
UML
Na docelową część programu składa się interfejs TargetInterface oraz klasa (bądź klasy) TargetClass implementujące ten interfejs.
Klasa AdaptedClass to klasa, którą chcemy dopasować do interfejsu docelowego. W tym celu tworzymy klasę Adapter, która implementuje interfejs docelowy. Adapter zawiera obiekt klasy AdaptedClass. W najprostszej wersji adaptera, metoda targetMethod w klasie Adapter wywołuje na obiekcie metodę otherMethod klasy adaptowanej.
Działanie wzorca Adapter z punktu widzenia klienta wygląda następująco: klient tworzy obiekt klasy Adapter, wywołuje metodę interfejsu docelowego i otrzymuje wynik. W trakcie działania programu klient nie wie, że adapter dokonuje jakichkolwiek konwersji. Korzysta z obiektu klasy Adapter w taki sam sposób jak korzysta z obiektów klas TargetClass.

Diagram UML stacji pogodowej

Diagram klas naszej stacji pogodowej będzie wyglądał następująco:
UML
Interfejs docelowy, czyli klasa abstrakcyjna Country i jego klasy podrzędne, pozostaje bez zmian.
Analogicznie wygląda sprawa z interfejsem adaptowanym. Nie wprowadzamy do niego żadnych zmian.
Jedyną zmianą, jaką wprowadziliśmy do kodu to stworzenie klasy StateAdapter, która łączy nam obydwa interfejsy. StateAdapter dziedziczy po klasie Country i jednocześnie zawiera obiekt klasy State. Metoda getName jest zdefiniowana tak samo w interfejsie docelowym jak i w interfejsie adaptowanym, dlatego tutaj jedynie wywołujemy metodę getName obiektu klasy State. Aby dopasować metodę getTemperature, by była kompatybilna z interfejsem docelowym, wykonano 4 kroki:
  • pobrano temperaturę poprzez wywołanie metody getTemperature na obiekcie State,
  • przekonwertowano temperaturę ze stopni Fahrenheita na stopnie Celcjusza,
  • przekonwertowano temperaturę z typu float na typ int,
  • zwrócono wynik.

Implementacja wzorca Adapter

public class StateAdapter extends Country {
    private State state;

    public StateAdapter(State state) {
        super(state.temp1, state.temp2, state.temp3);
        this.state = state;
    }

    @Override
    public int getTemp() {
        float tempInFahrenheit = state.getTemperature();

        float tempInCelcius = (tempInFahrenheit - 32) / 1.8f;

        int averageTempInCelcius = Math.round(tempInCelcius);

        return averageTempInCelcius;
    }

    @Override
    public String getName() {
        return state.getName();
    }
} 
Jak już zostało to wcześniej wspomniane, klasa StateAdapter zawiera obiekt klasy State. Konstruktor klasy StateAdapter, jako parametr pobiera ten obiekt i zapisuje wartości temperatur do poszczególnych zmiennych. Działanie metod getTemp i getName pominiemy, gdyż wszystko zostało wyjaśnione w poprzednim rozdziale.
public class WeatherStation {
    public static void main(String[] args) {
        Country countryPoland = new Poland(11, 13, 9);
        Country countryGermany = new Germany(13, 8, 11);
        Country countryFrance = new France(16, 13, 16);

        State stateWashington = new Washington(85, 79, 86);
        State stateTexas = new Texas(86, 80, 82);
        State stateFlorida = new Florida(89, 87, 84);

        Country stateWashingtonAdapter = new StateAdapter(stateWashington);
        Country stateTexasAdapter = new StateAdapter(stateTexas);
        Country stateFloridaAdapter = new StateAdapter(stateFlorida);


        System.out.println(countryPoland.getName() + ": " + countryPoland.getTemp() + " st. C");
        System.out.println(countryGermany.getName() + ": " + countryGermany.getTemp() + " st. C");
        System.out.println(countryFrance.getName() + ": " + countryFrance.getTemp() + " st. C");

        System.out.println(stateWashingtonAdapter.getName() + ": " + stateWashingtonAdapter.getTemp() + " st. C");
        System.out.println(stateTexasAdapter.getName() + ": " + stateTexasAdapter.getTemp() + " st. C");
        System.out.println(stateFloridaAdapter.getName() + ": " + stateFloridaAdapter.getTemp() + " st. C");
    }
} 
Aby skorzystać z Adaptera, należy najpierw utworzyć obiekty poszczególnych klas dziedziczących po State, a następnie te obiekty przesłać do konstruktora klasy StateAdapter. Dzięki temu, że klasa StateAdapter dziedziczy po klasie Country, możemy obiekty klasy StateAdapter zapisać do zmiennych typu Country, co daje nam możliwość korzystania z metod interfejsu docelowego. Adapter zajmie się całą resztą. Najwyższy czas by odpalić naszą apikację.
Nasza stacja pogoda działa poprawnie. Pomimo niezgodności interfejsów, udało się dokończyć rozbudowę aplikacji. Nowi użytkownicy programu będą zachwyceni.

Podsumowanie

Adapter jest stosunkowo prostym wzorcem dającym dużo możliwości. Dzięki jego zastosowaniu potrafimy połączyć ze sobą niekompatybilne części kodu. Wzorzec Adapter pozwala to wykonać szybko i sprawnie, bez żmudnej modyfikacji części kodu i narażania się na błędy.

Znajdź mnie również na:

arrow