Ein abstrahiertes und templatisiertes Input-Interface Part1

Motivation:

Ich habe einige Projekte gestartet oder an ihnen mitgearbeitet und die meisten teilen ein immer wiederkehrendes Problem. Den Input ihrer Settings oder Asset Konfiguration oder ähnlichem. Dabei ist nicht das große Problem, wie man diese Daten tatsächlich einließt, sondern wie man sich gegen doppelte Arbeit schützt und den ganzen Prozess soweit abstrahiert, dass man im weiteren Verlauf die InputLogik ohne weiteres austauschen kann, ohne gleich alles neu schreiben zu müssen. Auch ich musste einige Dinge schmerzlich lernen und habe einiges probiert. Als gerne genommenes Stichwort hierfür ist immer Runtime-Polymorphie, doch leider behandelt das nur eine Seite der Medaille, lässt die andere Seite aber außen vor.

Natürlich lässt sich vieles abstrahieren und in virtuelle Funktionen verpacken, aber bei weitem leider nicht alles. Viele Third-Party Bibliotheken haben komplett unterschiedliche Ansätze solche Input Prozesse anzugehen; es ist verdammt schwierig von vorn herein ein geeignetes Interface zu entwickeln, obwohl man noch gar nicht genau weiß, ob und was man später eigentlich genau ändern möchte. Im Laufe der Entwicklung eines Programms, Spiels oder was auch immer, entstehen immer wieder neuen Anforderungen; auch an den Config-Input. Um nicht später jeden Input-Call neu zu schreiben und testen zu müssen, habe ich eine Strategie entwickelt, um mir unnötige Arbeit abzunehmen und mögliche Fehlerquellen zu eliminieren.

Wir befinden uns derzeit im Jahr 2018, Runtime-Polymorphie ist nur noch selten der allgemein gültige Lösungsansatz, daher möchte ich in diesem Post einen alternativen Lösungsweg vorschlagen, der einige Beschränkungen des runtime-polymorphen Ansatzes umgeht, dennoch natürlich andere Nachteile mit sich bringt. Im Speziellen geht es um die Templatisierung des Input Prozesses, sodass dieser eben nicht runtime-polymorph agiert, sondern schon zur compile-time ausgewertet wird.

Auch wenn ich jetzt schon einige Worte über das Resultat verloren habe, möchte ich dennoch den Weg erarbeiten. Wer jetzt Performance-Vergleiche zwischen runtime und compile-time Polymorphie erwartet, den muss ich leider enttäuschen, das ist nicht die Intention die hinter diesem Beitrag steckt 😉

Nun denn, let’s go!

Anforderungen an die Reader:

Wie in der Einleitung bereits angesprochen, habe ich dieses Problem schon mehrere Male vorliegen gehabt und zu meiner Schande muss ich gestehen, dass ich bisher immer darum herum gearbeitet habe und die eigentliche Problematik nie angegangen bin. Mein letzter Versuch, dass Ganze zu lösen war im Nachhinein betrachtet auch nicht sonderlich schön. Doch, was erwartet uns nun hier?

Als Ausgangslage habe ich ein kleines rudimentäres .ini File, mit einigen wenigen Sektionen und jeweils ein paar Keys:

[hero]
    atk=100
    max_hp=100
    alive=true
    
[enemy.1]
    atk=200
    max_hp=200
    alive=true
    
[enemy.2]
    atk=100
    max_hp=300
    alive=true

Um nochmal ein paar Details über inis ins Gedächtnis zu rufen:

  • die strings innerhalb der [] Klammern sind die Sektionen
    • jede Sektion ist einmalig
    • es gibt keine Verschachtelungen
  • alle Key/Werte Paare, die unterhalb einer Sektion geschrieben wurden, gehören zu dieser Sektion

D.h. wir haben hier 3 Sektionen vorliegen (hero, enemy.1 und enemy.2). Warum dieses enemy.1 und enemy.2? Das ist ein kleiner Trick um mehr als einen enemy in diesem File speichern zu können; sozusagen als array.

Im Code sehen unsere Repräsentanten folgendermaßen aus:

struct Unit
{
    bool alive;
    int atk;
    int max_hp;
};

Ein simples struct, dass durch folgende Funktionen initialisiert werden soll:

template <class T>
void setup_value(T& _value, const in::Object& _obj, std::string_view _key)
{
    _value = in::value<T>(_obj, _key);
}

const in::Object& operator >>(const in::Object& _obj, Unit& _unit)
{
    setup_value(_unit.alive, _obj, "alive");
    setup_value(_unit.max_hp, _obj, "max_hp");
    setup_value(_unit.atk, _obj, "atk");
    return _obj;
}

Zur Erklärung:

Die Überladung des „input“-Operators ist allgemein üblich und sollte daher nicht sonderlich überraschen; die template Funktion setup_value() ist lediglich ein kleiner Helfer um den Typen der einzulesenden Variabel nicht explizit angeben zu müssen.

in ist der namespace alias den wir setzen, da ich die beiden verschiedenen Reader auch in verschiedene namespaces platzieren werde.

in::Object ist das erwartete Input-Objekt, aus dem wir nachher die Werte auslesen möchten. Dieses Input-Objekt soll sich immer auf eine Sektion beziehen, d.h. in unserem Fall soll das Objekt entweder auf die „hero“, „enemy.1“ oder „enemy.2“ Sektion der .ini zeigen.

Zum Abschluss noch die komplette main Funktion:

namespace in = input::ini;
inline const char* input_file = "test.ini";

int main()
{
    auto root = in::parse_file(input_file);

    Unit hero;
    in::section(root, "hero") >> hero;

    auto&& enemies = in::section(root, "enemy");

    Unit enemy1;
    in::section(enemies, "1") >> enemy1;

    Unit enemy2;
    in::section(enemies, "2") >> enemy2;
}

Man beachte in Zeile 15 das explizite Anfordern der Sektion „enemy“, die so nicht in diesem ini-File existiert. Wie bereits beschrieben soll uns das die Möglichkeit geben verschiedene Gegner mit aufsteigenden Zahlen auszulesen. Das heißt, dass die Sektionen später aus Tokens zusammen gesetzt werden (hier zu sehen „enemy“ und „1“ aus Zeile 18 werden am Ende zur „enemy.1“ Sektion verknüpft).

In Zeile 15 ist noch ein weiterer Kniff versteckt. Das auto&& lässt uns die Möglichkeit über in der Funktion in::section() Objekte mit verschiedenen Modifiern zurück zu geben. D.h. ein Reader könnte hier die Sektion als value zurück geben, während ein Anderer beispielsweise eine const Referenz zurück gibt. Dadurch sind wir später in der eigentlichen Implementierung wesentlich freier.

Zusammenfassend kann man nun feststellen, dass für unsere Implementierung folgende Funktionen und Objekte im namespace vorhanden sein müssen:

  • parse_file: nimmt einen Dateinamen inklusive Pfad entgegen und erstellt den Reader.
  • in::Object: eine Möglichkeit der Initialisierung ist das übergeben einer Reader Referenz
  • in::section: mit Übergabe eines in::Objects und eines Sektion-Namens gibt diese Funktion ein Objekt zurück, dass die Fähigkeit besitzt, Werte der entsprechenden Sektion auszulesen
  • in::value<T>: gibt mit Übergabe eines in::Objects und eines Keys den entsprechenden Wert zurück

Der ini-Reader:

In der Vorbereitung dieses Posts habe ich mich ein klein wenig auf die Suche gemacht, einen passenden ini-Reader zu finden. Boost beispielsweise bietet einen an, allerdings gibt es dort mit dem boost::property_tree bereits eine gut funktionierende Abstraktion, weswegen ich mich dagegen entschieden habe das als Grundlage zu nutzen. Entgegen des eigentlichen Vorhabens, habe ich mich schlussendlich für einen, vor allem der API betreffend, schlechteren ini parser entschieden: https://github.com/brofield/simpleini

Warum ist dieser nicht so gut? Er macht sicherlich seinen Job, bietet dem modernen C++ Anwender allerdings kaum sinnvolles Feedback über Erfolg oder Misserfolg. Man ist im Gegenteil sogar dazu gezwungen einen default Wert zu übergeben, der im Falle eines Fehlers zurück gegeben wird.

Dennoch lassen sich mit diesem Parser inis vernünftig parsen, auch wenn der Aufwand ein wenig höher ist, die API etwas zu „umwrappen“.

Wie ist nun also die Vorgehensweise?

Der namespace in welchem wir uns bewegen ist namespace input::ini. Für den Reader der lib lege ich den alias using Reader = CSimpleIniA; an, wobei das A Suffix für eine Nicht-Unicode Interpretation der Zeichenketten steht. Was es damit genau auf sich hat, würde den Rahmen dieses Posts deutlich sprengen. Wer genauere Information darüber erhalten möchte, findet über die Suchmaschine seines Vertrauens mehr als genug Informationen.

Das Wrapper-Objekt:

Bevor wir uns an die Implementierung dieses Objekt wagen, möchte ich vorher noch eine kleine Überlegung ansprechen. Ich habe vorhin bereits angedeutet, dass dieses Wrapper-Objekt (im Folgenden nur noch kurz Wrapper genannt) immer Sektion lokal agieren soll. D.h. ich möchte über einen Wrapper, der auf die Sektion „hero“ verweist, auch nur die Werte für die Keys „alive“, „atk“ und „max_hp“ der „hero“ Sektion erhalten. Das sollte denke ich soweit klar sein; aber wie sieht das nun mit den enemies aus?

Um später für Config-„Sprachen“ offen zu sein, die verschachtelte Sektionen unterstützen, möchte ich, dass „enemy“ als parent und die entsprechenden Nummern als sub-Sektion behandelt werden. Das ist natürlich nicht die sauberste Lösung, führt uns allerdings durchaus auch zum Ziel.

Die nächste Überlegung unseres Wrappers betreffend ist, was er überhaupt darstellen kann.

  • root
    • verweist auf die namenlose „root“ Sektion
    • hat einen Verweis auf den Reader
  • child
    • verweist auf eine benannte Sektion
    • hat einen Verweis auf das Parent

Hier könnte man natürlich 2 verschiedene Klassen schreiben, ich habe mich allerdings für einen Variant Member entschieden. Die Grundstruktur sieht demnach folgendermaßen aus:

class Wrapper
{
public:
    Wrapper(const Reader& _reader) :
        m_Type(Root{ &_reader })
    {
    }

    Wrapper(const Wrapper& _parent, std::string _sub_section) :
        m_Type(Child{ &_parent, std::move(_sub_section) })
    {
    }

private:
    struct Root
    {
        const Reader* reader;
    };

    struct Child
    {
        const Wrapper* parent;
        std::string sub_section;
    };
    std::variant<Root, Child> m_Type;
};

 

Ich bin ein sehr großer Freund von Referenzen. Eine Referenz hat den Vorteil, dass sie nicht invalid sein darf. D.h. ich muss nicht spezifisch auf null testen, sondern kann sie immer als gültig erachten; alles andere wäre Undefined Behaviour.

Dennoch hält das struct Root einen const Reader*. Das hat einfach den Hintergrund, dass man kein Objekt kopieren oder moven kann, das intern eine Referenz auf etwas hält. Dennoch verlange ich eine Referenz auf einen Reader und wandel diese selbst in einen Pointer um. Das verschafft mir etwas mehr Sicherheit und dem Nutzer der Klasse wird auch direkt klar, dass hier kein nullptr erlaubt ist.

Wenn wir auf unsere Liste zurück schauen, dann haben wir einen Punkt bereits erfüllt:

  • in::Object: eine Möglichkeit der Initialisierung ist das übergeben einer Reader Referenz

Der nächste Punkt ist auch nicht sonderlich schwierig, da wir dafür bereits alle Vorbereitungen getroffen haben:

Object section(const Object& _parent, std::string_view _name)
{
    return { _parent, _name.data() };
}
  • in::section: mit Übergabe eines in::Objects und eines Sektion-Namens gibt diese Funktion ein Objekt zurück, dass die Fähigkeit besitzt, Werte der entsprechenden Sektion auszulesen
Werte auslesen:

Jetzt kommt der schwierigste Teil: das Auslesen der Werte. Da, wie eingangs angesprochen, die Library nur sehr veralteten und schlecht zu nutzenden Zugriff auf die Werte bietet, müssen wir ein wenig tricksen. Die API bietet zwar eine Möglichkeit Werte auszulesen und diese aus einem CString auszulesen, aber eben leider nur mit einer default Value. Das wollen wir in diesem Fall aber nicht; wir bestehen darauf, dass das Programm eine Exception schmeißt, sofern ein gewünschter Key nicht vorhanden ist.

Die Lösung ist eigentlich recht einfach:

class Wrapper
{
public:
    // ...

    template <class T>
    T value(std::string_view _node) const
    {
        auto sec = section();
        if (auto valueBuf = _reader().GetValue(sec.c_str(), _node.data()))
        {
            if (auto val = _convert<T>(valueBuf))
                return *val;
        }
        using namespace std::literals;
        throw std::runtime_error("Unable to parse key: "s + _node.data() + " in section: " + sec);
    }

    std::string section() const
    {
        if (auto child = std::get_if<Child>(&m_Type))
        {
            assert(child->parent);
            if (auto sec = child->parent->section(); !std::empty(sec))
                return sec + "." + child->sub_section;
            return child->sub_section;
        }
        return "";
    }

private:
    // ...

    const Reader& _reader() const
    {
        struct _ReaderHelper
        {
            const Reader& operator()(const Root& _root)
            {
                assert(_root.reader);
                return *_root.reader;
            }

            const Reader& operator()(const Child& _child)
            {
                assert(_child.parent);
                return _child.parent->_reader();
            }
        };
        return std::visit(_ReaderHelper(), m_Type);
    }

    template <class T>
    std::optional<T> _convert(std::string _data) const
    {
        std::istringstream ss(_data);
        T val;
        ss >> std::boolalpha >> val;
        if (!ss.fail())
            return val;
        return std::nullopt;
    }
};

Kern des Ganzen ist die value<T>() Funktion des Wrappers. Dort holen wir uns die aktuelle Sektion (ja, das ist eine rekursive Funktion!) und den im root befindlichen Reader.

Danach versuchen wir mit GetValue() auf die Sektion und den entsprechenden Key zuzugreifen; dies kann natürlich auch schief gehen. Der return Type ist ein const char*; wenn wir hier also einen nullptr erhalten, dann haben wir entweder den Key oder die Sektion nicht gefunden.

Zu guter Letzt konvertieren wir diesen erhaltenen CString zu dem Typen, den wir tatsächlich haben wollen. Dazu bedienen wir uns eines std::istringstream und versuchen über die operator >>()zu konvertieren. Mit fail() prüfen wir schlussendlich noch, ob die Konvertierung erfolgreich war oder nicht. Man beachte, dass wir ein std::optional<T> statt eines blanken T benutzen; warum dies sauberer ist und die Intention des Codes besser ausdrückt als andere Konstrukte, habe ich bereits hier ausführlich erklärt:

Eine kleiner Einblick in std::optional

Hat das alles keinen Erfolg, dann schmeißen wir einfach eine Exception. Durch diese Herangehensweise wäre es nun ein leichtes, den vorhandenen Code entweder durch eine value() Funktion, die im Fehlerfall einen default Wert zurück gibt, oder eine opt_value() Funktion anzubieten, die eben lediglich das std::optional weiter gibt, zu erweitern.

Aufmerksame Leser werden feststellen, dass die _convert() Funktion einen std::string entgegen nimmt, wir aber lediglich einen const char* übergeben. Dadurch wird der übergebene CString zwangsläufig kopiert. Das lässt sich hier aber nicht vermeiden, da std::istringstream zwingend einen const std::string& erwartet. Das lässt sich natürlich mit einer eigenen istream Implementierung verbessern, wäre aber Detailarbeit und würde etwas den Rahmen sprengen. Daher lasse ich die Lösung für dieses Problem an dieser Stelle offen; ihr könnt das gerne als Herausforderung sehen, diesen istream für std::string_view zu implementieren 😉

Wir sind nun für value() leider noch nicht ganz fertig. Wir wollen ja eine freie Funktion value(). Das ist nun aber einfach erledigt:

template <class T>
T value(const Object& _section, std::string_view _name)
{
    return _section.value<T>(_name);
}
Erstellen des Readers:

Ein Blick auf unsere Anforderungsliste zeigt uns, dass wir nur noch einen Punkt offen haben:

  • parse_file: nimmt einen Dateinamen inklusive Pfad entgegen und erstellt den Reader.

Das ist auf den ersten Blick ganz einfach:

Reader parse_file(std::string_view _pathName)
{
    Reader doc = std::make_unique<InternalReader>();
    doc->LoadFile(_pathName.data());
    return doc;
}

Doch ein Druck auf F7 verrät, dass der Copy-CTor privat deklariert wurde und auch kein move CTor vorhanden ist. Dieses Problem habe ich tatsächlich erst sehr spät bemerkt, bin nun aber auch nicht mehr bereit von meinen Anforderungen abzurücken.

Da der Reader allerdings nur nach außen gegeben wird, um lange genug am Leben erhalten zu werden, bietet es sich an, die Situation noch einmal zu überdenken. Man könnte aus dem Reader einen std::unique_ptr kreieren, sodass ein kopieren und moven kein Problem mehr darstellt. Aber ist das nicht schon alles Indikator genug, um nicht ein wenig über den Tellerrand hinaus zu blicken und zu hinterfragen, weswegen es überhaupt notwendig ist, dass der Benutzer des namespaces dieses Objekt vorhalten muss, so lange er den Input benutzen möchte?

Es wird Zeit einen anderen Weg einzuschlagen. Zu diesem Zwecke ändern wir den alias von Reader auf using Reader = Wrapper;. Als zweites erstellen wir zwei zusätzliche aliasse und ändern wir den CTor und das struct Root wie folgt:

using InternalReader = CSimpleIniA;
using InternalReaderPtr = std::unique_ptr<InternalReader>;

class Wrapper
{
public:
    Wrapper(InternalReaderPtr _reader) :
        m_Type(Root{ std::move(_reader) })
    {
    }

    // ...

private:
    struct Root
    {
        InternalReaderPtr reader;
    };

    // ...
};

Zu guter Letzt noch eine Änderung an der parse_file() Funktion:

Reader parse_file(std::string_view _pathName)
{
    auto doc = std::make_unique<InternalReader>();
    doc->LoadFile(_pathName.data());
    return std::move(doc);
}

 

Durch diese Änderung ist der Nutzer nicht mehr gezwungen diesen unhandlichen Reader explizit vorzuhalten, sondern kann explizit mit unserem Wrapper arbeiten.

Dazu noch eine kleine Änderung in der main:

int main()
{
    auto root = in::parse_file(input_file);

    Unit hero;
    in::section(root, "hero") >> hero;

    auto&& enemies = in::section(root, "enemy");

    Unit enemy1;
    in::section(enemies, "1") >> enemy1;
}

 

Ausblick auf Teil 2:

Da dieser Beitrag nun doch schon etwas länger geworden ist, habe ich mich dazu entschieden hier zu unterbrechen und die nachträgliche Implementierung des 2. Readers (wir nehmen Json) in einem späteren Beitrag zu erläutern. Dort wird es dann natürlich auch darum gehen, wie wir unseren aktuellen ini-Reader mit einfachen Mitteln durch den neuen Json-Reader ersetzen können, ohne alten Code anfassen zu müssen.

 

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.