Предисловие
Данная статья является авторским переводом с английского собственной статьи под названием 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)
{
this->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)
{
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)
{
this->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)
{
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 this->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 this->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
Автор: gridem