Klassenschachtelung für Operatorüberladung ohne Dynamikverlust



  • Nexus schrieb:

    In Java nimmt implementiert man dafür normalerweise Interfaces. In C++ kann man das auch machen, es gibt aber auch eine direkte Möglichkeit für Callbacks. Entweder du nimmst Funktionszeiger oder, falls du sie zur Verfügung hast, die Klasse std::tr1::function bzw. boost::function . Darin kannst du alle möglichen Funktionen speichern und aufrufen, die eine entsprechende Signatur haben. Aber vielleicht geht das hier auch etwas zu weit...

    Da ich ja so nen Java-Hintergrund habe hab ich bisher in meinem Design oft etwas Ähnliches gemacht: Abstrakte Oberklasse (pure virtual, definiert so zu sagen das Interface) und abgeleitete, implementierende Klassen. Die Referenzen bzw. Zeiger des Objektes der implementierenden Klasse "RessourcenObserver" würde ich dann als Observer bei den Spielerressourcen einfügen, sie kennt dann auch alle Buttons und kann sie de-/aktivieren. Die Buttons kann ich dann mit Kosten versehen, die gg. die Ressourcen abgeglichen würden.
    Allerdings bin ich mir über einen generischen Mechnismus bzgl. der observierten Klassen noch nicht klar, die sich bzw. ihre obervierten Zustände ja an die Observer geben müssten. In diesem Falle würde der RessourcenObserver den aktuellen Ressourcenstand der Ressourcenklasse benötigen, um ihn mit den Kosten der einzelnen Buttons abgleichen zu können. Dies ist aber die spezifische Formulierung des allgemeinen Prinzips.
    Wie wäre denn die Idee in so einem Falle mit Funktionspointern (die kenne ich auch)? Die Ressourcenklasse würde die entsprechende Funktion des Observers aufrufen und den Ressourcenstand als Parameter mitgeben? Wäre dann auch eine sehr spezifische Schnittstelle und für eine andere zu observierende Klasse müsste dann ein anderer Funktionspointer genutzt werden. Oder gibt es noch eine algemeinere Möglichkeit?



  • Mit Laufzeitpolymorphie (wie in Java) könnte das Observer-Pattern etwa folgendermassen umgesetzt werden:

    class ResourceManager // besseren Namen wählen
    {
        public:
            ~ResourceManager() // Destruktor zum Aufräumen
            {
                for (/* alle itr in myObservers */)
                   delete *itr;
            }
    
            void AddObserver(Observer* obs)
            {
                myObservers.push_back(obs);
            }
    
            void Notify() const
            {
                for (/* alle itr in myObservers */)
                   itr->Update(myCurrentResources);
            }
    
        private:
            // Kopierkonstruktor und Zuweisungsoperator verbieten
    
            std::vector<Observer*> myObservers;
            ResourceAmount         myCurrentResources;
    };
    
    class Observer
    {
        public:
            virtual ~Observer();
            virtual void Update(const ResourceAmount& res) = 0;
    };
    
    class ButtonObserver : public Observer
    {
        public:
            ButtonObserver(Button& button)
            : myButton(button)
            {
            }
    
            virtual void Update(const ResourceAmount& res)
            {
                if (EnoughResources(res))
                    myButton.Enable();
                else
                    myButton.Disable();
            }
    
        private:
            Button& myButton;
    };
    
    int main()
    {
        ResourceManager mgr;
        Button button2;
        mgr.AddObserver(new ButtonObserver(button2));
        mgr.Notify();
    }
    

    Das grössere Problem bei diesem Ansatz ist, dass du an eine Klassenhierarchie und eine Funktion mit genau festgelegter Signatur gebunden bist. Das kleinere, dass du etwas mehr Code als bei den Alternativen schreibst. Der Ansatz über Funktionszeiger:

    class ResourceManager
    {
        public:
            // Observer ist nun ein Funktionszeiger
            typedef void (*Observer)(const ResourceAmount&);
    
            void AddObserver(Observer obs);
    
            void Notify() const
            {
                for (/* alle itr in myObservers */)
                   (*itr)(myCurrentResources); // Funktionsaufruf
            }
    };
    
    void ObserveButton(const ResourceAmount& res)
    {
        // Wo kriegen wir den Button her? Zusätzlicher Parameter geht nicht,
        // weil wir in Notify() nichts übergeben können. Ausserdem hat man dann
        // keine einheitliche Schnittstelle mehr.
        // Einzige Möglichkeit: Globaler Button. Das kanns aber nicht sein.
    }
    

    Man könnte jetzt einen Memberfunktionszeiger nehmen und ihn auf eine Update() -Funktion zeigen lassen. Damit hat man im Prinzip virtuelle Funktionen nachgebaut. Allerdings geht das viel eleganter, nämlich mit einer modernen Abstraktion eines Funktionszeigers:

    class ResourceManager
    {
        public:
            // Observer ist nun irgendein Callable (aufrufbares Objekt)
            // mit kompatibler (muss nicht exakt stimmen!) Signatur
            typedef std::tr1::function<void(const ResourceAmount&)> Observer;
    
            void AddObserver(const Observer& obs);
            void Notify(); // genau wie beim zweiten Ansatz
    };
    
    // 1. Möglichkeit: Funktor, also Klasse mit operator()
    // ähnlich wie ganz oben, nur mit statischer Polymorphie
    class ButtonObserver
    {
        public:
            ButtonObserver(Button& button);
    
            // Statt Update() überladener operator()
            void operator() (const ResourceAmount& res)
            {
                // konfiguriere myButton
            }
    
        private:
            Button& myButton;
    };
    
    // 2. Möglichkeit: Freie Funktion
    void ObserveButton(ResourceAmount res, Button& button)
    {
        // Konfiguriere button
    }
    
    // 3. Möglichkeit: Memberfunktion, z.B. direkt von Button
    // hier wahrscheinlich unangebracht, aber fürs Prinzip
    class Button
    {
        public:
            void AdaptToResources(const ResourceAmount& res);
    };
    
    int main()
    {
        ResourceManager mgr;
        Button button2;
    
        // 1. Funktionsobjekt/Funktor mit operator()
        mgr.AddObserver( ButtonObserver(button2) );
    
        // 2. Freie Funktion, an Objekt gebunden
        // Erzeugt einen Funktor mit Signatur void(const ResourceAmount&)
        // Also wie 1., nur generisch und mit viel weniger Code. ObserveButton()
        // nimmt den ersten Parameter per Value statt Const-Referenz, das stellt
        // aber kein Problem dar -> Signatur muss nur kompatibel sein!
        mgr.AddObserver( tr1::bind(&ObserveButton, _1, button2) );
    
        // 3. Memberfunktion, an Objekt gebunden
        // Es wird ebenfalls ein neuer Funktor erzeugt, der direkt auf button2 
        // die Methode Button::AdaptToResources() aufruft.
        mgr.AddObserver( tr1::bind(&Button::AdaptToResources, &button2) );
    
        // Cool, oder? Ruft alles Mögliche auf.
        mgr.Notify();
    }
    

    So, ich habe dir nun ein paar moderne Möglichkeiten in C++ gezeigt. Wahrscheinlich ist es für dich momentan am einfachsten, du bleibst bei dynamischer Polymorphie. Aber früher oder später solltest du dich mit den Alternativen vertraut machen, dann könntest du z.B. hier nachschauen. 😉



  • Also was ich bisher in ähnlichen Konstellationen programmiert habe ist ähnlich Deinem ersten Vorschlag. Wobei meines Erachtens die abstrakte Klasse Observer ResourceObserver heißen müsste, da sie keine allgemeine Observerklasse ist, sondern speziell für Ressourcen zuständig.

    Letzteres war eher das, was ich im Sinn hatte: Eine generische Observerklasse, von der dann spezielle Implementierende Klassen ableiten. Wobei das mit der "normalen" Klassenhierarchie wie in Java schwierig wird, da man ja an eine einheitliche Methodensignatur gebunden ist, die durch die allgemeine Observerklasse vorgegeben wird. Für einen ResourceObserver, der von solch einer allgemeinen Klasse ableitet müssten dann die Ressourcen, gegen die geprüft werden soll auf einem anderen Weg mitgegeben werden. Dies bringt andere Einschränkungen mit sich.

    Das mit der Abstraktion des Funktionszeigers hab ich nur zum Teil verstanden. Ich nehm mal an, dass die Grundalgen wieder in der

    std::tr1::function<>
    

    zu finden sind?
    V.a. den Bind der 2. und 3. Möglichkeit hab ich nicht verstanden. Bei der 2. Möglichkeit wird der Funktor an ?? gebunden, _1 ist dort sicher ein Platzhalter, der sich auf irgend ein erstes Argument bezieht. das den "Typ" ResourceAmount hat. Aber woher kommt dieses? Das zweite Argument ist dann ganz normal der Button, entsprechend der Signatur der freien Funktion.
    In der 3. Möglichkeit wird die Memberfunktion AdaptToResources der Buttonklasse an ?? gebunden. Gerufen wird sie auf der Instanz von button2, auf den eine Referenz übergeben wird. Soweit mal meine Interpretation.

    Aber wie kann man denn in den für mich "komplizierten" Möglichkeiten des abstrakten Funktionszeigers (weil ich das Konzept noch nicht kannte) den generischen Ansatz eines "allgemeinen Observers" fahren, von dem man dann spezielle Ausprägungen (für Resourcen und anderem) machen kann? Oder geht das in dem Sinne gar nicht?



  • Reth schrieb:

    Das mit der Abstraktion des Funktionszeigers hab ich nur zum Teil verstanden. Ich nehm mal an, dass die Grundalgen wieder in der

    std::tr1::function<>
    

    zu finden sind?

    Ja. Eine Einführung in std::tr1::function bzw. boost::function (die sind eigentlich gleich) findest du beispielsweise hier.

    Reth schrieb:

    _1 ist dort sicher ein Platzhalter, der sich auf irgend ein erstes Argument bezieht. das den "Typ" ResourceAmount hat. Aber woher kommt dieses? Das zweite Argument ist dann ganz normal der Button, entsprechend der Signatur der freien Funktion.

    Exakt. Du kannst dir Platzhalter so vorstellen, dass sie die finalen Parameter des erzeugten Objekts repräsentieren. Wenn man mit std::tr1::bind einen Ausdruck ohne Platzhalter erzeugt, ist der resultierende Funktor parameterlos, da bereits alle ursprünglichen Parameter mit konkreten Werten gefüllt wurden. std::tr1::bind ist also ein Hosentaschen-Fabrik, mit der man flexibel Funktoren zusammenbauen kann.

    In unserem Beispiel nehmen wir einen Zeiger auf die ObserveButton -Funktion:

    tr1::bind(&ObserveButton, _1, button2)
    

    Beim Aufruf wird als erstes Argument der Platzhalter _1 eingesetzt (d.h. noch kein konkreter Wert). Das zweite Argument wäre der feststehende button2 . Generiert wird also sowas:

    struct Functor
    {
        Functor(Button& static_arg2)
        : arg2(static_arg2)
        {
        }
    
        void operator() (const ResourceAmount& arg1)
        {
            ObserveButton(arg1, arg2);
        }
    
        Button& arg2;
    };
    
    // schlussendlich (mit ein paar Kopien dazwischen):
    Functor functorInstance(button2);
    

    Der entstehende Funktor wird nachher so im ResourceManager gespeichert. Beim Aufruf wird die Membervariable myResources für den Platzhalter _1 eingesetzt:

    // Überladener operator() wird aufgerufen
    functorInstance(myResources);
    

    Reth schrieb:

    In der 3. Möglichkeit wird die Memberfunktion AdaptToResources der Buttonklasse an ?? gebunden. Gerufen wird sie auf der Instanz von button2, auf den eine Referenz übergeben wird. Soweit mal meine Interpretation.

    Bei Memberfunktionen ist das Prinzip das gleiche, nur dass der erste bind() -Parameter (nach der Angabe der Funktion) immer für den this -Zeiger des Objekts steht, auf welchem die Methode aufgerufen wird. Grundsätzlich kannst du dir eine Memberfunktion auch als freie Funktion vorstellen:

    void MyClass::Member(int a, double b);
    void MyClass_Member(MyClass* this, int a, double b);
    

    Reth schrieb:

    Aber wie kann man denn in den für mich "komplizierten" Möglichkeiten des abstrakten Funktionszeigers (weil ich das Konzept noch nicht kannte) den generischen Ansatz eines "allgemeinen Observers" fahren, von dem man dann spezielle Ausprägungen (für Resourcen und anderem) machen kann? Oder geht das in dem Sinne gar nicht?

    Auf der Observerseite hast du im Grunde nur noch verpackte Funktionen ( std::tr1::function ), da kannst du nicht mehr viel generischer werden. Auf der Registrierungsklassen-Seite hingegen könntest du Templates verwenden.

    template <typename ObserverArgument>
    class RegistrationCenter
    {
        public:
            typedef std::tr1::function<void(ObserverArgument)> Observer;
    
            void Register(const Observer& obs) // hiess vorher AddObserver()
            {
                myObservers.push_back(obs);
            }
    
            void Notify(ObserverArgument arg) const
            {
                for (/* alle itr in myObservers */)
                   (*itr)(arg); // Funktionsaufruf
            }
    
        private:
            std::vector<Observer> myObservers;
    };
    

    Auf das Ressourcenbeispiel angewandt könnte eine Instanziierung so aussehen:

    RegistrationCenter<const ResourceAmount&> mgr;
    mgr.Register(/* irgendeine Funktion */);
    // ...
    mgr.Notify(myResourceAmount);
    

    Das könnte man aus einer anderen Klasse heraus tun (z.B. wäre dann myResourceAmount eine Membervariable). Kann aber auch sein, dass ich dich komplett falsch verstanden habe, in dem Falle müsstest du vielleicht etwas konkreter werden.



  • Hi nochmal!

    Danke! Genau so, wie in Deinem letzten Bsp. hab ich mir das vorgestellt! Allerdings sieht mein momentaner Code so aus, dass noch viele Konzepte durcheinander angewandt werden. Z.B. nehm ich noch nicht überall das Observer-Pattern, wo es angebracht wäre. Z.T. übergebe ich Referenzen/Zeiger auf Objekte, die geändert werden müssten direkt an die ändernden Klassen (z.B. wird das Cursorobjekt einmal an die Buttons übergeben, bei deren Aktivierung es sein Aussehen ändern soll und es wird zu dem an das Objekt [Observer] übergeben, welches auf Mausbewegungen hört, damit es an der Mausposition angezeigt werden kann). Gibt es da eigentlich eine gute Daumenregel, ab wann man sich besser an einheitliche Konzepte hält und wann man den pragmatischen Ansatz wählt (abgesehen von der persönlichen Präferenz natürlich)?



  • Ich würde mir da nicht allzu grosse Sorgen machen. Ältere Projekte von mir verwenden kaum Design Patterns (zumindest nicht bewusst), aber ich würde jetzt nicht sagen, dass der Code deswegen nicht durchdacht ist.

    Es ist sicher nicht schlecht, ab und zu neue Entwurfsmuster auszuprobieren, weil sie teilweise wirklich elegante Möglichkeiten mit sich bringen. Aber den Code auf Biegen und Brechen so zu gestalten, dass man möglichst viele davon nimmt, würde ich nicht. Oft spricht auch gegen den "pragmatischen" Ansatz nichts. Ich würde mir im konkreten Fall überlegen, bei welcher Variante mehr Code nötig ist, wo mehr Abhängigkeiten zwischen Klassen bestehen, wo bessere Erweiterbarkeit und Wartbarkeit gewährleistet wird, und wie wichtig diese Faktoren für jenen Fall überhaupt sind. Implementierungsaufwand und -zeit ist natürlich auch ein Kriterium, wobei das in Privatprojekten weniger ins Gewicht fällt. Man hat dafür mehr Zeit zum Experimentieren... 😉



  • Pattern dienen ja auch dazu einen Denkansatz zu bekommen. Oftmals verwende ich am Ende nicht wirklich das Pattern, sondern etwas ähnliches, was ein paar Gedanken des Pattern vereint, aber einfach ein Pattern abzubilden, damit man es benutzt macht nicht viel Sinn. Z.B das MVC Pattern benutzt ich in einer abgeänderter Version, aber die Idee dahinter bemerkt man ganz klar.



  • drakon schrieb:

    Oftmals verwende ich am Ende nicht wirklich das Pattern, sondern etwas ähnliches, was ein paar Gedanken des Pattern vereint

    Deshalb heißen die Dinger auch Pattern, weils eben keine feste Schablone ist sondern nur eine grobe Richtung. Ein Pattern genau nach Schema F wies im Buch steht abzubilden ist nicht Sinn der Sache.



  • Danke für eure Tips. Hatte mich wohl etwas missverständlich ausgedrückt. Natürlich geht es mir nicht um das sklavische Befolgen von irgendwelchen Vorgaben (Pattern o.ä.). Mir ist eher wichtiger zu wissen, ab wann ein Refactoring angesagt ist (quasi die "Schmerzgrenze" bzw. der Schwellwert), falls es dafür Kriterien gibt.

    Mit den Anregungen aus dem Thread (inhaltliche und technische) hab ich jedenfalls schon ein paar gute Ideen für demnächst!



  • pumuckl schrieb:

    drakon schrieb:

    Oftmals verwende ich am Ende nicht wirklich das Pattern, sondern etwas ähnliches, was ein paar Gedanken des Pattern vereint

    Deshalb heißen die Dinger auch Pattern, weils eben keine feste Schablone ist sondern nur eine grobe Richtung. Ein Pattern genau nach Schema F wies im Buch steht abzubilden ist nicht Sinn der Sache.

    Ja, das ist klar, aber ich meinte noch einen Schritt weiter. So dass man nicht sagen kann das ist jetzt das Pattern, sondern das benutzt vielleicht lediglich eine Idee davon.


Anmelden zum Antworten