Serialization with no efforts
In this post, I want to share my way how I usually deal with serialization using meta-programming.
Motivation
Once you need to send or receive your data, you have to solve the serialization problem. Alternatively, imagine you want to print your data in your terminal. Commonly, you write a bunch of functions that would iterate through the members in your objects and apply logic code (sending, receiving, or printing.) This approach has many problems:
- it is very wordy, repetitive, and error-prone,
- it is not generic, and every type, you want to work with, has to be done individually,
- it is very tedious and tiresome.
I invite readers to address those problems with C++ meta-programming.
To reduce wordiness, we abstract our logic code from the procedure iterating through members. The iteration procedure itself is simplified dramatically by exposing your members with a tuple. Furthermore, once you have exposed it, you don’t have to write anything else, except a special case when a type differs from normal behavior. In the end, you get the compiler doing the job for you in a type-safe manner, avoiding repetitive work and making fewer mistakes — good motivation to feel content.
Members exposure
Let’s begin with exposing our members so that they are available for iterating. Imagine we have a type:
struct employee {
std::string name;
int salary;
};
We want to have name
and salary
members available so that the callers, performing serialization, don’t depend on the concrete type, employee
. That is, for a given T
we should be able to access exposed members. To achieve it we define members
member function.
struct employee {
std::string name;
int salary;
auto members() noexcept {
return std::forward_as_tuple(name, salary);
}
auto members() const noexcept {
return std::forward_as_tuple(name, salary);
}
};
Having those “elemental” functions defined, we can do whatever we want.
Logic code abstraction
Once you have a tuple, you can use the standard library to perform an iteration and apply your logic code on its elements. Let’s write a simple serializer into a stream:
template <typename T>
std::ostream& operator << (std::ostream& os, T const& obj) {
using std::operator <<;
std::apply([&os](auto const& fst, auto const&... rest) {
os << fst;
((os << ", " << rest), ...);
}, obj.members());
return os;
}
Now, we can instantiate an employee
and easily print it:
employee e{"Steve Jobs", 1};
std::cout << e << std::endl;
The code above prints
Steve Jobs, 1
A complete and working example can be found at coliru (or gist if unreachable).
Simplification and Systemizing
There is, definitely, some amount of boilerplate: we have to maintain two versions of members
functions. The advanced use of preprocessor can help us to systemize our approach. Let’s define a few macros. I’m using Boost.Preprocessor for simplicity.
#define EXPOSE_MEMBERS(...) \
auto members() { \
return std::forward_as_tuple(__VA_ARGS__); \
} \
auto members() const { \
return std::forward_as_tuple(__VA_ARGS__); \
} \
static constexpr auto names() { \
return std::make_array( \
BOOST_PP_LIST_ENUM( \
BOOST_PP_LIST_TRANSFORM( \
EXPOSE_MEMBERS_Q, @, \
BOOST_PP_VARIADIC_TO_LIST(__VA_ARGS__)))); \
}
Now, we can reduce our example to:
struct employee {
std::string name;
int salary;
EXPOSE_MEMBERS(name, salary);
};
You may notice we have added static function names
that returns an array with member names in the same order as in members
. It allows us to create, for example, a pretty-print function or json serializers.
Let’s look at better versions of stream operators.
The operator <<
prints the member name as well:
template <typename T>
std::ostream& operator << (std::ostream& os, T const& obj) {
using std::operator <<;
std::apply([&os](auto const& names,
auto const& fst,
auto const&... rest) {
unsigned int i = 0;
os << names[i] << '=' << fst;
((os << ", " << names[++i] << '=' << rest), ...);
}, std::tuple_cat(std::make_tuple(obj.names()),
obj.members()));
return os;
}
The operator >>
allows us to deserealize an employee
from a string.
template <typename T>
std::istream& operator >> (std::istream& is, T& obj) {
using std::operator >>;
std::apply([&is](auto&... members) {
(is >> ... >> members);
}, obj.members());
return is;
}
Let’s use them:
employee e{"Steve Jobs", 1};
std::cout << e << std::endl;
// Prints
// name=Steve Jobs, salary=1
std::string input = "Bill-Gates 100";
std::istringstream ss{input};
ss >> e;
std::cout << e << std::endl;
// Prints
// name=Bill-Gates, salary=100
And this approach is appliable for any type simply by using one macro EXPOSE_MEMBERS
.
A complete and working example can be found at coliru (or gist if unreachable).
Further improvements
Constrained function templates
The operators (or any other serializers) have very wide input template parameters. It is pretty easy to come up with a SFINAE-based solution, but I will show it with Concepts. We need to make sure the serializable object contains methods members
and names
.
template <typename T>
concept bool Serializable = requires {
std::declval<T>().members();
std::declval<T>().names();
};
Now, to apply constraints Serializable
on a template parameter, you simply write template <Serializable T>
instead of template <typename T>
.
Note: don’t forget to pass -fconcepts
to gcc while experimenting.
Example at coliru.
Reflection
The use of preprocessor becomes unnecessary when Reflection proposal gets approved and mainstreamed. But for now, we have to list members by hand. A good part, you get a compilation error if there is a typo in member names.
Metaclass
With metaclasses you are able to define such a metaclass that runs exposing automatically. I highly recommend to read Metaclasses: Generative C++.