outdoordoor

Extending PicoJSON for easier serialization and deserialization

In my project I'm actively using Kazuho Oku's great PicoJSON library to handle JSON files. Working with JSON data with this library generally looks like this:

namespace json = picojson;

// get required object
const auto& appObj = obj.at("app").get<json::object>();

// read int value
app->posX = static_cast<int>(appObj.at("pos_x").get<double>());
// write the value back
appObj["pos_x"] = json::value(static_cast<double>(app->posX));

// read array value
const auto& langs = mt.at("languages").get<json::array>();
for (const auto& l : langs)
{
    app->languages.push_back(l.get<std::string>());
}
// write the value back
json::array langs;
for (const auto& l : meta->languages)
{
    langs.emplace_back(json::value(l));
}
metaObj["languages"] = json::value(langs);

The code is fine, but it becomes quite cumbersome when there are many values to read and write, so I wanted to simplify the process (which inevitably took far longer than it would've taken had I simply written the code like above).

The idea was to have two functions FromJson and ToJson that would take a list of variables to read or write and handle all the types and casts itself. I also didn't want to specify the name of the variable each time, it should be deduced automatically.

In order not to write a full reflection system before C++26, I decided to pass variable and its name as a pair variable - variable name. To get the name, I wrap the whole thing in a macro that stringifies the value passed. If the variable is a member of a class, I extract its name with the GetName function.

// for serialization
#define LW_SERIALIZE(var) std::make_pair(var, util::GetName(#var).c_str())

// for deserialization
#define LW_DESERIALIZE(var) std::make_pair(std::ref(var), util::GetName(#var).c_str())

// where GetName is defined as
std::string GetName(const char* name)
{
    std::string str(name);
    if (str.find('.') != str.npos)
        return str.substr(str.find_last_of('.') + 1);
    else if (str.find('>') != str.npos)
        return str.substr(str.find_last_of('>') + 1);
    else 
        return str;
}

Since different types may require different treatment, I'll need a way to distinguish them, for which I use some templates.

template<class T, template<class...> class Ref>
struct IsSpecialization : std::false_type {};

template<template<class...> class Ref, class... Args>
struct IsSpecialization<Ref<Args...>, Ref> : std::true_type {};

// check if T is specialization of std::vector
// I'm removing references since on deserialization
// I'll be passing values by reference, but need to know
// their type without it
template<class T>
struct IsVector : IsSpecialization<std::remove_reference_t<T>, std::vector> {};

template<class T>
inline constexpr bool IsVectorV = IsVector<T>::value;

template<class T>
struct VectorTraits {};

// gets the type of value and allocator of the vector.
// allocator type is not relevant to what I'm doing, 
// it's here for the sake of completeness
template<class A, class B>
struct VectorTraits<std::vector<A, B>>
{
	using valueType = A;
	using allocatorType = B;
};

template<class T>
struct IsString : std::is_same<std::remove_reference_t<T>, std::string> {};

template<class T>
inline constexpr bool IsStringV = IsString<T>::value;

template<class T>
inline constexpr bool IsEnumV = std::is_enum_v<std::remove_reference_t<T>>;

// check of T is arithmetic, excluding bool and char
template<class T>
struct IsNumber
	: std::integral_constant<
	bool,
	std::is_arithmetic_v<std::remove_reference_t<T>> &&
	!std::is_same_v<std::remove_reference_t<T>, bool> &&
	!std::is_same_v<std::remove_reference_t<T>, char>> {};

template<class T>
inline constexpr bool IsNumberV = IsNumber<T>::value;

template<class T>
struct IsBool : std::is_same<std::remove_reference_t<T>, bool> {};

template<class T>
inline constexpr bool IsBoolV = IsBool<T>::value;

// takes in user type and returns the type PicoJSON uses
// in its get methods (e.g. user needs an int, so we 
// use get<double>() and then cast it to int). 
template<class T>
struct JsonType
{
	using type = std::conditional_t
		<
		IsNumberV<T>, double,
		std::conditional_t
		<
		IsStringV<T>, std::string,
		bool
		>
		>;
};

template<class T>
using JsonTypeT = typename JsonType<T>::type;

Now, to actually write/read the value to/from the json object, I use variadic template functions, where I specify how to handle values depending on their type. ToJson looks like this:

template<class... Vars>
inline void ToJson(json::value::object& obj, const std::pair<Vars, const char*>&... vars)
{
    // fold expression to iterate over variables.
    // here, vars.first will be values of variables and
    // vars.second will be their names
    ([&]
        {
            // since all numbers are represented as doubles in PicoJSON, I cast them all to double
            if constexpr (IsNumberV<decltype(vars.first)>)
            {
                obj[vars.second] = json::value(static_cast<double>(vars.first));
            }
            // enums are stored as numbers
            else if constexpr (IsEnumV<decltype(vars.first)>)
            {
                obj[vars.second] = json::value(static_cast<double>(std::to_underlying(vars.first)));
            }
            // vectors are stored as arrays.
            // values are cast to the corresponding json type
            else if constexpr (IsVectorV<decltype(vars.first)>)
            {
                using T = std::remove_reference_t<decltype(vars.first)>;
                using VT = VectorTraits<T>::valueType;

                json::value::array vals;
                for (const auto& v : vars.first) 
                    vals.push_back(json::value(static_cast<JsonTypeT<VT>>(v)));
                obj[vars.second] = json::value(vals);
            }
            // bool and string values don't require any special treatment
            else if constexpr (IsBoolV<decltype(vars.first)> || IsStringV<decltype(vars.first)>)
            {
                obj[vars.second] = json::value(vars.first);
            }
        }
    (), ...);
}

FromJson looks like this:

template<class... Vars>
inline void FromJson(const json::object& obj, const std::pair<Vars&, const char*>&... vars)
{
    ([&]
        {
            // numbers are cast based on the type of the
            // variable passed. NumberToStandardType is
            // implemented as follows:
            // template<class T>
            // inline void NumberToStandardType(double num, T& var)
            // {
            //     var = static_cast<T>(num);
            // }
            if constexpr (IsNumberV<decltype(vars.first)>)
            {
                NumberToStandardType(obj.at(vars.second).get<double>(), vars.first);
            }
            // enums are cast back from numbers
            else if constexpr (IsEnumV<decltype(vars.first)>)
            {
                using ET = std::remove_reference_t<decltype(vars.first)>;
                vars.first = static_cast<ET>(obj.at(vars.second).get<double>());
            }
            // arrays are loaded into vectors, each value
            // is cast accordingly
            else if constexpr (IsVectorV<decltype(vars.first)>)
            {
                using T = std::remove_reference_t<decltype(vars.first)>;
                using VT = VectorTraits<T>::valueType;

                vars.first.clear();

                const auto vals = obj.at(vars.second).get<json::array>();
                for (const auto& v : vals) 
                    vars.first.push_back(static_cast<VT>(v.get<JsonTypeT<VT>>()));
            }
            else if constexpr (IsBoolV<decltype(vars.first)>)
            {
                vars.first = obj.at(vars.second).get<bool>();
            }
            else if constexpr(IsStringV<decltype(vars.first)>)
            {
                vars.first = obj.at(vars.second).get<std::string>();
            }
            else
            {
                LOG_F(WARNING, "wrong type");
            }
        }
    (), ...);
}

By combining these two functions and the macros, I can now write code like this to have the values read and written.

// serialization
json::value::object obj;
ToJson(
    obj,
    LW_SERIALIZE(test.intMember),
    LW_SERIALIZE(test.floatMember),
    LW_SERIALIZE(test.boolMember),
    LW_SERIALIZE(test.enumMember),
    LW_SERIALIZE(test.vectorMember),
    LW_SERIALIZE(test.stringMember)
);
json::value main(obj);
std::string str = main.serialize(true);

// deserialization
const auto& obj = val.get<json::object>();
FromJson(
    obj2,
    LW_DESERIALIZE(test.intMember),
    LW_DESERIALIZE(test.floatMember),
    LW_DESERIALIZE(test.boolMember),
    LW_DESERIALIZE(test.enumMember),
    LW_DESERIALIZE(test.vectorMember),
    LW_DESERIALIZE(test.stringMember)
);

Note that this is not supposed to be an exhaustive solution. It doesn't work with nested objects, containers other than std::vector, etc. However, it covers the use cases I need, and can be relatively easily extended.