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:
- I don't use specific mutex implementation. You can use either
std::mutex
or any kind ofBasicLockable
concept. T_base
represents the instance of the object with the same interface. It could beData
or event adaptedData
object likeDataLocked
.
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.
No comments :
Post a Comment