Custom Conversions

Boost.JSON uses two mechanisms to customize conversion between value and user types. One mechanism involves specializing use_category. The other one is more powerful and requires defining overloads of tag_invoke. Both mechanisms will be further explained in this section.

use_category

The library generally deduces the appropriate implementation of conversion for a given type using internal type traits. These type traits attempt to deduce the correct conversion category by checking if the type satisfies the requirements associated with that category. The categories are checked one by one, in the order of their appearance in the table in the previous subsection, and the first one whose requirements are satisfied is selected.

In some cases, though, a type would match a category with a higher priority, but the user intends for it to belong to a lower priority category. If this happens the user can specialize the template use_category for that type to be equivalent to the intended category.

Consider this type:

namespace user_ns
{

class ip_address
{
public:
    ip_address(
        unsigned char oct1,
        unsigned char oct2,
        unsigned char oct3,
        unsigned char oct4 );

    const unsigned char*
    begin() const;

    const unsigned char*
    end() const;

private:
    std::array<unsigned char, 4> octets_;
};

template< std::size_t N >
unsigned char
get(const ip_address& addr);

} // namespace user_ns

namespace std
{

template<>
struct tuple_size< user_ns::ip_address >
    : std::integral_constant<std::size_t, 4>
{ };

template< std::size_t N >
struct tuple_element< N, user_ns::ip_address >
{
    using type = unsigned char;
};

} // namespace std

It exposes both a sequence API and a tuple API. But converting from value to user_ns::ip_address would not be able to use implementation for sequences, since those are constructed empty and then populated one element at a time, while ip_address has a fixed size of 4. The tuple conversion would fit, though. The only problem is that tuple_category has a lower priority than sequence_category. In order to circumvent this, the user only needs to specialize use_category for ip_address.

namespace boost {
namespace json {

template<>
struct use_category<user_ns::ip_address> : tuple_category
{ };

} // namespace json
} // namespace boost

tag_invoke Overloads

The second, more powerful approach, is to provide the conversion implementation yourself. With Boost.JSON this is done by defining an overload of tag_invoke function (the benefits of this mechanism are outlined in C++ proposal P1895. In essence, tag_invoke provides a uniform interface for defining customization points by using argument-dependent lookup to find a viable overload from the point at which it is called. As the name suggests, a tag type is passed as an argument in order to:

  • discard candidates that are unrelated to that particular customization point, and

  • embed the user-defined type into the arguments list (e.g. by using a tag type template such as value_to_tag<T>) so that its associated namespaces and entities are examined when name lookup is performed.

This has the effect of finding user-provided tag_invoke overloads, even if they are declared (lexically) after the definition of the calling function.

Overloads of tag_invoke called by value_from take the form:

void tag_invoke( const value_from_tag&, value&, T );

While overloads of tag_invoke called by value_to take the form:

T tag_invoke( const value_to_tag< T >&, const value& );

If we implemented conversion for user_ns::ip_address manually with this approach, it would look like this:

friend
void
tag_invoke( const value_from_tag&, value& jv, ip_address const& addr )
{
    // Store the IP address as a 4-element array of octets
    value_from(addr.octets_, jv);
}

friend
ip_address
tag_invoke( const value_to_tag< ip_address >&, value const& jv )
{
    auto arr = value_to< std::array<unsigned char, 4> >(jv);
    return ip_address( arr[0], arr[1], arr[2], arr[3] );
}

Note, the tag_invoke overloads used here are inline friend functions, also known as "hidden friends". They have the benefit of not showing up in overload sets unless found via ADL. This can speed up compilation. Also, the first overload being a friend, it has access to ip_address's private data member.

The overloads delegate most of the implementation to value_to and value_from calls that deal with the underlying type. In general this is preferrable to reimplementing everything by hand, as those functions take care of error checking, memory resource selection, etc.

Since the type being converted is embedded into the function’s signature, user-provided overloads are visible to argument-dependent lookup and become candidates when a conversion is performed:

ip_address addr = { 127, 0, 0, 12 };
value jv = value_from( addr );
assert( serialize( jv ) == R"([127,0,0,12])" );

// Convert back to IP address
ip_address addr2 = value_to< ip_address >( jv );
assert(std::equal(
    addr.begin(), addr.end(), addr2.begin() ));

Users can freely combine types with custom conversions with types with library-provided conversions. The library handles them correctly:

std::map< std::string, ip_address > computers = {
    { "Alex", { 192, 168, 1, 1 } },
    { "Blake", { 192, 168, 1, 2 } },
    { "Carol", { 192, 168, 1, 3 } },
};

// conversions are applied recursively;
// the key type and value type will be converted
// using value_from as well
value jv = value_from( computers );
assert( jv.is_object() );

value serialized = parse(R"(
    {
        "Alex":  [ 192, 168, 1, 1 ],
        "Blake": [ 192, 168, 1, 2 ],
        "Carol": [ 192, 168, 1, 3 ]
    }
    )");
assert( jv == serialized );

tag_invoke Pitfalls

Let’s also discuss some pitfalls when implementing tag_invoke overloads.

Consider this implementation:

friend
void
tag_invoke( const value_from_tag&, value& jv, ip_address const& addr )
{
    jv = value_from(addr.octets_);
}

On the surface it looks almost exactly like the one suggested in the previous subsection. In fact, not only will it compile, but also the result will be the same. But there is an important difference. value_from(addr.octets_) creates a temporary value that uses the default memory resource. Then, when that value is assigned to jv, it will be reallocated using the memory resource of js. The most direct fix is to use the correct memory resource explicitly:

jv = value_from(addr.octets_, jv.storage());

This version is fully equivalent to the original one provided in the previous subsection, but is arguably unnecessarily explicit.

Now, lets consider this tag_invoke overload for value_to:

    friend
    ip_address
    tag_invoke( const value_to_tag< ip_address >&, value const& jv )
    {
        auto& arr = jv.as_array();
        return ip_address(
            arr.at(0).as_uint64(),
            arr.at(1).as_uint64(),
            arr.at(2).as_uint64(),
            arr.at(3).as_uint64());
    }

It may look correct, but there’s multiple issues with it. First of all, even a positive integral number will not necessarily be parsed as a std::uint64_t. On the other hand, the source document may erroneously contain integers outside of unsigned char's range (your compiler might even warn about the possibility of such lossy conversion), and this implementation never checks for that. To fix both of these problems we’ll use value::to_number:

    auto& arr = jv.as_array();
    return ip_address(
        arr.at(0).to_number<unsigned char>(),
        arr.at(1).to_number<unsigned char>(),
        arr.at(2).to_number<unsigned char>(),
        arr.at(3).to_number<unsigned char>());

Unfortunately, this implementation still fails to check for one more thing: the size of the source array. While, arr.at(3) ensures that the array has at least 4 elements, nothing ensures there’s not more than 4. We could check for that manually, and then throw an exception. Or we could just request a conversion to std::array<unsigned char, 4>, just like the implementation shown in the previous subsection.

To summarise, the easiest way to avoid most pitfalls is to delegate as much as possible to invocations of value_to and value_from.