This is a library for serializing and deserializing objects written in C++.
The reason of implementing this library in a header-files-only manner will be explained later.
$ ./test.shOr you can compile it manually:
$ cmake .
$ make -jUse make clean to clean previously built files.
Note that this library requires a compiler that supports (at least) C++17 to compile.
- Of course, basic requirements are fulfilled
- Bonus: XML serialization with
base64encoding - Anonymous
namespaces to hide internally used classes & functions from users - Using various type gymnastics (类型体操) to implement polymorphism and strong compile-time type checks
With polymorphism, we can write less codes and support more types.
- For xml,
struct XMLSerializable: base class for all serializable objects, with pure virtual functions to be overridden - For binary, provide additional {de,}serializer to
serializer::binary::serializeto support user-defined types std::map-like objects are supported, such asstd::unordered_mapor user-implemented maps- handled by same codes as
std::map std::map-like is defined as:T::key_type,T::mapped_type, as well asoperator[]that acceptsT::key_typeand returnsT&
- handled by same codes as
std::pair-like objects are supported in a similar way.std::vectorandstd::list's {de,}serialization are handled by same codes.- Additional container supports for
std::tuple. Type checks forstd::tupleare done by recursively iterating over the tuple at compile time.
With compile-time type checks, we can discover bugs at compile time. Usually, compile logs provide more helpful information to find the cause of the error. For instance:
./include/libbinary.h:197:16: required from ‘void serializer::binary::serialize(const T&, const string&) [with T = main()::Example; std::string = std::__cxx11::basic_string<char>]’
./tests/test_binary.cpp:107:26: required from here
./include/libbinary.h:183:44: error: ‘constexpr bool serializer::impossible_error(T&&, const char*) [with T = const main()::Example&]’ called in a constant expression
183 | constexpr auto x = impossible_error(t, "T is not a supported type, you must provide a serialize function");
By reading this log, we know the input type T, the reason of failing (T is not a supported type), and the location of the error. Without proper type checks, we can only discover this error at runtime, not knowing the type T, and some valuable information might be lost due to inlining & other optimizations. What's more, if the tests failed to cover all the cases, this runtime error might not be triggered, leaving a flaw inside the library.
Here is a list of type traits & techniques that I've used to implement compile-time type checks:
std::enable_if_t,std::is_arithmetic_v,std::is_base_of,std::is_same_tstd::remove_cv_t,std::remove_volatile_t,std::void_t,std::true_type,std::false_typestd::declval,decltypestd::decay_t,std::is_specialization_of(this is way too new, in fact it's still a proposal, so I have to implement a shim)if constexprstatic_assert- A manually implemented
constexpr bool impossible_errorto replacestatic_assert(false)in theelsebranch ofif constexpr-s, which will also be explained later
For implementation details, see include/type_utils.h.
Some runtime checks are not avoidable, acting as the last layer of safe guards to make sure that the program does not act in undefined behaviors. For instance, we need to check that files are correctly opened and are successfully read into the memory, and that the input is valid and sane. We also want to check that strings are successfully written into files and that files are correctly closed, etc.
However, due to the nature of the language, unexpected errors can still happen. Like, you can modify the XML file directly, and if you are deserializing some values into raw pointers, you might get a segfault caused by out-of-bound memory accesses.
- Just don't seprate template functions' declaration and definition. Keep them inside one Translation Unit (TU), or you'll have to explicitly instantiate them. That's too annoying.
remove_cv_twill not changeconst char*tochar *. It will actually changechar const*tochar *.static_assert(false), even provided inside theelsebranch ofif constexpr, causes ill-formed NDR.- gcc's implementation of
std::to_charsis partial. It only works for integral types. clang also doesn't supportstd::to_charsof floating-point types until LLVM 14.0. So we have to usestd::stringstream. - Set
std::stringstream's precision tostd::numeric_limits<T>::max_digits10instead ofstd::numeric_limits<T>::digits10. The latter causes rounding errors for some input. std::variantis tricky.std::variant<std::vector<T>, std::list<T>>as a function parameter's type will compile, but this won't work. It matches neitherstd::vector<T>norstd::list<T>. Actually,std::variantis not designed to be used this way.std::spandoes not supportstd::list, becausestd::list's data is not contiguous.- We can match
std::pairwithT::first_typeandT::second_type. Although it is said thatstd::pairis a specialization ofstd::tuple,std::tupledoes not havefirst_typeandsecond_typemembers. std::make_index_sequencecan also be used to iterate through a tuple at compile time, but I failed to make it work.- It's too hard to serialize user-defined structs into XML with the original structure preserved (i.e. directly mapped to XML's tree structure). Currently, nested structs are flattened when serializing to XML, result in many
<and>in the generated XML. Escaping of inner structs' serialized XML can be avoided if the user is able to provide a iterator for the input struct (and its inner structs), but that's not considered as a convenient mechanism.