Введение
Открываем блок структурных паттернов темой Adapter — одним из самых практически частых паттернов, решающим простую, но регулярно встречающуюся задачу: использование класса с интерфейсом, несовместимым с тем, что ожидает остальной код (например, готовая сторонняя библиотека или унаследованный, давно написанный код, статья 511 цикла синтаксиса про разделение объявления/определения), без изменения исходного кода этого несовместимого класса.
Концепция
Adapter оборачивает существующий класс (с «неудобным» для текущего использования интерфейсом, называемый adaptee) в новый класс, реализующий ОЖИДАЕМЫЙ остальным кодом интерфейс, и внутри методов этого нового класса вызывающий соответствующие методы оборачиваемого класса, преобразуя при необходимости параметры и результат — это позволяет использовать несовместимый класс там, где ожидается определённый, конкретный интерфейс, без изменения исходного кода самого несовместимого класса (который может быть, например, частью стороннней библиотеки, недоступной для модификации). Существует два классических варианта реализации — Adapter через композицию (adapter хранит экземпляр adaptee как поле) и Adapter через множественное наследование (adapter одновременно наследует и от ожидаемого интерфейса, и от adaptee) — композиция обычно предпочтительнее в C++ из-за известных сложностей множественного наследования.
Пример кода
#include <iostream>
#include <string>
// Существующий, "неудобный" класс — например, из старой, унаследованной библиотеки логирования
class LegacyLogger
{
public:
void writeLogEntry(const char *message, int severityLevel) // C-стиль интерфейса, severity как число
{
std::cout << "[Legacy severity=" << severityLevel << "] " << message << std::endl;
}
};
// ОЖИДАЕМЫЙ остальным кодом современный интерфейс
class ILogger
{
public:
virtual void info(const std::string &message) = 0;
virtual void error(const std::string &message) = 0;
virtual ~ILogger() = default;
};
// Adapter — согласует несовместимые интерфейсы через КОМПОЗИЦИЮ
class LegacyLoggerAdapter : public ILogger
{
public:
explicit LegacyLoggerAdapter(LegacyLogger &legacy) : m_legacy(legacy) {}
void info(const std::string &message) override
{
m_legacy.writeLogEntry(message.c_str(), 1); // преобразование std::string -> const char*, число severity
}
void error(const std::string &message) override
{
m_legacy.writeLogEntry(message.c_str(), 3);
}
private:
LegacyLogger &m_legacy;
};
void processWithModernLogger(ILogger &logger) // код, написанный для современного интерфейса
{
logger.info("Обработка начата");
logger.error("Произошла ошибка");
}
int main()
{
LegacyLogger legacy;
LegacyLoggerAdapter adapter(legacy);
processWithModernLogger(adapter); // СТАРЫЙ LegacyLogger используется через СОВРЕМЕННЫЙ интерфейс!
return 0;
}
Пояснения к коду
LegacyLoggerAdapter, наследующий от ILogger и хранящий ссылку на LegacyLogger как поле (композиция), показывает классическую реализацию — каждый метод адаптера (info/error) преобразует вызов в ожидаемом современном формате в соответствующий вызов устаревшего, несовместимого по сигнатуре метода writeLogEntry (с явным преобразованием std::string в const char* через .c_str(), и заменой семантически осмысленного уровня важности на «магическое» число, ожидаемое старым API). processWithModernLogger(ILogger &logger) показывает практическую ценность — этот код написан исключительно против современного, «чистого» интерфейса ILogger, и благодаря Adapter может работать с устаревшим LegacyLogger без какого-либо изменения собственной логики, не зная и не заботясь о том, что «под капотом» вызовы в итоге попадают в старую, несовместимую по интерфейсу реализацию.
Подводные камни
- Использование Adapter для исправления плохо спроектированного, но контролируемого разработчиком интерфейса (когда вместо адаптера было бы проще и правильнее напрямую улучшить, отрефакторить сам проблемный класс) — Adapter оправдан именно для интеграции с кодом, который РЕАЛЬНО недоступен для модификации (сторонние библиотеки, физически унаследованный, не подлежащий изменению легаси-код), и для собственного, контролируемого кода прямое улучшение интерфейса обычно предпочтительнее введения дополнительного слоя адаптации.
- Накопление множества цепочек Adapter поверх Adapter для согласования нескольких слоёв несовместимых интерфейсов одновременно — каждый дополнительный слой адаптации добавляет накладные расходы (дополнительные виртуальные вызовы) и усложняет понимание реального потока выполнения, и при обнаружении необходимости в нескольких последовательных адаптерах стоит рассмотреть, не является ли это признаком более глубокой архитектурной проблемы, заслуживающей более прямого решения, а не дальнейшего наслоения адаптеров.
- Потеря или неверное преобразование семантики при адаптации интерфейсов с принципиально разной моделью данных (как
severityLevelчисло в примере вместо именованных уровней важности, про enum) — преобразование между несовместимыми моделями данных не всегда тривиально и взаимно однозначно, и адаптер, скрывающий некорректное или неполное преобразование семантики, может создать иллюзию полной совместимости там, где реально присутствует частичная потеря информации или смысла при переходе между двумя разными моделями. - Использование множественного наследования для Adapter без действительно веской причины предпочесть его композиции — хотя классический GoF паттерн описывает и вариант через наследование, в C++ композиция (как в примере с
LegacyLoggerAdapter) обычно предпочтительнее из-за известной сложности и потенциальных проблем множественного наследования (ромбовидная проблема), и явный, осознанный выбор композиции как предпочтительного по умолчанию подхода для Adapter в C++ — общепринятая, рекомендуемая практика.