Предисловие
Данная статья является авторским переводом с английского собственной статьи под названием God Adapter. Вы также можете посмотреть видео выступления с конференции C++ Russia.
1 Аннотация
В статье представлен специальный адаптер, который позволяет оборачивать любой объект в другой с дополнением необходимой функциональности. Адаптированные объекты имеют один и тот же интерфейс, поэтому они полностью прозрачны с точки зрения использования. Будет последовательно введена общая концепция, использующая простые, но мощные и интересные примеры.
2 Введение
ПРЕДУПРЕЖДЕНИЕ. Почти все методы, указанные в статье, содержат грязные хаки и ненормальное использование языка C++. Так что, если вы не толерантны к таким извращениям, пожалуйста, не читайте эту статью.
Термин универсальный адаптер происходит от возможности универсальным образом добавить необходимое поведение для любого объекта.
3 Постановка задачи
Давным давно я представил концепцию умного мьютекса для упрощения доступа к общим данным. Идея была простой: связать мьютекс с данными и автоматически вызывать lock и unlock при каждом доступе к данным. Код выглядит следующим образом:
struct Data { int get() const { return val_; } void set(int v) { val_ = v; } private: int val_ = 0; }; // создаем экземпляр умного мьютекса SmartMutex<Data> d; // устанавливаем значение, автоматически блокируя и разблокируя мьютекс d->set(4); // получение значения std::cout << d->get() << std::endl;
Но в этом подходе есть несколько проблем.
3.1 Время блокировки
Блокировка держится в течении всего времени выполнения текущего выражения. Рассмотрим следующую строку:
std::cout << d->get() << std::endl;
Разблокировка вызывается после завершения выполнения всего выражения, включая вывод в std::cout. Это ненужная трата времени, что значительно увеличивает время ожидания при взятии блокировки.
3.2 Возможность взаимной блокировки
Как следствие первой проблемы, существует возможность взаимной блокировки из-за неявного механизма блокировки и длительного времени блокировки при выполнении текущего выражения. Рассмотрим следующий фрагмент кода:
int sum(const SmartMutex<Data>& x, const SmartMutex<Data>& y) { return x->get() + y->get(); }
Совершенно неочевидно, что функция потенциально с��держит взаимную блокировку. Это происходит из-за того, что метод ->get() можно вызывать в любом порядке для разных пар экземпляров x и y.
Таким образом, было бы лучше избегать увеличения времени взятия блокировки и не допускать упомянутые выше взаимные блокировки.
4 Решение
Идея довольно проста: нам нужно внедрить функциональность прокси-объекта внутрь самого вызова. А чтобы упростить взаимодействие с нашим объектом, заменим -> на ..
Проще говоря, нам нужно преобразовать объект Data в другой объект:
using Lock = std::unique_lock<std::mutex>; struct DataLocked { int get() const { Lock _{mutex_}; return data_.get(); } void set(int v) { Lock _{mutex_}; data_.set(v); } private: mutable std::mutex mutex_; Data data_; };
В этом случае мы контролируем операции получения и освобождения мьютекса внутри самих методов. Это предотвращает проблемы, упомянутые ранее.
Но такая запись неудобна для реализации, потому что базовая идея умного мьютекса заключается в том, чтобы избежать дополнительного кода. Предпочтительный способ — это использовать преимущества обоих подходов: меньше кода и меньше проблем одновременно. Таким образом, необходимо обобщить это решение и распространить его для более широких сценариев использования.
4.1 Обобщенный адаптер
Нам нужно как-то адаптировать нашу старую реализацию Data без mutex для реализации, содержащей mutex, которая должна выглядеть аналогично классу DataLocked. Для этого обернем вызов метода для дальнейшей трансформации поведения:
template<typename T_base> struct DataAdapter : T_base { // для простоты рассмотрим исключительно метод set void set(int v) { T_base::call([v](Data& data) { data.set(v); }); } };
Здесь мы откладываем вызов data.set(v) и передаем его в T_base::call(lambda). Возможная реализация T_base может быть такой:
struct MutexBase { protected: template<typename F> void call(F f) { Lock _{mutex_}; f(data_); } private: Data data_; std::mutex mutex_; };
Как вы можете видеть, мы разделили монолитную реализацию класса DataLocked на два класса: DataAdapter<T_base> и MutexBase как один из возможных базовых классов для созданного адаптера. Но фактическая реализация очень близка: мы удерживаем мьютекс во время вызова Data::set(v).
4.2 Больше обобщения
Давайте еще обобщим нашу реализацию. У нас MutexBase реализация работает только для Data. Улучшим это:
template<typename T_base, typename T_locker> struct BaseLocker : T_base { protected: template<typename F> auto call(F f) { using Lock = std::lock_guard<T_locker>; Lock _{lock_}; return f(static_cast<T_base&>(*this)); } private: T_locker lock_; };
Здесь использовано несколько обобщений:
- Я не использую определенную реализацию мьютекса. Можно использовать либо
std::mutexлибо любой объект, реализующийконцепцию BasicLockable. T_baseпредставляет собой экземпляр объекта с тем же интерфейсом. Это может бытьDataили даже уже адаптированный объектData, например, такой какDataLocked.
Таким образом, мы можем определить:
using DataLocked = DataAdapter<BaseLocker<Data, std::mutex>>;
4.3 Нужно больше обобщения
При использовании обобщений невозможно остановиться. Иногда я хотел бы преобразовать входные параметры. Для этого я изменю адаптер:
template<typename T_base> struct DataAdapter : T_base { void set(int v) { T_base::call([](Data& data, int v) { data.set(v); }, v); } };
И реализация BaseLocker преобразуется в:
template<typename T_base, typename T_locker> struct BaseLocker : T_base { protected: template<typename F, typename... V> auto call(F f, V&&... v) { using Lock = std::lock_guard<T_locker>; Lock _{lock_}; return f(static_cast<T_base&>(*this), std::forward<V>(v)...); } private: T_locker lock_; };
4.4 Универсальный адаптер
Наконец, давайте уменьшим размер шаблонного кода, связанный с адаптером. Шаблоны заканчиваются и в ход вступают продвинутые макросы с итераторами:
#define DECL_FN_ADAPTER(D_name) \ template<typename... V> \ auto D_name(V&&... v) \ { \ return T_base::call([](auto& t, auto&&... x) { \ return t.D_name(std::forward<decltype(x)>(x)...); \ }, std::forward<V>(v)...); \ }
DECL_FN_ADAPTER позволяет обернуть любой метод с именем D_name. Теперь осталось лишь перебрать все методы объекта и обернуть их:
#define DECL_FN_ADAPTER_ITERATION(D_r, D_data, D_elem) \ DECL_FN_ADAPTER(D_elem) #define DECL_ADAPTER(D_type, ...) \ template<typename T_base> \ struct Adapter<D_type, T_base> : T_base \ { \ BOOST_PP_LIST_FOR_EACH(DECL_FN_ADAPTER_ITERATION, , \ BOOST_PP_TUPLE_TO_LIST((__VA_ARGS__))) \ };
Теперь мы можем адаптировать наш Data, используя лишь одну строку:
DECL_ADAPTER(Data, get, set) // синтаксический сахар для синхронизирующего адаптера template<typename T, typename T_locker = std::mutex, typename T_base = T> using AdaptedLocked = Adapter<T, BaseLocker<T_base, T_locker>>; using DataLocked = AdaptedLocked<Data>;
И все!
5 Примеры
Мы рассмотрели адаптер на основе мьютекса. Рассмотрим другие интересные адаптеры.
5.1 Адаптер для подсчета ссылок
Иногда нам зачем-то нужно использовать shared_ptr для наших объектов. И было бы лучше скрыть это поведение от пользователя: вместо использования operator-> хотелось бы просто использовать operator.. Ну или хотя бы просто .. Реализация очень проста:
template<typename T> struct BaseShared { protected: template<typename F, typename... V> auto call(F f, V&&... v) { return f(*shared_, std::forward<V>(v)...); } private: std::shared_ptr<T> shared_; }; // вспомогательный класс для создания BaseShared объекта template<typename T, typename T_base = T> using AdaptedShared = Adapter<T, BaseShared<T_base>>;
Применение:
using DataRefCounted = AdaptedShared<Data>; DataRefCounted data; data.set(2);
5.2. Комбинация адаптеров.
Иногда возникает отличная идея пошарить данные между потоками. Общая схема состоит в объединении shared_ptr с mutex. shared_ptr решает проблемы с временем жизни объекта, а mutex используется для предотвращения состояния гонки.
Поскольку каждый адаптированный объект имеет тот же интерфейс, что и оригинальный, мы можем просто объединить несколько адаптеров:
template<typename T, typename T_locker = std::mutex, typename T_base = T> using AdaptedSharedLocked = AdaptedShared<T, AdaptedLocked<T, T_locker, T_base>>;
С таким использованием:
using DataRefCountedWithMutex = AdaptedSharedLocked<Data>; DataRefCountedWithMutex data; // экземпляр может быть скопирован и использован в разных потоках безопасно // интерфейс не изменяется int v = data.get();
5.3 Асинхронный пример: от обратных вызовов (callback) к будущему (future)
Шагнем в будущее. Например, у нас есть следующий интерфейс:
struct AsyncCb { void async(std::function<void(int)> cb); };
Но мы хотели бы использовать асинхронный интерфейс будущего:
struct AsyncFuture { Future<int> async(); };
Где Future имеет следующий интерфейс:
template<typename T> struct Future { struct Promise { Future future(); void put(const T& v); }; void then(std::function<void(const T&)>); };
Соответствующий адаптер:
template<typename T_base, typename T_future> struct BaseCallback2Future : T_base { protected: template<typename F, typename... V> auto call(F f, V&&... v) { typename T_future::Promise promise; f(static_cast<T_base&>(*this), std::forward<V>(v)..., [promise](auto&& val) mutable { promise.put(std::move(val)); }); return promise.future(); } };
Применение:
DECL_ADAPTER(AsyncCb, async) using AsyncFuture = AdaptedCallback<AsyncCb, Future<int>>; AsyncFuture af; af.async().then([](int v) { // обработка полученного значения });
5.4 Асинхронный пример: из будущего к обратному вызову
Т.к. это направляет нас в прошлое, то пусть это будет домашней задачей.
5.5 Ленивый адаптер
Разработчики ленивы. Давайте адаптируем любой объект для совместимости с разработчиками.
В этом контексте ленивость означает создание объекта по требованию. Рассмотрим следующий пример:
struct Obj { Obj(); void action(); }; Obj obj; // вызов: Obj::Obj obj.action(); // вызов: Obj::action obj.action(); // вызов: Obj::action AdaptedLazy<Obj> obj; // конструктор не вызывается! obj.action(); // вызов: Obj::Obj и Obj::action obj.action(); // вызов: Obj::action
Т.е. идея состоит в том, чтобы оттягивать создание объекта до последнего. Если пользователь решил использовать объект, мы должны его создать и вызвать соответствующий метод. Реализация базового класса может быть такой:
template<typename T> struct BaseLazy { template<typename... V> BaseLazy(V&&... v) { // лямбда добавляет ленивости state_ = [v...]() mutable { return T{std::move(v)...}; }; } protected: using Creator = std::function<T()>; template<typename F, typename... V> auto call(F f, V&&... v) { auto* t = boost::get<T>(&state_); if (t == nullptr) { // создаем объект в случае его отсутствия state_ = std::get<Creator>(state_)(); t = std::get<T>(&state_); } return f(*t, std::forward<V>(v)...); } private: // variant позволяет повторно использовать память // для двух разных объектов: лямбды и самого объекта std::variant<Creator, T> state_; }; template<typename T, typename T_base = T> using AdaptedLazy = Adapter<T, BaseLazy<T_base>>;
И теперь мы можем создать тяжелый ленивый объект и инициализировать его только в случае необходимости. При этом он полностью прозрачен для пользователя.
6 Накладные расходы
Давайте рассмотрим производительность адаптера. Дело в том, что мы используем лямбды и переносим их в другие объекты. Таким образом, было бы крайне интересно узнать накладные расходы таких адаптеров.
Для этого рассмотрим простой пример: обернем вызов объекта, используя сам объект, т.е. создадим тождественный адаптер и попытаемся измерить накладные расходы для такого случая. Вместо того, чтобы делать прямые измерения производительности, давайте просто посмотрим на сгенерированный код ассемблера для разных компиляторов.
Во-первых, давайте создадим простую версию нашего адаптера для работы только с методами on:
#include <utility> template<typename T, typename T_base> struct Adapter : T_base { template<typename... V> auto on(V&&... v) { return T_base::call([](auto& t, auto&&... x) { return t.on(std::forward<decltype(x)>(x)...); }, std::forward<V>(v)...); } };
BaseValue — это наш тождественный базовый класс для вызова методов непосредственно из того же типа T:
template<typename T> struct BaseValue { protected: template<typename F, typename... V> auto call(F f, V&&... v) { return f(t, std::forward<V>(v)...); } private: T t; };
И вот наш тестовый класс:
struct X { int on(int v) { return v + 1; } }; // референсная функция без накладных расходов int f1(int v) { X x; return x.on(v); } // адаптируемая функция для сравнения с референсной int f2(int v) { Adapter<X, BaseValue<X>> x; return x.on(v); }
Ниже вы можете найти результаты, полученные в онлайн-компиляторе:
GCC 4.9.2
f1(int): leal 1(%rdi), %eax ret f2(int): leal 1(%rdi), %eax ret
Clang 3.5.1
f1(int): # @f1(int) leal 1(%rdi), %eax retq f2(int): # @f2(int) leal 1(%rdi), %eax retq
Как можно видеть, здесь нет никакой разницы между f1 и f2, что означает, что компиляторы могут оптимизировать и полностью устранять накладные расходы, связанные с созданием и передачей лямбда-объекта.
7 Заключение
В статье представлен адаптер, который позволяет преобразовать объект в другой объект с дополнительной функциональностью, который оставляет неизменным интерфейс без накладных расходов на преобразование и вызов. Классы базового адаптера — универсальные трансформеры, которые могут быть применены к любому объекту. Они используются для улучшения и дальнейшего расширения функциональности адаптера. Различные комбинации базовых классов позволяют легко создавать очень сложные объекты без дополнительных усилий.
Эта мощная и занимательная техника будет использована и расширена в последующих статьях.
Полезные ссылки
[1] github.com/gridem/GodAdapter
[2] bitbucket.org/gridem/godadapter
[3] Blog: God Adapter
[4] Доклад C++ Russia: Универсальный адаптер
[5] Видео C++ Russia: Универсальный адаптер
[6] Хабрахабр: Полезные идиомы многопоточности С++
[7] Онлайн компилятор godbolt
