Reflexión estática y serialización de agregados en JSON

Introducción


Le Penseur - Auguste Rodin
Nota: Este artículo actualiza un post originalmente publicado en 2015 acerca de la gestión de ficheros. Esta nueva versión analiza la codificación/descodificación de agregados de datos mediante la biblioteca JSON for Modern C++, así como la introducción de reflexión estática mediante Boost.Hana con el fin de simplificar la tarea del programador.



La transformación de un agregado de datos diversos en un formato legible de intercambio como JSON es una acción recurrente para la que existen múltiples soluciones en C++. Según las necesidades del programador, disponemos de una amplia variedad de bibliotecas de serialización con distintos grados de adaptación a la biblioteca estándar y niveles de rendimiento. Puedes consultar un benchmark exhaustivo a este respecto en el siguiente portal:

https://github.com/miloyip/nativejson-benchmark

La biblioteca header-only JSON for Modern C++ de Niels Lohmann, en la que centraremos nuestra atención en este artículo, ofrece una sintaxis intuitiva y una limpia integración con la biblioteca estándar del lenguaje, facilitando en gran medida la gestión de datos JSON. Su instalación a través de package managers como Conan, Homebrew, MSYS2 o vcpkg resulta inmediata. Para más información, puedes consultar su portal en GitHub:

https://github.com/nlohmann/json

Una vez instalada, bastará incluir el siguiente fichero de cabecera para integrar la biblioteca en nuestros proyectos:

#include <nlohmann/json.hpp>


Funciones de serialización/deserialización


A modo de ejemplo, consideremos un agregado Target que contenga, como datos miembros, el nombre (string), el nivel de dificultad (int) y la localización 3-dimensional (array) de un objetivo ubicado en un campamento militar en el contexto de un videojuego. Una variable booleana adicional nos informará si el target está ya completado:

namespace game {     struct Target {        std::string name{};        int level{};        std::array<double3> location{};        bool achieved{};     }; // el espacio de nombres continúa más abajo...

Con el fin de habilitar la serialización/deserialización de objetos de tipo Target en formato JSON, definiremos dos funciones externas a la estructura, de nombre to_json y from_json, ubicadas en el mismo espacio de nombres al que pertenece el agregado:

// ...continuación del espacio de nombres     auto to_json(nlohmann::json& j, Target const& t) -> void     {        j = nlohmann::json{           {"name", t.name},           {"level", t.level},           {"location", t.location},           {"achieved", t.achieved}        };     }     auto from_json(nlohmann::json const& j, Target& t) -> void     {        j.at("name").get_to(t.name);        j.at("level").get_to(t.level);        j.at("location").get_to(t.location);        j.at("achieved").get_to(t.achieved);     } } // cierre del espacio de nombres 'game'  

No es necesario añadir serializadores/deserializadores para los contenedores estándar como std::arraystd::vector o std::map, al venir ya implementados en la biblioteca. Por desgracia, el lenguaje carece actualmente de capacidades nativas de reflexión estática, al menos hasta su esperada inclusión en el estándar C++23. Ello hace necesario explicitar las operaciones de transformación anteriores para la clase Target en la forma indicada, salvo que contemplemos la posibilidad de habilitar la reflexión a través de bibliotecas de meta-programación con templates como Boost.Hana, punto éste que analizaremos en la última sección del post.

La invocación del método at() en la función de deserialización from_json(), en particular, emitirá una excepción de tipo nlohmann::json::exception de no hallarse la clave proporcionada como argumento.


Ejemplo de uso


Consideremos el siguiente vector de targets (utilizamos aquí los inicializadores designados para agredados permitidos por C++20, tales como .name o .level, con el fin de mejorar la comprensión del código):

auto const targets = std::vector<game::Target>{     {.name = "soldier",  .level = 12, .location = {3.45.60.5}, .achieved = false},     {        "treasure",          0,              {7.83.25.0},             true},     {        "leader",            15,             {9.42.12.6},             false},     {        "soldier",           10,             {1.08.91.0},             true},     {        "treasure",          0,              {2.55.15.0},             false}, };

La biblioteca JSON bajo estudio permite el registro inmediato de estos objetos en un fichero JSON Lines (http://jsonlines.org/) a través de un sencillo bucle for que itera el vector:

if (auto ofs = std::ofstream{"military_camp.jsonl", std::ios::binary}) {     for (auto const& target : targets)        ofs << nlohmann::json(target) << '\n'; // conversión Target-->json } else {     std::cerr << "Unable to open the file\n";     // hagamos algo al respecto... }

Observemos que la apertura del fichero de escritura ofs sirve como condición misma de la sentencia if. Nos servimos aquí de la sobrecarga que la clase std::ofstream realiza del operador operator bool. Ésta permite emplear flujos como condiciones de sentencias de control, retornando true si el fichero está abierto (listo para realizar operaciones de salida) o false de haberse producido un error. La construcción de un objeto JSON a partir de un target se realiza mediante la llamada al constructor nlohmann::json(target), que a su vez invoca implícitamente a la función to_json() definida al principio de este post. Cada target es así registrado como objeto JSON (es decir, una serie de parejas clave-valor) en el fichero. Los objetos JSON se encuentran delimitados entre sí por un salto de línea '\n'. Por supuesto, el destructor de la clase std::ofstream es invocado automáticamente al salir ofs fuera de ámbito, cerrando el flujo al fichero.

La reconstrucción posterior del vector de targets original a partir del fichero military_camp.jsonl resulta también inmediata, sin más que parsear cada una de sus líneas JSON:

auto targets = std::vector<game::Target>{}; // vector inicialmente vacío if (auto ifs = std::ifstream{"military_camp.jsonl", std::ios::binary}) {     auto json_line = std::string{};     while (std::getline(ifs, json_line)) { // parseamos el fichero línea a línea        auto const j = nlohmann::json::parse(json_line);        targets.push_back(j.get<game::Target>()); // conversión json-->Target     } } else {     std::cerr << "Unable to open the file\n";     // hagamos algo al respecto... }

En el código anterior, cada línea JSON del archivo es parseada a un objeto JSON de nombre j. La invocación j.get<game::Target>() --que llama implícitamente a la función from_json()-- retorna finalmente el objeto Target a insertar al fondo del vector.


Reflexión con Boost.Hana


Esta reconocida biblioteca de meta-programación con templates desarrollada por Louis Dionne hace posible, mediante introspección, automatizar la generación de las funciones to_json() y from_json() para estructuras simples como las empleadas en este artículo.

En efecto, resulta relativamente sencillo diseñar un fichero de cabecera independiente (al que habría que añadir una licencia de uso adecuada) que incluya las bibliotecas JSON for Modern C++ y Boost.Hana y que facilite una función macro encargada de definir, de forma genérica, tanto el agregado de datos (que debe contener al menos un dato miembro) como las funciones de serialización (to_json) y deserialización (from_json) correspondientes:

// reflection_json.hpp - Intended for teaching purposes only #ifndef REFLECTION_JSON_HPP #define REFLECTION_JSON_HPP #include <boost/hana.hpp> #include <nlohmann/json.hpp> #define REFLECTION_JSON_STRUCT(S, ...)                   \     struct S {                                            \        BOOST_HANA_DEFINE_STRUCT(S, __VA_ARGS__);          \     };                                                    \                                                           \    inline auto to_json(nlohmann::json& j, S const& s)    \     {                                                     \        using namespace boost;                             \        hana::for_each(hana::keys(s), [&](auto&& name) {   \           auto const key = hana::to<char const*>(name);   \           j[key] = hana::at_key(s, name);                 \        });                                                \     }                                                     \                                                           \     inline auto from_json(nlohmann::json const& j, S& s)  \     {                                                     \        using namespace boost;                             \        hana::for_each(hana::keys(s), [&](auto&& name) {   \           auto const key = hana::to<char const*>(name);   \           auto& value = hana::at_key(s, name);            \           j.at(key).get_to(value);                        \        });                                                \     } static_assert(true"expect trailing semicolon") #endif // REFLECTION_JSON_HPP

Llegados a este punto, sería posible definir tanto el agregado Target como sus funciones asociadas de serialización/deserialización en la forma simplificada siguiente (a comparar con los dos primeros cuadros de código de este artículo):

#include "reflection_json.hpp" namespace game {     REFLECTION_JSON_STRUCT(Target,        (std::string, name),        (int, level),        (std::array<double3>, location),        (bool, achieved)     ); } // cierre del espacio de nombres 'game'

El resto de códigos de ejemplo de este artículo --envío de targets en un vector a un fichero y carga de datos de vuelta al vector-- permanecería invariante.

Puedes encontrar más información acerca de Boost.Hana en su portal:

https://www.boost.org/doc/libs/1_71_0/libs/hana/doc/html/index.html

No hay comentarios:

Publicar un comentario