...one of the most highly
regarded and expertly designed C++ library projects in the
world.
— Herb Sutter and Andrei
Alexandrescu, C++
Coding Standards
Previously in this section we've been assuming that there is a particular fitting JSON representation for a type. But this is not always the case. Often one needs to represent particular value with JSON of certain format in one situation and with another format in a different situation. This can be achieved with Boost.JSON by providing an extra argument—context.
Let's implement conversion from user_ns::ip_address
to a JSON string:
namespace user_ns { struct as_string { }; void tag_invoke( boost::json::value_from_tag, boost::json::value& jv, const ip_address& addr, const as_string& ) { boost::json::string& js = jv.emplace_string(); js.resize( 4 * 3 + 3 + 1 ); // XXX.XXX.XXX.XXX\0 auto it = addr.begin(); auto n = std::sprintf( js.data(), "%hhu.%hhu.%hhu.%hhu", it[0], it[1], it[2], it[3] ); js.resize(n); } ip_address tag_invoke( boost::json::value_to_tag<ip_address>, const boost::json::value& jv, const as_string& ) { const boost::json::string& js = jv.as_string(); unsigned char octets[4]; int result = std::sscanf( js.data(), "%hhu.%hhu.%hhu.%hhu", octets, octets + 1, octets + 2, octets + 3 ); if( result != 4 ) throw std::invalid_argument("not an IP address"); return ip_address( octets[0], octets[1], octets[2], octets[3] ); } }
These tag_invoke
overloads
take an extra as_string
parameter,
which disambiguates this specific representation of ip_address
from all other potential representations. In order to take advantage of them
one needs to pass an as_string
object to value_from
or value_to
as the last argument:
ip_address addr( 192, 168, 10, 11 ); value jv = value_from( addr, as_string() ); assert( jv == parse(R"( "192.168.10.11" )") ); ip_address addr2 = value_to< ip_address >( jv, as_string() ); assert(std::equal( addr.begin(), addr.end(), addr2.begin() ));
Note, that if there is no dedicated tag_invoke
overload for a given type and a given context, the implementation falls back
to overloads without context. Thus it is easy to combine contextual conversions
with conversions provided by the library:
std::map< std::string, ip_address > computers = { { "Alex", { 192, 168, 1, 1 } }, { "Blake", { 192, 168, 1, 2 } }, { "Carol", { 192, 168, 1, 3 } }, }; value jv = value_from( computers, as_string() ); assert( jv == parse( "{ " " \"Alex\" : \"192.168.1.1\", " " \"Blake\": \"192.168.1.2\", " " \"Carol\": \"192.168.1.3\" " "} " ) );
Normally, you wouldn't be able to provide conversions for types from third-party
libraries and standard types, because it would require yout to put tag_invoke
overloads into namespaces you
do not control. But with contexts you can put the overloads into your namespaces.
This is because the context will add its associated namespaces into the list
of namespaces where tag_invoke
overloads are searched.
As an example, let's implement conversion for std::chrono::system_clock::time_point
s
using ISO 8601
format.
namespace user_ns { struct as_iso_8601 { }; void tag_invoke( boost::json::value_from_tag, boost::json::value& jv, std::chrono::system_clock::time_point tp, const as_iso_8601& ) { boost::json::string& js = jv.emplace_string(); js.resize( 4 + 2 * 5 + 5 + 1 ); // YYYY-mm-ddTHH:MM:ss\0 std::time_t t = std::chrono::system_clock::to_time_t( tp ); std::tm tm = *std::gmtime( &t ); std::size_t n = std::strftime( js.data(), js.size(), "%FT%T", &tm ); js.resize(n); } }
Reverse conversion is left out for brevity.
The new context is used in a similar fashion to as_string
previously in this section.
std::chrono::system_clock::time_point tp; value jv = value_from( tp, as_iso_8601() ); assert( jv == parse(R"( "1970-01-01T00:00:00" )") );
One particular use case that is enabled by contexts is adaptor libraries that define JSON conversion logic for types from a different library.
Contexts we used so far were empty classes. But contexts may have data members and member functions just as any class. Implementers of conversion functions can take advantage of that to have conversions configurable at runtime or pass special objects to conversions (e.g. allocators).
Let's rewrite conversion for system_clock::time_point
s
to allow any format supported by std::strftime
.
namespace user_ns { struct date_format { std::string format; std::size_t buffer_size; }; void tag_invoke( boost::json::value_from_tag, boost::json::value& jv, std::chrono::system_clock::time_point tp, const date_format& ctx ) { boost::json::string& js = jv.emplace_string(); js.resize( ctx.buffer_size ); std::time_t t = std::chrono::system_clock::to_time_t( tp ); std::size_t n = std::strftime( js.data(), js.size(), ctx.format.c_str(), std::gmtime( &t ) ); js.resize(n); } }
This tag_invoke
overload
lets us change date conversion format at runtime. Also note, that there is
no ambiguity between as_iso_8601
overload and date_format
overload. You can use both in your program:
std::chrono::system_clock::time_point tp; value jv = value_from( tp, date_format{ "%T %D", 18 } ); assert( jv == parse(R"( "00:00:00 01/01/70" )") ); jv = value_from( tp, as_iso_8601() ); assert( jv == parse(R"( "1970-01-01T00:00:00" )") );
Often it is needed to use several conversion contexts together. For example,
consider a log of remote users identified by IP addresses accessing a system.
We can represent it as std::vector< std::pair<std::chrono::system_clock::time_point, ip_address
> >
.
We want to serialize both ip_address
es
and time_point
s as strings,
but for this we need both as_string
and as_iso_8601
contexts.
To combine several contexts just use std::tuple
.
Conversion functions will select the first element of the tuple for which
a tag_invoke
overload exists
and will call that overload. As usual, tag_invoke
overloads that don't use contexts and library-provided generic conversions
are also supported. Thus, here's our example:
using time_point = std::chrono::system_clock::time_point; time_point start; std::vector< std::pair<time_point, ip_address> > log = { { start += std::chrono::seconds(10), {192, 168, 10, 11} }, { start += std::chrono::hours(2), {192, 168, 10, 13} }, { start += std::chrono::minutes(14), {192, 168, 10, 10} }, }; value jv = value_from( log, std::make_tuple( as_string(), as_iso_8601() ) ); assert( jv == parse( " [ " " [ \"1970-01-01T00:00:10\", \"192.168.10.11\" ], " " [ \"1970-01-01T02:00:10\", \"192.168.10.13\" ], " " [ \"1970-01-01T02:14:10\", \"192.168.10.10\" ] " " ] " ) );
In this snippet time_point
is converted using tag_invoke
overload that takes as_iso_8601
,
ip_address
is converted using
tag_invoke
overload that
takes as_string
, and std::vector
is converted using a generic conversion provided by the library.
As was shown previously, generic conversions provided by the library forward contexts to conversions of nested objects. And in the case when you want to provide your own conversion function for a composite type enabled by a particular context, you usually also need to do that.
Consider this example. As was discussed in a previous section, is_map_like
requires that your key
type satisfies is_string_like
. Now, let's say your
keys are not string-like, but they do convert to string
. You can make such maps to
also convert to objects using a context. But if you want to also use another
context for values, you need a way to pass the full combined context to map
elements. So, you want the following test to succeed.
std::map< time_point, ip_address > log = { { start += std::chrono::seconds(10), {192, 168, 10, 11} }, { start += std::chrono::hours(2), {192, 168, 10, 13} }, { start += std::chrono::minutes(14), {192, 168, 10, 10} }, }; value jv = value_from( log, std::make_tuple( maps_as_objects(), as_string(), as_iso_8601() ) ); assert( jv == parse( " { " " \"1970-01-01T00:00:10\": \"192.168.10.11\", " " \"1970-01-01T02:00:10\": \"192.168.10.13\", " " \"1970-01-01T02:14:10\": \"192.168.10.10\" " " } " ) );
For this you will have to use a different overload of tag_invoke
.
This time it has to be a function template, and it should have two parameters
for contexts. The first parameter is the specific context that disambiguates
that particular overload. The second parameter is the full context passed
to value_to
or value_from
.
namespace user_ns { struct maps_as_objects { }; template< class Key, class Value, class Ctx > void tag_invoke( boost::json::value_from_tag, boost::json::value& jv, const std::map<Key, Value>& m, const maps_as_objects&, const Ctx& ctx ) { boost::json::object& jo = jv.emplace_object(); for( const auto& item: m ) { auto k = boost::json::value_from( item.first, ctx, jo.storage() ); auto v = boost::json::value_from( item.second, ctx, jo.storage() ); jo[std::move( k.as_string() )] = std::move( v ); } } template< class Key, class Value, class Ctx > std::map<Key, Value> tag_invoke( boost::json::value_to_tag< std::map<Key, Value> >, boost::json::value const& jv, const maps_as_objects&, const Ctx& ctx ) { const boost::json::object& jo = jv.as_object(); std::map< Key, Value > result; for( auto&& item: jo ) { Key k = boost::json::value_to< Key >( item.key(), ctx ); Value v = boost::json::value_to< Value >( item.value(), ctx ); result.emplace( std::move(k), std::move(v) ); } return result; } }