Blog 7 Min. Lesezeit

Design Patterns am RigsOfRods-Spielprojekt lernen

Diesen Artikel teilen
Learn Design Patterns from RigsOfRods Game Project

Die meisten Entwickler haben bereits von Design Patterns gehört; die GOF (Gang Of Four) -Patterns sind die bekanntesten, und jeder Entwickler hat seine eigene Art, sie zu lernen. Dazu gehören:

  • Ein Buch lesen.
  • Über Websites.
  • Von einem Kollegen.
  • An einer Schulung teilnehmen.

Unabhängig von der gewählten Methode können wir Patterns auswendig lernen und Stunden damit verbringen, ihre UML-Diagramme zu memorieren. Sie in einem realen Projekt anzuwenden, kann jedoch deutlich anspruchsvoller sein.

Entscheidend ist nicht, die exakten Namen der Patterns auswendig zu kennen oder sie genau wie in der Dokumentation beschrieben umzusetzen. Wichtiger ist es, die Motivation hinter jedem Pattern zu verstehen – denn aus diesen Motivationen entstehen die Patterns.

Eine gute Möglichkeit, die Motivation hinter diesen Patterns besser zu verstehen, besteht darin, sie in einem realen Projekt zu untersuchen. Genau das ist das Ziel dieses Artikels: Wir erkunden den Quellcode eines Open-Source-Projekts, das sie intensiv einsetzt.

Analyse von Rigs of Rods

Rigs of Rods (“RoR”) ist ein Open-Source-Multisimulationsspiel, das Soft-Body-Physik nutzt, um Bewegung und Verformung von Fahrzeugen zu simulieren. Das Spiel verwendet eine spezielle Soft-Body-Physik-Engine namens Beam. Sie simuliert ein Netzwerk miteinander verbundener Knoten, die Fahrgestell und Räder bilden, und ermöglicht die Simulation verformbarer Objekte. Unter Belastung können sich Fahrzeuge und ihre Ladung biegen und verformen; Kollisionen mit Wänden oder Gelände können ein Fahrzeug dauerhaft deformieren.

Sehen wir uns einige der von RoR verwendeten GoF-Design-Patterns an.

Singleton

Singleton gehört zu den bekanntesten und am häufigsten verwendeten Design Patterns. RoR verwendet ein generisches Singleton, um denselben Code nicht für jede Singleton-Klasse wiederholen zu müssen. Es definiert zwei Varianten: eine, die eine neue Instanz erzeugt, und eine weitere, der eine bereits erzeugte Instanz zugewiesen wird.

Suchen wir nach allen RoR-Singletons. Dafür können wir CQLinq:

from t in Types where t.DeriveFrom(“RoRSingletonNoCreation“) || t.DeriveFrom(“RoRSingleton“)
select t
Motivation:

Nehmen wir als Beispiel das Singleton InputEngine: RoR muss Daten über Tastatur, Maus und Joysticks speichern, die bei der Initialisierung von der Klasse InputEngine erkannt werden. Viele Klassen benötigen dieselben Daten der Eingabegeräte, und es ist nicht nötig, mehr als eine Instanz zu erzeugen. Die wichtigste Motivation besteht daher darin,“Create one instance of the InputEngine class“.

Die Verwendung von Singletons ist allerdings umstritten, und nicht alle Architekten und Designer empfehlen dieses Pattern. Hier finden Sie einen article zur Singleton-Kontroverse.

Factory Method

Factories sind kein Geheimnis: Ihr Zweck ist einfach die Erzeugung von Instanzen. Eine einfache Factory mit einer CreateInstance-Methode könnte dieses Ziel erfüllen. RoR verwendet jedoch für alle Factories das Factory-Method-Pattern statt einer einfachen Factory.

Motivation:

Um dieses Pattern besser zu verstehen, betrachten wir ein Szenario, in dem RoR es verwendet:

  • RoR uses the graphics engine OGRE, which needs to instantiate classes of the ParticleEmitter kind.
  • RoR definiert eine eigene ParticleEmitter-Klasse namens BoxEmitter, die von ParticleEmitter erbt, und benötigt OGRE, damit diese neue Klasse als ParticleEmitter verwendet werden kann.
  • OGRE doesn’t know anything about RoR.

Die Frage lautet: Woher weiß OGRE, wie diese neue BoxEmitter-Klasse aus RoR instanziiert und verwendet werden soll? Genau hier kommt das Entwurfsmuster „Factory Method“ ins Spiel:

OGRE besitzt eine abstrakte Klasse namens ParticleEmitterFactory, die die Methode CreateEmitter bereitstellt. Um ihre Aufgabe erfüllen zu können, benötigt OGRE eine konkrete Factory. RoR definiert dafür die neue Factory BoxEmitterFactory, die von ParticleEmitterFactory erbt und die Methode CreateEmitter überschreibt.

RoR stellt OGRE diese Factory über ParticleSystemManager::addEmitterFactory(ParticleEmitterFactory *factory) zur Verfügung. Jedes Mal, wenn OGRE eine Instanz von ParticleEmitter benötigt, wird BoxEmitterFactory aufgerufen, um sie zu erzeugen.

Die wichtigste Motivation ist die geringe Kopplung; indeed, OGRE doesn’t know anything about RoR and yet it can instantiate classes from it.

Eine weitere Motivation besteht darin, die Kohäsion zu erhöhen, indem die Instanziierung an eine spezielle Factory-Klasse delegiert wird.

Eine einfache Factory eignet sich gut, um die Instanziierungslogik zu isolieren und die Kohäsion zu verbessern. Wenn zusätzlich eine geringe Kopplung erforderlich ist, ist das Factory-Method-Pattern jedoch besser geeignet.

Template Method

Die Template Method definiert das Grundgerüst eines Algorithmus in einer Methode und überlässt einzelne Schritte den Unterklassen. Dadurch können Unterklassen bestimmte Schritte neu definieren, ohne die Struktur des Algorithmus zu verändern.

Ziel ist es, die Struktur des Algorithmus unverändert zu lassen, während Unterklassen Teile der Implementierung bereitstellen.

Let’s use CQLinqum alle Klassen zu erkennen, die das Template-Method-Pattern verwenden. Dazu können wir nach abstrakten Klassen suchen (der Klasse Abstract im UML-Diagramm unten), die eine oder mehrere Methoden besitzen (templateMethod() im Diagramm), welche wiederum Methoden verwenden, die in der Unterklasse implementiert sind (primitive1 und primitive2 im Diagramm).

from t in Types where t.IsAbstract && t.Methods.Where(a=> a.NbLinesOfCode>0 && a.MethodsCalled.Where(b=>b.IsPureVirtual && b.ParentType==t).Count()>0).Count()>0 select t
Motivation:

Nehmen wir die Klasse IRCWrapper als Beispiel. Ihre Methode „process“ enthält die Logik zur Verarbeitung empfangener IRC-Ereignisse. Dies sind die von ihr aufgerufenen Methoden:

Es ruft die rein virtuelle Methode processIRCEvent auf, die von einer von IRCWrapper abgeleiteten Klasse implementiert werden muss. LobbyGui ist eine solche Klasse. Da sie empfangene IRC-Ereignisse verarbeiten muss, überschreibt sie die Methode processIRCEvent, um ihr spezifisches Verhalten zu implementieren.

Mit diesem Pattern lässt sich die Implementierung eines Algorithmus leicht ändern, ohne sein Grundgerüst anzutasten. Es reduziert Boilerplate-Code und erleichtert die Wartung dieser Klassen.

Außerdem fördert es die geringe Kopplung, da der Client ausschließlich auf die abstrakte Klasse statt auf die konkreten Klassen verweisen kann.

Strategy

Es gibt viele Situationen, in denen sich Klassen nur in ihrem Verhalten unterscheiden. In solchen Fällen ist es sinnvoll, die Algorithmen in separate Klassen auszulagern, sodass zur Laufzeit zwischen verschiedenen Algorithmen gewählt werden kann.

Verwenden wir CQLinq, um alle Klassen zu erkennen, die das Strategy-Pattern einsetzen. Dazu können wir nach abstrakten Klassen mit mehreren abgeleiteten Klassen suchen, bei denen der Client auf die abstrakte Klasse statt auf die konkreten Implementierungen verweist.

from t in Types where t.IsAbstract && t.DirectDerivedTypes.Count()>1 !t.IsThirdParty
let tt=t.DirectDerivedTypes
from db in tt where db.Methods.Where(a=>a.NbMethodsCallingMe!=0 !a.IsStatic).Count()==0
select new {db,t}
Motivation:

Die Kamera kann verschiedene Verhaltensweisen besitzen – fest, frei, statisch oder isometrisch – und ihr Verhalten lässt sich dynamisch ändern. Künftig können zudem weitere Verhaltensweisen ergänzt werden.

CameraManager verwendet die abstrakte Klasse IBehavior. Im Folgenden sind alle CameraManager-Methoden aufgeführt, die IBehavior verwenden.

Wie zu sehen ist, gibt es eine Methode namens switchBehavior, die das Verhalten dynamisch ändert.

Dieses Pattern fördert die geringe Kopplung — indeed, CameraManager doesn’t know the concrete behaviors — and also enforces the high cohesion, because each specific behavior is implemented in an isolated class.

State

Aus architektonischer Sicht ähnelt das State-Pattern dem Strategy-Pattern. Deshalb wurden mit der vorherigen CQLinq-Abfrage zur Suche nach dem Strategy-Pattern auch State-Klassen gefunden.

Ihre Ziele unterscheiden sich jedoch: Das Strategy-Pattern repräsentiert einen Algorithmus, der eine oder mehrere IStrategy-Implementierungen verwendet. Zwischen diesen Verhaltensweisen besteht keine Beziehung. Beim State-Pattern hingegen wechseln wir von einem Zustand zum nächsten, um das endgültige Ziel zu erreichen; zwischen den Zuständen besteht also eine Beziehung.

Hier sind alle State-Klassen aufgeführt, die von der abstrakten Klasse AppState erben.

Wie beim Strategy-Pattern verweisen die anderen Klassen ausschließlich auf die abstrakte Klasse. Hier sind alle Methoden, die AppState verwenden.

Wie wir sehen, enthält AppStateManager mehrere Methoden zur Verwaltung des Lebenszyklus von Zuständen.

Motivation:

Like the Strategy pattern, this pattern enforces the geringe Kopplung — AppStateManager doesn’t know the concrete states — and also enforces the high cohesion, because each operation is isolated in its corresponding state.

Facade

Eine Fassade ist ein Objekt, das eine vereinfachte Schnittstelle zu einem größeren Codebestand, etwa einer Klassenbibliothek, bereitstellt. Eine einfache Möglichkeit, verwendete Fassaden zu identifizieren, besteht darin, nach externem Code zu suchen, auf den das Projekt verweist.

Hier sind alle vom RoR-Projekt verwendeten Namespaces:

Nehmen wir den Namespace Caelum als Beispiel und suchen wir nach RoR-Klassen, die ihn verwenden.

from m in Methods where m.IsUsing (“Caelum“)
select new { m }

Nur SkyManager verwendet den Caelum-Namespace direkt und bildet damit die Fassade für Caelum.

Motivation

Wenn wir eine externe Bibliothek verwenden, die eng mit unserem Code gekoppelt ist – also von vielen Klassen direkt genutzt wird –, lässt sie sich nur schwer ersetzen. Wird dagegen eine Fassade eingesetzt, muss beim Austausch der externen Bibliothek nur deren Implementierung geändert werden.

Dieses Pattern fördert die geringe Kopplung gegenüber externen Bibliotheken.

Fazit

Nachdem man die GoF-Patterns kennengelernt hat, sollte man auch die Motivation für ihren Einsatz im eigenen Code verstehen. Die Untersuchung ihrer Implementierung in bekannten Open-Source-Projekten hilft dabei, ihren tatsächlichen Nutzen besser zu erfassen.

Diesen Artikel teilen