Tuesday, November 10, 2015

Replicated Object. Part 2: God Adapter

1 Annotation

The article introduces a special adapter that allows developers to wrap any object into another one with additional features you want to include. Adapted objects have the same interface thus they are completely transparent from the usage point of view. The generic concept will be introduced step-by-step using simple but powerful examples.

2 Introduction

Disclaimer. If you are not tolerant to C++ perversions please stop reading this article.

The term god adapter is originated from god object meaning that it implements many features. The same idea is applicable for god adapter as well. Such adapter has outstanding responsibility and includes features that you can or even cannot imagine.

3 Problem Statement

Recently I've presented smart mutex concept to simplify shared data access. The idea was simple: associate mutex with the data and automatically invoke lock and unlock on any data access. The code looks like the following:

struct Data
{
    int get() const
    {
        return val_;
    }

    void set(int v)
    {
        val_ = v;
    }
private:
    int val_ = 0;
};

// declare smart mutex
SmartMutex<Data> d;
// set value, lock and unlock will be taken automatically
d->set(4);
// get value
std::cout << d->get() << std::endl;

There are several problems.

3.1 Locking Time

Lock is obtained for the time of the current expression. Let's consider the following line:

std::cout << d->get() << std::endl;

Unlock is called after all expression is executed including output to std::cout. It's wasting of the time under lock and significantly increases the probability of lock contention.

3.2 Deadlock possibility

As a consequence of the first problem, there is a possibility of deadlock due to implicit locking mechanism and expression locking time. Let's consider the following code snippet:

int sum(const SmartMutex<Data>& x, const SmartMutex<Data>& y)
{
    return x->get() + y->get();
}

It's not evident that the function potentially contains deadlock because ->get method can be called in any order for different pair of x and y instances.

Thus it would be better to avoid locking time increasing and mentioned deadlocks as much as possible.

4 Solution

The idea is quite simple: we need to incorporate proxy functionality inside the call invocation. To further improve user experience we replace -> with ..

Basically, we need to transform our Data into another object:

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_;
};

In that case we have controlled mutex obtain/release operations within methods scope. It prevents from the problems mentioned before.

But it's inconvenient to implement in this way because the base idea of smart mutex is to avoid additional boilerplate coding. The desired way is to use benefits from both approaches: less code and less problems. Thus I have to generalize that solution and spread it for wider usage scenarios.

4.1 Generalized Adapter

We need to somehow adapt our old implementation Data without mutex for mutex-based implementation that should look like DataLocked class. For that purpose let's wrap our method call to further invoke in another context:

template<typename T_base>
struct DataAdapter : T_base
{
    // let's consider just set method
    void set(int v)
    {
        this->call([v](Data& data) {
            data.set(v);
        });
    }
};

Here we postpone the call data.set(v) and transfer it to T_base::call(lambda) method. The possible implementation of T_base could be:

struct MutexBase
{
protected:
    template<typename F>
    void call(F f)
    {
        Lock _{mutex_};
        f(data_);
    }
private:
    Data data_;
    std::mutex mutex_;
};

As you can see we split the monolith implementation of DataLocked class into two classes: DataAdapter<T_base> and MutexBase as one of the possible base class for created adapter. But the actual implementation is very close: we hold the mutex during Data.set(v) call.

4.2 More Generalization

Let's further generalize our implementation. We have MutexBase implementation but it works only for Data. Let's solve this:

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_;
};

Here are several generalizations:

  1. I don't use specific mutex implementation. You can use either std::mutex or any kind of BasicLockable concept.
  2. T_base represents the instance of the object with the same interface. It could be Data or event adapted Data object like DataLocked.

Thus we can define:

using DataLocked = DataAdapter<BaseLocker<Data, std::mutex>>;

4.3 I Need More Generalization

I cannot stop myself. Sometimes I would like to transform the input parameters. For that purpose I modify the adapter:

template<typename T_base>
struct DataAdapter : T_base
{
    // let's consider just set method
    void set(int v)
    {
        this->call([](Data& data, int v) {
            data.set(v);
        }, v);
    }
};

And BaseLocker implementation is transformed to:

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 God Adapter

Finally let's reduce the boilerplate code related to adapter. For that purpose I will use macro:

#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)...); \
    }

It wraps any method with name D_name. The only needed action is to iterate through the object methods and wrap them individually:

#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<BOOST_PP_REMOVE_PARENS(D_type), T_base> : T_base \
    { \
        BOOST_PP_LIST_FOR_EACH(DECL_FN_ADAPTER_ITERATION, , \
                               BOOST_PP_TUPLE_TO_LIST((__VA_ARGS__))) \
    };

Now we can adapt our Data by using just a single line:

DECL_ADAPTER(Data, get, set)

// syntactic sugar for mutex-based adapter
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>;

That's it!

5 Examples

We considered mutex-based adapter. Let's consider other interesting adapters.

5.1 Reference Counting Adapter

Sometimes we need to use shared_ptr for our objects. And it would be better to hide this behavior from user: instead of using operator-> you would like to use just .. The implementation is very simple:

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_;
};

// helper class to create BaseShared object
template<typename T, typename T_base = T>
using AdaptedShared = Adapter<T, BaseShared<T_base>>;

Usage:

using DataRefCounted = AdaptedShared<Data>;

DataRefCounted data;
data.set(2);

5.2 Adapters Combining

Sometimes it's a good idea to share the data between threads. The common pattern is to combine shared_ptr with mutex. shared_ptr resolves the issues with object lifetime while mutex is used to avoid race conditions.

Because every adapted object has the same interface as original one we can simply combine several adapters together:

template<typename T, typename T_locker = std::mutex, typename T_base = T>
using AdaptedSharedLocked = AdaptedShared<T, AdaptedLocked<T, T_locker, T_base>>;

With usage:

using DataRefCountedWithMutex = AdaptedSharedLocked<Data>;
DataRefCountedWithMutex data;
// data instance can be copied, shared and used across threads safely
// interface remains the same
int v = data.get();

5.3 Asynchronous Example: From Callback to Future

Let's go to future. E.g. we have the following interface:

struct AsyncCb
{
    void async(std::function<void(int)> cb);
};

But we would like to use:

struct AsyncFuture
{
    Future<int> async();
};

Where Future has the following interface:

template<typename T>
struct Future
{
    struct Promise
    {
        Future future();
        void put(const T& v);
    };

    void then(std::function<void(const T&)>);
};

Corresponding adapter is:

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();
    }
};

template<typename T, typename T_future, typename T_base = T>
using AdaptedCallback = Adapter<T, BaseCallback2Future<T_base, T_future>>;

Usage:

DECL_ADAPTER(AsyncCb, async)
using AsyncFuture = AdaptedCallback<AsyncCb, Future<int>>;

AsyncFuture af;
af.async().then([](int v) {
    // obtained value
});

5.4 Asynchronous Example: From Future to Callback

Because it directs us to the past let it be the home task.

5.5 Lazy

Developers are lazy. Let's adapt any object to be consistent with developers.

In that context laziness means on-demand object creation. Let's consider the following example:

struct Obj
{
    Obj();

    void action();
};

Obj obj;               // Obj::Obj ctor is invoked
obj.action();          // Obj::action is invoked

AdaptedLazy<Obj> obj;  // ctor is not called!
obj.action();          // Obj::Obj and Obj::action are invoked

Therefore the idea is to avoid creation as later as possible. If the user decided to use the object we have to create it and invoke appropriate method. The base class implementation could be:

template<typename T>
struct BaseLazy
{
    template<typename... V>
    BaseLazy(V&&... v)
    {
        // lambda to lazily create the object
        state_ = [v...] {
            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)
        {
            // if we don't have instantiated object
            // => create it
            state_ = boost::get<Creator>(state_)();
            t = boost::get<T>(&state_);
        }
        return f(*t, std::forward<V>(v)...);
    }

private:
    // variant reuses memory to store either object state
    // or lambda to create the object
    boost::variant<Creator, T> state_;
};

template<typename T, typename T_base = T>
using AdaptedLazy = Adapter<T, BaseLazy<T_base>>;

And now we can create heavy-weight lazy object and create it only if it's necessary. It's completely transparent to the user.

6 Performance Overhead

Let's consider the performance penalty from using the adapter. The thing is that we use lambdas and transfer them to other objects. Thus we would like to know the overhead of such adapters.

For that purpose let's consider simple example: wrap object call by using object itself meaning that we create "nullable" adapter and try to measure overhead. And instead of doing direct measurements let's see just assembler output from different compilers.

First, let's create simple version of our adapter to deal with on methods only:

#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 is our nullable base class to invoke methods directly from the same type 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;
};

And here is our test class:

struct X
{
    int on(int v)
    {
        return v + 1;
    }
};

// reference function without overhead
int f1(int v)
{
    X x;
    return x.on(v);
}

// adapted function to be compared to the reference function
int f2(int v)
{
    Adapter<X, BaseValue<X>> x;
    return x.on(v);
}

Below you can find results obtained from online compiler:

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

As you can see there is no difference between f1 and f2 meaning that compilers are able to optimize and completely eliminate overhead related to lambda object creation.

7 Conclusion

I introduced the adapter that allows you to transform object into another object with additional features that provides the same interface with minimal overhead. Base adapter classes are universal transformers that could be applied to any object. They are used to enhance and further extend adapter functionality. Different combination of base classes allows easily creating very complex objects without additional efforts.

This powerful technique will be used and extended in subsequent articles.

References

github.com/gridem/GodAdapter

bitbucket.org/gridem/godadapter

No comments :

Post a Comment