Texte parsen mit Spirit (3)

Der Abschluss der boost::spirit-Reihe: wir parsen unseren JSON-Text in eine passende Datenstruktur

Video

Quelltext

#include <iostream>
#include <string>
#include <vector>
#include <map>

#include <boost/spirit/include/qi.hpp>
#include <boost/fusion/include/adapt_struct.hpp>
#include <boost/variant.hpp>

namespace qi = boost::spirit::qi;


struct Object;
struct Array;
typedef boost::variant<std::string, double, Object, Array, bool> Value;
typedef std::pair<std::string, Value> Pair;

struct Object : public std::map<std::string, Value> {
};

struct Array : std::vector<Value> {
};

BOOST_FUSION_ADAPT_STRUCT(
    Pair,
    (std::string, first)
    (Value, second)
)

struct JSONPrinter : public boost::static_visitor<>
{
    mutable int indentation;

    JSONPrinter() {
        indentation = 0;
    }

    void indent() const {
        std::cout << std::string(indentation, ' ');
    }

    void operator()(std::string const &v) const {
        std::cout << '"' << v << '"';
    }

    void operator()(double v) const {
        std::cout << v;
    }

    void operator()(Pair const &p) const {
        indent();
        std::cout << p.first << " => ";
        boost::apply_visitor(*this, p.second);
    }

    void operator()(Object const &o) const {
        std::cout << "{\n";

        indentation += 4;
        for (auto &elem : o) {
            (*this)(elem);
            std::cout << '\n';
        }
        indentation -= 4;
        indent();
        std::cout << "}";
    }

    void operator()(Array const &a) const {
        std::cout << "[\n";
        indent();
        indent();
        auto tmp = indentation;
        indentation = 0;
        for (auto &elem : a) {
            boost::apply_visitor(*this, elem);
            std::cout << ", ";
        }
        std::cout << '\n';
        indentation = tmp;
        indent();
        std::cout << "]";
    }
};

int main()
{
    std::string json_text = R"json(
        {
            "number" : 123.456,
            "string" : "Eine Zeichenkette",
            "ein array" : [ 1, 5, 8.9, "Noch eine Zeichenkette"],
            "object" : {
                "name" : "Ein Objekt"
            }
        }
    )json";

    qi::rule<std::string::iterator, Object(), qi::space_type> object;
    qi::rule<std::string::iterator, Pair(), qi::space_type> pair;
    qi::rule<std::string::iterator, Array(), qi::space_type> array;
    qi::rule<std::string::iterator, Value(), qi::space_type> value;
    qi::rule<std::string::iterator, std::string(), qi::space_type> string;

    object   = '{' >> -(pair % ',') >> '}';
    pair     = string >> ':' >> value;
    array    = '[' >> -(value % ',') >> ']';
    value    = string | qi::double_ | object | array | "true" | "false" | "null";
    string   = qi::lexeme[ '"' >> *(qi::char_ - qi::char_('"')) >> '"'];

    auto it = json_text.begin();
    Value v;
    if (qi::phrase_parse(it, json_text.end(), value, qi::space, v) &&
        it == json_text.end()) {
            boost::apply_visitor(JSONPrinter(), v);
            std::cout << '\n';
    }
    else {
        std::cout << "Der Text ist kein gültiges JSON\n";
    }
}

Erklärung

Die erste Aufgabe beim Parsen von (Quell)Texten ist das Design einer geeigneten Datenstruktur. Irgendwo muss man die Informationen ja ablegen. Compiler machen das typischerweise in einem Abstract Syntax Tree. Wir können hier allerdings gleich eine passende Struktur für JSON-Daten entwerfen. Diese (Zeilen 13-22) ist ähnlich strukturiert, wie die Grammatik selbst: wir haben mit Value einen boost::variant, der die verschiedenen JSON-Werte halten kann. Object ist eine einfache std::map, die Strings auf Values zuordnet (so, wie die Grammatik das für das JSON-Objekt vorgibt). In einem Array können auch beliebige Values vorkommen (auch das ist in JSON möglich: man kann verschiedenste Datentypen in einem Array mischen). Hier im Beispiel weggelassen sind passende Deklarationen für true, false und null.

Grundsätzlich gibt es mehrere Möglichkeiten, in boost::spirit an seine Daten zu kommen. Die erstmal scheinbar einfachste, aber letztlich fast unbenutzbare, sind semantic actions: Funktionsaufrufe, die an eine Regel angehängt werden können und immer dann zum Zuge kommen, wenn diese Regel match. Diese bekommen als Parameter den von der Regel erzeugten Rückgabewert. Diese machen aber erstens die Grammatik hässlich (weil sie sie aufblähen) und sind zweitens sehr schwer in den Griff zu kriegen, wenn man bspw. beim Parsen von mehreren Alternativen Backtracking betreiben muss.

Die bevorzugte Variante ist daher, den Regeln eine Signatur zu verpassen. Diese gibt (unter anderem) an, welchen Datentyp die Regel zurückliefern soll. In der Dokumentation zu boost stehen die Datentypen, die normalerweise die erzeugten Werte haben (bspw. double für qi::double_ oder std::vector<T> für einen List-Parser). Spirit versucht nun, den entsprechenden Wert zu erzeugen, indem es ein Objekt des Rückgabetyps anlegt und diesen als Sequenz betrachtet, auf die es frei zugreifen kann. Ggf. von den Teilparsern zurückgelieferte Objekte versucht es dabei an die entsprechenden Stellen zu schreiben. Für viele STL-Container weiß Spirit schon, wie das zu lösen ist. Wenn man allerdings mit einem eigenen Datentyp um die Ecke kommt, dann muss man diesen erst mit Spirit bekanntmachen. Das geschieht am einfachsten über das Makro BOOST_FUSION_ADAPT_STRUCT aus der Fusion Library. Diese nimmt den anzupassenden Datentyp und die einzelnen Felder mit ihren Datentypen in der Reihenfolge, wie sie anzugeben sind (wichtig ist die Reihenfolge in der Grammatikregel) und erzeugt den geeigneten Code, der die Benutzung als Sequenz zulässt).

Damit ist die Integration fast schon abgeschlossen. Wir müssen nun noch den Datentyp der einzelnen Regeln verändern, um festzulegen, welchen Datentyp diese jeweils als Ergebnis liefern sollen. Dazu bekommen sie jeweils als dritten Template-Parameter die Signatur der Regel (hier im Beispiel, Zeilen 99 bis 103). Damit ist der Regel bekannt, welchen Typ ihr Rückgabewert hat (und, je nach Typ, wenn die entsprechenden Anpassungen vorhanden sind, auch, wie sie diesen erzeugt).

Das letzte fehlende Detail ist die Ausgabevariable. Die Wurzel des JSON-Parsers (die value-Regel) liefert ein Value-Objekt. Dieses müssen wir anlegen und zur Verfügung stellen. Dafür gibt es eine Variante von phrase_parse, welche dieses Objekt als letzten Parameter nimmt. In dieses Objekt schreibt Spirit dann die Ausgabe (in unserem Fall wird dort bspw. das Object abgelegt, welches die Wurzel unseres Quelltextes ist).

Der hier noch demonstrierte JSONPrinter hat mit Spirit nichts zu tun. Dieses Objekt im Zusammenspiel mit boost::apply_visitor bietet nur eine einfache Möglichkeit, ein boost::variant zu verarbeiten. So müssen wir den Code für die Zerlegung nach den einzelnen enthaltenen Typen des variant nicht selbst schreiben. Wir schreiben für jeden Typ nur noch die Funktion, die die Behandlung des betreffenden konkreten Typen übernimmt und boost::apply_visitor macht den Rest (in unserem Fall: die Ausgabe).