Serialisierung von C++ Klassen mit möglichst wenig Code

Angenommen, du schreibst ein GUI-Programm – zum Beispiel in Qt – und du hast keine Lust, dessen Arbeitsordner und andere Einstellungen, die sich jedes Mal, wenn du es öffnest, stark ändern, ständig neu zu setzen. Die Lösung liegt auf der Hand: Speicher einige der Einstellungen auf der Festplatte. Dazu ist es erforderlich, Memory Dumps zu erstellen (die plattform-abhängig und schwierig zu debuggen und manuell zu bearbeiten sind) oder einen Parser zu schreiben (was einige Zeit dauert). Ich hatte dieses Problem mehrmals und die Lösung meistens immer wieder in neue Programme kopiert. Dann entschied ich, dass ich genug davon hatte und dass es an der Zeit war, eine kleine Bibliothek dafür zu schreiben. Eine, die so einfach wie möglich zu bedienen ist.

Das Design

Der bequemste Weg, das zu erreichen, wäre es, die Klasse als serialisierbar zu deklarieren und dann durch etwas auf die Anwesenheit von Klassenmitgliedern prüfen und die Save- und Load-Methoden entsprechend generieren zu lassen. Das könnte in Python geschehen, indem man den Inhalt von Klassen überprüft und alles, was serialisierbar aussieht, serialisiert. Python macht jedoch die Verkapselung kaputt, um das zu ermöglichen, was ein etwas zu großes Opfer darstellt, und Pythons Leistung ist selbst gegenüber der vieler anderer interpretierter Sprachen deutlich schlechter. In C könnte dies mit einem verrückten variadischen Makro geschehen, dessen falsche Verwendung Fehlermeldungen erzeugen könnte, die so kryptisch sind, dass sie wenn sie in elfischen Runen geschrieben wären, genauso verständlich wären. Außerdem könnte es praktisch sein, bestimmen zu können, welche Elemente serialisiert werden (einige werden besser nicht gespeichert) und wie sie benannt werden (z.B. erläuternde Namen in der gespeicherten Datei, da diese langen Namen den Quellcode nicht unübersichtlich machen).

Die naheliegende Lösung besteht darin, eine Methode zur Serialisierung und eine weitere Methode zur Deserialisierung zu haben, die Methoden aufrufen, die jeden Elementtyp in eine Zeichenkette schreiben oder diese aus einer Zeichenkette lesen. Diese Schreib- und Lesemethoden können von einer übergeordneten Klasse bereitgestellt werden. Aber dieser Ansatz erfordert das Schreiben des Namens jedes Elements und seines serialisierten Namens sowohl einmal in der Speichermethode, als auch einmal in der Lademethode, was dazu führt, dass man buchstäblich alles zweimal schreibt (Writing Everything Twice, kurz WET) – eine offensichtliche Verletzung des DRY-Prinzips (Don’t Repeat Yourself). Zusätzlich zum Schreiben von mehr Code wird nicht überprüft, ob der serialisierte Name in beiden Fällen ohne Tippfehler geschrieben wird, was eine Quelle für schwer zu findende Fehler darstellt.

Die Voraussetzung ist also, nur zu schreiben, dass die Klasse serialisierbar ist und die zu serialisierenden Elemente und deren Namen in der gespeicherten Datei aufzulisten. Etwa so:

class Preferences : Serialisable {
 std::string folder = "";
 int steps = 100;
 void saveOrLoad() {
   serialise("folder opened at last file opening", folder);
   serialise("number of computation steps", steps);
 }
};

Das würde auch das Deklarieren eines Makros ermöglichen, das das Schreiben nur eines Arguments ermöglicht, das sowohl der Attributname als auch der Name in der Datei ist.

Wie implementiert man das?

Da Abhängigkeiten in C++17 immer noch ein großes Ärgernis darstellen, sind Bibliotheken mit nur einer Header-Datei immer noch die bequemste Form einer kleinen Bibliothek. Das bedeutet, dass sie sich vorzugsweise nur auf Standardbibliotheken und nichts anderes stützen sollte.

Zunächst überlegte ich, das INI-Format als Format zum Speichern der Daten zu verwenden, aber ich entschied mich dann für JSON, weil es die Verschachtelung von Blöcken ermöglicht (was in INI ziemlich unpraktisch ist) und nicht so viel unnötiges Zeug schreibt wie XML. Das Einschließen einer JSON-Bibliothek würde eine Abhängigkeit hinzufügen, die die Bibliothek weniger bequem machen würde, also habe ich kurzerhand selbst eine kleine erstellt und sie zu einem Teil der Bibliothek gemacht. Es hat nicht viel Zeit gekostet.

Mit der gleichen Funktion können verschiedene Arten von Variablen gespeichert oder geladen werden, indem man sie überlädt.  Das ermöglicht, dass mehr als eine Funktion den gleichen Namen verwendet und zur Kompilierzeit entsprechend den Arten der Variablen die passende ausgewählt wird.

Eine Funktion sowohl zum Laden, als auch zum Speichern zu haben, ist etwas schwierig. Die Funktion ist nur eine und die Funktionen, die sie aufruft, sind zur Laufzeit fixiert. Es gibt jedoch ein uraltes Werkzeug, um zur Laufzeit zu entscheiden, was zu tun ist. Es heißt if. Der Elternteil hat eine Variable, die durch seine save() und load() Methoden unterschiedlich gesetzt wird, und die vereinheitlichten Serialisierungsmethoden überprüfen diese, um zu entscheiden, ob sie speichern oder laden. Der Ort, an dem es serialisiert werden soll, ist auch ein Element des Parents, da es sonst in jeden Aufruf jeder Methode eingefügt werden müsste. Hinweis für Java/C#-Entwickler: C++ erlaubt mehrfache Vererbung, so dass es keinen Unterschied zwischen Interface und übergeordneter Klasse gibt und die Implementierung von Methoden in und das Hinzufügen von Attributen zu Klassen, um von ihnen zu erben, hindert uns nicht daran, mehr implementierte Methoden und Attribute von etwas anderem zu erben. Tatsächlich ist das sehr praktisch, um das Schreiben von sich wiederholendem Code zu vermeiden.

class Serialisable {
 mutable std::shared_ptr<JSON> preferencesJson_;
 mutable bool preferencesSaving_;
public:
 virtual void saveOrLoad() = 0;
 
 inline void save(const std::string& fileName) const {
   preferencesJson_ = std::make_shared<JSONobject>();
   preferencesSaving_ = true;
   const_cast<Serialisable*>(this)->saveOrLoad();
   preferencesJson_->writeToFile(fileName);
   preferencesJson_.reset();
 }
 
 inline void load(const std::string& fileName) {
   preferencesJson_ = parseJSON(fileName);
   if (preferencesJson_->type() == JSONtype::NIL) {
     preferencesJson_.reset();
     return;
   }
   preferencesSaving_ = false;
   saveOrLoad();
   preferencesJson_.reset();
 }
 
// ...
}

Ein alternativer Ansatz könnte darin bestehen, der Methode ein vordefiniertes Argument hinzuzufügen und es als Argument an die synchronise()-Methoden zu übergeben, so dass der Compiler tatsächlich zwei Methoden daraus erstellt. Es wäre schneller, weil der Parent keine Elemente hinzufügen würde und es würde einen virtuellen Funktionsaufruf vermeiden, aber es würde mehr Code erfordern. Es sollte sowieso nicht allzu häufig ausgeführt werden. Außerdem wäre es unmöglich, die save() und load() Funktionen als Methoden zu implementieren; sie müssten Funktionen sein, die als Argumente übergeben würden.

Das mag wie eine Verletzung des Single Responsibility Prinzips aussehen, da diese serialise()-Methoden zwei Dinge bewirken: Sie serialisieren und deserialisieren. Wir können diese Methoden jedoch als logische Paare von Methoden betrachten, die zur Laufzeit ausgewählt werden. Die Begründung für das Single Responsibility Prinzip ist, dass wir vielleicht die Hälfte der Funktionalität später benötigen und wir zusätzlichen Code schreiben müssten, nur um etwas zu schaffen, das nur die Hälfte ausmacht. Diese beiden Funktionalitäten sind ohnehin unabhängig voneinander aufrufbar, so dass dieses Prinzip nicht wirklich verletzt wird.

Die Einzelelement-Serialisierungs-Methode

Die Serialisierung von Zeichenketten ist trivial:

inline void serialise(const std::string& key, std::string& value) {
 if (preferencesSaving_) {
   preferencesJson_->getObject()[key] = std::make_shared<JSONstring>(value);
 } else {
   auto found = preferencesJson_->getObject().find(key);
   if (found != preferencesJson_->getObject().end()) {
     value = found->second->getString();
   }
 }
}

Bools können auf die gleiche Weise synchronisiert werden, Zahlen hingegen nicht, da es mehrere Arten von ihnen gibt und wenn sie durch eine Referenz zurückgegeben werden, muss es eine genaue Übereinstimmung geben. Das kann mit einer Vorlage gelöst werden. SFINAE (Substitution Failure Is Not An Error) kann verwendet werden, um die Methode nur dann zu aktivieren, wenn der Typ eine Zahl ist, indem die Ableitung des Rückgabetyps fehlschlägt, wenn der Typ keine Zahl ist und den Compiler veranlasst, aus anderen Methoden mit einem solchen Namen auszuwählen. Es kann auch verwendet werden, um zu verhindern, dass der Bool-Typ als Argument akzeptiert wird. Der Header der Methode sieht also so aus:

template<typename T>
 typename std::enable_if<std::is_arithmetic<T>::value
     && !std::is_same<T, bool>::value, void>::type
 serialise(const std::string& key, T& value) {
  // Basically the same code as above

Das Gleiche ist auch für Mitgliederklassen nützlich. Wir können sie serialisieren, wenn sie von der übergeordneten Klasse erben, die sie serialisierbar macht. Der einzige größere Unterschied besteht darin, dass sie nicht zuweisbar sind, sie müssen erzeugt und ihre Synchronisationsmethoden aufgerufen werden. Die Art und Weise, wie geprüft werden kann, ob sie von der übergeordneten Klasse erben, ist seit C++11 standardisiert:

template<typename T>
typename std::enable_if<std::is_base_of<Serialisable, T>::value, void>::type
serialise(const std::string& key, T& value) {

Nun wird es auch Vektoren von serialisierbaren Objekten und Vektoren von Zeigern auf serialisierbare Objekte geben. Im Falle von Vektoren ist es recht trivial, alles, was getan werden muss, ist, dasselbe einfach in einer Schleife zu wiederholen. Aber wie sieht es nun mit dem Vektor der Zeiger aus? Es ist sinnvoll, das Einfügen von Objekten mehrerer Unterklassen derselben übergeordneten Klasse in den Vektor zu erlauben.

Wenn es nur rohe Zeiger gäbe, wäre es trivial. Aber in modernem C++ sind smarte Pointer ein Muss und rohe Pointer sind immer noch nützlich. Man könnte es mit Konzepten lösen, aber die sind Teil von C++20, das noch nicht weit verbreitet ist und auch die Unterstützung von C++14 ist nicht überall gegeben. Alles, was bisher benötigt wurde, war nur C++11. Wir können eine Art Duck Typing verwenden. Wenn ein * davor zu verwenden einen Typ zurückgibt, der serialisierbar ist und ein Zeiger darauf diesen Typ initialisieren kann, hat es alles, was wir für einen Pointer oder einen smarten Pointer benötigen (rohe Pointer würden die Überprüfung auf den -> Operator nicht bestehen, also wird es nicht in der Implementierung verwendet). Da der *-Operator nicht immer eine Methode ist, benötigen wir ein decltype, declval Paar.

Das Ergebnis sieht dann so aus:

template<typename T>
typename std::enable_if<std::is_base_of<Serialisable, typename std::remove_reference<decltype(*std::declval<T>())>::type>::value
   && std::is_constructible<T, typename std::remove_reference<decltype(*std::declval<T>())>::type*>::value, void>::type
serialise(const std::string& key, std::vector<T>& value) {
 if (preferencesSaving_) {
   auto making = std::make_shared<JSONarray>();
   for (unsigned int i = 0; i < value.size(); i++) {
     auto innerMaking = std::make_shared<JSONobject>();
     (*value[i]).preferencesSaving_ = true;
     (*value[i]).preferencesJson_ = innerMaking;
     (*value[i]).saveOrLoad();
     (*value[i]).preferencesJson_.reset();
     making->getVector().push_back(innerMaking);
   }
   preferencesJson_->getObject()[key] = making;
 } else {
   value.clear();
   auto found = preferencesJson_->getObject().find(key);
   if (found != preferencesJson_->getObject().end()) {
     for (unsigned int i = 0; i < found->second->getVector().size(); i++) {
       value.emplace_back(new typename std::remove_reference<decltype(*std::declval<T>())>::type());
       T& filled = value.back();
       (*filled).preferencesSaving_ = false;
       (*filled).preferencesJson_ = found->second->getVector()[i];
       (*filled).saveOrLoad();
       (*filled).preferencesJson_.reset();
     }
   }
 }
}

Schließlich benötigte die Bibliothek einen Namen. Ich nannte sie QuickPreferences und die Klasse QuickPreferences statt Serialisable, um mögliche Identifier-Konflikte zu vermeiden (die können über Namespaces behandelt werden, aber warum sollte man sie überhaupt verursachen?). Auch die vereinheitlichte Serialisierungs- und Deserialisierungsfunktion habe ich in synch() umbenannt, weil das kürzer und treffender ist. Den Quellcode findet ihr hier.

Fazit

Mit einigen Variablen für die Entscheidung in Echtzeit anstelle von mehreren Funktionen, einigen virtuellen Funktionen und einigen Methoden mit gleichen Namen habe ich eine Bibliothek zum Speichern von Einstellungen mit so wenig Code wie möglich erstellt. Dadurch ist es möglich, C++-Datenstrukturen in JSON auf eine kontrollierbare Weise zu speichern, die mit so wenig Code wie diesem verwendet werden kann:

#include "quick_preferences.hpp"
 
struct Settings : public QuickPreferences {
 std::string folder = "";
 std::string fileNames = "data";
 int lastIndex = 9;
 virtual void saveOrLoad() {
   synch("working_folder", folder);
   synch("working_file_names", fileNames);
   synch("last_working_file_index", lastIndex);
 }
};

Dieser Artikel erschien auch auf Michal’s Blog und wurde von André Hahn übersetzt.

Physiker, Informatiker und leidenschaftlicher Metalhead. Technologischer Spinner mit dem Talent, seine GNU/Linux-Systeme auf die verrücktesten Arten zu zerlegen.

1 comments On Serialisierung von C++ Klassen mit möglichst wenig Code

Leave a reply:

Your email address will not be published.

Site Footer

NerdZoom Media | Impressum & Datenschutz