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

    Предисловие


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

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 10
    • +4

      Метод call лучше вызывать не как this->call а как T_base::call. Иначе получится сюрприз при попытке сделать адаптер для типа, который содержит другой метод call.

      • 0

        Действительно, так лучше. Поправил.

      • 0
        В разделе №4.2 в коде примера:
        Lock _{lock_};
        откуда берется имя Lock?
        • 0

          Там выше есть определение:


          using Lock = std::unique_lock<std::mutex>;
          • +1
            Но тогда Lock получается заточен только под std::mutex в качестве типа T_locker.
            • 0

              Спасибо, поправил в тексте. В оригинальном коде все ок:


              https://github.com/gridem/GodAdapter/blob/master/include/god_adapter/shared.h#L32

              • +1
                а зачем вообще делать type alias на Lock? Можно же положиться на c++17 deduction guide и просто писать std::lock_guard _{_mutex};?
                • 0

                  Я это писал еще в 2015 году. Тогда еще не наступил с++17. К тому же, не все до сих пор перешли на с++17.


                  Но так можно сделать, да.

        • –1
          Двоякое отношение к конструкциям подобного типа. С одной стороны — удобно, с другой если вдруг где-то закрался баг, то отладка будет болезненной.
          • +1

            Обычно обобщенная базовая функциональность покрывается изрядным количеством тестов, чтобы потом не было мучительно больно.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.