1. Definicja
Adapter to strukturalny wzorzec projektowy, który umożliwia współpracę obiektów o niekompatybilnych interfejsach — opakowując jeden z nich w klasę pośredniczącą, która "tłumaczy" wywołania z jednego interfejsu na drugi.
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn't otherwise because of incompatible interfaces. — Gang of Four, Design Patterns
Najprościej rzecz ujmując: Adapter to tłumacz między dwoma klasami, które mówią różnymi językami.
2. Przykład z życia: zobaczmy jak to zrozumieć?
Wyobraźmy sobie, że przyjeżdżamy z laptopem do Wielkiej Brytanii.
Nasz laptop ma wtyczkę z dwoma okrągłymi bolcami — standard europejski. Gniazdka w brytyjskich hotelach mają trzy prostokątne otwory — standard UK. Laptop jest sprawny. Gniazdko jest sprawne. Problem? Interfejsy są niekompatybilne.
Rozwiązanie nie polega na wymianie laptopa ani na kuciu ścian, żeby zamontować europejskie gniazdko. Kupujemy adapter podróżny — małe urządzenie, które z jednej strony pasuje do gniazdka UK, z drugiej przyjmuje europejską wtyczkę. Żadna ze stron nie wie, że adapter w ogóle istnieje. Laptop myśli, że jest podłączony do europejskiego gniazdka. Gniazdko myśli, że ma do czynienia ze standardową brytyjską wtyczką.
To jest wzorzec Adapter w czystej postaci.
W programowaniu taka sytuacja zdarza się nagminnie:
- integrujemy zewnętrzną bibliotekę, której interfejs nie pasuje do naszego systemu,
- używamy starego kodu (legacy), który nie możemy zmodyfikować, ale chcemy z niego korzystać,
- łączymy dwa niezależne systemy, które zostały zaprojektowane bez wiedzy o sobie nawzajem.
Ćwiczenie myślowe: wyobraźmy sobie, że nasz system płatności działa z interfejsem Platnosc z metodą zaplac().
Nagle musimy zintegrować zewnętrzny procesor płatności — Stripe — który ma własne API z metodą charge().
Nie możemy zmienić Stripe'a. Nie chcemy zmieniać naszego systemu. Potrzebujemy tłumacza — Adaptera.
3. Przykład — bez Adaptera
Tworzymy system e-commerce. Początkowo wysyłaliśmy powiadomienia e-mail przez własną klasę. Teraz chcemy zintegrować zewnętrzny serwis SMS — SendSmsService — który ma zupełnie inne API niż to, czego oczekuje nasz system.
// Interfejs, którego oczekuje nasz system — zdefiniowany przez nas
interface SerwisWiadomosci {
void wyslijWiadomosc(String odbiorca, String tresc);
}
// Nasza własna implementacja — działa poprawnie
class EmailSerwis implements SerwisWiadomosci {
@Override
public void wyslijWiadomosc(String odbiorca, String tresc) {
System.out.println("Wysyłam e-mail do: " + odbiorca + " | Treść: " + tresc);
}
}
// Zewnętrzna biblioteka SMS — nie możemy jej modyfikować
// Ma własne API, zupełnie inne niż nasz SerwisWiadomosci
class SendSmsService {
// Inna nazwa metody, inne parametry, inny porządek argumentów
public void send(String phoneNumber, String message, String sender) {
System.out.println("Wysyłam SMS na: " + phoneNumber
+ " od: " + sender + " | " + message);
}
}
class OrderService {
private SerwisWiadomosci serwisWiadomosci;
public OrderService(SerwisWiadomosci serwisWiadomosci) {
this.serwisWiadomosci = serwisWiadomosci;
}
public void zlozZamowienie(String klient, String produkt) {
System.out.println("Składam zamówienie: " + produkt);
// OrderService oczekuje SerwisWiadomosci — nie wie nic o SendSmsService
serwisWiadomosci.wyslijWiadomosc(klient, "Zamówiono: " + produkt);
}
}
public class Main {
public static void main(String[] args) {
// To działa — EmailSerwis implementuje SerwisWiadomosci
OrderService serwis = new OrderService(new EmailSerwis());
serwis.zlozZamowienie("+48123456789", "Laptop");
// To NIE działa — SendSmsService nie implementuje SerwisWiadomosci
// Kompilator zgłosi błąd: SendSmsService cannot be converted to SerwisWiadomosci
// OrderService serwis2 = new OrderService(new SendSmsService()); // ← błąd!
// Jedyne "rozwiązanie" bez Adaptera — zabrudzenie kodu OrderService:
SendSmsService smsService = new SendSmsService();
// Musimy obsłużyć SMS osobno, poza standardowym przepływem
// Kod staje się niespójny — raz wyslijWiadomosc(), raz send()
smsService.send("+48123456789", "Zamówiono: Laptop", "QuickBite");
}
}
Co jest tutaj złe?
SendSmsService istnieje i działa, ale nie możemy go podłączyć tam, gdzie nasz system oczekuje SerwisWiadomosci.
Możemy albo zrezygnować z zewnętrznej biblioteki, albo zaśmiecić kod OrderService z wiedzą o dwóch różnych API.
Oba wyjścia są złe.
Konkretne konsekwencje:
- złamanie OCP — chcąc dodać SMS, musimy modyfikować
OrderService, - duplikacja logiki — obsługa SMS rozchodzi się po całym kodzie zamiast być w jednym miejscu,
- silne powiązanie —
OrderServicemusi znać szczegóły API zewnętrznej biblioteki, - niemożność testowania — nie możemy podmienić
SendSmsServicena mock przez wspólny interfejs.
4. Przykład — zgodny z wzorcem Adapter
Podejdźmy do tego lepiej. Tworzymy klasę pośredniczącą, która implementuje nasz interfejs i w środku deleguje wywołania do zewnętrznej biblioteki.
// Interfejs i klasy istniejące — bez zmian
interface SerwisWiadomosci {
void wyslijWiadomosc(String odbiorca, String tresc);
}
class EmailSerwis implements SerwisWiadomosci {
@Override
public void wyslijWiadomosc(String odbiorca, String tresc) {
System.out.println("Wysyłam e-mail do: " + odbiorca + " | Treść: " + tresc);
}
}
// Zewnętrzna biblioteka — również bez zmian
class SendSmsService {
public void send(String phoneNumber, String message, String sender) {
System.out.println("Wysyłam SMS na: " + phoneNumber
+ " od: " + sender + " | " + message);
}
}
// ADAPTER — tłumacz między naszym interfejsem a zewnętrzną biblioteką
// Od zewnątrz wygląda jak SerwisWiadomosci — nasz system go rozumie
// W środku używa SendSmsService — biblioteka jest zadowolona
class SmsAdapter implements SerwisWiadomosci {
// Referencja do adaptowanego obiektu — "opakowana" biblioteka
private final SendSmsService sendSmsService;
private final String nadawca;
public SmsAdapter(SendSmsService sendSmsService, String nadawca) {
this.sendSmsService = sendSmsService;
this.nadawca = nadawca;
}
// Implementujemy interfejs, którego oczekuje nasz system...
@Override
public void wyslijWiadomosc(String odbiorca, String tresc) {
// ...i w środku tłumaczymy wywołanie na API zewnętrznej biblioteki
// Adapter zna oba "języki" — to jego jedyna odpowiedzialność
sendSmsService.send(odbiorca, tresc, nadawca);
}
}
class OrderService {
private final SerwisWiadomosci serwisWiadomosci;
public OrderService(SerwisWiadomosci serwisWiadomosci) {
this.serwisWiadomosci = serwisWiadomosci;
}
public void zlozZamowienie(String klient, String produkt) {
System.out.println("Składam zamówienie: " + produkt);
// OrderService nie zmienił się ani trochę — nadal zna tylko SerwisWiadomosci
serwisWiadomosci.wyslijWiadomosc(klient, "Zamówiono: " + produkt);
}
}
public class Main {
public static void main(String[] args) {
// Wersja z e-mailem — bez zmian
OrderService serwisEmail = new OrderService(new EmailSerwis());
serwisEmail.zlozZamowienie("jan@przykład.pl", "Laptop");
// Wersja z SMS — przez Adapter
// OrderService nie wie, że pod spodem działa SendSmsService
SendSmsService sendSmsService = new SendSmsService();
SerwisWiadomosci smsAdapter = new SmsAdapter(sendSmsService, "QuickBite");
OrderService serwisSms = new OrderService(smsAdapter);
serwisSms.zlozZamowienie("+48123456789", "Mysz");
// Chcemy dodać trzeci kanał — Slack, WhatsApp, push?
// Tworzymy kolejny Adapter — OrderService pozostaje niezmieniony
}
}
Co zyskujemy?
OrderService nie wie i nie musi wiedzieć, że za SerwisWiadomosci kryje się zewnętrzna biblioteka SMS. Dodanie kolejnego kanału komunikacji to stworzenie nowego Adaptera — bez dotykania jakiejkolwiek istniejącej klasy. Zewnętrzna biblioteka pozostaje nienaruszona.
5. Dwa warianty Adaptera
GoF opisuje dwa sposoby implementacji wzorca. W Javie praktycznie zawsze stosujemy pierwszy.
Adapter obiektowy (przez kompozycję) — rekomendowany
To wersja z powyższego przykładu. Adapter trzyma referencję do adaptowanego obiektu i deleguje wywołania.
┌─────────────────┐ implementuje ┌──────────────────┐ │ OrderService │────────────────────►│ SerwisWiadomosci │ └─────────────────┘ └────────┬─────────┘ │ implementuje ┌────────▼─────────┐ │ SmsAdapter │ │ ─────────────── │ │ - smsService │◄── kompozycja │ + wyslijWiad... │ └────────┬─────────┘ │ używa ┌────────▼─────────┐ │ SendSmsService │ │(zewn. biblioteka)│ └──────────────────┘
Adapter klasowy (przez dziedziczenie) — rzadziej używany
Adapter dziedziczy po klasie adaptowanej i implementuje docelowy interfejs. W Javie wymaga wielokrotnego dziedziczenia przez interfejsy — możliwe, ale mniej elastyczne niż kompozycja.
// Adapter klasowy — dziedziczy po adaptowanej klasie
class SmsAdapterKlasowy extends SendSmsService implements SerwisWiadomosci {
private final String nadawca;
public SmsAdapterKlasowy(String nadawca) {
this.nadawca = nadawca;
}
@Override
public void wyslijWiadomosc(String odbiorca, String tresc) {
// Wywołujemy metodę odziedziczoną z SendSmsService
this.send(odbiorca, tresc, nadawca);
}
}
W praktyce preferuj adapter obiektowy (kompozycja) — jest bardziej elastyczny, łatwiejszy do testowania i nie uzależnia się od hierarchii dziedziczenia.
6. Diagram — pełna struktura wzorca
KLIENT INTERFEJS DOCELOWY ADAPTOWANY ───────────────────────────────────────────────────────────── OrderService SerwisWiadomosci ┌──────────────┐ ┌───────────────────┐ │ │──────►│ + wyslijWiadomosc │ │ zlozZamow.() │ └─────────┬─────────┘ └──────────────┘ │ implementuje ┌────────┴──────────┐ │ EmailSerwis │ ← istniejąca implementacja └───────────────────┘ ┌────────────────────┐ │ SmsAdapter │ ← ADAPTER │ ────────────────── │ │ - sendSmsService │──┐ kompozycja │ + wyslijWiadomosc()│ │ └────────────────────┘ │ ▼ ┌─────────────────────┐ │ SendSmsService │ ← zewnętrzna │ ─────────────────── │ biblioteka │ + send(nr, msg, od) │ └─────────────────────┘
7. Dlaczego Adapter jest ważny?
Integracja bez modyfikacji — możemy podłączyć zewnętrzną bibliotekę, stary kod lub system trzeci bez dotykania ani jednej linii istniejącego kodu. To kwintesencja zasady OCP.
Izolacja zależności — szczegóły API zewnętrznej biblioteki są zamknięte w jednej klasie Adaptera. Jeśli biblioteka zmieni API w nowej wersji — aktualizujemy tylko Adapter, nie całą aplikację.
Testowalność — ponieważ Adapter implementuje znany nam interfejs, możemy go zastąpić mockiem w testach jednostkowych tak samo jak każdą inną implementację.
Współpraca z legacy code — w rzeczywistych projektach często pracujemy z kodem, którego nie możemy lub nie chcemy modyfikować. Adapter to elegancka ścieżka integracji bez ryzyka destabilizacji starego systemu.
8. Kiedy stosować?
Adapter ma sens, gdy:
- chcesz użyć zewnętrznej biblioteki, której interfejs nie pasuje do Twojego systemu,
- musisz zintegrować stary kod (legacy), który nie może być zmodyfikowany,
- budujesz system, który ma współpracować z wieloma dostawcami tego samego typu usługi (płatności, wysyłka SMS, logowanie),
- chcesz odizolować resztę aplikacji od szczegółów konkretnej implementacji zewnętrznej.
Adapter w popularnych frameworkach Javy:
- Spring
HandlerAdapter— Spring MVC używa Adapterów, by móc obsługiwać różne typy kontrolerów (@Controller,HttpRequestHandler,Servlet) przez ten sam mechanizm dispatchera, Arrays.asList()iCollections.unmodifiableList()— opakowują tablicę lub kolekcję w inny interfejs,InputStreamReader— adapter międzyInputStream(strumień bajtów) aReader(strumień znaków); tłumaczy bajty na znaki zgodnie z zadanym kodowaniem,- JDBC
ResultSetIterator— różne biblioteki opakowująResultSetwIterator, by móc iterować po wynikach zapytania w standardowy sposób.
Sygnały, że czas wprowadzić Adapter:
- masz klasę, która robi dokładnie to czego potrzebujesz, ale ma inną nazwę metody lub inne parametry,
- chcesz podmienić jedną bibliotekę na inną, ale obydwie mają inne API,
- dodanie nowej integracji wymaga modyfikacji klas, które powinny o niej nie wiedzieć,
- w code review pojawia się pytanie: "dlaczego OrderService zna szczegóły API Stripe'a?"
BEZ ADAPTERA: klient musi znać API zewnętrznej biblioteki → silne powiązanie Z ADAPTEREM: klient zna tylko swój interfejs → biblioteka zamknięta w jednej klasie