Czym jest adapter?

WzGniazdko jako przykład adapteraorzec projektowy adapter jest całkiem prosty do zrozumienia, ponieważ łatwo jest znaleźć jego odzwierciedlenie w prawdziwym życiu. Pewnie słyszałeś o istnieniu kilku standardów gniazdek elektronicznych. Jadąc do innego kraju można napotkać gniazdko, do którego nie będą pasować wtyczki, te których używamy w Polsce.

Dlatego przed wyjazdem do takowego kraju zazwyczaj zaopatrujemy się w przejściówki, a inaczej mówiąc – w adaptery.

To właśnie adaptery umożliwiają nam korzystanie z naszych urządzeń we wszystkich krajach bez konieczności wymiany wtyczki.

Wzorzec adapter

Na początek zacząłem od prostego przykładu z życia wziętego, abyś mógł zrozumieć koncepcję wzorca. Teraz przejdę do rzeczy – czyli tworzenia oprogramowania i nakreślenia przykładowego problemu, w którym to wzorzec adapter może być dla nas przydatny.

Warto zaznaczyć, że adapter należy do wzorców strukturalnych – czyli takich, które opisują budowę obiektu – ich strukturę.

Przechodząc do mięsa, zacznę od trywialnego przykładu.

Koncepcja

Klocki jako prosty przykład adaptera

Przypuśćmy, że naszym zadaniem jest przygotować kod, który będzie sprawdzać czy piłka przejdzie przez otwór pudełka. Otwór pudełka jest w kształcie koła.

Na początek stwórzmy klasę odpowiedzialną za reprezentację pudełka.

public class Box {
    private final int holeRadius;
    private final List<Circle> content = new ArrayList<>();

    public Box(int holeRadius) {
        this.holeRadius = holeRadius;
    }

    public void put(Circle circle) {
        if (circle.getRadius() > holeRadius) {
            throw new IllegalArgumentException("Object cannot pass through the box's hole");
        }

        content.add(circle);
    }
}

public interface Circle {
    float getRadius();
}

Klasa Box posiada metodę put, która przyjmuje wszystko co jest okrągle tj. implementuje interfejs Circle. Czas na stworzenie pierwszego obiektu z przekrojem koła.

public class Ball implements Circle {
    private final float radius;

    public Ball(float radius) {
        this.radius = radius;
    }

    public float getRadius() {
        return radius;
    }
}

I wszystko jest w porządku dopóki klient nie zmieni wymagań funkcjonalnych i nie zażyczy sobie wrzucania kostek sześciennych do pudełka.

Na pierwszy rzut oka zadanie nie wydaje się skomplikowane – w końcu wystarczy stworzyć klasę Cube i zaimplementować interfejs Circle.

Tylko czy aby na pewno?

Jest to najprostsze rozwiązanie, ale w tak prostym przykładzie można zauważyć jedną istotną rzecz. Niekoniecznie chcemy, aby klasa Cube była interpretowana jako obiekt z przekrojem koła.

Zauważasz już problem?

Czasami może zdarzyć się sytuacja, w której chcemy uniknąć bezpośredniej zmiany klasy, ponieważ uważamy, że jest to niezgodne z założeniami.

W innej sytuacji może chodzić o klasę, której wszyscy boją się dotknąć. Każda zmiana w takiej klasie może spowodować kilka błędów w systemie.

Na szczęście jest inne podejście do problemu – dla naszej kostki można stworzyć adapter, który jedynie będzie udawał, że kostka ma przekrój koła.

public class Cube {
    private final float sideLength;

    public Cube(float sideLength) {
        this.sideLength = sideLength;
    }

    public float getSideLength() {
        return sideLength;
    }
}

public class CubeCircleHoleAdapter implements Circle {
    private final Cube cube;

    public CubeCircleHoleAdapter(Cube cube) {
        this.cube = cube;
    }

    @Override
    public float getRadius() {
        return 0.5f * cube.getSideLength();
    }
}

Dzięki tak prostemu zabiegowi otrzymujemy obiekt kompatybilny z pudełkiem, a dodatkowo nie zaburzyliśmy żadnych założeń, ani nic przypadkiem nie popsuliśmy. 😉

Ważna jest analiza poniższego kodu, aby dobrze zrozumieć problem i jego rozwiązanie przy użyciu adaptera.

public class Main {
    public static void main(String[] args) {
        Box box = new Box(10);

        Ball smallBall = new Ball(5);
        Ball biggerBall = new Ball(12);

        try {
            box.put(smallBall);
            System.out.println("Small ball has been put into box");
        } catch (Exception e) {
            System.err.println("Small ball could not be put into box due to: " + e.getMessage());
        }


        try {
            box.put(biggerBall);
            System.out.println("Bigger ball has been put into box");
        } catch (Exception e) {
            System.err.println("Bigger ball could not be put into box due to: " + e.getMessage());
        }


        Cube cube = new Cube(10);

        // can't be pass to Box.put method, because cube has different interface
        // box.put(cube);

        // We need adapter to adapt cube to being a circle
        CubeCircleHoleAdapter cubeCircleHoleAdapter = new CubeCircleHoleAdapter(cube);

        // now we can check if cube can be put into box
        box.put(cubeCircleHoleAdapter);
        System.out.println("Cube has been put into box");
    }
}

Rezultat uruchomienia powyższego fragmentu kodu.

Small ball has been put into box
Cube has been put into box
Bigger ball could not be put into box due to: Object cannot pass through the box's hole

Praktyczne użycie adaptera

Wyobraź sobie, że korzystasz z biblioteki z domeny lotów. Umożliwia ona wyszukiwanie odlotów z podanego miasta. Biblioteki mają to do siebie, że wymagają dostarczenia odpowiednich zależności. Tak samo jest i w tym przypadku. Biblioteka wymaga dostarczenia klienta HTTP zgodnego z opisanym interfejsem.

Okazuje się, że w Twoim projekcie jest już HttpClient. Jesteś szczęśliwy, bo możesz natychmiast zacząć czerpać korzyści z użycia biblioteki.

public class OldHttpClient {
    public RequestResponse get(String url) {
        return new RequestResponse(200, "");
    }

    public RequestResponse post(String url, String payload) {
        return new RequestResponse(200, "");
    }
}

Przykładowy HttpClient w projekcie

Niestety nic z tego – HttpClient zawarty w twoim systemie nie pasuje do interfejsu wymaganego przez bibliotekę. Jednak wpadasz na super pomysł i zaczynasz od implementacji interfejsu w klasie OldHttpClient.

public interface HttpClient {
    Response get(String url);

    Response post(String url, String payload);

    Response put(String url, String payload);

    Response delete(String url);

    @RequiredArgsConstructor
    @Getter
    class Response {
        private final int statusCode;
        private final String body;
    }
}


public class OldHttpClient implements HttpClient {
    public RequestResponse get(String url) {
        return new RequestResponse(200, "");
    }

    public RequestResponse post(String url, String payload) {
        return new RequestResponse(200, "");
    }

    @Override
    public Response get(String url) {
        return new Response(200, "");
    }

    @Override
    public Response post(String url, String payload) {
        return new Response(200, "");
    }


    @Override
    public Response put(String url, String payload) {
        return new Response(200, "");
    }

    @Override
    public Response delete(String url) {
        return new Response(200, "");
    }
}

Próba implementacji interfejsu z biblioteki

Po chwili uświadamiasz sobie, że jest to jednak bez sensu – nazwy metod i argumenty się pokrywają. Potrzebne są zmiany istniejących już metod. Po wszystkim klasa wygląda dużo gorzej i na dodatek jest wymieszana z wymaganiami zewnętrznej biblioteki.

I jest to kolejny przykład, w którym sprawdzi się wzorzec adapter. Dzięki niemu nie zrobimy zamieszania w istniejącym kodzie i przy okazji niczego nie zepsujemy – tyczy się to szczególnie projektów legacy.

Przykładowe wykorzystanie adaptera prezentuje się tak.

@RequiredArgsConstructor
public class SearchFlightExternalLib {
    private final HttpClient httpClient;

    public List<Flight> searchFrom(Flight.City from) {
        HttpClient.Response response = httpClient.get("https://flight-provider.com/search?from+" + from.name());

        // map response to flights

        return Collections.emptyList();
    }
}

@RequiredArgsConstructor
public class HttpClientFlightLibAdapter implements HttpClient {
    private final OldHttpClient oldHttpClient;

    @Override
    public Response get(String url) {
        RequestResponse requestResponse = oldHttpClient.get(url);
        return new Response(requestResponse.getHttpStatus(), requestResponse.getResponse());
    }

    @Override
    public Response post(String url, String payload) {
        RequestResponse requestResponse = oldHttpClient.post(url, payload);
        return new Response(requestResponse.getHttpStatus(), requestResponse.getResponse());
    }

    @Override
    public Response put(String url, String payload) {
        return new Response(200, "");
    }

    @Override
    public Response delete(String url) {
        return new Response(200, "");
    }
}

Tak jak w poprzednim przykładzie z obiektami i pudełkiem – udajemy, że pewien obiekt jest czymś innym.

Tym razem stworzyliśmy adapter dla OldHttpClient – nic nie popsuliśmy i klasa OldHttpClient została dostarczona do zewnętrznej biblioteki.

public class Main {
    public static void main(String[] args) {
        OldHttpClient oldHttpClient = new OldHttpClient();

        // Can't pass OldHttpClient, because class doesn't implement HttpClient interface
        // SearchFlightExternalLib searchFlightExternalLib = new SearchFlightExternalLib(oldHttpClient);

        // Need to create adapter for old http client
        HttpClient adapter = new HttpClientFlightLibAdapter(oldHttpClient);

        SearchFlightExternalLib searchFlightExternalLib = new SearchFlightExternalLib(adapter);

        List<Flight> flightsToParis = searchFlightExternalLib.searchFrom(Flight.City.Paris);
    }
}

Różne implementacje

Warto zaznaczyć, że adapter może zostać zaimplementowany na dwa sposoby:

  • przy użyciu kompozycji
  • lub dziedziczenia.

W powyższych przykładach zastosowałem kompozycję. Jednak nic nie stoi na przeszkodzie, żeby adapter rozszerzał klasę, którą adaptuje (np. Cube) do odpowiedniego interfejsu (np. Circle).

Chociaż podkreślę, że w przypadku języka Java klasa adoptowana może być oznaczona jako final i wtedy kompozycja jest lepszym (praktycznie jedynym) rozwiązaniem.

Kiedy, więc używać?

Każdy wzorzec projektowy ma określone sytuacje, w których powinien zostać użyty. W przypadku adaptera kryterium jest proste, więc jest bardzo łatwo dostrzec możliwość jego wykorzystania.

Wykorzystaj wzorzec w sytuacji, gdy klasa ma niekompatybilny interfejs z usługą, którą chcesz wykorzystać.

Zalety

  • Odseparowanie istniejącej części kodu od nowej – czyli system jest otwarty na rozszerzenie i zamknięty na zmiany
  • Zachowanie zasady jednej odpowiedzialności – nowe funkcjonalności mogą być wydzielone do osobnej klasy

Wady

  • Wzrost trudności zrozumienia kodu – spowodowane jest to wprowadzaniem nowych interfejsów i klas

Podsumowanie

W tym wpisie przedstawiłem wzorzec projektowy adapter. Moim zdaniem, jest jednym z łatwiejszych wzorców do zrozumienia – przynajmniej pod kątem koncepcji. Na pewno warto o nim pamiętać, ponieważ nie raz może ułatwić nam adaptację do wymaganego interfejsu.

Niestety jak każde rozwiązanie ma swoje plusy i minusy. Warto być ich świadomym podczas użycia wzorca adapter.

Kamil Klimek

Od 2016 jestem programistą Java. Przez pierwsze 4 lata pracowałem jako Full Stack Java Developer. Później postanowiłem postawić nacisk na Javę, żeby jeszcze lepiej ją poznać.

Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x