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.

 

„DoubleBuffer“-Pattern

Dieser Beitrag ist ein Resultat aus meinen eigenen Erfahrungen, die ich jetzt gerade vor kurzem machen durfte. Bevor ich auf das eigentliche Problem eingehe, möchte ich vorher ein wenig Kontext schaffen um das Ganze ein wenig zu veranschaulichen. Ich verwende hier keine größeren Tricks oder speziellen Kniffe, sodass der Code für jeden verständlich sein sollte.

Als Grundgerüst soll uns eine schlanke Implementierung eines GameObjekts, kurz Unit, dienen. Dieses Unit hat einige wenige Eigenschaften wie z.B. Hp, Angriffskraft (Atk) und einen Namen.

class Unit
{
public:
    Unit(int _hp, int _atk, std::string _name) :
        m_Hp(_hp),
        m_Atk(_atk),
        m_Name(std::move(_name))
    {
        assert(_hp > 0 && _atk > 0 && !m_Name.empty());
    }

    int getHp() const { return m_Hp; }
    int getAtk() const { return m_Atk; }
    const std::string& getName() const { return m_Name; }

private:
    int m_Hp;
    int m_Atk;
    std::string m_Name;
};

Wer sich nun über die Zeile mit dem assert wundert, dem lege ich ans Herze sich darüber einmal zu informieren. Zugegeben, in diesem Zusammenhang wird man asserts wohl eher selten nutzen; da hier aber alles hardcoded umgesetzt wird, ist es durchaus brauchbar und erfüllt seinen Zweck.

Wir nutzen also eine relativ simple Klasse um einen epischen Kampf zwischen 2 Kontrahenten austragen zu lassen. Dazu implementieren wir noch folgende Methoden.

bool isAlive() const { return getHp() > 0; }
void attack(Unit& _enemy)
{
    assert(this != &_enemy);
    if (isAlive()) // dead units can't attack
    {
        _enemy.m_Hp -= getAtk();
    }
}

Die Regeln sind relativ einfach. Mit jeder Attacke werden dem Verteidiger die Angriffspunkte von den Lebenspunkten abgezogen. Hat am Ende einer Runde jemand 0 oder weniger Hp, ist das Spiel vorbei. Als zusätzliche Bedingung dürfen natürlich nur lebende Units angreifen. Diese scheinbar logische und auch triviale Regel ist unser Kernproblem, auf das ich im späteren Verlauf gerne weiter eingehen würde.

Doch zuerst implementieren wir unsere Spiellogik. Da ich hier die Komplexität möglichst gering halten möchte, wird das Ganze auch entsprechend trivial umgesetzt; denn wie gesagt geht es im Kern dieses Beitrags um die Bedingung, dass nur lebende Units angreifen können.

int main()
{
    Unit hero(100, 25, "Hero");
    Unit warrior(100, 25, "Warrior");
    
    while (hero.isAlive() && warrior.isAlive())
    {
        hero.attack(warrior);
        warrior.attack(hero);
    }
}

Mit dieser kurzen Logik können wir nun unseren Kampf simulieren. Um noch etwas mehr Feedback zu generiere, erstellen wir noch 2 print Funktionen.

const Unit* getWinner(const Unit& _first, const Unit& _second)
{
    if (_first.isAlive() && !_second.isAlive())
        return &_first;
    else if (_second.isAlive() && !_first.isAlive())
        return &_second;
    return nullptr;
}

void printHp(const Unit& _unit)
{
    std::cout << _unit.getName() << " has " << _unit.getHp() << " Hp left." << std::endl;
}

int main()
{
    Unit hero(100, 25, "Hero");
    Unit warrior(100, 25, "Warrior");
    
    while (hero.isAlive() && warrior.isAlive())
    {
        hero.attack(warrior);
        warrior.attack(hero);
        printHp(hero);
        printHp(warrior);
    }

    if (auto winner = getWinner(hero, warrior))
        std::cout << "The winner is: " << winner->getName() << std::endl;
    else
        std::cout << "There is no winner." << std::endl;
}

Es werden jede Runde die restlichen Hp jedes Kontrahenten ausgegeben und am Ende der Gewinner ermittelt (natürlich äußerst simpel). Wollen wir uns nun mal die Ausgabe näher anschauen.

Hero has 75 Hp left.
Warrior has 75 Hp left.
Hero has 50 Hp left.
Warrior has 50 Hp left.
Hero has 25 Hp left.
Warrior has 25 Hp left.
Hero has 25 Hp left.
Warrior has 0 Hp left.
The winner is: Hero
Die Hp beider Streiter nimmt wie erwartet kontinuierlich jede Runde um 25 ab, bis am Ende der Held den Sieg davon trägt. Nur, moment, ist das eigentlich fair? Wenn wir im Code die Reihenfolge der Attacken ändern, sodass der Warrior vor dem Helden angreift, dann würde nun der Warrior siegen.
Spinnt man dieses Szenario noch ein wenig weiter und überträgt es auf ein reelles Projekt, in dem Units dynamisch erstellt werden, so wird man schnell verstehen, dass das schlecht zu kontrollieren ist. Vor allem wenn es in den Bereich PvP geht wird es die Spieler schnell vergraulen, wenn derjenige einen Vorteil hat, dessen Unit zuerst erstellt wurde.
Eine Lösung wäre nun, die Bedingung innerhalb der attack Methode zu entfernen; aber ist das wirklich dass, was wir wollen? Es ist durchaus richtig, dass ein totes Unit nicht angreifen kann.
Es gilt sich darüber bewusst zu werden, dass innerhalb eines Durchlaufs der „GameLoop“ prinzipiell alles zeitgleich passiert. Alles was passiert sollte grundlegend unabhängig davon sein, wann es innerhalb dieses Durchlaufs passiert. Diese Herangehensweise verschafft eine gewisse Konsistenz und Robustheit, die sich später bezahlt macht. Die generelle Überlegung ist also, dass alle Eigenschaften, die sich innerhalb eines Durchlaufs verändern können, innerhalb des derzeitigen Durchlaufs unverändert bleiben.
Das mag ein wenig verwirrend klingen, heißt aber grundlegend nur, dass Eigenschaften nicht innerhalb des Durchlaufs verändert werden dürfen, sondern erst danach.
Damit hätten wir auch schon eine naive Lösung für unser Problem. Wir erstellen uns eine m_CurrentHp Eigenschaft und implementieren eine endTick() Methode um den beiden Units zu signalisieren, dass sie jetzt die Eigenschaften modifizieren können.
Die finale Klasse sieht nun folgendermaßen aus.
class Unit
{
public:
    Unit(int _hp, int _atk, std::string _name) :
        m_CurrentHp(_hp),
        m_Hp(_hp),
        m_Atk(_atk),
        m_Name(std::move(_name))
    {
        assert(_hp > 0 && _atk > 0 && !m_Name.empty());
    }

    int getHp() const { return m_Hp; }
    int getAtk() const { return m_Atk; }
    const std::string& getName() const { return m_Name; }
    
    bool isAlive() const { return getHp() > 0; }
    void attack(Unit& _enemy)
    {
        assert(this != &_enemy);
        if (isAlive())   // dead units can't attack
        {
            _enemy.m_CurrentHp -= getAtk();
        }
    }
    
    void endTick()
    {
        m_Hp = m_CurrentHp;
    }
    
private:
    int m_CurrentHp;
    int m_Hp;
    int m_Atk;
    std::string m_Name;
};

Nun müssen wir lediglich noch innerhalb unserer „GameLoop“ die endTick() Methode aufrufen. Das machen wir am Besten vor der Ausgabe, da diese prinzipiell unsere visuelle Darstellung ist und davor das updaten der Units abgeschlossen sein muss.

while (hero.isAlive() && warrior.isAlive())
{
    hero.attack(warrior);
    warrior.attack(hero);
        
    hero.endTick();
    warrior.endTick();
        
    printHp(hero);
    printHp(warrior);
}

Lassen wir nun diesem epischen Kampf seinen Lauf, erhalten wir folgende Ausgabe:

Hero has 75 Hp left.
Warrior has 75 Hp left.
Hero has 50 Hp left.
Warrior has 50 Hp left.
Hero has 25 Hp left.
Warrior has 25 Hp left.
Hero has 0 Hp left.
Warrior has 0 Hp left.
There is no winner.

Wie erwartet, beide Kontrahenten sind besiegt! Damit wäre unser Problem gelöst.

Dieses Pattern lässt sich prinzipiell immer da anwenden, wo Eigenschaften über einen update Prozess hinweg unverändert bleiben sollen. Ein Sonderfall, wo es eher schwierig wird ist das Bewegen und die Kollisionskontrolle, da hier die Gegenseite der Kollision ein direktes Feedback benötigt, wie weit sie sich bewegen darf.

Eine weitere Einschränkung ist, dass es nun schwierig wird ein direktes Feedback über den reell zugefügten Schaden zu generieren. Stellt man sich die folgende Situation vor.

Unser Held hat noch 10 Hp
Kämpfer 1 fügt ihm 2 Schaden zu -> 8 Hp übrig
Kämpfer 2 fügt ihm 10 Schaden zu -> -2 Hp übrig
Heiler heilt den Helden um 1 Hp -> -1 Hp übrig

In vielen Spielen werden Erfahrungspunkte anhand des reell zugefügten Schadens verteilt. Hier in diesem Beispiel hatte der Held 10 Hp, es wurden aber insgesamt 12 Schadenspunkte verursacht. Zusätzlich wurde der Held noch um 1 Hp geheilt, was den effektiven Hp Pool auf 11 vergrößern würde. Dennoch haben wir hier ein Ungleichgewicht, aufgrund dessen nicht 100%ig ersichtlich ist, wie viel Schaden tatsächlich reell zugefügt wurde. Allerdings sind solche Fälle dann doch eher die Ausnahme, sodass meiner Meinung nach die Vorteile dieser Methode überwiegen. Dennoch sollte man sich dieser Probleme bewusst sein und sich Gedanken machen, ob und in wie weit dies dem persönlichen Nutzen zusagt oder nicht.

Wie mit jedem Pattern auch, gilt es also abzuwägen und nicht blind zu nutzen.

Einen bekannten Anwendungsfall für dieses Pattern gibt es übrigens beim Zeichnen der einzelnen Szenen in den Backbuffer. Meistens werden 2 oder mehrere dieser Buffer verwendet, wodurch immer auf einer aktuell nicht sichtbaren „Leinwand“ gezeichnet werden kann und der Nutzer vor dem Bildschirm keine Zwischenzustände zu sehen bekommen.

Ich hoffe das Thema auf verständliche Art und Weise vorgestellt zu haben und wäre über jedwede Art des Feedbacks froh.

Zum Schluss dann noch einmal der komplette Code des Projekts:

#include <iostream>
#include <string>
#include <cassert>

class Unit
{
public:
    Unit(int _hp, int _atk, std::string _name) :
        m_CurrentHp(_hp),
        m_Hp(_hp),
        m_Atk(_atk),
        m_Name(std::move(_name))
    {
        assert(_hp > 0 && _atk > 0 && !m_Name.empty());
    }

    int getHp() const { return m_Hp; }
    int getAtk() const { return m_Atk; }
    const std::string& getName() const { return m_Name; }
    
    bool isAlive() const { return getHp() > 0; }
    void attack(Unit& _enemy)
    {
        assert(this != &_enemy);
        if (isAlive())   // dead units can't attack
        {
            _enemy.m_CurrentHp -= getAtk();
        }
    }
    
    void endTick()
    {
        m_Hp = m_CurrentHp;
    }
    
private:
    int m_CurrentHp;
    int m_Hp;
    int m_Atk;
    std::string m_Name;
};

const Unit* getWinner(const Unit& _first, const Unit& _second)
{
    if (_first.isAlive() && !_second.isAlive())
        return &_first;
    else if (_second.isAlive() && !_first.isAlive())
        return &_second;
    return nullptr;
}

void printHp(const Unit& _unit)
{
    std::cout << _unit.getName() << " has " << _unit.getHp() << " Hp left." << std::endl;
}

int main()
{
    Unit hero(100, 25, "Hero");
    Unit warrior(100, 25, "Warrior");
    
    while (hero.isAlive() && warrior.isAlive())
    {
        hero.attack(warrior);
        warrior.attack(hero);
        
        hero.endTick();
        warrior.endTick();
        
        printHp(hero);
        printHp(warrior);
    }

    if (auto winner = getWinner(hero, warrior))
        std::cout << "The winner is: " << winner->getName() << std::endl;
    else
        std::cout << "There is no winner." << std::endl;
}

 

Eine kleiner Einblick in std::optional

C++17 ist jetzt schon ein paar Monde alt und ich hatte die Zeit mich ein wenig intensiver damit zu beschäftigen. Vorab: Das hier ist keine spezielle Erklärung für std::optional allein. Dieses Pattern gibt es in ähnlicher oder sogar identischer Form auch in anderen Bibliotheken (z.B. boost::optional).

Was bedeutet optional?

Prinzipiell ist es eine zusätzliche Abstraktionsebene zu einem reinen Wert. Letztendlich dient es vor allem dazu, den Code selbst-dokumentierend zu gestalten. Nehmen wir zum Beispiel eine Funktion die einen 8 bit integer zurück gibt. int8_t myFunction(); Der Autor dieser Funktion hatte vorgesehen, dass die Werte 0 – 127 (also alle nicht negativen) gültige Werte sind; -1 ist der default „irgendwas ist falsch gelaufen“ Wert. Nun, soweit ist das auch kein Problem, nur was ist, wenn jemand fremdes den Code benutzt? Als Autor einer Bibliothek  muss man in jedem Fall dafür Sorge tragen, dass dieses Verhalten dieser Funktion irgendwo dokumentiert wird – notfalls auch direkt im Code!

// Werte 0 - 127 sind gültig; -1 ist ein error Code
int8_t myFunction()

Danach muss der Nutzer der Funktion die Dokumentation lesen und verstehen, und dessen – zumindest das er es ließt – kann der Autor sich nicht sicher sein. Wäre es da nicht besser, wir hätten ein Objekt, dass direkt anzeigt, ob es einen korrekten Wert enthält, oder nicht?

Und hier kommt optional ins Spiel. std::optional<int8_t> myFunction(); Nun hat man als Nutzer der Funktion die Möglichkeit, eine simple Abfrage zu nutzen.

if (auto value = myFunction())
{
    // do something ...
}

Hiermit ist sofort semantisch ersichtlich, dass die Funktion auch ungültige Werte zurück liefern kann.

An den eigentlich Wert von value kommen wir entweder mit dem operator * und mit der value() Methode oder dem operator ->.

if (auto value = myFunction())
{
    std::cout << *value;
}

 

Die Idee an sich ist nicht neu. Um es nochmal ein wenig zu verdeutlichen, hier ein wenig Code:

struct Vector
{
    int x;
    int y;
}

Vector getSomeFancyVector();

Das ist etwas, was – zumindest bei mir – relativ häufig auftritt. Ich lasse mir einen Vector zurück geben; soweit nichts spektakuläres. Wenn die Funktion allerdings auch ungültige Vectoren zurück liefern soll, dann wird es knifflig. Da in einem üblichen Koordinatensystem negative Werte auch zum Gültigkeitsbereich gehören, haben wir über das Objekt an sich keinerlei Möglichkeit zu identifizieren ob der Vector nun gültig ist oder nicht.

Es gab hier verschiedene Ansätze; ich hab schon vieles gesehen. Das eine mehr, das andere weniger gut (kommt natürlich immer auf den Kontext an).

Vector* getSomeFancyVector();                 // (1) ist ok, wenn der Aufrufer nicht für das Aufräumen verantwortlich ist

std::unique_ptr<Vector> getSomeFancyVector(); // (2) gibt den Besitz an den Aufrufer ab; benutzt aber unnötigerweise den Heap

std::pair<bool, Vector> getSomeFancyVector(); // (3) emulieren von std::optional

Generell wende ich alle Methoden mehr oder weniger häufig an; je nach Anwendungsfall. Prinzipiell ist std::optional eine semantische Verbesserung zu (3), mit ein wenig mehr Kapselung (das Objekt regelt selbst, ob es gesetzt wurde oder nicht). Das sind meine Kriterien, wann ich ein std::optional nutze:

  • es kann ein ungültiger Zustand entstehen, der semantisch nicht durch das Objekt vom Typ T erkennbar ist (Beispielsweise bieten manche Klasse isEmpty(), isNull(), isValid() Methoden an)
  • der Besitz soll an den Aufrufer übertragen werden
  • T ist kein polymorpher Typ

Für unser Beispiel mit Vector trifft das alles zu, daher bietet es sich an, std::optional zu nutzen.

struct Vector
{
    int x;
    int y;
}
std::optional<Vector> getSomeFancyVector();


if (auto vec = getSomeFancyVector())
    std::cout << " Vector hat die position x: " << vec->x << " y: " << vec->y;
else
    std::cout << "Vector ist invalid";

 

Under the hood:

Intern ist es eigentlich relativ unspektakulär. Neben der Variabel T, hält optional noch einen bool. Ist std::optional durch den default Constructor erstellt worden, ist er false; wurde ihm ein Wert zugewiesen (bei der Erstellung oder Zuweisung) ist er true.

Wir haben also einen bool Overhead je optional Objekt und eine zusätzliche Indirektion; das gilt es zu wissen!

Zusätzliche Vorsicht ist geboten, wenn man auf das Objekt T eines std::optional zugreifen möchte. Ist das Objekt bisher nicht zugewiesen worden (also das interne bool von optional auf false), ist es undefiniertes Verhalten mit den beiden operatoren * und -> darauf zu zugreifen. Im debug Mode wird man durch assertions unterstützt, im release Mode hingegen kann es zu sehr schwer nachvollziehbaren Fehlern kommen. Es ist also immer erforderlich, std::optional (zumindest einmalig) auf Gültigkeit zu überprüfen. Mit der value() Methode hingegen hat man den Schutz durch Exceptions; allerdings auch permanent eine interne Bedingung ob das optional Objekt nun gültig ist oder nicht, was einen entsprechenden Overhead mit sich bringt (Exceptions sind hier von Compiler zu Compiler zu unterschiedlich, als dass sich eine pauschale Aussage treffen lässt).

 

Andere Anwendungsfälle:

Natürlich lassen sich noch viele andere Anwendungsfälle finden; man ist hier nicht rein auf return Werte beschränkt.

void doSomeFancyWork(std::optional<Vector> _vec)
{
    std::cout "Ausführung von \"doSomeFancyWork\" an ";
    if (_vec)
        std::cout << "Position x: " << _vec->x << " y: " << _vec->y << std::endl;
    else
        "keiner Position";

    // ...
}

Wir haben hier eine Funktion, die einen optionalen Parameter erhalten kann. Das Beispiel ist natürlich nicht sonderlich komplex oder gar sinnvoll; soll hier allerdings auch nur dazu dienen das Prinzip zu verdeutlichen. Es ist eine Alternative zu dieser, durchaus gängigen Methode:

void doSomeFancyWork(const Vector* _vec)
{
    std::cout "Ausführung von \"doSomeFancyWork\" an ";
    if (_vec)
        std::cout << "Position x: " << _vec->x << " y: " << _vec->y << std::endl;
    else
        "keiner Position";

    // ...
}

Prinzipiell sollte klar sein, was hier passiert. Allerdings ist hier die Gefahr, dass der Aufrufer fälschlicherweise an nimmt, dass er den Besitz des Vectors an die Funktion übergibt und den Vector deswegen unnötiger Weise auf dem Heap erstellt und danach nicht mehr löscht. Das ist mit optional eindeutiger. Der Nachteil ist jedoch die zusätzliche Kopie; die entfällt bei richtiger Anwendung der zweiten Methode.

Es macht allerdings keinen Sinn, jetzt alles und jeden bei jeder Gelegenheit optional zu deklarieren; wie jedes Pattern auch, soll es semantisch unterstützen und Code klarer, wartbarer und fehlerfreier gestalten. Ein übermäßiger Einsatz hätte nicht nur Performance Nachteile, sondern wäre auch semantisch mehr als falsch (weil es kann eben nicht alles optional sein!). Setzt es lediglich dort ein, wo es auch Sinn macht.

 

Abschließend möchte ich noch erwähnen, dass es auch für die nicht C++17 Nutzer eine Möglichkeit gibt, std::optional zu nutzen. Die gängigsten Compiler Hersteller haben den experimental namespace eingefügt; dort kann man optional finden, sollte man kein C++17 benutzen (warum auch immer, das Update lohnt sich!).

#include <experimental/optional>
std::experimental::optional<T>

Quellen:

http://en.cppreference.com/w/cpp/experimental/optional
http://en.cppreference.com/w/cpp/utility/optional

 

2 dimensionale vectoren

!!!Achtung: Dieser Artikel ist veraltet. Ich lasse ihn dennoch für alle lesbar, da trotzdem noch viel (meiner Meinung nach) richtiges enthalten ist!!!

 

Da ich mich in letzter Zeit immer mal wieder darüber geärgert habe, dass ich noch keine wirklich gute Implementierung für einen 2 dimensionalen vector geschrieben habe, und ständig verschachtelte Konstrukte zweier vectoren genutzt habe, habe ich mich mal dran gesetzt und ein wenig herum experimentiert. Ich habe natürlich nicht das Rad komplett neu erfunden, sondern habe mich an existierende Ideen gehalten.

In dem nachfolgenden Beitrag versuche ich die Nachteile der verschachtelten vectoren zu erklären und die generelle Funktionsweise von einem internen vector vorzustellen.

 

Nachteile der Verschachtelung:

Eine verschachtelte Datenstruktur ist im Prinzip folgendes:
std::vector<std::vector<T>>
Man hat hier also einen vector, der vectoren mit ints hält, um eine 2 dimensionale Datenstruktur zu simulieren. An sich ist diese Idee gar nicht so falsch, denn hier kann man direkt über die at Methoden oder die [] Operatoren auf die Einträge zugreifen. Warum das allerdings keine wirklich gute Idee ist, soll der nachfolge Text verdeutlichen.

Um dieses System sicher nutzen zu können, ist es zwingend erforderlich, eine eigene Klasse um den vector zu bauen, da ansonsten nicht sichergestellt werden kann, dass bei einer Größenänderung alle Zeilen und Spalten betroffen sind, da man ansonsten sehr schnell einen out of range error hervorruft oder unnötig Speicherplatz verschwendet.
Durch die Verschachtelung, kann man nun nicht alle inneren vectoren auf einmal verändern, sondern muss diese nacheinander bearbeiten und das kann unter Umständen sehr zu lasten der Performance gehen, da pro Zeile oder Spalte (je nachdem, wie man seine Datenstruktur anlegt) eine Allocation benötigt, die potentiell erst einmal teuer ist. Man kann sich das z.B. so vorstellen, als würden bei einem Imbiss mehrere Leute Pommes bestellen, und der Imbiss frittiert jede Portion einzeln nacheinander, statt alle zusammen auf einmal. Das verlängert nicht nur die Zeit, die man für die einzelnen Kunden benötigt, sondern macht diese auch unzufrieden. Die unzufriedenen Kunden wären in diesem Fall die Nutzer des Programms 😉
Da in einem vector alle Daten hintereinander im Speicher liegen, ist das Vergrößern/Verkleinern noch einmal doppelt teuer, da der nachfolgende Speicher erst einmal weiter nach hinten verschoben werden muss, damit zwischendrin Speicher angefordert werden kann. Die nachfolgenden Bilder sollen das oben genannte Problem veranschaulichen.

4x4Vector
Dieses Bild stellt einen logischen 3×3 vector dar. Das ist NICHT die Darstellung für den intern genutzten Speicher.

 

 

 

 

6x4VectorAlloc
Wieder eine Darstellung eines logischen 2 dimensionalen vectors. Diesmal jedoch mit einer Größe von 5×3. Die gestrichelten Kästchen stellen den neu allokierten Speicher dar. An sich sieht das ganz vernünftig aus, schauen wir uns allerdings mal an, was im Hintergrund wirklich passiert.

 

 

Die folgenden Bilder zeigen den Zusammenhang des internen Speichers und soll die Problematik beim Vergrößern ein wenig besser illustrieren.

4x4VectorMemory

Wie man hier gut sieht, sind die Speicherblöcke der einzelnen Zeilen direkt hintereinander im Speicher. Das ist auch gut so, da so ein Zugriff auf jede dieser Zellen in konstanter Zeit erfolgen kann. Was aber getan werden muss, wenn wir unsere Struktur vergrößern, zeigt das nächste Bild.

 

6x4VectorMemoryAlloc

Dieses Bild stellt die Schritte dar, die für die 3 Zeilen durchgeführt werden, wenn man sie auf die oben erklärte Art und Weise ausführt.

  1. 2 zusätzliche Blöcke werden angefordert und die Zeilen 2 und 3 werden im Speicher nach hinten geschoben, um Platz zu schaffen
  2. Wieder werden 2 Blöcke angefordert (diesmal um die Zeile 2 zu vergrößern) und Zeile 3 wird nach hinten geschoben.
  3. 2 Blöcke werden angefordert, und an Zeile 3 angehängt

Wie man sieht, sind 3 allocationen nötig und Zeile 2 wird einmal und Zeile 3 sogar 2 mal verschoben. Leider ist das sogar der optimale Fall.
Wenn der vector in einem Speicherbereich liegt, der nicht genug Platz zum Vergrößern bietet, wird die gesamte Struktur verschoben, und nicht nur die dahinter liegenden Zeilen. Das kann auch bei jeder Vergrößerung einer Zeile passieren. Wenn man also seine Klasse öfter transformiert, wird die Performance bei einer gewissen Anzahl an Elementen deutlich leiden.

 

Das ein vector System

Wie also gerade beschrieben, ist es sinnvoller den Speicherplatz einmal zu reservieren, statt stückchenweise, allerdings ist das mit dieser Struktur nicht möglich.
Aus diesem Grund bietet es sich an, EINEN großen vector zu nutzen, diesen einmal zu vergrößern/verkleinern und die Daten intern EINMAL zu verschieben.
Trotzdem können wir die einzelnen Zellen genau bestimmen. Die Formel für den Index einer Zelle ist ziemlich einfach.
index = y * breite + x
Möchten wir also bei einem vector von 3 x 3 (9 Zellen) auf die Zelle x = 0 und y = 0 zugreifen, erhalten wir einen internen Index von 0 (das erste Element im vector), bei x = 2, y = 1 wäre der Index 6.

Aber wie können wir das Ganze nun transformieren?

Um diesen vector zu transformieren, benötigen wir die neue und die alte Größe. Es bietet sich an, diese beiden Dimensionen (also Höhe und Breite) getrennt von einander zu behandeln.
Neue Zeilen anzufügen ist sehr trivial. Wir vergrößern den vector um
(neueHöhe - alteHöhe) * breite
Elemente. D.h., wenn wir unseren 3 x 3 vector um 2 Zeilen erweitern wollen, fügen wir
5(neueHöhe) - 3(alteHöhe) * 3(breite) = 6
Zellen hinten an. Da wir nichts zwischen unseren alten Daten verändern, müssen wir diese auch nicht verschieben.
Schematisch dar gestellt, sieht das folgendermaßen aus:

Vector2DresizeHeight
Hier kann man ganz gut sehen, das hier die Zeilen hintereinander liegen, und deswegen die bereits vorhandenen Zeilen nicht verändert werden.
Jedoch ist auch hier nicht garantiert, dass der Vector an der momentanen Speicherposition genug Platz hat. Deswegen ist es möglich, dass er an eine andere Adresse verschoben wird. Allerdings passiert das garantiert nur einmalig und ist auch eine vergleichsweise sehr günstige Operation. Zudem können wir das auch gar nicht erst verhindern, da das im Hintergrund passiert. Wichtig ist jedoch, dass Referenzen und Zeiger auf Elemente innerhalb dieses Vectors danach ungültig sein können!
Das Verkleinern funktioniert im Prinzip genau gleich. Auch hier muss nichts verschoben werden, da die zu löschenden Daten bereits am Ende liegen.

Die Breite wird mit der gleichen Formel verändert, allerdings müssen wir jetzt intern noch die bereits vorhandenen Daten verschieben, damit sie am Ende auch an der richtigen Stelle stehen.
Dazu zuerst mal eine schematische Darstellung.

Vector2DresizeWidth
Wie man sieht, kann hat man hier ein bisschen mehr Arbeit, als beim Höhen verändern, ist aber prinzipiell auch recht einfach. Damit wir jede Zeile nur einmal verschieben müssen, fangen wir an, uns von hinten nach vorne zu arbeiten, weil wir sonst möglicherweise noch zu verschiebende Einträge überschreiben würden. D.h. zuerst auf die benötigte Breite festsetzen und dann die vorhandenen Elemente entsprechend nach hinten verschieben.
Beim Verkleinern arbeiten wir uns diesmal von vorne nach hinten und verschieben die Einträge, die behalten werden sollen, nach vorne.

Je nach Bedarf, kann man nun seine vector2D Klasse die Iteratoren von std::vector nach außen anbieten, und damit eine Iteration von außen über alle Elemente ermöglichen.

 

Zeilen und Spalten Modelle

Jetzt kommen wir zu einem Bereich, der bei solchen Implementierungen oft vergessen wird. Wir haben nun einen funktionierenden 2 dimensionalen vector und Zugriff auf alle Elemente. Wenn wir aber nur über eine Spalte/Zeile iterieren wollen, sind wir gezwungen, das über die Indexes zu machen.
Das hat aber den Nachteil, dass keine Funktionen genutzt werden können, die Iteratoren erwarten.
Dazu habe ich folgende Lösung entwickelt:
Die Vector2D Klasse stellt Methoden bereit, die eine Art „Modell“ ihrer Zeilen und Spalten zurück liefert. Diese Modelle beinhalten lediglich einen Index auf die Zeile/Spalte und eine Referenz auf das Vector2D Objekt. Diese Modelle bieten direkten Zugriff auf die darunter liegenden Elemente der Zeile/Spalte und stellen auch Iteratoren bereit. Prinzipiell nicht sonderlich kompliziert.

Mit diesen Modellen können wir nun die range based for Loops und andere nette Dinge nutzen, die uns vorher verwehrt geblieben sind.

Eine vollständige Implementierung und Dokumentation des oben beschriebenen Systems findet ihr hier:
Vector2D.h
Dokumentation

 

Ich freue mich über jedwede Kritik und Kommentare

mfg

SigSlot, eine Beziehung ohne Wissen.

!!!Achtung: Dieser Artikel ist veraltet. Ich lasse ihn dennoch für alle lesbar, da trotzdem noch viel (meiner Meinung nach) richtiges enthalten ist!!!

 

Eine Beziehung, ohne davon zu wissen?! Wie soll das gehen?

Ich weiß, es ist eine ziemlich seltsame Aussage, aber ich rede hier auch nicht von einer Zwischenmenschlichen Beziehung, sondern von einem bekannten Programmier-Pattern.

Was ist eigentlich ein Pattern?

Ein Pattern ist im Prinzip ein Lösungsvorschlag zu einem bekannten Problem. Die Pattern haben den Vorteil, dass sich schon viele kluge Menschen darüber Gedanken gemacht haben und dadurch eine Lösung zu Stande gekommen ist, die mit hoher Wahrscheinlichkeit für die entsprechenden Problemfälle gut zu benutzen ist. Das heißt natürlich nicht, dass man alle Probleme mit Hilfe dieser Patterns lösen muss. Man sollte immer abwägen, ob dem eigentlichen Problem damit geholfen ist oder ob man sich damit nur noch mehr Probleme auferlegt.

Aber genug von Patterns im Allgemeinen, kommen wir zu unserem spezifischen Problem.

Das observer bzw. listener Pattern:

Dieses Pattern beschreibt, wie 2 Objekte unterschiedlichen Typs miteinander kommunizieren können, ohne von einander zu wissen, wobei die Kommunikation eine Einbahnstraße darstellt.

Das heißt im Prinzip haben wir auf der einen Seite einen Sender und auf der anderen Seite mehrere Empfänger. Diese Empfänger lauschen jetzt darauf, bis der Sender sich meldet und ihnen ein Signal sendet, das sie verarbeiten können. Den Sender interessiert nicht, wer alles das Signal empfangen hat, oder was mit dem Signal genau passiert, er weiß lediglich, dass er es versendet hat. Damit ist seine Arbeit getan.

Dem Empfänger ist der letztendliche Sender auch egal, da er lediglich mitbekommt, dass ein Signal an kommt und nicht wer es sendet.

 

Verschiede Begrifflichkeiten:

Im Laufe dieses Posts werden einige Begrifflichkeiten im Bezug auf das Pattern auftauchen, die ich hier mal näher erklären möchte.

Signal: Im Prinzip ist ein Signal nichts anderes als man erwarten würde. Es signalisiert, dass ein Ereignis eingetreten ist und sendet das an seine Empfänger. Es kümmert sich nicht darum, warum es etwas schickt, noch an wen es etwas schickt. Alle Empfänger die auf dieses Signal „lauschen“ erhalten es auch.

Slot: Ein Slot ist eine Funktion, die durch ein Signal ausgelöst wird. Ein Slot weiß nicht, woher ein Signal kommt oder warum es gesendet wurde, er erhält lediglich die durch das Signal bereit gestellt Informationen.

Empfänger/Receiver: Ein Receiver ist ein Objekt, das ein oder mehrere Slots besitzt.

Verbindung/Connection: Die Beziehung von einem Signal zu einem Slot nennt man Connection.

Emit: Emit heißt so viel wie „aussenden“ und bezeichnet den Vorgang, wenn ein Signal ausgelöst wird.

Wie man sich nun denken kann, setzt sich SigSlot demzufolge aus den beiden Begriffen Signal und Slot zusammen 😉

 

Überblick über einige Implementierungen:

Für dieses System gibt es verschiedene Implementierungen, die einen besser, die anderen schlechter. Die Beste mir bekannte, dafür auch die aufwendigste, bietet das Qt Framework. Hierbei wurde das Pattern geschickt in die Sprache „verflochten“, sodass eine sehr intuitive und effektive Nutzung möglich ist. Allerdings ist das Signal/Slot System ein sehr, sehr großer Teil des Frameworks und hat viele Abhängigkeiten, sodass eine Nutzung in kleineren Projekten zu viel Overhead und Konfigurationsarbeit mit sich ziehen würde.

Eine andere mir bekannte Implementierung ist in der boost Library. Auch wenn ich mich bisher nicht sonderlich intensiv mit boost::signals bzw. boost::signals2 (die Thread sichere Variante) beschäftigt habe, ist das mit Sicherheit keine schlechte Alternative. Allerdings sind viele Leute der Ansicht, dass die boost::signals(2) Library nicht performant genug ist.

Nach ein wenig mehr Recherche habe ich die sigslot Implementierung gefunden. Auf den ersten Blick eine sehr robuste, allerdings auch veraltete Implementierung. Durch C++11 könnte man den Code wesentlich einfacher und sauberer gestalten. Der Hauptgrund, warum ich letztendlich gegen diese Implementierung bin ist, dass sie Rekursion nicht beachtet. Wenn man ein signal/Slot löscht, während man in der „emit“ Phase ist, läuft man unweigerlich in einen Error. Das ständig im Auge zu behalten, wann ich wo welche Objekte löschen darf, war mir ein Dorn im Auge; … ein ziemlich großer sogar.

Damit war letztendlich die Entscheidung gefallen, etwas eigenes zu entwickeln. Nach ein wenig herumprobieren und Stolpern über den ein oder anderen Denkfehler, habe ich schlussendlich ein Konzept entwickelt, was meinen Anforderungen stand hält und sich der Features des C++11 Standards bedient.

Ein kurzer Überblick der Probleme:

Es gibt, wie bereits gezeigt, verschiedene Möglichkeiten dieses Pattern zu implementieren. Um aber mal einen kleinen Überblick über die Probleme zu geben, die während der Entwicklung einer Implementierung entstehen können, versuche ich Schritt für Schritt aufbauende Beispiele zu liefern, wie man etwas angehen kann und was dabei die Nachteile sind.

Die wohl einfachste Möglichkeit um ein Signal zu versenden, ist eine Methode die eine Methode eines anderen Objekts aufruft.

class Label
{
public:
    void onButtonClicked()
    {
        // mach was
    }
};

class Button
{
    Label* m_pLabel;

public:
    Button(Label* pLabel)
        : m_pLabel(pLabel)
    {}

    void clicked()
    {
        m_pLabel->onButtonClicked();
    }
};

int main()
{
    Label label;
    Button button(&label);
    button.clicked();
}

Wie man sieht, ist das ganze sehr zweckmäßig über zwei einfache Methoden in den Klassen Label und Button geregelt. Label::onButtonClicked wird aufgerufen, wenn Button::clicked aufgerufen wird. Das Ganze bringt aber gewisse Unschönheiten mit sich. Will man denn jedes mal auch ein Label haben, wenn man einen Button erstellt? Was ist, wenn ich das nicht möchte?

Wie man sieht ist die Benutzung solch eines Designs zwar einfach aber auch sehr beschränkt. Ich muss mir für jeden Anwendungsfall meine eigene Button Klasse erstellen und alle Objekte, die ein Signal erhalten sollen, mindestens als Zeiger oder Referenz bekannt machen. Das bringt noch einen Haufen Probleme mit sich. Was ist z.B. wenn das Objekt, welches das Signal erhalten soll, gelöscht wird? Dann sind Aufräumarbeiten von Außen nötig. Das gilt es allerdings zu vermeiden, da solche Konstrukte äußerst fehleranfällig und schlecht zu warten sind.

Wir haben also mehrere große Nachteile:

  • Sender muss Empfänger explizit kennen
  • schlechte Erweiterbarkeit
  • kein automatisches Aufräumen bei Löschung von Objekten

Schauen wir uns die ersten 2 Punkte an, erkennen wir einen gewissen Zusammenhang, den wir uns im nächsten Abschnitt näher anschauen wollen.

Eine Möglichkeit das Ganze ein wenig generischer zu machen, wäre für unsere Buttonklasse einen std::vector mit Zeigern vom Typen Receiver (Empfänger) oder ähnlichem zu füllen. Wir könnten eine abstracte Methode „onButtonApply“ einführen, und somit unsere Empfänger dynamisch hinzufügen oder auch wieder löschen.

Das sähe dann in etwa so aus:

class Receiver
{
public:
    virtual void onButtonClicked() = 0;
};

class Label : public Receiver
{
public:
    void onButtonClicked()
    {
        // mach was
    }
};

class Button
{
    std::vector<Receiver*> m_pReceivers;

public:
    void addReceiver(Receiver* pReceiver)
    {
        m_pReceivers.push_back(pReceiver);
    }

    void clicked()
    {
        for (auto pRec : m_pReceivers)
            pRec->onButtonClicked();
    }
};

int main()
{
    Label label;
    Button button;
    button.addReceiver(&label);
    button.clicked();
}

Die Nachteile hier liegen auf der Hand:

  • Button ruft immer eine explizite Methode der Objekte auf
  • Dementsprechend für jedes Signal eine eventuell nur einmal genutzte Methode in der Interface Klasse
  • Kein automatisches Aufräumen

Ist zwar an sich keine allzu schlechte Sache, aber für eine generische Lösung noch viel zu beschränkt.

Überlegen wir also ein Stück weiter:

C++ bietet uns die Möglichkeit Funktionszeiger zu nutzen. Funktionszeiger sind im Prinzip Zeiger auf eine Stelle im Code. Jede Funktion hat ihre eigene Adresse, die man dadurch referenzieren kann. Schwieriger wird es bei Methoden (Klassenfunktionen). Diese funktionieren ein wenig anders, was den Aufwand für eine generische Lösung erhöht aber nicht unmöglich werden lässt.
Wir könnten uns dabei die STL zu Nutze machen und std::function und std::bind nutzen. Ein guter Weg, den wir im nächsten Schritt mal weiter verfolgen möchten.

#include <functional>
#include  <vector>
#include <iostream>

class Label
{
public:
     void onButtonClicked()
     {
          std::cout << "called!";
     }
};

class Button
{
     std::vector<std::function<void()>> m_pReceivers;

public:
     void addReceiver(std::function<void()> func)
     {
          m_pReceivers.push_back(func);
     }

     void clicked()
     {
          for (auto pFunc : m_pReceivers)
               pFunc();
     }
};

int main()
{
     Label label;
     Button button;
     button.addReceiver(std::bind(&Label::onButtonClicked, &label));
     button.clicked();
}

Super! Wir haben nun einen std::vector in dem wir verschiede Objekte beliebiger Typs mitsamt ihrer Methode aufnehmen können.

Allerdings bleiben noch 2 weitere Problem:

  • mühsam zu erweitern
  • kein automatisches Aufräumen

Um das Erweitern zu vereinfachen lagern wir den Signal Code aus Button in eine eigene Klasse Signal aus und erstellen uns einen public Member von Signal in Button. Diesen nennen wir „clicked“.

Das mag auf dem ersten Blick ein wenig verwirren, allerdings macht das bei näherem Hinsehen durchaus Sinn, so vorzugehen.

Dadurch betrachten wir nun die Signale nicht länger als reine Funktion, sondern als Objekte, was sie ja eigentlich auch sein sollen. Sie besitzen nun gewisse Automatismen, welche nötig sind um das Programm am Laufen zu halten und Fehlern vorzubeugen.

Diese Signal Objekte besitzen nun jeweils ihre eigene Liste der Empfänger und der Methoden, die sie aufrufen.

#include <functional>
#include  <vector>
#include <iostream>

class Label
{
public:
    void onButtonClicked()
    {
        std::cout << "clicked!";
    }

    void onButtonReleased()
    {
        std::cout << "released!";
    }
};

class Signal
{
    std::vector<std::function<void()>> m_pReceivers;

public:
    void addReceiver(std::function<void()> func)
    {
        m_pReceivers.push_back(func);
    }

    void emit()
    {
        for (auto pFunc : m_pReceivers)
            pFunc();
    }
};

class Button
{
public:
    // unsere beiden public Signal Member
    Signal clicked;
    Signal released;
};

int main()
{
    Label label;
    Button button;
    button.clicked.addReceiver(std::bind(&Label::onButtonClicked, &label));
    button.clicked.emit();
    button.released.addReceiver(std::bind(&Label::onButtonReleased, &label));
    button.released.emit();
}

Da wir nun Signale als Objekte betrachten und nicht als reine Funktionen, haben wir die Möglichkeit das Signal eigenständig arbeiten zu lassen. Sie halten ihre eigene Liste auf Empfänger und können die „Verbindung“ zu ihren Slots eigenständig trennen, wenn eines der beiden nicht mehr existiert.

Aber wie setzen wir das automatische disconnecten nun um?
Mein Lösung dazu ist, dass alle Objekte, die ein Signal empfangen sollen, von einer gemeinsamen Basisklasse erben müssen. Diese Basisklasse enthält den nötigen Code zum Lösen einer Verbindung. D.h. im Klartext: Das Empfänger Objekt sendet im Destruktor eine Anweisung an alle mit ihm verbundenen Signale, um die Verbindung zu trennen.
Das hat zwar zur Folge, dass ich nicht mehr alle beliebigen Objekte an mein Signal übergeben kann, sondern nur noch diese, die von der Receiver Klasse erben, aber das gibt mir auch entsprechende Sicherheit, dass ich mich um das Aufräumen nicht mehr kümmern muss.
Da ein Beispiel für die weitere Implementierung ein wenig zu weit gehen würde, poste ich hier ein paar Links, die zu meiner Implementierung führen.
Der Code ist dokumentiert und kann hier nachgelesen werden:

connection.h
receiver.h
receiver.cpp
signal.h

Ich würde mich über ein paar Kommentare freuen, gerne auch Kritik und Verbesserungsvorschläge 😉

mfg

Hallo erst mal, ich weiß ja nicht ob Sie es schon wussten, …

dass ich in diesem Blog meine Projekte vorstellen werde und von Zeit zu Zeit auch ein paar philosophische Worte über neue Erkenntnisse verlieren möchte.

Keine Angst, ich werde hier nicht über den Weltfrieden oder irgendwelche politischen Ansichten debattieren, sondern es geht vielmehr um programmierung mit C++ und andere damit verbundene Themen.
mfg

Was erwartet den geneigten Leser also im Detail?

Ich plane hier meine Fortschritte in der SimpleLib und der Simple2D-Engine festzuhalten und entsprechende Implementierungen auch im Detail zu erörtern. Dazu gehört unter anderem, wieso ich etwas so gelöst habe und welche Alternativen es dazu gibt.

Lasst euch also überraschen, es könnte spannend werden 😉

mfg