Sérialisation en C ++

Cet article se concentrera sur l'automatisation du processus de sérialisation en C ++. Au début, nous discuterons des mécanismes de base pour simplifier la lecture / écriture des données dans les flux d'entrée / sortie, après quoi une description sera donnée d'un système de génération de code basé sur libclang primitif. Un lien vers le référentiel avec une version de démonstration de la bibliothèque se trouve à la fin de l'article.

À ruSO, des questions se posent périodiquement concernant la sérialisation des données en C ++, parfois ces questions sont de nature générale, lorsque TC ne sait pas par où commencer, parfois ce sont des questions décrivant un problème spécifique. Le but de cet article est de résumer l'une des manières possibles d'implémenter la sérialisation en C ++, qui vous permettra de suivre les étapes de la construction d'un système depuis les étapes initiales jusqu'à une conclusion logique, lorsque ce système peut déjà être utilisé dans la pratique.

1. Informations initiales


Cet article utilisera un format de données binaires, dont la structure est déterminée en fonction des types d'objets sérialisables. Cette approche nous évite d'utiliser des bibliothèques tierces, se limitant uniquement aux outils fournis par la bibliothèque C ++ standard.

Le processus de sérialisation consistant à convertir l'état d'un objet en un flux d'octets, qui devrait évidemment être accompagné d'opérations d'écriture, ces dernières seront utilisées à la place du terme «sérialisation» pour décrire des détails de bas niveau. De même pour la lecture / désérialisation.

Pour réduire le volume de l'article, seuls des exemples de sérialisation d'objets seront donnés (sauf dans les cas où la désérialisation contient des détails qui méritent d'être mentionnés). Le code complet peut être trouvé dans le référentiel ci-dessus.

2. Types pris en charge


Tout d'abord, il convient de décider des types que nous prévoyons de prendre en charge - cela dépend directement de la façon dont la bibliothèque sera implémentée.

Par exemple, si le choix est limité aux types fondamentaux de C ++, alors un modèle de fonction (qui est une famille de fonctions pour travailler avec les valeurs des types entiers) et ses spécialisations explicites suffiront. Modèle principal (utilisé pour les types std :: int32_t, std :: uint16_t, etc.):

template<typename T> auto write(std::ostream& os, T value) -> std::size_t { const auto pos = os.tellp(); os.write(reinterpret_cast<const char*>(&value), sizeof(value)); return static_cast<std::size_t>(os.tellp() - pos); } 

Remarque : si les données obtenues lors de la sérialisation sont prévues pour être transférées entre des machines avec des ordres d'octets différents, il est nécessaire, par exemple, de convertir une valeur de l'ordre d'octets local en octet réseau, puis d'effectuer l'opération inverse sur la machine distante, donc des modifications seront nécessaires quant à la fonction d'écriture données au flux de sortie, et pour la fonction de lecture à partir du flux d'entrée.

Spécialisation pour bool:

 constexpr auto t_value = static_cast<std::uint8_t>('T'); constexpr auto f_value = static_cast<std::uint8_t>('F'); template<> auto write(std::ostream& os, bool value) -> std::size_t { const auto pos = os.tellp(); const auto tmp = (value) ? t_value : f_value; os.write(reinterpret_cast<const char*>(&tmp), sizeof(tmp)); return static_cast<std::size_t>(os.tellp() - pos); } 

Cette approche définit la règle suivante: si une valeur de type T peut être représentée comme une séquence d'octets de longueur sizeof (T), la définition du modèle primaire peut être utilisée pour cela, sinon, il est nécessaire de déterminer la spécialisation. Cette exigence peut être dictée par les caractéristiques de la représentation d'un objet de type T en mémoire.

Considérez le conteneur std :: string: il est évident que nous ne pouvons pas prendre l'adresse d'un objet du type spécifié, le convertir en un pointeur sur char et l'écrire dans le flux de sortie - ce qui signifie que nous avons besoin d'une spécialisation:

 template<> auto write(std::ostream& os, const std::string& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<std::uint32_t>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); if (len > 0) os.write(value.data(), len); return static_cast<std::size_t>(os.tellp() - pos); } 

Deux points importants à souligner ici:

  1. Non seulement le contenu de la chaîne est écrit dans le flux de sortie, mais également sa taille.
  2. Cast std :: string :: size_type pour taper std :: uint32_t. Dans ce cas, il convient de prêter attention non pas à la taille du type cible, mais au fait qu'il est de longueur fixe. Une telle réduction permettra d'éviter des problèmes dans le cas, par exemple, si des données sont transmises sur un réseau entre des machines de tailles de mots machine différentes.

Nous avons donc découvert que les valeurs des types fondamentaux (et même des objets de type std :: string) peuvent être écrites dans le flux de sortie à l'aide du modèle de fonction d' écriture . Analysons maintenant les modifications que nous devons apporter si nous voulons ajouter des conteneurs à la liste des types pris en charge. Nous n'avons qu'une seule option pour la surcharge - utilisez le paramètre T comme type d'éléments de conteneur. Et si dans le cas de std :: vector cela fonctionnera:

 template<typename T> auto write(std::ostream& os, const std::vector<T>& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<std::uint16_t>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(os.tellp() - pos); if (len > 0) { std::for_each(value.cbegin(), value.cend(), [&](const auto& e) { size += ::write(os, e); }); } return size; } 

, puis avec std: map - non, car le modèle std :: map nécessite au moins deux paramètres - le type de clé et le type de valeur. Ainsi, à ce stade, nous ne pouvons plus utiliser le modèle de fonction - nous avons besoin d'une solution plus universelle. Avant de découvrir comment ajouter la prise en charge des conteneurs, rappelons que nous avons toujours des classes personnalisées. De toute évidence, même en utilisant la solution actuelle, il ne serait pas judicieux de surcharger la fonction d' écriture pour chaque classe qui nécessite une sérialisation. Dans le meilleur des cas, nous aimerions avoir une spécialisation du modèle d' écriture qui fonctionne avec les types de données personnalisés. Mais pour cela, il est nécessaire que les classes aient la capacité de contrôler indépendamment la sérialisation, respectivement, elles devraient avoir une interface qui permettrait à l'utilisateur de sérialiser et de désérialiser les objets de cette classe. Comme il s'avère un peu plus tard, cette interface servira de «dénominateur commun» pour le modèle d' écriture lors de l'utilisation de classes personnalisées. Définissons-le.

 class ISerializable { protected: ~ISerializable() = default; public: virtual auto serialize(std::ostream& os) const -> std::size_t = 0; virtual auto deserialize(std::istream& is) -> std::size_t = 0; virtual auto serialized_size() const noexcept -> std::size_t = 0; }; 

Toute classe qui hérite de ISerializable s'engage à:

  1. Remplacer l'état sérialiser - écrire (membres de données) dans le flux de sortie.
  2. Override deserialize - Lit l'état (initialisation des membres de données) du flux d'entrée.
  3. Override serialized_size - calcule la taille des données sérialisées pour l'état actuel de l'objet.

Donc, revenons au modèle de fonction d' écriture : en général, nous pouvons implémenter la spécialisation pour la classe ISerializable , mais nous ne pouvons pas l'utiliser, jetez un œil:

 template<> auto write(std::ostream& os, const ISerializable& value) -> std::size_t { return value.serialize(os); } 

À chaque fois, nous devions convertir le type d'héritier en ISerializable pour profiter de cette spécialisation. Permettez-moi de vous rappeler qu'au tout début, nous nous étions fixé comme objectif de simplifier l'écriture de code lié à la sérialisation, et non l'inverse, de le compliquer. Donc, si les types pris en charge par notre bibliothèque ne se limitent pas aux types fondamentaux, alors nous devrions chercher une autre solution.

3. stream_writer


L'utilisation de modèles de fonctions pour implémenter une interface universelle pour écrire des données dans un flux n'était pas une solution tout à fait appropriée. La prochaine option que nous devrions vérifier est le modèle de classe. Nous suivrons la même méthodologie que celle utilisée avec le modèle de fonction - le modèle principal sera utilisé par défaut et des spécialisations explicites seront ajoutées pour prendre en charge les types nécessaires.

De plus, nous devons prendre en compte tout ce qui précède sur ISerializable - évidemment, nous ne serons pas en mesure de résoudre le problème avec de nombreuses classes successives sans recourir à type_traits: à partir de C ++ 11, le modèle std :: enable_if est apparu dans la bibliothèque standard, ce qui permet d'ignorer les classes de modèles lorsque certaines conditions lors de la compilation - et c'est exactement ce dont nous allons profiter.

Modèle de classe Stream_writer :

 template<typename T, typename U = void> class stream_writer { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; 

La définition de la méthode d' écriture :

 template<typename T, typename U> auto stream_writer<T, U>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); os.write(reinterpret_cast<const char*>(&value), sizeof(value)); return static_cast<std::size_t>(os.tellp() - pos); } 

La spécialisation pour ISerializable sera la suivante:

 template<typename T> class stream_writer<T, only_if_serializable<T>> : public stream_io<T> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; 

où only_if_serializable est un type d'assistance:

 template<typename T> using only_if_serializable = std::enable_if_t<std::is_base_of_v<ISerializable, T>>; 

Ainsi, si le type T est une classe dérivée de ISerializable , alors cette spécialisation sera considérée comme candidate à l'instanciation, respectivement, si le type T n'est pas dans la même hiérarchie de classes qu'ISerializable , elle sera exclue des candidats possibles.

Il serait juste de poser ici la question suivante: comment cela fonctionnera-t-il? Après tout, le modèle principal aura les mêmes valeurs de paramètres typiques que sa spécialisation - <T, void>. Pourquoi la spécialisation sera-t-elle privilégiée et le sera-t-elle? Réponse: sera, car un tel comportement est prescrit par la norme ( source ):

(1.1) Si exactement une spécialisation correspondante est trouvée, l'instanciation est générée à partir de cette spécialisation

La spécialisation de std :: string ressemblera maintenant à ceci:

 template<typename T> class stream_writer<T, only_if_string<T>> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; template<typename T> auto stream_writer<T, only_if_string<T>>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<std::uint32_t>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); if (len > 0) os.write(value.data(), len); return static_cast<std::size_t>(os.tellp() - pos); } 

où only_if_string est déclaré comme:

 template<typename T> using only_if_string = std::enable_if_t<std::is_same_v<T, std::string>>; 

Il est temps de retourner dans les conteneurs. Dans ce cas, nous pouvons utiliser le type de conteneur paramétré avec un certain type de U, ou <U, V>, comme dans le cas de std :: map, directement comme valeur du paramètre T du modèle de la classe stream_writer . Ainsi, rien ne change dans l'interface de notre interface - c'est ce que nous visions. Cependant, la question se pose, quel devrait être le deuxième paramètre du modèle pour la classe stream_writer pour que tout fonctionne correctement? C'est dans le chapitre suivant.

4. Concepts


Tout d'abord, je donnerai une brève description des concepts utilisés, puis seulement je montrerai des exemples mis à jour.

 template<typename T> concept String = std::is_same_v<T, std::string>; 

Honnêtement, ce concept a été défini pour la fraude, que nous verrons sur la ligne suivante:

 template<typename T> concept Container = !String<T> && requires (T a) { typename T::value_type; typename T::reference; typename T::const_reference; typename T::iterator; typename T::const_iterator; typename T::size_type; { a.begin() } -> typename T::iterator; { a.end() } -> typename T::iterator; { a.cbegin() } -> typename T::const_iterator; { a.cend() } -> typename T::const_iterator; { a.clear() } -> void; }; 

Le conteneur contient les exigences que nous «faisons» au type pour vraiment nous assurer qu'il s'agit bien d'un des types de conteneur. C'est exactement l'ensemble des exigences dont nous aurons besoin lors de l'implémentation de stream_writer , la norme a bien sûr beaucoup plus d'exigences.

 template<typename T> concept SequenceContainer = Container<T> && requires (T a, typename T::size_type count) { { a.resize(count) } -> void; }; 

Concept pour les conteneurs séquentiels: std :: vector, std :: list, etc.

 template<typename T> concept AssociativeContainer = Container<T> && requires (T a) { typename T::key_type; }; 

Concept pour les conteneurs associatifs: std :: map, std :: set, std :: unordered_map, etc.

Maintenant, pour déterminer la spécialisation des conteneurs consécutifs, il ne nous reste plus qu'à imposer des restrictions sur le type T:

 template<typename T> requires SequenceContainer<T> class stream_writer<T, void> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; template<typename T> requires SequenceContainer<T> auto stream_writer<T, void>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); // to support std::forward_list we have to use std::distance() const auto len = static_cast<std::uint16_t>( std::distance(value.cbegin(), value.cend())); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(os.tellp() - pos); if (len > 0) { using value_t = typename stream_writer::value_type; std::for_each(value.cbegin(), value.cend(), [&](const auto& item) { size += stream_writer<value_t>::write(os, item); }); } return size; } 

Conteneurs pris en charge:

  • std :: vecteur
  • std :: deque
  • std :: list
  • std :: forward_list

De même pour les conteneurs associatifs:

 template<typename T> requires AssociativeContainer<T> class stream_writer<T, void> : public stream_io<T> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; template<typename T> requires AssociativeContainer<T> auto stream_writer<T, void>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<typename stream_writer::size_type>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(os.tellp() - pos); if (len > 0) { using value_t = typename stream_writer::value_type; std::for_each(value.cbegin(), value.cend(), [&](const auto& item) { size += stream_writer<value_t>::write(os, item); }); } return size; } 

Conteneurs pris en charge:

  • std :: map
  • std :: unordered_map
  • std :: set
  • std :: unordered_set

Dans le cas de map, il y a une petite nuance, elle concerne l'implémentation de stream_reader . Le type value_type pour std :: map <K, T> est std :: pair <const K, T>, respectivement, lorsque nous essayons de convertir un pointeur en const K en un pointeur en char lors de la lecture du flux d'entrée, nous obtenons une erreur de compilation. Nous pouvons résoudre ce problème comme suit: nous savons que pour les conteneurs associatifs value_type est soit un seul type K ou std :: pair <const K, V>, alors nous pouvons écrire de petites classes d'aide de modèle qui seront paramétrées par value_type et à l'intérieur déterminer le type dont nous avons besoin.

Pour std :: set, tout reste inchangé:

 template<typename U, typename V = void> struct converter { using type = U; }; 

Pour std :: map - supprimez const:

 template<typename U> struct converter<U, only_if_pair<U>> { using type = std::pair<std::remove_const_t<typename U::first_type>, typename U::second_type>; }; 

La définition de lecture pour les conteneurs associatifs:

 template<typename T> requires AssociativeContainer<T> auto stream_reader<T, void>::read(std::istream& is, T& value) -> std::size_t { const auto pos = is.tellg(); typename stream_reader::size_type len = 0; is.read(reinterpret_cast<char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(is.tellg() - pos); if (len > 0) { for (auto i = 0U; i < len; ++i) { using value_t = typename converter<typename stream_reader::value_type>::type; value_t v {}; size += stream_reader<value_t>::read(is, v); value.insert(std::move(v)); } } return size; } 


5. Fonctions auxiliaires


Prenons un exemple:

 class User : public ISerializable { public: User(std::string_view username, std::string_view password) : m_username(username) , m_password(password) {} SERIALIZABLE_INTERFACE protected: std::string m_username {}; std::string m_password {}; }; 

La définition de la méthode serialize (std :: ostream &) pour cette classe devrait ressembler à ceci:

 auto User::serialize(std::ostream& os) const -> std::size_t { auto size = 0U; size += stream_writer<std::string>::write(os, m_username); size += stream_writer<std::string>::write(os, m_password); return size; } 

Cependant, vous devez admettre qu'il n'est pas pratique d'indiquer à chaque fois le type d'objet qui est écrit dans le flux de sortie. Nous écrivons une fonction auxiliaire qui déduirait automatiquement le type T:

 template<typename T> auto write(std::ostream& os, const T& value) -> std::size_t { return stream_writer<T>::write(os, value); } 

Maintenant, la définition est la suivante:

 auto User::serialize(std::ostream& os) const -> std::size_t { auto size = 0U; size += ::write(os, m_username); size += ::write(os, m_password); return size; } 

Le dernier chapitre nécessitera quelques fonctions d'assistance supplémentaires:

 template<typename T> auto write_recursive(std::ostream& os, const T& value) -> std::size_t { return ::write(os, value); } template<typename T, typename... Ts> auto write_recursive(std::ostream& os, const T& value, const Ts&... values) { auto size = write_recursive(os, value); return size + write_recursive(os, values...); } template<typename... Ts> auto write_all(std::ostream& os, const Ts&... values) -> std::size_t { return write_recursive(os, values...); } 

La fonction write_all vous permet de répertorier tous les objets à sérialiser à la fois, tandis que write_recursive garantit le bon ordre d'écriture dans le flux de sortie. Si l'ordre des calculs était défini pour les expressions de repli (à condition d'utiliser l'opérateur binaire +), nous pourrions les utiliser. En particulier, dans la fonction size_of_all (elle n'a pas été mentionnée plus haut, elle est utilisée pour calculer la taille des données sérialisées), ce sont les expressions de repli qui sont utilisées en raison de l'absence d'opérations d'entrée-sortie.

6. Génération de code


L'API libclang-C pour clang est utilisée pour générer le code. De haut niveau, cette tâche peut être décrite comme suit: nous devons parcourir récursivement le répertoire avec le code source, vérifier tous les fichiers d'en-tête pour les classes marquées avec un attribut spécial, et s'il y en a un, vérifier les membres de données pour le même attribut et compiler la chaîne à partir des noms des membres de données répertorié avec une virgule. Il ne nous reste plus qu'à écrire les modèles de définition des fonctions de la classe ISerializable (dans laquelle nous ne pouvons que mettre l'énumération des membres de données nécessaires).

Un exemple de classe pour laquelle le code sera généré:

 class __attribute__((annotate("serializable"))) User : public ISerializable { public: User(std::string_view username, std::string_view password) : m_username(username) , m_password(password) {} User() = default; virtual ~User() = default; SERIALIZABLE_INTERFACE protected: __attribute__((annotate("serializable"))) std::string m_username {}; __attribute__((annotate("serializable"))) std::string m_password {}; }; 

Les attributs sont écrits dans le style GNU car libclang refuse de reconnaître le format d'attribut de C ++ 20, et il ne prend pas non plus en charge les attributs non annotés. Traversée du répertoire source:

 for (const auto& file : fs::recursive_directory_iterator(argv[1])) { if (file.is_regular_file() && file.path().extension() == ".hpp") { processTranslationUnit(file, dst); } } 

La définition de la fonction processTranslationUnit :

 auto processTranslationUnit(const fs::path& path, const fs::path& targetDir) -> void { const auto pathname = path.string(); arg::Context context { false, false }; auto translationUnit = arg::TranslationUnit::parse(context, pathname.c_str(), CXTranslationUnit_None); arg::ClassExtractor extractor; extractor.extract(translationUnit.cursor()); const auto& classes = extractor.classes(); for (const auto& [name, c] : classes) { SerializableDefGenerator::processClass(c, path, targetDir.string()); } } 

Dans cette fonction, seul ClassExtractor nous intéresse - tout le reste est nécessaire pour la formation d'AST. La définition de la fonction d' extraction est la suivante:

   void ClassExtractor::extract(const CXCursor& cursor) { clang_visitChildren(cursor, [](CXCursor c, CXCursor, CXClientData data) { if (clang_getCursorKind(c) == CXCursorKind::CXCursor_ClassDecl) { /*   */ /* -    - */ /* -     */ } return CXChildVisit_Continue; } , this); } 

Ici, nous voyons déjà directement les fonctions de l'API C pour clang. Nous avons intentionnellement laissé uniquement le code nécessaire pour comprendre comment libclang est utilisé. Tout ce qui reste dans les coulisses ne contient pas d'informations importantes - c'est juste un enregistrement des noms de classe, des membres de données, etc. Un code plus détaillé peut être trouvé dans le référentiel.

Et enfin, dans la fonction processClass , la présence des attributs de sérialisation de chaque classe trouvée est vérifiée, et s'il y en a un, un fichier est généré avec la définition des fonctions nécessaires. Le référentiel fournit des exemples spécifiques: où obtenir le ou les noms de l'espace de noms (ces informations sont stockées directement dans la classe Class ) et le chemin d'accès au fichier d'en-tête.

Pour la tâche susmentionnée, la bibliothèque Argentum est utilisée, ce que, malheureusement, je ne vous recommande pas d'utiliser - j'ai commencé à la développer à d'autres fins, mais parce que pour cette tâche, j'avais juste besoin des fonctionnalités qui y étaient implémentées, et j'étais paresseux, Je n'ai pas réécrit le code, mais l'ai simplement posté sur Bintray et connecté au fichier CMake via le gestionnaire de paquets Conan. Tout ce que cette bibliothèque fournit est de simples wrappers sur l'API clang C pour les classes et les membres de données.

Et encore une petite remarque - je ne fournis pas de bibliothèque toute faite, je dis seulement comment l'écrire.

UPD0 : cppast peut être utilisé à la place de libclang . Merci à masterspline pour le lien fourni.

1. github.com/isnullxbh/dsl
2. github.com/isnullxbh/Argentum

Source: https://habr.com/ru/post/fr479462/


All Articles