Wzorzec Adapter zamienia interfejs jednej klasy na interfejs, którego oczekuje klient. Dzięki temu dwie części kodu mogą współpracować, choć powstały z innymi założeniami. Adapter stoi między nimi, tłumaczy wywołania i nie zmienia żadnej ze stron. W C++ biblioteka standardowa używa tego wzorca często, choć nazywa go "adaptorem".
Problem
Masz przydatną klasę albo bibliotekę C z właściwym zachowaniem, ale o złym kształcie. Twój kod oczekuje read_celsius(), a biblioteka daje legacy_read_fahrenheit(handle, &value). Zmiana którejkolwiek strony jest kosztowna albo niemożliwa. Kopiowanie zachowania do nowej klasy dubluje logikę i zostawia dwie wersje do utrzymania.
Adapter opakowuje niezgodny obiekt i wystawia interfejs, którego już używasz. Klient dalej wywołuje tę samą metodę. Adaptowany obiekt zachowuje swój interfejs. Tylko opakowanie zna obie strony.
Typowe przypadki:
- biblioteka zewnętrzna, której nie możesz edytować
- stary interfejs C z uchwytami i kodami błędów
- komponenty biblioteki standardowej, których interfejs nie pasuje do twojej pętli, na przykład
std::reverse_iteratoralbostd::back_inserter - dwa podsystemy, które nie powinny włączać swoich nagłówków
Struktura
| Rola | Odpowiedzialność |
|---|---|
| Target | Interfejs, którego już używa klient |
| Client | Kod wywołujący interfejs docelowy |
| Adaptee | Istniejąca klasa albo API z niezgodnym interfejsem |
| Adapter | Tłumaczy wywołania docelowe na wywołania adaptowanego obiektu |
Adapter obiektowy i klasowy
Adapter obiektowy trzyma referencję albo wskaźnik do adaptowanego obiektu i przekazuje wywołania. W C++ to zwykle lepszy wybór. Działa z klasami oznaczonymi final, nie wystawia implementacji obiektu i pozwala podmienić go w czasie wykonania.
class Target {
public:
virtual ~Target() = default;
virtual void request() = 0;
};
class Adaptee {
public:
void specific_request() { /* ... */ }
};
class ObjectAdapter : public Target {
public:
explicit ObjectAdapter(Adaptee& adaptee) : adaptee_(adaptee) {}
void request() override {
adaptee_.specific_request();
}
private:
Adaptee& adaptee_;
};
Adapter klasowy dziedziczy jednocześnie po interfejsie docelowym i po klasie adaptowanej. Oszczędza jedno pośrednictwo, ale wymaga wielokrotnego dziedziczenia, nie działa z klasą final i wiąże adapter z chronionymi składowymi obiektu. Sięgaj po niego tylko wtedy, gdy to dziedziczenie naprawdę coś daje.
class ClassAdapter : public Target, private Adaptee {
public:
void request() override {
specific_request();
}
};
Stary interfejs C
Załóżmy, że aplikacja pracuje z takim interfejsem:
#include <iostream>
#include <memory>
#include <stdexcept>
class Sensor {
public:
virtual ~Sensor() = default;
virtual double read_celsius() const = 0;
};
Producent czujnika dostarcza API w C, które zwraca stopnie Fahrenheita przez nieprzezroczysty uchwyt:
extern "C" {
struct legacy_sensor;
legacy_sensor* legacy_open(const char* device);
void legacy_close(legacy_sensor* sensor);
int legacy_read_fahrenheit(legacy_sensor* sensor, double* out);
}
Adapter przejmuje uchwyt, sprawdza kod błędu i przelicza jednostkę:
class LegacySensorAdapter : public Sensor {
public:
explicit LegacySensorAdapter(const char* device)
: handle_(legacy_open(device), &legacy_close) {
if (!handle_) {
throw std::runtime_error("nie można otworzyć czujnika");
}
}
double read_celsius() const override {
double fahrenheit = 0.0;
if (legacy_read_fahrenheit(handle_.get(), &fahrenheit) != 0) {
throw std::runtime_error("odczyt czujnika nie powiódł się");
}
return (fahrenheit - 32.0) * 5.0 / 9.0;
}
private:
std::unique_ptr<legacy_sensor, void (*)(legacy_sensor*)> handle_;
};
Kod klienta nie potrzebuje nagłówka C, uchwytu ani przeliczenia:
void log_temperature(const Sensor& sensor) {
std::cout << sensor.read_celsius() << " C\n";
}
int main() {
LegacySensorAdapter sensor("/dev/sensor0");
log_temperature(sensor);
}
To jest wzorzec w swojej najlepszej postaci: mała klasa, która trzyma szczegół dostawcy z dala od reszty programu.
Adapter w bibliotece standardowej C++
Biblioteka standardowa używa adapterów w trzech miejscach.
Adaptory kontenerów dają istniejącemu kontenerowi sekwencyjnemu węższy interfejs. std::stack<T> domyślnie używa std::deque<T> i wystawia tylko push, pop, top, empty oraz size. std::queue i std::priority_queue działają tak samo. Możesz wybrać inny kontener jako argument szablonu, ale interfejs nadal należy do adaptora. Zobacz Adaptory kontenerów.
Adaptery iteratorów opakowują iterator i zmieniają jego zachowanie. std::reverse_iterator odwraca kierunek przechodzenia. std::back_insert_iterator zamienia przypisanie na push_back. std::move_iterator sprawia, że dereferencja daje referencję do rvalue. Iteratory strumieniowe przedstawiają strumień jako iterator. Zobacz Adaptery iteratorów.
Adaptery obiektów wywoływalnych zmieniają kształt funkcji. std::bind uzupełnia albo przestawia argumenty. std::mem_fn zamienia wskaźnik do składowej w obiekt wywoływalny. std::not_fn neguje predykat. std::function ukrywa typ obiektu wywoływalnego za stałą sygnaturą. Zobacz Adaptery obiektów wywoływalnych.
Kiedy używać Adaptera
Używaj Adaptera, gdy nie możesz zmienić żadnej ze stron, a granica między nimi potrzebuje nazwy. Pomaga też, gdy kilka niezgodnych API ma wyglądać tak samo dla reszty kodu albo gdy nagłówki dostawcy nie powinny trafiać do typów domenowych.
Unikaj go, gdy kontrolujesz oba interfejsy. Zmień interfejs. Jednolinijkowy adapter, który tylko przekazuje wywołanie i niczego nie tłumaczy, jest zwykle szumem. Jeśli opakowanie dodaje zachowanie zamiast dopasowywać interfejs, chcesz Decorator. Jeśli upraszcza cały podsystem, chcesz Facade.
Kompromisy
| Aspekt | Korzyść | Koszt |
|---|---|---|
| Dopasowanie interfejsów | Istniejący kod i biblioteki współpracują bez zmian | Jeden typ i jedno pośrednictwo więcej |
| Izolacja | Nagłówki i konwencje dostawcy zostają w jednym miejscu | API adaptera może ukryć przydatne funkcje |
| Testowanie | Łatwo podstawić fałszywy obiekt adaptowany | Sam adapter wymaga testów konwersji i błędów |
| Własność | Adapter może posiadać, pożyczać albo referencować obiekt | Zasady czasu życia muszą być jawne |
| Składanie | Kilka adapterów ujednolica kilka API | Zbyt wiele cienkich opakowań komplikuje graf wywołań |
Powiązane wzorce
- Decorator ma taki sam kształt opakowania, ale dodaje zachowanie zamiast dopasowywać interfejs.
- Facade upraszcza zbiór interfejsów. Adapter dopasowuje jeden konkretny interfejs docelowy.
- Proxy wystawia ten sam interfejs co opakowany obiekt i kontroluje dostęp.
- Bridge rozdziela abstrakcję od implementacji z góry. Adapter naprawia niezgodność po fakcie.
- Strategy wymienia algorytmy. Adapter wymienia interfejsy.
- Iterator jest często klientem adaptera iteratora.
Podsumowanie
Adapter jest mały i nieefektowny, i właśnie dlatego działa. Zarabia na siebie, gdy nie możesz zmienić żadnej ze stron, a tłumaczenie musi gdzieś mieszkać. Jeśli możesz zmienić interfejs, zrób to. Adaptory kontenerów, iteratorów i obiektów wywoływalnych w bibliotece standardowej pokazują wzorzec w najlepszej formie: cienkie opakowania, które dopasowują istniejące komponenty do protokołu bez kopiowania ich logiki.