Engine Pattern



  • Hallo,

    ich betrachte gerade folgendes Beispiel für statischen Polymorphismus. Und mir ist nicht ganz klar, was der Vorteil der "statischen basisklasse" Swallow ist. Ich kann ja, wie in der methode print_nobase() eine Art "duck-typing" betreiben, und verschiedene klassen gleich behandeln.

    #include <iostream>
    using namespace std;
    
    template<class Engine> 
    class Swallow
    {
        Engine engine_imp;
    public:
        float get_airspeed() const
        {
    	return engine_imp.get_airspeed();
        }
    };
    
    class EuropeanSwallow
    {
     public:
        float get_airspeed() const
        {
    	return 11.0f;
        }
    };
    
    class AfricanSwallow
    {
     public:
        float get_airspeed() const
        {
    	return 12.0f;
        }
    };
    
    template<class T>
    void print_airspeed(const Swallow<T> & s)
    {
        cout << "Flying at a speed of " << s.get_airspeed() << endl;
    }
    
    template<class T>
    void print_nobase(const T & s)
    {
        cout << "Flying at a speed of " << s.get_airspeed() << ", NO BASE!" << endl;
    }
    
    int main()
    {
        Swallow<AfricanSwallow> a;
        Swallow<EuropeanSwallow> b;
        print_airspeed(a);
        print_airspeed(b);
        print_nobase(a);
        print_nobase(b);
    }
    


  • In dem Beispiel hast du keinen Vorteil.
    Im realen Einsatz hast du vermutlich relativ viel Code in Swallow<T> stehen, und nur relativ wenig in T. Bzw. einfach andere Dinge.



  • hustbaer schrieb:

    In dem Beispiel hast du keinen Vorteil.
    Im realen Einsatz hast du vermutlich relativ viel Code in Swallow<T> stehen, und nur relativ wenig in T.

    Aber dann kann ich die Spezialisierungen immer noch von Swallow erben (mit nichtvirtuellen Methoden), und habe keinen Geschwindigkeitsnachteil?

    Bzw. einfach andere Dinge.

    elaborate? real world examples?



  • SummerOf84 schrieb:

    hustbaer schrieb:

    In dem Beispiel hast du keinen Vorteil.
    Im realen Einsatz hast du vermutlich relativ viel Code in Swallow<T> stehen, und nur relativ wenig in T.

    Aber dann kann ich die Spezialisierungen immer noch von Swallow erben (mit nichtvirtuellen Methoden), und habe keinen Geschwindigkeitsnachteil?

    Wo siehst du hier Spezialisierung? Ich sehe hier nur ein ungünstiges Beispiel, das nur einen Spezialfall zeigt, nämlich wo die "Engine" Funktion gleicht heisst und genau 100% dasselbe tut wie die Frontend-Funktion.

    Bzw. einfach andere Dinge.

    elaborate? real world examples?

    Look for yourself?
    Boost.Asio?

    ----

    Vielleicht ist in dem Paper/... wo du das her hast etwas anderes gemeint. Wäre halt hilfreich gewesen wenn du es verlinkt hättest...


  • Administrator

    hustbaer schrieb:

    Bzw. einfach andere Dinge.

    elaborate? real world examples?

    Look for yourself?
    Boost.Asio?

    Oder vielleicht etwas einfacher zu verstehen: Boost.Random .

    Grüssli



  • hustbaer schrieb:

    Vielleicht ist in dem Paper/... wo du das her hast etwas anderes gemeint. Wäre halt hilfreich gewesen wenn du es verlinkt hättest...

    http://hal.iwr.uni-heidelberg.de/teaching/numsimneuro_ss2010/c++-skript.pdf



  • Also das Beispiel in dem Skript finde ich genauso unglücklich gewählt wie dein Beispiel 😉

    Vergiss erstmal dass in das Skript von klassischer Vererbungs-Polymorphis mit virtuellen Funktionen ausgeht und versucht dann die Kurve zur "Engine" zu kratzen.

    Das was du "statische Basisklasse" nennst, ist nämlich keine Basisklasse, das ist einfach eine Klasse die halt irgendeine Aufgabe hat.

    Sehen wir uns ein Boost.Random-inspiriertes Beispiel an.

    Sagen wir du willst Zufallszahlen erzeugen. Sagen wir weiterhin du benötigst immer nur gleichverteilte Zahlen von 0 bis N-1 (N=variabel zur Laufzeit), möchtest aber verschiedene Generatoren verwenden können (LC, Mersenne-Twister, echte Hardware-Generatoren - was auch immer).

    Könnte man natürlich alles ohne Polymorphie und/oder Engines lösen. Geht aber schöner mit, denn den Teil "gleichverteilte Zahlen von 0 bis N-1" können wir 1x für alle Generatoren implementieren. Dazu benötigen wir vom Generator lediglich eine Funktion mit der man gleichverteilte Zahlen von 0 bis K-1 ziehen kann, wobei K vom Generator vorgegeben wird (üblicherweise ist K konstant, dürfte aber für dieses Beispiel genauso variabel sein, nur dass wir der Einfachkeit halber N <= K fordern).

    Klassisch könnte man das nun natürlich über eine Basisklasse und Vererbung lösen (wobei ich es nicht empfehlen würde, weil es Zuständigkeiten vermischt und die Vererbung vollkommen unnötig ist):

    class GeneratorBase
    {
    private:
    	// Einschränkung: K muss in einem 32 Bit Integer Platz haben
    	virtual uint32_t GetRawUpperBound() const = 0; // unser "K"
    	virtual uint32_t GetRawNumber() = 0;
    public:
    	uint32_t GetNumber(uint32_t upperBound) // unser "N"
    	{
    		assert(upperBound >= 1); // Resultat von 0 bis (upperBound - 1) gewünscht, also darf upperBound nie 0 werden.
    		uint32_t const rawUpperBound = GetRawUpperBound();
    		assert(rawUpperBound >= upperBound); // würde für dieses Beispiel zu weit führen wenn wir N > K erlauben
    		uint32_t const usableRawLimit = (rawUpperBound / upperBound) * upperBound;
    		for (;;)
    		{
    			uint32_t const rawNumber = GetRawNumber();
    			if (rawNumber < usableRawLimit)
    				return rawNumber % upperBound;
    		}
    	}
    };
    
    class BuiltinGenerator : public GeneratorBase
    {
    private:
    	virtual uint32_t GetRawUpperBound() const { return RAND_MAX; }
    	virtual uint32_t GetRawNumber() { return rand(); }
    };
    
    class SomeHardwareGenerator : public GeneratorBase
    {
    public:
    	SomeHardwareGenerator(char const* devicePath)
    	{
    		// Device öffnen
    	}
    private:
    	virtual uint32_t GetRawUpperBound() const
    	{
    		// ggf. Upper Bound vom Device holen wenn nicht zur Compile-Zeit bekannt
    	}
    	virtual uint32_t GetRawNumber()
    	{
    		// Nummer vom Device holen
    	}
    };
    

    Kann man aber auch so schreiben:

    class RngEngine
    {
    public:
    	virtual uint32_t GetRawUpperBound() const { return RAND_MAX; }
    	virtual uint32_t GetRawNumber() { return rand(); }
    };
    
    class VariateGenerator
    {
    public:
    	explicit VariateGenerator(RngEngine* engine) :
    		m_engine(engine)
    	{
    	}
    
    	uint32_t GetNumber(uint32_t upperBound)
    	{
    		assert(upperBound >= 1); 
    		uint32_t const rawUpperBound = m_engine->GetRawUpperBound(); // immer noch ein virtual call, aber bessere Trennung der Zuständigkeiten
    		assert(rawUpperBound >= upperBound); 
    		uint32_t const usableRawLimit = (rawUpperBound / upperBound) * upperBound;
    		for (;;)
    		{
    			uint32_t const rawNumber = m_engine->GetRawNumber(); // ebenso
    			if (rawNumber < usableRawLimit)
    				return rawNumber % upperBound;
    		}
    	}
    private:
    	RngEngine* m_engine;
    };
    
    class BuiltinGenerator : public RngEngine
    {
    public:
    	virtual uint32_t GetRawUpperBound() const { return RAND_MAX; }
    	virtual uint32_t GetRawNumber() { return rand(); }
    };
    
    class SomeHardwareGenerator : public RngEngine
    {
    public:
    	explicit SomeHardwareGenerator(char const* devicePath) { ... }
    	virtual uint32_t GetRawUpperBound() const { ... }
    	virtual uint32_t GetRawNumber() { ... }
    };
    

    Und schon sind wir die (sinnlose) Vererbung los.

    Genau das kann man jetzt auch mittels Duck-Typing machen, was die virtuellen Methodenaufrufe zu normalen macht, was wiederum Inlining ermöglicht, was wiederum viele weitere Optimierungen ermöglicht.
    Sieht dann so aus:

    template <class Engine>
    class VariateGenerator
    {
    public:
    	explicit VariateGenerator(Engine engine = Engine()) :
    		m_engine(engine)
    	{
    	}
    
    	uint32_t GetNumber(uint32_t upperBound)
    	{
    		assert(upperBound >= 1);
    		uint32_t const rawUpperBound = m_engine.GetRawUpperBound();
    		assert(rawUpperBound >= upperBound);
    		uint32_t const usableRawLimit = (rawUpperBound / upperBound) * upperBound;
    		for (;;)
    		{
    			uint32_t const rawNumber = m_engine.GetRawNumber();
    			if (rawNumber < usableRawLimit)
    				return rawNumber % upperBound;
    		}
    	}
    private:
    	Engine m_engine;
    };
    
    class BuiltinGenerator
    {
    public:
    	uint32_t GetRawUpperBound() const { return RAND_MAX; }
    	uint32_t GetRawNumber() { return rand(); }
    };
    
    class SomeHardwareGenerator
    {
    public:
    	explicit SomeHardwareGenerator(char const* devicePath) { ... }
    	uint32_t GetRawUpperBound() const { ... }
    	uint32_t GetRawNumber() { ... }
    };
    

    Und man muss hier nicht Schluss machen. Wenn wir schon mit Templates arbeiten, dann muss uint32_t hier kein Fixer Typ mehr sein, sondern wir können ein Typedef in der Engine-Klasse verwenden, um so wo nötig einen grösseren Typ verwenden zu können (z.B. uint64_t, evtl. noch grössere wenn die Plattform welche hat), und wo nicht nötig einen kleineren Typ (z.B. weil Rechnungen mit kleineren Typen auf einigen CPUs schneller sind).

    Bei diesem einfachen Beispiel fallen mir jetzt auch keine weiteren Dinge ein wo man sich zu Netzen machen könnte dass man mit Templates arbeitet, aber je nach Anwendung kann es da einiges geben.

    Nun noch zum Sinn oder Unsinn eine Template-Funktion auf Ding<T> (in meinem Beispiel VariateGenerator<Engine>) einzuschränken, anstatt einfach jedes T zuzulassen...
    Sinn: eine solche Einschränkung kann zu besser verständlichem und robusterem Code führen.
    Unsinn: es ist eben eine Einschränkung 🙂 Könnte ja sein dass jemand schon ein fertiges AnderesDing hat, was genau die gewünschten Eigenschaften erfüllt. Dieses AnderesDing könnte er dann nichtmehr verwenden.

    Was schwerer wiegt muss man von Fall zu Fall unterscheiden.

    p.S.:
    Und natürlich kann es auch Sinn machen das ganze mit klassischer Polymorphie zu mischen. Das sähe in unserem Beispiel dann vielleicht so aus:

    class IVariateGenerator // muss kein reines Interface sein, könnte auch Datenmember
                            // und/oder implementierte Funktionen haben
    {
    public:
    	virtual uint32_t GetNumber(uint32_t upperBound) = 0;
    };
    
    template <class Engine>
    class VariateGenerator : public IVariateGenerator
    {
    public:
    	explicit VariateGenerator(Engine engine = Engine()) :
    		m_engine(engine)
    	{
    	}
    
    	virtual uint32_t GetNumber(uint32_t upperBound)
    	{
    		// ...
    	}
    private:
    	Engine m_engine;
    };
    

    Dadurch könnte man verschiedene VariateGenerator an Stellen verwenden wo Runtime-Polymorphismus nötig ist und/oder Sinn macht (z.B. weniger Tempalte-Bloat).
    Allerdings lässt sich das auch nachträglich drüberstülpen, in dem man den VariateGenerator<T> nochmal in eine Klasse "einwickelt" die dann von der Interface-Klasse erbt. Ich nehme an dass das auch der Grund ist, warum man solche Konstrukte relativ selten in Libraries findet.

    p^2.S.: Im Prinzip ist auch std::string so ein Ding. Die "Engine" wäre hier die char_traits Klasse. Oder auch alle Standard-Container, die als "Engine" einen Allocator verwenden.



  • Whoa 🕶 super Beitrag. Jetzt bin ich glaube ich wunschlos glücklich.

    hier ist nochmal das Orginalpaper von Veldhuizen: https://www.cs.indiana.edu/cgi-bin/techreports/TRNNN.cgi?trnum=TR542



  • Muaha 😃 (siehe unten)
    Danke für das vollständige Paper!

    Jetzt weiss ich auch was mit "Barton and Nackman Trick", der meint einfach nur CRTP. Klar dass man das in seinem Beispiel mit CRTP vereinfachen kann, und dann macht auch der Begriff "statische Basisklasse" auf einmal etwas Sinn.

    Zumindest laut Wikipedia ist der "Barton and Nackman Trick" nämlich mehr als nur einfach CRTP:
    http://en.wikipedia.org/wiki/Barton-Nackman_trick

    Und wie man DAS auf das in dem Paper beschriebene Problem anwenden sollte, weiss ich wirklich nicht 🙂
    Wegen muaha: musste nur lachen, weil ich mir mein Hirn zergrübelt habe, und sich jetzt herausstellt, dass wiedermal nur Begriffsverwirrung das Problem war.



  • Hi,

    nur um klarzuwerden dass ich das auch richtig verstanden haben: Der Vorteil von CRTP ist die Möglichkeit, aus der Basisklasse auf Methoden der Kinder zugreifen zu können (ohne virtuelle Funktionen zu benutzen)?



  • Ja, und nicht nur auf Methoden. Wenn du magst auch auf Konstanten, Typedefs etc.


Anmelden zum Antworten