Адаптер (Adapter)
Даёт старому или чужому классу тот интерфейс, которого ждёт клиент, не меняя ни класс, ни клиента.
Проблема
Термостат теплицы написан против контракта TemperatureSensor: метод celsius() возвращает градусы Цельсия. На объект поставили старый датчик, и его драйвер LegacyThermometer умеет только readTenthsFahrenheit(): целое число десятых долей градуса Фаренгейта, 770 вместо 25 °C. Драйвер чужой, его нельзя поправить. Если научить термостат понимать оба датчика, в нём появятся if по типу устройства и формулы пересчёта, а на третьем датчике это станет кашей.
Старый класс в примере совсем маленький: значение приходит в конструкторе, метод его отдаёт. В жизни это драйвер, SDK или клиент внешнего API, но суть та же: полезное поведение есть, форма неподходящая.
Решение
Поставить между ними адаптер FahrenheitAdapter: он реализует TemperatureSensor, внутри держит LegacyThermometer и в celsius() переводит десятые доли Фаренгейта в Цельсий. Термостат получает обычный датчик и про старый драйвер не знает, драйвер не знает про термостат.
Адаптер не добавляет поведения, он только переводит: имена методов, единицы, форматы, коды ошибок. Если в нём появляется логика, которой нет ни у старого класса, ни в контракте, это уже другой приём.
Структура
Целевой интерфейс — то, что нужно клиенту, в его словах и единицах. Он объявлен рядом с клиентом, а не рядом со старым классом: контракт диктует тот, кто им пользуется. Это объектный адаптер (композиция). Классовый адаптер через наследование от старого класса тоже возможен, но привязывает к одной реализации и не работает с final классами.
Как это собирается
Адаптер выбирает не термостат, а код, который собирает систему при запуске. В примере это функция sensorFor: по типу устройства из реестра зон она отдаёт родной датчик, FahrenheitAdapter вокруг LegacyThermometer или ошибку "неизвестный датчик". Дальше каждая зона получает свой Thermostat, и он работает одинаково со всеми датчиками: читает celsius() и решает, греть или нет.
Зона с незнакомым устройством не роняет запуск: вызывающий код ловит ошибку, пишет skipped и идёт к следующей зоне. В приложении реестр живёт в базе или конфиге, а sensorFor - в DI-контейнере или фабрике драйверов: новый тип датчика добавляется одной веткой там, термостат не меняется.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Клиент Thermostat получает датчик в конструкторе и сравнивает показание с целевой температурой. В нём нет ни одного упоминания Фаренгейта: поэтому его можно проверить любым датчиком, а старый подключается без правки клиента.
| Язык | Особенность |
|---|---|
| PHP | Классический объектный адаптер: final readonly класс реализует интерфейс и держит старый объект |
| Go | Интерфейсы неявные: адаптеру достаточно метода Celsius(). Второй вариант — функциональный тип SensorFunc, как http.HandlerFunc |
| Python | Контракт — Protocol, адаптер — frozen dataclass без наследования: подходит любой объект с celsius() |
| TypeScript | Структурная типизация: адаптер — функция fahrenheitAdapter, возвращающая объектный литерал |
Запуск: make test T=patterns/structural/adapter
Плюсы и минусы
Тесты проверяют три вещи. Адаптер переводит показания: таблица из четырёх точек от замерзания до кипения. Термостат через адаптер работает со старым датчиком так же, как с родным: 15 °C ниже цели — греть, 25 °C — нет. И термостат знает только контракт: в тесте ему подсовывают датчик из трёх строк, без всякого старого драйвера.
- ✅ Старый или чужой код подключается без правки: ни клиент, ни старый класс не меняются.
- ✅ Перевод форматов собран в одном месте и тестируется отдельно.
- ✅ Клиент пишется против своего контракта и проверяется подделкой.
- ❌ Ещё один класс на каждую пару «контракт - старый API».
- ❌ Адаптер легко превратить в свалку: кэш, повторы, логирование. Это уже Декоратор или Фасад.
Когда не применять
- Старый класс можно поправить: переименовать метод проще, чем держать прослойку.
- Интерфейсы уже совпадают с точностью до имени: в Go, Python и TypeScript подойдёт сам объект или функция.
- Нужно не перевести, а упростить большой API: это Фасад.
Отличия между языками
- PHP — интерфейсы номинальные: старый класс не реализует
TemperatureSensor, даже если бы метод назывался так же. Без адаптера не обойтись, и это классический вид приёма. - Go — интерфейс удовлетворяется неявно, поэтому адаптер нужен только когда методы различаются. Если поведение — одна функция, хватает типа
SensorFuncс методомCelsius(): функция становится датчиком, как обычная функция становитсяhttp.Handlerчерезhttp.HandlerFunc. - Python —
Protocolпроверяется только статически, в рантайме работает утиная типизация. Адаптер — обычный класс без наследования, а в тестах подойдёт любой объект с методомcelsius(). - TypeScript — структурные типы делают адаптер функцией:
fahrenheitAdapter(legacy)возвращает{ celsius: () => ... }, и этого объекта достаточно. Класс нужен, только если у адаптера есть своё состояние.
Практика
Учебный адаптер переводит одно число в другое. В жизни адаптер прячет целый чужой протокол: форматы входных данных, коды ошибок, повторяемые и окончательные сбои, а клиент принимает решения уже по своим понятиям. Практика: SMS через старый шлюз подключает старый SMS-шлюз к интерфейсу уведомлений, переводит номера телефонов и числовые коды ответа и показывает, как тестировать всё это с подменённым шлюзом.
Связанные темы
- Практика: SMS через старый шлюз — адаптер, который прячет форматы номеров и коды ошибок
- Стратегия (Strategy) — клиент так же пишется против интерфейса; адаптер — способ подключить к нему чужую реализацию