Wzorce projektowe to sprawdzone sposoby rozwiązywania powtarzających się problemów w programowaniu. Nie są gotowymi fragmentami kodu do kopiowania, ale raczej opisem pewnej idei: jak zaprojektować klasy i ich współpracę, aby kod był elastyczny, czytelny i łatwiejszy w utrzymaniu.
Jednym z najczęściej spotykanych wzorców strukturalnych jest Adapter. Jego głównym zadaniem jest umożliwienie współpracy klasom, które normalnie nie mogłyby ze sobą działać, ponieważ mają niezgodne interfejsy.
Adapter odpowiada więc na pytanie:
Co zrobić, gdy mam istniejącą klasę z potrzebną funkcjonalnością, ale jej metody nie pasują do tego, czego oczekuje mój kod?
Odpowiedź brzmi: można napisać klasę pośredniczącą, czyli adapter.
Adapter to wzorzec projektowy, który pozwala obiektom o niezgodnych interfejsach współpracować ze sobą. Adapter „opakowuje” istniejący obiekt i udostępnia na zewnątrz taki interfejs, jakiego oczekuje klient.
Brzmi technicznie, więc rozbijmy to na prostsze elementy.
Wyobraź sobie, że masz klasę:
public class OldPrinter {
public void printOldFormat(String text) {
System.out.println("Printing: " + text);
}
}
Ta klasa działa poprawnie. Potrafi drukować tekst. Problem polega na tym, że w nowej części aplikacji oczekujesz obiektu implementującego taki interfejs:
public interface Printer {
void print(String text);
}
Klasa OldPrinter nie implementuje interfejsu Printer, ponieważ jej metoda nazywa się inaczej: printOldFormat(), a nie print().
Masz kilka możliwości:
OldPrinter.Najczęściej najlepszym rozwiązaniem będzie trzecia opcja.
public class OldPrinterAdapter implements Printer {
private final OldPrinter oldPrinter;
public OldPrinterAdapter(OldPrinter oldPrinter) {
this.oldPrinter = oldPrinter;
}
@Override
public void print(String text) {
oldPrinter.printOldFormat(text);
}
}
Teraz możesz używać starej klasy tak, jakby była zgodna z nowym interfejsem:
public class Main {
public static void main(String[] args) {
OldPrinter oldPrinter = new OldPrinter();
Printer printer = new OldPrinterAdapter(oldPrinter);
printer.print("Hello Adapter!");
}
}
Wynik:
Printing: Hello Adapter!
Klasa OldPrinterAdapter jest właśnie adapterem.
W praktyce programistycznej bardzo często spotykamy sytuacje, w których jedna część systemu oczekuje określonego interfejsu, a inna część systemu udostępnia podobną funkcjonalność, ale w innej formie.
Może to wynikać z kilku powodów:
Adapter nie zmienia działania klasy adaptowanej. On jedynie zmienia sposób, w jaki klient się z nią komunikuje.
To bardzo ważne: Adapter nie powinien zawierać skomplikowanej logiki biznesowej. Jego główne zadanie to tłumaczenie jednego interfejsu na drugi.
W klasycznym opisie wzorca Adapter występują następujące elementy:
Target to interfejs, którego oczekuje klient. Innymi słowy, jest to forma, do której chcemy dopasować istniejącą klasę.
Przykład:
public interface MediaPlayer {
void play(String fileName);
}
Adaptee to istniejąca klasa, która ma potrzebną funkcjonalność, ale nie pasuje do oczekiwanego interfejsu.
public class AdvancedAudioPlayer {
public void playMp3File(String fileName) {
System.out.println("Playing MP3 file: " + fileName);
}
}
Adapter to klasa pośrednicząca. Implementuje interfejs oczekiwany przez klienta i wewnętrznie korzysta z klasy adaptowanej.
public class AudioPlayerAdapter implements MediaPlayer {
private final AdvancedAudioPlayer advancedAudioPlayer;
public AudioPlayerAdapter(AdvancedAudioPlayer advancedAudioPlayer) {
this.advancedAudioPlayer = advancedAudioPlayer;
}
@Override
public void play(String fileName) {
advancedAudioPlayer.playMp3File(fileName);
}
}
Client to kod, który korzysta z interfejsu Target, nie wiedząc, że pod spodem znajduje się adapter.
public class Main {
public static void main(String[] args) {
MediaPlayer player = new AudioPlayerAdapter(new AdvancedAudioPlayer());
player.play("song.mp3");
}
}
Załóżmy, że tworzymy aplikację sklepu internetowego. Nasz system oczekuje, że każda metoda płatności będzie implementowała taki interfejs:
public interface PaymentProcessor {
void pay(double amount);
}
Mamy nowoczesną klasę płatności kartą:
public class CardPaymentProcessor implements PaymentProcessor {
@Override
public void pay(double amount) {
System.out.println("Paid by card: " + amount + " PLN");
}
}
Ale dodatkowo chcemy użyć starej klasy obsługującej przelewy bankowe:
public class LegacyBankTransfer {
public void makeTransfer(BigDecimal value) {
System.out.println("Bank transfer completed: " + value + " PLN");
}
}
Problem: LegacyBankTransfer nie implementuje PaymentProcessor.
Nie chcemy zmieniać starej klasy, bo może pochodzić z zewnętrznej biblioteki albo być używana w innych miejscach systemu. Tworzymy więc adapter:
import java.math.BigDecimal;
public class BankTransferAdapter implements PaymentProcessor {
private final LegacyBankTransfer legacyBankTransfer;
public BankTransferAdapter(LegacyBankTransfer legacyBankTransfer) {
this.legacyBankTransfer = legacyBankTransfer;
}
@Override
public void pay(double amount) {
BigDecimal value = BigDecimal.valueOf(amount);
legacyBankTransfer.makeTransfer(value);
}
}
Teraz możemy traktować przelew bankowy jak każdą inną metodę płatności:
public class ShopApplication {
public static void main(String[] args) {
PaymentProcessor cardPayment = new CardPaymentProcessor();
PaymentProcessor bankTransfer = new BankTransferAdapter(new LegacyBankTransfer());
cardPayment.pay(150.00);
bankTransfer.pay(299.99);
}
}
Wynik:
Paid by card: 150.0 PLN
Bank transfer completed: 299.99 PLN
Klient, czyli kod sklepu, nie musi wiedzieć, że LegacyBankTransfer ma inną metodę, inny typ argumentu i pochodzi ze starszego systemu.
Wzorzec Adapter występuje najczęściej w dwóch odmianach:
W Javie zdecydowanie częściej stosuje się adapter obiektowy.
Adapter obiektowy przechowuje wewnątrz siebie referencję do klasy adaptowanej.
Przykład:
public class BankTransferAdapter implements PaymentProcessor {
private final LegacyBankTransfer legacyBankTransfer;
public BankTransferAdapter(LegacyBankTransfer legacyBankTransfer) {
this.legacyBankTransfer = legacyBankTransfer;
}
@Override
public void pay(double amount) {
legacyBankTransfer.makeTransfer(BigDecimal.valueOf(amount));
}
}
To rozwiązanie jest bardzo elastyczne, ponieważ adapter nie dziedziczy po klasie adaptowanej. Zamiast tego korzysta z niej przez pole.
Zalety adaptera obiektowego:
final,To właśnie ten wariant najczęściej zobaczysz w prawdziwych projektach.
Adapter klasowy korzysta z dziedziczenia. W niektórych językach można byłoby zrobić coś takiego:
public class SomeAdapter extends LegacyClass implements TargetInterface {
// ...
}
W Javie jest to ograniczone, ponieważ Java nie obsługuje wielodziedziczenia klas. Klasa może dziedziczyć tylko po jednej klasie, ale może implementować wiele interfejsów.
Przykład uproszczony:
public class LegacyLogger {
public void logOld(String message) {
System.out.println("OLD LOG: " + message);
}
}
Interfejs docelowy:
public interface Logger {
void log(String message);
}
Adapter klasowy:
public class LegacyLoggerAdapter extends LegacyLogger implements Logger {
@Override
public void log(String message) {
logOld(message);
}
}
Użycie:
public class Main {
public static void main(String[] args) {
Logger logger = new LegacyLoggerAdapter();
logger.log("Application started");
}
}
Ten wariant działa, ale jest mniej elastyczny. Adapter jest na stałe powiązany z klasą bazową. Jeżeli potrzebujesz adaptować różne obiekty albo wstrzykiwać zależności, lepszy będzie adapter obiektowy.
Wzorzec Adapter nie jest tylko teorią z książek. W Javie występuje bardzo często.
Dobrym przykładem są klasy z pakietu java.io.
InputStream służy do odczytu bajtów.
InputStream inputStream = System.in;
Problem polega na tym, że bajty nie zawsze są wygodne. Często chcemy czytać znaki, czyli tekst.
Do tego służy Reader.
Klasa InputStreamReader jest adapterem między światem bajtów a światem znaków.
InputStreamReader reader = new InputStreamReader(System.in);
Można powiedzieć, że:
InputStream dostarcza bajty,Reader oczekuje pracy na znakach,InputStreamReader tłumaczy jedno na drugie.Jeszcze częściej używamy tego w połączeniu z BufferedReader:
BufferedReader reader = new BufferedReader(
new InputStreamReader(System.in)
);
String line = reader.readLine();
System.out.println(line);
Tutaj mamy kilka warstw:
System.in — wejście bajtowe,InputStreamReader — adapter bajtów na znaki,BufferedReader — dekorator dodający wygodne buforowanie i metodę readLine().To bardzo praktyczny przykład pokazujący, że wzorce projektowe często współpracują ze sobą. InputStreamReader pełni rolę adaptera, a BufferedReader jest bliższy wzorcowi Dekorator.
Podobnie działa OutputStreamWriter.
OutputStream zapisuje bajty:
OutputStream outputStream = new FileOutputStream("file.txt");
Ale często chcemy zapisywać tekst, czyli znaki. Do tego mamy klasę Writer.
OutputStreamWriter pozwala pisać znaki do strumienia bajtowego:
Writer writer = new OutputStreamWriter(
new FileOutputStream("file.txt")
);
writer.write("Cześć Java!");
writer.close();
Adapter wykonuje za nas konwersję znaków na bajty z użyciem odpowiedniego kodowania.
W praktycznym kodzie warto jawnie podawać kodowanie, na przykład UTF-8:
Writer writer = new OutputStreamWriter(
new FileOutputStream("file.txt"),
StandardCharsets.UTF_8
);
Dzięki temu unikamy problemów z polskimi znakami.
Wzorzec Adapter bardzo dobrze wpisuje się w zasady SOLID, szczególnie w dwie z nich.
Zasada otwarte-zamknięte mówi, że klasy powinny być otwarte na rozszerzanie, ale zamknięte na modyfikację.
Adapter pozwala dodać nową zgodność bez zmieniania istniejących klas.
Nie musimy modyfikować LegacyBankTransfer. Nie musimy też zmieniać kodu sklepu, który oczekuje PaymentProcessor. Dodajemy tylko nową klasę BankTransferAdapter.
Kod wysokiego poziomu powinien zależeć od abstrakcji, a nie od konkretnych klas.
Jeżeli nasz sklep używa interfejsu:
PaymentProcessor processor;
to nie obchodzi go, czy pod spodem jest płatność kartą, przelew bankowy, PayPal, BLIK czy adapter starego systemu.
Adapter pozwala ukryć szczegóły techniczne i dopasować niezgodne klasy do abstrakcji używanej w aplikacji.
Adapter bywa mylony z kilkoma innymi wzorcami.
Dekorator również opakowuje obiekt, ale jego cel jest inny.
Adapter zmienia interfejs obiektu.
Dekorator dodaje nowe zachowanie bez zmiany interfejsu.
Przykład:
Reader reader = new InputStreamReader(System.in);
BufferedReader bufferedReader = new BufferedReader(reader);
InputStreamReader adaptuje InputStream do Reader.
BufferedReader rozszerza możliwości Reader, dodając buforowanie i metodę readLine().
Fasada upraszcza dostęp do skomplikowanego systemu. Może ukrywać wiele klas za jednym prostym interfejsem.
Adapter zwykle dopasowuje jeden interfejs do drugiego.
Fasada odpowiada na pytanie:
Jak uprościć korzystanie ze złożonego podsystemu?
Adapter odpowiada na pytanie:
Jak sprawić, żeby niezgodne interfejsy mogły ze sobą współpracować?
Proxy kontroluje dostęp do innego obiektu. Może dodawać leniwe ładowanie, autoryzację, logowanie, cache albo komunikację zdalną.
Adapter nie służy głównie do kontroli dostępu, tylko do dopasowania interfejsów.
Warto użyć Adaptera, gdy:
Przykład z życia zawodowego: masz aplikację, która przez lata korzystała ze starego systemu wysyłki SMS. Teraz chcesz przejść na nowego dostawcę, ale nie chcesz zmieniać całej logiki biznesowej. Możesz stworzyć wspólny interfejs:
public interface SmsSender {
void sendSms(String phoneNumber, String message);
}
Następnie możesz przygotować adaptery:
public class OldSmsProviderAdapter implements SmsSender {
// adaptuje stary system
}
public class NewSmsProviderAdapter implements SmsSender {
// adaptuje nowego dostawcę
}
Kod aplikacji używa tylko SmsSender. Nie wie, który dostawca działa pod spodem.
Adapter jest bardzo przydatny, ale nie należy używać go na siłę.
Nie warto stosować Adaptera, gdy:
Adapter powinien być cienką warstwą dopasowującą. Jeżeli zaczyna robić bardzo dużo rzeczy, może to oznaczać, że w projekcie brakuje lepszego podziału odpowiedzialności.
Najważniejsze zalety Adaptera to:
Adapter często jest bardzo praktycznym wzorcem w realnych projektach, szczególnie tam, gdzie system żyje wiele lat i musi współpracować z różnymi technologiami.
Adapter ma też swoje minusy:
W praktyce największym zagrożeniem nie jest sam Adapter, ale jego nadużywanie.
Pisząc adaptery w Javie, warto pamiętać o kilku zasadach.
Nie wkładaj do adaptera złożonej logiki biznesowej. Jego zadaniem jest dopasowanie interfejsu.
Dobrze:
@Override
public void pay(double amount) {
legacyBankTransfer.makeTransfer(BigDecimal.valueOf(amount));
}
Podejrzanie:
@Override
public void pay(double amount) {
validateCustomer();
calculateDiscount();
checkFraudRules();
saveInvoice();
sendEmail();
legacyBankTransfer.makeTransfer(BigDecimal.valueOf(amount));
}
Taki kod robi zbyt wiele.
Adapter ma największy sens wtedy, gdy reszta aplikacji zależy od abstrakcji.
private final PaymentProcessor paymentProcessor;
zamiast:
private final LegacyBankTransfer legacyBankTransfer;
W Javie najczęściej lepszy jest adapter obiektowy:
private final LegacyBankTransfer legacyBankTransfer;
niż adapter oparty na dziedziczeniu.
Nazwy typu:
BankTransferAdapter
OldPrinterAdapter
ExternalSmsProviderAdapter
są czytelne i od razu pokazują intencję klasy.
Adaptery są świetnymi miejscami do testów jednostkowych, ponieważ często odpowiadają za mapowanie danych.
Przykład: adapter płatności może zamieniać double na BigDecimal, kod waluty, identyfikator klienta albo format wiadomości wymagany przez zewnętrzne API. Warto sprawdzić, czy robi to poprawnie.
Wzorzec projektowy Adapter jest jednym z najbardziej praktycznych wzorców w programowaniu obiektowym. Jego zadaniem jest umożliwienie współpracy klasom, które mają niezgodne interfejsy.
Adapter działa jak tłumacz. Klient mówi jednym językiem, klasa adaptowana rozumie inny język, a adapter stoi pomiędzy nimi i przekłada wywołania metod.
W Javie spotykamy Adapter bardzo często, nawet jeśli na co dzień nie zawsze nazywamy go wzorcem. Klasy takie jak InputStreamReader i OutputStreamWriter są świetnymi przykładami adapterów w standardowej bibliotece Javy. Pierwsza pozwala czytać znaki ze strumienia bajtowego, druga pozwala zapisywać znaki do strumienia bajtowego.
Najważniejsza myśl do zapamiętania:
Adapter pozwala wykorzystać istniejącą klasę w miejscu, gdzie oczekiwany jest inny interfejs.
To wzorzec szczególnie użyteczny podczas integracji, pracy ze starym kodem, korzystania z bibliotek zewnętrznych oraz projektowania aplikacji zgodnych z zasadami SOLID.
Dobrze użyty Adapter sprawia, że kod jest bardziej elastyczny, mniej zależny od konkretnych implementacji i łatwiejszy w dalszym rozwoju. źle użyty może jednak tylko zwiększyć liczbę klas i ukryć problemy architektoniczne. Dlatego warto stosować go świadomie: wtedy, gdy rzeczywiście trzeba dopasować jeden interfejs do drugiego.
Programując w Java nie zwalniasz pamięci ręcznie. Nie wywołujesz free(), nie martwisz się o wskaźniki. A jednak aplikacja działa stabilnie, obiekty znikają, a pamięć się nie „zapcha”.
Za kulisami pracuje …
W ramach powtórki poniżej przedstawiam wam listę kolekcji i interfejsów oraz wyjaśnię jak działa proces iteracji po elementach Set i Map, które nia maja określonego porzadku …
Na pierwszy rzut oka obiekt w Javie wydaje się czymś prostym: tworzysz go, używasz i… zapominasz. W praktyce jednak każdy obiekt przechodzi kilka wyraźnych etapów, a ich zrozumienie pomogło mi …
W Javie obiekty nie pojawiają się znikąd. Zanim zaczną „żyć” w pamięci programu, muszą zostać poprawnie zainicjalizowane. Właśnie w tym momencie do gry wchodzą konstruktory – specjalne metody odpowiedzialne …
Komentarze (0)
Nie dodano jeszcze żadnych komentarzy
Dodaj nowy komentarz: