Dieser Artikel konzentriert sich auf die Automatisierung des Serialisierungsprozesses in C ++. Zu Beginn werden die grundlegenden Mechanismen betrachtet, die das Lesen / Schreiben von Daten in Eingabe- / Ausgabestreams erleichtern. Anschließend wird ein primitives libclang-basiertes Codegenerierungssystem beschrieben. Ein Link zum Repository mit einer Demoversion der Bibliothek befindet sich am Ende des Artikels.
Bei ruSO tauchen regelmäßig Fragen zur Serialisierung von Daten in C ++ auf, manchmal sind diese Fragen allgemeiner Natur, wenn TC im Grunde nicht weiß, wo man anfangen soll, manchmal handelt es sich um Fragen, die ein bestimmtes Problem beschreiben. In diesem Artikel wird eine der möglichen Methoden zur Implementierung der Serialisierung in C ++ zusammengefasst, mit der Sie die Schritte zum Erstellen eines Systems von den ersten Schritten bis zu einer logischen Schlussfolgerung verfolgen können, wenn dieses System bereits in der Praxis verwendet werden kann.
1. Erstinformation
In diesem Artikel wird ein binäres Datenformat verwendet, dessen Struktur anhand der Typen serialisierbarer Objekte bestimmt wird. Dieser Ansatz erspart uns die Verwendung von Bibliotheken von Drittanbietern, da wir uns nur auf die Tools beschränken, die von der Standard-C ++ - Bibliothek bereitgestellt werden.
Da der Serialisierungsprozess darin besteht, den Zustand eines Objekts in einen Strom von Bytes umzuwandeln, der offensichtlich von Schreiboperationen begleitet sein sollte, wird letzteres anstelle des Begriffs "Serialisierung" verwendet, wenn Details auf niedriger Ebene beschrieben werden. Ähnliches gilt für read / deserialize.
Um das Volumen des Artikels zu verringern, werden nur Beispiele für die Serialisierung von Objekten angegeben (außer in Fällen, in denen die Deserialisierung einige erwähnenswerte Details enthält). Der vollständige Code befindet sich im obigen Repository.
2. Unterstützte Typen
Zuallererst lohnt es sich zu entscheiden, welche Typen wir unterstützen möchten - es hängt direkt davon ab, wie die Bibliothek implementiert wird.
Wenn die Auswahl beispielsweise auf die grundlegenden C ++ - Typen beschränkt ist, reichen eine Funktionsvorlage (eine Funktionsfamilie für die Arbeit mit Werten ganzzahliger Typen) und ihre expliziten Spezialisierungen aus. Primäre Vorlage (wird für die Typen std :: int32_t, std :: uint16_t usw. verwendet):
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); }
Hinweis : Wenn die während der Serialisierung erhaltenen Daten zwischen Computern mit unterschiedlichen Byte-Reihenfolgen übertragen werden sollen, muss beispielsweise ein Wert aus der lokalen Byte-Reihenfolge in ein Netzwerkbyte konvertiert und anschließend der umgekehrte Vorgang auf dem Remotecomputer ausgeführt werden, sodass Änderungen für die Schreibfunktion erforderlich sind Daten in den Ausgabestrom und für die Funktion des Lesens aus dem Eingabestrom.
Spezialisierung für 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); }
Dieser Ansatz definiert die folgende Regel: Wenn ein Wert vom Typ T als eine Folge von Bytes der Länge sizeof (T) dargestellt werden kann, kann die Definition der primären Schablone dafür verwendet werden, andernfalls muss die Spezialisierung bestimmt werden. Diese Anforderung kann durch die Merkmale der Darstellung eines Objekts vom Typ T im Speicher vorgegeben werden.
Betrachten Sie den Container std :: string: Es ist offensichtlich, dass wir die Adresse eines Objekts des angegebenen Typs nicht in einen Zeiger auf char umwandeln und in den Ausgabestream schreiben können - das bedeutet, wir müssen uns spezialisieren:
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); }
Zwei wichtige Punkte, die Sie hier ansprechen sollten:
- In den Ausgabestream wird nicht nur der Inhalt des Strings geschrieben, sondern auch dessen Größe.
- Konvertieren Sie std :: string :: size_type in std :: uint32_t. In diesem Fall sollte nicht auf die Größe des Zieltyps geachtet werden, sondern auf die Tatsache, dass er eine feste Länge hat. Eine solche Reduzierung ermöglicht es, Probleme zu vermeiden, wenn beispielsweise Daten über ein Netzwerk zwischen Maschinen mit unterschiedlichen Maschinenwortgrößen übertragen werden.
Wir haben also herausgefunden, dass Werte grundlegender Typen (und sogar Objekte vom Typ std :: string) mithilfe der
Schreibfunktionsvorlage in den Ausgabestream
geschrieben werden können. Lassen Sie uns nun analysieren, welche Änderungen vorgenommen werden müssen, wenn der Liste der unterstützten Typen Container hinzugefügt werden sollen. Wir haben nur eine Option zum Überladen - verwenden Sie den T-Parameter als Typ der Containerelemente. Und wenn im Fall von std :: vector dies funktioniert:
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; }
, dann mit std: map - nein, da die Vorlage std :: map mindestens zwei Parameter erfordert - den Schlüsseltyp und den Wertetyp. In diesem Stadium können wir die Funktionsvorlage daher nicht mehr verwenden - wir benötigen eine universellere Lösung. Bevor wir herausfinden, wie die Containerunterstützung hinzugefügt wird, möchten wir daran erinnern, dass wir noch benutzerdefinierte Klassen haben. Selbst unter Verwendung der aktuellen Lösung wäre es natürlich nicht ratsam, die
Schreibfunktion für jede Klasse, die eine Serialisierung erfordert, zu überladen. Im besten Fall möchten wir eine Spezialisierung des
Schreibmusters haben, die mit benutzerdefinierten Datentypen funktioniert. Hierzu ist es jedoch erforderlich, dass Klassen die Möglichkeit haben, die Serialisierung unabhängig zu steuern, bzw. dass sie über eine Schnittstelle verfügen, über die der Benutzer Objekte dieser Klasse serialisieren und deserialisieren kann. Wie sich später herausstellt, dient diese Schnittstelle als "gemeinsamer Nenner" für die
Schreibvorlage, wenn Sie mit benutzerdefinierten Klassen arbeiten. Definieren wir es.
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; };
Jede Klasse, die von
ISerializable erbt, stimmt zu:
- Serialisierung überschreiben - Schreibstatus (Datenelemente) in den Ausgabestream.
- Override deserialize - Liest den Status (Initialisierung von Datenelementen) aus dem Eingabestream.
- Serialized_size überschreiben - berechnet die Größe der serialisierten Daten für den aktuellen Status des Objekts.
Zurück zur
Schreibfunktionsvorlage : Im Allgemeinen können wir die Spezialisierung für die
ISerializable- Klasse
implementieren , aber wir können sie nicht verwenden. Schauen Sie sich das an:
template<> auto write(std::ostream& os, const ISerializable& value) -> std::size_t { return value.serialize(os); }
Jedes Mal mussten wir
den Typ
des Erben auf
ISerializable setzen , um diese Spezialisierung zu nutzen. Lassen Sie mich daran erinnern, dass wir uns gleich zu Beginn zum Ziel gesetzt haben, das Schreiben von Code im Zusammenhang mit der Serialisierung zu vereinfachen und nicht umgekehrt, um es zu komplizieren. Wenn also die von unserer Bibliothek unterstützten Typen nicht auf grundlegende Typen beschränkt sind, sollten wir nach einer anderen Lösung suchen.
3. stream_writer
Die Verwendung von Funktionsschablonen zur Implementierung einer universellen Schnittstelle zum Schreiben von Daten in einen Stream war keine vollständig geeignete Lösung. Die nächste Option, die wir prüfen sollten, ist die Klassenvorlage. Wir folgen der gleichen Methode wie bei der Funktionsvorlage - die primäre Vorlage wird standardmäßig verwendet, und es werden explizite Spezialisierungen hinzugefügt, um die erforderlichen Typen zu unterstützen.
Darüber hinaus sollten wir all die obigen
Punkte zu
ISerializable berücksichtigen - offensichtlich können wir das Problem bei vielen Nachfolgeklassen nicht lösen, ohne auf type_traits zurückzugreifen: Ab C ++ 11 wurde die Vorlage std :: enable_if in der Standardbibliothek angezeigt, sodass Vorlagenklassen ignoriert werden können, wenn bestimmte Bedingungen während der Kompilierung - und genau das werden wir ausnutzen.
Stream_writer- Klassenvorlage:
template<typename T, typename U = void> class stream_writer { public: static auto write(std::ostream& os, const T& value) -> std::size_t; };
Die Definition der
Schreibmethode :
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); }
Die Spezialisierung für
ISerializable lautet wie folgt:
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; };
Dabei ist only_if_serializable ein Hilfstyp:
template<typename T> using only_if_serializable = std::enable_if_t<std::is_base_of_v<ISerializable, T>>;
Wenn also Typ T eine von
ISerializable abgeleitete
Klasse ist , wird diese Spezialisierung als Kandidat für die Instanziierung betrachtet.
Befindet sich Typ T nicht in derselben Klassenhierarchie wie
ISerializable , wird er von möglichen Kandidaten ausgeschlossen.
Es wäre fair, hier die folgende Frage zu stellen: Wie wird das funktionieren? Immerhin hat die primäre Vorlage dieselben Werte typischer Parameter wie ihre Spezialisierung - <T, void>. Warum wird die Spezialisierung bevorzugt und wird sie bevorzugt? Antwort: wird sein, da ein solches Verhalten durch die Norm (
Quelle ) vorgeschrieben ist:
(1.1) Wird genau eine passende Spezialisierung gefunden, wird die Instanziierung aus dieser Spezialisierung generiert
Die Spezialisierung für std :: string sieht nun folgendermaßen aus:
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); }
Dabei wird only_if_string wie folgt deklariert:
template<typename T> using only_if_string = std::enable_if_t<std::is_same_v<T, std::string>>;
Es ist Zeit, zu den Containern zurückzukehren. In diesem Fall können wir den mit einem Typ von U oder <U, V> parametrisierten Containertyp wie im Fall von std :: map direkt als Wert des Parameters T der Vorlage der
stream_writer- Klasse verwenden. Es ändert sich also nichts an der Oberfläche in unserer Oberfläche - das haben wir uns zum Ziel gesetzt. Es stellt sich jedoch die Frage, was der zweite Parameter der Vorlage für die
stream_writer- Klasse sein soll,
damit alles korrekt funktioniert. Dies ist im nächsten Kapitel.
4. Konzepte
Zuerst werde ich eine kurze Beschreibung der verwendeten Konzepte geben und erst dann aktualisierte Beispiele zeigen.
template<typename T> concept String = std::is_same_v<T, std::string>;
Ehrlich gesagt wurde dieses Konzept für Betrug definiert, was wir in der nächsten Zeile sehen werden:
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; };
Der Container enthält die Anforderungen, die wir an den Typ „stellen“, um wirklich sicherzustellen, dass es sich um einen der Containertypen handelt. Dies sind genau die Anforderungen, die wir für die Implementierung von
stream_writer benötigen. Der Standard hat natürlich viel mehr Anforderungen.
template<typename T> concept SequenceContainer = Container<T> && requires (T a, typename T::size_type count) { { a.resize(count) } -> void; };
Konzept für sequentielle Container: std :: vector, std :: list, etc.
template<typename T> concept AssociativeContainer = Container<T> && requires (T a) { typename T::key_type; };
Konzept für assoziative Container: std :: map, std :: set, std :: unordered_map usw.
Um die Spezialisierung für aufeinanderfolgende Container zu bestimmen, müssen wir nur noch den Typ T einschränken:
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();
Unterstützte Container:
- std :: vector
- std :: deque
- std :: liste
- std :: forward_list
Ähnliches gilt für assoziative Container:
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; }
Unterstützte Container:
- std :: map
- std :: unordered_map
- std :: set
- std :: unordered_set
Bei map gibt es eine kleine Nuance, die die Implementierung von
stream_reader betrifft . Der Wert_Typ für std :: map <K, T> ist std :: pair <const K, T>. Wenn wir beim Lesen aus dem Eingabestream versuchen, einen Zeiger auf const K auf einen Zeiger auf char zu setzen, wird ein Kompilierungsfehler ausgegeben. Wir können dieses Problem wie folgt lösen: Wir wissen, dass value_type für assoziative Container entweder ein einzelner Typ K oder std :: pair <const K, V> ist. Dann können wir kleine Template-Hilfsklassen schreiben, die durch value_type und inside parametrisiert werden Bestimmen Sie den Typ, den wir brauchen.
Für std :: set bleibt alles unverändert:
template<typename U, typename V = void> struct converter { using type = U; };
Für std :: map - entferne 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>; };
Die Definition von
read für assoziative Container:
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. Hilfsfunktionen
Betrachten Sie ein Beispiel:
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 {}; };
Die Definition der Serialize-Methode (std :: ostream &) für diese Klasse sollte folgendermaßen aussehen:
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; }
Sie müssen jedoch zugeben, dass es unpraktisch ist, jedes Mal den Objekttyp anzugeben, der in den Ausgabestream geschrieben wird. Wir schreiben eine Hilfsfunktion, die automatisch den Typ T herleitet:
template<typename T> auto write(std::ostream& os, const T& value) -> std::size_t { return stream_writer<T>::write(os, value); }
Nun lautet die Definition wie folgt:
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; }
Das letzte Kapitel wird einige weitere Hilfsfunktionen erfordern:
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...); }
Mit der Funktion
write_all können
Sie alle zu serialisierenden Objekte gleichzeitig
auflisten , während
write_recursive die richtige Reihenfolge für das Schreiben in den Ausgabestream sicherstellt. Wenn die Reihenfolge der Berechnungen für
Fold-Ausdrücke definiert wäre (vorausgesetzt, wir verwenden den binären + -Operator), könnten wir sie verwenden. Insbesondere in der Funktion
size_of_all (die zuvor nicht erwähnt wurde, wird sie zum Berechnen der Größe serialisierter Daten verwendet) werden Fold-Ausdrücke verwendet, da keine Eingabe-Ausgabe-Operationen vorhanden sind.
6. Codegenerierung
Die libclang-C-API für clang wird zum Generieren des Codes verwendet. Allgemein lässt sich diese Aufgabe wie folgt beschreiben: Wir müssen das Verzeichnis mit dem Quellcode rekursiv durchgehen, alle Header-Dateien auf Klassen prüfen, die mit einem speziellen Attribut gekennzeichnet sind, und, falls es eine gibt, die Datenelemente auf dasselbe Attribut prüfen und die Zeichenfolge aus den Namen der Datenelemente kompilieren mit einem Komma aufgeführt.
Jetzt müssen wir nur noch die Definitionsvorlagen für die Funktionen der Klasse
ISerializable schreiben (in die wir nur die Aufzählung der erforderlichen Datenelemente schreiben können).
Ein Beispiel für eine Klasse, für die der Code generiert wird:
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 {}; };
Attribute werden im GNU-Stil geschrieben, weil libclang das Attributformat von C ++ 20 nicht erkennt und auch nicht kommentierte Attribute nicht unterstützt. Quellverzeichnis Traversal:
for (const auto& file : fs::recursive_directory_iterator(argv[1])) { if (file.is_regular_file() && file.path().extension() == ".hpp") { processTranslationUnit(file, dst); } }
Die Definition der
processTranslationUnit- Funktion:
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()); } }
In dieser Funktion interessiert uns nur
ClassExtractor - alles andere ist für die Bildung von AST notwendig. Die Definition der
Extraktfunktion lautet wie folgt:
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); }
Hier sehen wir bereits direkt die C-API-Funktionen für clang. Wir haben absichtlich nur den Code hinterlassen, der benötigt wird, um zu verstehen, wie libclang verwendet wird. Alles, was hinter den Kulissen bleibt, enthält keine wichtigen Informationen - es ist nur eine Registrierung von Klassennamen, Datenelementen usw. Detaillierteren Code finden Sie im Repository.
Und schließlich wird in der Funktion
processClass das Vorhandensein von Serialisierungsattributen jeder gefundenen Klasse überprüft, und falls es eines gibt, wird eine Datei mit der Definition der erforderlichen Funktionen generiert. Das Repository enthält spezifische Beispiele: Woher die Namen des Namespaces (diese Informationen werden direkt in der
Class- Klasse gespeichert) und den Pfad zur Header-Datei.
Für die oben genannte Aufgabe wird die Argentum-Bibliothek verwendet, deren Verwendung ich leider nicht empfehle. Ich habe damit begonnen, sie für andere Zwecke zu entwickeln, aber da ich für diese Aufgabe nur die dort implementierte Funktionalität benötigte und faul war, Ich habe den Code nicht umgeschrieben, sondern einfach auf Bintray gepostet und ihn über den Conan-Paketmanager mit der CMake-Datei verbunden. Diese Bibliothek bietet lediglich einfache Wrapper für die Clang-C-API für Klassen und Datenmember.Und noch eine kleine Bemerkung: Ich stelle keine fertige Bibliothek zur Verfügung, sondern erzähle nur, wie man sie schreibt.
UPD0 : cppast kann
anstelle von libclang verwendet werden . Danke an
masterspline für den Link.
1.
github.com/isnullxbh/dsl2.
github.com/isnullxbh/Argentum