Pull to refresh

Универсальный адаптер

Reading time 9 min
Views 13K

Предисловие


Данная статья является авторским переводом с английского собственной статьи под названием 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_;
};

Здесь использовано несколько обобщений:


  1. Я не использую определенную реализацию мьютекса. Можно использовать либо std::mutex либо любой объект, реализующий концепцию BasicLockable.
  2. 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

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+26
Comments 10
Comments Comments 10

Articles