handbookНаписать

patterns/structural/adapter/README.md · тема · 4 языка · 27 тестов

+ практика: SMS через старый шлюз

Адаптер (Adapter) ​

Даёт старому или чужому классу тот интерфейс, которого ждёт клиент, не меняя ни класс, ни клиента.

Проблема ​

Термостат теплицы написан против контракта TemperatureSensor: метод celsius() возвращает градусы Цельсия. На объект поставили старый датчик, и его драйвер LegacyThermometer умеет только readTenthsFahrenheit(): целое число десятых долей градуса Фаренгейта, 770 вместо 25 °C. Драйвер чужой, его нельзя поправить. Если научить термостат понимать оба датчика, в нём появятся if по типу устройства и формулы пересчёта, а на третьем датчике это станет кашей.

Старый класс в примере совсем маленький: значение приходит в конструкторе, метод его отдаёт. В жизни это драйвер, SDK или клиент внешнего API, но суть та же: полезное поведение есть, форма неподходящая.

Решение ​

Поставить между ними адаптер FahrenheitAdapter: он реализует TemperatureSensor, внутри держит LegacyThermometer и в celsius() переводит десятые доли Фаренгейта в Цельсий. Термостат получает обычный датчик и про старый драйвер не знает, драйвер не знает про термостат.

Адаптер не добавляет поведения, он только переводит: имена методов, единицы, форматы, коды ошибок. Если в нём появляется логика, которой нет ни у старого класса, ни в контракте, это уже другой приём.

Структура ​

uses

wraps

«interface»

TemperatureSensor

+celsius() : float

Thermostat

-sensor: TemperatureSensor

-targetCelsius: float

+shouldHeat() : bool

LegacyThermometer

+readTenthsFahrenheit() : int

FahrenheitAdapter

-legacy: LegacyThermometer

+celsius() : float

Целевой интерфейс — то, что нужно клиенту, в его словах и единицах. Он объявлен рядом с клиентом, а не рядом со старым классом: контракт диктует тот, кто им пользуется. Это объектный адаптер (композиция). Классовый адаптер через наследование от старого класса тоже возможен, но привязывает к одной реализации и не работает с 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-шлюз к интерфейсу уведомлений, переводит номера телефонов и числовые коды ответа и показывает, как тестировать всё это с подменённым шлюзом.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS