Design Patterns für die Spieleprogrammierung

Robert Nystrom

Übersetzung aus dem Amerikanischen von Knut Lorenzen

Impressum

Bibliografische Information der Deutschen Nationalbibliothek

Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über <http://dnb.d-nb.de> abrufbar.

ISBN 978-3-95845-092-9

1. Auflage 2015

www.mitp.de

E-Mail: mitp-verlag@sigloch.de

Telefon: +49 7953 / 7189 - 079

Telefax: +49 7953 / 7189 - 082

© 2015 mitp Verlags GmbH & Co. KG

Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Verarbeitung in elektronischen Systemen.

Die Wiedergabe von Gebrauchsnamen, Handelsnamen, Warenbezeichnungen usw. in diesem Werk berechtigt auch ohne besondere Kennzeichnung nicht zu der Annahme, dass solche Namen im Sinne der Warenzeichen- und Markenschutz-Gesetzgebung als frei zu betrachten wären und daher von jedermann benutzt werden dürften.

Authorized translation from the English language edition, entitled »Game Programming Patterns«, 978-0-9905829-0-8 by Nystrom, Robert, published by Genever Benning, Copyright © 2014 Robert Nystrom. All rights reserved.

Lektorat: Sabine Schulz

Sprachkorrektorat: Maren Feilen

Coverbild: Robert Nystrom

electronic publication: III-satz, Husby, www.drei-satz.de

Dieses Ebook verwendet das ePub-Format und ist optimiert für die Nutzung mit dem iBooks-reader auf dem iPad von Apple. Bei der Verwendung anderer Reader kann es zu Darstellungsproblemen kommen.

Der Verlag räumt Ihnen mit dem Kauf des ebooks das Recht ein, die Inhalte im Rahmen des geltenden Urheberrechts zu nutzen. Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheherrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und Einspeicherung und Verarbeitung in elektronischen Systemen.

Der Verlag schützt seine ebooks vor Missbrauch des Urheberrechts durch ein digitales Rechtemanagement. Bei Kauf im Webshop des Verlages werden die ebooks mit einem nicht sichtbaren digitalen Wasserzeichen individuell pro Nutzer signiert.

Bei Kauf in anderen ebook-Webshops erfolgt die Signatur durch die Shopbetreiber. Angaben zu diesem DRM finden Sie auf den Seiten der jeweiligen Anbieter.

Für Megan, für Vertrauen und Zeit,
den beiden wichtigsten Bestandteilen.

Danksagungen

Ich habe mir sagen lassen, dass nur andere Autoren einschätzen können, was zum Schreiben eines Buches alles dazugehört. Allerdings gibt es da noch ein anderes Völkchen, das genau weiß, was für eine Belastung das ist – nämlich die Unglücklichen, die in einer Beziehung zum Autor stehen. Ich habe mir die Zeit zum Schreiben mühsam zusammenkratzen müssen, denn der Alltag ist für meine Frau Megan und mich eigentlich schon anstrengend genug. Abzuwaschen und die Kinder zu baden, ist zwar nicht gerade das, was man unter »Schreiben« versteht, aber ohne ihre Hilfe gäbe es dieses Buch nicht.

Als ich mit diesem Projekt begann, war ich bei Electronic Arts als Programmierer tätig. Ich denke, das Unternehmen wusste nicht so recht, was davon zu halten war, und so bin ich Michael Malone, Olivier Nallet und Richard Wifall für ihre Unterstützung und ihr detailliertes, aufschlussreiches Feedback zu den ersten paar Kapiteln umso dankbarer.

Nachdem etwa die Hälfte des Buches erledigt war, entschloss ich mich, auf die Veröffentlichung durch einen traditionellen Verlag zu verzichten. Mir war durchaus bewusst, dass mir damit auch die fachliche Unterstützung eines Lektorats fehlen würde, andererseits hatten mir jedoch schon Dutzende Leser gemailt, die keinen Zweifel daran ließen, in welche Richtung das Buch ihrer Meinung nach gehen sollte. Auch auf das Korrektorat musste ich verzichten, dafür hatte ich aber bereits mehr als 250 Fehlerberichte erhalten, die mir dabei halfen, den Text auf Vordermann zu bringen. Ich hatte es schnell aufgegeben, mich beim Schreiben an einen Zeitplan zu halten – doch wenn einem die Leser nach der Fertigstellung der einzelnen Kapitel auf die Schulter klopfen, ist das Ansporn genug!

Ich musste zwar auf ein verlagsseitiges Lektorat verzichten, nicht aber auf gute Korrekturleser. Lauren Briese war stets zur Stelle, wenn ich Hilfe brauchte, und hat ausgezeichnete Arbeitet geleistet.

Mein besonderer Dank gilt Colm Sloan, der jedes einzelne Kapitel zweimal überprüft hat und mir haufenweise fabelhaftes Feedback lieferte – nur weil er so ein großzügiger Mensch ist. Du hast ein Bier (oder zwanzig) bei mir gut!

Diese Art der Veröffentlichung wird als »Selbstverlag« oder »Self-Publishing« bezeichnet, obwohl der Begriff »Crowd-Publishing« der Wahrheit näher kommt. Schreiben kann man zwar auch alleine, ich selbst war allerdings niemals alleine. Sogar als ich die Arbeit am Buch zwei Jahre ruhen ließ, wurde ich weiterhin ermutigt, es fertigzustellen. Ohne all die Leute (Dutzende!), die mich immer wieder daran erinnerten, dass sie auf weitere Kapitel warteten, hätte ich das Buch wohl nicht zu Ende gebracht.

Ich möchte allen, die E-Mails geschrieben, Kommentare gesendet, getwittert, Fehler berichtet, Freunden von diesem Buch erzählt oder irgendwie Verbindung mit mir aufgenommen haben, sagen: Ich bin euch wirklich von ganzem Herzen dankbar. Dieses Buch fertigzustellen, gehörte zu meinen größten Wünschen. Und ihr habt dafür gesorgt, dass ich es geschafft habe. Danke!

Bob Nystrom, 6. September 2014

Teil I: Einführung

In diesem Teil:

Kapitel 1

Architektur, Performance und Spiele

Im fünften Schuljahr standen in unserem Klassenzimmer ein paar ziemlich ramponierte TRS-80-Computer herum, die meine Freunde und ich benutzen durften. Ein Lehrer, der unsere Begeisterung wecken wollte, gab uns einen Stapel Ausdrucke einiger einfacher BASIC-Programme, mit denen wir experimentieren konnten.

Die Kassettenlaufwerke der Rechner waren defekt, d.h., wenn wir Code ausführen wollten, mussten wir ihn immer sehr sorgfältig komplett neu eingeben. Das führte dazu, dass wir bevorzugt auf Programme zurückgriffen, die nur einige wenige Zeilen lang waren:

10 PRINT "BOBBY IST EIN RADIKALER!!!"
20 GOTO 10

Vielleicht wird es ja auf wundersame Weise wahr, wenn der Computer es nur oft genug ausgibt ...

Trotzdem war das Ganze Glückssache. Wir hatten keine Ahnung von Programmierung, und schon der kleinste Syntaxfehler war für uns unergründlich. Wenn ein Programm nicht funktionierte, und das kam des Öfteren vor, fingen wir einfach von vorne an.

Am unteren Ende des Papierstapels mit Ausdrucken fanden wir schließlich ein richtiges Ungeheuer: ein Programm, das aus mehreren eng mit Code bedruckten Seiten bestand. Es dauerte ein Weilchen, bis wir überhaupt den Mut aufbrachten, es einzugeben, aber es war einfach unwiderstehlich, denn der Titel am Anfang des Listings lautete »Tunnels & Trolls«. Wir hatten keinen blassen Schimmer, worum es bei dem Programm ging, aber es klang nach einem Spiel, und was könnte cooler sein als ein selbstprogrammiertes Computerspiel?

Leider haben wir es nie zum Laufen bekommen und im darauffolgenden Schuljahr zogen wir in ein anderes Klassenzimmer um. (Jahre später, nachdem ich etwas BASIC gelernt hatte, wurde mir klar, dass es sich seinerzeit gar nicht um ein Spiel gehandelt hatte. Das Programm erzeugte bloß verschiedene Persönlichkeiten für das eigentliche Rollenspiel.) Dessen ungeachtet waren die Würfel gefallen: Ich hatte mir in den Kopf gesetzt, Spieleprogrammierer zu werden.

Als ich ein Teenager war, schaffte sich meine Familie einen Macintosh mit QuickBASIC an, später kam dann THINK C dazu. Ich habe fast meine gesamten Sommerferien damit verbracht, Spiele zusammenzuhacken. Es war mühsam, auf mich allein gestellt zu lernen und es ging nur langsam vorwärts. Anfangs war es gar nicht so schwer und ich bekam schnell etwas zum Laufen – vielleicht eine Landkartendarstellung oder ein kleines Puzzle. Aber sobald die Programme etwas umfangreicher waren, wurde es immer schwieriger.

Die übrige Zeit verbrachte ich damit, in den Sümpfen des südlichen Louisianas Schlangen und Schildkröten einzufangen. Wenn es draußen nicht so glühend heiß wäre, könnte sich dieses Buch sehr wohl auch um Amphibien- und Reptilienforschung drehen, statt um Programmierung.

Zunächst bestand die Herausforderung nur darin, überhaupt etwas zum Funktionieren zu bekommen. Dann musste ich mir überlegen, wie ich Programme schreiben konnte, die aus mehr Zeilen bestanden, als ich mir gleichzeitig merken konnte. Anstatt Bücher wie »Programmierung in C« von Kernighan und Ritchie zu lesen, versuchte ich Fachliteratur zu finden, die sich mit dem Organisieren von Programmen befasste.

Zeitsprung. Mehrere Jahre später drückte mir ein Bekannter das Buch Design Patterns: Entwurfsmuster als Elemente wiederverwendbarer objektorientierter Software in die Hand. Endlich! Das war genau das Nachschlagewerk, nach dem ich seit meiner Teenagerzeit gesucht hatte. Ich las es in einem Rutsch von vorne bis hinten durch. Zwar hatte ich noch immer mit meinen eigenen Programmen zu kämpfen, aber mir fiel ein Stein vom Herzen, als ich begriff, dass andere Leute ähnliche Schwierigkeiten hatten und Lösungen dafür erarbeiteten. Ich hatte das Gefühl, dass ich statt »nur« meiner bloßen Hände nun endlich richtige Werkzeuge einsetzen konnte.

Den Bekannten, der mir das Buch gab, lernte ich bei dieser Gelegenheit übrigens gerade erst kennen. Fünf Minuten nachdem wir einander vorgestellt wurden, saß ich auf seinem Sofa und verbrachte die nächsten Stunden mit Lesen, wobei ich ihn vollkommen ignorierte. Ich hoffe allerdings, dass sich meine Sozialkompetenz seitdem doch ein wenig verbessert hat.

2001 trat ich meinen Traumjob an: Programmierer bei Electronic Arts. Ich konnte es kaum erwarten, mir die richtigen Spiele anzusehen und herauszufinden, wie die Profis sie entwickeln. Wie sieht wohl die Architektur eines so gigantischen Spiels wie Madden Football aus? Wie arbeiten die verschiedenen Systemkomponenten zusammen? Wie schaffen sie es, eine einzige Codebasis auf mehreren Plattformen zum Laufen zu bringen?

Als ich den Quellcode sah, war das eine demutsvolle und überraschende Erfahrung. Der Code zur Grafikerzeugung, die künstliche Intelligenz (KI), die Animationen und visuellen Effekte – all das war einfach brillant. Hier gab es Leute, die das letzte bisschen Leistung aus der CPU herauskitzeln und nutzen konnten. Sie erledigten Dinge, von denen ich gar nicht wusste, dass sie überhaupt möglich sind, schon vor der Mittagspause.

Aber der Architektur, in der sich dieser brillante Code befand, wurde kaum Beachtung geschenkt. Die Programmierer waren so sehr auf Features konzentriert, dass sie dabei die Organisation übersahen. Es wimmelte nur so von eng gekoppelten Modulen. Neue Features wurden der Codebasis da aufgepfropft, wo es gerade passte. Ernüchtert musste ich feststellen, dass es viele der Programmierer offenbar nicht weiter als bis zum Singleton-Pattern geschafft hatten – wenn sie das Buch Design Patterns denn überhaupt mal aufgeschlagen haben sollten.

Na ja, ganz so schlimm war es natürlich auch wieder nicht. Ich hatte mir allerdings ausgemalt, dass Spieleprogrammierer mithilfe von Wandtafelen wochenlang in aller Seelenruhe in einem Elfenbeinturm die architektonischen Einzelheiten ausdiskutierten – tatsächlich war der Code, den ich mir ansah, jedoch von Leuten geschrieben worden, die sich mit engen Abgabefristen konfrontiert sahen. Sie gaben wirklich ihr Bestes und das war, so dämmerte mir allmählich, oft sehr gut. Je länger ich mich mit der Arbeit am Gamecode beschäftigte, desto mehr brillante Codeschnipsel entdeckte ich, die sich unter der Oberfläche versteckten.

Leider war »versteckt« häufig eine allzu treffende Beschreibung. Es waren echte Juwelen im Code verborgen, die viele völlig übersahen. Ich habe mehr als nur einmal erlebt, dass andere Programmierer sich damit abmühten, Dinge neu zu erfinden, obwohl in der Codebasis, mit der sie arbeiteten, bereits gute Lösungen für genau die betreffende Fragestellung vorhanden waren.

Dieses Problem möchte das vorliegende Buch lösen. Ich habe die besten in der Spieleprogrammierung vorkommenden Patterns ausgewählt, geordnet und zusammengestellt, damit wir unsere Zeit damit verbringen können, Dinge tatsächlich neu zu erfinden, anstatt sie wieder zu erfinden.

Darum geht es

Es gibt bereits Dutzende Bücher über Spieleprogrammierung – warum also ein weiteres schreiben? Die meisten mir bekannten Bücher zum Thema Spieleprogrammierung gehören einer der beiden folgenden Kategorien an:

Themenspezifische Bücher Diese Bücher sind weitestgehend auf einen bestimmten Aspekt der Spieleprogrammierung fokussiert, wie etwa 3D-Grafik, Rendern in Echtzeit, Physiksimulation, künstliche Intelligenz oder Audio. Das sind die Gebiete, auf die sich viele Spieleprogrammierer im Lauf ihrer Karriere spezialisieren.

Bücher über komplette Spiel-Engines Im Gegensatz zu der vorgenannten Kategorie versuchen diese Bücher, alle Bestandteile einer Engine abzuhandeln. Sie sind auf die Entwicklung einer kompletten Engine für ein bestimmtes Spielgenre ausgerichtet, meist 3D-Ego-Shooter.

Mir sagen die beiden Kategorien durchaus zu, aber ich denke, dass sie einige Lücken lassen. Themenspezifische Bücher erklären selten, wie der Code mit dem Rest des Spiels zusammenwirkt. Sie mögen vielleicht beim Rendern und der Physiksimulation ein alter Hase sein, aber wissen Sie auch, wie man beides vernünftig miteinander verbindet?

Dieser Bereich wird von der zweiten Kategorie zwar abgedeckt, ich finde die Bücher über komplette Spiel-Engines allerdings oft zu einseitig und genrebezogen. Mit dem Aufkommen von Smartphones und der damit einhergehenden Verbreitung mobiler Spiele und sogenannter Casual Games (einfache Spiele, die man bei Gelegenheit spielt) sind wir in einem Zeitalter angelangt, in dem viele verschiedene Spielegenres bedient werden. Wir klonen nicht mehr nur Quake. Bücher über Spiel-Engines helfen Ihnen nicht weiter, wenn Ihr Spiel nicht zur Engine passt.

Ich bemühe mich hier darum, die Themen eher wie auf einer Speisekarte zu präsentieren. Die einzelnen Kapitel im Buch beruhen auf jeweils unabhängigen Konzepten, die auf Ihren Code anwendbar sind. Auf diese Weise können Sie sie so zusammenstellen, wie es am besten zu dem Spiel passt, das Sie entwickeln möchten.

Ein weiteres Beispiel für diesen »Speisekarten-Ansatz« ist die hochgelobte Buchreihe Game Programming Gems.

Bezug zu Design Patterns

Jedes Buch über Programmierung, dessen Titel das Wort »Patterns« enthält, steht in einer gewissen Beziehung zu dem Klassiker Design Patterns: Entwurfsmuster als Elemente wiederverwendbarer objektorientierter Software von Erich Gamma, Richard Helm, Ralph Johnson und John Vlissides (die merkwürdigerweise als die »Gang of Four« bezeichnet werden).

Das Buch Design Patterns wurde seinerseits von einem anderen Buch inspiriert. Die Idee, Patterns zur Beschreibung ergebnisoffener Problemlösungen zu verwenden, entstammt dem Buch A Pattern Language von Christopher Alexander (das er zusammen mit Sarah Ishikawa und Murray Silverstein verfasst hat).

Darin geht es zwar um Architektur (also richtige Architektur, Gebäude, Wände usw.), die Autoren hatten jedoch die Hoffnung geäußert, dass die von ihnen beschriebenen Lösungsstrukturen auch auf andere Bereiche angewendet würden – und Design Patterns ist der Versuch der Gang of Four, diese Methode auf Software zu übertragen.

Mit dem Titel Design Patterns für die Spieleprogrammierung will ich keineswegs sagen, dass die Design Patterns der Gang of Four nicht auf Spiele anwendbar sind. Im Gegenteil: Das Kapitel Design Patterns neu überdacht stellt viele der klassischen Design Patterns vor, legt den Schwerpunkt dabei aber gezielt auf deren Anwendung in der Spieleprogrammierung.

Umgekehrt denke ich, dass dieses Buch auch auf Software anwendbar ist, bei der es sich nicht um Spiele handelt. Ich hätte ebenso gut einen Titel wie Weitere Design Patterns wählen können, ich finde aber, dass Spiele oft besonders ansprechende Beispiele ermöglichen. Sie wollen doch nicht wirklich schon wieder ein Buch über Angestelltendatensätze und Bankkonten lesen, oder?

Die hier vorgestellten Patterns sind nicht nur für andere Software nützlich, sie sind meiner Ansicht nach auch besonders gut für Probleme geeignet, die bei der Spieleprogrammierung häufig auftreten:

Zeit und Abläufe sind oftmals das Kernstück einer Spielarchitektur. Vorgänge müssen in der richtigen Reihenfolge und zum richtigen Zeitpunkt stattfinden.

Die Entwicklungszyklen folgen oft eng aufeinander und die Programmierer müssen in der Lage sein, schnell neue Versionen mit vielen verschiedenen neuen Verhaltensweisen zu entwickeln, ohne sich dabei gegenseitig im Weg zu stehen oder überall in der Codebasis Spuren zu hinterlassen.

Wenn die Verhaltensweisen festgelegt sind, folgen die Interaktionen. Monster beißen den Helden, Zaubertränke werden angerührt und Bomben sprengen Freund und Feind in die Luft. Solche Interaktionen müssen stattfinden können, ohne dass die Codebasis zu einem verworrenen Knäuel wird.

Und zu guter Letzt kommt der Performance bei Spielen eine maßgebliche Bedeutung zu. Spieleentwickler befinden sich in einem dauerhaften Wettstreit, um herauszufinden, wer das meiste aus einer Plattform herausholen kann. Tricks und Kniffe, mit denen sich einige CPU-Takte einsparen lassen, können den Unterschied zwischen einem hervorragend bewerteten, millionenfach verkauften und einem von Bildaussetzern geprägten sowie genervten Spieletestern verrissenen Spiel bedeuten.

Hinweise zur Lektüre

Das vorliegende Buch ist in drei größere Teile gegliedert. Der erste (I) besteht aus der Einführung, die Sie gerade lesen, und dem folgenden Kapitel 1.

Der zweite Teil (II), Design Patterns neu überdacht, stellt einige der Entwurfsmuster aus dem Buch der Gang of Four vor. Ich lege in den einzelnen Kapiteln meine Ansichten zu den einzelnen Patterns dar und beschreibe, wie sie sich auf die Spieleprogrammierung anwenden lassen.

Danach folgt der Hauptteil des Buches (III bis VI), in dem 13 Design Patterns präsentiert werden, die sich als nützlich erwiesen haben. Sie sind in vier Kategorien unterteilt: Sequenzierungsmuster (III, Sequencing Patterns), Verhaltensmuster (IV, Behavioral Patterns), Entkopplungsmuster (V, Decoupling Patterns) und Optimierungsmuster (VI, Optimization Patterns). Die dazugehörigen Patterns werden jeweils auf einheitliche Art und Weise beschrieben, damit Sie das Buch als Referenz verwenden können und schnell finden, was Sie suchen:

Der einleitende Abschnitt erläutert kurz und knapp die Zielsetzung des Design Patterns und beschreibt, für welche Problemstellungen es gedacht ist. Diese Informationen sollen es Ihnen ermöglichen, das Buch bei der Suche nach einem Pattern, das bei der Lösung eines Problems hilfreich wäre, schnell durchforsten zu können.

Der Abschnitt Motivation beschreibt ein Anwendungsbeispiel des Patterns. Im Gegensatz zu konkreten Algorithmen ist ein Pattern mehr oder weniger formlos, solange es nicht auf ein bestimmtes Problem angewendet wird. Ein Pattern ohne ein Beispiel zu erklären, käme einem Backkurs gleich, in dem der Teig keine Erwähnung findet. Dieser Abschnitt liefert sozusagen den Teig, der in den nachfolgenden Abschnitten gebacken wird.

Der Abschnitt Das Pattern fasst die wesentlichen Charakteristiken und Eigenschaften des im vorausgegangenen Beispiel dargestellten Entwurfsmusters zusammen. Wenn Sie eine trockene, lehrbuchhafte Beschreibung des Patterns suchen, werden Sie hier fündig. Dieser Abschnitt kann auch zur Wissensauffrischung dienen, wenn Ihnen der Gebrauch eines Patterns schon geläufig ist und Sie sich vergewissern möchten, dass Sie nichts vergessen haben.

Bisher wurde das Pattern nur anhand eines einzigen Beispiels erläutert. Wie aber können Sie feststellen, ob es für Ihr Problem geeignet ist? Der Abschnitt Anwendbarkeit enthält einige Richtlinien dazu, wann die Verwendung des Patterns sinnvoll ist und wann man besser darauf verzichten sollte.

Der Abschnitt Konsequenzen weist auf mögliche Risiken bei der Anwendung des Patterns hin.

Wenn es Ihnen wie mir geht und Sie ein konkretes Beispiel brauchen, um etwas wirklich zu begreifen, sind Sie im Abschnitt Beispielcode richtig. Hier finden Sie eine schrittweise Implementierung des Patterns, damit Sie genau nachvollziehen können, wie es funktioniert.

Patterns unterscheiden sich von Algorithmen dadurch, dass sie ergebnisoffen sind. Wenn Sie ein Pattern mehrmals verwenden, werden Sie es vermutlich jedes Mal anders implementieren. Der Abschnitt Designentscheidungen geht dem auf den Grund und zeigt verschiedene Möglichkeiten bei der Anwendung des Patterns auf.

Und zum Abschluss wird in einem kurzen Abschnitt Siehe auch ... erklärt, in welcher Beziehung das Pattern zu anderen Patterns steht. Außerdem finden Sie hier Hinweise auf Open-Source-Code, der das Pattern in der Praxis einsetzt.

Über den Beispielcode

Die Codebeispiele in diesem Buch sind in C++ geschrieben, das heißt jedoch nicht, dass die Design Patterns nur in dieser Programmiersprache nützlich sind oder dass C++ besonders gut dafür geeignet wäre. Fast alle Programmiersprachen sind geeignet, allerdings setzen einige Patterns das Vorhandensein von Objekten und Klassen voraus.

Ich habe aus verschiedenen Gründen C++ gewählt. Zunächst einmal handelt es sich hierbei um die bei kommerziell vertriebenen Spielen verbreitetste Programmiersprache. C++ ist sozusagen die Lingua franca der Spielebranche. Außerdem bildet die C-Syntax, auf der C++ beruht, auch die Grundlage für Java, C#, JavaScript und viele andere Programmiersprachen. Selbst wenn Sie sich nicht mit C++ auskennen, stehen die Chancen daher nicht schlecht, dass Sie den Beispielcode mit ein klein wenig Mühe verstehen können.

Es ist nicht das Ziel dieses Buches, Sie C++ zu lehren. Die Beispiele sind so einfach wie möglich gehalten und sollen weder guten Programmierstil noch eine besondere Verwendungsart darstellen. Denken Sie beim Betrachten des Codes an das dahinterstehende Konzept, nicht an den Code, der es formuliert.

Der Code wurde insbesondere nicht im »modernen« Stil (C++11 oder neuer) verfasst. Die Standardbibliothek wird nicht verwendet und Templates nur selten. Der C++-Code ist zwar nicht besonders elegant, aber er ist kompakt und ich hoffe, dass er auf diese Weise für Leute, die Erfahrung mit C, Objective-C, Java oder anderen Sprachen haben, leichter verständlich ist.

Um Platz zu sparen, werde ich in den Beispielen hin und wieder Code weglassen, wenn Sie ihn schon kennen oder er für ein Pattern nicht relevant ist. Diese Stellen sind durch Auslassungspunkte gekennzeichnet.

Betrachten Sie beispielsweise eine Funktion, die bestimmte Aufgaben erledigt und dann einen Rückgabewert liefert. Für das Pattern ist nur der Rückgabewert von Bedeutung, nicht die Erledigung der Aufgaben. Der Beispielcode sieht dann folgendermaßen aus:

bool update()
{
  // Aufgaben erledigen ...
  return isDone();
}

Wie geht es weiter?

Patterns sind ein Bestandteil der Softwareentwicklung, der einem ständigen Wandel und kontinuierlichen Erweiterungen unterliegt. Dieses Buch möchte den von der Gang of Four angestoßenen Prozess fortsetzen, die bislang entdeckten Patterns zu dokumentieren und mit anderen zu teilen. Und natürlich ist dieses Vorhaben mit dem vorliegenden Buch noch lange nicht abgeschlossen!

Sie selbst sind ebenfalls Teil dieses Prozesses. Wenn Sie Ihre eigenen Patterns entwickeln und die im Buch vorgestellten optimieren (oder sie ablehnen!), erweisen Sie der Community der Softwareentwickler ebenfalls einen Dienst. Sollten Sie Vorschläge haben, Fehler entdecken oder anderweitiges Feedback geben wollen, melden Sie sich bitte!

Kapitel 1: Architektur, Performance und Spiele

Bevor wir uns kopfüber in einen Haufen Patterns stürzen, ist es vermutlich hilfreich, wenn ich Ihnen an dieser Stelle zunächst kurz darstelle, wie ich persönlich über die Softwarearchitektur und deren Anwendung in der Spieleprogrammierung denke. Dadurch wird der Rest des Buches möglicherweise besser verständlich. Zumindest möchte ich Sie mit einigen schlagenden Argumenten bewaffnen, falls Sie demnächst in eine Diskussion hineingezogen werden, in der es darum geht, wie furchtbar (oder fabelhaft) Design Patterns sind.

Beachten Sie bitte, dass ich keine Vermutungen darüber anstelle, auf welcher Seite Sie bei einer solchen Diskussion stehen würden. Wie jeder Waffenhändler, beliefere ich gern alle Beteiligten ...

1.1  Was ist Softwarearchitektur?

Wenn Sie dieses Buch von vorne bis hinten durchlesen, werden Sie anschließend weder die der 3D-Grafik zugrunde liegende lineare Algebra noch die für Physiksimulationen erforderliche Infinitesimalrechnung beherrschen. Es wird nicht erklärt, wie man einen binären Suchbaum rekursiv durchläuft oder wie man bei der Audiowiedergabe den Widerhall eines Raums simuliert.

Meine Güte, der letzte Absatz wäre ein wirklich furchtbarer Werbetext für das Buch!

Dieses Buch thematisiert den Code, der all die genannten Dinge miteinander verknüpft. Es geht weniger darum, den Code zu schreiben, sondern vielmehr darum, ihn zu organisieren. Jedes Programm ist in irgendeiner Form organisiert – selbst wenn es dabei nur nach der Devise »Stopf den ganzen Krempel in main() und guck, was passiert« zugeht. Interessanter ist die Frage, was denn eigentlich eine gute Organisation auszeichnet. Wie unterscheidet sich eine gute Architektur von einer schlechten?

Über diese Frage grüble ich seit etwa fünf Jahren nach. Natürlich verfüge ich, ebenso wie Sie, über ein gewisses Gespür für gutes Design. Aber wir alle haben doch schon so schlechte Codebasen gesehen, dass es das Beste gewesen wäre, sie zu löschen, um sie von ihrem Leiden zu erlösen.

Machen wir uns nichts vor: Die meisten von uns haben die eine oder andere davon sogar selbst fabriziert!

Einige wenige Glückliche haben aber auch die gegenteilige Erfahrung machen dürfen, mit wunderbar organisiertem Code zu arbeiten – der Sorte Codebasis, die sich anfühlt wie ein sorgfältig ausgewähltes Luxushotel voller Bediensteter, die eifrig darauf warten, den Gästen jeden Wunsch von den Augen abzulesen. Doch worin besteht dieser Qualitätsunterschied eigentlich?

1.1.1  Was zeichnet eine gute Softwarearchitektur aus?

Für mich bedeutet gutes Design, dass das gesamte Programm bestens für Änderungen, die ich gegebenenfalls daran vornehmen möchte, gerüstet ist. Ich kann eine Aufgabe durch einige wenige ausgewählte Funktionsaufrufe lösen, die sich problemlos einfügen lassen, ohne an der friedlich ruhenden Struktur des Codes zu kratzen.

Das klingt hübsch, ist aber tatsächlich kaum machbar. »Schreib den Code einfach so, dass Änderungen die ruhende Struktur des Codes unberührt lassen.« Jaja.

Lassen Sie mich das einmal etwas genauer aufschlüsseln. Entscheidend ist, dass es bei der Architektur um Änderungen geht. Irgendjemand muss die Codebasis ändern. Wenn der Code nicht geändert wird (sei es nun, weil er bereits perfekt ist, oder aber weil er so elendig ist, dass niemand seinen Texteditor damit besudeln möchte), ist das Design irrelevant. Ein Maßstab für die Qualität eines Designs ist die Einfachheit, mit der sich Änderungen vornehmen lassen. Eine Codebasis, an der keine Änderungen vorgenommen werden, ist wie ein Sprinter, der die Startlinie erst gar nicht verlässt.

1.1.2  Wie nimmt man Änderungen vor?

Bevor Sie den Code ändern, um eine neue Funktion hinzuzufügen, einen Fehler zu beheben oder warum auch immer Sie Ihren Texteditor sonst gestartet haben mögen, müssen Sie verstehen, wie der schon vorhandene Code funktioniert. Es ist natürlich nicht nötig, das gesamte Programm zu kennen, Sie müssen aber durchaus alle relevanten Bestandteile in Ihrem »Primatenhirn« hinterlegen.

Es ist eine merkwürdige Vorstellung, dass es sich hierbei buchstäblich um eine Form der optischen Texterkennung handelt.

Dieser Schritt wird gern unter den Teppich gekehrt, obwohl er zu den zeitraubendsten Aufgaben der Programmierung überhaupt gehört. Wenn Sie der Ansicht sind, dass das Paging einiger Daten von der Festplatte ins RAM zu langsam abläuft, dann versuchen Sie mal, das mit ganzen zwei Sehnerven und einem menschlichen Großhirn zu bewerkstelligen.

Sobald Sie Ihre kleinen grauen Zellen mit den richtigen Inhalten gefüttert haben, denken Sie ein wenig nach und gelangen schließlich zu einer Lösung. Sie werden vermutlich eine Weile Hin- und Herüberlegen, meistens funktioniert das aber relativ unkompliziert – denn wenn Sie das Problem erst mal verstanden und die zugehörigen Stellen des Codes gefunden haben, ist die eigentliche Programmierung manchmal sogar trivial.

Also lassen Sie Ihre muskulösen Finger ein Weilchen über die Tastatur wandern, bis die richtigen »Lämpchen« auf dem Bildschirm angehen, und die Aufgabe ist erledigt, richtig? Nicht so hastig. Bevor Sie Tests für den Code programmieren und ihn zur Überprüfung einchecken können, müssen Sie nicht selten erst mal aufräumen.

Ist da eben der Begriff »Tests« gefallen? Oh ja, tatsächlich. Das Programmieren von Unit-Tests kann bei manchen Spielen ziemlich schwierig sein, der Großteil der Codebasis lässt sich aber bestens testen.

Ich werde jetzt keine Predigt zu diesem Thema halten, möchte Sie aber an dieser Stelle bitten, mehr automatisierte Tests in Betracht zu ziehen (sofern Sie das nicht ohnehin schon tun). Oder haben Sie etwa nichts Besseres zu tun, als den Kram immer wieder von Hand zu überprüfen?

Vielleicht haben Sie etwas zusätzlichen Code in Ihr Spiel eingebracht, möchten aber verhindern, dass der nächstbeste Programmierer über die Ecken und Kanten stolpert, die Sie im Code hinterlassen haben? Sofern es sich nicht gerade um eine sehr geringfügige Änderung handelt, ist oft eine gewisse Umstrukturierung nötig, damit sich Ihr neuer Code nahtlos in das übrige Programm einfügt – aber wenn Sie das richtig anstellen, wird der nächste Programmierer nicht feststellen können, zu welchem Zeitpunkt die einzelnen Codezeilen jeweils programmiert wurden.

Langer Rede, kurzer Sinn: Das Ablaufdiagramm der Programmierung sieht in etwa aus wie in Abbildung 1.1:

Abb. 1.1: Ihr Arbeitstag kurz zusammengefasst

Wenn ich genauer darüber nachdenke, finde ich die Tatsache, dass es kein Entkommen aus dieser Schleife gibt, doch etwas beunruhigend.

1.1.3  Inwiefern hilft eine Entkopplung?

Es ist nicht gerade offensichtlich, aber ich denke, dass ein Großteil der Softwarearchitektur von dieser Lernphase bestimmt wird. Den Code in die Hirnzellen aufzunehmen, geht so qualvoll langsam vonstatten, dass es sich lohnt, nach Strategien zur Verringerung seines Volumens zu suchen. Deshalb ist ein ganzer Teil dieses Buches den Entkopplungsmustern gewidmet, und darüber hinaus befasst sich auch ein guter Teil der Design Patterns mit diesem Konzept.

Der Begriff »Entkopplung« lässt sich auf verschiedene Weise definieren. Meiner Auffassung nach bedeutet die Entkopplung zweier Codeabschnitte, dass man den einen verstehen kann, ohne den anderen verstehen zu müssen. Wenn Sie den Code entkoppeln, können Sie über beide Teile unabhängig voneinander nachdenken. Das hat den enormen Vorteil, dass Sie nur denjenigen Code in Ihrem Gedächtnis aufnehmen müssen, der Ihr aktuelles Problem betrifft – und nicht auch noch die andere Hälfte.

Das ist meiner Ansicht nach das entscheidende Ziel der Softwarearchitektur: Minimierung des Wissens, das man im Kopf haben muss, um Fortschritte machen zu können.

Die nachfolgenden Schritte spielen natürlich auch eine Rolle. Eine andere Definition der Entkopplung besagt, dass man Änderungen an einem Teil des Codes vornehmen kann, ohne zugleich auch den anderen Teil ändern zu müssen. Es liegt auf der Hand, dass irgendetwas geändert werden muss, aber je geringer die Kopplung ist, desto weniger beeinflusst eine Änderung den Rest des Programms.

1.2  Zu welchem Preis?

Das klingt doch großartig, oder? Alles entkoppeln und schon kann man programmieren wie ein Wirbelwind. Jede Änderung betrifft nur ein oder zwei ausgewählte Methoden und Sie können auf der Struktur der Codebasis herumtanzen, ohne auch nur den Schatten einer Spur zu hinterlassen.

Das ist genau der Grund, warum die Leute sich für Abstrahierung, Modularität, Design Patterns und Softwarearchitektur begeistern. Es ist wirklich ein Vergnügen, mit einer guten Softwarearchitektur zu arbeiten – und produktiver zu sein, weiß jeder zu schätzen. Eine vernünftige Architektur bewirkt einen immensen Anstieg der Produktivität. Man kann gar nicht oft genug betonen, wie tiefgreifend die Auswirkungen sind.

Aber es gibt im Leben nun mal nichts umsonst. Eine gute Architektur erfordert Anstrengung und Disziplin. Bei jeder Änderung und jeder Ergänzung einer neuen Funktion sollten Sie stets um eine elegante Integration der entsprechenden Neuerung in den Rest des Programms bemüht sein. Sie müssen sorgfältig darauf achten, den Code nicht nur ordentlich zu organisieren, sondern diese Organisation während der zigfachen kleinen Änderungen, die jeder Entwicklungszyklus mit sich bringt, auch beizubehalten.

Die Beibehaltung der Struktur bedarf besonderer Aufmerksamkeit. Ich habe schon oft erlebt, dass Programme anfangs wunderbar strukturiert waren und dann allmählich tausend kleine Tode starben, weil die Programmierer immer wieder »mal eben nur eine winzig kleine Änderung« dazupackten.

Hier verhält es sich wie bei der Gartenarbeit: Es reicht nicht aus, die Blumen nur zu pflanzen – man muss sie auch beschneiden und Unkraut jäten.

Sie müssen sich also ein paar Gedanken dazu machen, welche Teile des Programms entkoppelt werden sollen und dort dann Abstrahierungen vornehmen. Außerdem müssen Sie herausfinden, wo Erweiterungen von vornherein vorgesehen werden sollten, um dadurch künftige Änderungen zu erleichtern.

Softwareentwickler lassen sich von diesem Konzept wirklich begeistern. Sie malen sich aus, dass andere Entwickler (oder sie selbst) in Zukunft eine leistungsfähige Codebasis vorfinden, in der alles machbar ist und die geradezu danach schreit, erweitert zu werden. Sie stellen sich gewissermaßen die einzig wahre Spiel-Engine vor.

Doch an diesem Punkt wird es knifflig: Wenn Sie eine Abstrahierung vornehmen, handelt es sich um eine spekulative Annahme, dass Sie diese Flexibilität später einmal benötigen werden. Sie fügen dem Spiel Code und damit Komplexität hinzu, deren Entwicklung, Debugging und Maintenance Zeit kostet.

Wenn Sie mit Ihrer spekulativen Annahme richtig liegen, lohnt sich der Aufwand und Sie können den Code später problemlos nutzen. Allerdings ist es wirklich schwierig, genaue Vorhersagen dazu zu treffen, ob dies für die Zukunft wirklich sinnvoll und nötig ist – und sollte die Modularität gar nicht erforderlich sein, kann sie sich schnell sogar nachteilig auswirken, denn letzten Endes haben Sie es hier mit zusätzlichem Codeballast zu tun.

Im Englischen wird häufig das Akronym YAGNI (You Aren’t Gonna Need It, Das wirst du nicht brauchen) in regelrechter Mantra-Manier gegen den Drang verwendet, darüber zu spekulieren, was in Zukunft benötigt werden wird.

Wenn die Programmierer hierbei übereifrig werden, führt das zu einer Codebasis, deren Architektur außer Kontrolle gerät. Überall finden sich Interfaces und Abstrahierungen, Plug-in-Systeme und abstrakte Basisklassen, virtuelle Methoden und alle möglichen Erweiterungen.

Es dauert ewig, in diesem Gesamtkonstrukt überhaupt Code zu finden, der tatsächlich eine richtige Aufgabe erledigt. Wenn Sie eine Änderung vornehmen möchten, gibt es vermutlich irgendein Interface, das hilfreich wäre – aber viel Spaß beim Suchen. Theoretisch sollten all diese Entkopplungen dazu führen, dass man weniger Code verstehen muss, um ihn ändern zu können – aber schon allein die Abstraktionsschichten dürften Ihre gesamte Gedächtniskapazität in Anspruch nehmen.

Codebasen dieser Art sind der Grund dafür, dass manche Leute die Softwarearchitektur als solche ablehnen, insbesondere Design Patterns. Man kann sich leicht so sehr in dem Code selbst verzetteln, dass man aus den Augen verliert, dass man ja eigentlich ein Spiel liefern möchte. Der Sirenengesang der Erweiterbarkeit lockt zahllose Entwickler an, die dann jahrelang an einer Engine arbeiten, ohne jemals herauszufinden, wofür diese Engine überhaupt verwendet werden kann.

1.3  Performance und Geschwindigkeit

Insbesondere im Bereich der Spieleentwicklung wird in Bezug auf die Softwarearchitektur und die Abstrahierung mitunter kritisiert, dass die Performance der Spiele darunter leidet. Viele der Patterns, die den Code flexibler machen, beruhen auf virtuellen Weiterleitungen, Interfaces, Zeigern, Benachrichtigungen und anderen Mechanismen, die zumindest etwas Zeit kosten.

Ein interessantes Gegenbeispiel sind Templates in C++. Mitunter lässt sich mithilfe der Template-Metaprogrammierung eine Abstrahierung der Interfaces erzielen, ohne dass sich dadurch irgendwelche Nachteile zur Laufzeit ergeben.

Hier steht eine ziemliche Bandbreite an Flexibilität zur Verfügung. Wenn Sie Code schreiben, der eine konkrete Methode einer Klasse aufruft, legen Sie diesen Aufruf bereits zum Zeitpunkt der Programmierung fest – die Klasse ist somit hartkodiert und fest vorgegeben. Wenn Sie hingegen eine virtuelle Methode oder ein Interface verwenden, ist erst zur Laufzeit bekannt, welche Klasse aufgerufen wird. Diese Vorgehensweise ist sehr viel flexibler, bedeutet aber auch einen Mehraufwand zur Laufzeit.

Die Template-Metaprogrammierung liegt irgendwo dazwischen. Hier fällt die Entscheidung, welche Klasse aufgerufen wird, beim Kompilieren, nämlich wenn das Template instanziiert wird.

Dafür gibt es einen Grund: Bei einem Großteil der Softwarearchitektur geht es darum, Ihr Programm flexibler zu machen und die Vornahme von Änderungen zu erleichtern. Letztendlich bedeutet das, weniger Annahmen in das Programm hineinzustecken. Sie verwenden Interfaces, damit Ihr Code mit jeder beliebigen Klasse zusammenarbeitet, die das Interface korrekt implementiert – statt nur mit der einen, die fürs Erste ausreichen würde. Sie verwenden Beobachter (Observer, Kapitel 4) und Benachrichtigungen (siehe Kapitel 15, Event Queue (Ereigniswarteschlange)), damit nicht nur zwei Bestandteile des Spiels miteinander kommunizieren können, sondern zukünftig auch drei oder vier.

Bei der Performance geht es immer auch um Annahmen. Viele Optimierungen sind nur aufgrund konkreter Einschränkungen möglich. Können wir mit Sicherheit annehmen, dass es nie mehr als 256 Feinde gibt? Großartig, dann können wir die ID auch in einem einzigen Byte unterbringen. Rufen wir hier nur Methoden eines bestimmten Typs auf? Gut, dann können wir eine statische Weiterleitung oder inline programmieren. Gehören alle Objekte zur selben Klasse? Fein, dann können wir ein schönes zusammenhängendes Array (siehe Kapitel 17, Data Locality) verwenden.

Das heißt natürlich nicht, dass mehr Flexibilität schlecht ist! Wir können unser Spiel schnell verändern – ebenso sind möglichst zügige Fortschritte in der Entwicklung unverzichtbar, damit die Sache Spaß macht. Auf einem Blatt Papier kann niemand ein ausgewogenes Spieldesign entwickeln, nicht mal der SimCity-Erfinder Will Wright. Dazu ist es notwendig, zu experimentieren und immer wieder alles zu überprüfen.

Je schneller man Ideen umsetzen und ausprobieren kann, wie sie sich auswirken, umso mehr kann man experimentieren und desto wahrscheinlicher wird es, möglicherweise auf etwas wirklich Großartiges zu stoßen. Selbst wenn die richtigen Spielmechanismen feststehen, ist noch viel Zeit für das Feintuning erforderlich – denn schon die kleinste Unausgewogenheit kann den Spielspaß ruinieren.

Es gibt hier keine einfache Lösung. Eine erhöhte Flexibilität, die das Entwickeln eines Prototyps beschleunigt, geht mit gewissen Performanceeinbußen einher. Und umgekehrt verringern Optimierungen die Flexibilität.

Meiner Erfahrung nach ist es allerdings einfacher, ein gelungenes Spiel zu beschleunigen, als einem schnellen Spiel mehr Spielspaß zu verleihen. Den Code flexibel zu halten, bis sich kaum noch etwas am Spiel ändert, ist ein gangbarer Kompromiss. Danach können dann einige der Abstrahierungen entfernt werden, um die Performance zu verbessern.

1.4  Das Gute an schlechtem Code

Das bringt mich zum nächsten Punkt, nämlich dass verschiedene Programmierstile ihre Berechtigung haben. In diesem Buch geht es vor allem darum, sauberen und gut zu wartenden Code zu produzieren und insofern steht wohl außer Frage, dass es meine Pflicht ist, die Programmierung »ordentlich« zu erledigen – aber auch »schlampiger« Code kann nützlich sein.

Eine vernünftige Systemarchitektur erfordert sorgfältige Überlegungen, und das wiederum kostet Zeit. Darüber hinaus ist die Maintenance (Aufrechterhaltung) einer guten Architektur über den Zeitraum der Lebensdauer eines Projekts hinweg ziemlich aufwendig. Sie sollten Ihre Codebasis behandeln wie Camper ihren Campingplatz: Versuchen Sie stets, sie in einem besseren Zustand als dem vorgefundenen zurückzulassen.

Das zahlt sich aus, gerade wenn Sie längere Zeit an und mit dem Code arbeiten. Wie ich aber bereits erwähnt habe, erfordert das Spieldesign eine Menge Experimentier- und Erkundungsarbeit. Gerade im Anfangsstadium der Entwicklung ist es üblich, Code zu schreiben, von dem Sie wissen, dass Sie ihn bald wieder löschen werden.

Wenn Sie nur herausfinden möchten, ob die Spielmechanik überhaupt funktioniert, ist eine sorgfältige Systemarchitektur Zeitverschwendung und es dauert länger, bis das Spiel tatsächlich läuft und Sie ein Feedback erhalten. Funktioniert die Spielidee nicht, wäre die in eleganten Code investierte Zeit vergeudet.

Die Prototyperstellung, also das »Zusammenklatschen« von Code, der gerade eben so weit funktioniert, dass Designfragen beantwortet werden können, hat absolut ihre Berechtigung. Dabei gibt es allerdings einen wichtigen Vorbehalt: Wenn Sie planen, Wegwerf-Code zu schreiben, müssen Sie sich vergewissern, dass Sie ihn auch tatsächlich wieder entfernen können. Zu oft habe ich schon erlebt, wie unfähige Manager folgendes Spielchen treiben:

Chef: »He, wir haben da eine Idee, die wir ausprobieren wollen. Nur ein Prototyp, Sie müssen das nicht ordentlich ausarbeiten. Wie lange brauchen Sie dafür?«

Programmierer: »Tja, wenn ich dies und jenes weglasse, keine Tests durchführe, auf die Dokumentation verzichte und Bugs wie Sand am Meer in Ordnung sind, dann ein paar Tage.«

Chef: »Klasse!«

Einige Tage später ...

Chef: »He, der Prototyp ist großartig. Können Sie noch ein paar Stunden dranhängen und ein wenig aufräumen, damit wir das Programm ausliefern können?«

Sie müssen sich vergewissern, dass die Anwender des Wegwerf-Codes begreifen, dass der Code, obwohl er anscheinend funktioniert, nicht gewartet werden kann und neu geschrieben werden muss. Wenn auch nur die geringste Wahrscheinlichkeit besteht, dass der Code dauerhaft verwendet werden wird, sollten Sie defensiv vorgehen und ihn gleich von Anfang an ordentlich ausarbeiten.

Hier ein Trick, mit dem Sie sicherstellen können, dass der Prototyp-Code nicht zu »echtem« Code wird: Schreiben Sie ihn in einer anderen Programmiersprache als das Spiel. Auf diese Weise muss er auf jeden Fall neu geschrieben werden, bevor er Eingang in Ihr Programm findet.

1.5  Ein ausgewogenes Verhältnis finden

Im Allgemeinen sind folgende Zielsetzungen zu beachten:

Wir wollen eine vernünftige Architektur, damit der Code über die Lebensdauer des Projekts hinweg verständlich bleibt.

Wir wollen eine gute Laufzeitperformance erreichen.

Wir wollen anstehende Änderungen schnell erledigen können.

Ich finde es bemerkenswert, dass es bei diesen Zielsetzungen immer um eine Form von Geschwindigkeit geht: Die Geschwindigkeit der langfristigen Entwicklung, die Ausführungsgeschwindigkeit des Spiels und die Geschwindigkeit der kurzfristigen Entwicklung.

Diese Ziele sind zumindest teilweise gegensätzlich. Langfristig erhöht eine vernünftige Architektur die Produktivität, aber die Beibehaltung der Architektur bedeutet kurzfristig auch, dass jede Änderung ein klein wenig aufwendiger ist.

Diejenige Implementierung, die sich am schnellsten schreiben lässt, ist nur in den seltensten Fällen auch diejenige, die am schnellsten läuft. Vielmehr ist ein beträchtlicher Zeitaufwand für die Optimierung erforderlich. Und sobald diese erledigt ist, neigt die Codebasis dazu, zu versteinern: Hochoptimierter Code ist unflexibel, und es ist sehr schwierig, ihn zu ändern.

Es gibt immer den Druck, das heutige Arbeitspensum auch heute zu erledigen – um alles andere kann man sich morgen kümmern. Aber wenn alle Änderungen so schnell wie möglich durchgeführt werden, wird die Codebasis zu einem Durcheinander aus Hacks, Bugs und Inkonsistenzen, das unsere zukünftige Produktivität unterminiert.

Auch hier gibt es keine einfache Lösung, nur Kompromisse. Den E-Mails zufolge, die ich erhalte, lassen sich viele Leute davon entmutigen. Insbesondere für Neulinge, die einfach nur mal ein Spiel programmieren möchten, ist es ziemlich einschüchternd, wenn man ihnen sagt: »Eine richtige Lösung gibt es nicht, nur verschiedene Ausprägungen von falschen Lösungen.«

Ich selbst hingegen finde das spannend! Sehen Sie sich andere Tätigkeitsfelder an, denen manche Leute ihre gesamte Karriere widmen, um sie zu meistern. Immer gibt es irgendwelche zentralen Einschränkungen. Gäbe es eine einfache Lösung, würden sich alle daran halten. Ein Aufgabengebiet, das man in einer Woche zu beherrschen lernt, ist letzten Endes doch langweilig. Oder haben Sie etwa schon mal von einer bemerkenswerten Karriere im Ausheben von Straßengräben gehört?

Möglicherweise haben Sie ja doch schon von solch einer Karriere gehört, ich habe das nicht weiter recherchiert. Soweit ich weiß, spricht nichts dagegen, dass sich passionierte Straßengrabenausheber auf Fachtagungen für Straßengrabenausheber treffen und eine eigene Subkultur pflegen. Wer bin ich denn, das zu beurteilen?

Meiner Meinung nach gibt es hier einige Parallelen mit den Spielen selbst. Ein Spiel wie Schach wird wohl niemals vollständig verstanden werden, weil die verschiedenen Spielfiguren so perfekt aufeinander abgestimmt sind. Sie könnten Ihr ganzes Leben damit verbringen, brauchbare Strategien zu erforschen. Bei einem schlecht gestalteten Spiel hingegen verfolgt man immer wieder die eine gewinnbringende Taktik, bis man es gelangweilt beendet.

1.6  Einfachheit

Ich habe seit einiger Zeit immer mehr das Gefühl, dass es doch eine geeignete Methode gibt, um mit diesen Einschränkungen fertig zu werden: Einfachheit. Inzwischen gebe ich mir bei meinem Code die größte Mühe, die einfachste und unmittelbarste Lösung des Problems zu schreiben. Bei dieser Sorte Code ist Ihnen beim Betrachten sofort klar, was er leistet, und es ist kaum eine andere Lösung vorstellbar.

Ich versuche zunächst, die passenden Datenstrukturen und Algorithmen zu finden (mehr oder weniger in dieser Reihenfolge) und mache dann auf dieser Grundlage weiter. Meiner Erfahrung nach produziere ich insgesamt weniger umfangreichen Code, sofern es mir gelingt, die Dinge einfach zu halten. Und das bedeutet wiederum, dass ich mir weniger Code merken muss, um Änderungen daran vorzunehmen. Außerdem läuft das Programm so meist auch recht schnell, weil weniger Mehraufwand anfällt und auch weniger Code auszuführen ist. (Das trifft allerdings mit Sicherheit nicht immer zu. Auch in einigen wenigen Codezeilen lassen sich jede Menge Schleifen und Rekursionen unterbringen.)

Bekanntlich beendete Blaise Pascal einen Brief mit den Worten »Ich hätte einen kürzeren Brief geschrieben, hatte aber leider keine Zeit.« Ein weiteres Zitat stammt von Antoine de Saint-Exupéry: »Perfektion ist nicht dann erreicht, wenn es nichts mehr hinzuzufügen gibt, sondern wenn man nichts mehr weglassen kann.«

In diesem Zusammenhang ist mir aufgefallen, dass die Kapitel dieses Buches nach jeder Durchsicht kürzer geworden sind – manche waren am Ende um ganze 20% geschrumpft!

Ich behaupte damit jedoch keineswegs, dass das Schreiben einfachen Codes weniger Zeit erfordert. Man könnte annehmen, dass dem so wäre, weil letzten Endes insgesamt weniger Code vorhanden ist, aber eine gute Lösung besteht nicht aus einer Anhäufung von Code, sondern einem Konzentrat.

Mit wirklich eleganten Problemen werden wir nur selten konfrontiert. Meistens handelt es sich um eine Reihe verschiedener Fälle, die eintreten können: Wenn der Fall Z eintritt, soll X die Aktion Y ausführen, aber Aktion W, wenn der Fall A eintritt usw. Mit anderen Worten: Eine lange Liste mit verschiedenen vorgegebenen Verhaltensweisen. Die unterschiedlichen Fälle einfach der Reihe nach zu programmieren, ist die Lösung, die am wenigsten Nachdenken erfordert. Das lässt sich insbesondere bei Programmieranfängern oft beobachten: Sie produzieren am laufenden Band einen Haufen bedingter Abfragen, eine für jeden möglichen Fall, der ihnen in den Sinn kommt.

Elegant ist das allerdings nicht – und außerdem versagt derartiger Code häufig, wenn er mit Eingaben gefüttert wird, die nur minimal von den Beispielen abweichen, die der Programmierer berücksichtigt hat. Wenn wir an Eleganz denken, schwebt uns eher eine allgemeine Lösung vor: ein kleiner Schnipsel Logik, der dennoch eine Vielzahl von Anwendungsfällen korrekt behandelt.

Diese Logik zu entwickeln, besitzt eine gewisse Ähnlichkeit mit der Verwendung regulärer Ausdrücke oder dem Lösen eines Puzzles: Es ist manchmal nicht ganz einfach, die verborgene Ordnung zu entdecken, die all den verschiedenen Fällen zugrunde liegt. Aber wenn Sie es schaffen, ist das ein wirklich erhebendes Gefühl.

1.7  Fang endlich an!

Die meisten Leser überspringen die einführenden Kapitel, also – herzlichen Glückwunsch, dass Sie es bis hierhin geschafft haben! Ich kann Ihre Geduld kaum hinreichend belohnen, aber ich biete Ihnen einige Ratschläge an, die Ihnen hoffentlich von Nutzen sein werden:

Abstrahierung und Entkopplung vereinfachen und beschleunigen die Entwicklung Ihres Programms, Sie sollten Ihre kostbare Zeit aber nicht dafür aufwenden, sofern Sie nicht sicher sind, dass der fragliche Code diese Flexibilität auch tatsächlich benötigt.

Schenken Sie der Performance des Codes bei jedem Entwicklungsschritt Beachtung, aber verschieben Sie maschinennahe und sehr detaillierte Optimierungen zeitlich so weit wie möglich nach hinten.

Eins dürfen Sie mir glauben: Zwei Monate vor dem Auslieferungstermin ist kein geeigneter Zeitpunkt, um sich des lästigen kleinen Problems anzunehmen, dass das Spiel nur mit 1 FPS (frames per second, Bilder pro Sekunde) läuft.

Loten Sie die Möglichkeiten aus und nehmen Sie Änderungen zügig vor, führen Sie sie aber nicht so hastig durch, dass Sie ein wirres Durcheinander hinterlassen – schließlich müssen Sie weiterhin damit arbeiten.

Wenn Sie planen, Wegwerf-Code zu schreiben, verschwenden Sie keine Zeit damit, ihn aufzuhübschen. Rockstars verwüsten Hotelzimmer nur deshalb, weil sie genau wissen, dass sie das Hotel am nächsten Tag verlassen.

Und zum Schluss das Wichtigste: Wenn das Produkt Spaß machen soll, sorgen Sie dafür, dass auch die Entwicklung Spaß macht.

Teil II: Design Patterns neu überdacht

In diesem Teil:

Kapitel 2

Command (Befehl)

Kapitel 3

Flyweight (Fliegengewicht)

Kapitel 4

Observer (Beobachter)

Kapitel 5

Prototype (Prototyp)

Kapitel 6

Singleton

Kapitel 7

State (Zustand)

Das Buch Design Patterns: Entwurfsmuster als Elemente wiederverwendbarer objektorientierter Software ist inzwischen über 20 Jahre alt. Würde es sich dabei um einen Wein handeln, wäre er inzwischen wohl alt genug, um ihn genussvoll trinken zu können – in der schnelllebigen Softwarebranche ist das praktisch schon antik. Die anhaltende Popularität des Buches zeigt aber auch, wie zeitlos Design doch im Vergleich zu vielen Frameworks oder so manchem anderen Verfahren ist.

Zwar sind die Design Patterns nach wie vor von Bedeutung, wir haben in den letzten beiden Jahrzehnten aber auch dazugelernt. In diesem Abschnitt werden wir uns einige der ursprünglichen Patterns der Gang of Four ansehen, und ich hoffe, zu jedem einzelnen von ihnen den einen oder anderen nützlichen Beitrag leisten zu können.

Ich bin der Ansicht, dass manche Patterns (Singleton, Kapitel 6) überbeansprucht und andere (Command, Kapitel 2) unterschätzt werden. Einige sind hier aufgeführt, weil ich ihre Bedeutung für die Spieleprogrammierung aufzeigen möchte (Flyweight und Observer, Kapitel 3 und 4). Und in manchen Fällen ist es einfach nur interessant zu sehen, wie die Patterns mit dem Gesamtbild der Programmierung verwoben sind (Prototype und State, Kapitel 5 und 7).

Kapitel 2: Command (Befehl)

Kapselung eines Requests als Objekt, um so die Parametrisierung von Clients mit verschiedenen Requests, Warteschlangen- oder Logging-Operationen sowie das Rückgängigmachen von Operationen zu ermöglichen.

Command (Befehl) ist eins meiner Lieblings-Patterns. Die meisten größeren Programme, die ich schreibe, Spiele und anderes, verwenden es irgendwo. Wenn ich es an der richtigen Stelle einsetze, wird dadurch wirklich verknoteter Code fein säuberlich entwirrt. Für dieses schicke Pattern hat die Gang of Four eine ziemlich schwer verständliche Beschreibung gefunden – siehe oben.

Ich denke, wir sind uns einig, dass das ein furchtbarer Satz ist. Zunächst einmal entstellt er die Metapher, die er verwendet. Jenseits der seltsamen Softwarewelt, wo Wörter alles Mögliche bedeuten können, ist ein »Client« eine Person, mit der man geschäftlich verkehrt. Ich kenne aber keinen Menschen, den man »parametrisieren« könnte.

Und der Rest des Satzes listet einfach verschiedene Dinge auf, die man vielleicht mit dem Pattern anstellen könnte. Das ist allerdings nicht gerade sehr erhellend, wenn sich Ihr Anwendungsfall nicht zufällig auch darunter befindet. Mein prägnanter Slogan für das Command-Pattern lautet dagegen:

Command ist ein reifizierter Methodenaufruf.

Der Begriff Reifikation entstammt dem Lateinischen und bedeutet Vergegenständlichung, Verdinglichung oder Konkretisierung.

Introspektion oder Reflexion bezeichnet die Fähigkeit eines Programms, seine eigene Struktur zu kennen und gegebenenfalls zu ändern. Sie können ein Objekt zur Laufzeit instanziieren, den Typ ermitteln und feststellen, was er leistet. Mit anderen Worten: Introspektion ist ein reifiziertes Typsystem.

Nun heißt »prägnant« natürlich manchmal auch »unverständlich kurz«, und das würde die Sache nun auch nicht gerade besser machen. Also lassen Sie mich das ein wenig entwirren: »Reifizieren« bedeutet »verdinglichen« oder »vergegenständlichen«. Man könnte hier auch sagen, dass man ein »First-Class-Objekt« erzeugt.

In beiden Fällen ist gemeint, dass man ein Konzept verwendet, um daraus Daten zu machen – sprich ein Objekt, das in einer Variablen gespeichert oder einer Funktion übergeben werden kann usw. Wenn ich also sage, das Command-Pattern ist ein »reifizierter Methodenaufruf«, meine ich damit, dass es sich um einen Methodenaufruf handelt, der in einem Objekt verpackt ist.

Das klingt doch sehr nach »Callback«, »First-Class-Funktion«, »Funktionszeiger«, »Closure« oder »Currying«, je nachdem, welche Programmiersprache Sie verwenden. Und tatsächlich spielen all diese Methoden auch in derselben Liga. Bei der Gang of Four heißt es später: »Befehlsobjekte sind ein objektorientierter Ersatz für Callbacks.«

Das wäre eine bessere Beschreibung des Patterns gewesen als die gewählte, trotzdem ist das Ganze noch immer abstrakt und nebulös. Ich mag es eigentlich, wenn ein Kapitel mit etwas Konkretem beginnt, und das habe ich nun vermasselt. Zur Wiedergutmachung folgen von nun an stets handfeste Beispiele, wenn das Command-Pattern für eine Aufgabe bestens geeignet ist.

2.1  Eingabekonfiguration

In jedem Spiel gibt es irgendwo einen Codeabschnitt, der die Eingaben des Benutzers einliest – Tastendrücke, Tastatureingaben, Mausklicks, was auch immer. Der Code wandelt diese Eingaben in sinnvolle Spielhandlungen um:

Abb. 2.1: Tasten zur Spielsteuerung

Eine besonders einfache Implementierung sieht folgendermaßen aus:

void InputHandler::handleInput()
{
    if (isPressed(BUTTON_X)) jump();
    else if (isPressed(BUTTON_Y)) fireGun();
    else if (isPressed(BUTTON_A)) swapWeapon();
    else if (isPressed(N_B)) lurch();
}

Diese Funktion wird typischerweise ein Mal pro angezeigtem Frame von der Game Loop (Hauptschleife, Kapitel 9) aufgerufen, und ich bin mir sicher, das Sie sich vorstellen können, was ihre Aufgabe ist. Wenn wir zulassen wollen, dass die Eingaben den Aktionen im Spiel fest zugeordnet sind, funktioniert dieser Code – viele Spiele gestatten allerdings eine individuelle Konfiguration der Tastenzuordnung.

Um das zu unterstützen, müssen wir die direkten Aufrufe von jump() (Springen), fireGun() (Feuern), lurch() (Abrollen) und swapWeapon() (Waffe wechseln) durch etwas Austauschbares ersetzen. Der Begriff »austauschbar« weist hier schon darauf hin, dass wir eine Variablenzuweisung vornehmen müssen, also brauchen wir ein Objekt, das die verschiedenen Spielhandlungen speichern kann. Es folgt der Auftritt des Command-Patterns.

Zur Repräsentation der ausführbaren Aktionen des Spiels definieren wir eine Basisklasse:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
};

Wenn eine Klasse nur eine einzige Methode ohne Rückgabewert besitzt, stehen die Chancen nicht schlecht, dass es sich um ein Command-Pattern handelt.

Dann erstellen wir Unterklassen für die verschiedenen Aktionen:

class JumpCommand : public Command
{
public:
  virtual void execute() { jump(); }
};

class FireCommand : public Command
{
public:
  virtual void execute() { fireGun(); }
};

// Und so weiter ...

In der Klasse InputHandler speichern wir für jeden Button einen Zeiger auf einen der Befehle:

class InputHandler
{
public:
  void handleInput();

  // Methoden zum Einbinden der Befehle

private:
  Command* buttonX_;
  Command* buttonY_;
  Command* buttonA_;
  Command* buttonB_;
};

Der InputHandler leitet die Aufrufe nun um:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) buttonX_->execute();
  else if (isPressed(BUTTON_Y)) buttonY_->execute();
  else if (isPressed(BUTTON_A)) buttonA_->execute();
  else if (isPressed(BUTTON_B)) buttonB_->execute();
}

Ist Ihnen aufgefallen, dass keine Überprüfung auf NULL stattfindet? Hier wird vorausgesetzt, dass jedem Button eine Aktion zugeordnet ist.

Wenn wir Buttons ohne Funktion unterstützen möchten, ohne ausdrücklich auf NULL testen zu müssen, könnten wir eine Command-Klasse definieren, deren execute()-Methode nichts tut. Anstatt einen der Button-Handler auf NULL zu setzen, verweist er dann auf dieses Objekt. Dieses Pattern wird als Nullobjekt bezeichnet.

An der Stelle, an der vorher ein direkter Funktionsaufruf stattfand, befindet sich nun eine Umleitungsschicht:

Abb. 2.2: Den Buttons können Befehle zugeordnet werden.

Das war das Command-Pattern in Kürze. Wenn Sie dessen Vorzüge bereits zu schätzen wissen, können Sie den Rest dieses Kapitels als Bonus betrachten.

2.2  Regieanweisungen

Im vorhergehenden Beispiel funktionieren die soeben definierten Command-Klassen, ihre Funktionalität ist jedoch ziemlich beschränkt. Das Problem besteht darin, dass sie vom Vorhandensein der Funktionen jump(), fireGun() usw. ausgehen, denen wiederum bekannt sein muss, wie der Avatar des Spielers ausfindig gemacht und als die Marionette, die er ja schließlich ist, zum Tanzen gebracht wird.

Die hierbei vorausgesetzte Kopplung begrenzt den Nutzen dieser Befehle. Der Befehl JumpCommand kann nur den Spieler zum Springen bringen, doch diese Beschränkung soll nun aufgehoben werden. Statt Funktionen aufzurufen, die das herumkommandierte Objekt selbst finden, übergeben wir das Objekt, das wir »aufscheuchen« möchten:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute(GameActor& actor) = 0;
};

Hier ist GameActor die »Spielobjekt«-Klasse, die einen Akteur in der Spielwelt repräsentiert. Wir übergeben sie an execute(), damit der Befehl die Methode mit einem Akteur unserer Wahl aufrufen kann:

class JumpCommand : public Command
{
public:
  virtual void execute(GameActor& actor)
  {
    actor.jump();
  }
};

Nun können wir die Klasse dazu verwenden, jede Figur des Spiels zum Springen zu bringen. Es fehlt nur noch die Verbindung zwischen InputHandler und dem Befehl, damit er mit dem richtigen Objekt ausgeführt wird. Zunächst ändern wir handleInput(), damit die Funktion Befehle zurückliefert:

Command* InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) return buttonX_;
  if (isPressed(BUTTON_Y)) return buttonY_;
  if (isPressed(BUTTON_A)) return buttonA_;
  if (isPressed(BUTTON_B)) return buttonB_;
  // Keine Taste gedrückt, also nichts tun

    return NULL;
}

Der Befehl kann nicht sofort ausgeführt werden, weil nicht bekannt ist, welcher Akteur übergeben werden soll. Jetzt machen wir uns die Tatsache zunutze, dass es sich bei diesem Befehl um einen reifizierten Aufruf handelt – das heißt, wir können die Ausführung des Aufrufs verzögern.

Wir benötigen ein Stück Code, um den Befehl mit dem Akteur auszuführen, der den Spieler repräsentiert. Etwa so:

Command* command = inputHandler.handleInput();
if (command)
{
  command->execute(actor);
}

Die Figur des Spielers wird nun anhand der Benutzereingabe korrekt angesteuert, sofern actor eine Referenz auf diesen Akteur ist. Wir sind somit wieder bei der Verhaltensweise des ersten Beispiels angelangt. Durch das Hinzufügen der Umleitungsschicht zwischen dem Befehl und dem Akteur, der ihn ausführt, eröffnet sich eine interessante Möglichkeit: Der Spieler kann nun jede beliebige Figur des Spiels steuern, indem wir den Akteur austauschen, mit dem der Befehl ausgeführt wird.

In der Praxis wird diese Funktionalität kaum benötigt, aber es gibt einen ähnlichen Anwendungsfall, der häufig auftritt: Bislang haben wir uns nur um die vom Spieler gesteuerte Figur gekümmert, doch was ist mit all den anderen Figuren in der Spielwelt? Diese werden von der künstlichen Intelligenz (KI) des Spiels gesteuert. Wir können also dasselbe Command-Pattern als Interface zwischen der KI-Engine und den anderen Figuren verwenden – der KI-Code liefert einfach nur Befehlsobjekte.

Die Entkopplung zwischen der KI, die Befehle auswählt, und dem Code des Akteurs, der sie ausführt, beschert uns eine Menge Flexibilität. Wir können beispielsweise für verschiedene Akteure unterschiedliche KI-Module verwenden. Oder verschiedene KI-Module für unterschiedliche Verhaltensweisen zusammenstellen. Sie wollen einen aggressiveren Gegner? Dann programmieren Sie doch einfach eine aggressivere KI, die entsprechende Befehle erzeugt. Tatsächlich könnten wir sogar der Figur des Spielers eine KI verpassen, was beispielsweise für einen Demo-Modus nützlich wäre, in dem ein Autopilot die Steuerung übernimmt.

Indem wir die Befehlsobjekte, die einen Akteur steuern, zu First-Class-Objekten machen, haben wir die enge Kopplung durch einen direkten Methodenaufruf entfernt. Stellen Sie sich stattdessen eine Warteschlange oder einen Befehlsstrom vor:

Abb. 2.3: Eine schlecht gezeichnete Analogie

Was Warteschlangen noch alles für Sie erledigen können, erfahren Sie in Kapitel 15, Event Queue (Ereigniswarteschlange).

Warum nur habe ich ein Bild von einem »Strom« gezeichnet? Und warum sieht er aus wie eine Röhre?

Mancher Code (z.B. der InputHandler oder die KI) erzeugt Befehlsobjekte und reiht sie in den Befehlsstrom ein. Anderer Code (der den Befehlsstrom in der Warteschlange abarbeitet, oder der Akteur selbst) nimmt Befehlsobjekte entgegen und führt die Befehle aus. Durch die Platzierung der Warteschlange in der Mitte haben wir den Erzeuger am einen Ende von den Befehlsempfängern am anderen Ende entkoppelt.

Wenn wir die Befehlsobjekte serialisierbar machen, können sie auch über das Netzwerk versendet werden. Wir können die Eingaben des Spielers entgegennehmen, sie via Netzwerk an einen anderen Rechner übermitteln und dort wiedergeben. Diese Vorgehensweise nimmt bei Spielen für mehrere vernetzte Spieler eine wichtige Rolle ein.

2.3  Rückgängig und Wiederholen

Das letzte Beispiel ist wohl auch das bekannteste für dieses Pattern. Wenn ein Befehlsobjekt etwas ausführen kann, ist es nur noch ein kleiner Schritt bis zum Rückgängigmachen. In manchen Strategiespielen ist es möglich, Spielzüge rückgängig zu machen, wenn das Ergebnis dem Benutzer nicht gefällt. Bei der Entwicklung von Spielen gehört das Rückgängigmachen zu den absolut unentbehrlichen Werkzeugen. Den Spieleentwicklern einen Level-Editor bereitzustellen, der es nicht erlaubt, die tollpatschigen Fehler rückgängig zu machen, ist die sicherste Methode, dafür zu sorgen, dass sie Sie hassen.

Ich spreche hier womöglich aus eigener Erfahrung.

Einen Mechanismus zum Rückgängigmachen ohne das Command-Pattern zu implementieren, ist erstaunlich schwierig. Mit dem Pattern ist es jedoch ein Kinderspiel. Nehmen wir an, wir entwickeln ein rundenbasiertes Spiel für einen einzelnen Spieler. Diesem Spieler soll es gestattet sein, Spielzüge rückgängig zu machen, damit er sich mehr auf die Strategie konzentrieren kann und nicht auf Mutmaßungen angewiesen ist.

Praktischerweise haben wir schon Befehlsobjekte zur Abstrahierung der Eingabe verwendet, die Züge des Spielers sind also bereits gekapselt. Ein Spielzug könnte beispielsweise folgendermaßen aussehen:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    x_(x),
    y_(y)
  {}

  virtual void execute()
  {
    unit_->moveTo(x_, y_);
  }

private:
  Unit* unit_;
  int x_;
  int y_;
};

Beachten Sie, dass es hier einige Unterschiede zum vorherigen Befehlsobjekt gibt. Im letzten Beispiel sollte der Befehl vom ausführenden Akteur abstrahiert werden. In diesem Fall wollen wir ihn ausdrücklich an die im Spielzug bewegte Unit (das könnte z.B. ein Spielstein sein) anbinden. Eine Instanz dieses Befehlsobjekts soll keine allgemeine »Bewege etwas«-Operation sein, die für verschiedene Zwecke verwendbar ist, sondern eine ganz bestimmte, konkrete Bewegung in der Abfolge der Spielzüge.

Das wird auch durch eine Änderung in der Implementierung des Command-Patterns betont. In manchen Fällen, wie bei den ersten Beispielen, ist das Befehlsobjekt ein wiederverwendbares Objekt, das etwas Ausführbares repräsentiert. Der vorherige InputHandler bezog sich auf ein einziges Befehlsobjekt und ruft die execute()-Methode auf, wenn die richtige Taste betätigt wird.

Hier hingegen sind die Befehlsobjekte viel spezieller. Sie repräsentieren etwas nur zu einem bestimmten Zeitpunkt Ausführbares. Das heißt, dass der Code des InputHandlers jedes Mal eine neue Instanz erzeugt, wenn der Spieler zieht.

Bei einer Programmiersprache ohne Garbage Collection, sprich automatische Speicherbereinigung, wie etwa C++, ist der Code, der Befehlsobjekte verwendet, natürlich auch für die Freigabe des von den Befehlsobjekten belegten Arbeitsspeichers verantwortlich.

Das könnte wie folgt aussehen:

Command* handleInput()
{
  Unit* unit = getSelectedUnit();

  if (isPressed(BUTTON_UP)) {
    // Einen Schritt nach oben
    int destY = unit->y() - 1;
    return new MoveUnitCommand(
        unit, unit->x(), destY);
  }

  if (isPressed(BUTTON_DOWN)) {
    // Einen Schritt nach unten
    int destY = unit->y() + 1;
    return new MoveUnitCommand(
        unit, unit->x(), destY);
  }

  // Weitere Züge ...

    return NULL;
}

Die Tatsache, dass Befehlsobjekte nur einmalig verwendet werden, wird sogleich von Vorteil sein. Um einen Befehl widerrufbar zu machen, definieren wir eine weitere Operation, die jede Befehlsklasse implementieren muss:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
  virtual void undo() = 0;
};

Die undo()-Methode stellt den Zustand des Spiels vor der durch die zugehörige execute()-Methode vorgenommenen Änderung wieder her. Hier der Spielzug mit der undo()-Methode:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit), x_(x), y_(y)
    xBefore_(0), yBefore_(0),
  {}

  virtual void execute()
  {
    // Position vor dem Zug merken, damit sie
    // wiederhergestellt werden kann.
    xBefore_ = unit_->x();
    yBefore_ = unit_->y();
    unit_->moveTo(x_, y_);
  }

  virtual void undo()
  {
    unit_->moveTo(xBefore_, yBefore_);
  }

private:
  Unit* unit_;
  int x_, y_;
  int xBefore_, yBefore_;
};

Beachten Sie, dass die Klasse nun weitere Zustände kennt. Nach einer Bewegung ist der Unit ihre vorherige Position nicht mehr bekannt. Wenn wir einen Spielzug rückgängig machen möchten, müssen wir uns die alte Position in den Variablen xBefore_ und yBefore_ merken.

Damit der Spieler einen Zug widerrufen kann, müssen wir also den zuletzt ausgeführten Befehl speichern. Wenn die Tastenkombination zum Rückgängigmachen gedrückt wird, rufen wir die undo()-Methode des Befehls auf. (Falls bereits ein Rückgängigmachen stattgefunden hat, wird aus dem Befehl ein »Wiederholen« und wir führen stattdessen den Befehl erneut aus.)

Ein mehrfaches Rückgängigmachen zu implementieren, ist kaum schwieriger: Statt den letzten Befehl zu speichern, werden eine Liste mit Befehlen und eine Referenz auf den aktuellen Befehl angelegt. Wenn der Spieler einen Befehl ausführt, wird er der Liste hinzugefügt und zum aktuellen Befehl gemacht.

Abb. 2.4: Durchlaufen des Undo-Stacks

Hier scheint eigentlich das Memento-Pattern angebracht zu sein, aber ich habe festgestellt, dass es nicht besonders gut funktioniert. Da Befehle tendenziell nur einen geringen Teil des Objektzustands ändern, wäre es Speicherverschwendung, das gesamte Objekt zu kopieren. Es ist effektiver, nur die tatsächlich ausgeführten Änderungen zu speichern.

Eine weitere Möglichkeit sind dauerhafte Datenstrukturen. Dabei erzeugt jede Änderung an einem Objekt ein neues Objekt und das Original bleibt unverändert. Eine clevere Implementierung verwendet auch die Daten des Originals, was erheblich effektiver ist, als das gesamte Objekt zu klonen.

Bei der Verwendung dauerhafter Datenstrukturen speichert jedes Befehlsobjekt vor der Ausführung eine Referenz auf das alte Objekt. Wird nun der Befehl zum Rückgängigmachen ausgeführt, kann einfach wieder die Referenz auf das alte Objekt verwendet werden.

Wenn der Spieler Rückgängig auswählt, machen wir den aktuellen Befehl rückgängig und setzen den Zeiger für den aktuellen Befehl auf den zuvor ausgeführten Befehl. Wird Wiederholen ausgewählt, setzen wir den Zeiger auf den nachfolgenden Befehl und führen ihn aus. Verwendet der Benutzer nach dem Rückgängigmachen einen neuen Befehl, werden alle dem aktuellen Befehl folgenden Inhalte der Befehlsliste verworfen.

In Spielen ist ein Wiederholen nicht üblich, sehr wohl aber eine erneute Wiedergabe des Spielablaufs (Replay). Eine naive Implementierung könnte nach jedem Frame den Zustand des gesamten Spiels speichern, allerdings würde das zu viel Speicherplatz erfordern.

Stattdessen zeichnen viele Spiele nach jedem Frame die ausgeführten Befehle auf. Beim Replay muss die Spiel-Engine dann einfach nur die vorher aufgezeichneten Befehle ausführen.

Nachdem ich dieses Verfahren zum ersten Mal in einem Level-Editor implementiert hatte, kam ich mir vor wie ein Genie. Ich war erstaunt, wie unkompliziert es gewesen war und wie reibungslos alles funktionierte. Es erfordert Disziplin, alle Änderungen mittels eines Befehlsobjekts auszuführen, aber wenn das strikt eingehalten wird, ist der Rest wirklich einfach.

2.4  Klassen ohne Funktionen?

Ich habe vorhin erwähnt, dass Befehlsobjekte mit First-Class-Funktionen oder Closures vergleichbar sind. Wenn Sie mit funktionaler Programmierung vertraut sind, werden Sie sich vermutlich fragen, wo die Funktionen abgeblieben sind.

Ich habe die Beispiele auf diese Weise programmiert, weil C++ First-Class-Funktionen kaum unterstützt. Funktionszeiger sind zustandslos, Funktionsobjekte (Funktoren) sind sonderbar und erfordern nach wie vor das Definieren einer Klasse, und die Lambdas in C++11 sind aufgrund der manuellen Speicherverwaltung knifflig.

Das soll nicht heißen, dass Sie beim Command-Pattern in anderen Sprachen auf die Verwendung von Funktionen verzichten sollten. Wenn Ihnen der Luxus echter Closures zur Verfügung steht: Verwenden Sie sie! In mancher Hinsicht emuliert das Command-Pattern Closures in Programmiersprachen, die selbst keine besitzen.

Ich sage hier »in mancher Hinsicht«, weil die Verwendung von Klassen oder Datenstrukturen für Befehlsobjekte auch in Programmiersprachen nützlich ist, die über Closures verfügen. Wenn Ihr Befehlsobjekt mehrere Operationen ausführt (wie beim Rückgängigmachen), lässt sich das nur schwer mit einer einzigen Funktion abbilden.

Die Definition einer Klasse mit Objektfeldern erleichtert es dem Betrachter außerdem zu erkennen, welche Daten das Befehlsobjekt enthält. Closures bieten zwar eine wunderbar kompakte Möglichkeit, um automatisch einen Zustand zu speichern, allerdings geht der Automatismus manchmal so weit, dass kaum erkennbar ist, welchen Zustand sie eigentlich speichern.

Bei der Programmierung eines Spiels in JavaScript, könnte der Spielzug wie folgt aussehen:

function makeMoveUnitCommand(unit, x, y) {
  // Diese Funktion ist das Befehlsobjekt:
  return function() {
    unit.moveTo(x, y);
  }
}

Mittels zweier Closures könnten wir auch ein Rückgängigmachen unterstützen:

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

Wenn Ihnen der funktionale Stil zusagt, ist es naheliegend, die Aufgabe auf diese Weise zu erledigen. Wenn nicht, kann ich nur hoffen, dass dieses Kapitel Ihnen ein wenig weiterhilft. Meiner Ansicht nach zeigt die Brauchbarkeit des Command-Patterns, wie effektiv der funktionale Ansatz bei vielen Aufgaben ist.

2.5  Siehe auch ...

Häufig werden eine ganze Menge Befehlsobjekte benötigt. Um deren Implementierung zu erleichtern, ist es oft nützlich, eine Basisklasse mit einem Bündel bequemer Methoden zu definieren, die das Verhalten der zugehörigen Befehle festlegen. Die execute()-Methode des Befehlsobjekts wird dabei zum Pattern Subclass Sandbox (Unterklassen-Sandbox, Kapitel 12).

In den Beispielen wird der befehlsausführende Akteur ausdrücklich ausgewählt. In manchen Fällen, insbesondere bei hierarchischen Objektmodellen, ist die Sache jedoch nicht so eindeutig. Ein Objekt könnte selbst auf einen Befehl reagieren oder ihn an ein untergeordnetes Objekt weiterleiten. Wenn Sie auf diese Weise vorgehen möchten, verwenden Sie das Pattern Chain of Responsibility (Zuständigkeitskette).

Manche Befehlsobjekte sind zustandslos und besitzen nur ein Verhalten, wie z.B. JumpCommand im ersten Beispiel. In solchen Fällen ist die Verwendung von mehr als einer Instanz der Klasse Speicherverschwendung, weil alle Instanzen gleichwertig sind. Das Pattern Flyweight (Fliegengewicht, Kapitel 3) nimmt sich dieses Problems an.

Man könnte auch ein Singleton (Kapitel 6) daraus machen.

Kapitel 3: Flyweight (Fliegengewicht)

Gemeinsame Nutzung feingranularer Objekte, um sie auch in großer Anzahl effizient einsetzen zu können.

Der Nebel lichtet sich und enthüllt einen majestätisch gewachsenen alten Wald. Unzählige uralte Bäume türmen sich zu einer Kathedrale aus Vegetation auf. Das Blätterdach aus Baumkronen zerstreut das Sonnenlicht in Myriaden goldener Lichtstrahlen und die Reihe der gigantischen Baumstämme erstreckt sich bis zum Horizont.

So sieht die unwirkliche Welt aus, von der wir als Spieleentwickler träumen. Szenarien wie dieses werden durch ein Pattern ermöglicht, dessen Name kaum bescheidener sein könnte: Flyweight (Fliegengewicht).

3.1  Den Wald vor lauter Bäumen nicht sehen

Ein ausgedehntes Waldgebiet mit einigen wenigen Sätzen zu beschreiben und es tatsächlich in Echtzeit in einem Spiel zu implementieren, sind allerdings zwei paar Schuhe. Wenn auf dem Bildschirm ein ganzer Wald aus einzelnen Bäumen dargestellt wird, sieht ein Grafikprogrammierer nichts weiter als Millionen von Polygonen, die er irgendwie binnen einer Sechzigstelsekunde zur GPU schaufeln muss.

Wir sprechen hier von Tausenden von Bäumen mit detaillierter Geometrie, die wiederum aus Tausenden von Polygonen bestehen. Selbst wenn genug Arbeitsspeicher verfügbar ist, um den Wald zu beschreiben, müssen die Daten zwecks Darstellung erst einmal über den Bus ihren Weg von der CPU zur GPU finden.

Jeder Baum umfasst verschiedene Bestandteile:

ein Netz aus Polygonen, das die Form des Stamms, der Zweige und der Blätter beschreibt,

Texturen für die Baumrinde und die Blätter,

die Position und Ausrichtung innerhalb des Waldes,

veränderliche Parameter wie Größe und Farbton, damit jeder Baum anders aussieht.

Wenn man das als Code formuliert, kommt etwa Folgendes dabei heraus:

class Tree
{
private:
  Mesh mesh_;        // Netz
  Texture bark_;     // Rinde
  Texture leaves_;   // Blätter
  Vector position_;  // Position
  double height_;    // Höhe
  double thickness_; // Dicke
  Color barkTint_;   // Farbton Rinde
  Color leafTint_;   // Farbton Blätter
};

Abb. 3.1: Beachten Sie, dass in den kleinen Kästchen bei jedem Baum dasselbe steht.

Das sind eine Menge Daten – insbesondere das Netz und die Texturen sind ziemlich groß. Mit einem ganzen Wald dieser Objekte wird die GPU in einem Frame nicht fertig. Glücklicherweise gibt es jedoch einen altehrwürdigen Trick, um damit zurechtzukommen.

Selbst wenn es Tausende von Bäumen im Wald gibt, sehen sie doch im Wesentlichen alle gleich aus. Wahrscheinlich verwenden alle dasselbe Netz und dieselbe Textur. Und das wiederum bedeutet, dass die meisten Objektfelder all dieser Instanzen identisch sind.

Wer die Kosten dafür tragen wollte, jeden einzelnen Baum des ganzen Waldes individuell von Grafikern entwerfen zu lassen, müsste schon verrückt oder Milliardär sein. Oder beides.

Wir können dies modellieren, indem wir das Objekt in zwei Teile aufteilen. Zuerst sondern wir die Daten ab, die allen Bäumen gemeinsam sind und erstellen eine separate Klasse:

class TreeModel
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};

Das Spiel benötigt nur eine Instanz dieser Klasse, denn es gibt keinen Grund, identische Netze und Texturen tausendfach im Speicher abzulegen. Jede Instanz eines Baumes erhält nur eine Referenz auf die gemeinsam genutzte TreeModel-Instanz.

Dieses Pattern sieht dem Pattern Type Object (Typ-Objekt, Kapitel 13) sehr ähnlich. Beide bringen einen Teil des Objektzustands in einem anderen Objekt unter, das von mehreren Instanzen gemeinsam genutzt wird. Allerdings unterscheiden sich die Ziele, die dadurch erreicht werden sollen.

Bei einem Typ-Objekt soll die Anzahl der Klassen minimiert werden, die definiert werden müssen, indem ein eigenes Objektmodell Typen aufnimmt. Der dadurch eingesparte Speicher ist nur ein erfreulicher Nebeneffekt. Beim Flyweight-Pattern geht es hingegen ausschließlich um die Effektivität.

In der Tree-Klasse verbleiben nur die von Instanz zu Instanz verschiedenen Zustandsdaten:

class Tree
{
private:
  TreeModel* model_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

Und so lässt sich das visualisieren:

Abb. 3.2: Vier Baum-Instanzen teilen sich ein Modell.

Was den Speicherbedarf betrifft, ist nun alles schön und gut, aber beim Rendern hilft das nicht. Bevor der Wald auf dem Bildschirm dargestellt werden kann, muss er zunächst einmal seinen Weg zur GPU finden. Wir müssen diese gemeinsame Ressourcennutzung so formulieren, dass auch die Grafikkarte damit zurechtkommt.

3.2  Tausend Instanzen

Um die an die GPU zu übertragende Datenmenge zu minimieren, dürfen die gemeinsam genutzten Daten – die TreeModel-Instanz – nur einmalig übermittelt werden. Danach werden dann der Reihe nach die den verschiedenen Baum-Instanzen eigenen Daten übertragen, also Position, Farbe und Größe. Und dann sagen wir der GPU: »Verwende dieses Modell zum Rendern jeder einzelnen dieser Instanzen.«

Erfreulicherweise wird diese Funktionalität heutzutage von Grafik-APIs und Grafikkarten unterstützt. Die Details sind umständlich und würden viel zu weit führen, aber sowohl Direct3D als auch OpenGL ermöglichen das sogenannte Instanced Rendering.

Dass diese API direkt auf der Grafikkarte implementiert ist, bedeutet möglicherweise, dass es sich beim Flyweight-Pattern um das einzige hardwareunterstützte Design Pattern der Gang of Four handelt.

Bei beiden APIs müssen Sie zwei Datenströme bereitstellen: Der erste enthält die gemeinsam genutzten Daten, die mehrfach gerendert werden, also Netz und Texturen in unserem Baum-Beispiel. Und der zweite besteht aus einer Liste der Instanzen und deren Parameter, die bei jedem Zeichenvorgang dazu verwendet werden, diesen ersten Datenblock ein wenig anders aussehen zu lassen. So wächst durch einen einzigen Aufruf ein ganzer Wald heran.

3.3  Das Flyweight-Pattern

Auf der Grundlage dieses konkreten Beispiels möchte ich Ihnen im Folgenden das Pattern allgemein vorstellen. Wie die Bezeichnung Fliegengewicht bereits vermuten lässt, kommt es vor allem dann in Betracht, wenn Sie leichtgewichtigere Objekte benötigen – im Allgemeinen deshalb, weil es sehr viele davon gibt. Beim Instanced Rendering geht es weniger darum, dass die Objekte zu viel Speicher belegen, sondern vielmehr darum, dass zu viel Zeit benötigt wird, um sie über den Bus zur GPU zu befördern – die zugrunde liegende Idee ist jedoch dieselbe.

Das Pattern löst das Problem dadurch, dass die Daten eines Objekts in zwei Kategorien eingeteilt werden. Die erste beinhaltet die Daten, die nicht zu einer speziellen Instanz gehören und von allen Instanzen gemeinsam genutzt werden können. Die Gang of Four bezeichnet dies als intrinsischen Zustand, allerdings stelle ich mir das Ganze lieber als »kontextlose« Daten vor.

Die übrigen Daten repräsentieren den extrinsischen Zustand, also die Daten, die sich von Instanz zu Instanz unterscheiden. In diesem Fall sind das die Position, die Größe und die Farbe. Wie im obigen Beispielcode zu sehen, spart das Pattern Speicherplatz, indem es eine einzige Kopie des intrinsischen Zustands bereitstellt, wenn ein Objekt ihn benötigt.

Auf den ersten Blick scheint es sich hier um eine einfache gemeinsame Ressourcennutzung zu handeln, die man wohl kaum als Pattern bezeichnen würde. Das liegt allerdings zum Teil daran, dass wir in diesem Beispiel problemlos eine eindeutig abtrennbare Identität für die gemeinsam genutzten Daten finden konnten, nämlich das TreeModel.

Ich halte den Einsatz dieses Patterns für weniger offensichtlich (und daher für schlauer), wenn es keine wirklich wohldefinierten Kriterien für den Inhalt des gemeinsam genutzten Objekts gibt. In solchen Fällen hat man das Gefühl, dass ein Objekt wie von Zauberhand an mehreren Orten gleichzeitig vorhanden ist. Nehmen wir dazu ein anderes Beispiel.

3.4  Ein Ort, um Wurzeln zu schlagen

Der Grund und Boden, auf dem die Bäume wachsen, muss natürlich ebenfalls in unserem Spiel repräsentiert werden. Dabei kann es sich um Gras, Erde, Hügel, Seen, Flüsse oder was auch immer Sie sich ausdenken handeln. Wir verwenden dazu Kacheln: Die Oberfläche der Welt besteht aus einem riesigen, von kleinen Kacheln bedeckten Gitternetz. Jede Kachel stellt eine bestimmte Art von Gelände dar.

Und jede Geländeform besitzt wiederum bestimmte Merkmale, die Einfluss auf die Spielmechanik haben:

Verschiedene Geländeformen behindern die Fortbewegung des Spielers in unterschiedlichem Maße (dafür verwende ich die Variable MovementCost).

Jede Geländeform besitzt eine Kennzeichnung (Variable isWater), die angibt, ob sie schiffbar ist, also mit einem Boot überquert werden kann.

Alle Geländeformen besitzen eine Textur, die für die Bildschirmdarstellung verwendet wird.

Da wir Spieleentwickler in Bezug auf die Effektivität recht paranoid sind, kommt es natürlich nicht infrage, all diese Zustände in jeder einzelnen Kachel der Spielwelt abzuspeichern.

Das haben Sie ja schon bei den Bäumen kennengelernt.

Stattdessen wird für die Geländetypen üblicherweise eine Enumeration verwendet:

enum Terrain
{
  TERRAIN_GRASS,
  TERRAIN_HILL,
  TERRAIN_RIVER
  // Weitere Geländeformen ...
};

In der Spielwelt kommt dann ein damit bestücktes Gitternetz zum Einsatz:

class World
{
private:
  Terrain tiles_[WIDTH][HEIGHT];
};

Ich nutze hier ein zweidimensionales Array zum Speichern des Gitternetzes. In C/C++ ist das effizient, denn alle Elemente werden am Stück im Speicher abgelegt. In Java oder anderen Programmiersprachen mit Garbage Collection erhält man allerdings ein Array mit Referenzen auf die einzelnen Spalten, was möglicherweise nicht so speichereffizient ist wie erwünscht.

In beiden Fällen würde man dieses Implementierungsdetail in einem »echten« Programm am besten hinter einer eleganten 2D-Gitternetz-Datenstruktur verbergen. Ich verwende hier jedoch der Einfachheit halber das Array.

Um an die eigentlichen Daten einer Kachel zu gelangen, geht man wie folgt vor:

int World::getMovementCost(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return 1;
    case TERRAIN_HILL:  return 3;
    case TERRAIN_RIVER: return 2;
      // Weitere Geländeformen ...
  }
}

bool World::isWater(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return false;
    case TERRAIN_HILL:  return false;
    case TERRAIN_RIVER: return true;
      // Weitere Geländeformen ...
  }
}

Sie verstehen schon. Das funktioniert zwar, aber es gefällt mir nicht – immerhin sind MovementCost und isWater doch Daten, die das Gelände beschreiben. Hier sind diese Informationen jedoch im Code eingebettet. Und noch schlimmer ist, dass die Daten zu einer bestimmten Geländeform auf mehrere verschiedene Methoden verteilt sind. Es wäre schön, wenn sie stattdessen zusammen verkapselt würden – dafür sind Objekte schließlich da.

Warum also nicht eine Klasse wie die folgende verwenden?

class Terrain
{
public:
  Terrain(int moveCost, bool isWater,
          Texture texture)
  : moveCost_(moveCost),
    isWater_(isWater),
    texture_(texture)
  {}

  int getMoveCost() const { return moveCost_; }
  bool isWater() const { return isWater_; }
  const Texture& getTexture() const
  {
    return texture_;
  }

private:
  int moveCost_;
  bool isWater_;
  Texture texture_;
};

Sie werden bemerkt haben, dass alle Methoden vom Typ const sind – und das ist kein Zufall. Da dasselbe Objekt in verschiedenen Kontexten verwendet wird, würden Änderungen daran an mehreren Orten gleichzeitig in Erscheinung treten.

Das ist allerdings oft unerwünscht. Die Optimierungsmaßnahme, zum Einsparen von Speicherplatz Objekte gemeinsam zu nutzen, sollte das sichtbare Verhalten des Programms nicht beeinflussen.

Wir möchten aber auch nicht den Preis zahlen, für jede Kachel der Welt eine Instanz dieser Klasse erstellen zu müssen. Wenn Sie sich die Klasse genauer ansehen, werden Sie feststellen, dass sie keine Informationen darüber enthält, wo sich die Kachel befindet. In der Flyweight-Terminologie heißt das, der Zustand einer Terrain-Instanz ist komplett »intrinsisch« oder »kontextlos«.

Angesichts dessen gibt es keinen Grund, mehr als eine davon für jede Geländeform anzulegen. Alle Gras-Kacheln sind identisch. Statt eines Gitternetzes von Enumerationen oder Terrain-Objekten verwenden wir ein Gitternetz aus Zeigern auf Terrain-Objekte:

class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];
  // Weiterer Code ...
};

Alle Kacheln, die dasselbe Gelände verwenden, besitzen einen Zeiger, der auf dieselbe Terrain-Instanz verweist:

Abb. 3.3: Wiederverwendung von Terrain-Objekten durch eine Reihe von Kacheln

Da die Terrain-Instanzen an verschiedenen Orten verwendet werden, wäre die Handhabung ihrer Lebensdauer bei dynamischer Allokation etwas kompliziert. Stattdessen speichern wir sie direkt in der Welt:

class World
{
public:
  World()
  : grassTerrain_(1, false, GRASS_TEXTURE),
    hillTerrain_(3, false, HILL_TEXTURE),
    riverTerrain_(2, true, RIVER_TEXTURE)
    {}

private:
  Terrain grassTerrain_;
  Terrain hillTerrain_;
  Terrain riverTerrain_;
  // Weiterer Code ...
};

Nun können wir wie folgt Landschaften anlegen:

void World::generateTerrain()
{
  // Boden mit Gras bedecken
  for (int x = 0; x < WIDTH; x++)
  {
    for (int y = 0; y < HEIGHT; y++)
    {
      // Ein paar Hügel verstreuen
      if (random(10) == 0)
      {
        tiles_[x][y] = &hillTerrain_;
      }
      else
      {
        tiles_[x][y] = &grassTerrain_;
      }
    }
  }

  // Einen Fluss anlegen
  int x = random(WIDTH);
  for (int y = 0; y < HEIGHT; y++) {
    tiles_[x][y] = &riverTerrain_;
  }
}

Statt World-Methoden zum Zugriff auf die Terrain-Eigenschaften zu verwenden, können wir das Terrain-Objekt direkt bereitstellen:

const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}

Ich räume gern ein, dass es sich hier nicht um den weltbesten prozeduralen Algorithmus zur Geländeerzeugung handelt.

Auf diese Weise ist World nicht mehr an alle möglichen Details des Geländes gekoppelt. Wenn Sie an den Eigenschaften einer Kachel interessiert sind, können Sie sie direkt von dem Objekt abfragen:

int cost = world.getTile(2, 3).getMovementCost();

Wir können wieder die gefällige API zum Umgang mit echten Objekten verwenden, und zwar fast ohne Mehraufwand, denn ein Zeiger belegt oft gar nicht mehr Speicher als eine Enumeration.

3.5  Und die Performance?

Ich sagte eben »fast«, weil die Erbsenzähler zu Recht wissen wollen, wie sich die Performance im Vergleich zu einer Enumeration verhält. Die Referenzierung des Geländes mithilfe von Zeigern beinhaltet eine indirekte Suche. Um wie oben an die Geländedaten zu gelangen, muss im Gitternetz zunächst das Terrain-Objekt aufgesucht werden, auf das der Zeiger verweist. Dann muss getMovementCost() aufgerufen werden. Auf diese Weise einem Zeiger zu folgen, kann allerdings einen Cache-Miss verursachen, der die Sache verlangsamt.

Mehr zu Zeigern und Cache-Trefferraten erfahren Sie im Kapitel 17, Data Locality (Datenlokalität).

Hier gilt wieder die Goldene Regel der Optimierung: Nehmen Sie zunächst eine Messung vor. Moderne Computerhardware ist so kompliziert, dass die Performance keine Frage des reinen Nachdenkens mehr ist. Beim Schreiben dieses Kapitels habe ich Tests durchgeführt, die keinen Performanceunterschied zwischen Flyweights und Enumeration ergeben haben. Tatsächlich waren Flyweights sogar spürbar schneller, aber das hängt vollkommen davon ab, wie andere Dinge im Speicher abgelegt werden.

Ich bin mir aber sicher, dass man Flyweights keinesfalls rundweg ablehnen sollte. Sie bieten alle Vorteile eines objektorientierten Stils, ohne den mit vielen Objekten einhergehenden Aufwand. Wenn Sie feststellen, dass Sie Enumerationen mit vielen switch-Anweisungen verwenden, sollten Sie dieses Pattern in Betracht ziehen. Falls Ihnen die Performance Schwierigkeiten bereitet, sollten Sie zumindest Messungen vornehmen, bevor Sie sich auf einen Programmierstil einlassen, der schlechter zu warten ist.

3.6  Siehe auch ...

Im Kachel-Beispiel haben wir eifrig für jede Geländeform Instanzen erstellt und sie in World gespeichert. Das erleichtert den Zugriff und die Wiederverwendung der gemeinsam genutzten Instanzen. In vielen Fällen ist es jedoch wünschenswert, nicht sämtliche Flyweights vorab zu erzeugen.

Wenn nicht vorhersehbar ist, welche Flyweights tatsächlich benötigt werden, ist es besser, sie bei Bedarf zu erzeugen. Um in den Genuss der gemeinsamen Nutzung zu kommen, können Sie vorher überprüfen, ob bereits ein Flyweight dieser Art erzeugt wurde. Wenn dem so ist, geben Sie die bereits vorhandene Instanz zurück.

Für gewöhnlich muss dann die Erzeugung gekapselt und hinter einem Interface verborgen werden, die zunächst nach einem bereits vorhandenen Flyweight sucht. Einen Konstruktor auf diese Weise zu verbergen, ist ein Beispiel für das Pattern Factory Method (Fabrikmethode).

Um schon vorhandene Flyweights zurückgeben zu können, müssen Sie den Pool der bereits instanziierten Flyweights irgendwie handhaben. Wie der Name schon sagt, könnte sich zum Speichern der Flyweights ein Object Pool (Objektpool, Kapitel 19) als nützlich erweisen.

Wenn Sie das Pattern State (Zustand, Kapitel 7) verwenden, haben Sie es häufig mit Zustandsobjekten zu tun, die keine speziell für den Automaten, der diesen Zustand annimmt, ausgelegten Datenfelder besitzen. Das Objekt an sich und dessen Methoden sind allein schon von Nutzen. In diesem Fall können Sie das Flyweight-Pattern anwenden und dieselbe Zustandsinstanz problemlos in mehreren Zustandsautomaten gleichzeitig verwenden.

Kapitel 4: Observer (Beobachter)

Definition einer 1-zu-n-Abhängigkeit zwischen Objekten, damit im Fall einer Zustandsänderung eines Objekts alle davon abhängigen Objekte entsprechend benachrichtigt und automatisch aktualisiert werden.

Es ist kaum möglich, mit einem Stein nach einem Computer zu werfen, ohne dabei eine Anwendung zu treffen, die gemäß der MVC-Architektur (Model-View-Controller) entwickelt wurde, der das Observer-Pattern zugrunde liegt. Das Observer-Pattern ist so allgegenwärtig, dass es in Javas Hauptbibliothek (java.util.Observer) zu finden und in C# sogar Bestandteil der Sprache ist (in Form des Schlüsselworts event).

Wie so viele Dinge im Softwareumfeld wurde MVC in den 70er-Jahren von Smalltalkern erfunden. Womöglich behauptet die Lisp-Fraktion allerdings, sie hätte es bereits in den 60ern benutzt und sich bloß nicht die Mühe gemacht, es zu dokumentieren.

4.1  Erzielte Leistungen

Nehmen wir an, wir möchten unserem Spiel ein System hinzufügen, das die erzielten Leistungen und Errungenschaften, kurz die Achievements ausweist. Dieses »Achievement-System« soll ein Dutzend verschiedener Badges (Abzeichen) enthalten, die freigeschaltet werden, sobald ein bestimmter Meilenstein erreicht wird, beispielsweise »100 Dämonen gemeuchelt«, »Von einer Brücke gestürzt« oder »Abschluss eines Levels nur mit einem toten Nerz bewaffnet«.

Abb. 4.1: Unbeabsichtigte doppelte Bedeutung

Eine saubere Implementierung ist knifflig, weil es so viele verschiedene Badges gibt, die durch alle möglichen Ereignisse freigeschaltet werden. Wenn wir nicht aufpassen, wird sich das Achievement-System wie eine Schlingpflanze bis in den dunkelsten Winkel unserer Codebasis ausbreiten. Sicher, »Von einer Brücke gestürzt« steht irgendwie in Verbindung zur Physik-Engine, aber wollen wir wirklich inmitten der linearen Algebra des Kollisionserkennungsalgorithmus einen Aufruf wie unlockFallOffBridge() sehen?

Hier handelt es sich um eine rhetorische Frage. Kein Programmierer einer Physik-Engine, der noch einen Funken Selbstachtung besitzt, würde seine wundervolle Mathematik mit etwas so Prosaischem wie dem Gameplay (Spielmechanik) verunglimpfen!

Der Code, der sich mit einer bestimmten Facette des Spiels befasst, soll wie immer an einer Stelle zusammengefasst werden. Das Problem ist hier, dass die Achievements durch einen ganzen Haufen verschiedener Aspekte des Gameplays freigeschaltet werden. Wie also lässt sich das erreichen, ohne den Code des Achievement-Systems mit all diesen Aspekten zu koppeln?

Zu diesem Zweck gibt es das Observer-Pattern. Es ermöglicht dem Code zu verkünden, dass etwas Interessantes vorgefallen ist, ohne sich darum zu kümmern, wer darüber benachrichtigt wird.

In der Physik-Engine gibt es beispielsweise für die Gravitation zuständigen Code, der darauf achtet, welche der Figuren sich auf flachen Oberflächen ausruhen und welche dem sicheren Dahinscheiden entgegenstürzen. Um das »Von einer Brücke gestürzt«-Badge freizuschalten, könnten wir den Code dort einfügen, aber das wäre ein Chaos. Stattdessen verwenden wir das hier:

void Physics::updateEntity(Entity& entity)
{
  bool wasOnSurface = entity.isOnSurface();
  entity.accelerate(GRAVITY);
  entity.update();
  if (wasOnSurface && !entity.isOnSurface())
  {
    notify(entity, EVENT_START_FALL);
  }
}

Dieser Code verkündet sozusagen: »Keine Ahnung, ob das jemanden interessiert, aber das Ding hier ist gerade abgestürzt. Macht was draus.«

Die Physik-Engine muss entscheiden, welche Benachrichtigungen versendet werden und ist daher nicht vollständig entkoppelt. Bei der Systemarchitektur geht es aber meistens nicht darum, Systeme perfekt zu machen, sondern darum, sie zu verbessern.

Das Achievement-System abonniert diese Benachrichtigungen und wird informiert, sobald die Physik-Engine etwas vermeldet. Es kann dann überprüfen, ob es sich bei dem fallenden Körper um den nicht sonderlich eleganten Helden handelt und ob sein Aufenthaltsort vor dieser unerfreulichen Begegnung mit der klassischen Mechanik eine Brücke war. Ist dem so, wird das entsprechende Badge mit dem dazugehörigen Feuerwerk und einer Fanfare freigeschaltet. All das geschieht ohne Beteiligung der Physik-Engine.

Tatsächlich können wir die verschiedenen Badges beliebig ändern oder sogar das gesamte Achievement-System entfernen, ohne eine einzige Zeile des Codes der Physik-Engine anzufassen. Sie sendet weiterhin Benachrichtigungen, ohne davon Notiz zu nehmen, dass niemand sie empfängt.

Wenn das Achievement-System dauerhaft entfernt wird und niemand sonst den Benachrichtigungen der Physik-Engine Beachtung schenkt, könnten wir natürlich auch den Code zum Versenden der Benachrichtigungen löschen – während der Weiterentwicklung des Spiels ist es allerdings recht angenehm, über diese Flexibilität zu verfügen.

4.2  Funktionsweise

Wenn Sie nicht schon wissen, wie das Pattern implementiert wird, könnten Sie es vermutlich anhand der voranstehenden Beschreibung erraten. Der Einfachheit halber fasse ich den Vorgang jedoch im Folgenden kurz zusammen.

4.2.1  Der Observer

Fangen wir mit der neugierigen Klasse an, die davon informiert werden will, wenn andere Objekte interessante Dinge tun. Diese wissbegierigen Objekte werden folgendermaßen definiert:

class Observer
{
public:
  virtual ~Observer() {}
  virtual void onNotify(const Entity& entity,
                        Event event) = 0;
};

Jede konkrete Klasse, die onNotify() implementiert, wird zu einem Observer. In unserem Beispiel handelt es sich um das Achievement-System, also ergibt sich Folgendes:

class Achievements : public Observer
{
public:
  virtual void onNotify(const Entity& entity,
                        Event event)
  {
    switch (event)
    {
    case EVENT_ENTITY_FELL:
      if (entity.isHero() && heroIsOnBridge_)
      {
        unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
      } break;

      // Andere Ereignisse verarbeiten ...
      // heroIsOnBridge_ aktualisieren ...
    }

private:
  void unlock(Achievement achievement)
  {
   // Freischalten, falls noch nicht geschehen ...
  }

  bool heroIsOnBridge_;
};

Die Parameter für onNotify() bleiben Ihnen überlassen. Es heißt schließlich Observer-Pattern und nicht Observer-»Fertiger Code zum Einfügen in den Spielcode«. Typische Parameter sind das Objekt, das die Benachrichtigung versendet und ein allgemeiner »Daten«-Parameter, dem Sie zusätzliche Informationen zuweisen können.

Wenn Sie in einer Sprache programmieren, die Generics (parametrisierte Typen) oder Templates unterstützt, könnten Sie sie hier verwenden, es ist aber problemlos möglich, die Parameter für den eigenen Bedarf maßzuschneidern. Ich verwende hier einfach eine Spielfigur und eine Enumeration, die das Vorkommnis beschreibt.

4.2.2  Das Subjekt

Die Methode zur Benachrichtigung wird vom beobachteten Objekt aufgerufen. In der Gang-of-Four-Terminologie wird dieses Objekt als »Subjekt« bezeichnet und hat zwei Aufgaben. Zunächst einmal führt es in Form einer Liste Buch über die Observer, die mit einer Engelsgeduld auf eine Nachricht von ihm warten:

class Subject
{
private:
  Observer* observers_[MAX_OBSERVERS];
  int numObservers_;
};

In echtem Code würden Sie eine Collection-Klasse mit dynamischer Größe statt eines einfachen Arrays verwenden. Den Leuten zuliebe, die andere Programmiersprachen verwenden und die C++-Standardbibliothek nicht kennen, bleibe ich an dieser Stelle jedoch bei den Grundlagen.

Das entscheidende Detail ist hier, dass das Subjekt eine öffentliche API zum Ändern dieser Liste bereitstellt:

class Subject
{
public:
  void addObserver(Observer* observer)
  {
    // Zum Array hinzufügen ...
  }

  void removeObserver(Observer* observer)
  {
    // Aus dem Array entfernen ...
  }

  // Weiterer Code ...
};

Außenstehender Code kann somit steuern, wer Benachrichtigungen erhält. Das Subjekt kommuniziert zwar mit den Observern, ist jedoch nicht an sie gekoppelt. In unserem Beispiel werden Achievements mit keinem Wort in der Physik-Engine erwähnt, dennoch kann sie mit dem Achievement-System kommunizieren. Das ist der Clou bei diesem Pattern.

Außerdem ist zu beachten, dass das Subjekt nicht nur über einen Observer, sondern über eine Liste von Observern verfügt. Dadurch ist gewährleistet, dass die Observer nicht implizit untereinander gekoppelt werden. Nehmen wir beispielsweise an, die Audio-Engine soll ebenfalls vom Absturz benachrichtigt werden, um einen passenden Klang abzuspielen. Würde das Subjekt lediglich einen Observer unterstützen, erhielte das Achievement-System keine Benachrichtigungen mehr, sobald sich die Audio-Engine für Benachrichtigungen registrieren würde.

Die beiden Systeme würden einander in die Quere kommen, und zwar auf besonders unschöne Weise, weil das zweite das erste deaktivieren würde. Durch die Verwendung einer Liste ist dagegen sichergestellt, dass alle Observer voneinander unabhängig behandelt werden. Den Observern selbst müssen die anderen Observer nicht bekannt sein.

Die zweite Aufgabe des Subjekts ist das Versenden von Benachrichtigungen:

class Subject
{
protected:
  void notify(const Entity& entity, Event event)
  {
    for (int i = 0; i < numObservers_; i++)
    {
      observers_[i]->onNotify(entity, event);
    }
  }

  // Weiterer Code ...
};

Beachten Sie, dass der Code davon ausgeht, dass die Observer die Liste in ihrer onNotify()-Methode nicht modifizieren. Eine solidere Implementierung würde das entweder verhindern oder konkurrierende Änderungen geschickter handhaben.

4.2.3  Beobachtung der Physik-Engine

Nun müssen wir das Ganze nur noch in die Physik-Engine einbauen, damit sie Benachrichtigungen versenden und das Achievement-System sich bei ihr registrieren kann. Wir bleiben in diesem Fall dicht am originalen Rezept der Design Patterns und beerben die Klasse Subject:

class Physics : public Subject
{
public:
  void updateEntity(Entity& entity);
};

Bei echtem Code würde ich hier auf die Vererbung verzichten und Physics eine Subject-Instanz zuweisen. Statt die Physik-Engine selbst zu observieren, wäre das Subjekt ein eigenes »Absturz«-Objekt. Die Observer könnten sich dann mit physics.entityFell().addObserver(this); registrieren.

Meiner Ansicht nach macht das den Unterschied zwischen »Observer«- und »Event«-, also ereignisgesteuerten Systemen aus: Bei einem »Observer«-System beobachten Sie das Objekt, das etwas Interessantes tut, bei einem »Event«-System hingegen wird ein Objekt beobachtet, das dieses interessante Ereignis repräsentiert.

Die notify()-Methode der Subject-Klasse ist dann vom Typ protected (geschützt). Auf diese Weise kann die abgeleitete Physik-Engine-Klasse sie aufrufen, um Benachrichtigungen zu versenden, außenstehender Code jedoch nicht. addObserver() und removeObserver() hingegen sind öffentlich (public), damit sich andere auf die Physik-Engine zugreifende Objekte als Observer registrieren können.

Wenn die Physik-Engine nun etwas Beachtenswertes zu vermelden hat, ruft sie wie im Beispiel notify() auf, das alle Observer auf seiner Liste davon informiert.

Abb. 4.2: Ein Subjekt und die Liste der Observer-Referenzen

Ziemlich einfach, oder? Es gibt nur eine Klasse, die eine Liste von Zeigern auf Observer-Instanzen verwaltet. Kaum zu glauben, dass etwas so Unkompliziertes die Grundlage der Kommunikation zahlloser Programme und Frameworks ist.

Aber auch am Observer-Pattern gibt es etwas zu nörgeln. Wenn ich andere Spieleentwickler frage, was sie davon halten, liefern sie mir in der Regel schon ein paar Beschwerden. Sehen wir uns einmal an, was sich dagegen unternehmen lässt.

4.3  »Das ist zu langsam!«

Das bekomme ich oft zu hören, meistens von Programmierern, die mit dem Pattern kaum vertraut sind. Sie gehen stillschweigend davon aus, dass alles, was irgendwie nach einem Design Pattern riecht, haufenweise Klassen, indirekte Zugriffe und andere kreative Möglichkeiten zur Vergeudung von CPU-Zeit mit sich bringt.

Aus diesem Grund halte ich es für so wichtig, Patterns zu dokumentieren. Wenn man sich auf unklare Terminologie einlässt, geht die Möglichkeit verloren, kurz und knapp miteinander zu kommunizieren. Sie sagen »Observer«, Ihr Gegenüber versteht jedoch »Events« oder »Messaging« (Meldungen), weil sich entweder niemand darum gekümmert hat, den Unterschied zu dokumentieren, oder weil die Dokumentation nicht gelesen wurde.

Darum gibt es dieses Buch. Es enthält auch ein Kapitel zu den Grundlagen von Ereignissen und Benachrichtigungen (Kapitel 15, Event Queue (Ereigniswarteschlange)).

Das Observer-Pattern besitzt hier einen besonders schlechten Ruf, weil es immer im Zusammenhang mit so zwielichtigen Erscheinungen wie »Events«, »Messages« und sogar »Data Binding« (Datenbindung) erwähnt wird. Einige dieser Systeme sind tatsächlich sehr langsam (wenn auch nicht selten gewollt und aus gutem Grund). Hier kommen dann Dinge wie Warteschlangen oder dynamische Allokationen von Benachrichtigungen ins Spiel.

Sie kennen jetzt die eigentliche Implementierung des Patterns und wissen, dass das alles gar nicht stimmt. Das Versenden einer Benachrichtigung erfordert nur das Durchlaufen einer Liste und den Aufruf einiger virtueller Methoden. Das geht zugegebenermaßen etwas langsamer als ein statischer Aufruf vonstatten, ist aber andererseits vernachlässigbar, wenn es nicht um Code geht, bei dem auch das letzte bisschen Performance herausgeholt werden muss.

Dieses Pattern lässt sich ohnehin am besten dort anwenden, wo es nicht auf Performance ankommt, daher kann man sich für gewöhnlich dynamische Aufrufe leisten. Davon abgesehen gibt es praktisch keinerlei Aufwand. Es müssen weder Objekte instanziiert noch Warteschlangen eingerichtet werden, es handelt sich lediglich um eine Umleitung durch einen synchronen Methodenaufruf.

4.3.1  Oder ist es doch zu schnell?

Das Observer-Pattern arbeitet synchron, daher ist tatsächlich Vorsicht geboten: Das Subjekt ruft seine Observer direkt auf, und das bedeutet, dass es seine eigenen Aufgaben erst wieder wahrnimmt, wenn alle Observer ihre onNotify()-Methoden ausgeführt haben. Ein langsamer Observer kann also das Subjekt lahmlegen.

Das klingt zwar erschreckend, in der Praxis bedeutet es aber auch nicht gleich das Ende der Welt. Sie müssen sich des Problems nur bewusst sein. Entwickler von Benutzeroberflächen, die seit Ewigkeiten ereignisgesteuerte Programmierung wie diese verwenden, haben dafür eine abgedroschene Parole: »Finger weg vom UI-Thread!«

Wenn Sie Ereignisse synchron verarbeiten, muss das schnellstmöglich geschehen, damit die Benutzeroberfläche nicht blockiert wird. Wenn Sie zeitraubende Aufgaben erledigen müssen, sollten Sie einen eigenen Thread oder eine Warteschlange verwenden.

Die gleichzeitige Verwendung von Observer und Threading mit Blockierungen ist nicht ungefährlich. Wenn ein Observer versucht, auf vom Subjekt gesperrte Daten zuzugreifen, kann das in eine Sackgasse führen und das Spiel komplett außer Gefecht setzen. Bei einer Engine, die in hohem Maße Threading verwendet, ist es oft besser, stattdessen eine Ereigniswarteschlange (Kapitel 15, Event Queue) mit asynchroner Kommunikation einzusetzen.

4.4  »Zu viele dynamische Allokationen«

Ganze Sippschaften des Entwicklervölkchens, inklusive vieler Spieleprogrammierer, verwenden inzwischen Programmiersprachen mit automatischer Speicherbereinigung (Garbage Collection), daher sind dynamische Allokationen nicht mehr das Schreckgespenst, das sie früher einmal waren. Bei performancekritischer Software wie Spielen sind Speicherallokationen allerdings nach wie vor von Bedeutung, selbst bei Sprachen mit Garbage Collection. Dynamische Allokationen kosten Zeit, ebenso wie es Zeit kostet, belegten Speicher wieder freizugeben – auch dann, wenn dies automatisch geschieht.

Viele Spieleentwickler sorgen sich weniger um Allokationen als um die Fragmentierung. Wenn Ihr Spiel zwecks Zertifizierung tagelang ununterbrochen ohne Absturz laufen muss, kann ein zunehmend fragmentierter Heap die Auslieferung der Software verhindern.

Mehr dazu erfahren Sie im Kapitel Object Pool (Objektpool, Kapitel 19) das auch eine gängige Methode vorstellt, Fragmentierung zu verhindern.

In den vorhergehenden Beispielen habe ich der Einfachheit halber ein Array verwendet. In der Praxis handelt es sich bei der Observer-Liste fast immer um eine dynamisch allozierte Datenstruktur, die wächst und schrumpft, wenn Observer hinzugefügt bzw. entfernt werden. Manchen Leuten ist dieses »Herumrühren« im Speicher allerdings nicht ganz geheuer.

Zunächst einmal ist festzustellen, dass nur beim Hinzufügen eines Observers Speicher reserviert wird. Beim Senden einer Benachrichtigung ist das überhaupt nicht erforderlich – hierbei handelt es sich ja nur um einen Methodenaufruf. Wenn Sie die Observer beim Start des Spiels hinzufügen und sie dann mehr oder weniger in Ruhe lassen, bleiben die dynamischen Allokationen auf ein Minimum beschränkt.

Für den Fall, dass Ihnen diese Vorgehensweise dennoch Schwierigkeiten bereitet, werde ich Ihnen nun eine Methode vorführen, mit der sich Observer ganz ohne dynamische Allokationen hinzufügen und entfernen lassen.

4.4.1  Verkettete Observer

In den bisherigen Codebeispielen nutzte Subject eine Liste von Zeigern auf die Observer, von denen es beobachtet wurde. Die Observer-Klasse selbst besaß keine Referenz auf diese Liste. Es handelte sich um ein rein virtuelles Interface, das im Allgemeinen einer konkreten, zustandsbehafteten Klasse vorzuziehen ist.

Wenn wir es jedoch zulassen, dass der Observer einen Zustand speichert, können wir auf dynamische Allokationen verzichten. Die Observer selbst sollen die Liste des Subjekts repräsentieren. Statt im Subjekt eine List von Zeigern auf die Observer zu speichern, machen wir aus den Observern Elemente einer verketteten Liste:

Abb. 4.3: Das Subjekt verfügt über einen Zeiger auf eine verkettete Liste von Observern.

Zwecks Implementierung müssen wir zunächst das Array aus der Subject-Klasse entfernen und durch einen Zeiger auf den Anfang der verketteten Observer-Liste ersetzen:

class Subject
{
  Subject()
  : head_(NULL)
  {}

  // Methoden ...

private:
  Observer* head_;
};

Dann erweitern wir die Observer-Klasse um einen Zeiger auf den nächsten Observer der Liste:

class Observer
{
  friend class Subject;

public:
  Observer()
  : next_(NULL)
  {}

  // Weiterer Code ...

private:
  Observer* next_;
};

Außerdem machen wir Subject hier zu einer Friend-Klasse. Die API zum Hinzufügen und Entfernen von Observern stellt zwar die Subject-Klasse bereit, aber die zur Verwaltung der Observer verwendete Liste ist nun Bestandteil der Observer-Klasse selbst. Die Subject-Klasse als friend-Klasse zu deklarieren, ist die einfachste Methode, ihr Zugriff auf diese Liste zu gewähren.

Zum Registrieren eines neuen Observers muss er bloß der Liste hinzugefügt werden. Wir wählen hierzu die einfachste Möglichkeit und fügen ihn am Anfang der Liste ein:

void Subject::addObserver(Observer* observer)
{
  observer->next_ = head_;
  head_ = observer;
}

Die andere Möglichkeit wäre es, den Observer am Ende der Liste zu ergänzen, was die Sache jedoch ein wenig verkompliziert: Die Subject-Klasse müsste die Liste zunächst von Anfang bis Ende durchlaufen oder einen zusätzlichen Zeiger tail_ verwenden, der stets auf das letzte Element der Liste verweist.

Insofern ist es einfacher, den Observer am Anfang der Liste hinzuzufügen, aber auch dabei gibt es eine Nebenwirkung: Wenn wir die Liste durchlaufen, um allen Observern eine Benachrichtigung zu senden, wird der zuletzt hinzugefügte Observer zuerst benachrichtigt. Wenn Sie also die Observer A, B und C in dieser Reihenfolge registrieren, werden sie in der Reihenfolge C, B, A benachrichtigt.

Theoretisch spielt das keine Rolle: Zwei Observer, die dasselbe Objekt beobachten, sollten grundsätzlich nicht davon abhängig sein, in welcher Reihenfolge sie benachrichtigt werden. Falls die Reihenfolge aber doch von Bedeutung ist, haben Sie es mit einer subtilen Kopplung zu tun, die Ihnen noch Schwierigkeiten bereiten könnte.

Schauen wir uns nun auch das Entfernen an:

void Subject::removeObserver(Observer* observer)
{
  if (head_ == observer)
  {
    head_ = observer->next_;
    observer->next_ = NULL;
    return;
  }

  Observer* current = head_;
  while (current != NULL)
  {
    if (current->next_ == observer)
    {
      current->next_ = observer->next_;
      observer->next_ = NULL;
      return;
    }

    current = current->next_;
  }
}

Wie Sie hier sehen, muss beim Entfernen von Elementen aus einer verketteten Liste das erste Element als unschöner Spezialfall besonders berücksichtigt werden. Es gibt aber noch eine elegantere Lösung, die einen Zeiger auf einen Zeiger verwendet.

Ich verzichte an dieser Stelle jedoch auf deren Einsatz, weil sie erfahrungsgemäß die meisten Leute verwirrt. Dennoch ist es eine lohnende Übung, sie einmal auszuprobieren, weil man dadurch die Funktionsweise von Zeigern wirklich gut verstehen lernt.

Da es sich hier um eine einfach verkettete Liste handelt, müssen wir sie durchlaufen, um den Observer zu finden, der entfernt werden soll. Das wäre bei einem einfachen Array ebenso. Bei einer doppelt verketteten Liste verfügt jedes Element über Zeiger auf seinen Vorgänger und seinen Nachfolger. Das Löschen eines Observers findet dann innerhalb einer konstanten Zeitspanne statt. In der Praxis würde ich es so machen.

Nun fehlt nur noch das Versenden der Benachrichtigung. Dazu muss nur die Liste durchlaufen werden:

void Subject::notify(const Entity& entity,
                     Event event)
{
  Observer* observer = head_;
  while (observer != NULL)
  {
    observer->onNotify(entity, event);
    observer = observer->next_;
  }
}

In diesem Beispiel wird beim Durchlaufen der Liste jeder Observer einzeln benachrichtigt. Dadurch ist gewährleistet, dass alle Observer gleichwertig und unabhängig voneinander behandelt werden.

Man könnte dieses Verfahren auch dahingehend ändern, dass der benachrichtigte Observer einen Wert zurückgibt, der signalisiert, ob das Subjekt die Liste weiter abarbeiten oder anhalten soll. Wenn Sie das so handhaben, sind Sie schon ziemlich nah am Pattern Chain of Responsibility (Zuständigkeitskette) dran.

Gar nicht schlecht, oder? Das Subjekt kann beliebig viele Observer verwenden – vollkommen ohne dynamische Allokationen. Das Hinzufügen bzw. Entfernen von Observern funktioniert genauso schnell wie mit einem einfachen Array. Ein kleines Feature haben wir allerdings doch geopfert.

Da wir das Observer-Objekt selbst als Listenelement verwenden, kann es auch nur zur Observer-Liste eines einzigen Subjekts gehören. Mit anderen Worten: Ein Observer kann jeweils nur ein einziges Subjekt beobachten. Bei der herkömmlichen Implementierung besitzt hingegen jedes Subjekt eine unabhängige eigene Liste und ein Observer kann sich gleichzeitig in mehreren dieser Listen befinden.

Womöglich können Sie mit dieser Einschränkung leben. Es kommt meiner Meinung nach häufiger vor, dass mehrere Observer zu einem Subjekt gehören als umgekehrt. Sollte es dennoch ein Problem für Sie darstellen, steht Ihnen noch eine weitere, etwas kompliziertere Lösung zur Verfügung, die ebenfalls ohne dynamische Allokationen auskommt. Sie ist zu umfassend, um sie ausführlicher in dieses Kapitel hineinzuquetschen, ich werde im Folgenden trotzdem versuchen, sie kurz zu umreißen und überlasse es Ihnen, die Lücken zu füllen ...

4.4.2  Ein Pool von Listenknoten

Wie zuvor verwendet jedes Subjekt eine verkettete Liste von Observern, allerdings sind die Listenelemente nicht mehr die Observer-Objekte selbst. Stattdessen werden eigenständige kleine »Listenknoten«-Objekte verwendet, die sowohl einen Zeiger auf den Observer als auch einen Zeiger auf den nachfolgenden Knoten enthalten.

Abb. 4.4: Ein Subjekt und eine verkettete Liste von Knoten, die auf Observer verweisen

Es gibt zwei Arten von verketteten Listen. Diejenige, die Sie vermutlich aus der Schule kennen, besteht aus Knoten, die Daten enthalten. Im vorletzten Beispiel ist es umgekehrt: Die Daten (in diesem Fall die Observer) enthalten die Knoten (also die next_-Zeiger).

Letztere werden als »intrusive« verkettete Listen bezeichnet, weil sich die Verwendung eines Objekts in einer Liste sozusagen in die Definition des Objekts selbst »einmischt«. Dadurch sind intrusive Listen zwar weniger flexibel, aber – wie Sie gesehen haben – auch effizienter. Sie werden beispielsweise im Linux-Kernel eingesetzt, weil es dort Sinn ergibt, diesen Kompromiss einzugehen.

Da mehrere Knoten auf denselben Observer verweisen können, kann sich auch ein Observer zugleich in den Listen mehrerer Subjekte befinden. Es ist also wieder möglich, mehrere Subjekte gleichzeitig zu beobachten.

Der Verzicht auf dynamische Allokationen ist einfach: Da alle Knoten von gleicher Art und Größe sind, reservieren Sie einfach einen Objektpool (Object Pool, Kapitel 19). Damit verfügen Sie über einen Pool fester Größe, der Ihnen Listenknoten bereitstellt, die Sie nach Bedarf einsetzen und wiederverwenden können, ohne eine weitere Speicherreservierung vornehmen zu müssen.

4.5  Verbleibende Schwierigkeiten

Ich denke, wir haben die drei Schreckgespenster, die die Leute davon abhalten sollen, dieses Pattern zu verwenden, erfolgreich vertrieben: Wie Sie sehen konnten, ist es einfach, schnell und arbeitet auch vernünftig mit der Speicherverwaltung zusammen. Doch soll das nun heißen, dass Sie überall Observer verwenden sollten?

Nun, das ist eine andere Frage. Wie alle Design Patterns ist auch das Observer-Pattern kein Allheilmittel. Und selbst wenn es korrekt und effizient implementiert wird, ist es womöglich nicht die richtige Lösung. Dass Design Patterns überhaupt einen schlechten Ruf besitzen, liegt in der Regel einfach daran, dass gute und sinnvolle Patterns auf die falschen Problemstellungen angewendet werden – was dann alles nur noch verschlimmert.

Zwei Herausforderungen bleiben aber dennoch bestehen, eine technische und eine weitere, die eher die Wartungsfreundlichkeit betrifft. Kümmern wir uns zunächst um die technische, weil das normalerweise am einfachsten abzuhandeln ist.

4.5.1  Subjekte und Observer löschen

Der betrachtete Beispielcode ist solide, lässt jedoch eine wichtige Frage offen: Was geschieht, wenn man ein Subjekt oder einen Observer löscht?

Ich will ja niemandem einen Vorwurf machen, möchte aber darauf hinweisen, dass dieser Frage im Buch Design Patterns keinerlei Beachtung geschenkt wird.

Wenn Sie sorglos die delete-Methode eines Observers aufrufen, könnte das Subjekt noch eine Referenz auf ihn besitzen, die dann einen verwaisten Zeiger auf nicht reservierten Speicher darstellt. Wenn das Subjekt nun versucht, eine Benachrichtigung zu versenden, dann ... Ich möchte es mal so sagen: Es macht keinen Spaß.

Das Subjekt zu löschen, ist einfacher, weil die Observer bei den meisten Implementierungen keine Referenz darauf besitzen. Dennoch kann es zu Problemen kommen, wenn das Subjekt in den Papierkorb geworfen wird. Möglicherweise erwarten die Observer, weiterhin Benachrichtigungen zu erhalten, weil ihnen nicht bekannt ist, dass dies nicht mehr passieren wird – weil sie tatsächlich gar keine Beobachter mehr sind und das nur noch nicht wissen.

Diese Situation können Sie auf verschiedene Weise handhaben. Am einfachsten ist es, das zu tun, was ich getan habe, nämlich sich darauf zu verlassen, dass es keine Observer mehr gibt. Schließlich ist es Aufgabe eines Beobachters, sich beim Subjekt abzumelden, wenn er gelöscht wird. In den meisten Fällen ist ihm außerdem durchaus bekannt, welches Subjekt er beobachtet. Eigentlich muss also nur dem Destruktor des Observers ein removeObserver()-Aufruf hinzugefügt werden.

Wie so häufig ist das Schwierige daran nicht, es zu tun, sondern schlicht daran zu denken.

Wenn Sie vermeiden möchten, dass noch Observer übrig bleiben, wenn das Subjekt seinen Geist aufgibt, lässt sich das leicht sicherstellen: Sorgen Sie dafür, dass das Subjekt unmittelbar vor seiner Löschung eine »Letzter Atemzug«-Benachrichtigung versendet. Auf diese Weise sind die Observer informiert und können in angemessener Weise darauf reagieren.

Beispielsweise wehklagen, Blumen schicken oder einen Nachruf verfassen ...

Man kann sich darauf verlassen, dass man sich auf Menschen nicht verlassen kann – das gilt auch für diejenigen von uns, die genügend Zeit in der Gesellschaft von Computern zugebracht haben, dass ein wenig von deren Verlässlichkeit auf sie abgefärbt ist. Genau aus diesem Grund wurden Computer erfunden: Ihnen unterlaufen nicht die Fehler, die wir Menschen so häufig machen.

Wenn Sie auf Nummer sicher gehen wollen, müssen Sie dafür sorgen, dass sich die Observer beim Löschen automatisch bei all ihren Subjekten abmelden. Sofern Sie die Programmlogik hierfür einmalig in der Basisklasse Ihrer Observer implementieren, muss sich niemand, der sie nutzt, mehr selbst darum kümmern. Ein wenig verkompliziert es die Sache aber auch, denn: Jeder Observer benötigt eine Liste der Subjekte, die er beobachtet. Es gibt also letztlich Zeiger in beide Richtungen.

4.5.2  Keine Sorge, der Garbage Collector erledigt das schon

All die coolen Leute mit ihren modernen Programmiersprachen, die die Garbage Collection (automatische Speicherbereinigung) beherrschen, sind ganz schön überheblich. Meinen die wirklich, sie bräuchten sich um nichts mehr zu kümmern, nur weil sie niemals irgendetwas ausdrücklich löschen? Das würde ich mir lieber zweimal überlegen!

Stellen Sie sich folgende Situation vor: Ihr Spiel kann eine Bildschirmseite anzeigen, die verschiedene statistische Informationen über die Spielfigur enthält, wie z.B. deren Gesundheitszustand usw. Wenn der Spieler sich diese Seite anzeigen lässt, instanziieren Sie ein neues Objekt dafür. Und wird die Anzeige später beendet, kümmern Sie sich nicht weiter um dieses Objekt, sondern lassen den Garbage Collector seine Arbeit verrichten.

Jedes Mal, wenn die Spielfigur einen Schlag ins Gesicht (oder sonst wohin, nehme ich an) abbekommt, wird eine entsprechende Benachrichtigung an die Statistikseite versendet, die daraufhin wiederum den kleinen Lebensbalken aktualisiert, der die Gesundheit des Spielers anzeigt. Schön und gut. Aber was geschieht, wenn der Spieler die Statistikseite schließt, Sie den Observer aber nicht entfernen?

Die Statistikseite ist dann nicht mehr sichtbar, wird aber auch von der Garbage Collection nicht erfasst, weil der Observer noch immer eine Referenz auf sie besitzt. Und so wird mit jeder Anzeige der Statistikseite der stetig wachsenden Liste von Instanzen eine neue hinzugefügt.

Während der Spieler herumrennt und in Kämpfe verwickelt wird, versendet die Spielfigur Benachrichtigungen, die von sämtlichen Instanzen der Statistikseite empfangen werden. Diese werden zwar nicht auf dem Bildschirm angezeigt, empfangen aber Benachrichtigungen und vergeuden durch das Aktualisieren unsichtbarer Elemente der Benutzeroberfläche CPU-Zeit. Wenn sie dann noch andere Aufgaben erledigen, wie etwa die Wiedergabe von Klängen, führt das sogar zu einem spürbaren Fehlverhalten des Spiels.

Dieses Problem tritt bei ereignisgesteuerten Systemen so häufig auf, dass es einen eigenen Namen erhalten hat: das Lapsed-listener-Problem (zu Deutsch etwa »Problem des verschwundenen Zuhörers«).

Ein noch deutlicherer Hinweis auf die Bedeutung dieses Problems: Es gibt sogar einen (englischen) Wikipedia-Artikel darüber.

Da Subjekte die Referenzen auf ihre Observer nicht entfernen, können dauerhaft im Arbeitsspeicher vorhandene Zombie-Objekte zurückbleiben. Die wichtigste Lektion, die man daraus lernen kann, ist, dass bei der Abmeldung von Observern eine gewisse Disziplin nötig ist.

4.5.3  Was geht hier vor?

Ein weiteres, tiefgreifenderes Problem des Observer-Patterns ist eine direkte Konsequenz seines eigentlichen Zwecks: Wir verwenden es zur Entkopplung zweier Codeabschnitte. Es ermöglicht Subjekten, indirekt mit Observern zu kommunizieren, ohne statisch an sie gebunden zu sein.

Wenn man über das Verhalten von Subjekten nachdenkt, ist das der eigentliche Vorteil. Alles Weitere wäre eine lästige Ablenkung. Wenn man eine Physik-Engine programmiert, möchte man weder den Bildschirm noch seine Gedanken mit irgendwelchem Zeug über Achievements zukleistern.

Sollte Ihr Programm jedoch andererseits nicht funktionieren und der Bug eine Kette von Observern betreffen, ist es sehr viel schwieriger, den Informationsfluss nachzuvollziehen. Wenn es eine ausdrückliche Kopplung gibt, muss nur die aufgerufene Methode untersucht werden. Für eine übliche IDE ist das ein Kinderspiel, weil die Kopplung statisch ist.

Kommt die Kopplung jedoch durch eine Observer-Liste zustande, gibt es nur eine Möglichkeit, herauszufinden, wer benachrichtigt wird – nämlich zur Laufzeit des Programms zu überprüfen, welche Observer sich gerade in der Liste befinden. Statt über die statische Kommunikationsstruktur des Programms nachzudenken, müssen Sie sich mit dem dynamisch entstehenden Verhalten des Programms befassen.

Mein Rat, wie man damit umgehen sollte, ist ziemlich simpel: Wenn Sie sich häufiger über beide Arten der Kommunikation Gedanken machen müssen, um einen Programmteil zu verstehen, sollten Sie das Observer-Pattern nicht zur Formulierung der vorhandenen Verflechtungen verwenden, sondern einer eher expliziten Methode den Vorzug geben.

Wenn man an einem größeren Programm arbeitet, muss man oft an mehreren irgendwie zusammenhängenden Programmteilen gleichzeitig Hand anlegen. Dafür gibt es viele Bezeichnungen wie »Aufgabenteilung«, »Einheitlichkeit und Geschlossenheit« oder »Modularität«, aber letzten Endes bedeutet es doch nur: »Der Kram hier gehört zusammen und das Zeug hier passt nicht dazu.«

Das Observer-Pattern ermöglicht es, größtenteils zusammenhangslose Programmteile miteinander kommunizieren zu lassen, ohne sie in einen Topf werfen zu müssen. Innerhalb eines Programmteils, der sich einem bestimmten Aspekt oder einer bestimmten Funktionalität widmet, ist es weniger nützlich.

Aus diesem Grund passt es gut zu unserem Beispiel: Achievement-System und Physik-Engine sind nahezu vollkommen zusammenhangslose Bereiche, die vermutlich auch von verschiedenen Personen implementiert worden sind. Wir möchten nur das absolute Minimum an Kommunikation zwischen den Bereichen zulassen, damit die Arbeit an einem der Programmteile keine umfassenderen Kenntnisse über den anderen erfordert.

4.6  Heutige Observer

Das Buch Design Patterns ist 1994 erschienen. Damals war objektorientierte Programmierung das Paradigma schlechthin. Alle Programmierer wollten »Objektorientierte Programmierung in 30 Tagen« lernen, das mittlere Management bezahlte sie anhand der Anzahl der Klassen, die sie erstellten, und Softwareentwickler bemaßen ihre Ambitionen danach, wie tief hinab die Vererbungshierarchie reichte.

Im Jahr 1994 hatten Ace of Base nicht nur einen, sondern drei Hits – und das spricht Bände über den damaligen Geschmack und das zeitgenössische Urteilsvermögen ...

Der damalige Zeitgeist machte das Observer-Pattern populär, daher überrascht es nicht, dass es ziemlich klassenlastig ist. Heutzutage sagt den meisten Entwicklern die funktionale Programmierung mehr zu. Ein komplettes Interface implementieren zu müssen, nur um eine Benachrichtigung zu empfangen, widerspricht der modernen Ästhetik.

Nach heutigen Maßstäben macht das Observer-Pattern einen schwerfälligen und starren Eindruck. Tatsächlich ist es schwerfällig und starr. So ist es beispielsweise nicht möglich, dass eine einzelne Klasse für verschiedene Subjekte unterschiedliche Benachrichtigungsverfahren verwendet.

Aus diesem Grund übergibt sich das Subjekt beim Aufruf des Observers für gewöhnlich selbst. Da ein Observer nur eine einzige onNotify()-Methode besitzt, muss es feststellen können, wer es aufruft, wenn es mehrere Subjekte beobachtet.

Ein etwas modernerer Ansatz ist ein »Beobachter«, der die einzige Referenz auf eine Methode oder Funktion darstellt. In Programmiersprachen, die First-Class-Funktionen unterstützen, insbesondere in solchen mit Closures, ist dies ein viel gebräuchlicheres Verfahren zur Implementierung von Observern.

C# beispielsweise enthält das Sprachelement »Event«. Der registrierte Observer ist hier ein sogenannter »Delegate« – so wird in dieser Sprache eine Referenz auf eine Methode bezeichnet. In JavaScript können Observer Objekte sein, die ein spezielles EventListener-Protokoll unterstützen, sie können aber auch einfache Funktionen sein – in den allermeisten Fällen kommt letztere Variante zum Einsatz.

Heutzutage verfügt fast jede Programmiersprache über Closures. C++ hat die Herausforderung bewältigt, Closures in einer Sprache ohne Garbage Collection zu implementieren, und sogar Java hat schließlich die Kurve gekriegt und sie mit dem JDK 8 eingeführt.

Wenn ich heutzutage ein Observer-System entwickeln sollte, würde ich es auf Funktionen aufbauen, nicht auf Klassen. Ich würde sogar in C++ ein System bevorzugen, das es erlaubt, statt Observer-Instanzen Zeiger auf Member-Funktionen als Observer zu registrieren.

4.7  Zukünftige Observer

Ereignisgesteuerte Systeme und Observer-artige Patterns sind heutzutage enorm weit verbreitet und können als ausgetretene Pfade betrachtet werden. Wenn Sie aber einige umfangreichere Programme geschrieben haben, die solche Patterns verwenden, wird Ihnen etwas auffallen: Ein großer Teil des Codes in Ihren Observern ist immer wieder derselbe – üblicherweise etwa in dieser Form:

Eine Benachrichtigung trifft ein, dass sich irgendein Zustand geändert hat.

Irgendein Teil der Benutzeroberfläche muss aktualisiert werden, um dem geänderten Zustand Rechnung zu tragen.

So nach dem Motto »Oh, der Gesundheitszustand des Helden ist um 7 Einheiten gesunken? Dann muss ich die Breite des Balkens, der seine Gesundheit anzeigt, auf 85 Pixel setzen.« Nach einer Weile wird das ziemlich lästig. Informatiker und Softwareentwickler versuchen seit geraumer Zeit, diesen Stumpfsinn abzuschaffen. Ihre dahingehenden Vorstöße tragen wohlklingende Namen wie »Datenflussprogrammierung« oder »funktional reaktive Programmierung«.

Es gibt zwar einige Fortschritte, vor allem in Bereichen wie der Audioverarbeitung oder der Chipentwicklung, aber der Stein der Weisen wurde noch nicht entdeckt. Mittlerweile hat ein weniger ambitionierter Ansatz Fahrt aufgenommen: Viele jüngere Anwendungs-Frameworks verwenden jetzt die Datenbindung.

Im Gegensatz zu drastischeren Vorgehensweisen wird bei der Datenbindung nicht versucht, die Möglichkeit, Code auszuführen, völlig zu eliminieren und die gesamte Anwendung in Form eines gigantischen Datenflussdiagramms zu gestalten. Vielmehr werden unproduktive Aufgaben automatisiert, wie etwa das Aktualisieren von Elementen der Benutzeroberfläche oder das Berechnen von Eigenschaften, die Änderungen irgendwelcher Werte anzeigen.

Ebenso wie andere deklarative Systeme ist die Datenbindung vermutlich zu langsam und zu kompliziert, um in den Kern einer Spiel-Engine implementiert zu werden. Ich wäre aber nicht überrascht, wenn sie allmählich in andere, weniger kritische Bereiche der Spielentwicklung Einzug hält, z.B. in die Gestaltung der Benutzeroberfläche.

Bis es so weit ist, steht uns das gute alte Observer-Pattern zur Verfügung. Wohl wahr, es ist nicht so spannend wie nagelneue Technologien, die Bezeichnungen wie »funktional« und »reaktiv« tragen, aber es ist einfach und es funktioniert. Meiner Ansicht nach sind das oft die beiden wichtigsten Kriterien für eine Lösung.

Kapitel 5: Prototype (Prototyp)

Spezifikation der zu erzeugenden Objekttypen mittels einer prototypischen Instanz und Erzeugung neuer Objekte durch Kopieren dieses Prototyps.

Der Begriff »Prototyp« ist mir zum allerersten Mal überhaupt in dem Buch Design Patterns begegnet. Heutzutage ist er allgegenwärtig, allerdings ist damit nicht immer das Design Pattern gemeint, mit dem wir uns in diesem Kapitel befassen. Ich möchte Ihnen auch einige andere interessante Zusammenhänge aufzeigen, in denen der Begriff »Prototyp« und die jeweils dahinterstehenden Ideen Anwendung finden können. Zunächst werfen wir jedoch einen Blick auf das ursprüngliche Pattern.

Ich benutze das Wort »ursprünglich« an dieser Stelle ganz bewusst, denn: In dem Buch Design Patterns wird Ivan Sutherlands legendäres Sketchpad-Projekt aus dem Jahr 1963 als eins der ersten Beispiele dieses Patterns genannt. Während alle anderen sich Bob Dylan und die Beatles anhörten, war Sutherland damit beschäftigt, mal eben die Grundlagen von CAD, interaktiver Grafik und objektorientierter Programmierung zu erfinden.

5.1  Das Design Pattern Prototype

Nehmen wir an, wir entwickeln ein Spiel im Stil von Gauntlet. Es gibt Ungeheuer und böse Geister, die den Helden umschwirren und um ihren Anteil an seinem Leichnam wetteifern. Die widerwärtigen Spießgesellen werden von der Klasse Spawner ausgebrütet, und für die verschiedenen Typen der Gegenspieler gibt es jeweils eigene »Ausbrüter«.

In diesem Beispiel soll es für jede Art Monster eine eigene Klasse geben – Ghost, Demon, Sorcerer usw. Etwa so:

class Monster
{
// Weiterer Code ...
};

class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

Die Spawner-Klasse erzeugt Instanzen eines bestimmten Monstertyps. Um alle Monstertypen zu unterstützen, könnten wir eine eigene Spawner-Klasse für jedes Monster verwenden, was eine parallele Klassenhierarchie zur Folge hätte:

Abb. 5.1: Parallele Klassenhierarchie

Zur Erstellung dieses Diagramms musste ich in einem verstaubten UML-Buch nachschlagen. Der Pfeil bedeutet »erbt von«.

Und hier die Implementierung:

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};

class GhostSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Ghost();
  }
};

class DemonSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Demon();
  }
};

// Und so weiter ...

Wenn man nicht gerade zeilenweise für den Code bezahlt wird, macht es wirklich keinen Spaß, ihn auf diese Weise zusammenzuschustern – jede Menge Klassen, jede Menge Textbausteine, jede Menge Redundanz, jede Menge Duplikate, jede Menge jede Menge ...

Hierfür bietet das Prototype-Pattern eine Lösung. Die grundlegende Idee ist, dass ein Objekt ihm ähnliche Objekte erzeugen kann. Wenn es einen Geist und einen Dämonen gibt, lassen sich weitere Geister und Dämonen erzeugen. Jedes Monster ist als Prototyp verwendbar, um weitere Monster zu erzeugen.

Um das zu implementieren, fügen wir der Basisklasse Monster eine abstrakte clone()-Methode hinzu:

class Monster
{
public:
  virtual ~Monster() {}
  virtual Monster* clone() = 0;

  // Weiterer Code ...
};

Jede Monster-Unterklasse gibt ein neues Objekt zurück, dessen Zustand und Klasse mit dem Zustand und der Klasse der Unterklasse selbst identisch ist. Zum Beispiel:

class Ghost : public Monster {
public:
  Ghost(int health, int speed)
  : health_(health),
    speed_(speed)
  {}

  virtual Monster* clone()
  {
    return new Ghost(health_, speed_);
  }

private:
  int health_;
  int speed_;
};

Wenn alle Monster das unterstützen, ist es nicht mehr erforderlich, dass es für jede Monster-Klasse eine eigene Spawner-Klasse gibt. Stattdessen definieren wir nur eine:

class Spawner
{
public:
  Spawner(Monster* prototype)
  : prototype_(prototype)
  {}

  Monster* spawnMonster()
  {
    return prototype_->clone();
  }

private:
   Monster* prototype_;
};

Diese Klasse enthält intern ein verstecktes Monster, dessen einzige Aufgabe es ist, beim Erzeugen neuer Monster als Vorlage zu dienen – fast wie eine Bienenkönigin, die niemals ihren Bienenstock verlässt.

Abb. 5.2: Monster-Erzeugung mittels Prototyp

Um einen Ghost-Spawner zu erstellen, erzeugen wir zunächst eine Ghost-Instanz als Prototyp und dann den Spawner, der diesen Prototyp enthält:

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

Ein besonders interessanter Aspekt dieses Patterns ist die Tatsache, dass es nicht nur die Klasse des Prototyps kopiert, sondern auch den Zustand. Auf diese Weise ist es möglich, Spawner für schnelle, schwache oder langsame Geister zu erstellen, indem man einfach einen entsprechenden Prototyp-Geist erzeugt.

Dieses Pattern ist elegant und überraschend zugleich. Ich kann mir zwar nicht vorstellen, dass ich selbst darauf gekommen wäre, aber jetzt, da ich es kenne, kann ich mir ebenso wenig vorstellen, nicht davon zu wissen.

5.1.1  Wie gut funktioniert es?

Nun, wir müssen nicht mehr für jedes Monster eine eigene Spawner-Klasse erstellen, das ist doch schon mal nicht schlecht. Allerdings müssen wir in jeder Monster-Klasse clone() implementieren, und dazu ist genauso viel Code erforderlich wie für die Spawner.

Außerdem gibt es einige unschöne semantische Fallstricke bei der Programmierung einer korrekten clone()-Methode. Wird eine tiefe oder eine flache Kopie (engl. deep bzw. shallow copy) erstellt? Mit anderen Worten: Wenn ein Dämon eine Heugabel in seinen Händen hält, wird die Heugabel dann beim Klonen des Dämons ebenfalls kopiert?

In diesem fiktiven Beispiel wird durch das Pattern praktisch kein Code eingespart, aber es ist eben auch nur ein fiktives Beispiel. Wir mussten es einfach hinnehmen, dass es für jedes Monster eine eigene Klasse gibt. Heutzutage funktionieren die meisten Spiel-Engines jedoch eindeutig nicht auf diese Weise.

Die meisten von uns haben auf die harte Tour lernen müssen, dass es eine Qual sein kann, umfangreiche Klassenhierarchien wie diese zu handhaben. Aus diesem Grund verwenden wir stattdessen Patterns wie Component (Komponente, Kapitel 14) und Type Object (Typ-Objekt, Kapitel 13), um die verschiedenen Spielobjekte zu modellieren, ohne sie jeweils in eine eigene Klasse zu stecken.

5.1.2  Spawn-Funktionen

Selbst wenn wir für jedes Monster eine eigene Klasse verwenden, stehen noch verschiedene Möglichkeiten zur Verfügung. Anstatt für jedes Monster eine eigene Spawner-Klasse einzurichten, könnten wir auch spawn-Funktionen verwenden:

Monster* spawnGhost()
{
  return new Ghost();
}

Hier gibt es weniger Wiederholungen und Redundanzen als beim Einrichten ganzer Klassen zur Erstellung eines Monsters. Die einzige Spawner-Klasse speichert dann einfach einen Funktionszeiger:

typedef Monster* (*SpawnCallback)();

class Spawner
{
public:
  Spawner(SpawnCallback spawn)
  : spawn_(spawn)
{}

  Monster* spawnMonster() { return spawn_(); }

private:
  SpawnCallback spawn_;
};

Und so wird ein Spawner für Geister erstellt:

Spawner* ghostSpawner = new Spawner(spawnGhost);

5.1.3  Templates

Mittlerweile sind die meisten C++-Programmierer mit Templates vertraut.

Ich bin mir nicht sicher, ob die C++-Programmierer sie zu schätzen gelernt haben, oder ob die Templates manche Leute einfach komplett aus der Entwicklungswelt verscheucht haben – jedenfalls verwenden heutzutage alle mir bekannten C++-Programmierer auch Templates.

Die Spawner-Klasse soll Instanzen eines bestimmten Typs erzeugen, wir möchten im Code jedoch keine Monster-Klasse fest vorgeben. Die naheliegende Lösung ist ein Typ-Parameter. Templates machen das möglich:

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};

template <class T>
class SpawnerFor : public Spawner
{
public:
  virtual Monster* spawnMonster() { return new T(); }
};

Die Spawner-Klasse ist hier dafür ausgelegt, dass Code, der nicht an der Art des erzeugten Monsters interessiert ist, sie einfach über einen Zeiger auf Monster verwenden kann.

Gäbe es nur die SpawnerFor<T>-Klasse, würde es auch keinen gemeinsamen Supertyp aller Instanziierungen des Templates geben, und jeglicher Code, der Spawner beliebiger Monster verwendet, müsste selbst einen Template-Parameter entgegennehmen.

Die Verwendung erfolgt auf diese Weise:

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

5.1.4  First-Class-Typen

Die beiden vorgenannten Lösungen nehmen sich des Problems an, dass eine Klasse (Spawner) erforderlich ist, die durch einen Typ parametrisiert wird. In C++ sind Typen im Allgemeinen keine First-Class-Objekte, daher sind ein paar Verrenkungen nötig. Wenn Sie dynamisch typisierte Sprachen wie JavaScript, Python oder Ruby verwenden, in denen Klassen durchweg normale Objekte sind, die herumgereicht werden können, lässt sich das Problem viel direkter lösen.

In mancher Hinsicht kann auch das Pattern Type Object (Typ-Objekt, Kapitel 14) als Ersatz für First-Class-Typen dienen. Es ist aber auch in Sprachen nützlich, die First-Class-Typen unterstützen, weil Sie festlegen, was genau ein »Typ« ist, wenn Ihre Anforderungen von dem abweichen, was die Sprache zu bieten hat.

Übergeben Sie beim Erstellen des Spawners einfach die Klasse des Monsters, das erzeugt werden soll – das zur Laufzeit vorhandene Objekt, das die Monster-Klasse repräsentiert. Wirklich einfach.

Angesichts all dieser Möglichkeiten kann ich offen gestanden nicht behaupten, dass ich auf einen Fall gestoßen wäre, bei dem ich das Design Pattern Prototype für die beste Lösung halte. Vielleicht machen Sie andere Erfahrungen, aber lassen wir das hinter uns und wenden wir uns anderen Dingen zu: Prototypen als Grundlage einer Programmiersprache.

5.2  Eine auf Prototypen beruhende Sprache

Viele Leute glauben, dass »objektorientierte Programmierung« (OOP) gleichbedeutend mit »Klassen« sei. Die verschiedenen OOP-Definitionen klingen nicht selten wie Glaubensbekenntnisse gegensätzlicher Religionsgemeinschaften, relativ unumstritten ist jedoch, dass OOP es ermöglicht, »Objekte« zu definieren, die Daten und Code bündeln. Im Vergleich zu strukturierten Sprachen wie C oder funktionalen Sprachen wie Scheme ist das charakteristische OOP-Merkmal, dass Zustand und Verhalten eng miteinander verknüpft sind.

Sie mögen ja vielleicht denken, dass Klassen die einzige Möglichkeit sind, dies zu erreichen – einige Leute wie Dave Ungar und Randall Smith sind da allerdings anderer Ansicht. Die beiden haben in den 80er Jahren eine Programmiersprache namens Self entwickelt, die vollständig objektorientiert ist, aber keine Klassen verwendet.

5.2.1  Self

In gewissem Sinne ist Self objektorientierter als eine auf Klassen beruhende Sprache. Wir denken bei OOP an das »Verheiraten« von Zustand und Verhalten, tatsächlich gibt es jedoch bei auf Klassen beruhenden Programmiersprachen eine Abgrenzung.

Betrachten Sie einmal die Semantik Ihrer bevorzugten auf Klassen beruhenden Programmiersprache. Um auf den Zustand eines Objekts zuzugreifen, sehen Sie sich den Speicherinhalt der Instanz selbst an: Der Zustand ist in der Instanz enthalten.

Um eine Methode aufzurufen, müssen Sie in der Instanz nachsehen, zu welcher Klasse sie gehört. In der Klasse finden Sie dann die Methode. Das Verhalten ist also in der Klasse enthalten. Dieser Umweg ist beim Zugriff auf eine Methode immer notwendig, daher unterscheiden sich Datenfelder und Methoden.

Um beispielsweise in C++ eine virtuelle Methode aufzurufen, folgen Sie dem Zeiger der Instanz, der auf die vtable der Klasse verweist. Dort können Sie dann die Methode finden.

Abb. 5.3: Methoden werden in der Klasse gespeichert, Daten in der Instanz.

In Self gibt es diese Unterscheidung nicht. Sie sehen sich stets nur das Objekt an. Sowohl der Zustand als auch das Verhalten können in einer Instanz enthalten sein. Ein einziges Objekt kann ganz für sich allein eine Methode enthalten, die nur in diesem Objekt vorhanden ist.

Abb. 5.4: Niemand ist eine Insel – dieses Objekt schon.

Wenn das alles wäre, was Self leistete, wäre es allerdings schwierig, die Sprache zu verwenden. In auf Klassen beruhenden Programmiersprachen stellt die Vererbung, trotz ihrer Nachteile, nützliche Mechanismen zur Wiederverwendung polymorphen Codes zur Verfügung und vermeidet dadurch doppelten Code – Self verwendet hingegen die Delegation, um ohne Klassen Ähnliches zu erreichen.

Um ein Datenfeld zu finden oder eine Methode aufzurufen, sehen wir zuerst im Objekt selbst nach. Werden wir fündig, war's das schon. Wenn nicht, sehen wir uns das Elternobjekt des Objekts an. Enthält das erste Objekt das gesuchte Datenfeld nicht, überprüfen wir dessen Elternobjekt, danach dessen Elternobjekt usw. Mit anderen Worten: Gescheiterte Suchen werden an das Elternobjekt delegiert.

Abb. 5.5: Delegation an das Elternobjekt

Ich habe die Darstellung hier vereinfacht – tatsächlich unterstützt Self mehrere Elternobjekte. Elternobjekte sind einfach speziell gekennzeichnete Datenfelder. Objekte können Elternobjekte beerben oder ihre Elternobjekte zur Laufzeit ändern, was dann zur sogenannten dynamischen Vererbung führt.

Mittels Elternobjekten können Verhalten (und Zustand!) mehrerer Objekte wiederverwendet werden – das wäre also erledigt. Das zweite wichtige Merkmal von Klassen ist die Möglichkeit, Instanzen zu erzeugen. Wenn Sie ein neues Dingsbums benötigen, können Sie einfach new Dingsbums() verwenden (oder wie auch immer die Syntax in Ihrer bevorzugten Sprache lautet). Eine Klasse an sich kann Instanzen von sich selbst erstellen.

Aber wie erzeugt man Neues ohne Klassen? Und wie erzeugt man mehrere neue Dinge, die bestimmte Eigenschaften gemeinsam haben? Wie bei dem Design Pattern wird auch in Self zu diesem Zweck geklont.

In Self unterstützen quasi alle Objekte automatisch das Prototype-Pattern. Jedes Objekt kann geklont werden. Um mehrere gleichartige Objekte zu erzeugen, gehen Sie folgendermaßen vor:

Erstellen Sie ein Objekt ganz nach Ihrem Geschmack. Sie können beispielsweise das eingebaute Objekt des Typs Object klonen und ihm Datenfelder und Methoden hinzufügen.

Klonen Sie das Objekt so oft Sie mögen.

Auf diese Weise erhalten wir die Eleganz des Prototype-Patterns, ohne clone() selbst implementieren zu müssen, denn die Methode ist bereits Bestandteil des Systems. Das ist ein so schönes, cleveres und minimalistisches System, dass ich sofort, nachdem ich es kennengelernt hatte, damit begann, eine auf Prototypen beruhende Sprache zu entwickeln, um mehr Erfahrung damit zu sammeln.

Mir ist schon klar, dass die Entwicklung einer Sprache von Grund auf keine besonders effiziente Lernmethode ist, aber was soll ich sagen? Ich bin ein wenig eigentümlich. Falls Sie neugierig geworden sind: Die Sprache heißt Finch.

5.2.2  Wie ist es gelaufen?

Ich fand es unglaublich spannend, mit einer rein prototypbasierten Sprache herumzuspielen, als ich sie dann aber zum Laufen bekommen hatte, wurde ich mit einer unerfreulichen Tatsache konfrontiert: Es machte überhaupt keinen Spaß, darin zu programmieren.

Die Sprache war einfach zu implementieren, sicher, doch das liegt daran, dass die Schwierigkeiten dem Anwender auferlegt werden. Als ich anfing, sie zu benutzen, merkte ich schnell, dass mir die Struktur fehlte, die Klassen mit sich bringen. Schließlich habe ich versucht, die Sprache in Form einer Bibliothek zusammenzufassen, weil das noch nicht funktionierte.

Wenn man der Gerüchteküche Glauben schenkt, sind viele der Self-Programmierer zum selben Schluss gekommen. Das Projekt war dennoch alles andere als ein Reinfall. Self war derart dynamisch, dass alle möglichen Innovationen bei der Entwicklung virtueller Maschinen nötig waren, damit die Sprache hinreichend schnell ausgeführt wurde.

Die dabei entstandenen Verfahren wie die Just-in-time-Kompilierung, die Garbage Collection und Optimierungen beim Aufruf von Methoden sind genau dieselben Technologien (teilweise sogar von denselben Leuten implementiert!), die heute dafür sorgen, dass viele dynamisch typisierte Sprachen schnell genug sind, um äußerst beliebte Programme auszuführen.

Vielleicht liegt es daran, dass meine Erfahrungen auf klassenbasierten Sprachen beruhen und meine Denkweise dadurch beeinflusst wird – ich habe jedoch den Verdacht, dass die meisten Leute einfach lieber wohldefinierte Dinge verwenden.

Neben dem anhaltenden Erfolg klassenbasierter Sprachen ist auch zu beachten, wie viele Spiele explizite Klassen für Figuren aufweisen und präzise Aufstellungen der verschiedenen fein säuberlich gekennzeichneten Feindesarten, Gegenstände und Fähigkeiten besitzen. Es gibt hingegen kaum noch Spiele, in denen jedes Monster so einzigartig wie eine Schneeflocke ist, wie etwa eine »Mischung aus Troll und Kobold mit einem Hauch von Schlange«.

Auch wenn Prototypen ein wirklich cooles Paradigma sind, das viel bekannter sein sollte, bin ich doch froh, dass die meisten von uns sie nicht alltäglich einsetzen. Der mir bekannte Code, der sich Prototypen voll und ganz zu Eigen macht, ist von einer merkwürdigen »Weichlichkeit« und für mich schwer verständlich.

Es spricht auch Bände, wie wenig Code tatsächlich im Prototypstil geschrieben wird. Ich habe das überprüft.

5.2.3  Was ist mit JavaScript?

Na gut, wenn prototypbasierte Sprachen so benutzerunfreundlich sind, wieso ist JavaScript dann so erfolgreich? Hier wird eine Sprache mit Prototypen täglich von Millionen Menschen verwendet. JavaScript läuft auf mehr Computern als irgendeine andere Programmiersprache.

Brendan Eich, der Erfinder von JavaScript, ließ sich unmittelbar von Self inspirieren, und ein Großteil der JavaScript-Semantik beruht auf Prototypen.

Für einen Sprachendesigner ist an Prototypen äußerst reizvoll, dass sie einfacher zu implementieren sind als Klassen. Eich hat sich das bestens zunutze gemacht: Die Entwicklung der ersten JavaScript-Version dauerte ganze zehn Tage.

Jedes JavaScript-Objekt kann eine beliebige Anzahl von Eigenschaften besitzen, und zwar sowohl Datenfelder als auch »Methoden« (die eigentlich nur Funktionen sind, die in Datenfeldern gespeichert werden). Ein Objekt kann außerdem ein zugehöriges Objekt besitzen, das als dessen »Prototyp« bezeichnet wird, an das es Anfragen delegiert, wenn der Zugriff auf ein Datenfeld fehlschlägt.

Dessen ungeachtet denke ich, dass JavaScript mehr mit auf Klassen beruhenden Programmiersprachen gemeinsam hat als mit prototypbasierten. Ein Hinweis darauf, wie weit sich JavaScript von Self entfernt hat, ist die Tatsache, dass es das für prototypbasierte Sprachen charakteristische Klonen in JavaScript überhaupt nicht gibt.

Es findet sich in JavaScript tatsächlich keine Methode zum Klonen. Object.create() kommt dem noch am nächsten. Diese Methode ermöglicht es, ein neues Objekt zu erstellen, das fehlgeschlagene Datenfeldzugriffe an ein bereits vorhandenes Objekt delegiert. Und selbst das wurde erst mit ECMAScript 5 hinzugefügt, vierzehn Jahre, nachdem JavaScript veröffentlicht wurde. Statt zu klonen, führe ich daher im Folgenden die typische Vorgehensweise bei der Definition von Typen und beim Erzeugen von Objekten in JavaScript vor. Zunächst wird eine Konstruktorfunktion erstellt:

function Weapon(range, damage) {
  this.range = range;
  this.damage = damage;
}

Hiermit wird ein neues Objekt erzeugt, dessen Datenfelder daraufhin initialisiert werden. Der Aufruf sieht so aus:

var sword = new Weapon(10, 16);

Durch das new wird der Body der Weapon()-Funktion aufgerufen. Das darin enthaltene this bezieht sich nun auf das neu erstellte Objekt. Es können dann nach Belieben Datenfelder hinzugefügt werden. Danach wird das jetzt mit Eigenschaften ausgestattete Objekt zurückgegeben.

Das new erledigt aber noch eine weitere Aufgabe: Beim Erzeugen des leeren neuen Objekts wird automatisch ein Prototypobjekt erstellt, an das gegebenenfalls Anfragen delegiert werden. Sie können mittels Weapon.prototype direkt darauf zugreifen.

Der Zustand kann also im Body des Konstruktors ergänzt werden. Um das Verhalten festzulegen, können Sie dem Prototypobjekt wie folgt Methoden hinzufügen:

Weapon.prototype.attack = function(target) {
  if (distanceTo(target) > this.range) {
    console.log("Out of range!");
  } else {
    target.health -= this.damage;
  }
}

Der Code fügt dem Weapon-Prototyp eine attack-Eigenschaft hinzu, die eine Funktion enthält. Da jedes von new Weapon() zurückgelieferte Objekt Anfragen an Weapon.prototype delegieren kann, können Sie nun sword.attack() verwenden, um die in der attack-Eigenschaft gespeicherte Funktion aufzurufen. Das sieht dann etwa so aus:

Abb. 5.6: Ein sword-Objekt und dessen weapon-Prototyp

Fassen wir zusammen:

Objekte werden mit einer new-Operation erzeugt, der ein Objekt übergeben wird, das den gewünschten Typ repräsentiert. Dies ist die Konstruktorfunktion.

Die Instanz selbst speichert den Zustand.

Das Verhalten wird indirekt gehandhabt (Delegation an den Prototyp) und wird in einem eigenen Objekt gespeichert, das die von allen Objekten eines bestimmten Typs gemeinsam genutzten Methoden beherbergt.

Sie mögen mich für verrückt halten, aber das hört sich doch sehr nach meiner vorangehenden Beschreibung der Klassen an, oder? Es ist durchaus möglich, in JavaScript Code im Prototypstil zu schreiben (ohne zu klonen), allerdings sind die Syntax und die Sprachelemente eher für den Gebrauch von Klassen ausgelegt.

Ich persönlich halte das für vernünftig. Wie gesagt, die Fokussierung auf Prototypen erschwert die Programmierung, daher sagt es mir zu, dass die JavaScript-Sprachelemente eher klassenartig sind.

5.3  Prototypen zur Datenmodellierung

Okay, ich habe mich die ganze Zeit mit den Dingen befasst, die mir an Prototypen nicht gefallen und mache dieses Kapitel damit zu einer echten Schlaftablette. Allerdings betrachte ich dieses Buch mehr als Komödie denn als Tragödie, also lassen Sie uns zum Abschluss noch einen Bereich betrachten, in dem ich Prototypen, oder genauer gesagt die Delegation, für sehr nützlich halte.

Wenn Sie überprüfen, wie viele Bytes eines Spielprogramms aus Code und wie viele aus Daten bestehen, werden Sie feststellen, dass der Anteil der Daten im Laufe der Zeit ständig angestiegen ist. In den ersten Computerspielen wurde fast alles prozedural generiert, damit sie auf eine Diskette oder ein Steckmodul passten. Heutzutage ist der Code nur eine »Engine«, die ein Spiel betreibt, das komplett durch die Daten repräsentiert wird.

Das ist auch schön und gut, aber alle Inhalte in Dateien zu verschieben, löst nicht wie von Zauberhand die organisatorischen Probleme eines großen Projekts. Tatsächlich werden sie dadurch sogar eher vergrößert. Darum verwenden wir Programmiersprachen, die Werkzeuge mitbringen, um diesen Schwierigkeiten zu begegnen.

Statt Code zu kopieren und an zehn verschiedenen Stellen einzufügen, erstellen wir eine Funktion, die über ihren Namen aufrufbar ist. Und anstatt eine Methode in Dutzende Klassen zu kopieren, erstellen wir eine eigene Klasse, von der diese Klassen erben.

Wenn die Datenmenge eines Spiels eine gewisse Größe erreicht, wünscht man sich hierfür eine ähnliche Funktionalität. Datenmodellierung ist ein tiefgründiges Thema, dem ich an dieser Stelle kaum gerecht werden kann, dennoch möchte ich hier ein Feature aufzeigen, das Sie bei Ihren eigenen Spielen in Betracht ziehen sollten: den Einsatz von Prototypen und Delegation zur Wiederverwendung von Daten.

Nehmen wir an, wir definieren das Datenmodell für das vorhin erwähnte schamlose Gauntlet-Plagiat. Die Spieldesigner müssen nun die Attribute von Monstern und Gegenständen in irgendwelchen Dateien speichern.

Ich meine natürlich den Originaltitel, der in keinerlei Weise von schon vorhandenen Action-Rollenspielen für mehrere Spieler inspiriert wurde. Bitte verklagt mich nicht.

Ein gebräuchlicher Ansatz ist die Verwendung von JSON. Die Datenobjekte heißen maps oder property bags oder tragen irgendeine andere von Dutzenden Bezeichnungen – denn Programmierer mögen nichts lieber, als neue Bezeichnungen für Dinge zu erfinden, die bereits einen Namen haben.

Wir haben schon so viele verschiedene Bezeichnungen erfunden, dass Steve Yegge sich veranlasst sah, dafür die Bezeichnung »universelles Design Pattern« zu erfinden.

Ein Kobold in diesem Spiel könnte folgendermaßen definiert werden:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

Das ist ziemlich schnörkellos, selbst der textscheuste Designer kann damit umgehen. Also schreiben wir der großen Koboldfamilie noch ein paar Verwandte ins Stammbuch:

{
  "name": "goblin wizard",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "spells": ["fire ball", "lightning bolt"]
}
{
  "name": "goblin archer",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "attacks": ["short bow"]
}

Wenn es sich hierbei um Code handeln würde, wäre unser ästhetisches Empfinden unangenehm berührt: Die Datenobjekte enthalten jede Menge Duplikate – und wohlerzogene Programmierer hassen das. Es verschwendet nicht nur Platz, es dauert auch länger, es zu schreiben. Man muss ganz genau hinsehen, um festzustellen, ob zwei Datenobjekte auch wirklich vollständig übereinstimmen, und die Wartung ist ein Albtraum. Wenn wir uns dazu entschließen, sämtliche Kobolde im Spiel widerstandsfähiger zu machen, müssen wir alle drei ändern. Nee, nee, nee.

Wäre es Code, würden wir eine Abstrahierung für Kobolde verwenden und diese bei den drei verschiedenen Typen wiederverwenden. Einfaches JSON kann so etwas nicht leisten, also verbessern wir das Ganze ein wenig.

Wir legen Folgendes fest: Wenn ein Objekt ein Datenfeld "prototype" besitzt, wird damit ein anderes Objekt definiert, an das dieses Objekt delegieren kann. Besitzt dieses Objekt eine Eigenschaft nicht, wird sie beim Prototypobjekt gesucht.

Auf diese Weise werden aus den "prototype"-Daten Metadaten. Kobolde haben warzige, grüne Haut und gelbe Zähne, aber keine Prototypen. Der Prototyp ist eine Eigenschaft des Datenobjekts, das den Kobold repräsentiert, nicht der Kobold selbst.

Dadurch können wir das JSON für die Koboldtruppe vereinfachen:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}
{
  "name": "goblin wizard",
  "prototype": "goblin grunt",
  "spells": ["fire ball", "lightning bolt"]
}
{
  "name": "goblin archer",
  "prototype": "goblin grunt",
  "attacks": ["short bow"]
}

archer und wizard verwenden den Prototyp grunt, daher müssen die Werte für health (Gesundheit), resists (Widerstandsfähigkeit) und weaknesses (Schwächen) nicht wiederholt werden. Die dem Datenmodell hinzugefügte Logik ist extrem einfach – eine schlichte Delegation –, aber wir sind schon einen Haufen Duplikate losgeworden.

Hier ist außerdem interessant, dass wir keinen vierten »Basis-Kobold« als abstrakten Prototyp für die konkreten Kobolde eingerichtet haben, an den diese delegieren könnten. Stattdessen haben wir einen möglichst einfachen Kobold ausgewählt, an den delegiert wird.

In einem prototypbasiertem System, in dem jedes Objekt zum Erzeugen neuer, verfeinerter Objekte geklont werden kann, erscheint das naheliegend, und ich denke, hier verhält es sich genauso. Diese Methode ist besonders gut für die Daten in Spielen geeignet, in denen es Spielgestalten oder Objekte in der Spielwelt nur einmalig gibt.

Denken Sie dabei an Anführer oder einzigartige Gegenstände. Meistens handelt es sich dabei um Verfeinerungen von im Spiel gebräuchlichen Objekten. Das magische »Schwert des Köpfens«, das eigentlich nur ein herkömmliches Langschwert mit einigen Bonuseigenschaften ist, kann direkt als solches formuliert werden:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

Eine solche Erweiterung des Datenmodellierungssystems der Spiel-Engine erleichtert es den Designern, viele kleine Variationen an der Bewaffnung und den die Spielwelt bevölkernden Bestien vorzunehmen – und eben diese Vielfalt ist es, die Spieler erfreut.

Kapitel 6: Singleton

Sicherstellung der Existenz nur einer einzigen Klasseninstanz sowie Bereitstellung eines globalen Zugriffspunkts für diese Instanz.

Dieses Kapitel ist eine Anomalie: Während die anderen Kapitel dieses Buches jeweils die Verwendung eines Design Patterns erläutern, hat dieses zum Thema, wie man ein Design Pattern nicht verwendet.

Trotz aller ehrenwerten Absichten bringt das von der Gang of Four beschriebene Singleton-Pattern oft mehr Schaden als Nutzen. Sie betonen zwar ausdrücklich, dass dieses Pattern nur sparsam eingesetzt werden sollte, in der Spielebranche ist das allerdings nie so richtig angekommen.

Als sich ein Großteil der Branche von C verabschiedete und der objektorientierten Programmierung zuwandte, standen die Programmierer oft vor der Frage: »Wo bekomme ich eine Instanz her?« Sie verfügten zwar über eine Methode, die sie aufrufen wollten, nicht jedoch über eine Instanz des Objekts, das die gewünschte Methode zur Verfügung stellte. Singletons (oder anders ausgedrückt globale Objekte) boten hier einen einfachen Ausweg.

Ebenso wie bei anderen Patterns ist auch die Anwendung des Singleton-Patterns in ungeeigneten Situationen etwa so sinnvoll, wie eine Schussverletzung mit einer Gipsschiene zu behandeln. Und weil es viel zu häufig eingesetzt wird, befasst sich dieses Kapitel hauptsächlich damit, wie man Singletons umgeht. Sehen wir uns das Pattern aber zunächst einmal an.

6.1  Das Singleton-Pattern

Sehen Sie sich die aus dem Buch Design Patterns zitierte Zusammenfassung des Singleton-Patterns am Kapitelanfang an. Wir teilen den Satz beim »sowie« in zwei Hälften und betrachten die beiden Teile einzeln.

6.1.1  Beschränkung einer Klasse auf eine Instanz

Unter bestimmten Umständen kann eine Klasse nicht korrekt funktionieren, wenn es mehr als eine Instanz von ihr gibt. Das ist beispielsweise dann der Fall, wenn die fragliche Klasse mit einem externen System interagiert, das einen eigenen globalen Zustand besitzt.

Betrachten Sie eine Klasse, die Aufrufe entgegennimmt, um auf eine Dateisystem-API zuzugreifen. Da Schreib- und Leseoperationen ziemlich lange dauern können, führt dieser Dateisystem-Wrapper die Operationen asynchron aus. Das wiederum bedeutet, dass mehrere Operationen miteinander konkurrieren können und daher aufeinander abgestimmt werden müssen. Wenn wir mit einem Aufruf eine Datei erstellen und dieselbe Datei mit einem weiteren Aufruf löschen, muss die Klasse das berücksichtigen, damit gewährleistet ist, dass sich die beiden Aufrufe nicht in die Quere kommen.

Um dies zu ermöglichen, müssen bei einem Aufruf einer Methode der Klasse alle vorhergehenden Operationen verfügbar sein. Könnten Anwender nach Belieben Instanzen erzeugen, wäre es unmöglich, dass einer Instanz die von anderen Instanzen gestarteten Operationen bekannt sind. Auftritt Singleton: Das Pattern ermöglicht es einer Klasse, schon beim Kompilieren zu gewährleisten, dass es stets nur eine Instanz von ihr gibt.

6.1.2  Bereitstellung eines globalen Zugriffspunkts

Der Dateisystem-Wrapper wird von verschiedenen Teilsystemen des Spiels genutzt, für die Protokollierung, für die Ladeprozesse, für die Spielstandspeicherung etc. Wie aber gelangen diese Teilsysteme an eine Instanz, wenn sie selbst sie nicht erzeugen können?

Auch hierfür bietet das Singleton eine Lösung: Es erzeugt nicht nur die einzige Instanz, sondern stellt zusätzlich noch eine global verfügbare Methode für den Zugriff auf diese Instanz bereit. So kann jeder von überall aus Hand an die gesegnete Instanz anlegen. Zusammengefasst sieht das folgendermaßen aus:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    // Späte Initialisierung
    if (instance_ == NULL)
    {
      instance_ = new FileSystem();
    }
      return *instance_;
  }

private:
  FileSystem() {}
  static FileSystem* instance_;
};

Die statische Variable instance_ enthält eine Instanz der Klasse und der private Konstruktor gewährleistet, dass es sich um die einzige Instanz handelt. Die statische öffentliche Methode instance() gewährt allen Teilen der Codebasis Zugriff auf diese Instanz. Außerdem ist sie dafür verantwortlich, das Singleton beim ersten Zugriff zu instanziieren (späte Initialisierung).

Hier eine modernere Variante:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    static FileSystem *instance = new FileSystem();
    return *instance;
  }
private:
  FileSystem() {}
};

C++11 verlangt, dass der Initialisierer einer lokalen statischen Variable nur einmalig ausgeführt wird. Dieser Code ist daher im Gegensatz zum ersten Beispiel threadsicher – vorausgesetzt, Sie verwenden einen modernen C++-Compiler.

Die Threadsicherheit der Singleton-Klasse selbst ist natürlich ein anderes Paar Schuhe! Hier geht es lediglich um die Threadsicherheit der Initialisierung.

6.2  Gründe für die Verwendung

Wir haben offenbar einen echten Siegertypen gefunden: Der Dateisystem-Wrapper ist überall verfügbar, ohne dass er ständig übergeben werden muss. Und die Klasse selbst trägt dafür Sorge, dass wir kein Chaos durch mehrfaches Instanziieren anrichten können. Hier sind noch einige weitere Vorzüge:

Die Instanz wird nicht erzeugt, wenn man die Klasse nicht benötigt. Arbeitsspeicher und Rechenzeit einzusparen, ist immer von Vorteil. Die Instanziierung des Singletons findet erst beim Zugriff darauf statt.

Das Singleton wird zur Laufzeit instanziiert. Eine Klasse mit statischen Membervariablen ist eine gebräuchliche Alternative zum Singleton. Mir gefallen einfache Lösungen, daher verwende ich nach Möglichkeit statt eines Singletons lieber eine statische Klasse. Allerdings gibt es hier einen Vorbehalt: die automatische Initialisierung. Der Compiler initialisiert statische Klassen, bevor er main() aufruft, daher sind Informationen, die erst im laufenden Programm zur Verfügung stehen (wie beispielsweise die aus einer Datei eingelesene Konfiguration), nicht nutzbar. Darüber hinaus dürfen verschiedene Klassen nicht voneinander abhängen, da der Compiler keine bestimmte Reihenfolge der Initialisierung statischer Klassen garantiert.

Durch die späte Initialisierung werden diese beiden Probleme gelöst: Das Singleton wird so spät wie möglich initialisiert und zu diesem Zeitpunkt sollten eigentlich alle erforderlichen Informationen zur Verfügung stehen. Sofern es keine Zirkelbezüge gibt, kann ein Singleton bei der Initialisierung sogar auf ein anderes verweisen.

Von Singletons können Unterklassen erstellt werden. Hierbei handelt es sich um eine leistungsstarke Fähigkeit, die leicht übersehen wird. Nehmen wir beispielsweise an, der Dateisystem-Wrapper soll plattformunabhängig funktionieren. Zu diesem Zweck könnte ein abstraktes Dateisystem-Interface mit Unterklassen verwendet werden, die Interfaces für die verschiedenen Plattformen implementieren. Hier die Basisklasse:

class FileSystem
{
  public:
    virtual ~FileSystem() {}
    virtual char* read(char* path) = 0;
    virtual void write(char* path, char* text) = 0;
};

Nun werden die abgeleiteten Klassen für verschiedene Plattformen definiert:

class PS3FileSystem : public FileSystem
{
  public:
    virtual char* read(char* path)
    {
      // Sonys Datei-IO-API verwenden
    }
    virtual void write(char* path, char* text)
    {
      // Sonys Datei-IO-API verwenden
};

class WiiFileSystem : public FileSystem
{
  public:
    virtual char* read(char* path)
    {
      // Nintendos Datei-IO-API verwenden
    }
    virtual void write(char* path, char* text)
    {
      // Nintendos Datei-IO-API verwenden
    }
};

Dann machen wir FileSystem zu einem Singleton:

class FileSystem
{
public:
  static FileSystem& instance();
    virtual ~FileSystem() {}
    virtual char* read(char* path) = 0;
    virtual void write(char* path, char* text) = 0;
protected:
  FileSystem() {}
};

Der clevere Teil ist die Erzeugung der Instanz:

FileSystem& FileSystem::instance()
{
#if PLATFORM == PLAYSTATION3
  static FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
  static FileSystem *instance = new WiiFileSystem();
#endif
  return *instance;
}

Durch eine einfache Abfrage wird der Dateisystem-Wrapper beim Kompilieren an den zugehörigen konkreten Typ angebunden. Die gesamte Codebasis kann nun mittels FileSystem::instance() auf das Dateisystem zugreifen, ohne dass eine Kopplung an plattformspezifischen Code besteht. Stattdessen ist diese Kopplung in der Implementierungsdatei der FileSystem-Klasse selbst gekapselt.

Damit sind wir in etwa bei der Methode angelangt, die von den meisten Entwicklern verwendet wird, um Probleme dieser Art zu lösen: einem Dateisystem-Wrapper, der zuverlässig funktioniert und global verfügbar ist, so dass von überall darauf zugegriffen werden kann. Nun ist es wohl an der Zeit, den Code einzuchecken und das Ganze mit einem wohlschmeckenden Kaltgetränk zu feiern.

6.3  Gründe, die Verwendung zu bereuen

Kurzfristig betrachtet, ist das Singleton-Pattern relativ harmlos – wie bei vielen Designentscheidungen muss der Preis dafür erst langfristig gezahlt werden. Nachdem wir dem Code ein paar überflüssige Singletons verpasst haben, sehen wir uns jedoch schon bald mit den Problemen konfrontiert, die wir uns eingehandelt haben.

6.3.1  Singletons sind globale Variablen

Als Spiele noch von ein paar Leuten in der Garage programmiert wurden, war es wichtiger, auch noch das letzte bisschen Leistung aus der Hardware herauszukitzeln, als irgendwelchen Prinzipien aus dem Elfenbeinturm der Softwareentwicklung Beachtung zu schenken. Die C- und Assembler-Programmierer der alten Schule konnten völlig problemlos auf globale und statische Variablen zurückgreifen und lieferten tolle Programme. Im Laufe der Zeit wurden die Spiele jedoch zunehmend komplexer und umfangreicher – ihre Architektur und Wartungsfreundlichkeit stellten sich allmählich als Engpässe heraus. Die Auslieferung von Spielen wurde nicht mehr durch die Einschränkungen der Hardware behindert, sondern durch die begrenzte Produktivität der Entwickler.

Folglich wechselten wir zu Programmiersprachen wie C++ und versuchten, die mühsam erarbeiteten Weisheiten unserer Entwicklervorfahren anzuwenden. Dass globale Variablen aus verschiedenen Gründen ein Übel sind, gehört zu den diversen Lehren, die wir daraus zogen:

Globale Variablen erschweren das Verständnis des Codes. Angenommen, wir suchen in einer von einem anderen Entwickler programmierten Funktion nach einem Bug. Sofern diese Funktion den globalen Zustand nicht modifiziert, sprich keine globalen Variablen verwendet, muss man nur den Code innerhalb der Funktion und die ihr übergebenen Argumente verstehen, um dem Problem auf den Grund zu gehen.

Stellen Sie sich nun im Vergleich dazu vor, dass inmitten der Funktion ein Aufruf von SomeClass::getSomeGlobalData() erfolgt. Um jetzt herauszubekommen, was eigentlich vor sich geht und was die globalen Daten bewirken, muss die gesamte Codebasis in Augenschein genommen werden. Wenn Sie schon mal nachts um drei in Millionen Codezeilen mit grep nach dem einen fehlerhaften Aufruf gesucht haben, der einer statischen Variablen einen falschen Wert zuweist, dürften Sie bereits gelernt haben, globale Daten zu hassen.

Informatiker bezeichnen Funktionen, die nicht auf den globalen Zustand zugreifen oder ihn modifizieren, als »rein funktional« (Pure Functions). Sie sind leichter zu verstehen, der Compiler kann sie besser optimieren und sie ermöglichen interessante Funktionalitäten, wie z.B. die Memoisierung, bei der die Rückgabewerte von Funktionen zwischengespeichert und wiederverwendet werden, anstatt sie jedes Mal erneut zu berechnen.

Es ist zwar schwierig, ausschließlich solche Funktionen einzusetzen, aber die Vorteile sind doch so verlockend, dass Informatiker Sprachen wie Haskell entwickelt haben, in denen nur diese »puren« Funktionen erlaubt sind.

Globale Variablen begünstigen die Kopplung. Bedauerlicherweise ist der neue Programmierer in Ihrem Team noch nicht mit der wundervoll wartungsfreundlichen, lose gekoppelten Architektur Ihres Spiels vertraut. Er steht vor seiner ersten Aufgabe: Gesteinsbrocken sollen beim Aufschlagen auf den Boden ein Geräusch von sich geben. Ihnen und mir ist klar, dass der Code der Physik-Engine nicht mit dem Audio-Code gekoppelt werden sollte, aber er ist darauf bedacht, seine Aufgabe zu erledigen. Leider ist die AudioPlayer-Instanz global zugänglich – und schon mit einem kleinen #include hat der Neuling die sorgfältig geplante Architektur kompromittiert.

Selbst wenn er mit einem #include den Header importieren würde, könnte er ohne eine globale AudioPlayer-Instanz dennoch nichts damit anfangen. Und diese Schwierigkeit wäre ein deutliches Signal dafür, dass diese beiden Module nichts voneinander wissen sollen und er nach einer anderen Methode zur Lösung seines Problems suchen muss. Die Zugriffssteuerung auf Instanzen steuert auch die Kopplung.

Globale Variablen behindern das Multithreading. Die Tage von Spielen, die nur einen Prozessorkern nutzen, sind gezählt. Heutiger Code muss Multithreading zumindest unterstützen, selbst wenn er kaum Vorteile aus dem Vorhandensein zusätzlicher Prozessorkerne zieht. Eine globale Variable repräsentiert einen Speicherbereich, auf den sämtliche Threads zugreifen können – völlig unabhängig davon, ob ihnen nun bekannt ist, was andere Threads damit anfangen, oder nicht. Das kann zu blockierten Threads, Wettlaufsituationen und ähnlichen höllisch schwer zu behebenden Fehlern bei der Threadsynchronisierung führen.

Probleme dieser Art reichen schon aus, um uns von der Deklaration globaler Variablen abzuhalten – und damit auch von der Verwendung des Singleton-Patterns. Das bringt uns aber hinsichtlich der Gestaltung des Spiels auch nicht weiter: Wie entwirft man ein Spiel ohne globale Zustände?

Auf diese Frage gibt es einige ausführliche Antworten (ein großer Teil dieses Buches ist in vielerlei Hinsicht eine Antwort darauf), die allerdings weder offensichtlich noch leicht zu verstehen sind. Wir müssen jedoch Spiele abliefern – und das Singleton-Pattern scheint ein Patentrezept zu sein. Immerhin entstammt es einem Buch über objektorientierte Entwurfsmuster, dann muss es doch wohl architektonisch korrekt sein, oder? Außerdem können wir Software damit auf dieselbe Art und Weise entwerfen, wie wir es schon seit Jahren praktizieren.

Leider handelt es sich hier eher um ein Placebo als um ein Heilmittel. Wenn Sie sich die Liste der von globalen Variablen verursachten Probleme genau ansehen, werden Sie feststellen, dass das Singleton-Pattern keins davon löst, denn ein Singleton ist ein globaler Zustand – der lediglich in einer Klasse gekapselt wird.

6.3.2  Das Pattern löst zwei Probleme, selbst wenn es nur eins gibt

Das Wort »sowie« in der Singleton-Definition der Gang of Four ist ein wenig merkwürdig. Soll das Pattern ein Problem lösen oder zwei? Und was geschieht, wenn ohnehin bloß eins davon vorhanden ist? Die Existenz nur einer einzigen Instanz zu gewährleisten, ist nützlich, aber warum eigentlich soll jeder darauf zugreifen können? Ebenso ist auch globaler Zugriff sehr bequem, das trifft jedoch auf Klassen, die mehrere Instanzen erlauben, ebenfalls zu.

Der Grund für die Verwendung des Singleton-Patterns ist fast immer der zweite der beiden genannten Aspekte, nämlich bequemer Zugriff. Betrachten Sie z.B. eine Klasse zur Protokollierung. Die meisten Module eines Spiels ziehen Vorteile daraus, wenn es die Möglichkeit gibt, Informationen zur Fehlersuche zu protokollieren. Jeder einzelnen Funktion eine Instanz der Log-Klasse zu übergeben, würde allerdings die Methodensignatur aufblähen und von der eigentlich Aufgabe des Codes ablenken.

Aus der Log-Klasse ein Singleton zu machen, wäre eine naheliegende Lösung, denn dann könnten sich alle Funktionen direkt bei der Klasse selbst eine Instanz beschaffen. Damit handeln wir uns jedoch ungewollt eine eigentümliche kleine Einschränkung ein: Plötzlich ist es nicht mehr möglich, mehr als eine Log-Klasse zu verwenden.

Zunächst erscheint das unproblematisch: Wir schreiben ohnehin nur in eine einzige Protokolldatei, also brauchen wir auch nur eine Instanz. Aber wenn die Entwicklung schon weit fortgeschritten ist, stoßen wir auf Schwierigkeiten: Alle Teammitglieder haben die Log-Klasse für eigene Zwecke eingesetzt und die Protokolldatei ist zu einer riesigen Müllhalde geworden. Die Entwickler müssen nun seitenlange Texte durchsuchen, nur um die eine gesuchte Zeile aufzuspüren.

Wir würden das gerne ändern und das Protokoll in mehrere Dateien aufteilen. Dazu bräuchten wir eigene Log-Klassen für verschiedene Bereiche des Spiels, wie etwa Online, Benutzeroberfläche, Audio oder Spielmechanik. Das funktioniert aber nicht! Die Log-Klasse ist nicht nur auf eine Instanz beschränkt, diese Beschränkung ist auch durch Aufrufe wie

Log::instance().write("Irgendein Ereignis.");

schon tief im Code verwurzelt. Damit die Log-Klasse wie ursprünglich mehrere Dateien unterstützt, müssen wir nicht nur die Klasse selbst ändern, sondern auch jede einzelne Codezeile, die sie verwendet. Der bequeme Zugriff ist plötzlich gar nicht mehr so bequem.

Es könnte sogar noch schlimmer kommen! Nehmen Sie an, Ihre Log-Klasse wäre Bestandteil einer Bibliothek, die von mehreren Spielen genutzt wird. Falls Sie jetzt eine Änderung am Design vornehmen wollten, müsste dies mit verschiedenen Personengruppen koordiniert werden, deren Mitglieder größtenteils weder Zeit noch Lust hätten, sich damit zu beschäftigen.

6.3.3  Die späte Initialisierung entzieht Ihnen die Kontrolle

Auf Desktoprechnern, die virtuellen Arbeitsspeicher nutzen und deren Software nur geringe Hardwareanforderungen stellt, ist die späte Initialisierung ein schlauer Trick. Spiele sind jedoch ein anderes Paar Schuhe. Zur Initialisierung des Systems ist Zeit erforderlich: Speicher reservieren, Ressourcen laden usw. Wenn die Initialisierung des Audiosystems mehrere hundert Millisekunden in Anspruch nimmt, müssen wir festlegen können, zu welchem Zeitpunkt sie stattfindet. Falls wir es gestatten, dass die Initialisierung erst bei der Wiedergabe des ersten Tons erfolgt, könnte dies möglicherweise während einer actiongeladenen Spielphase geschehen und zu deutlich sichtbaren Bildaussetzern und Rucklern führen.

Ebenso müssen Spiele im Allgemeinen die Speicherbelegung des Heaps kontrollieren können, um dessen Fragmentierung zu verhindern. Falls das Audiosystem während der Initialisierung einen Speicherbereich des Heaps für sich reserviert, muss bekannt sein, wann dieser Vorgang stattfindet, damit wir steuern können, wo sich dieser Bereich innerhalb des Heaps befindet.

Im Kapitel 19 Object Pool (Objektpool) wird die Speicherfragmentierung ausführlich erläutert.

Aufgrund dieser beiden Schwierigkeiten verzichten die meisten mir bekannten Spiele auf die späte Initialisierung und implementieren stattdessen auf folgende Weise ein Singleton-Pattern:

class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }

private:
  FileSystem() {}
  static FileSystem instance_;
};

Dadurch werden zwar die mit der späten Initialisierung einhergehenden Probleme behoben, allerdings auf Kosten einiger Vorteile, die ein Singleton gegenüber einer einfachen globalen Variablen aufweist. Mit einer statischen Instanz ist kein Polymorphismus möglich und die Klasse muss zum Zeitpunkt der Initialisierung auch erzeugt werden können. Außerdem kann der von der Instanz belegte Arbeitsspeicher nicht freigegeben werden, wenn sie nicht benötigt wird.

Statt ein Singleton zu erstellen, wird hier eine einfache statische Klasse definiert. Das muss nicht zwangsläufig von Nachteil sein, wenn jedoch eine statische Klasse ausreicht, könnte man auch gleich ganz auf die instance()-Methode verzichten und stattdessen eine statische Funktion benutzen. Der Aufruf Foo::bar() ist nicht nur einfacher als Foo::instance().bar(), sondern verdeutlicht gleichzeitig, dass es sich um statischen Speicher handelt.

Das gängige Argument dafür, Singletons statischen Klassen vorzuziehen, lautet wie folgt: Wenn Sie sich später dazu entschließen, aus der statischen Klasse eine nicht statische zu machen, müssen sämtliche Aufrufe modifiziert werden. Theoretisch ist das bei Singletons nicht erforderlich, denn man könnte die Instanz herumreichen und sie wie eine gewöhnliche Instanzmethode aufrufen.

In der Praxis ist mir diese Vorgehensweise noch nie untergekommen. Es wird immer einfach Foo::instance().bar() in einer Zeile aufgerufen. Wenn Foo kein Singleton mehr wäre, müssten dennoch alle Aufrufe modifiziert werden. Angesichts dessen ziehe ich eine einfache Klasse und die einfachere Syntax des Aufrufs vor.

6.4  Verzicht auf Singletons

Sollte ich mein Ziel erreicht haben, werden Sie es sich künftig zweimal überlegen, bevor Sie nach dem Singleton in Ihrer Werkzeugkiste greifen. Das Problem muss jedoch nach wie vor gelöst werden. Nach welchem Werkzeug also sollte man denn greifen? Je nachdem, was Sie erreichen möchten, habe ich verschiedene Optionen im Angebot. Doch stellen Sie sich zunächst einmal die Frage:

6.4.1  Wird die Klasse überhaupt benötigt?

Bei vielen der Singleton-Klassen, die mir in Spielen untergekommen sind, handelt es sich um »Manager« – nebulöse Klassen, deren einziger Zweck es ist, als Babysitter für andere Objekte zu dienen. In manchen Codebasen scheinen fast sämtliche Klassen einen Manager zu besitzen: Monster, MonsterManager, Particle, ParticleManager, Sound, SoundManager, ManagerManager. Gelegentlich heißt es zur Abwechslung statt »Manager« auch »System« oder »Engine«, aber es bleibt dasselbe Prinzip.

Solche »Aufpasser«-Klassen können durchaus nützlich sein, sie zeugen oftmals allerdings auch von fehlender Erfahrung mit objektorientierter Programmierung. Betrachten wir einmal diese zwei Klassen, die ich mir ausgedacht habe:

class Bullet
{
public:
  int getX() const { return x_; }
  int getY() const { return y_; }
  void setX(int x) { x_ = x; }
  void setY(int y) { y_ = y; }

private:
  int x_,
  int y_;
};

class BulletManager
{
public:
  Bullet* create(int x, int y)
  {
    Bullet* bullet = new Bullet();
    bullet->setX(x);
    bullet->setY(y);
    return bullet;
  }

  bool isOnScreen(Bullet& bullet)
  {
    return bullet.getX() >= 0 &&
           bullet.getY() >= 0 &&
           bullet.getX() < SCREEN_WIDTH &&
           bullet.getY() < SCREEN_HEIGHT;
  }

  void move(Bullet& bullet)
  {
    bullet.setX(bullet.getX() + 5);
  }
};

Dieses Beispiel mag etwas albern erscheinen, mir ist jedoch vergleichbarer Code, der (von irgendwelchen Details abgesehen) nach diesem Prinzip arbeitet, schon sehr oft untergekommen. Beim Betrachten dieses Codes ist es naheliegend anzunehmen, dass die Klasse BulletManager ein Singleton sein sollte. Schließlich wird jedes Objekt, dem eine Bullet-Instanz zugeordnet ist, auch einen BulletManager benötigen. Wie viele BulletManager-Instanzen werden hier benötigt?

Die Antwort lautet tatsächlich: gar keine. In diesem Fall wird das »Singleton-Problem« der Manager-Klasse wie folgt gelöst:

class Bullet
{
public:
  Bullet(int x, int y)
  : x_(x), y_(y)
  {}

  bool isOnScreen()
  {
    return x_ >= 0 && x_ < SCREEN_WIDTH &&
           y_ >= 0 && y_ < SCREEN_HEIGHT;
  }
  void move() { x_ += 5; }

private:
  int x_, y_;
};

Siehe da: Kein Manager, kein Problem. Mangelhaft gestaltete Singletons sind oftmals »Helferchen«, die anderen Klassen Funktionalität hinzufügen. Verlagern Sie das Verhalten nach Möglichkeit in die Klasse, der geholfen wird. Letzten Endes geht es bei OOP ja darum, dass Objekte sich selbst um sich kümmern.

Jenseits von Managern gibt es jedoch noch andere Problemstellungen, bei deren Lösung man schnell geneigt ist, nach einem Singleton zu greifen. Hierfür sind jedoch jeweils verschiedene Alternativen in Betracht zu ziehen.

6.4.2  Nur eine Instanz einer Klasse

Dies ist die eine Hälfte dessen, was das Singleton-Pattern zu bieten hat. Wie bei dem Dateisystem-Beispiel kann es von entscheidender Bedeutung sein, dass es nur eine Instanz einer Klasse gibt. Das bedeutet jedoch nicht zwangsläufig, dass wir auch öffentlichen und globalen Zugriff auf diese Instanz gewähren wollen. Vielleicht soll der Zugriff nur bestimmten Teilen des Codes möglich sein oder sogar nur einer einzigen Klasse.

Wir könnten beispielsweise den Dateisystem-Wrapper in einer weiteren Abstrahierungsebene verpacken.

In solchen Fällen schwächt die Bereitstellung eines globalen Zugriffspunkts die Architektur.

Wir suchen nach einer Möglichkeit, die Existenz nur einer Instanz zu gewährleisten, ohne globalen Zugriff auf diese Instanz zu erlauben. Das kann auf verschiedene Weise realisiert werden. Beispielsweise so:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }

  ~FileSystem() { instantiated_ = false; }

private:
  static bool instantiated_;
};
bool FileSystem::instantiated_ = false;

Eine Assertion bietet die Möglichkeit, einen »Vertrag« in den Code einzubetten. Beim Aufruf von assert() wird der in Klammern übergebene Ausdruck evaluiert. Ergibt die Auswertung true, passiert gar nichts – das Spiel läuft weiter. Liefert die Auswertung hingegen den Wert false, wird das Spiel sofort unterbrochen. Bei der Ausführung innerhalb der Entwicklungsumgebung wird normalerweise der Debugger gestartet oder zumindest der Dateiname und die Zeilennummer des Codes ausgegeben, der für das Fehlschlagen der Assertion verantwortlich ist.

Ein assert(Ausdruck) bedeutet sozusagen: »Ich versichere, dass dieser Ausdruck immer den Wert true besitzt. Falls dem nicht so ist, handelt es sich um einen Bug und das Programm muss sofort angehalten werden, damit der Fehler behoben werden kann.« Auf diese Weise können Sie dafür sorgen, dass verschiedene Codeabschnitte gewissermaßen einen Vertrag miteinander abschließen. Wenn eine Funktion via assert() zusichert, dass eins der Argumente nicht den Wert NULL besitzt, entspricht das der Aussage: »Die Abmachung zwischen mir und dem Aufrufer lautet, dass er mir keinen NULL-Wert übergibt.«

Mithilfe von Assertionen lassen sich Bugs aufspüren, sobald im Spiel irgendetwas Unerwartetes geschieht – und nicht erst dann, wenn sich der Bug durch für den Spieler erkennbare Fehler bemerkbar macht. Ein bildlicher Vergleich: Assertionen sind in der Codebasis die Zäune, die Bugs einpferchen, damit sie nicht dem Code entfliehen können, der für den Fehler verantwortlich ist.

Diese Klasse kann von jedermann instanziiert werden, aber die Assertion wird fehlschlagen, wenn versucht wird, mehr als eine Instanz zu erzeugen. Sofern die erste Instanz vom richtigen Code erstellt wird, ist dadurch sichergestellt, dass anderer Code weder an diese Instanz gelangen, noch eine weitere erzeugen kann. Die Klasse gewährleistet die Einschränkung auf nur eine Instanz, gibt jedoch nicht vor, wie sie zu verwenden ist.

Der Nachteil dieser Implementierung ist, dass die Überprüfung auf mehrfache Instanziierung nur zur Laufzeit erfolgt. Dass Singleton-Pattern hingegen garantiert durch die Struktur der Klasse selbst eine einzige Instanz bereits beim Kompilieren.

6.4.3  Bequemer Zugriff auf eine Instanz

Der bequeme Zugriff ist der Hauptgrund für den Einsatz von Singletons. Sie erleichtern es, von den verschiedensten Codeabschnitten aus an die erwünschten Objekte zu gelangen. Diese Bequemlichkeit hat aber auch ihren Preis, denn es ist genauso leicht, von Codeabschnitten aus auf die Objekte zuzugreifen, in denen sie gerade nicht verwendet werden sollen.

Die allgemeine Regel lautet, dass der Gültigkeitsbereich von Variablen nur so groß sein sollte, dass die anstehende Aufgabe erledigt werden kann. Je kleiner der Gültigkeitsbereich ist, desto weniger Orte sind beim Umgang mit der Variablen zu berücksichtigen. Bevor wir im Hauruckverfahren ein Singleton-Objekt mit globalem Gültigkeitsbereich einsetzen, sollten wir andere Möglichkeiten in Betracht ziehen, der Codebasis ein Objekt bereitzustellen:

Übergeben Sie das Objekt. Die einfachste und häufig beste Lösung ist die Übergabe des Objekts als Argument an die Funktion, die es benötigt. Es lohnt sich, diese Möglichkeit in Betracht zu ziehen, bevor man sie als zu beschwerlich verwirft.

Manchmal wird diese Vorgehensweise als »Injektion von Abhängigkeiten« (Dependency Injection) bezeichnet. Nicht der Code selbst versucht, durch globale Aufrufe an die Objekte zu gelangen, von denen er abhängt, stattdessen werden sie ihm als Parameter übergeben. Nach Ansicht mancher Entwickler sollte die eingangs genannte Bezeichnung allerdings komplexeren Methoden, dem Code Objekte bereitzustellen, vorbehalten bleiben.

Betrachten Sie z.B. eine Funktion zum Rendern von Objekten. Sie benötigt Zugriff auf das Objekt, das den Zustand des Objekts speichert und das grafische Ausgabegerät repräsentiert. Dieses Objekt wird üblicherweise an die Renderfunktionen übergeben, für gewöhnlich in Form eines Parameters namens context (oder einer ähnlichen Bezeichnung).

Andererseits gehören manche Objekte einfach nicht in die Signatur einer Methode. Eine KI-Funktion muss möglicherweise auch über Zugriff auf die Protokolldatei verfügen, aber das ist nicht ihre eigentliche Aufgabe. Es wäre sonderbar, wenn Log zur Argumentliste gehören würde. In solchen Fällen sollten wir lieber andere Möglichkeiten in Betracht ziehen.

Nicht modularisierbare Aufgaben, wie z.B. die in der gesamten Codebasis verstreute Protokollierung, werden als Cross-Cutting Concerns bezeichnet. Sie elegant zu handhaben, ist eine fortwährende architektonische Herausforderung, insbesondere in statisch typisierten Programmiersprachen.

Die aspektorientierte Programmierung wurde entwickelt, um sich dieses Problems anzunehmen.

Stellen Sie das Objekt über die Basisklasse bereit. Viele Spielarchitekturen besitzen eine zwar flache, jedoch breite Vererbungshierarchie, die oftmals nur eine Ebene tief ist. So könnte es beispielsweise die Basisklasse GameObject geben, von der die Unterklassen für die Gegenspieler oder andere Spielobjekte abgeleitet werden. Bei solch einer Architektur befindet sich ein Großteil des Spielcodes in diesen abgeleiteten Klassen, die alle bereits auf die Basisklasse GameObject zugreifen können. Das wiederum können wir zu unserem Vorteil nutzen:

class GameObject
{
protected:
  Log& log() { return log_; }

private:
  static Log& log_;
};

class Enemy : public GameObject
{
  void doSomething()
  {
     getLog().write("Ich kann protokollieren!");
  }
};

Auf diese Weise ist gewährleistet, dass außerhalb der GameObject-Klasse kein Zugriff auf das Log-Objekt möglich ist, alle abgeleiteten Objekte jedoch darauf zugreifen und log() verwenden können. Die Vorgehensweise, dass abgeleitete Objekte sich mittels ihnen bereitgestellten geschützten Methoden implementieren, wird in Kapitel 12 Subclass Sandbox (Unterklassen-Sandbox) erläutert.

Hier stellt sich die Frage, wie GameObject an die Log-Instanz gelangt. Eine einfache Lösung wäre, dass die Basisklasse sie selbst erzeugt und so über eine statische Instanz verfügt.

Wenn Sie nicht möchten, dass die Basisklasse eine so aktive Rolle einnimmt, können Sie eine Funktion zur Initialisierung einrichten, der Sie die Log-Instanz übergeben, oder das Pattern Service Locator (Dienstlokalisierung, Kapitel 16) anwenden, um sie aufzuspüren.

Stellen Sie das Objekt über ein bereits vorhandenes globales Objekt bereit. Das Ziel, auf sämtliche globale Zustände zu verzichten, ist ehrenwert, in der Praxis jedoch nur selten erreichbar. In den meisten Codebasen sind ein paar global verfügbare Objekte vorhanden, wie etwa ein einzelnes Game- oder World-Objekt, das den Gesamtzustand des Spiels repräsentiert.

Die Anzahl der globalen Klassen lässt sich reduzieren, wenn wir uns die bereits vorhandenen zunutze machen. Statt aus Log, FileSystem und AudioPlayer Singletons zu machen, können Sie folgendermaßen vorgehen:

class Game
{
public:
  static Game& instance() { return instance_; }

  Log&         log()         { return *log_; }
  FileSystem&  fileSystem()  { return *files_; }
  AudioPlayer& audioPlayer() { return *audio_; }
  // Funktionen, die log_ usw. Werte zuweisen

private:
  static Game instance_;
  Log         *log_;
  FileSystem  *files_;
  AudioPlayer *audio_;
};

Auf diese Weise ist lediglich Game global verfügbar. Die anderen Systeme hingegen sind über Game erreichbar:

Game::instance().getAudioPlayer().play(LOUD_BANG);

Puristen werden hier einwenden, dass diese Vorgehensweise das Gesetz von Demeter verletzt. Ich entgegne dann, dass dies immer noch besser ist als ein Haufen Singletons.

Sollte die Architektur später geändert werden, um mehrere Game-Instanzen zu unterstützen (vielleicht um Streaming zu nutzen oder zu Testzwecken), blieben Log, FileSystem und AudioPlayer davon unberührt – sie würden den Unterschied gar nicht bemerken. Der Nachteil dabei ist natürlich, dass weiterer Code an die Game-Klasse selbst gekoppelt wird. Auch wenn eine Klasse nur einen Klang abspielen soll, muss sie dennoch Kenntnis von Game haben, um an das AudioPlayer-Objekt zu gelangen.

Diesem Problem begegnen wir mit einer Hybridlösung: Code der ohnehin Kenntnis von Game hat, greift darüber auf das AudioPlayer-Objekt zu. Dem übrigen Code stellen wir den Zugriff über eine der anderen hier aufgeführten Optionen bereit.

Stellen Sie das Objekt über einen Service Locator bereit. Bisher sind wir davon ausgegangen, dass es sich bei der globalen Klasse um eine herkömmliche konkrete Klasse wie Game handelt. Es gibt jedoch auch die Möglichkeit, eine Klasse zu definieren, die ausschließlich dazu dient, globalen Zugriff auf Objekte bereitzustellen. Diesem gängigen Pattern, das als Service Locator (Dienstlokalisierung, Kapitel 16) bezeichnet wird, ist ein eigenes Kapitel gewidmet.

6.5  Was bleibt dem Singleton?

Es ist noch immer die Frage offen, wann man denn nun das Singleton-Pattern einsetzen sollte. Offen gestanden habe ich die komplette Implementierung des Singleton-Patterns der Gang of Four in einem Spiel noch nie benutzt. Ich verwende normalerweise einfach eine statische Klasse, um das Vorhandensein nur einer Instanz zu gewährleisten. Funktioniert das nicht, benutze ich ein statisches Flag, um zur Laufzeit zu verhindern, dass mehr als eine Instanz der Klasse erzeugt wird.

In diesem Buch gibt es noch einige andere Kapitel, die hier hilfreich sind. Das Pattern Subclass Sandbox (Unterklassen-Sandbox, Kapitel 12) ermöglicht der Instanz einer Klasse den Zugriff auf gemeinsam genutzte Zustandsdaten, ohne diese global verfügbar zu machen. Das Pattern Service Locator (Dienstlokalisierung, Kapitel 16) hingegen macht ein Objekt zwar global verfügbar, bietet bei der Konfiguration dieses Objekts jedoch mehr Flexibilität.

Kapitel 7: State (Zustand)

Anpassung der Verhaltensweise eines Objekts im Fall einer internen Zustandsänderung, so dass es den Anschein hat, als hätte es seine Klasse gewechselt.

Ich muss gestehen, dass hier die Pferde mit mir durchgegangen sind und ich viel zu viele Themen in diesem Kapitel untergebracht habe. Im Wesentlichen sollte es um das Design Pattern State (Zustand) gehen, allerdings kann ich im Zusammenhang mit Spielen nicht darüber schreiben, ohne nicht auch das grundlegende Konzept endlicher Automaten oder Zustandsautomaten zu erwähnen. Und wenn wir schon mal bei diesem Thema sind, kann ich doch auch gleich noch kurz die hierarchischen Zustandsautomaten und Kellerautomaten vorstellen, oder?

Das sind eine Menge Themen, daher lasse ich in den Codebeispielen aus Platzgründen einige Details weg, die Sie selbst ergänzen müssen. Ich hoffe aber, die Beispiele sind dennoch klar genug, um das große Ganze zu verstehen.

Wundern Sie sich nicht, wenn Sie noch nie von »Zustandsautomaten« gehört haben. Im KI-Umfeld und unter Compiler-Hackern sind sie alltäglich, andere Programmierergruppen sind hingegen kaum damit vertraut. Ich bin allerdings der Ansicht, dass sie bekannter sein sollten und werde sie daher einsetzen, um eine neue Art Problem zu lösen.

KI-Umfeld und Compiler-Hacker – diese Kombination erinnert an die Frühzeit der KI-Forschung. In den 50er und 60er Jahren konzentrierte sie sich auf die Spracherkennung. Viele der heute von Compilern verwendeten Technologien zur Verarbeitung von Programmiersprachen wurden ursprünglich erfunden, um menschliche Sprache zu verstehen.

7.1  Altbekanntes

Wir arbeiten im Folgenden an einem kleinen Jump'n'Run-Side-Scroller. Unsere Aufgabe ist die Implementierung der Heldin, die den Avatar des Spielers in der Spielwelt repräsentiert. Sie soll auf Eingaben reagieren: Ein Druck auf die Taste B lässt sie springen. Kein Problem:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    yVelocity_ = JUMP_VELOCITY;
    setGraphics(IMAGE_JUMP);
  }
}

Haben Sie den Fehler entdeckt? Es wird nicht verhindert, dass die in der Luft befindliche Heldin erneut springt – hämmern Sie auf die Taste B ein, während sie sich in der Luft befindet, und sie schwebt unbegrenzt vor sich hin. Die einfache Lösung besteht darin, Heroine eine boolesche Variable Jumping_ als Flag hinzuzufügen, das angibt, ob die Heldin gerade springt und eine entsprechende Abfrage zu programmieren:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_)
    {
      isJumping_ = true;
      // Springen ...
    }
  }
}

Hier fehlt der Code, der Jumping_ auf false zurücksetzt, sobald die Heldin wieder den Boden berührt. Ich habe ihn der Kürze halber weggelassen.

Nun soll die Heldin, sofern sie sich am Boden befindet, bei Betätigung der Taste Pfeil unten in die Hocke gehen und sich wieder aufrichten, sobald die Taste losgelassen wird:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    // Springen, wenn nicht in der Luft
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    setGraphics(IMAGE_STAND);
  }
}

Haben Sie auch hier den Fehler gefunden? Der Spieler könnte jetzt:

die Taste Pfeil unten drücken und gedrückt halten, um in die Hocke zu gehen,

die Taste B drücken, um aus der hockenden Position zu springen,

die Taste B wieder loslassen, während sich die Heldin noch in der Luft befindet.

Damit nimmt die Heldin mitten im Sprung ihre stehende Haltung ein. Also noch ein Flag ...

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // Springen ...
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      isDucking_ = false;
      setGraphics(IMAGE_STAND);
    }
  }
}

Außerdem wäre es doch cool, wenn die Heldin einen Hechtsprung ausführen würde, sobald der Spieler während des Sprungs die Pfeil unten-Taste drückt:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // Springen ...
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
    else
    {
      isJumping_ = false;
      setGraphics(IMAGE_DIVE);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      // Aufrecht stehen ...
    }
  }
}

Und wieder geht es auf Bug-Suche. Gefunden? Nun wird zwar ein erneutes Springen während des herkömmlichen Springens verhindert, nicht aber während eines Hechtsprungs. Und wieder ein Flag ...

Wie es scheint, stimmt mit dem hier verfolgten Ansatz grundsätzlich etwas nicht, denn schon bei der kleinsten Änderung an diesen wenigen Codezeilen funktioniert irgendetwas nicht mehr richtig. Und es fehlen ja noch eine ganze Reihe weiterer Bewegungsarten – wir haben doch noch nicht mal das normale Laufen implementiert. Nur, wenn das so weitergeht, bricht das ganze Programm im Nu unter der Last all dieser Bugs zusammen.

Die von Ihnen bewunderten Programmierer, die scheinbar stets und ständig fehlerlosen Code liefern, kochen auch nur mit Wasser. Allerdings haben sie ein Gespür dafür, welche Art von Code fehleranfällig ist und vermeiden es daher, ihn zu verwenden. Dazu gehören beispielsweise komplizierte Verzweigungen und veränderliche Zustände (Datenfelder, die im Laufe der Zeit andere Werte annehmen) – in dem vorgestellten Beispiel kommt jedoch beides vor.

7.2  Zustandsautomaten erledigen das

Es reicht! In einem Anfall von Frustration fegen Sie bis auf Papier und Bleistift alles vom Tisch und fangen an, ein Ablaufdiagramm zu zeichnen. Die verschiedenen Bewegungszustände der Heldin stellen Sie jeweils in einem eigenen Kasten dar: Stehen, Springen, Hechten, Hocken. Und wenn sie in einem dieser Zustände auf einen Tastendruck reagieren können soll, zeichnen Sie einen Pfeil von diesem Kasten zum neuen Bewegungszustand und beschriften ihn mit der jeweiligen Taste.

Abb. 7.1: Ablaufdiagramm eines Zustandsautomaten

Herzlichen Glückwunsch, Sie haben soeben einen Zustandsautomaten erstellt. Dieses Verhaltensmodell entstammt einem Zweig der theoretischen Informatik, der als Automatentheorie bezeichnet wird und sich mit einer Gruppe von Datenstrukturen wie Zustandsautomaten, Kellerautomaten, Zellularautomaten und den berühmten Turingmaschinen befasst. Zustandsautomaten sind die einfachsten Vertreter dieser Spezies.

Meine Lieblingsanalogie für Zustandsautomaten sind frühe Textadventures wie Zork. Die Spielwelt besteht dabei aus verschiedenen Räumen, die durch Ausgänge miteinander verbunden sind und mithilfe von Kommandos wie »Gehe nach Norden« erforscht werden.

Das lässt sich durch einen Zustandsautomaten unmittelbar abbilden: Der Raum, in dem man sich gerade befindet, repräsentiert den aktuellen Zustand, die Ausgänge des Raumes entsprechen den Übergängen von einem Zustand zum anderen, und die Navigationskommandos sind die Eingaben.

Die wesentlichen Eigenschaften von Zustandsautomaten sind folgende:

Ein Automat kann eine festgelegte Anzahl von Zuständen annehmen. In unserem Beispiel sind das die Zustände Stehen, Springen, Hocken und Hechten.

Der Automat kann jeweils nur einen einzigen Zustand annehmen. Unsere Heldin kann ja schließlich nicht gleichzeitig stehen und springen. Und um genau das auch sicherzustellen, verwenden wir ja schließlich einen Zustandsautomaten.

Der Automat verarbeitet eine Sequenz von Eingaben oder Ereignissen. In unserem Beispiel entspricht das dem Drücken bzw. Loslassen einer Taste.

In jedem Zustand gibt es einen Satz von Zustandsübergängen, denen jeweils eine Eingabe und ein Endzustand zugeordnet sind. Wenn eine Eingabe erfolgt, die zu einem der Zustandsübergänge passt, nimmt der Automat diesen Zustand an.

Wenn die Heldin steht, bewirkt ein Druck auf die Taste Pfeil unten, dass sie in die Hocke geht. Springt sie hingegen gerade, bewirkt der Tastendruck einen Zustandsübergang zum Hechten. Ist kein Zustandsübergang für den aktuellen Zustand definiert, wird die Eingabe ignoriert.

In der reinsten Form ist das schon des Pudels Kern: Zustände, Eingaben und Zustandsübergänge, die Sie als Ablaufdiagramme zeichnen können. Leider ist der Compiler jedoch nicht in der Lage, Ihr Gekritzel zu erkennen – wie also erfolgt die Implementierung eines Zustandsautomaten? Eine Möglichkeit ist das State-Pattern der Gang of Four – doch dazu kommen wir erst später. Fangen wir mit etwas Einfacherem an.

7.3  Enumerationen und Switch-Anweisungen

In der Heroine-Klasse gibt es das Problem, dass einige der Kombinationen der booleschen Variablen ungültig sind: isJumping_ und isDucking_ dürfen beispielsweise nie gleichzeitig true sein. Eine Handvoll Flags, von denen nur eins zurzeit true sein darf, ist ein klarer Hinweis darauf, dass eine Enumeration verwendet werden sollte.

Im vorliegenden Fall umfasst diese Enumeration genau die Zustände des Automaten, die wir wie folgt definieren:

enum State
{
  STATE_STANDING,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};

Statt eines Haufens einzelner Flags erhält Heroine ein state_-Datenfeld. Außerdem ändern wir die Reihenfolge der Verzweigung. Der alte Code verzweigte zuerst anhand der Eingabe und danach anhand des Zustands. Dadurch wurde jedoch verhindert, einen Tastendruck »am Stück« abzuhandeln und der entsprechende Code war bei der Behandlung eines Zustands überall verstreut. Dieser Code soll aber zusammenbleiben, daher ändern wir die Reihenfolge der Verzweigung, so dass sich Folgendes ergibt:

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_B)
      {
        state_ = STATE_JUMPING;
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
      }
      else if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        setGraphics(IMAGE_DUCK);
      }
      break;

    // Weitere Zustände ...
  }
}

Nun können die übrigen Zustände wie folgt eingebaut werden:

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    // Zustand Stehen ...

    case STATE_JUMPING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DIVING;
        setGraphics(IMAGE_DIVE);
      }
      break;

    case STATE_DUCKING:
      if (input == RELEASE_DOWN)
      {
         state_ = STATE_STANDING;
         setGraphics(IMAGE_STAND);
      }
      break;
  }
}

Das erscheint vielleicht trivial, ist jedoch im Vergleich zum vorherigen Code eine echte Verbesserung.

Die Heldin kann insbesondere keine ungültigen Zustände mehr annehmen. Bei der Nutzung boolescher Flags sind Wertekombinationen möglich, die keinen Sinn ergeben. Bei der Enumeration stellt dagegen jeder Wert einen gültigen Zustand dar.

Es finden sich zwar immer noch einige bedingte Verzweigungen, der sich ändernde Zustand betrifft jedoch nur noch ein Datenfeld. Sämtlicher zur Handhabung eines einzelnen Zustands erforderliche Code befindet sich nun an einem Ort. Dies ist die einfachste Art, einen Zustandsautomaten zu implementieren, und für manche Anwendungen reicht sie auch vollkommen aus.

Früher oder später könnte diese Lösung allerdings zu eingeschränkt sein. Angenommen, die Heldin soll sich hinhocken können, um für einen Moment lang Kräfte für einen speziellen Angriff zu sammeln. Dazu müssen wir in der Variablen chargeTime_ speichern, wie lange sie schon hockt.

Also ergänzen wir Heroine um ein solches Datenfeld. Vorausgesetzt, es gibt bereits eine update()-Methode, die pro Frame einmal aufgerufen wird, kommt folgender Code hinzu:

void Heroine::update()
{
  if (state_ == STATE_DUCKING)
  {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      superBomb();
    }
  }
}

Sollten Sie gemutmaßt haben, dass es sich hier um das Pattern Update Method (Aktualisierungsmethode, Kapitel 10) handelt, haben Sie einen Leuchtkeks gewonnen!

Der Timer muss zurückgesetzt werden, wenn die Heldin sich hinhockt, also modifizieren wir handleInput() entsprechend:

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        chargeTime_ = 0;
        setGraphics(IMAGE_DUCK);
      }

      // Weitere Eingaben verarbeiten ...
      break;

      // Weitere Zustände ...
  }
}

Wir mussten also zwei Methoden modifizieren und der Heroine-Klasse das Datenfeld chargeTime_ hinzufügen, obwohl es nur dann sinnvoll ist, wenn sich die Heldin hinhockt. Schöner wäre es jedoch, wenn sich der ganze Code und die dazugehörigen Daten fein säuberlich an nur einem Ort zusammenfassen ließen. Gut, dass sich die Gang of Four hierfür bereits etwas hat einfallen lassen.

7.4  Das State-Pattern

Für tief in der objektorientierten Programmierung verwurzelte Entwickler bietet jede bedingte Verzweigung eine Gelegenheit für den dynamischen Nachrichtenversand (sprich den Aufruf einer virtuellen Methode in C++). Allerdings sieht für jemanden, der nur einen Hammer hat, auch alles wie ein Nagel aus. Manchmal ist schon eine if-Anweisung völlig ausreichend.

Die Sache hat einen einfachen Hintergrund: Die Gang of Four entstammt dem Smalltalk-Umfeld. In dieser Programmiersprache ist ifThen: nur eine Methode, die mit der Bedingung aufgerufen wird und für das true- und false-Objekt unterschiedliche Implementierungen besitzt.

In unserem Beispiel haben wir jedoch einen Wendepunkt erreicht, an dem ein objektorientierter Ansatz besser geeignet ist. Und das führt uns zum State-Pattern: Wir wenden nun das von der Gang of Four beschriebene Pattern auf unsere Heldin an.

7.4.1  Das Interface für den Zustand

Zunächst einmal definieren wir das Interface für den Zustand. Alle zustandsabhängigen Verhaltensweisen, für die wir bislang switch-Anweisungen verwendet haben, werden zu virtuellen Methoden dieses Interfaces. Hier sind das handleInput() und update():

class HeroineState
{
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine,
                           Input input) {}
  virtual void update(Heroine& heroine) {}
};

7.4.2  Klassen für alle Zustände

Für jeden Zustand wird eine Klasse definiert, die ein solches Interface implementiert. Ihre Methoden legen das Verhalten der Heldin fest, wenn sie sich in dem jeweiligen Zustand befindet. Der vormals in den einzelnen case-Abschnitten der switch-Anweisung befindliche Code wandert also in die den verschiedenen Zuständen zugehörigen Klassen:

class DuckingState : public HeroineState
{
public:
  DuckingState()
  : chargeTime_(0)
  {}

  virtual void handleInput(Heroine& heroine,
                             Input input) {
    if (input == RELEASE_DOWN)
    {
      // Übergang zum Zustand Stehen ...
      heroine.setGraphics(IMAGE_STAND);
    }
  }

  virtual void update(Heroine& heroine) {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      heroine.superBomb();
    }
  }

private:
  int chargeTime_;
};

Beachten Sie hier, dass außerdem chargeTime_ nicht mehr zu Heroine gehört, sondern nun Teil der DuckingState-Klasse ist. Und das ist auch gut so, denn die Variable ergibt nur in diesem Zustand einen Sinn und das Objektmodell spiegelt dies jetzt ausdrücklich wider.

7.4.3  An den Zustand delegieren

Nun fügen wir der Heroine-Klasse einen Zeiger auf den aktuellen Zustand hinzu, verzichten auf die switch-Anweisungen und delegieren stattdessen an die den verschiedenen Zuständen zugehörigen Methoden:

class Heroine
{
public:
  virtual void handleInput(Input input)
  {
     state_->handleInput(*this, input);
  }

  virtual void update() { state_->update(*this); }

  // Weitere Methoden ...
private:
  HeroineState* state_;
};

Um einen Zustandsübergang zu veranlassen, muss jetzt nur noch state_ ein Zeiger auf ein anderes HeroineState-Objekt zugewiesen werden. Das ist schon das ganze State-Pattern.

Es gibt hier Ähnlichkeiten zu den Patterns Strategy und Type Object (Typ-Objekt, Kapitel 13). Bei allen drei Patterns delegiert das Hauptobjekt an untergeordnete Objekte. Die vorhandenen Unterschiede sind beabsichtigt:

Beim Pattern Strategy soll die Hauptklasse von einem Teil ihres Verhaltens entkoppelt werden.

Beim Pattern Type Object sollen mehrere Objekte ähnliches Verhalten zeigen, indem sie eine Referenz auf ein Objekt des gegebenen Typs gemeinsam nutzen.

Beim Pattern State soll das Hauptobjekt sein Verhalten ändern, indem an verschiedene Objekte delegiert wird.

7.5  Wo sind die Zustandsobjekte?

Ich habe hier eine Kleinigkeit unter den Teppich gekehrt: Um den Zustand zu ändern, muss state_ auf ein anderes Zustandsobjekt verweisen – aber wie gelangt man an eine solche Instanz? Bei der Enumeration war das ein Kinderspiel, denn bei den Werten handelt es sich um elementare Datentypen. Die Zustände hingegen sind Klassen und wir benötigen eine Instanz. Hier sind zwei Vorgehensweisen üblich.

7.5.1  Statische Zustände

Besitzt das Zustandsobjekt keine weiteren Datenfelder, speichert es nur einen Zeiger auf die Tabelle der internen virtuellen Methoden, damit diese aufgerufen werden können.

Sollte der Zustand keine weiteren Datenfelder und nur eine einzige virtuelle Methode besitzen, lässt sich das Pattern weiter vereinfachen. Ersetzen Sie die Zustandsklasse durch eine Zustandsfunktion – eine ganz normale globale Funktion. Das state_-Datenfeld der Hauptklasse ist dann ein normaler Funktionszeiger.

In diesem Fall gibt es keinen Grund, mehrere Instanzen zu erzeugen, da sie ohnehin identisch wären. Erzeugen Sie also eine einzelne statische Instanz. Selbst wenn Sie zuhauf Zustandsautomaten verwenden, die diesen Zustand annehmen, können dennoch alle auf diese eine Instanz verweisen, da sie keine Informationen über die Automaten enthält.

Hierbei handelt es sich um das Pattern Flyweight (Fliegengewicht, Kapitel 3).

Wo genau Sie die statische Instanz erzeugen, bleibt Ihnen überlassen. Suchen Sie nach einer Stelle, die Sinn ergibt. Ich platziere den Code hier ohne besonderen Grund in der Basisklasse der Zustände:

class HeroineState
{
public:
  static StandingState standing;
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;

  // Weiterer Code ...
};

Jedes dieser statischen Datenfelder repräsentiert die eine Instanz des Zustands, die das Spiel verwendet. Um die Heldin zum Springen zu bringen, könnte nun folgender Code verwendet werden:

if (input == PRESS_B)
{
  heroine.state_ = &HeroineState::jumping;
  heroine.setGraphics(IMAGE_JUMP);
}

7.5.2  Instanziierte Zustandsobjekte

In manchen Fällen ist der soeben vorgestellte Ansatz jedoch ungeeignet. Beim In-die-Hocke-gehen würde ein statischer Zustand nicht funktionieren, weil es ein chargeTime_-Datenfeld gibt, das der Heldin zugeordnet ist, die sich gerade hinhockt. Das könnte in unserem Spiel zufälligerweise funktionieren, bei dem es zurzeit nur eine Heldin gibt, würde man das Spiel jedoch um eine Kampfgefährtin erweitern wollen, käme es zu Schwierigkeiten.

In diesem Fall müssten wir ein Zustandsobjekt erzeugen, sobald dieser Zustand angenommen wird. Jeder Zustandsautomat verfügt dann über eine eigene Instanz des Zustands. Und wenn wir einen neues Zustandsobjekt instanziieren, muss das noch aktuelle natürlich freigegeben werden. Hier ist allerdings Vorsicht geboten, denn der Code, der die Änderung verursacht, ist Teil einer Methode des aktuellen Zustands, daher können wir nicht einfach this löschen.

Bei der dynamischen Erzeugung von Zustandsobjekten muss eine mögliche Speicherfragmentierung berücksichtigt werden. Dabei ist das Pattern Object Pool (Objektpool, Kapitel 19) hilfreich.

Stattdessen ermöglichen wir es handleInput() in der Heroine-Klasse, optional ein neues Zustandsobjekt zurückzugeben. Wenn das geschieht, soll Heroine das alte löschen und durch das neue ersetzen:

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(
                              *this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
  }
}

Auf diese Weise wird das vorherige Zustandsobjekt erst gelöscht, wenn dessen Methode beendet wurde. Durch das Erzeugen einer neuen Instanz kann nun der Zustand »Stehen« in den Zustand »Hocken« übergehen:

HeroineState* StandingState::handleInput(
           Heroine& heroine, Input input)
{
  if (input == PRESS_DOWN)
  {
    // Weiterer Code ...
    return new DuckingState();
  }

  // In diesem Zustand bleiben
  return NULL;
}

Ich ziehe es vor, nach Möglichkeit statische Zustände zu verwenden, da sie nicht bei jeder Zustandsänderung Arbeitsspeicher und CPU-Zeit benötigen. Für Zustände die, nun ja, zustandsorientierter sind, ist dies jedoch die geeignete Methode.

7.6  Eintritts- und Austrittsaktionen

Ziel des State-Patterns ist die Kapselung sämtlichen Verhaltens und aller Daten eines Zustands in einer einzigen Klasse. Ein Teil dieses Ziels ist bereits erreicht, ein paar Kleinigkeiten sind jedoch noch zu erledigen. Bei einer Zustandsänderung der Heldin weisen wir ihr auch eine andere Grafik zu. Dieser Code gehört momentan zu dem Zustand, in dem sie sich vor der Änderung befindet. Wenn sie aus der Hocke aufsteht, weist ihr also das dem Zustand »Hocken« zugeordnete Objekt ein Bild zu:

HeroineState* DuckingState::handleInput(
          Heroine& heroine, Input input)
{
  if (input == RELEASE_DOWN)
  {
    heroine.setGraphics(IMAGE_STAND);
    return new StandingState();
  }
  // Weiterer Code ...
}

Es wäre jedoch wünschenswert, dass jeder Zustand die zugehörige Grafik selbst steuert. Dies wird durch das Hinzufügen einer Eintrittsaktion ermöglicht:

class StandingState : public HeroineState
{
public:
  virtual void enter(Heroine& heroine)
  {
    heroine.setGraphics(IMAGE_STAND);
  }

  // Weiterer Code ...
};

In der Heroine-Klasse modifizieren wir dann den Code, der Zustandsänderungen handhabt. Er ruft die Eintrittsaktion auf:

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(
                              *this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
    // Eintrittsaktion des neuen Zustands aufrufen
    state_->enter(*this);
  }
}

Damit vereinfacht sich der für den Zustand »Hocken« zuständige Code zu:

HeroineState* DuckingState::handleInput(
          Heroine& heroine, Input input)
{
  if (input == RELEASE_DOWN)
  {
    return new StandingState();
  }

  // Weiterer Code ...
}

Hier wird nur noch die Änderung zum Zustand »Stehen« veranlasst – um die Grafik kümmert sich der Zustand selbst. Jetzt ist der Zustand vollständig gekapselt. Das besonders Praktische an Eintrittsaktionen ist, dass sie aufgerufen werden, wenn der Zustand angenommen wird – und zwar unabhängig davon, in welchem Zustand sich das Objekt vorher befand.

Bei den meisten echten Zustandsgraphen gibt es mehrere Übergänge in ein und denselben Zustand. Beispielsweise nimmt unsere Heldin auch nach dem Hechten oder Springen wieder den Zustand »Stehen« an. Das würde dann bei jedem der Übergänge zu doppeltem Code führen. Eintrittsaktionen sind der geeignete Ort, dies zusammenzufassen.

Ebenso lässt sich ein Zustand um eine Austrittsaktion erweitern. Hierbei handelt es sich um eine Methode des Zustands, der verlassen wird, die unmittelbar vor dem Übergang in den neuen Zustand aufgerufen wird.

7.7  Wo ist der Haken?

Nun habe ich Ihnen die ganze Zeit Zustandsautomaten schmackhaft gemacht, nur um Ihnen jetzt den Teppich unter den Füßen wegzuziehen. Alles, was ich bisher geschrieben habe stimmt, Zustandsautomaten sind für manche Problemstellungen wirklich gut geeignet. Allerdings ist ihr größter Vorzug auch ihre größte Schwachstelle.

Zustandsautomaten können Ihnen dabei helfen, verwickelten Code zu entwirren, indem ihm eine sehr beengende Struktur auferlegt wird. Es bleiben lediglich eine festgelegte Anzahl an Zuständen, ein einziger aktueller Zustand sowie einige fest vorgegebene Übergänge übrig.

Ein Zustandsautomat ist noch nicht einmal Turing-vollständig. In der Automatentheorie werden Rechenmethoden durch eine Reihe abstrakter Modelle beschrieben, die zunehmend komplexer werden. Eins der ausdruckstärksten dieser Modelle wird als Turingmaschine bezeichnet.

»Turing-Vollständigkeit« bedeutet, dass ein System (für gewöhnlich eine Programmiersprache) mächtig genug ist, um damit eine Turingmaschine zu implementieren. Das wiederum bedeutet, dass alle Turing-vollständigen Sprachen in gewisser Hinsicht gleich ausdrucksstark sind. Zustandsautomaten sind nicht flexibel genug, um in diesen exklusiven Club aufgenommen zu werden.

Falls Sie versuchen, Zustandsautomaten für komplexere Aufgaben einzusetzen, wie z.B. die Spiel-KI, werden Sie die Einschränkungen dieser Herangehensweise mit voller Wucht zu spüren bekommen. Erfreulicherweise haben unsere Vorgänger Möglichkeiten gefunden, einige dieser Hindernisse zu umgehen. Ich schließe dieses Kapitel damit ab, einige davon vorzustellen.

7.8  Nebenläufige Zustandsautomaten

Wir haben uns entschlossen, der Heldin die Fähigkeit zu verleihen, eine Waffe zu tragen. Sie kann auch bewaffnet dieselben Bewegungszustände annehmen wie zuvor: Laufen, Springen, Hocken usw. Allerdings soll sie auch in der Lage sein, die Waffe dabei abzufeuern.

Sofern wir bei einem Zustandsautomaten bleiben, muss die Anzahl der Zustände verdoppelt werden. Zu jedem der bereits vorhandenen Zustände kommt ein weiterer hinzu, der dem Umstand Rechnung trägt, dass sie bewaffnet ist: Stehen, Stehen mit Waffe, Springen, Springen mit Waffe usw.

Falls jetzt noch verschiedene Waffentypen dazukommen, wächst die Anzahl der möglichen Zustände gemäß den Regeln der Kombinatorik: Sie explodiert geradezu.

Wenn n Bewegungszustände und m Waffen durch einen einzigen Automaten abgebildet werden, sind n x m Zustände erforderlich. Bei zwei Automaten sind es nur n + m Zustände.

Außerdem kommt es dabei nicht nur zu einer riesigen Anzahl von Zuständen, sondern auch zu einem hohen Maß an Redundanz: Die bewaffneten und unbewaffneten Zustände sind fast identisch – bis auf das bisschen Code zum Abfeuern der Waffe.

Das Problem entsteht dadurch, dass wir zwei unterschiedliche Arten eines Zustands, nämlich wie sich die Heldin bewegt und was sie trägt, in nur einen Zustandsautomaten gepfercht haben. Um alle möglichen Kombinationen abzubilden, bräuchten wir einen Zustand für jede Paarung aus Bewegungszustand und Waffenausstattung. Und die naheliegende Lösung lautet: zwei unabhängige Zustandsautomaten

Wir lassen den Automaten für den Bewegungszustand also unangetastet und definieren einen zweiten Zustandsautomaten für die Ausstattung. Heroine verfügt dann über zwei Referenzen auf Zustände:

class Heroine
{
  // Weiterer Code ...

  private:
    HeroineState* state_;
    HeroineState* equipment_;
};

Der Anschaulichkeit halber verwenden wir für die Ausstattung hier das vollständige State-Pattern. Da es nur zwei Zustände gibt, wäre in der Praxis ein boolesches Flag ausreichend.

Wenn die Heroine-Klasse Eingaben an die Zustände delegiert, werden beide berücksichtigt:

void Heroine::handleInput(Input input)
{
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}

Bei einem ausgereifteren System würde es vermutlich die Möglichkeit geben, dass einer der beiden Zustandsautomaten eine Eingabe »verschluckt«, damit der andere sie nicht bemerkt. Dadurch wird verhindert, dass irrtümlicherweise beide Automaten versuchen, auf dieselbe Eingabe zu reagieren.

Auf diese Weise können beide Zustandsautomaten unabhängig voneinander auf Eingaben reagieren, Verhalten auslösen und ihren Zustand ändern. Sofern sich die beiden Zustandsarten nicht gegenseitig beeinflussen, funktioniert das bestens.

In der Praxis kommt es jedoch manchmal vor, dass Zustände miteinander wechselwirken. Beispielsweise könnte die Heldin möglicherweise beim Springen nicht feuern oder nicht hechten, während sie bewaffnet ist. Hier würde man vermutlich im Code des einen Zustands eine ganz ordinäre if-Abfrage des Zustands des anderen Automaten vornehmen, um das Verhalten zu koordinieren. Das ist zwar nicht die eleganteste Lösung, sie erfüllt jedoch durchaus ihren Zweck.

7.9  Hierarchische Zustandsautomaten

Nachdem das Verhalten unserer Heldin etwas weiter ausgestaltet ist, wird sie wahrscheinlich über eine ganze Reihe ähnlicher Zustände verfügen. Es könnte beispielsweise die Zustände Stehen, Gehen, Rennen oder Rutschen geben. Durch Betätigung der Taste B springt sie und durch Drücken der Pfeil unten geht sie in die Hocke.

Bei einer einfachen Implementierung eines Zustandsautomaten muss dieser Code für die verschiedenen Zustände dupliziert werden. Vernünftiger wäre es jedoch, ihn nur einmalig zu implementieren und in den verschiedenen Zuständen wiederzuverwenden.

Würde es sich hier nicht um einen Zustandsautomaten, sondern um rein objektorientierten Code handeln, wäre der Gebrauch des Vererbungsprinzips eine Möglichkeit, den Code wiederzuverwenden: Wir könnten ein Klasse für den Zustand »Auf dem Boden« erstellen, die das Springen und In-die-Hocke-gehen handhabt, von der Stehen, Gehen, Rennen und Rutschen erben und dann ihre eigenen Verhaltensweisen hinzufügen.

Dieses Vorgehen hat Vor- und Nachteile. Die Vererbung ist zwar eine leistungsfähige Methode zur Wiederverwendung von Code, bedeutet aber auch eine enge Kopplung zweier Codeabschnitte. Sie ist ein mächtiges Werkzeug – seien Sie besser dementsprechend vorsichtig.

Tatsächlich handelt es sich hier um eine gängige Struktur, die als hierarchischer Zustandsautomat bezeichnet wird. Ein Zustand kann einen übergeordneten Zustand besitzen (wodurch er selbst zu einem Subzustand wird). Wenn Ereignisse eintreten, die ein Subzustand nicht handhabt, werden sie der Reihe nach an die übergeordneten Zustände weitergeleitet. Das Ganze funktioniert genauso wie das Überschreiben ererbter Methoden.

Tatsächlich können wir die Klassenvererbung verwenden, um die Hierarchie abzubilden, wenn wir zur Implementierung des Zustandsautomaten das State-Pattern einsetzen. Zunächst wird für den übergeordneten Zustand eine Basisklasse definiert:

class OnGroundState : public HeroineState
{
public:
  virtual void handleInput(Heroine& heroine,
                                Input input)
  {
    if (input == PRESS_B) // Springen ...
    else if (input == PRESS_DOWN) // Hocken ...
  }
};

Von dieser Basisklasse erben die Subzustände:

class DuckingState : public OnGroundState
{
public:
  virtual void handleInput(Heroine& heroine,
                                Input input)
  {
    if (input == RELEASE_DOWN)
    {
       // Aufstehen ...
    }
    else
    {
      // Eingabe wurde nicht verarbeitet, also
      // dem übergeordneten Zustand übergeben
      OnGroundState::handleInput(heroine, input);
    }
  }
};

Dies ist natürlich nicht die einzige Möglichkeit, die Hierarchie zu implementieren. Wenn Sie nicht das Pattern der Gang of Four verwenden, funktioniert diese Methode nicht. Statt in der übergeordneten Klasse nur einen einzigen Zustand zu verwenden, können Sie jedoch auch explizit einen Stack mit Zuständen definieren, um die übergeordneten Zustände des aktuellen Zustands abzubilden.

Der aktuelle Zustand befindet sich ganz oben auf dem Stack. Darunter folgt der unmittelbar übergeordnete Zustand, dann dessen übergeordneter Zustand usw. Falls nun zustandsspezifische Ereignisse auftreten, durchlaufen Sie den Stack von oben nach unten, bis einer der Zustände sich des Ereignisses annimmt – oder ignorieren es andernfalls.

7.10  Kellerautomaten

Es gibt noch eine weitere gebräuchliche Erweiterung von Zustandsautomaten, bei der ebenfalls ein Stack von Zuständen Anwendung findet. Verwirrenderweise stellt er hier allerdings etwas völlig anderes dar und wird zur Lösung eines weiteren Problems eingesetzt.

Dieses Problem besteht darin, dass bei Zustandsautomaten das Konzept eines Verlaufs fehlt. Der aktuelle Zustand ist bekannt, nicht aber die vorhergehenden Zustände – und daher ist es problematisch, zum vorigen Zustand zurückzukehren.

Ein Beispiel: Vorhin haben wir es unserer furchtlosen Heldin gestattet, sich bis an die Zähne zu bewaffnen. Wenn sie ihre Waffe abfeuert, benötigen wir einen zusätzlichen Zustand, der eine Animation der verschossenen Munition und andere visuelle Effekte abspielt. Wir legen also einen Zustand FiringState an und sorgen dafür, dass alle Zustände, in denen die Waffe abgefeuert werden kann, in diesen Zustand übergehen, wenn die Feuer-Taste gedrückt wird.

Auch dieses Verhalten kommt in mehreren Zuständen vor, daher wäre hier womöglich ebenfalls der Einsatz eines hierarchischen Zustandsautomaten zwecks Wiederverwendung von Code angebracht.

Aber in welchen Zustand soll die Heldin nach dem Abfeuern der Waffe übergehen? Sie könnte die Waffe ja sowohl im Stehen als auch beim Gehen, Rennen, Springen oder im Hocken abgefeuert haben. Nach der beim Abfeuern abgespielten Animation sollte sie wieder in den vorherigen Zustand übergehen.

Beim 08/15-Zustandsautomaten ist diese Information jedoch schon verloren. Um sie zu speichern, bräuchten wir eine Menge nahezu identischer Zustände – Feuern im Stehen, Feuern beim Rennen, Feuern beim Springen usw. –, in denen jeweils fest vorgegeben wäre, welcher Zustand angenommen werden soll.

Wir müssten den Zustand, in dem sich die Heldin vor dem Abfeuern ihrer Waffe befand, speichern können, um ihn später wieder abzurufen. Und auch hier weiß die Automatentheorie Rat: die entsprechende Datenstruktur heißt Kellerautomat (und ist eine Form einer Stackmaschine).

Während ein herkömmlicher Zustandsautomat nur einen einzigen Zeiger auf einen Zustand besitzt, verfügt ein Kellerautomat über einen Stack von Zeigern. Findet ein Übergang in einen anderen Zustand statt, wird der Zeiger bei einem Zustandsautomaten ersetzt. Das ist beim Kellerautomaten ebenfalls möglich, es gibt jedoch zwei weitere Optionen:

Sie können den neuen Zustand auf dem Stack ablegen (Push). Der aktuelle Zustand befindet sich stets ganz oben, also wird in diesem Fall der neue Zustand angenommen, jedoch bleiben die vorherigen Zustände auf dem Stack erhalten.

Sie können den obersten auf dem Stack befindlichen Zustand entfernen (Pop). Er wird gelöscht und der darunter befindliche Zustand wird zum aktuellen.

Abb. 7.2: Pushing und Popping, nicht zu verwechseln mit dem Tanzstil Popping und Locking

Das ist genau das, was wir beim Abfeuern der Waffe brauchen. Wir erstellen einen einzigen »Feuern«-Zustand. Befindet sich die Heldin in irgendeinem anderen Zustand, legen wir ihn bei Betätigung der Feuer-Taste auf dem Stack ab. Sobald die Animation beendet ist, entfernen wir ihn wieder und der Kellerautomat sorgt dafür, dass der vorherige Zustand wieder eingenommen wird.

7.11  Wie nützlich sind sie?

Selbst mit den vorgestellten Erweiterungen sind die Einsatzmöglichkeiten von Zustandsautomaten doch ziemlich begrenzt. Heutzutage geht der Trend bei der Spiele-KI mehr in Richtung Verhaltensbäume und Planungssysteme. Falls Sie sich für komplexe KI interessieren und dieses Kapitel Ihre Neugier geweckt hat, sollten Sie sich nach weiterführender Literatur umsehen, um diese Neugier zu befriedigen.

Das soll aber keinesfalls heißen, dass Zustandsautomaten, Kellerautomaten und vergleichbare, einfache Systeme nicht nützlich sind. Für manche Arten von Problemen sind sie bestens geeignet, insbesondere dann, wenn

sich das Verhalten einer Figur oder eines Objekts aufgrund interner Zustände ändert,

sich dieser Zustand streng in eine vergleichsweise kleine Anzahl verschiedener Möglichkeiten unterteilen lässt, oder

die Figur oder das Objekt im Spielverlauf auf eine Reihe verschiedener Eingaben oder Ereignisse reagieren muss.

In Spielprogrammen werden Zustandsautomaten vornehmlich für die KI genutzt, sie werden jedoch auch häufig zur Verarbeitung von Eingaben, zur Navigation in Menüs, zum Parsen von Text, für Netzwerkprotokolle sowie für weiteres asynchrones Verhalten eingesetzt.

Teil III: Sequenzierungsmuster (Sequencing Patterns)

In diesem Teil:

Kapitel 8

Double Buffer (Doppelter Buffer)

Kapitel 9

Game Loop (Hauptschleife)

Kapitel 10

Update Method (Aktualisierungsmethode)

Videospiele sind vor allem deswegen spannend, weil sie uns in eine andere Welt entführen. Ein paar Minuten lang (na ja, seien wir ehrlich, in der Regel doch viel länger) werden wir zu Einwohnern einer virtuellen Welt. Sich solche Welten auszudenken, gehört zu den größten Freuden bei der Arbeit als Spieleprogrammierer.

Einem Aspekt schenken die meisten dieser Spiele Beachtung: der Zeit – die künstliche Welt lebt und wandelt sich in ihrem eigenen Rhythmus. Als Weltenerbauer müssen wir die Zeit erfinden und eine Mechanik ersinnen, damit der Lauf der Dinge in der Spielwelt nicht zum Stillstand kommt.

Die Patterns in diesem Teil des Buches sind Werkzeuge, die genau das leisten sollen. Die Game Loop (Hauptschleife, Kapitel 9) ist der Dreh- und Angelpunkt des Spiels, der die Uhr antreibt. Die Objekte hören deren Uhrwerk dank des Patterns Update Method (Aktualisierungsmethode, Kapitel 10) ticken. Und die sequenzielle Natur des Computers lässt sich mittels des Patterns Double Buffer (Doppelter Buffer, Kapitel 8) hinter einer Fassade aus Momentaufnahmen verbergen: Die gesamte Spielwelt wird scheinbar zeitgleich aktualisiert.

Kapitel 8: Double Buffer (Doppelter Buffer)

Auslösung einer Reihe scheinbar augenblicklich oder gleichzeitig stattfindender sequenzieller Operationen.

8.1  Motivation

Im Grunde genommen sind Computer sequenzielle Untiere.

Angesichts von Threading und Mehrkernarchitekturen ist das eigentlich nicht ganz richtig, allerdings laufen auch bei mehreren Prozessorkernen nur einige wenige Prozesse wirklich parallel.

Ihre Leistungsfähigkeit verdanken sie der Tatsache, dass umfangreiche Aufgaben in winzige Schritte zerlegt werden können, die nacheinander ausführbar sind. In unserem Fall ist es jedoch oftmals erforderlich, dass der Spieler den Eindruck bekommt, mehrere Dinge würden gleichzeitig geschehen.

Ein typisches Beispiel hierfür ist das Rendern – und davon ist jede Spiel-Engine betroffen. Das Spiel zeichnet die Welt, die der Spieler zu sehen bekommt stückweise – die entfernten Berge, die Hügellandschaft, die Bäume, ein Objekt nach dem anderen. Könnte der Spieler dabei zusehen, wie alles nacheinander erzeugt wird, würde das die Illusion einer zusammenhängenden Spielwelt zunichte machen. Stattdessen muss sich die Ansicht schnell und fließend aufbauen und komplette Frames darstellen, die sofort am Bildschirm ausgegeben werden.

Das Double Buffering löst dieses Problem. Um allerdings die Funktionsweise dieses Verfahrens zu verstehen, müssen wir zunächst einmal rekapitulieren, wie ein Computer Grafik darstellt.

8.1.1  Computergrafik kurz und bündig

Ein Anzeigegerät wie ein Monitor mit Kathodenstrahlröhre zeichnet die Pixel einzeln. Der Kathodenstrahl überstreicht eine Zeile blitzschnell von links nach rechts, dann kommt die Zeile darunter an Reihe. Wenn die untere rechte Ecke erreicht wird, springt der Kathodenstrahl nach oben links und der Vorgang beginnt wieder von vorne. Das geschieht so schnell – etwa 60 Mal pro Sekunde –, dass unser Auge es nicht wahrnimmt. Wir sehen eine scheinbar stillstehende, mit farbigen Pixeln gefüllte Fläche – ein Bild.

Diese Erklärung ist, nun ja, sagen wir »stark vereinfacht«. Sollten Sie Hardwareexperte sein und an dieser Stelle zusammenzucken, können Sie diesen Abschnitt gern überspringen, denn dann wissen Sie bereits genug, um diesem Kapitel folgen zu können. Falls Sie kein Hardwareexperte sind: Mein Ziel ist es hier, kurz die Grundlagen darzustellen, um das nachfolgend erörterte Pattern verständlich zu machen.

Stellen Sie sich das Ganze wie einen winzigen Wasserschlauch vor, der kein Wasser, sondern Pixel auf den Bildschirm sprüht. Am anderen Ende des Schlauchs werden die verschiedenen Farben eingefüllt, die dann auf dem Bildschirm verteilt werden, wobei jedes Pixel seine eigene Farbe erhält. Doch woher kommen die Informationen, wo welche Farbe hingehört?

Bei den meisten Computern entstammen sie einem sogenannten Framebuffer. Dabei handelt es sich um einen Speicherbereich im RAM, in dem jeweils einige Bytes die Farbe eines einzelnen Pixels repräsentieren. Während der Kathodenstrahl den Bildschirm überstreicht, werden aus diesem Speicher Byte für Byte die Farbwerte ausgelesen.

Die genaue Zuordnung der Werte der Bytes zu Farben wird durch das Pixelformat und die Farbtiefe des Systems beschrieben. Die meisten Spielkonsolen verwenden für jedes Pixel 32 Bit: jeweils acht für die Farbkanäle Rot, Grün und Blau sowie weitere acht, die für verschiedene andere Zwecke einsetzbar sind.

Um unser Spiel auf dem Bildschirm darzustellen, müssen wir tatsächlich nur diesen Framebuffer mit Werten befüllen. All die abgehobenen Grafikalgorithmen zielen letzten Endes genau darauf ab: Werte in den Framebuffer zu schreiben. Allerdings gibt es dabei ein kleines Problem.

Eingangs habe ich geschrieben, dass Computer sequenziell arbeiten – es ist also nicht zu erwarten, dass sie beim Abarbeiten unseres Rendering-Codes zur selben Zeit irgendwelche anderen Aufgaben erledigen. Das ist im Großen und Ganzen auch richtig, allerdings finden während der Ausführung unseres Programms eben doch ein paar andere Abläufe statt. Dazu gehört etwa, dass immer wieder der Buffer ausgelesen wird, während das Spiel läuft – und das kann Schwierigkeiten bereiten.

Angenommen, wir wollen ein lachendes Gesicht auf dem Bildschirm darstellen. Unser Programm beginnt nun damit, den Framebuffer zu befüllen, um die Pixel einzufärben. Dass der Grafiktreiber währenddessen den Buffer ausliest, bemerken wir gar nicht. Beim Scannen der von uns eingefärbten Pixel erscheint allmählich das lachende Gesicht auf dem Bildschirm. Doch nun überholt der Grafiktreiber uns und liest Pixel ein, die wir noch gar nicht geschrieben haben. Auf diese Weise entsteht das sogenannte Tearing, »zerrissene« Einzelbilder mit unschönen Effekten, den »Artefakten« – ein grauenhafter visueller Bug, bei dem nur die Hälfte eines Objekts auf dem Bildschirm dargestellt wird.

Aus diesem Grund benötigen wir das Pattern. Unser Programm zeichnet die Pixel der Reihe nach, der Grafiktreiber muss sie jedoch alle gleichzeitig einlesen – in einem Frame ist das Gesicht noch nicht vorhanden, im nachfolgenden aber sehr wohl. Double Buffering löst dieses Problem. Wie das funktioniert, möchte ich anhand einer Analogie erläutern.

Abb. 8.1: Tearing: Wir fangen an, die Pixel zu zeichnen, während der Grafiktreiber beginnt, den Buffer einzulesen (1). Der Grafiktreiber holt den Renderer ein, lässt ihn dann hinter sich und gelangt zu Pixeln, die wir noch gar nicht gezeichnet haben (2). Wir schließen das Zeichnen ab, der Treiber bekommt die neu hinzugefügten Pixel jedoch nicht mehr zu sehen (3). Das Resultat (4): Der Benutzer sieht nur die Hälfte der Zeichnung. Das Phänomen verdankt seine Bezeichnung »Tearing« (engl. to tear = zerreißen) der Tatsache, dass es so aussieht, als ob der untere Teil des Bildes abgerissen worden sei.

8.1.2  Erster Akt, erste Szene

Nehmen wir an, die Benutzer sehen sich ein selbstgeschriebenes Schauspiel an. Nach dem Ende der ersten und vor Beginn der zweiten Szene muss das Bühnenbild geändert werden. Würden die Bühnenarbeiter die Kulissen für die Zuschauer erkennbar verschieben, wäre die Illusion eines stimmigen Bühnenbildes damit dahin. Man könnte die Bühne zwar abdunkeln oder Vorhänge fallen lassen (so wie es in echten Theatern geschieht), dem Publikum wäre aber dennoch klar, dass irgendetwas vor sich geht. Nein, die Szenen sollen nahtlos ineinander übergehen.

Sofern genügend Platz vorhanden ist, gibt es eine clevere Lösung: Wir errichten zwei für das Publikum sichtbare Bühnen mit jeweils eigener Beleuchtung, die wir als Bühne A und Bühne B bezeichnen. Die erste Szene findet auf Bühne A statt. Währenddessen liegt Bühne B völlig im Dunkeln und die Bühnenarbeiter richten das Bühnenbild für die zweite Szene her. Sobald die erste Szene endet, wird die Beleuchtung von Bühne A ab- und die von Bühne B eingeschaltet. Das Publikum wendet seine Aufmerksamkeit der Bühne B zu und es geht sofort mit der zweiten Szene weiter.

Mittels eines halbdurchlässigen Spiegels und einem geschickten Aufbau wäre es möglich, dass beide Bühnen am selben Ort für das Publikum sichtbar sind. Sobald die Beleuchtung wechselt, würde die andere Bühne sichtbar, ohne dass die Blickrichtung gewechselt werden müsste. Die Umsetzung dieser Idee bleibt als interessante Übung dem geneigten Leser überlassen.

Die Bühnenarbeiter begeben sich zur nun unbeleuchteten Bühne A und bauen das Bühnenbild für die dritte Szene auf. Sobald die zweite Szene endet, wird wieder Bühne A beleuchtet. Das wird während des gesamten Schauspiels so fortgesetzt: Die unbeleuchtete Bühne dient als Arbeitsbereich zum Aufbau des Bühnenbildes der nachfolgenden Szene. Bei jedem Szenenwechsel muss nur noch die Beleuchtung umgeschaltet werden. Dem Publikum wird eine durchgehende Vorstellung geboten und die Bühnenarbeiter bekommt es nicht zu Gesicht.

8.1.3  Zurück zur Grafik

Auf genau diese Weise funktioniert auch das Double Buffering. Diese Vorgänge liegen dem Rendering-System so ziemlich jeden Spiels zugrunde, das Sie je gesehen haben.

Tatsächlich arbeiten nicht ausnahmslos alle Spiele und Konsolen nach diesem Prinzip. Ältere und einfachere Spielkonsolen, bei denen der Arbeitsspeicher knapp bemessen ist, synchronisieren stattdessen das Zeichnen der Grafik mit der Bildwiederholfrequenz – ein ziemlich knifflige Angelegenheit.

Statt eines Framebuffers gibt es zwei. Einer davon repräsentiert den aktuellen Frame – das entspricht in der Analogie der Bühne A. Die Grafikhardware liest diesen Speicher aus, und die GPU kann nach Belieben und zu jedem Zeitpunkt darauf zugreifen.

Der Rendering-Code hingegen beschreibt den zweiten Buffer – die unbeleuchtete Bühne B. Sobald der Code das Zeichnen abgeschlossen hat, werden die beiden Buffer vertauscht. Die Grafikhardware liest dann statt des ersten den zweiten Buffer aus. Sofern dieses Umschalten nach dem Zeichnen einer Szene zum richtigen Zeitpunkt erfolgt, kommt es nicht zu einem Tearing – die vollständige Szene erscheint gleichzeitig auf dem Bildschirm.

Nun steht der alte Buffer wieder zur Verfügung und wir können die nächste Szene zeichnen. Voilà!

8.2  Das Pattern

Eine gebufferte Klasse verkapselt einen Framebuffer, also einen veränderlichen Speicherbereich. Dieser Buffer wird schrittweise bearbeitet, jedoch sollen sämtliche Modifizierungen für außenstehenden Code gleichzeitig als eine einzige, atomare Änderung sichtbar werden. Zu diesem Zweck besitzt die Klasse zwei Instanzen dieses Buffers: den kommenden Buffer und den aktuellen Buffer.

Die ausgelesenen Informationen entstammen stets dem aktuellen Buffer. Werden hingegen Informationen geschrieben, wird dafür der kommende Buffer verwendet. Sobald die Änderungen abgeschlossen sind, werden die beiden Buffer vertauscht, so dass der modifizierte Buffer sichtbar wird. Der ehemals aktuelle Buffer steht dann zur Wiederverwendung als nächster kommender Buffer zur Verfügung.

8.3  Anwendbarkeit

Dieses Pattern gehört zu denjenigen, bei denen Sie wissen werden, wann Sie sie brauchen. Falls einem System das Double Buffering fehlt, ist dies vermutlich an visuellen Störungen (Tearing und dergleichen) oder anderem Fehlverhalten erkennbar. Nun ist es etwas dürftig, einfach zu behaupten, Sie würden schon wissen, wann die Anwendung dieses Patterns angezeigt ist – dies ist dann der Fall, wenn alle folgenden Beobachtungen zutreffen:

Es gibt einen Zustand, der schrittweise modifiziert wird.

Auf diesen Zustand wird womöglich während der Modifizierung zugegriffen.

Es soll verhindert werden, dass der auf den Zustand zugreifende Code die noch in Arbeit befindlichen Modifizierungen bemerkt.

Der Zustand soll jederzeit lesbar sein, ohne darauf warten zu müssen, dass Modifizierungen abgeschlossen werden.

8.4  Konsequenzen

Im Gegensatz zu größeren architektonischen Patterns wird das Double Buffering vergleichsweise maschinennah implementiert. Aus diesem Grund zieht es auch weniger Konsequenzen für die übrige Codebasis nach sich – für den größten Teil des Spiels macht es überhaupt keinen Unterschied. Dennoch gibt es einige Vorbehalte.

8.4.1  Der Austausch selbst kostet Zeit

Das Double Buffering erfordert den Austausch der Framebuffer, sobald die Modifizierungen abgeschlossen sind. Dieser Vorgang muss atomar stattfinden – während des Austauschs kann auf beide Zustände nicht zugegriffen werden. In vielen Fällen muss zu diesem Zweck lediglich ein Zeiger zugewiesen werden, sollte es jedoch länger dauern, den Austausch durchzuführen, als die Modifizierungen vorzunehmen, ist uns damit wirklich nicht geholfen.

8.4.2  Zwei Framebuffer belegen mehr Arbeitsspeicher

Eine weitere Konsequenz dieses Patterns ist der erhöhte Bedarf an Arbeitsspeicher. Wie der Name schon sagt, erfordert das Pattern Double Buffer, dass sich permanent zwei Kopien des Zustands im RAM befinden. Bei Geräten mit begrenztem Arbeitsspeicher ist das womöglich ein zu hoher Preis, der gezahlt werden müsste. Falls es nicht tragbar ist, zwei Buffer zu verwenden, müssen Sie nach einer anderen Möglichkeit suchen, um zu verhindern, dass während der Modifizierungen auf den Zustand zugegriffen wird.

8.5  Beispielcode

Nachdem der theoretische Teil nun erledigt ist, wollen wir uns ansehen, wie das Pattern in der Praxis funktioniert. Dafür verwenden wir ein ganz schlichtes Grafiksystem, das es gestattet, in einem Framebuffer Pixel zu zeichnen. Bei den meisten Spielkonsolen und PCs wird dieser maschinennahe Teil des Grafiksystems vom Treiber bereitgestellt. Die manuelle Implementierung ermöglicht es jedoch, den Vorgang genau zu verfolgen. Hier der Code für den eigentlichen Buffer:

class Framebuffer
{
public:
  // Konstruktor und Methoden ...

private:
  static const int WIDTH = 160;
  static const int HEIGHT = 120;
  char pixels_[WIDTH * HEIGHT];
};

Er verfügt über grundlegende Operationen, um den gesamten Speicherbereich auf eine Standardfarbe zurückzusetzen sowie einzelnen Pixeln eine Farbe zuzuweisen:

void Framebuffer::clear()
{
  for (int i = 0; i < WIDTH * HEIGHT; i++)
  {
    pixels_[i] = WHITE;
  }
}

void Framebuffer::draw(int x, int y)
{
  pixels_[(WIDTH * y) + x] = BLACK;
}

Der arithmetische Ausdruck rechnet 2D-Koordinaten in ein Pixel-Array um.

Darüber hinaus gibt es eine Funktion, um auf den Speicherbereich zuzugreifen, der die Rohdaten der Pixel enthält:

const char* Framebuffer::getPixels()
{
  return pixels_;
}

Anhand des Beispiels ist das zwar nicht erkennbar, jedoch ruft der Grafiktreiber diese Funktion häufig auf, um Inhalte aus dem Buffer auf dem Bildschirm anzuzeigen. Wir verpacken diesen Framebuffer für Rohdaten in eine Scene-Klasse. Sie übernimmt hier die Aufgabe, mittels draw()-Aufrufen etwas auf dem Bildschirm darzustellen:

class Scene
{
public:
  void draw()
  {
    buffer_.clear();
    buffer_.draw(1, 1); buffer_.draw(4, 1);
    buffer_.draw(1, 3); buffer_.draw(2, 4);
    buffer_.draw(3, 4); buffer_.draw(4, 3);
  }

  Framebuffer& getBuffer() { return buffer_; }

private:
  Framebuffer buffer_;
};

Abb. 8.2: Ein künstlerisches Meisterwerk: eine Art Gesicht

In jedem Frame wird die Scene-Klasse angewiesen zu zeichnen. Zunächst wird der Buffer gelöscht, dann werden der Reihe nach einige Pixel gezeichnet. Außerdem ermöglicht sie mittels getBuffer() den Zugriff auf die internen Daten, damit der Grafiktreiber sie abrufen kann.

Das Ganze erscheint ziemlich schnörkellos – wenn wir es jedoch dabei belassen, werden wir auf Schwierigkeiten stoßen. Das Problem besteht darin, dass der Grafiktreiber die Funktion getPixels() jederzeit aufrufen kann, sogar an dieser Stelle:

buffer_.draw(1, 1); buffer_.draw(4, 1);
// <- Hier greift der Grafiktreiber auf Pixel zu!
buffer_.draw(1, 3); buffer_.draw(2, 4);
buffer_.draw(3, 4); buffer_.draw(4, 3);

Wenn es dazu kommt, sieht der Benutzer zwar die Augen, der Mund fehlt jedoch in einem Frame. Und im darauffolgenden Frame könnte das Zeichnen an anderer Stelle unterbrochen werden. Das Endresultat wäre jedenfalls eine furchtbar flackernde Grafik. Dies wird durch das Double Buffering behoben:

class Scene
{
public:
  Scene()
  : current_(&buffers_[0]),
    next_(&buffers_[1])
  {}

  void draw()
  {
    next_->clear();
    next_->draw(1, 1);
    // ...
    next_->draw(4, 3);
    swap();
  }

  Framebuffer& getBuffer() { return *current_; }

private:
  void swap()
  {
    // Nur die Zeiger vertauschen
    Framebuffer* temp = current_;
    current_ = next_;
    next_ = temp;
  }

  Framebuffer  buffers_[2];
  Framebuffer* current_;
  Framebuffer* next_;
};

Nun besitzt Scene zwei Buffer, die im buffer_-Array gespeichert werden. Diese werden jedoch nicht direkt über das Array referenziert, stattdessen gibt es die beiden Membervariablen next_ und current_, die auf die Inhalte dieses Arrays verweisen. Beim Zeichnen wird der durch next_ referenzierte Buffer verwendet. Wenn hingegen der Grafiktreiber Pixel abruft, greift er auf den durch current_ referenzierten Buffer zu.

Auf diese Weise bekommt der Grafiktreiber nie den Buffer zu Gesicht, den wir gerade bearbeiten. Das letzte Mosaiksteinchen ist der Aufruf von swap(), nachdem Scene das Zeichnen des Frames abgeschlossen hat. Hier werden einfach durch den Austausch der Zeiger next_ und current_ die beiden Buffer vertauscht. Wenn der Grafiktreiber nun das nächste Mal getBuffer() aufruft, erhält er den soeben fertiggestellten neuen Buffer und stellt ihn auf dem Bildschirm dar. Tearing oder unschönes Flackern tritt jetzt nicht mehr auf.

8.5.1  Nicht nur Grafik

Das Double Buffering löst das Problem, dass auf einen Zustand während dessen Modifizierung zugegriffen wird. Dafür gibt es zwei typische Ursachen. Die erste haben wir soeben mit dem Grafikbeispiel abgehandelt: Auf den Zustand wird durch Code in einem anderen Thread oder Interrupt direkt zugegriffen.

Es gibt aber noch einen weiteren, ähnlich häufigen Auslöser: Wenn der Code, der die Modifizierung vornimmt, selbst auf den Zustand zugreift, den er gerade modifiziert. Dergleichen kann an einer Vielzahl verschiedener Stellen vorkommen, insbesondere bei Physik- und KI-Engine, also wenn unterschiedliche Objekte miteinander interagieren. Hier erweist sich das Double Buffering häufig ebenfalls als nützlich.

8.5.2  Künstliche Unintelligenz

Angenommen, wir entwickeln das Verhaltenssystem für ein Spiel, das ausgerechnet auf einer Slapstick-Komödie beruht. In diesem Spiel gibt es eine Bühne, auf der die Mitwirkenden ihre Faxen machen und groben Unfug veranstalten. Hier die Klasse eines einfachen Akteurs:

class Actor
{
public:
  Actor() : slapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;

  void reset()      { slapped_ = false; }
  void slap()       { slapped_ = true; }
  bool wasSlapped() { return slapped_; }

private:
  bool slapped_;
};

In jedem Frame muss das Spiel die update()-Methode der actor-Klasse aufrufen, damit sie Gelegenheit erhält, Aktualisierungen vorzunehmen. Aus Sicht des Spielers ist entscheidend, dass alle Akteure gleichzeitig aktualisiert werden.

Hierbei handelt es sich um ein Beispiel für das Pattern Update Method (Aktualisierungsmethode, Kapitel 10).

Die Akteure können außerdem miteinander interagieren, sofern man unter »interagieren« versteht, dass Backpfeifen verteilt werden. Bei der Aktualisierung kann jeder Akteur die slap()-Funktion anderer Akteure aufrufen, um ihnen eine Ohrfeige zu verpassen und durch den Aufruf der Funktion wasSlapped() feststellen, ob sie bereits geohrfeigt wurden.

Stellen wir unseren Protagonisten also eine Bühne zur Verfügung, auf der sie interagieren können:

class Stage
{
public:
  void add(Actor* actor, int index)
  {
    actors_[index] = actor;
  }

  void update()
  {
    for (int i = 0; i < NUM_ACTORS; i++)
    {
      actors_[i]->update();
      actors_[i]->reset();
    }
  }

private:
  static const int NUM_ACTORS = 3;
  Actor* actors_[NUM_ACTORS];
};

Die Klasse Stage gestattet es, Akteure hinzuzufügen und stellt zudem auch eine update()-Methode zum Aktualisieren der Akteure bereit. Aus Sicht des Benutzers scheinen sich die Akteure gleichzeitig zu ändern, auch wenn sie intern der Reihe nach aktualisiert werden.

Hier ist noch zu erwähnen, dass der Zustand eines Akteurs unmittelbar nach der Aktualisierung durch reset() zurückgesetzt wird, damit er nur einmalig auf eine Ohrfeige reagiert.

Zunächst einmal definieren wir eine konkrete actor-Unterklasse. Diese Art Komiker ist von eher schlichtem Gemüt: Er blickt nur einen einzelnen anderen Akteur an, den er ohrfeigt, wenn er selbst von irgendjemandem geohrfeigt wird.

class Comedian : public Actor
{
public:
  void face(Actor* actor) { facing_ = actor; }

  virtual void update()
  {
    if (wasSlapped()) facing_->slap();
  }

private:
  Actor* facing_;
};

Sehen wir uns nun einmal an, was geschieht, wenn sich drei Akteure auf der Bühne befinden. Jeder Akteur blickt den nächsten und der letzte den ersten an, so dass sich ein geschlossener Kreis ergibt:

Stage stage;

Comedian* hansdampf = new Comedian();
Comedian* kahlkopf = new Comedian();
Comedian* chrisdoof = new Comedian();

hansdampf->face(kahlkopf);
kahlkopf->face(chrisdoof);
chrisdoof->face(hansdampf);

stage.add(hansdampf, 0);
stage.add(kahlkopf, 1);
stage.add(chrisdoof, 2);

Damit ergibt sich die in Abbildung 8.3 dargestellte Situation. Ein Pfeil zeigt an, wen der Akteur anblickt und die Nummer gibt den Index im actors_-Array der Stage-Klasse an.

Abb. 8.3: Gewalt in Computerspielen

Um die Sache in Gang zu bringen, ohrfeigen wir Hansdampf und betrachten, wie die Verarbeitung vonstatten geht:

hansdampf->slap();
stage.update();

Beachten Sie hier, dass die update()-Methode der Stage-Klasse die Akteure der Reihe nach aktualisiert. Es ergibt sich somit folgender Ablauf:

Stage aktualisiert Akteur 0 (Hansdampf).

Hansdampf wurde geohrfeigt, also ohrfeigt er Kahlkopf.

Stage aktualisiert Akteur 1 (Kahlkopf).

Kahlkopf wurde geohrfeigt und ohrfeigt deshalb Chrisdoof.

Stage aktualisiert Akteur 2 (Chrisdoof).

Chrisdoof wurde geohrfeigt und ohrfeigt schließlich Hansdampf.

Die Aktualisierung ist beendet.

Die eingangs erteilte Ohrfeige hat sich in nur einem Frame der Reihe nach von einem Akteur zum nächsten fortgepflanzt. Um das Ganze ein wenig zu variieren, sollen die Akteure nun die Plätze tauschen, sich jedoch in derselben Reihenfolge wie zuvor anschauen. Der Code bleibt also unangetastet – bis auf das Hinzufügen der Akteure, das auf diese Weise erfolgt:

stage.add(hansdampf, 2);
stage.add(kahlkopf, 1);
stage.add(chrisdoof, 0);

Abb. 8.4: Brutale Vergeltung

Betrachten wir als Nächstes, was geschieht, wenn wir das Experiment wiederholen:

Stage aktualisiert Akteur 0 (Chrisdoof).

Chrisdoof wurde nicht geohrfeigt und reagiert daher nicht.

Stage aktualisiert Akteur 1 (Kahlkopf).

Kahlkopf wurde auch nicht geohrfeigt und reagiert ebenfalls nicht.

Stage aktualisiert Akteur 3 (Hansdampf).

Hansdampf wurde geohrfeigt und ohrfeigt Kahlkopf.

Die Aktualisierung ist beendet.

Ha! Völlig verschieden! Das Problem ist klar: Bei der Aktualisierung der Akteure wird ihr Zustand modifiziert – der Zustand, den wir während der Aktualisierung auch lesen. Aus diesem Grund haben während der Aktualisierung frühzeitig vorgenommene Modifizierungen Auswirkungen auf später ausgeführte Änderungen – wohlgemerkt während desselben Aktualisierungsschritts.

Bei fortgesetzter Aktualisierung würde sich die Ohrfeige allmählich (ein Akteur pro Frame) der Reihe nach von einem Akteur zum nächsten fortpflanzen: Im ersten Frame ohrfeigt Hansdampf Kahlkopf, im zweiten ohrfeigt Kahlkopf Chrisdoof, usw.

Letzten Endes hängt es nur von der Reihenfolge zweier Akteure ab, ob ein Akteur im selben Frame oder erst im nächsten auf eine Ohrfeige reagiert. Das läuft jedoch der Anforderung zuwider, dass die Akteure gleichzeitig agieren sollen – die Reihenfolge der Aktualisierung innerhalb eines einzelnen Frames sollte keine Rolle spielen.

8.5.3  Gebufferte Ohrfeigen

Erfreulicherweise kann das Pattern Double Buffer hier Abhilfe schaffen. Statt zwei Kopien eines monolithischen Framebuffers zu verwenden, gehen wir hier auf viel feinerer Ebene vor und buffern den »Geohrfeigt«-Status der Akteure:

class Actor
{
public:
  Actor() : currentSlapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;

  void swap()
  {
     // Buffer vertauschen
     currentSlapped_ = nextSlapped_;
     // next-Buffer löschen
     nextSlapped_ = false;
  }

  void slap()       { nextSlapped_ = true; }
  bool wasSlapped() { return currentSlapped_; }

private:
  bool currentSlapped_;
  bool nextSlapped_;
};

Die Akteure besitzen nun zwei Flags. Ähnlich wie bei dem Grafikbeispiel wird currentSlapped zum Lesen und nextSlapped zum Schreiben verwendet.

Die reset()-Funktion wurde durch swap() ersetzt. Vor dem Löschen wird der kommende Zustand dem aktuellen zugewiesen. Dadurch wird auch eine kleine Änderung an der Stage-Klasse erforderlich:

void Stage::update()
{
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->update();
  }

  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->swap();
  }
}

Die update()-Funktion aktualisiert nun alle Akteure und tauscht danach die Zustände aus. Dadurch bemerkt der Akteur eine Ohrfeige erst im nachfolgenden Frame. Unabhängig von ihrer Reihenfolge im Array verhalten sich die Akteure jetzt stets auf dieselbe Weise. Für den Benutzer oder außenstehenden Code sieht es so aus, als würden sie allesamt innerhalb eines Frames aktualisiert.

8.6  Designentscheidungen

Das Pattern Double Buffer ist vergleichsweise unkompliziert, und die vorgestellten Beispiele dürften die meisten Varianten umfassen, denen Sie voraussichtlich begegnen werden. Bei der Implementierung des Patterns sind im Wesentlichen zwei Entscheidungen zu treffen.

8.6.1  Wie werden die Buffer ausgetauscht?

Das Vertauschen ist bei diesem Vorgang der entscheidende Punkt, denn wir müssen während des Tauschs den Zugriff auf beide Buffer sperren. Das Ganze sollte daher schnellstmöglich geschehen.

Vertauschen von Zeigern oder Verweisen auf den Buffer:

Auf diese Weise sind wir beim Grafikbeispiel vorgegangen. Hierbei handelt es sich um die gängigste Methode für das Double Buffering von Grafiken.

Es geht schnell. Unabhängig von der Buffer-Größe besteht der Tausch nur aus einigen Zeigerzuweisungen. Das ist an Einfachheit und Schnelligkeit kaum zu schlagen.

Außenstehender Code kann keine dauerhaft gültigen Zeiger auf die Inhalte des Buffers verwenden. Hierbei handelt es sich um den größten Nachteil. Da die eigentlichen Daten nicht bewegt werden, müssen wir der übrigen Codebasis regelmäßig mitteilen, wo der aktuelle Buffer zu finden ist – wie bei der ursprünglichen Schauspielbühnenanalogie. Das aber bedeutet, dass der übrige Code keine Zeiger auf im Buffer befindliche Daten speichern darf, weil sie schon einen Augenblick später auf den falschen Buffer verweisen könnten. Das ist besonders bei solchen Systemen problematisch, deren Grafiktreiber erwartet, den Buffer an einer festen Position im Arbeitsspeicher vorzufinden. Hier entfällt diese Möglichkeit.

Die im Buffer vorhandenen Daten entstammen nicht dem letzten, sondern dem vorletzten Frame. Aufeinanderfolgende Frames werden abwechselnd in die Buffer geschrieben, ohne dass Daten zwischen Buffern ausgetauscht werden. Das sieht dann so aus:

Frame 1 wird in Buffer A gezeichnet

Frame 2 wird in Buffer B gezeichnet

Frame 3 wird in Buffer A gezeichnet

...

Sie werden bemerkt haben, dass die zum Zeitpunkt des Zeichnens von Frame 3 im Buffer befindlichen Daten Frame 1 entstammen, nicht dem direkt vorhergehenden Frame 2. In den meisten Fällen ist das unproblematisch – für gewöhnlich wird der Buffer ohnehin vor dem Zeichen gelöscht. Wenn wir jedoch beabsichtigen, Teile der im Buffer befindlichen Daten wiederzuverwenden, muss berücksichtigt werden, dass die Daten einen Frame älter sind, als wir vielleicht erwarten.#

Die Erzeugung von Bewegungsunschärfe gehört zu den klassischen Formen der Wiederverwendung von im Buffer befindlichen Daten. Dabei wird der Inhalt des aktuellen Frames mit Teilen des zuvor dargestellten Frames vermischt. Das resultierende Bild ist dann dem einer echten Kamera ähnlicher.

Daten von Buffer zu Buffer kopieren:

Wenn Zeiger auf die Inhalte des Buffers schon nicht erlaubt sind, besteht immer noch die Möglichkeit, die eigentlichen Daten des nächsten Frames in den aktuellen Frame zu kopieren. So haben wir es bei den Slapstick-Komikern gemacht. Hier wird diese Methode verwendet, weil der Zustand – ein einfaches boolesches Flag – genauso schnell kopiert werden kann wie ein Zeiger auf den Buffer.

Die im Buffer vorhandenen Daten entstammen dem letzten Frame. Dies ist der Vorteil beim Kopieren der Daten gegenüber der abwechselnden Nutzung der beiden Buffer. Wenn wir auf die Inhalte des vorhergehenden Buffers zugreifen müssen, sind die Daten aktueller.

Das Vertauschen nimmt möglicherweise mehr Zeit in Anspruch. Dies ist natürlich der große Nachteil: Beim Vertauschen muss der gesamte Buffer im Arbeitsspeicher kopiert werden. Bei einem großen Buffer wie dem Framebuffer für Grafik kann das beträchtliche Zeit beanspruchen. Da während des Kopiervorgangs auf beide Buffer weder lesend noch schreibend zugegriffen werden kann, ist das eine ziemlich bedeutende Einschränkung.

8.6.2  Wie fein ist der Buffer untergliedert?

Die zweite Designentscheidung betrifft den Aufbau des Buffers: Handelt es sich um einen einzelnen, monolithischen Speicherabschnitt oder ist der Inhalt auf mehrere Objekte verteilt? Ersteres war beim Grafikbeispiel der Fall, Letzteres bei den Komikern.

In den meisten Fällen können Sie anhand der im Buffer enthaltenen Daten entscheiden, welche der beiden Formen besser geeignet ist – es gibt hier jedoch etwas Spielraum. Beispielsweise hätten unsere Akteure ihre Informationen auch in einem einzigen Block speichern können, auf dessen Inhalt sie alle über einen Index zugreifen würden.

Für monolithische Buffer gilt:

Vertauschen ist einfacher. Da es nur zwei Buffer gibt, reicht ein einziger Tauschvorgang aus. Wenn sich dies durch den Wechsel von Zeigern vornehmen lässt, ist das Vertauschen des kompletten Buffers, unabhängig von dessen Größe, mit einigen wenigen Zuweisungen durchführbar.

Für viele verschiedene Objekte gilt:

Vertauschen ist langsamer. Um den Tausch durchzuführen, müssen sämtliche Objekte abgearbeitet und einzeln vertauscht werden.

Bei dem Beispiel mit den Komikern war das tragbar, weil der Zustand ohnehin gelöscht wurde – alle gebufferten Zustände wurden in jedem Frame aktualisiert. Wenn es keinen anderen Grund gibt, auf den alten Buffer zuzugreifen, gibt es eine einfache Optimierung, um dieselbe Performance wie mit einem monolithischen Buffer zu erzielen, den Buffer dabei aber in mehrere Objekte zu unterteilen.

Hier soll das Konzept der »aktuellen« und »kommenden« Zeiger auf die einzelnen Objekte angewendet werden, indem sie den Abstand zum nächsten Objekt speichern. Etwa so:

class Actor
{
public:
  static void init() { current_ = 0; }
  static void swap() { current_ = next(); }

  void slap() { slapped_[next()] = true; }
  bool wasSlapped() { return slapped_[current_]; }

private:
  static int current_;
  static int next() { return 1 - current_; }

  bool slapped_[2];
};

Die Akteure greifen auf ihren aktuellen »Geohrfeigt«-Status zu, indem sie current_ als Index des Zustand-Arrays verwenden. Der nächste Zustand ist der jeweils andere Index des Arrays, der sich daher mittels next() berechnen lässt. Der Austausch des Zustands geschieht durch einfaches Wechseln des current_-Index. Das Pfiffige daran: swap() ist jetzt eine statische Funktion, die nur ein Mal aufgerufen werden muss, um den Zustand aller Akteure auszutauschen.

8.7  Siehe auch ...

Das Pattern Double Buffer findet sich in nahezu allen Grafik-APIs wieder. So verfügt beispielsweise OpenGL über das Sprachelement swapBuffers(), Direct3D kennt sogenannte Swap Chains (Vertauschungsketten) und Microsofts XNA-Framework vertauscht die Framebuffer innerhalb der endDraw()-Methode.

Kapitel 9: Game Loop (Hauptschleife)

Entkopplung des Voranschreitens der Spielzeit von Benutzereingaben und Prozessorgeschwindigkeit.

9.1  Motivation

Wenn es in diesem Buch ein unverzichtbares Pattern gibt, dann dieses: Eine Game Loop (Hauptschleife) ist als Design Pattern für die Spieleprogrammierung exemplarisch. Nahezu jedes Spiel besitzt eine, sie stimmen jedoch nie exakt überein und nur wenige Programme jenseits von Spielen verwenden sie.

Um zu erkennen, wie nützlich sie sind, müssen wir weit zurückblicken. In der Frühzeit der Computerprogrammierung, in der noch alle Leute Bärte trugen, funktionierten Programme wie Geschirrspülmaschinen: Man lud seinen Code ab, drückte auf einen Knopf, wartete und erhielt irgendwann ein Ergebnis. Fertig. Die Programme liefen im Stapelverarbeitungsmodus – sobald die Aufgabe erledigt war, stoppte das Programm.

Den Pionierdamen Ada Lovelace sowie der Flottillenadmiralin Grace Hopper wurden ehrenhalber Bärte verliehen.

Solche Programme gibt es auch heute noch, allerdings müssen sie dankenswerterweise nicht mehr mittels Lochkarten programmiert werden. Shell-Skripts, Kommandozeilenprogramme oder auch ein kleines Python-Skript, das aus einem Haufen Markdown-Code dieses Buch macht – all dies sind Programme, die im Stapelverarbeitungsmodus arbeiten.

9.1.1  Interview mit einer CPU

Allmählich wurde den Programmierern klar, dass das Abladen von Code im Rechenzentrum und das Abholen der Ergebnisse einige Stunden später eine schrecklich umständliche Methode war, um die Bugs in ihren Programmen zu beheben. Sie wollten umgehendes Feedback erhalten. Und so erblickten die interaktiven Programme das Licht der Welt. Einige der ersten von ihnen waren Spiele:

YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK BUILDING. AROUND YOU IS A FOREST. A SMALL STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.
> GO IN
YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.

Der Text entstammt dem Spiel Colossal Cave Adventure, dem allerersten Textadventure.

Die deutsche Übersetzung lautet etwa wie folgt:

DU BEFINDEST DICH AM ENDE EINER STRASSE VOR EINEM KLEINEN BACKSTEINGEBÄUDE. DER PLATZ IST VON EINEM WALD UMGEBEN. EIN KLEINER BACH FLIESST AUS DEM HAUS HERAUS IN EINEN KANAL.
> GEH HINEIN
DU BEFINDEST DICH IN EINEM GEBÄUDE, DEM BRUNNENHAUS EINER GROSSEN QUELLE.

Man konnte sich in Echtzeit mit dem Programm »unterhalten«: Es wartete auf eine Eingabe, auf die es dann antwortete. Dann gab man wieder etwas ein, immer brav Zug um Zug, wie man es im Kindergarten lernt. Wenn der Spieler am Zug war, hielt das Programm inne, etwa so:

while (true)
{
  char* command = readCommand();
  handleCommand(command);
}

Hier handelt es sich natürlich um eine Endlosschleife und es gäbe keine Möglichkeit, das Spiel zu beenden. In einem echten Spiel würde man ein Konstrukt wie while (!done) verwenden und done zum Beenden auf true setzen. Ich habe das der Einfachheit halber weggelassen.

9.1.2  Ereignisschleifen

Moderne Programme mit grafischer Benutzeroberfläche sind diesen alten Textadventures verblüffend ähnlich, wenn man mal unter die Haube guckt. Ihr Textverarbeitungsprogramm beispielsweise dreht auch nur Däumchen, wenn Sie nicht gerade eine Eingabe vornehmen oder etwas anklicken:

while (true)
{
  Event* event = waitForEvent();
  dispatchEvent(event);
}

Der eigentliche Unterschied besteht darin, dass nicht auf Textkommandos, sondern auf vom Benutzer ausgelöste Ereignisse gewartet wird – nämlich Mausklicks und Tastendrücke. Die grundlegende Funktionsweise entspricht derjenigen des alten Textadventures, bei dem das auf eine Eingabe wartende Spiel andere Programme blockiert – und das ist das Problem.

Bei den meisten Ereignisschleifen gibt es sogenannte Idle Events, die signalisieren, dass sich das System im Leerlauf befindet und es ermöglichen, Aufgaben jenseits der vom Benutzer ausgelösten Ereignisse zu erledigen. Zur Anzeige eines blinkenden Cursors oder eines Fortschrittbalkens mag das genügen, für ein Spiel ist es jedoch unzureichend.

Im Gegensatz zu den meisten anderen Programmen läuft ein Spiel weiter, auch wenn der Benutzer keine Eingaben tätigt – selbst wenn Sie nur auf den Bildschirm starren, bleibt das Spiel ja nicht stehen. Animationen laufen weiterhin ab. Visuelle Effekte tanzen über den Bildschirm und funkeln. Wenn Sie Pech haben, kaut auch dieses Monster weiter auf dem Helden herum.

Nun folgt der erste entscheidende Teil einer echten Game Loop: Sie verarbeitet Benutzereingaben, wartet aber nicht darauf. Die Hauptschleife läuft ständig weiter:

while (true)
{
  processInput();
  update();
  render();
}

Wir werden diese Schleife später noch weiterentwickeln, die wesentlichen Komponenten sind jedoch bereits vorhanden: Die Funktion processInput() verarbeitet die seit dem letzten Aufruf aufgetretenen Benutzereingaben. Dann sorgt update() dafür, dass die gesamte Simulation des Spiels einen Schritt weiter fortschreitet. Dabei werden KI- und Physik-Engine konsultiert (für gewöhnlich in dieser Reihenfolge). Und schließlich stellt render() das Ganze auf dem Bildschirm dar, damit der Spieler sieht, was vor sich geht.

Wie Sie sich aufgrund des Namens womöglich schon gedacht haben, ist update() ein passender Ort zur Anwendung des Patterns Update Method (Aktualisierungsmethode, Kapitel 10).

9.1.3  Eine aus dem Takt geratene Welt

Wenn diese Schleife nicht auf Eingaben wartet, stellt sich natürlich sofort die Frage: Wie schnell ist sie dann? Bei jedem Durchlauf der Game Loop schreitet der Zustand des Spiels ein wenig voran. Aus Sicht eines Einwohners der Spielwelt ist der Uhrzeiger ein Stückchen vorgerückt.

Die üblichen Bezeichnungen für solch einen Durchlauf lauten »Tick« oder »Frame«.

Unterdessen läuft jedoch auch die echte Uhr des Spielers weiter. Wenn wir messen, wie viele Durchläufe der Game Loop in einer Sekunde der realen Zeit stattfinden, erhalten wir die »Frames pro Sekunde« (FPS) des Spiels. Wird die Schleife schnell durchlaufen, resultiert das in einem hohen FPS-Wert und das Spiel wird schnell und flüssig dargestellt. Läuft sie hingegen nur langsam ab, ruckelt es vor sich hin wie ein Stop-Motion-Film.

Die noch unausgereifte hier verwendete Schleife läuft so schnell es eben geht. Der FPS-Wert hängt von zwei Faktoren ab – zum einen davon, wie viel Arbeit in jedem Frame zu verrichten ist: Komplexe Physik, haufenweise Spielobjekte und detaillierte Grafik halten CPU und GPU auf Trab und es dauert länger, einen Frame fertigzustellen.

Der zweite Faktor ist die Geschwindigkeit der Plattform, auf der das Spiel läuft. Schnellere Chips können in derselben Zeitspanne mehr Code abarbeiten. Mehrere Prozessorkerne, GPUs, eigene Audiohardware und die Zuteilung von CPU-Rechenzeit durch den Prozess-Scheduler des Betriebssystems beeinflussen, wie viel während eines Ticks erledigt werden kann.

9.1.4  Sekunden pro Sekunde

In den ersten Videospielen ließ sich der zweite Faktor nicht beeinflussen. Bei der Programmierung eines Spiels für das Nintendo Entertainment System (NES) oder den Apple IIe war genau bekannt, auf welcher CPU das Spiel laufen würde und man konnte den Code dafür optimieren (wovon auch reger Gebrauch gemacht wurde). Hier brauchte man sich nur darum zu kümmern, wie viele Aufgaben während eines Ticks erledigt wurden.

Ältere Spiele wurden sorgfältig dafür ausgelegt, bei jedem Frame nur so viele Aufgaben zu erledigen, dass sie mit der vom Entwickler vorgesehenen Geschwindigkeit liefen. Das hatte allerdings zur Folge, dass Spiele auf einem schnelleren oder langsameren Rechner beschleunigt oder verlangsamt liefen.

Aus diesem Grund besaßen viele alte PCs eine »Turbo«-Taste. Die älteren Programme liefen auf neueren PCs zu schnell, als dass man sie hätte spielen können. Durch das Abschalten des Turbos wurden die Computer verlangsamt und die alten Spiele konnten wieder verwendet werden.

Heutzutage steht jedoch nur einigen wenigen Entwicklern der Luxus zur Verfügung, genau zu wissen, auf welcher Hardware ihr Spiel laufen wird. Die Spiele müssen sich vielmehr intelligent an eine Vielzahl von Geräten anpassen.

Und dies ist der zweite entscheidende Teil einer Game Loop: Das Spiel wird trotz unterschiedlicher Hardware mit gleichbleibender Geschwindigkeit ausgeführt.

9.2  Das Pattern

Die Game Loop läuft während des Spiels ununterbrochen. Sie verarbeitet bei jedem Durchlauf Benutzereingaben (ohne auf sie zu warten), aktualisiert den Zustand des Spiels und stellt Änderungen auf dem Bildschirm dar. Außerdem erkennt sie den Ablauf der realen Zeit und steuert die Geschwindigkeit des Spielgeschehens.

9.3  Anwendbarkeit

Ein ungeeignetes Pattern zu verwenden, kann schlimmere Folgen haben, als ganz auf ein Pattern zu verzichten. Normalerweise sollen die an dieser Stelle im Buch gelieferten Informationen auch ein wenig vor übertriebenem Enthusiasmus warnen – schließlich geht es bei den Design Patterns nicht darum, möglichst viele davon nur um ihrer selbst Willen zu verwenden.

Bei diesem Pattern verhält es sich jedoch ein wenig anders. Ich kann mit ziemlicher Gewissheit behaupten, dass Sie dieses Pattern verwenden werden. Wenn Sie eine Spiel-Engine benutzen, programmieren Sie es zwar nicht selbst, Sie nutzen es aber dennoch.

Hier zeigt sich meiner Ansicht nach der Unterschied zwischen einer »Engine« und einer »Bibliothek«: Beim Einsatz einer Bibliothek stammt die Game Loop aus Ihrer Feder und Sie rufen Bibliotheksfunktionen auf. Eine Engine hingegen ruft in ihrer Game Loop Ihren Code auf.

Vielleicht ist Ihnen zwischendurch mal in den Sinn gekommen, dass Sie das Pattern bei Spielen, die Zug um Zug gespielt werden, womöglich nicht benötigen. Doch selbst wenn sich der Zustand des Spiels nicht ändert, bevor der Spieler seinen Zug ausführt, gilt dies für Visuelles oder Hörbares häufig nicht. Animationen und Musik laufen weiter, während das Spiel auf den nächsten Zug des Spielers wartet.

9.4  Konsequenzen

Die Hauptschleife, von der hier die Rede ist, gehört zum wichtigsten Code Ihres Spiels. Man hört immer wieder, dass etwa 90% der Rechenzeit durch nur 10% des Codes verursacht wird. Die Game Loop gehört mit Sicherheit zu diesen 10%. Sie sollten hier außerordentlich sorgfältig sein und der Leistungsfähigkeit besondere Beachtung schenken.

Kaum belegbare Statistiken wie diese sind der Grund dafür, dass Maschinenbauer und Elektrotechniker uns Programmierer nicht ernst nehmen.

9.4.1  Abstimmung mit der Ereignisschleife des Betriebssystems

Falls Ihr Spiel auf einem Betriebssystem oder einer Plattform mit grafischer Benutzeroberfläche oder eigener Ereignisschleife laufen soll, sind sogar zwei Hauptschleifen zu berücksichtigen, die sich miteinander arrangieren müssen.

In manchen Fällen ist es allerdings möglich, das Heft selbst in die Hand zu nehmen – und nur Ihre Hauptschleife zu nutzen. Wenn Sie beispielsweise ein Spiel unter Verwendung der altehrwürdigen Windows-API programmieren, kann die main()-Funktion nur aus der Game Loop bestehen. Innerhalb der Schleife wird dann PeekMessage() aufgerufen, um vom Betriebssystem ausgelöste Ereignisse zu verarbeiten und gegebenenfalls weiterzuleiten. Im Gegensatz zu GetMessage() blockiert PeekMessage() andere Programme beim Warten auf Benutzereingaben nicht – Ihre Game Loop läuft also weiter.

Auf anderen Plattformen ist es dagegen nicht so einfach, aus der Ereignisschleife des Betriebssystems auszusteigen. Falls Ihr Spiel in einem Webbrowser läuft, ist dessen Ereignisschleife fester Bestandteil der Ausführung des Browsers. Sie hat hier das Sagen und dient gleichzeitig auch als Game Loop. Man ruft dann Funktionen wie requestAnimationFrame() auf, die wiederum den eigenen Code aufrufen, um die Ausführung des Spiels fortzusetzen.

9.5  Beispielcode

Angesichts einer so langen Einführung ist der eigentliche Code der Game Loop tatsächlich eher unkompliziert. Wir werden uns verschiedene Varianten ansehen und die Vor- und Nachteile untersuchen.

Die Game Loop steuert die KI, das Rendering und andere Komponenten des Spiels – diese gehören jedoch nicht zum eigentlichen Pattern, deshalb werden in den Beispielen einfach fiktive Methoden aufgerufen. Die Implementierung von render(), update() und weiteren Funktionen bleibt dem Leser als (anspruchsvolle!) Übung überlassen.

9.5.1  Die Beine in die Hand nehmen

Die einfachste Game Loop ist bereits bekannt:

while (true)
{
  processInput();
  update();
  render();
}

Hier stellt sich jedoch das Problem, dass es nicht möglich ist, die Geschwindigkeit zu steuern: Auf einem schnellen Computer wird die Schleife mit so hoher Geschwindigkeit durchlaufen, dass die Benutzer nicht erkennen können, was vor sich geht. Auf einem langsamen Rechner hingegen kriecht das Spiel im Schneckentempo dahin. Der Spielablauf wird auch verlangsamt, falls Teile des Spiels intensiven Gebrauch von KI- bzw. Physik-Engine machen oder besonders inhaltsreich sind.

9.5.2  Ein kleines Nickerchen

Die erste Variante, die wir nun betrachten, bietet eine einfache Lösung dieses Problems. Nehmen wir an, das Spiel soll mit 60 FPS laufen. Das entspricht etwa 16 Millisekunden (1.000 Millisekunden geteilt durch den FPS-Wert) pro Frame. Sofern alle Verarbeitungsprozesse des Spiels und das Rendering verlässlich in kürzerer Zeit erledigt werden können, lässt sich das Spiel mit einem festen FPS-Wert betreiben. Sie brauchen nur den Frame zu erzeugen und warten dann einfach ab, bis es an der Zeit für den nächsten ist:

Abb. 9.1: Besonders einfache Hauptschleife

Der Code dafür sieht folgendermaßen aus:

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

 sleep(start + MS_PER_FRAME - getCurrentTime());
}

Die sleep()-Funktion soll gewährleisten, dass das Spiel nicht zu schnell läuft, wenn die Erzeugung eines Frames in sehr kurzer Zeit erfolgt. Falls das Spiel zu langsam läuft, schafft sie keine Abhilfe. Sollte es länger als 16 Millisekunden dauern, den Frame zu erzeugen und darzustellen, wird die Wartezeit sogar negativ. Gäbe es Computer, die Zeitreisen in die Vergangenheit unternehmen könnten, wäre alles Mögliche einfacher – dem ist aber leider nicht so.

Das Spiel wird vielmehr verlangsamt. Sie können das umgehen, indem pro Frame weniger Aufgaben erledigt werden, z.B. durch vereinfachte Grafik oder eine weniger effekthaschendere KI. Allerdings beeinträchtigt das die Qualität des Spiels – auch für Spieler mit schnellen Rechnern.

9.5.3  Ein kleiner und ein großer Schritt

Lassen Sie uns etwas Raffinierteres ausprobieren. Wir stehen im Wesentlichen vor folgendem Problem:

Bei jeder Aktualisierung rückt die Spielzeit um eine gewisse Zeitspanne vor.

Für die Verarbeitung der Aktualisierung ist eine gewisse Zeitspanne der realen Zeit erforderlich.

Falls Schritt 2 länger dauert als Schritt 1, wird das Spiel verlangsamt. Sofern die Verarbeitung mehr als 16 Millisekunden in Anspruch nimmt, ist es nicht möglich, mitzuhalten. Wenn wir jedoch die Spielzeit in einem Schritt um mehr als 16 Millisekunden vorrücken lassen, müssen wir das Spiel nicht so oft aktualisieren und bleiben dennoch auf dem Laufenden.

Die Idee ist also, anhand der seit dem letzten Frame vergangenen realen Zeit ein Zeitintervall auszuwählen, um das die Spielzeit voranschreiten soll. Je länger die Erzeugung des Frames dauert, desto größer muss das Zeitintervall sein, um das die Spielzeit vorgerückt wird. Das Spiel kann auf diese Weise immer mit der realen Zeit mithalten, weil die Zeitsprünge im Spiel zunehmend größer werden. Man bezeichnet das als variablen oder fließenden Zeitschritt. Hier der Code dafür:

double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}

Bei jedem Frame wird ermittelt, wie viel reale Zeit seit der letzten Aktualisierung verstrichen ist (elapsed). Dieser Wert wird bei der Aktualisierung des Spiels mittels update(elapsed) übergeben. Für das Vorrücken der Spielzeit um diese Zeitspanne ist dann die Spiel-Engine verantwortlich.

Stellen Sie sich eine Kugel vor, die quer über den Bildschirm fliegt. Bei konstanten Zeitschritten würde sie in jedem Frame entsprechend ihrer Geschwindigkeit fortbewegt werden. Bei variablen Zeitschritten muss hierfür die verstrichene Zeit berücksichtigt werden. Wenn die Zeitschritte größer werden, legt die Kugel in jedem Frame eine längere Strecke zurück. Sie durchquert den Bildschirm also in derselben realen Zeitspanne, und zwar unabhängig davon, ob sie dafür zwanzig kleine, schnelle oder nur vier große, langsame Schritte benötigt. Das sieht doch ganz nach einer praktikablen Lösung aus:

Das Spiel läuft auf unterschiedlicher Hardware mit konstanter Geschwindigkeit.

Spieler mit schnelleren Rechnern werden durch flüssigere Grafik belohnt.

Doch leider lauert hier ein ernsthaftes Problem: Das Spiel ist nicht mehr deterministisch und wird unvorhersagbar und instabil.

»Deterministisch« bedeutet, dass dieselben Benutzereingaben bei jeder Ausführung des Programms zu identischen Ergebnissen führen. Sie können sich sicher vorstellen, dass die Fehlerbehebung bei deterministischen Programmen sehr viel einfacher ist. Sobald Sie wissen, welche Eingabe einen Fehler verursacht, lässt sich der Fehler auch reproduzieren.

Computer sind ihrer Natur nach deterministisch: Sie folgen Anweisungen auf mechanische Weise. Nicht-deterministisches Verhalten tritt immer dann auf, wenn die chaotische reale Welt ins Spiel kommt. So unterliegen beispielsweise Netzwerk, Systemuhrzeit oder Thread-Verwaltung externen Einflüssen, die sich der Kontrolle des Programms entziehen.

Hier ein Beispiel dafür, welcher Gefahr wir uns dadurch aussetzen: Betrachten Sie ein Spiel für zwei Teilnehmer, das via Netzwerk gespielt wird. Frank besitzt einen aufgemotzten Spieleboliden, während Georg den uralten PC seiner Oma benutzt. Die vorhin erwähnte Kugel fliegt gerade über die Bildschirme beider Spieler. Auf Franks Maschine läuft das Spiel in rasantem Tempo und die Zeitschritte sind winzig. In der Sekunde, die die Kugel zum Durchqueren des Bildschirms benötigt, werden etwa 50 Frames dargestellt. Georgs Rechner schafft dagegen nur rund fünf Frames.

Auf Franks Computer aktualisiert die Physik-Engine die Position der Kugel 50 Mal, auf Georgs jedoch nur fünf Mal. Die meisten Spiele verwenden Fließkommazahlen – und dabei kommt es bekanntlich zu Rundungsfehlern. Bei jeder Addition zweier Fließkommazahlen ist das Ergebnis nicht ganz exakt. Franks Rechner führt zehn Mal mehr Rechenoperationen durch, daher summieren sich seine Rundungsfehler zu einem größeren Betrag als bei Georg. Das bedeutet jedoch, dass sich ein und dieselbe Kugel bei Frank und Georg an unterschiedlichen Bildschirmpositionen befindet.

Hierbei handelt es sich nur um eins von vielen scheußlichen Problemen, die variable Zeitschritte verursachen können. Um in Echtzeit laufen zu können, verwenden Physik-Engines in Spielen Näherungen für die Gesetze der klassischen Mechanik. Und damit diese Näherungen nicht aus dem Ruder laufen, wird eine Dämpfung angewendet, die genau an einen bestimmten Wert für den Zeitschritt angepasst ist. Ändert man diesen Wert, wird die Physik des Spiels unbeständig.

Diese Instabilität ist schlimm genug, dass die geschilderte Situation hier nur als abschreckendes Beispiel dienen und uns zu etwas Besserem führen soll ...

9.5.4  Aufholjagd

Das Rendering ist für gewöhnlich von einem variablen Zeitschritt nicht betroffen. Da die Rendering-Engine immer nur eine Momentaufnahme wiedergibt, spielt es keine Rolle, wie viel Zeit seit der vorhergehenden verstrichen ist.

Streng genommen ist das nicht ganz korrekt. Beispielsweise kann die Erzeugung von Bewegungsunschärfe durch den Zeitschritt betroffen sein, allerdings bemerkt der Spieler leichte Schwankungen in der Regel nicht.

Diesen Umstand können wir zu unserem Vorteil nutzen: Wir verwenden zur Aktualisierung des Spiels einen konstanten Zeitschritt, denn dadurch wird für KI- und Physik-Engine vieles einfacher und stabiler. Beim Rendering hingegen lassen wir etwas Spielraum, um über ein wenig mehr CPU-Rechenzeit verfügen zu können.

Das funktioniert folgendermaßen: Seit dem letzten Durchlauf der Game Loop ist eine bestimmte reale Zeitspanne verstrichen. Um diese Zeitspanne muss die Spielzeit vorgerückt werden, damit das »Jetzt« der Spielwelt wieder mit dem des Spielers übereinstimmt. Dazu verwenden wir eine Reihe konstanter Zeitschritte. Hier der entsprechende Code:

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;
  processInput();

  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
  }
  render();
}

Dazu ist einiges zu erwähnen. Am Anfang eines jeden Frames aktualisieren wir lag entsprechend der real verstrichenen Zeit. Der Wert dieser Variablen gibt an, wie weit die Spielzeit hinter der realen Zeit zurückliegt. Dann folgt die innere Schleife zur Aktualisierung des Spiels in konstanten Zeitschritten, die läuft, bis die aktuelle Zeit erreicht wird. Nachdem das erledigt ist, wird das Rendering durchgeführt. Dann beginnt der ganze Vorgang wieder von vorne. Das lässt sich etwa so visualisieren:

Abb. 9.2: Entfernen des Renderings aus der Schleife

Beachten Sie hier, dass der Zeitschritt jetzt nicht mehr den sichtbaren FPS-Wert darstellt. Die Variable MS_PER_UPDATE gibt vielmehr an, wie fein zeitlich unterteilt die Aktualisierung des Spiels stattfindet. Je kürzer dieses Zeitintervall ist, desto länger dauert es, bis die reale Zeit eingeholt wird – und je länger es ist, umso ruckeliger wird der Spielablauf. Im Idealfall ist es sehr kurz, am besten schneller als 60 FPS, damit die Simulation auf schnellen Rechnern möglichst naturgetreu wirkt.

Achten Sie jedoch darauf, das Zeitintervall nicht zu kurz zu wählen. Sie müssen sicherstellen, dass es größer ist als die zur Ausführung von update() erforderliche Zeitspanne, auch auf sehr langsamer Hardware – andernfalls kann das Spiel schlicht und einfach nicht hinterherkommen.

Ich habe es hier weggelassen, Sie können sich jedoch davor schützen, indem Sie die Schleife nach einer maximalen Anzahl von Durchläufen abbrechen. Das Spiel wird dann zwar langsamer, das ist aber immer noch besser, als komplett festzuhängen.

Glücklicherweise haben wir uns hier etwas Luft verschafft. Der Clou ist das Entfernen des Renderings aus der Aktualisierungsschleife. Dadurch steht mehr CPU-Rechenzeit zur Verfügung. Letzten Endes läuft das Spiel bei vernünftig gewählten Zeitschritten auf unterschiedlicher Hardware mit konstanter Framerate. Allerdings wird der für den Spieler sichtbare Teil des Spiels auf langsamen Rechnern etwas ruckeliger.

9.5.5  In der Mitte hängen geblieben

Eine letzte Schwierigkeit ist die noch verbliebene Verzögerung. Wir aktualisieren das Spiel in festen Zeitintervallen, führen das Rendering jedoch zu beliebigen Zeitpunkten durch. Aus Sicht des Benutzers findet das Rendering oftmals zu einem Zeitpunkt statt, der zwischen zwei Updates liegt. Eine Zeitleiste verdeutlicht das:

Abb. 9.3: Zeitleiste mit den Zeitpunkten, an denen Updates und Renderings stattfinden

Wie Sie sehen, finden die Aktualisierungen in regelmäßigen Abständen statt. Das Rendering hingegen erfolgt, wenn die erforderliche Zeit verfügbar ist. Es findet weniger häufig und auch nicht so regelmäßig wie Updates statt, das ist jedoch unproblematisch. Unschön ist, dass das Rendering nicht immer zum Zeitpunkt der Aktualisierung erfolgt, sondern manchmal, wie beim dritten Rendering, mitten zwischen zwei Updates:

Abb. 9.4: Rendering inmitten zweier Frames

Stellen Sie sich nun wieder eine Kugel vor, die über den Bildschirm fliegt. Zum Zeitpunkt der ersten Aktualisierung befindet sie sich auf der linken Seite. Die zweite Aktualisierung bewegt sie auf die rechte Seite. Das Rendering findet zu einem Zeitpunkt zwischen diesen beiden Updates statt. Der Benutzer erwartet nun, die Kugel in der Bildschirmmitte zu sehen. Bei der jetzigen Implementierung befindet sie sich jedoch noch immer auf der linken Seite. Bewegungen erscheinen dadurch abgehackt oder stotternd.

Praktischerweise wissen wir genau, an welcher Stelle zwischen zwei Aktualisierungen wir uns beim Rendering befinden: Der Wert ist in der Variablen lag gespeichert. Also brechen wir die Aktualisierungsschleife nicht ab, wenn lag Null wird, sondern sobald der Wert denjenigen des Zeitschritts unterschreitet. Und der verbleibende Wert? Er gibt an, an welcher Stelle des nächsten Frames wir uns befinden.

Beim Rendering übergeben wir folgende Wertangabe:

render(lag / MS_PER_UPDATE);

Wir dividieren hier durch MS_PER_UPDATE, um den Wert zu normalisieren. Der an render() übergebene Wert liegt zwischen 0 (direkt am Ende des vorhergehenden Frames) und etwas weniger als 1.0 (unmittelbar vor dem nächsten Frame) – und zwar dank der Normalisierung unabhängig vom Wert des Zeitschritts. Auf diese Weise braucht sich der Renderer nicht um den FPS-Wert zu kümmern, sondern verwendet Werte von 0 bis 1.

Dem Renderer sind alle Spielobjekte und deren aktuelle Geschwindigkeiten bekannt. Angenommen eine Kugel befindet sich 20 Pixel vom linken Bildschirmrand entfernt und bewegt sich mit 400 Pixeln pro Frame nach rechts. Wenn wir uns genau in der Mitte zweier Frames befinden, wird render() der Wert 0.5 übergeben. Die Kugel wird dann einen halben Frame voraus bei 220 Pixeln gezeichnet. Siehe da: flüssige Bewegung.

Es könnte natürlich sein, dass sich diese Extrapolation als falsch erweist. Bei der Berechnung des nächsten Frames könnten wir feststellen, dass die Kugel auf ein Hindernis trifft oder verlangsamt wird usw. Die dazwischenliegende Position wird aus der Position im letzten Frame und der mutmaßlichen Position im nächsten Frame ermittelt. Sie steht erst endgültig fest, wenn die Aktualisierung abgeschlossen und KI- und Physik-Engine zum Zuge gekommen sind.

Die Extrapolation ist also eher eine Schätzung und misslingt gelegentlich. Glücklicherweise bemerkt man derartige Korrekturen für gewöhnlich gar nicht. Jedenfalls sind sie weniger offensichtlich als die abgehackten Bewegungen, die bei völligem Verzicht auf Extrapolation entstehen.

9.6  Designentscheidungen

Trotz der Länge dieses Kapitels habe ich einiges ausgelassen. Sobald Dinge wie die Synchronisierung mit der Bildwiederholfrequenz des Monitors, Multithreading und Grafikprozessoren ins Spiel kommen, kann eine echte Game Loop zu einer ziemlich haarigen Angelegenheit werden.

Nachfolgend finden Sie daher einige allgemeinere Fragen, die Sie sich stellen sollten.

9.6.1  Stammt die Game Loop aus Ihrer Feder oder benutzen Sie die der Plattform?

Hierbei handelt es sich um eine Entscheidung, die Sie für gewöhnlich nicht selbst treffen. Wenn Sie ein Spiel für einen Webbrowser entwickeln, ist es praktisch kaum möglich, eine eigene, klassische Game Loop zu verwenden. Das ist durch die ereignisgesteuerte Natur eines Webbrowsers nahezu ausgeschlossen. Ebenso werden Sie beim Einsatz einer Spiel-Engine deren Game Loop verwenden, anstatt selbst eine zu programmieren.

Verwendung der plattformeigenen Game Loop:

Dies ist die einfachste Lösung. Sie brauchen sich nicht um die Programmierung und die Optimierung der Hauptschleife zu kümmern.

Sie arbeitet gut mit der Plattform zusammen. Sie brauchen sich nicht darum zu kümmern, dem Computer Zeit zur Verfügung zu stellen, damit er seine eigenen Ereignisse verarbeiten und cachen oder anderweitige durch die unterschiedlichen Eingabeverfahren zustande kommende Schwierigkeiten lösen kann.

Sie verlieren die Kontrolle über das Timing. Die Plattform ruft Ihren Code nach eigenem Gutdünken auf. Wenn das nicht so häufig und reibungslos geschieht, wie Sie es gern hätten: Pech gehabt. Noch schlimmer ist allerdings, dass die Ereignisschleifen der meisten Anwendungen nicht für Spiele ausgelegt sind und daher für gewöhnlich langsam sind und ruckelige Animationen liefern.

Verwendung der Game Loop einer Spiel-Engine:

Sie müssen sie nicht selbst schreiben. Die Programmierung einer Game Loop kann ziemlich knifflig sein. Da der Code bei jedem einzelnen Frame ausgeführt wird, können schon kleine Fehler oder Performanceprobleme heftige Auswirkungen auf Ihr Spiel haben. Eine kompakte Game Loop bietet durchaus Anlass genug, um eine bereits vorhandene Spiel-Engine in Betracht zu ziehen.

Sie können sie nicht selbst schreiben. Die Kehrseite der Medaille ist natürlich, dass sie nicht mehr das Heft in der Hand halten, falls die Spiel-Engine Ihren Ansprüchen nicht zu 100% gerecht wird.

Schreiben Sie die Game Loop selbst:

Sie haben die vollständige Kontrolle. Sie können die Game Loop nach Herzenslust Ihren Wünschen oder den speziellen Anforderungen Ihres Spiels anpassen.

Sie müssen sich mit den Interfaces der Plattform auseinandersetzen. Anwendungs-Frameworks und Betriebssysteme erwarten für gewöhnlich, dass ihnen ein Teil der Rechenzeit bereitgestellt wird, um Ereignisse zu verarbeiten und andere Aufgaben zu erledigen. Das ist jedoch bei einer eigenen Hauptschleife nicht automatisch der Fall. Sie müssen explizit regelmäßig die Kontrolle abgeben, um zu gewährleisten, dass sich ein Framework oder Betriebssystem nicht aufhängt oder durcheinandergerät.

9.6.2  Wie handhaben Sie die Leistungsaufnahme?

Vor fünf Jahren hat kein Hahn danach gekräht: Spiele liefen auf Dingern, die an der Steckdose hingen oder auf speziellen tragbaren Geräten. Mit dem Aufkommen von Smartphones, Laptops und mobilen Spielen stehen die Chancen jedoch nicht schlecht, dass Sie dieser Frage nun Beachtung schenken, denn: Ein wunderbar laufendes Spiel, das die Mobilgeräte der Spieler in Heizöfen verwandelt, bevor ihnen nach 30 Minuten der Saft ausgeht, macht die Leute nicht glücklich.

Heutzutage müssen Sie sich nicht nur darüber Gedanken machen, dass Ihr Spiel besonders schick aussieht, sondern auch dafür sorgen, dass es die CPU möglichst wenig nutzt. Wahrscheinlich werden Sie eine Obergrenze für die Performance setzen wollen, bei der die CPU nicht weiter belastet wird, sobald sämtliche in einem Frame zu erledigenden Aufgaben durchgeführt worden sind.

Die Game Loop so schnell wie möglich ausführen:

Das ist vermutlich die Methode der Wahl für PC-Spiele (obwohl diese auch zunehmend auf Laptops gespielt werden). Hier wird Ihre Game Loop dem Betriebssystem zu keinem Zeitpunkt ausdrücklich erlauben, nichts zu tun. Stattdessen wird sämtliche verfügbare Rechenzeit dazu genutzt, die Framerate hochzuschrauben oder naturgetreuere Grafik darzustellen.

Diese Methode liefert zwar das bestmögliche Spielerlebnis, bringt jedoch auch die höchste Leistungsaufnahme mit sich. Falls die Spieler Laptops verwenden, haben sie eine veritable Wärmflasche auf dem Schoß.

Beschränken Sie die Framerate:

Mobile Spiele sind oft eher auf die Qualität des Spielerlebnisses als auf möglichst detaillierte Grafik bedacht. Viele dieser Spiele begrenzen die Framerate (üblicherweise auf 30 oder 60 FPS). Falls die Game Loop alle Aufgaben erledigt hat, bevor die ihr zugeteilte Rechenzeit aufgebraucht ist, wartet sie einfach.

Dem Spieler beschert dies ein gerade noch befriedigendes Spielerlebnis, der Akku wird jedoch geschont.

9.6.3  Wie steuern Sie die Spielgeschwindigkeit?

Die Game Loop hat zwei entscheidende Aufgaben: Benutzereingaben entgegenzunehmen, ohne das System zu blockieren, und die Anpassung des zeitlichen Ablaufs. Eingaben entgegenzunehmen, ist unkompliziert, aber den Zeitablauf richtig hinzubekommen, kann wirklich knifflig sein. Es gibt unzählige Plattformen, auf denen Spiele laufen können, und jedes einzelne Spiel wiederum soll womöglich auf mehreren laufen. Wie die verschiedenen Plattformen berücksichtigt werden, ist von entscheidender Bedeutung.

Es liegt offenbar in der Natur des Menschen, Spiele zu erfinden, denn bislang wurden noch immer Spiele programmiert, wenn eine Maschine gebaut wurde, die rechnen kann. Der PDP-1 (Programmed Data Processor 1) war eine 2-KHz-Maschine mit einem Arbeitsspeicher von ganzen 4.096 Worten (18-Bit großen Werten). Nichtdestotrotz schafften es Steve Russel und Kollegen, damit das Spiel Spacewars! zu erstellen.

Konstante Zeitschritte ohne Synchronisierung:

Dies entspricht dem ersten Beispielcode. Die Game Loop wird schlicht und einfach so schnell wie möglich ausgeführt.

Dies ist die einfachste Methode. Das ist der wesentliche Vorteil (um nicht zu sagen: der einzige).

Die Geschwindigkeit hängt unmittelbar von der Hardware und der Komplexität des Spiels ab. Und das ist auch das größte Manko, denn das Spiel läuft auf verschiedener Hardware unterschiedlich schnell. Diese Methode entspricht sozusagen einem Fahrrad ohne Gangschaltung.

Konstante Zeitschritte mit Synchronisierung:

Auf der nächsten Stufe der Komplexität wird das Spiel mit konstanten Zeitschritten betrieben, am Ende der Game Loop findet jedoch zwecks Synchronisierung eine Verzögerung statt, damit das Spiel nicht zu schnell läuft.

Diese Methode ist auch noch ziemlich einfach. Hier gibt es nur eine Zeile Code mehr, als beim »Zu-einfach-um-zu-funktionieren«-Beispiel. In den meisten Game Loops findet wahrscheinlich ohnehin eine Synchronisierung statt. Vermutlich werden Sie zur Darstellung der Grafik das Pattern Double Buffer (Doppelter Buffer, Kapitel 8) anwenden und das Vertauschen der Buffer mit der Bildwiederholfrequenz synchronisieren.

Die Methode ist energiesparend. Dieser Aspekt ist für mobile Spiele von erstaunlicher Bedeutung. Schließlich wollen Sie den Akku des Benutzers ja nicht unnötigerweise leeren. Einige Millisekunden lang nichts zu tun, anstatt bei jedem Tick noch mehr Verarbeitungsschritte durchzuführen, spart Energie.

Das Spiel läuft nicht zu schnell. Die eine Hälfte der bei einer Game Loop mit konstanten Zeitschritten angebrachten Bedenken spielt hier keine Rolle mehr.

Möglicherweise läuft das Spiel zu langsam. Wenn die Aktualisierung und das Rendern zu viel Zeit in Anspruch nehmen, wird sich das Spiel verlangsamen. Da diese beiden Aktivitäten hier nicht getrennt voneinander ablaufen, wird dieser Fall wahrscheinlich eher eintreten als bei den fortgeschritteneren Optionen. Anstatt das Rendern einzelner Frames zu überspringen, verlangsamt sich das Spiel.

Variable Zeitschritte:

Ich nehme diese Option hier bei den Lösungsmöglichkeiten mit dem Vorbehalt auf, dass die meisten mir bekannten Spieleentwickler davon abraten. Es ist allerdings vernünftig, sich daran zu erinnern, warum es sich um keine gute Idee handelt.

Das Spiel läuft nicht zu langsam und nicht zu schnell. Wenn der Spielablauf nicht mit dem realen Zeitablauf Schritt halten kann, finden zunehmend größere Zeitschritte statt, bis das Spielgeschehen wieder in Echtzeit läuft.

Das Spiel wird dadurch nicht-deterministisch und unvorhersagbar. Hierbei handelt es sich um das eigentliche Problem. Die korrekte Handhabung von Physik und Netzwerkverbindungen ist bei variablen Zeitschritten ungleich schwieriger.

Konstante Zeitschritte, variables Rendering:

Die letzte der im Beispiel vorgestellten Möglichkeiten ist auch die komplizierteste. Die Aktualisierung findet in konstanten Zeitschritten statt, bei Bedarf wird jedoch das Rendern von Frames ausgelassen, um das Spiel in Echtzeit darzustellen.

Das Spiel läuft nicht zu langsam und nicht zu schnell. Solange das Spiel in Echtzeit aktualisiert werden kann, gerät es nicht aus dem Takt. Auf schnellen Rechnern wird es flüssiger dargestellt.

Diese Methode ist komplizierter. Bei dieser Implementierung ist von Nachteil, dass zusätzliche Aufgaben anfallen. Sie müssen die Zeitschritte so justieren, dass sie bei schnellen Rechnern möglichst kurz und bei langsamen nicht zu lang sind.

9.7  Siehe auch ...

Der Artikel »Fix Your Timestep!« von Glenn Fiedler (http://gafferongames.com/game-physics/fix-your-timestep/) ist ein Klassiker zum Thema Game Loops. Ohne ihn sähe dieses Kapitel wohl anders aus.

Koen Witters Artikel »deWITTERS Game Loop« (http://www.koonsolo.com/news/dewitters-gameloop/) ist knapp Zweitplatzierter.

Das Framework Unity verfügt über eine komplexe Game Loop, die in einer wunderbaren Illustration dargestellt wird. Suchen Sie im Internet nach »MonoBehaviour Lifecycle«.

Kapitel 10: Update Method (Aktualisierungsmethode)

Simulation einer Sammlung unabhängiger Objekte, indem jedes Objekt eine Methode implementiert, die das Verhalten dieses Objekts innerhalb eines Frames ausführt.

10.1  Motivation

Der mächtigen Walküre des Spielers wurde geheißen, die auf den Knochen des lange verstorbenen Hexenmeisters ruhenden prächtigen Juwelen zu rauben. Sie tastet sich in der ehrfurchtgebietenden Gruft vorsichtig vor und wird plötzlich angegriffen von einem ... Nichts. Keine verfluchten Statuen, die Blitze nach ihr schleudern. Keine untoten Krieger, die am Eingang patrouillieren. Sie spaziert einfach herein und ergreift die Beute. Spielende. Sie haben gewonnen.

So geht das natürlich nicht. Die Gruft muss von Wachen geschützt werden – Feinden, mit denen sich unsere wackere Heldin herumschlagen kann. Zunächst einmal soll ein wiederbelebtes Skelett vor der Eingangstür hin- und herlaufen.

Sollte sich der Hexenmeister intelligenteres Verhalten gewünscht haben, hätte er eine Kreatur wiederbeleben sollen, die noch etwas Hirngewebe besaß.

Vergessen Sie für einen Moment alles, was Sie bereits über Spieleprogrammierung wissen – dann sieht die einfachste Möglichkeit, das Skelett nach links und rechts taumeln zu lassen, etwa so aus:

while (true)
{
  // Nach rechts laufen
  for (double x = 0; x < 100; x++) skeleton.setX(x);
  // Nach links laufen
  for (double x = 100; x > 0; x--) skeleton.setX(x);
}

Hier besteht das Problem natürlich darin, dass sich das Skelett zwar hin- und herbewegt, der Spieler es jedoch überhaupt nicht zu sehen bekommt. Das Programm steckt in einer Endlosschleife fest – nicht gerade das, was man sich unter einem spaßigen Spielerlebnis vorstellt. Eigentlich ist erwünscht, dass sich das Skelett pro Frame um einen Schritt weiterbewegt.

Wir müssen diese Schleifen wieder entfernen und die äußere Game Loop verwenden, um das Spiel voranschreiten zu lassen. Dadurch ist gewährleistet, dass es auf Benutzereingaben reagiert und das Rendering ausführt, während der Wächter seine Runden dreht. Etwa so:

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// Hauptschleife
while (true)
{
  if (patrollingLeft)
  {
    x--;
    if (x == 0) patrollingLeft = false;
  }
  else
  {
    x++;
    if (x == 100) patrollingLeft = true;
  }

  skeleton.setX(x);

  // Benutzereingaben verarbeiten und Rendern ...
}

Ich zeige hier den Code vor und nach den angestellten Überlegungen, um vorzuführen, wie er allmählich komplexer wird. Ursprünglich bestand das Patrouillieren aus zwei einfachen for-Schleifen. In welche Richtung sich das Skelett bewegt, wurde implizit durch die jeweils ausgeführte Schleife festgelegt. Da wir nun bei jedem Frame der äußeren Game Loop die Kontrolle überlassen und beim nächsten Durchlauf die Bewegung des Skeletts dort fortsetzen, wo sie aufgehört hat, müssen wir die Richtung explizit in der Variablen patrollingLeft speichern.

Das Ganze funktioniert mehr oder weniger, machen wir also weiter. Ein hirnloser Haufen Knochen stellt für unsere nordische Jungfer keine besondere Herausforderung dar, wir fügen daher einige verwunschene Statuen hinzu, die hin und wieder Blitze nach ihr schleudern, um sie auf Trab zu halten.

Wir verfolgen weiterhin den »Wie-kann-man-das-am-einfachsten-programmieren«-Kurs und damit ergibt sich Folgendes:

// Variablen ...
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;

// Hauptschleife
while (true)
{
  // Provisorischer Code ...
  if (++leftStatueFrames == 90)
  {
    leftStatueFrames = 0;
    leftStatue.shootLightning();
  }
  if (++rightStatueFrames == 80)
  {
    rightStatueFrames = 0;
    rightStatue.shootLightning();
  }

  // Benutzereingaben verarbeiten und Rendern
}

Man kann schon jetzt sagen, dass sich das Ganze nicht gerade zu Code entwickelt, den man freudig warten möchte: In der Hauptschleife sammeln sich immer mehr Variablen und imperativer Code an, wobei jeweils nur eine bestimmte Figur des Spiels gehandhabt wird. Damit alle Figuren gleichzeitig erscheinen, haben wir die Game Loop mit ihrem Code zugekleistert.

Falls sich Ihre Architektur durch den Begriff »zugekleistert« treffend beschreiben lässt, haben Sie womöglich ein Problem.

Das Pattern, das wir zum Beheben dieses Problems verwenden werden, ist so einfach, dass es Ihnen wohl schon selbst in den Sinn gekommen ist: Jede Figur des Spiels sollte ihr eigenes Verhalten kapseln. Die Game Loop wird dann nicht mehr zugekleistert und das Hinzufügen und Entfernen einer Figur vereinfacht sich ebenfalls.

Zu diesem Zweck benötigen wir eine Abstrahierungsschicht, die wir in Form einer abstrakten update()-Methode erstellen. Die Game Loop wird um eine Sammlung von Objekten erweitert, kennt deren konkrete Typen aber nicht. Ihr ist lediglich bekannt, dass sie aktualisiert werden können. Auf diese Weise werden die Objekte sowohl von der Game Loop als auch voneinander abgesondert.

Ein Mal pro Frame ruft die Game Loop die update()-Methode der Objekte auf. Dadurch erhalten alle Objekte die Gelegenheit, die für einen Frame angebrachten Änderungen vorzunehmen. Da die Methode in jedem Frame bei allen Objekten aufgerufen wird, ändern sie ihr Verhalten gleichzeitig.

Irgendein Pedant würde mich sowieso darauf hinweisen: Ja, sie ändern ihr Verhalten nicht wirklich exakt gleichzeitig. Während der Aktualisierung eines Objekts wird kein anderes aktualisiert. Wir kommen darauf noch zu sprechen.

Die Game Loop verfügt über eine dynamische Objektsammlung, daher fällt es leicht, dem Spiel Objekte hinzuzufügen und daraus zu entfernen: Ergänzen Sie sie einfach zur Objektsammlung oder entfernen Sie sie daraus. Jetzt ist nichts mehr fest vorgegeben und wir könnten das Spiel sogar mittels einer Datei bevölkern – und das ist genau das, was sich die Leveldesigner wünschen.

10.2  Das Pattern

Die Spielwelt verfügt über eine Objektsammlung. Alle Objekte implementieren eine update()-Methode, die das Verhalten des Objekts innerhalb eines Frames ausführt. Das Spiel aktualisiert bei jedem Frame alle Objekte der Sammlung.

10.3  Anwendbarkeit

Wenn das Pattern Game Loop (Kapitel 9) das Beste seit dem sprichwörtlichen »geschnitten Brot« ist, dann ist das Pattern Update Method die dazugehörige Butter. Der Großteil der Spiele, in denen Figuren auftauchen, mit denen der Spieler interagiert, verwendet dieses Pattern in der einen oder anderen Form. Wenn in einem Spiel Weltraumsoldaten, Drachen, Marsmännchen, Geister oder Athleten vorkommen, ist dieses Pattern mit einiger Wahrscheinlichkeit mit von der Partie.

Handelt es sich jedoch um ein abstraktes Spiel, in dem die beweglichen Objekte weniger lebendigen Akteuren, sondern eher Schachfiguren ähneln, ist dieses Pattern oft ungeeignet. Bei einem Spiel wie Schach ist es nicht nötig, alle Spielfiguren gleichzeitig zu simulieren, und es dürfte auch kaum erforderlich sein, die Bauern in jedem Frame anzuweisen, sich zu aktualisieren.

Es ist zwar nicht nötig, das Verhalten in jedem Frame zu aktualisieren, aber selbst bei einem Brettspiel könnte es erforderlich sein, das Aussehen, sprich die Animation, in jedem Frame auf den neuesten Stand zu bringen. Hierfür ist das Pattern durchaus geeignet.

Das Pattern Update Method ist gut geeignet, wenn

das Spiel eine Reihe von Objekten oder Systemen enthält, die gleichzeitig bewegt werden bzw. laufen müssen.

das Verhalten der Objekte im Wesentlichen von anderen Objekten unabhängig ist.

sich die Objekte im Laufe der Zeit verändern.

10.4  Konsequenzen

Dieses Pattern ist ziemlich einfach, daher gibt es auch bei ungewöhnlichen Anwendungen kaum verborgene Überraschungen. Dennoch kann jede Zeile Code Begleiterscheinungen mit sich bringen.

10.4.1  Verkomplizierung durch Aufteilen des Codes in einzelne Frames

Beim Vergleich der beiden ersten Codeabschnitte werden Sie feststellen, dass der zweite um einiges komplizierter ist. Beide sorgen dafür, dass sich das Skelett hin- und herbewegt, der zweite übergibt jedoch in jedem Frame die Kontrolle der Game Loop.

Eine solche Umstrukturierung des Codes ist fast immer notwendig, um Benutzereingaben, Rendering und weitere Aufgaben der Game Loop zu handhaben, daher ist das erste Beispiel ziemlich praxisfern. Sie sollten jedoch im Hinterkopf behalten, dass beträchtliche Vorarbeiten erforderlich sind, um den für das Verhalten zuständigen Code auf diese Weise umzubauen.

Ich schreibe hier »fast«, weil es manchmal möglich ist, sich den Pelz zu waschen, ohne dabei nass zu werden. Sie können abgeschlossenen Code verwenden, der sich nicht um das Verhalten der Objekte kümmert und auch eine Reihe von Objekten einsetzen, die von der Game Loop koordiniert gleichzeitig bewegt werden.

Dafür benötigen wir ein System, das mehrere »Threads« gleichzeitig erlaubt. Wenn der Code eines Objekts zu einem beliebigen Zeitpunkt pausieren und seine Arbeit später wieder aufnehmen kann, anstatt die Kontrolle wieder vollständig an den Aufrufer zu übergeben, ist eine eher imperative Form der Programmierung möglich.

Tatsächlich sind herkömmliche Threads zu schwergewichtig für diese Aufgabe, um wirklich gut zu funktionieren. Falls Ihre Programmiersprache jedoch leichtgewichtige Konstrukte zur gleichzeitigen Ausführung von Code wie etwa Generatoren, Koroutinen oder Fibers unterstützt, können Sie diese womöglich ebenfalls verwenden.

Das Pattern Bytecode (Kapitel 11) ist eine weitere Möglichkeit, Ausführungsthreads auf Anwendungsebene zu verwenden.

10.4.2  Der Zustand muss gespeichert werden, um im nächsten Frame fortfahren zu können

Im ersten Codeabschnitt gibt es keine Variablen, die speichern, ob sich der Wächter nach links oder rechts bewegt. Das hängt davon ab, welche der beiden Schleifen gerade ausgeführt wird.

Beim Wechsel zu der »Ein-Frame-nach-dem-anderen«-Form mussten wir die Variable patrollingLeft anlegen, um die Bewegungsrichtung zu speichern. Nach der Abarbeitung des Codes ist beim nächsten Frame nicht mehr bekannt, welcher Zweig der if-Anweisung vorher ausgeführt wurde. Wir müssen daher genügend Informationen speichern, um an der richtigen Stelle fortfahren zu können.

Hier ist häufig das Pattern State (Zustand, Kapitel 7) hilfreich. Zustandsautomaten speichern (wie der Name bereits vermuten lässt) den Zustand, den Sie abrufen, um fortzufahren. Dies ist teilweise der Grund dafür, dass sie in Spielen gebräuchlich sind.

10.4.3  Alle Objekte simulieren jeden Frame, aber nicht wirklich exakt gleichzeitig

Bei diesem Pattern durchläuft die Game Loop eine Sammlung von Objekten und aktualisiert sie. Innerhalb des update()-Aufrufs können die Objekte auf die übrige Spielwelt zugreifen und sie ändern – auch andere Objekte, die gerade aktualisiert werden. Das heißt, dass die Reihenfolge der Aktualisierung von Bedeutung ist.

Wenn sich A in der Objektliste vor B befindet, sieht A bei der Aktualisierung Bs gegenwärtigen Zustand. B sieht bei der Aktualisierung jedoch As neuen Zustand, da A in diesem Frame ja schon aktualisiert wurde. Auch wenn sich aus Sicht des Spielers alle Objekte gleichzeitig bewegen, ist das Spiel im Grunde genommen doch rundenbasiert – es ist nur so, dass eine »Runde« eben nur ein Frame lang ist.

Falls Sie sich aus irgendeinem Grund dafür entscheiden, dass Ihr Spiel nicht auf diese sequenzielle Weise vorgehen soll, müssten Sie etwas in der Art des Patterns Double Buffer (Doppelter Buffer, Kapitel 8) verwenden. Die Reihenfolge der Aktualisierung spielt dann keine Rolle, weil sowohl A als auch B jeweils den Zustand im vorhergehenden Frame sehen.

Was die Spiellogik betrifft, ist das im Großen und Ganzen von Vorteil. Eine wirklich gleichzeitige Aktualisierung kann zu unangenehmen semantischen Problemen führen. Stellen Sie sich ein Schachspiel vor, in dem Schwarz und Weiß gleichzeitig ziehen. Wenn nun beide Spieler eine Figur auf dasselbe leere Feld ziehen wollen, wie soll dann damit umgegangen werden?

Solche Probleme treten bei der sequenziellen Aktualisierung nicht auf – jedes Update überführt die Spielwelt von einem vernünftigen Zustand in den nächsten, ohne dass es einen Zeitpunkt gäbe, an dem irgendetwas nicht eindeutig wäre, was wieder in Einklang gebracht werden müsste.

Hierdurch wird auch die Programmierung vernetzter Spiele vereinfacht, weil die Spielvorgänge serialisierbar sind und sich daher leicht über das Netzwerk versenden lassen.

10.4.4  Obacht bei der Modifizierung der Objektliste während der Aktualisierung

Bei der Verwendung dieses Patterns wird ein Großteil des Spielverhaltens in die update()-Methoden eingebettet. Dies schließt häufig auch Code ein, der dem Spiel andere aktualisierbare Objekte hinzufügt bzw. daraus entfernt.

Beispielsweise könnte das Skelett einen Gegenstand fallen lassen, wenn es gemeuchelt wird. Neue Objekte lassen sich für gewöhnlich ohne größere Probleme am Ende der Objektliste ergänzen. Sie durchlaufen die Liste wie gewohnt und gelangen so irgendwann auch zu den am Ende hinzugefügten Objekten und können diese aktualisieren.

Dies bedeutet jedoch, dass neue Objekte innerhalb des Frames, in dem sie entstanden sind, die Gelegenheit zum Handeln erhalten, ohne dass der Spieler auch nur die Möglichkeit hat, sie überhaupt zu sehen. Wenn Sie das unterbinden möchten, können Sie die Anzahl der Objekte in der Liste am Anfang der Aktualisierungsschleife zwischenspeichern und nur diese aktualisieren:

int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
  objects_[i]->update();
}

Hier ist objects_ ein Array der aktualisierbaren Objekte des Spiels und numObjects_ gibt dessen Länge an. Beim Hinzufügen neuer Objekte wird numObjects_ erhöht. Die Länge wird am Anfang der Schleife in numObjectsThisTurn zwischengespeichert, damit sie abbricht, bevor sie die im aktuellen Frame ergänzten Objekte erreicht.

Problematischer ist es, wenn Objekte während der Abarbeitung der Schleife entfernt werden. Haben Sie ein garstiges Biest besiegt, muss es aus der Objektliste verschwinden. Doch falls es sich in der Objektliste vor dem Objekt befindet, das Sie gerade aktualisieren, kann irrtümlicherweise ein Objekt übersprungen werden:

for (int i = 0; i < numObjects_; i++)
{
  objects_[i]->update();
}

Diese einfache Schleife erhöht bei jedem Durchlauf den Index des zu aktualisierenden Objekts. Die linke Seite der Abbildung 10.1 zeigt das Array während der Aktualisierung der Heldin.

Abb. 10.1: Das Entfernen des garstigen Biests während der Abarbeitung der Schleife überspringt den armen Bauern.

Da sie gerade aktualisiert wird, ist i = 1. Sie erschlägt das garstige Biest, das aus dem Array entfernt wird. Die Heldin rückt dann auf die Indexposition 0 und der arme Bauer auf 1 vor. Nach der Aktualisierung der Heldin wird i auf 2 erhöht. Wie Sie der rechten Seite der Abbildung 10.1 entnehmen können, wird der arme Bauer übersprungen und daher auch nicht aktualisiert.

Für dieses Problem gibt es eine einfache Lösung: Durchlaufen Sie die Liste rückwärts. So rücken beim Entfernen eines Objekts nur Objekte vor, die bereits aktualisiert wurden.

Geben Sie also beim Entfernen von Objekten Acht und aktualisieren Sie die Schleifenvariablen, um gelöschte Objekte zu berücksichtigen. Eine andere Möglichkeit wäre, das Entfernen von Objekten zu verschieben, bis die Liste abgearbeitet ist. Kennzeichnen Sie das Objekt als »tot«, belassen Sie es jedoch im Array. Sorgen Sie dann dafür, dass so gekennzeichnete Objekte bei der Aktualisierung übersprungen werden. Wenn das erledigt ist, können Sie die Liste erneut durchlaufen, um die Leichen zu beseitigen.

Falls in der Aktualisierungsschleife bei der Verarbeitung der Objekte mehrere Threads zum Einsatz kommen, wird es noch wahrscheinlicher, dass Sie irgendwelche Modifizierungen verschieben möchten, um während der Aktualisierung aufwendige Threadsynchronisierungen zu vermeiden.

10.5  Beispielcode

Dieses Pattern ist so schnörkellos, dass der Beispielcode das Thema schon fast zu Tode reitet. Das heißt jedoch nicht, dass es nicht nützlich ist. Zum Teil ist es nützlich, weil es so einfach ist: Es handelt sich um eine klare und deutliche Lösung eines Problems ohne große Ausschmückungen.

Sehen wir uns also eine konkrete, einfache Implementierung an. Zunächst legen wir eine Entity-Klasse an, die Skelette und Statuen repräsentiert:

class Entity
{
public:
  Entity()
  : x_(0), y_(0) {}

  virtual ~Entity() {}
  virtual void update() = 0;

  double x() const { return x_; }
  double y() const { return y_; }

  void setX(double x) { x_ = x; }
  void setY(double y) { y_ = y; }

private:
  double x_, y_;
};

Ich habe hier noch ein paar Dinge hinzugefügt, jedoch nur das absolute Minimum dessen, was später noch benötigt wird. Echter Code enthielte vermutlich noch eine Menge weiterer Dinge, die Grafik, Physik usw. handhaben. Der springende Punkt bei diesem Pattern ist, dass es eine abstrakte update()-Methode verwendet.

Das Spiel verfügt über eine Sammlung solcher Entity-Objekte. In unserem Beispiel platzieren wir sie in einer die Spielwelt repräsentierenden Klasse:

class World
{
public:
  World()
  : numEntities_(0) {}

  void gameLoop();

private:
  Entity* entities_[MAX_ENTITIES];
  int numEntities_;
};

In einem echten Programm würde man hierfür vermutlich eine der Klassen für Objektsammlungen verwenden. Ich verwende hier der Einfachheit halber ein 08/15-Array.

Nachdem das erledigt ist, implementiert das Spiel das Pattern, indem in jedem Frame alle Entity-Objekte aktualisiert werden:

void World::gameLoop()
{
  while (true)
  {
    // Benutzereingaben verarbeiten ...

    // Alle Objekte aktualisieren
    for (int i = 0; i < numEntities_; i++)
    {
      entities_[i]->update();
    }

  // Physik und Rendering ...
  }
}

Wie der Name der Klasse schon sagt, handelt es sich hier um eine Implementierung des Patterns Game Loop (Hauptschleife, Kapitel 9).

10.5.1  Entity-Unterklassen?

Manchen Lesern werden hier vielleicht die Haare zu Berge stehen, weil ich für die Entity-Klasse Vererbung verwende, um verschiedene Verhaltensweisen zu definieren. Für den Fall, dass Ihnen das Problem nicht klar ist, werde ich ein wenig weiter ausholen.

Als die Spielebranche dem urzeitlichen Meer des 6502-Assemblercodes entstieg und die Gestade objektorientierter Programmiersprachen erklomm, gerieten die Entwickler in einen regelrechten Softwarearchitektur-Rausch. Der bedeutendste betraf die Vererbung. Es wurden turmhohe, unglaublich komplizierte Klassenhierarchien verwendet, die groß genug waren, um die Sonne zu verdunkeln.

Wie sich herausstellte, war das keine wirklich gute Idee – niemand kann gigantische Klassenhierarchien warten, ohne ihnen gleichzeitig beim Verfall zuzusehen. Schon die Gang of Four wusste das, als sie 1994 schrieb: »Die Objektkomposition ist der Klassenvererbung vorzuziehen.«

Unter uns gesagt: Ich bin der Ansicht, dass der Trend weg von der Unterklassenbildung etwas übertrieben wird. Im Allgemeinen vermeide ich sie zwar, hinsichtlich der Nichtverwendung der Vererbung dogmatisch zu sein, ist jedoch genauso wenig zielführend, wie bei der Verwendung dogmatisch zu sein. Sie brauchen also nicht darauf zu verzichten, solange Sie sie maßvoll einsetzen.

Als sich diese Erkenntnis in der Spielebranche allmählich durchsetzte, etablierte sich als Lösung das Pattern Component (Komponente, Kapitel 14). Beim Einsatz dieses Patterns gehört update() zu den Komponenten der Entity-Klasse, nicht zur Entity-Klasse selbst. Auf diese Weise werden bei der Definition des Verhaltens und bei der Wiederverwendung des Codes komplizierte Klassenhierarchien vermieden. Stattdessen wählen Sie Komponenten aus und kombinieren sie miteinander.

Bei einem echten Spiel würde ich wohl so vorgehen. Aber in diesem Kapitel geht es (im Gegensatz zu Kapitel 14) nicht um Komponenten, sondern um update()-Methoden – und der einfachste Weg, sie ohne allzu viele veränderliche Bestandteile vorzuführen, ist die Platzierung der Methode direkt in der Entity-Klasse sowie das Erstellen einiger Unterklassen.

10.5.2  Entities definieren

Zurück zum Beispiel. Das ursprüngliche Ziel war die Definition eines patrouillierenden Skeletts und einiger Blitze schleudernder magischer Statuen. Fangen wir mit unserem knöchernen Freund an. Zur Definition seines Verhaltens erstellen wir eine Unterklasse der Entity-Klasse, die update() dementsprechend implementiert:

class Skeleton : public Entity
{
public:
  Skeleton()
  : patrollingLeft_(false) {}

  virtual void update()
  {
    if (patrollingLeft_)
    {
      setX(x() - 1);
      if (x() == 0) patrollingLeft_ = false;
    }
    else
    {
      setX(x() + 1);
      if (x() == 100) patrollingLeft_ = true;
    }
  }

private:
  bool patrollingLeft_;
};

Wie Sie sehen, haben wir im Wesentlichen den Codeabschnitt der Game Loop am Anfang des Kapitels ausgeschnitten und in die update()-Methode der Skeleton-Klasse eingefügt. Der einzige kleine Unterschied besteht darin, dass patrollingLeft_ jetzt keine lokale Variable mehr ist, sondern ein Datenfeld. Auf diese Weise bleibt der Wert zwischen zwei update()-Aufrufen erhalten.

Das Ganze wird nun mit der Statue-Klasse wiederholt:

class Statue : public Entity
{
public:
  Statue(int delay)
  : frames_(0),
    delay_(delay)
  {}

  virtual void update()
  {
    if (++frames_ == delay_)
    {
      shootLightning();
      // Zähler zurücksetzen
      frames_ = 0;
    }
  }

private:
  int frames_;
  int delay_;
  void shootLightning()
  {
    // Blitz schleudern
  }
};

Wieder bestehen die Änderungen hauptsächlich aus der Übernahme des Codes in die Klasse und einigen Umbenennungen. In diesem Fall haben wir die Codebasis sogar vereinfacht: In dem scheußlichen ursprünglichen imperativen Code gab es eigene Variablen für den Framezähler und die Häufigkeit des Blitzschleuderns jeder Statue.

Da diese nun Bestandteil der Statue-Klasse selbst sind, können Sie beliebig viele Instanzen hinzufügen – jede davon verfügt über ihren eigenen Zähler. Dies ist der eigentliche Grund für das Pattern: Es ist nun viel einfacher, der Spielwelt neue Objekte hinzuzufügen, denn sie bringen bereits alles mit, was sie brauchen, damit sie sich um sich selbst kümmern können.

Das Pattern gestattet es, das Bevölkern der Spielwelt von der Implementierung zu trennen. Und das wiederum erlaubt es, die Spielwelt auf flexible Weise zu bevölkern, beispielsweise mittels einer eigenen Datendatei oder einem Leveleditor.

Abb. 10.2: Ein UML-Diagramm des soeben Erstellten – falls Sie sich für UML interessieren

10.5.3  Zeitablauf

Dies ist im Wesentlichen das Pattern, ich möchte jedoch eine gängige Erweiterung noch kurz erwähnen. Bislang sind wir davon ausgegangen, dass jeder update()-Aufruf den Zustand der Spielwelt um einen konstanten Zeitschritt voranbringt.

Mir persönlich ist das auch lieber, allerdings verwenden viele Spiele variable Zeitschritte. Dabei wird nach jedem Durchlaufen der Game Loop ein mal längerer und mal kürzerer Zeitschritt simuliert – je nachdem, wie lange die Verarbeitung und das Rendern des vorangehenden Frames dauert.

Im Kapitel über die Game Loop (Hauptschleife, Kapitel 9) werden die Vor- und Nachteile konstanter und variabler Zeitschritte ausführlich erörtert.

Das bedeutet jedoch, dass bei jedem update()-Aufruf bekannt sein muss, wie weit die virtuelle Uhr schon fortgeschritten ist. Daher sieht man häufig, dass der update()-Methode die verstrichene Zeit übergeben wird. Unser patrouillierendes Skelett könnte beispielsweise variable Zeitschritte wie folgt handhaben:

void Skeleton::update(double elapsed)
{
  if (patrollingLeft_)
  {
    x -= elapsed;
    if (x <= 0)
    {
      patrollingLeft_ = false;
      x = -x;
    }
  }
  else
  {
    x += elapsed;
    if (x >= 100)
    {
      patrollingLeft_ = true;
      x = 100 - (x - 100);
    }
  }
}

Die vom Skelett zurückgelegte Distanz wächst nun, wenn die verstrichene Zeit zunimmt. Hier wird auch die zusätzliche Komplexität bei der Handhabung variabler Zeitschritte erkennbar. So könnte das Skelett etwa nach einem großen Zeitschritt außerhalb seines normalen Patrouillegebiets landen – und dies muss sorgfältig berücksichtigt werden.

10.6  Designentscheidungen

Bei einem so einfachen Pattern wie diesem gibt es zwar nicht allzu viele Variationen, dennoch lässt sich die eine oder andere Einstellung vornehmen.

10.6.1  Zu welcher Klasse gehört die update()-Methode?

Zu welcher Klasse die update()-Methode gehört, ist die offensichtlichste und auch wichtigste Entscheidung, die Sie treffen müssen.

Die Entity-Klasse:

Falls es bereits eine Entity-Klasse gibt, ist dies die einfachste Möglichkeit, da keine weiteren Klassen ins Spiel kommen. Wenn es nicht zu viele verschiedene Arten von Objekten gibt, kann dies gut funktionieren, die Spielebranche lehnt dieses Modell im Allgemeinen jedoch zunehmend ab.

Bei jedem neuen Verhalten eine Entity-Unterklasse erstellen zu müssen, kann zu fragilem Code führen und ist mühselig, falls es viele verschiedene Objektarten gibt. Früher oder später werden Sie den Wunsch verspüren, Teile des Codes auf eine Weise wiederzuverwenden, die nicht vernünftig zu einer einzelnen Vererbungshierarchie passt. Dann befinden Sie sich in einer Sackgasse.

Die Component-Klasse:

Falls Sie das Pattern Component (Komponente, Kapitel 14) bereits einsetzen, brauchen Sie nicht lange zu überlegen: Die Komponenten aktualisieren sich unabhängig voneinander selbst. Auf dieselbe Weise, wie das Pattern Update Method im Allgemeinen in der Spielwelt Objekte voneinander entkoppelt, ermöglicht das Pattern Component, Teile einer einzelnen Entity voneinander zu entkoppeln. Rendering, Physik und KI können sich um sich selbst kümmern.

Eine Delegate-Klasse:

Es gibt noch einige andere Patterns, die Teile des Verhaltens einer Klasse an andere Objekte delegieren. Das Pattern State (Zustand, Kapitel 7) ermöglicht es Ihnen, das Verhalten eines Objekts zu modifizieren, indem Sie ändern, an wen das Objekt delegiert. Das Pattern Type Object (Typ-Objekt, Kapitel 13) gestattet es, dass mehrere Entities derselben »Art« auch dasselbe Verhalten zeigen.

Falls Sie eins dieser Pattern einsetzen, liegt es nahe, die update()-Methode der Delegate-Klasse hinzuzufügen. In diesem Fall gibt es zwar womöglich weiterhin die update()-Methode der Hauptklasse, sie ist jedoch nicht virtuell und leitet Aufrufe an das Delegate-Objekt weiter. In etwa dergestalt:

void Entity::update()
{
  // Weiterleitung an das State-Objekt
  state_->update();
}

Falls Sie es so handhaben, können Sie durch den Austausch des Delegate-Objekts neues Verhalten festlegen. Wie bei der Verwendung von Komponenten, können Sie das Verhalten flexibel modifizieren, ohne gleich eine neue Unterklasse definieren zu müssen.

10.6.2  Wie werden inaktive Objekte gehandhabt?

In der Spielwelt gibt es häufig einige Objekte, die aus irgendeinem Grund vorübergehend nicht aktualisiert werden müssen. Sie könnten deaktiviert oder nicht auf dem Bildschirm sichtbar sein, vielleicht wurden sie auch noch nicht freigegeben. Sollte es viele solcher Objekte geben, wäre es eine Verschwendung von CPU-Rechenzeit, sie alle in jedem Frame zu durchlaufen, nur um dann nichts mit ihnen anzufangen.

Hier bietet sich die Alternative an, eine eigene Sammlung »aktiver« Objekte anzulegen, die auch tatsächlich aktualisiert werden müssen. Bei der Deaktivierung eines Objekts wird es dann aus der Sammlung entfernt und wieder hinzugefügt, sobald es reaktiviert wird. Auf diese Weise werden nur diejenigen Objekte durchlaufen, bei denen auch tatsächlich Arbeit zu erledigen ist.

Bei Verwendung einer einzigen Sammlung, die auch inaktive Objekte enthält:

Sie verschwenden Rechenzeit. Sie müssen entweder ein Flag überprüfen, das anzeigt, ob das Objekt aktiv ist, oder aber Sie rufen eine Methode auf, die überhaupt nichts tut.

Das sinnlose Durchlaufen von Objekten verschwendet beim Überprüfen des Flags und dem Überspringen nicht aktiver Objekte nicht nur CPU-Rechenzeit, sondern kann auch Ihren Cache unbrauchbar machen. Die CPU optimiert Lesevorgänge, indem sie Speicherbereiche des RAMs in den erheblich schnelleren Cachespeicher des Prozessors lädt. Das geschieht allerdings spekulativ, weil angenommen wird, dass wahrscheinlich weitere Lesevorgänge an derselben Stelle folgen werden.

Wenn Sie nun ein Objekt überspringen, springen Sie womöglich über das Ende des Caches hinaus und veranlassen dadurch das vergleichsweise langsame Einlesen eines anderen Speicherbereichs in den Cache.

Im Kapitel Data Locality (Datenlokalität, Kapitel 17) wird diese Problematik ausführlich erläutert.

Bei Verwendung einer separaten Sammlung, die nur aktive Objekte enthält:

Die weitere Sammlung erfordert zusätzlichen Arbeitsspeicher. Üblicherweise gibt es nach wie vor die Hauptsammlung, die sämtliche Objekte enthält – für den Fall, dass alle benötigt werden. Technisch gesehen ist die separate Sammlung somit redundant. Falls die Performance wichtiger ist als der zusätzlich belegte Arbeitsspeicher (und dem ist oft so), kann es sich hier dennoch um einen lohnenswerten Kompromiss handeln.

Eine weitere Möglichkeit, dieses Problem zu entschärfen, ist die Verwendung zweier Sammlungen, wobei die zweite jedoch nur inaktive Objekte enthält.

Sie müssen die beiden Sammlungen synchronisieren. Wenn neue Objekte erzeugt oder endgültig gelöscht (nicht nur deaktiviert) werden, dürfen Sie nicht vergessen, sowohl die Hauptsammlung als auch die Sammlung aktiver Objekte zu modifizieren.

Das für den verwendeten Ansatz entscheidende Kriterium ist die Anzahl der inaktiven Objekte. Je mehr es davon gibt, desto lohnender ist eine separate Sammlung, die es vermeidet, dass die Objekte in der Game Loop durchlaufen werden.

10.7  Siehe auch ...

Die Patterns Game Loop (Hauptschleife, Kapitel 9), Component (Komponente, Kapitel 14) und dieses Pattern sind Bestandteile einer Dreifaltigkeit, die oftmals den Kern einer Spiel-Engine bildet.

Wenn Sie anfangen, sich bei der Aktualisierung einer Reihe von Objekten oder Komponenten in einer Schleife in jedem Frame Sorgen über die Performance des Caches zu machen, kann das Pattern Data Locality (Datenlokalität, Kapitel 17) bei der Beschleunigung dieses Vorgangs helfen.

Das Unity-Framework verwendet dieses Pattern in einigen Klassen, unter anderem in der Klasse MonoBehaviour.

Microsofts XNA-Plattform nutzt dieses Pattern in den Klassen Game und GameComponents.

Die JavaScript-Spiel-Engine Quintus verwendet dieses Pattern in der Sprite-Klasse.

Teil IV: Verhaltensmuster (Behavioral Patterns)

In diesem Teil:

Kapitel 11

Bytecode

Kapitel 12

Subclass Sandbox (Unterklassen-Sandbox)

Kapitel 13

Type Object (Typ-Objekt)

Sobald Sie die Kulissen Ihres Spiels aufgebaut, es mit Akteuren bestückt und mit Requisiten ausgestattet haben, kann es endlich losgehen. Dafür ist jedoch Verhalten erforderlich – ein Drehbuch, das den Akteuren Ihres Spiels mitteilt, was sie tun sollen.

Natürlich stellt letzten Endes jeglicher Code »Verhalten« dar – sämtliche Software definiert ein Verhalten. Bei Spielen gibt es allerdings häufig einen wichtigen Unterschied, nämlich den enormen Umfang des Verhaltens, das implementiert werden muss. Ihre Textverarbeitung mag eine Menge Features bieten, sieht jedoch im Vergleich zu der in einem typischen Rollenspiel vorhandenen Anzahl von Einwohnern, Gegenständen und Aufgabenstellungen ziemlich alt aus.

Die in diesem Kapitel vorgestellten Patterns sollen dabei helfen, eine Vielzahl unterschiedlicher Verhaltensweisen zu definieren und weiterzuentwickeln, die sich gut warten lassen. Mithilfe des Patterns Type Object (Typ-Objekt, Kapitel 13) lassen sich Verhaltensweisen ohne die mit der Definition einer Klasse einhergehenden Zwänge implementieren. Eine Subclass Sandbox (Unterklassen-Sandbox, Kapitel 12) stellt einen Satz elementarer Operationen zur Verfügung, die Sie dazu verwenden können, ein breites Spektrum von Verhaltensweisen zu definieren. Und die anspruchsvollste Option ist das Pattern Bytecode (Kapitel 11), das das Verhalten aus dem Code entfernt und es komplett in Daten umwandelt.

Kapitel 11: Bytecode

Gewährleistung datenähnlicher Flexibilität für Verhaltensweisen durch Umwandlung in Anweisungen für eine virtuelle Maschine.

11.1  Motivation

Spiele zu entwickeln, macht zwar Spaß, ist allerdings auch nicht ganz einfach. Die Codebasen moderner Spiele sind komplex und enorm umfangreich. Die Spielkonsolenhersteller und die Wächter über App-Stores stellen hohe Qualitätsansprüche. Schon ein einzelner zum Absturz führender Bug kann die Auslieferung eines Spiels verhindern.

Ich habe schon an einem Spiel mitgearbeitet, das aus mehr als sechs Millionen Zeilen C++-Code bestand. Zum Vergleich: Die Software zur Steuerung des Mars-Rovers »Curiosity« ist nicht einmal halb so groß.

Gleichzeitig wird aber auch von uns erwartet, dass wir selbst das letzte bisschen Leistung noch aus der Plattform herauskitzeln. Spiele reizen die Hardware wie keine andere Software aus, und wir müssen unablässig Optimierungen durchführen, um mit der Konkurrenz Schritt zu halten.

In dem Bestreben, diesen hohen Anforderungen an Stabilität und Performance gerecht werden zu können, greifen wir auf schwergewichtige Sprachen wie C++ zurück, die zwecks bestmöglicher Nutzung der Hardware nicht nur eine maschinennahe Programmierung erlauben, sondern auch über ein vielfältiges Typisierungssystem verfügen, um Bugs zu verhindern oder zumindest dingfest zu machen.

Wir sind stolz auf unser fachliches Können, allerdings ist das natürlich nicht umsonst zu haben. Um den Status eines kompetenten Programmierers zu erreichen, ist jahrelange Erfahrung notwendig – und am Ende plagt man sich mit dem schieren Ausmaß seiner riesigen Codebasis herum. Der für das Kompilieren eines komplexen Spiels erforderliche Zeitrahmen variiert von »Geh einen Kaffee trinken« bis hin zu »Röste dir deine eigenen Kaffeebohnen, mahle sie mit der Hand, brüh einen Espresso, schäume Milch auf und übe dich als Barista«.

Darüber hinaus stellen uns Spiele auch noch vor eine weitere gemeine Herausforderung: Sie sollen Spaß machen. Die Spieler verlangen nach einem sowohl originellen als auch sorgfältig ausgewogenen Spielerlebnis. Zu diesem Zweck sind ständige Überarbeitungen bzw. Anpassungen des Spiels notwendig. Wenn man jedoch bei jeder kleinen Änderung erst einen Programmierer bedrängen muss, damit er Hand an einen Haufen maschinennahen Code anlegt, und dann ewig auf die Neukompilierung warten muss, ist das der Kreativität nicht gerade förderlich.

11.1.1  Wettkampf der Zaubersprüche

Nehmen wir an, wir entwickeln ein auf Magie beruhendes Wettkampfspiel: Zwei Hexenmeister gehen in die Offensive und werfen mit Verwünschungen um sich, bis ein Sieg verkündet wird. Wir könnten die Zaubersprüche unmittelbar im Code definieren, dann müsste jedoch bei jeder Modifizierung ein Programmierer tätig werden. Würde ein Designer dann ein paar Werte ändern wollen, um auszuprobieren, wie sich dies auswirkt, müsste dazu erst das gesamte Spiel neu kompiliert, neu gestartet und ein neuer Wettkampf begonnen werden.

Außerdem muss es wie bei den meisten heutigen Spielen möglich sein, das Programm auch nach der Auslieferung zwecks Fehlerbehebung und Ergänzung neuer Inhalte zu aktualisieren. Wären die Zaubersprüche jedoch Bestandteil des Codes, müsste die ausführbare Programmdatei bei einem Update gepatcht werden.

Gehen wir noch einen Schritt weiter und nehmen an, dass auch das Modding unterstützt werden soll, d.h., die Benutzer sollen selbsterdachte Zaubersprüche verwenden können. Wären die Zaubersprüche generell Teil des Codes, bräuchte jeder Spieler einen Compiler und wir müssten den Quelltext veröffentlichen. Und noch schlimmer wäre es, wenn Bugs in solchen individuellen Zaubersprüchen das Programm auf den Rechnern anderer Spieler zum Absturz bringen würden.

11.1.2  Daten > Code

Es liegt auf der Hand, dass die zur Implementierung verwendete Programmiersprache hier ungeeignet ist. Die Zaubersprüche müssen eindeutig vom eigentlichen Spielprogramm separiert werden. Sie sollen leicht zu ändern und einzulesen sein und sich in einer eigenen Datei befinden.

Ich weiß ja nicht, wie es Ihnen geht, aber in meinen Ohren klingt das doch sehr nach Daten. Wenn wir das Verhalten in eigenen Dateien definieren, die von der Spiel-Engine irgendwie eingelesen und »ausgeführt« werden, können wir alle unsere Ziele erreichen.

Nun müssen wir nur noch herausfinden, was »ausführen« im Zusammenhang mit Daten bedeutet. Wie lässt sich ein Verhalten durch ein paar Bytes in einer Datei ausdrücken? Hierfür gibt es verschiedene Möglichkeiten. Ich denke, die Stärken und Schwächen dieses Patterns werden am deutlichsten, wenn man es mit einem anderen vergleicht: dem Pattern Interpreter.

11.1.3  Das Interpreter-Pattern

Ich könnte ein ganzes Kapitel über dieses Pattern schreiben, doch das haben bereits vier andere Leute für mich erledigt.

Ich beziehe mich hier natürlich auf das Interpreter-Pattern im Buch Design Patterns.

Deshalb stelle ich es hier lediglich kurz vor. Zunächst einmal geht es um eine Sprache (im Sinne einer Programmiersprache), deren Anweisungen ausgeführt werden sollen. Nehmen wir beispielsweise an, sie unterstützt arithmetische Ausdrücke wie diesen:

(1 + 2) * (3 – 4)

Nun wird jeder Bestandteil des Ausdrucks gemäß der Grammatik der Sprache in ein Objekt umgewandelt. Die Zahlliterale sind dann Objekte:

Abb. 11.1: Vier Zahlliterale in einer Reihe

Dabei handelt es sich im Wesentlichen um eine »Verpackung« der Rohdaten. Die Operatoren werden ebenfalls in Objekte umgewandelt, die Referenzen auf ihre Operanden besitzen. Wenn man die Klammern und deren Vorrang berücksichtigt, wird dieser Ausdruck wie von Zauberhand zu einem kleinen Objektbaum:

Abb. 11.2: Abstrakter Syntaxbaum eines verschachtelten Ausdrucks

Mit der »Zauberhand« ist ein Parser gemeint. Ein Parser nimmt eine Zeichenkette entgegen und wandelt sie in einen abstrakten Syntaxbaum um – eine Objektsammlung, die die grammatikalische Struktur des Textes repräsentiert.

Mit einem vernünftigen Parser hat man schon die Hälfte eines Compilers beisammen.

Beim Interpreter-Pattern geht es nicht darum, diesen Baum zu erstellen, sondern darum, ihn auszuführen. Die Funktionsweise ist ziemlich pfiffig. Jedes Objekt im Baum ist entweder ein Ausdruck oder ein Unterausdruck. Und in typisch objektorientierter Manier lassen wir die Ausdrücke sich selbst auswerten.

Zunächst definieren wir ein grundlegendes Interface, das alle Ausdrücke implementieren müssen:

class Expression
{
public:
  virtual ~Expression() {}
  virtual double evaluate() = 0;
};

Dann definieren wir eine Klasse, die dieses Interface für die verschiedenen Ausdrucksarten in der Grammatik der Sprache implementiert. Die einfachste ist diejenige für Zahlen:

class NumberExpression : public Expression
{
public:
  NumberExpression(double value)
  : value_(value)
  {}

  virtual double evaluate() { return value_; }

private:
  double value_;
};

Ein Zahlliteral ergibt bei der Auswertung einfach seinen Wert. Addition und Multiplikation sind ein wenig komplizierter, weil sie Unterausdrücke enthalten. Bevor diese sich selbst auswerten können, müssen sie wie folgt ihre Unterausdrücke rekursiv auswerten:

class AdditionExpression : public Expression
{
public:
  AdditionExpression(Expression* left,
                     Expression* right)
  : left_(left),
    right_(right)
  {}

  virtual double evaluate()
  {
    // Operanden auswerten
    double left = left_->evaluate();
    double right = right_->evaluate()
    // Addieren
    return left + right;
  }

private:
  Expression* left_;
  Expression* right_;
};

Wie die Implementierung für die Multiplikation aussieht, können Sie sich sicher denken.

Ziemlich elegant, oder? Nur ein paar einfache Klassen und schon können wir beliebig komplexe arithmetische Ausdrücke repräsentieren und auswerten. Wir müssen nur die richtigen Objekte erzeugen und sie korrekt miteinander verbinden.

Die Programmiersprache Ruby wurde rund 15 Jahre lang auf diese Weise implementiert. Seit der Version 1.9 wurde dann der in diesem Kapitel beschriebene Bytecode verwendet. Da können Sie mal sehen, wie viel Zeit ich Ihnen erspare!

Das Pattern ist schön und einfach, birgt jedoch auch einige Probleme. Sehen Sie sich Abbildung 11.2 noch einmal an. Fällt Ihnen etwas auf? Viele kleine Kästchen und viele Verbindungslinien. Der Code wird in Form eines sich ausbreitenden fraktalen Baums aus kleinen Objekten repräsentiert. Dies hat einige unangenehme Konsequenzen:

Zum Einlesen des Baums aus einer Datei müssen zuhauf Objekte instanziiert und miteinander in Beziehung gesetzt werden.

Diese Objekte und all die Zeiger, durch die sie miteinander verbunden sind, belegen eine Menge Speicherplatz. Auf einer 32-Bit-Maschine sind für den kleinen arithmetischen Ausdruck mindestens 68 Bytes erforderlich – und dabei sind die Leerzeichen zum Auffüllen der Lücken noch gar nicht berücksichtigt.

Falls Sie damit herumexperimentieren möchten, sollten Sie nicht vergessen, die vtable-Zeiger zu berücksichtigen.

Den Baum bis zu den Unterausdrücken zu durchlaufen, ist Mord am Daten-Cache. Und all die Aufrufe virtueller Methoden richten im Instruktions-Cache ein Blutbad an.

Im Kapitel 17 Data Locality (Datenlokalität) erfahren Sie mehr über den Cache und seinen Einfluss auf die Geschwindigkeit.

Fassen Sie diese Punkte zusammen, ergibt sich: L-A-H-M. Es hat schon seinen Grund, dass die meisten Programmiersprachen nicht auf dem Interpreter-Pattern beruhen, denn es ist schlicht und einfach zu langsam und zu speicherhungrig.

11.1.4  Faktisch Maschinencode

Beim Ausführen des Spiels durchläuft der Computer des Spielers allerdings zur Laufzeit keine C++-Baumstrukturen. Stattdessen kompilieren wir vorab Maschinencode, den die CPU ausführt. Welche Vorteile hat Maschinencode?

Er ist kompakt. Der Maschinencode ist ein stabiler, zusammenhängender Speicherbereich binärer Daten, bei dem keinerlei Platz verschwendet wird.

Er ist linear. Die aneinandergereihten Instruktionen werden der Reihe nach ausgeführt und es findet kein »Herumspringen« im Arbeitsspeicher statt (sofern Sie den Programmablauf nicht bewusst steuern).

Er ist maschinennah. Jede Anweisung erledigt nur eine minimale Aufgabe. Interessantes Verhalten entsteht erst durch die Zusammenstellung mehrerer Instruktionen.

Er ist schnell. Aus den genannten Gründen (und aufgrund der Tatsache, dass er direkt in Hardware implementiert ist) ergibt sich die Konsequenz, dass Maschinencode blitzschnell ist.

Das klingt ja ziemlich großartig, allerdings wollen wir eigentlich keinen Maschinencode für unsere Zaubersprüche verwenden. Wenn Benutzer Maschinencode bereitstellen können, den unser Spiel ausführt, ist das geradezu eine Einladung dazu, Sicherheitsvorschriften zu verletzen.

Aus diesem Grund ist es auf vielen Spielkonsolen und auf iOS nicht erlaubt, dass Programme geladenen oder zur Laufzeit erzeugten Maschinencode ausführen – und das ist ärgerlich, weil die schnellsten Implementierungen von Programmiersprachen genau diesen Mechanismus nutzen. Sie verwenden einen sogenannten JIT-Compiler (Just-in-time), der die Anweisungen der Programmiersprache in Echtzeit in Maschinencode übersetzt.

Wir brauchen also einen Kompromiss zwischen der Schnelligkeit des Maschinencodes und der Sicherheit des Interpreter-Patterns.

Wie wäre es denn damit, statt richtigen Maschinencode zu laden und direkt auszuführen, eigenen virtuellen Maschinencode zu definieren? Wir würden dann in unserem Spiel einen kleinen Emulator programmieren und das Ganze wäre echtem Maschinencode recht ähnlich – kompakt, linear, relativ maschinennah –, würde jedoch komplett von unserem Spiel gehandhabt werden und ließe sich problemlos abschotten.

Ein solcher Emulator wird als virtuelle Maschine (oder kurz VM) bezeichnet. Der von ihr ausgeführte synthetische, binäre Maschinencode heißt Bytecode.

In Programmiererkreisen werden die Begriffe »virtuelle Maschine« und »Interpreter« oft synonym gebraucht und auch ich verwende sie hier austauschbar. Wenn ich mich auf das Interpreter-Pattern der Gang of Four beziehe, benutze ich der Deutlichkeit halber den Begriff »Pattern«.

Der Bytecode besitzt dieselbe Flexibilität und Benutzerfreundlichkeit wie Daten, läuft aber schneller als auf Hochsprachen beruhenden Codedarstellungen wie dem Interpreter-Pattern.

Das klingt nun allerdings zunächst einmal ein wenig einschüchternd. Ziel des verbleibenden Kapitels soll es daher sein, Ihnen vorzuführen, dass die Sache gar nicht so kompliziert ist, wie es scheint – sofern man die Liste der Funktionalitäten halbwegs einschränkt. Auch wenn Sie das Pattern letzten Endes selbst gar nicht verwenden, werden Sie zumindest Lua und viele andere Sprachen, für deren Implementierung es verwendet wird, besser verstehen.

11.2  Das Pattern

Ein Befehlssatz definiert die ausführbaren, maschinennahen Operationen. Eine Reihe von Befehlen wird in Form einer Bytesequenz kodiert. Die Befehle werden unter Verwendung eines Stacks für Zwischenwerte von einer virtuellen Maschine nacheinander ausgeführt. Durch die Kombination mehrerer Befehle kann komplexes Verhalten auf Anwendungsebene definiert werden.

11.3  Anwendbarkeit

Hierbei handelt es sich um das komplizierteste Pattern dieses Buches und es sollte nicht leichtfertig in ein Spiel eingebracht werden. Verwenden Sie es, wenn Sie sehr viele Verhaltensweisen festlegen müssen und die zur Implementierung des Spiels verwendete Sprache aus einem der nachfolgenden Gründe dafür ungeeignet ist:

Die Sprache ist zu maschinennah und macht die Programmierung mühsam und fehleranfällig.

Das Spiel zu überarbeiten, dauert zu lange. Grund dafür könnten eine zu langwierige Kompilierung oder andere die Werkzeugsammlung betreffende Schwierigkeiten sein.

Die Sprache besitzt zu umfangreiche Zugriffsrechte. Wenn Sie sicherstellen möchten, dass neu definiertes Verhalten keine Störungen im Spielablauf verursachen kann, müssen Sie es von der übrigen Codebasis abschotten.

Natürlich beschreibt diese Liste bereits weite Teile Ihres Spiels. Wer hätte nicht gerne eine schnellere Game Loop oder mehr Sicherheit? Das hat jedoch auch seinen Preis. Bytecode ist langsamer als nativer Maschinencode und daher für geschwindigkeitskritische Teile der Spiel-Engine ungeeignet.

11.4  Konsequenzen

Es ist schon verlockend, eine eigene Sprache oder ein System innerhalb eines Systems zu entwickeln.

Für mich persönlich ist die Spieleentwicklung auf dieselbe Art und Weise verlockend. In beiden Fällen strebe ich danach, virtuelle Räume für andere zu schaffen, in denen man herumexperimentieren und kreativ sein kann

Ich führe hier nur ein minimales Beispiel vor, in echten Programmen schießen derlei Erweiterungen jedoch oft wie Pilze aus dem Boden.

Wenn jemand eine kleine Sprache oder ein Skriptsystem entwickelt, heißt es immer: »Keine Sorge, das bleibt überschaubar.« Und dann werden doch zwangsläufig immer mehr kleine Features hinzugefügt, bis es sich schließlich um eine ausgewachsene Sprache handelt – leider ist sie im Gegensatz zu anderen Sprachen nur zu diesem Zweck organisch gewachsen und besitzt daher die architektonische Eleganz einer Barackensiedlung.

Beispielsweise sämtliche jemals entwickelten Schablonensprachen ...

Es gibt ja im Prinzip nichts daran auszusetzen, eine vollständige Sprache zu entwickeln, stellen Sie jedoch sicher, dass dies absichtlich geschieht. Geben Sie außerdem sehr genau darauf acht, was sich durch Ihren Bytecode alles formulieren lässt – und setzen Sie hier lieber enge Grenzen, bevor die Pferde mit ihm durchgehen.

11.4.1  Befehlsformat

Maschinennaher Bytecode verbessert die Performance, die Anwender sollen aber kein binäres Bytecode-Format erstellen müssen. Einer der Gründe dafür, das Verhalten nicht mehr im Code unterzubringen, ist ja schließlich, dass wir es auf höherer Ebene formulieren möchten. Die Anwender faktisch zum Schreiben von Assemblercode zu zwingen – auch selbstentworfenem –, stellt nun wahrlich keinen Fortschritt dar, wenn schon C++ zu maschinennah ist!

Diese Feststellung wird allerdings durch das altehrwürdige Spiel RoboWar infrage gestellt. Hier schreibt der Spieler zwecks Steuerung eines Roboters kleine Programme in einer Sprache, die große Ähnlichkeit mit Assembler und den hier beschriebenen Befehlen besitzt.

Ich selbst bin durch dieses Spiel erstmals mit einer Assembler-ähnlichen Sprache in Berührung gekommen.

Ähnlich wie beim Interpreter-Pattern der Gang of Four wird hier davon ausgegangen, dass Ihnen eine Möglichkeit zur Erzeugung des Bytecodes zur Verfügung steht. Für gewöhnlich erstellen die Anwender die Verhaltensweisen in Form einer Hochsprache, die dann von einem Hilfsprogramm in den Bytecode übersetzt wird, den unsere virtuelle Maschine versteht – mit anderen Worten: einen Compiler.

Mir ist schon klar, dass sich das zunächst einmal beängstigend anhört, deshalb erwähne ich es an dieser Stelle. Sollten Ihnen die Ressourcen zum Erstellen eines solchen Programms fehlen, ist Bytecode für Ihre Zwecke womöglich ungeeignet. Sie werden aber bald sehen, dass die Sache gar nicht so schwierig ist, wie Sie vielleicht denken.

11.4.2  Fehlender Debugger

Programmierung ist nicht einfach. Wir wissen zwar, welche Aufgabe die Maschine erledigen soll, teilen ihr das jedoch nicht immer korrekt mit – und produzieren Bugs. Und um diese aufzuspüren und zu beheben, haben wir einen Haufen Werkzeuge zusammengetragen, mit denen ermittelt werden kann, was unser Code falsch macht und wie sich das korrigieren lässt. Es gibt Debugger, Werkzeuge zur statischen Analyse des Codes, Tools zur Dekompilierung usw. All diese Hilfsprogramme sind für bereits vorhandene Sprachen ausgelegt, nämlich entweder für Maschinencode oder für verschiedene Hochsprachen.

Bei der Entwicklung einer eigenen Bytecode-VM müssen Sie auf diese Tools verzichten. Sie können natürlich den Ablauf der VM im Debugger verfolgen, aber Sie sehen dann ja nur die Vorgänge innerhalb der VM selbst, nicht jedoch was der Bytecode anstellt, den sie ausführt. Und den Bytecode mittels Dekompilierung wieder in seiner ursprünglichen Form anzuzeigen, hilft Ihnen auch nicht weiter.

Wenn das festgelegte Verhalten einfach ist, kommen Sie beim Debuggen vielleicht gerade so über die Runden, wenn die Inhalte allerdings umfangreicher werden, sollten Sie Zeit für die Entwicklung von Features einplanen, die es den Benutzern erlauben zu beobachten, was ihr Bytecode eigentlich bewirkt. Derartige Features werden vielleicht gar nicht zusammen mit dem Spiel ausgeliefert, sie sind aber unverzichtbar, um zu gewährleisten, dass Ihr Spiel überhaupt ausgeliefert werden kann.

Falls das Spiel von den Benutzern selbst modifiziert werden soll, müssen diese Features natürlich ebenfalls ausgeliefert werden. Das verleiht ihnen nur noch größere Bedeutung.

11.5  Beispielcode

Angesichts der vorhergehenden Abschnitte werden Sie womöglich überrascht sein, wie einfach die Implementierung ist. Zunächst einmal wird der Befehlssatz für die VM benötigt. Bevor wir uns den Kopf über Bytecode und dergleichen zerbrechen, sollten wir uns das Ganze wie eine API vorstellen.

11.5.1  Eine zauberhafte API

Welche Art API bräuchten wir, um den Code aufzurufen, wenn die Zaubersprüche durch einfache C++-Anweisungen definiert werden? Was sind die grundlegenden Operationen der Spiel-Engine, durch die sich Zaubersprüche beschreiben lassen? Die meisten Sprüche modifizieren den Zustand eines Hexenmeisters – fangen wir also mit Gesundheitszustand (Health), Weisheit (Wisdom) und Wendigkeit (Agility) an:

void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);

Der erste Parameter gibt an, welcher der Hexenmeister betroffen ist, beispielsweise 0 für den Spieler und 1 für seinen Rivalen. Auf diese Weise können heilende Zaubersprüche auch auf den Hexenmeister des Spielers angewendet werden, während schädigende Angriffe den Konkurrenten verletzen. Diese drei kleinen Methoden decken bereits ein erstaunlich breites Spektrum an magischen Effekten ab.

Würde ein Zauberspruch einfach nur lautlos die Kennzahlen aktualisieren, wäre die Spiellogik davon zwar nicht beeinträchtigt, die Spieler wären jedoch zu Tode gelangweilt. Ändern wir das:

void playSound(int soundId);
void spawnParticles(int particleType);

Der Spielverlauf wird dadurch nicht beeinflusst, aber das Spielerlebnis wird intensiviert. Wir könnten noch weitere Effekte hinzufügen, etwa ein wackelndes Bild, Animationen oder Ähnliches, fürs Erste soll dies jedoch ausreichen.

11.5.2  Ein bezaubernder Befehlssatz

Sehen wir uns nun an, wie sich diese programmatische API durch Daten steuern lässt. Wir fangen ganz bescheiden an und arbeiten uns dann allmählich vor. Als Erstes entsorgen wir die ganzen Parameter dieser Methoden. Die set___()-Methoden sollen sich immer auf den Hexenmeister des Spielers beziehen und das vorhandene Potenzial jeweils voll ausschöpfen. Und die Effekt-Operationen sollen stets einen einzelnen, fest vorgegebenen Sound wiedergeben bzw. bestimmte Partikel darstellen.

Angesichts dessen besteht ein Zauberspruch einfach nur aus einer Reihe von Anweisungen, die festlegen, welche Aktion ausgeführt werden soll. Diese Instruktionen können wir durchnummerieren:

enum Instruction
{
  INST_SET_HEALTH      = 0x00,
  INST_SET_WISDOM      = 0x01,
  INST_SET_AGILITY     = 0x02,
  INST_PLAY_SOUND      = 0x03,
  INST_SPAWN_PARTICLES = 0x04
};

Manche Bytecode-VMs verwenden mehr als nur ein Byte zur Kodierung eines einzelnen Befehls und die Regeln zum Entschlüsseln sind komplexer. Tatsächlich ist der Maschinencode echter Prozessoren, wie z.B. des verbreiteten x86, um einiges komplizierter.

Die Java Virtual Machine und Microsofts Laufzeitumgebung, die Grundlage der .NET-Plattform ist, kommen hingegen mit einem Byte aus. Für unsere Zwecke ist ein Byte ebenfalls ausreichend.

Zur Kodierung eines Zauberspruchs als Daten verwenden wir ein Array von enum-Werten. Da es nur einige wenige elementare Typen gibt, ist ein Byte zum Speichern der enum-Werte völlig ausreichend. Der Code eines Zauberspruchs besteht also aus einer Liste von Bytewerten – daher ja auch die Bezeichnung »Bytecode«.

Zur Ausführung eines einzelnen Befehls überprüfen wir, um welchen Typ es sich handelt und rufen die zugehörige API-Methode auf:

switch (instruction)
{
  case INST_SET_HEALTH:
    setHealth(0, 100);
    break;

  case INST_SET_WISDOM:
    setWisdom(0, 100);
    break;

  case INST_SET_AGILITY:
    setAgility(0, 100);
    break;

  case INST_PLAY_SOUND:
    playSound(SOUND_BANG);
    break;

  case INST_SPAWN_PARTICLES:
    spawnParticles(PARTICLE_FLAME);
    break;
}

Auf diese Weise verbindet der Interpreter Daten und Code. Wir können das Ganze wie folgt in eine kleine VM »verpacken«, die dann einen kompletten Zauberspruch ausführt:

class VM
{
public:
  void interpret(char bytecode[], int size)
  {
    for (int i = 0; i < size; i++)
    {
      char instruction = bytecode[i];
      switch (instruction)
      {
        // Case-Anweisungen für die Befehle ...
      }
    }
  }
};

Geben Sie den Code ein – damit haben Sie Ihre erste virtuelle Maschine programmiert. Leider ist sie noch nicht besonders flexibel: Sie können weder einen Zauberspruch definieren, der auf den Gegenspieler wirkt, noch dessen Kennzahlen vermindern. Und es kann nur ein einziger Sound ausgegeben werden!

Damit sich das Ganze halbwegs wie eine Programmiersprache benutzen lässt, brauchen Sie hier ein paar Parameter.

11.5.3  Eine Stackmaschine

Bei der Verarbeitung eines komplexen Ausdrucks fängt man mit dem am tiefsten verschachtelten Unterausdruck an. Er wird berechnet und das Ergebnis wandert als Argument der ihn umgebenden Ausdrücke die Verschachtelungsebenen hinauf, bis schließlich der gesamte Ausdruck ausgewertet ist.

Beim Interpreter-Pattern wird dies in Form eines Baums verschachtelter Objekte abgebildet – wir sind jedoch daran interessiert, die Schnelligkeit einer unverschachtelten Liste von Befehlen zu erzielen, müssen aber sicherstellen, dass die verschachtelten Ausdrücke in der richtigen Reihenfolge ausgewertet werden. Zu diesem Zweck verwenden wir dasselbe Verfahren wie die CPU: Wir benutzen einen Stack.

class VM
{
public:
  VM() : stackSize_(0) {}

  // Weiterer Code ...

private:
  static const int MAX_STACK = 128;
  int stackSize_;
  int stack_[MAX_STACK];
};

Diese Architektur wird einfallsloserweise als Stackmaschine bezeichnet. Programmiersprachen wie Forth, PostScript und Factor bieten dem Anwender unmittelbar darauf beruhende Interfaces an.

Die VM verfügt somit über einen internen Stack, auf dem Werte abgelegt werden können. In unserem Beispiel kommen als Werte ausschließlich Zahlen vor, wir können daher für den Stack ein einfaches int-Array verwenden. Wenn Daten zwischen Befehlen ausgetauscht werden müssen, wird dafür der Stack verwendet.

Daten können vom Stack entfernt (Pop) oder darauf abgelegt werden (Push). Zu diesem Zweck benötigen wir entsprechende Methoden:

class VM
{
private:
  void push(int value)
  {
    // Überlauf prüfen
    assert(stackSize_ < MAX_STACK);
    stack_[stackSize_++] = value;
  }

  int pop()
  {
    // Der Stack darf nicht leer sein
    assert(stackSize_ > 0);
    return stack_[--stackSize_];
  }

  // Weiterer Code ...
};

Falls ein Befehl Parameter benötigt, holt er sie sich folgendermaßen vom Stack:

switch (instruction)
{
  case INST_SET_HEALTH:
  {
    int amount = pop();
    int wizard = pop();
    setHealth(wizard, amount);
    break;
  }

  // Dito für SET_WISDOM und SET_AGILITY ...

  case INST_PLAY_SOUND:
    playSound(pop());
    break;

  case INST_SPAWN_PARTICLES:
    spawnParticles(pop());
    break;
}

Zum Ablegen von Werten auf dem Stack benötigen wir einen zusätzlichen Befehl, nämlich ein Literal, das eine Ganzzahl repräsentiert. Wie aber gelangt das Literal an seinen Wert? Wie können wir hier endlose Rückschritte nach Art des Schildkröten-bis-ganz-unten-Modells vermeiden?

Der Trick besteht darin, sich zunutze zu machen, dass es sich bei den Befehlen um eine Sequenz von Bytes handelt: Wir können die Zahl einfach einem Element des Arrays zuweisen. Dazu definieren wir einen weiteren Befehlstyp für ein Zahlliteral:

switch (instruction)
{
  // Weitere case-Anweisungen ...

  case INST_LITERAL:
  {
    // Nächstes Byte einlesen
    int value = bytecode[++i];
    push(value);
    break;
  }
}

Ich lese hier nur ein einzelnes Byte ein, um auf den kniffligen Code verzichten zu können, der zur Dekodierung von Ganzzahlen erforderlich ist, die mehrere Bytes belegen. Bei einer echten Implementierung werden Sie vermutlich den gesamten Zahlenbereich unterstützen wollen.

Der Code liest das nächste Byte des Bytecodes als Zahl ein und legt den Wert auf dem Stack ab.

Abb. 11.3: Ein Literal als Bytecode

Nun werden einige der Befehle aneinandergereiht – und wir schauen zu, wie der Interpreter sie ausführt, um ein Gefühl für die Funktionsweise des Stacks zu bekommen.

Anfangs ist der Stack leer und der Interpreter verweist auf den ersten Befehl:

Abb. 11.4: Vor der Ausführung des ersten Befehls

Zunächst wird die erste INST_LITERAL-Anweisung ausgeführt. Das nächste Byte (0) des Bytecodes wird eingelesen und auf dem Stack abgelegt:

Abb. 11.5: Nach der Ausführung

Dann wird die zweite INST_LITERAL-Anweisung ausgeführt. Die 10 wird eingelesen und auf dem Stack abgelegt:

Abb. 11.6: Kurz vor der Ausführung des letzten Befehls

Schließlich wird INST_SET_HEALTH ausgeführt. Dabei wird erst die 10 vom Stack entfernt und in der Variablen amount gespeichert, dann wird die 0 entfernt und der Variablen wizard zugewiesen. Danach wird setHealth() mit diesen Parametern aufgerufen.

Voilà! Ein Zauberspruch, der den Gesundheitswert des Spieler-Hexenmeisters auf 10 Punkte setzt. Nun verfügen wir über genügend Flexibilität, um Zaubersprüche zu definieren, die den Kennzahlen beider Hexenmeister beliebige Werte zuweisen. Außerdem können wir unterschiedliche Sounds abspielen und verschiedene Partikel darstellen.

Dennoch ... das Ganze wirkt immer noch wie ein Datenformat. So ist es beispielsweise nicht möglich, den Gesundheitszustand eines Hexenmeisters um die Hälfte seiner Weisheit zu erhöhen. Unsere Designer wollen aber nicht nur Werte angeben, sondern verlangen danach, Regeln formulieren zu können.

11.5.4  Verhalten = Komposition

Betrachtet man die kleine VM als Programmiersprache, unterstützt sie bislang nur einige eingebaute Funktionen mit konstanten Parametern. Um durch den Bytecode Verhalten abbilden zu können, benötigen wir die Komposition.

Unsere Designer sollen in der Lage sein, Ausdrücke zu verwenden, die verschiedene Werte auf interessante Art und Weise miteinander kombinieren. Beispielsweise wünschen sie sich Zaubersprüche, die Kennzahlen nicht auf einen Wert setzen, sondern ihn um einen bestimmten Betrag ändern. Dazu ist es natürlich erforderlich, den aktuellen Wert einer Kennzahl zu berücksichtigen. Befehle zur Zuweisung von Kennzahlen haben wir bereits, wir müssen jedoch auch ein paar zum Lesen der Werte ergänzen:

case INST_GET_HEALTH:
{
  int wizard = pop();
  push(getHealth(wizard));
  break;
}

case INST_GET_WISDOM:
     ...
case INST_GET_AGILITY:
     ...
// Und so weiter ...

Wie Sie sehen, wird der Stack hier auf beide Arten genutzt: Parameter werden vom Stack entfernt, um festzustellen, von welchem Hexenmeister eine Kennzahl abgefragt werden soll – und dann wird der Wert abgerufen und auf dem Stack abgelegt.

Damit können wir Zaubersprüche schreiben, die Kennzahlen hin- und herkopieren. Wir könnten beispielsweise einen Zauberspruch erstellen, der die Wendigkeit eines Hexenmeisters auf den Wert seiner Weisheit setzt oder eine sonderbare Beschwörungsformel, die dafür sorgt, dass der Gesundheitszustand eines Hexenmeisters denselben Wert annimmt wie die entsprechende Kennzahl seines Widersachers.

Schon besser, aber immer noch mit vielen Einschränkungen verbunden. Als Nächstes brauchen wir Arithmetik, denn es wird Zeit, dass unsere Mini-VM lernt, eins und eins zusammenzuzählen. Wir fügen also weitere Befehle hinzu. Vermutlich haben Sie den Bogen inzwischen sowieso schon heraus und können sich denken, wie diese aussehen. Ich demonstriere an dieser Stelle nur die Addition:

case INST_ADD:
{
  int b = pop();
  int a = pop();
  push(a + b);
  break;
}

Wie bei den anderen Befehlen werden Werte vom Stack entfernt und verarbeitet, dann wird das Ergebnis auf dem Stack abgelegt. Bisher hat jeder neue Befehl die Ausdrucksfähigkeit stufenweise erhöht, allerdings haben wir gerade einen großen Schritt nach vorne gemacht: Es ist zwar nicht offensichtlich, aber wir können nun die verschiedensten komplizierten und tief verschachtelten arithmetischen Ausdrücke verarbeiten.

Sehen wir uns ein etwas komplexeres Beispiel an. Ein Zauberspruch soll den Gesundheitszustand eines Hexenmeisters um den Durchschnittswert aus Wendigkeit und Weisheit erhöhen. Hier der zugehörige Code:

setHealth(0, getHealth(0) +
    (getAgility(0) + getWisdom(0)) / 2);

Man könnte meinen, die ausdrückliche Gruppierung durch die Klammerung des Ausdrucks bedürfte einer besonderen Behandlung, doch das ist nicht nötig, denn der Stack unterstützt dies bereits implizit. Sie würden bei der Auswertung von Hand wie folgt vorgehen:

Aktuellen Gesundheitszustand des Hexenmeisters ermitteln und merken.

Wendigkeit des Hexenmeisters ermitteln und merken.

Weisheit des Hexenmeisters ermitteln und merken.

Die letzten beiden Werte addieren und merken.

Das Ergebnis durch 2 teilen und merken.

Sich an den Gesundheitszustand erinnern und zum Ergebnis addieren.

Das Ergebnis dem Gesundheitszustand des Hexenmeisters zuweisen.

Sind Ihnen die Begriffe »merken« und »sich erinnern« aufgefallen? Jedes »merken« entspricht dem Ablegen auf dem Stack (Push) und das »sich erinnern« entspricht dem Entfernen eines Wertes (Pop) vom Stack. Der Vorgang lässt sich also ganz einfach in Bytecode übersetzen. Beispielsweise wird aus dem ersten Punkt:

LITERAL 0
GET_HEALTH

Dieser Bytecode legt die Kennzahl für den Gesundheitszustand des Hexenmeisters auf dem Stack ab. Wenn wir alle sieben Punkte auf diese Weise übersetzen, erhalten wir den Bytecode, der den ursprünglichen Ausdruck auswertet. Damit Sie ein Gefühl dafür bekommen, wie der Code aufgebaut ist, werde ich das gleich einmal durchexerzieren.

Um die verschiedenen Zustände des Stacks zu zeigen, wird der Ablauf hier schrittweise nachverfolgt. Die Kennzahlen des Hexenmeisters für Gesundheitszustand, Wendigkeit und Weisheit lauten 45, 7 bzw. 11. Neben dem Befehl sind jeweils der Zustand des Stacks nach der Ausführung sowie eine kurze Beschreibung des Befehlszwecks aufgelistet:

LITERAL 0   [0]            # Hexenmeisterindex
LITERAL 0   [0, 0]         # Hexenmeisterindex
GET_HEALTH  [0, 45]        # Gesundheitszustand
LITERAL 0   [0, 45, 0]     # Hexenmeisterindex
GET_AGILITY [0, 45, 7]     # Wendigkeit
LITERAL 0   [0, 45, 7, 0]  # Hexenmeisterindex
GET_WISDOM  [0, 45, 7, 11] # Weisheit
ADD         [0, 45, 18]    # Weisheit + Wendigkeit
LITERAL 2   [0, 45, 18, 2] # Divisor
DIVIDE      [0, 45, 9]     # Durchschnitt bilden
ADD         [0, 54]        # Zum Gesundheitszustand addieren
SET_HEALTH  []             # Gesundheitszustand zuweisen

Wenn Sie den Zustand des Stacks nach jedem der Schritte betrachten, können Sie verfolgen, wie die Daten auf fast magische Weise durch ihn hindurchfließen.

Mein Anspruch an die Bezeichnung »magisch« ist hier zugegebenermaßen sind sonderlich hoch.

Eingangs wird für den Hexenmeisterindex eine Null auf dem Stack abgelegt, die während des gesamten Vorgangs am unteren Ende des Stacks verbleibt, bis sie schließlich durch den letzten Befehl SET_HEALTH entfernt wird.

11.5.5  Eine virtuelle Maschine

Ich könnte jetzt fortfahren, einen Befehl nach dem anderen hinzuzufügen, es bietet sich jedoch an, an dieser Stelle einzuhalten. Wir haben hier eine hübsche kleine VM, die es gestattet, mehr oder weniger beliebiges Verhalten in einem einfachen, kompakten Datenformat zu definieren. »Bytecode« und »virtuelle Maschine« klingen zunächst einmal einschüchternd, doch wie Sie gesehen haben, sind dafür manchmal nur ein Stack, eine Schleife und eine switch-Anweisung erforderlich.

Erinnern Sie sich noch daran, dass es unser ursprüngliches Ziel war, das Verhalten vernünftig abzuschotten? Nun wissen Sie, wie die VM implementiert wird und es ist offensichtlich, dass wir dieses Ziel erreicht haben. Der Bytecode kann nichts Bösartiges anstellen oder auf andere Teile der Spiel-Engine zugreifen, weil wir nur eine wenige Befehle definiert haben, die mit dem übrigen Spiel interagieren.

Wir können den Speicherbedarf der VM durch die Größe des verwendeten Stacks bestimmen und stellen sicher, dass es zu keinem Überlauf kommen kann. Wir können sogar festlegen, wie viel Rechenzeit der VM zugestanden wird. In der Schleife zur Befehlsverarbeitung können wir messen, wie viel Zeit darin verbracht wird und sie abbrechen, wenn ein Grenzwert überschritten wird.

Im Beispiel ist es nicht nötig, die Ausführungsdauer zu überprüfen, weil es keine Schleifenbefehle gibt. Wir könnten die Ausführungszeit durch Beschränkung der Gesamtgröße des Bytecodes begrenzen. Das bedeutet allerdings zugleich, dass unser Bytecode nicht Turing-vollständig ist.

Es gibt jetzt nur noch eine Schwierigkeit, nämlich den eigentlichen Bytecode zu erzeugen. Bislang haben wir kleine Pseudocode-Abschnitte manuell in Bytecode kompiliert – sofern Sie nicht gerade eine Menge Freizeit haben, ist dieses Verfahren in der Praxis jedoch ungeeignet.

11.5.6  Hexerwerkzeuge

Eins unserer ursprünglichen Ziele war es, das Verhalten in einer Hochsprache zu formulieren, mit dem Bytecode sind wir aber zu einer Lösung gekommen, die noch maschinennäher ist als C++. Die gewünschte Performance zur Laufzeit und die Sicherheit sind zwar vorhanden, von der Benutzerfreundlichkeit für die Spieldesigner findet sich jedoch keine Spur.

Zum Schließen dieser Lücke benötigen wir ein Tool – ein Programm, dass es den Benutzern ermöglicht, die Auswirkungen zu definieren, die ein Zauberspruch im Spiel hat und daraus den entsprechenden Bytecode für die VM generiert.

Das scheint erheblich schwieriger zu sein, als die VM selbst zu erstellen. Viele Programmierer wurden auf der Hochschule durch einen Kurs zum Thema »Compilerbau« geschleift und das Einzige, was ihnen das eingebracht hat, ist eine posttraumatische Belastungsstörung, die durch die Begriffe »lex«, »yacc« oder den Anblick eines Buches mit einem Drachen auf dem Titel ausgelöst wird.

Ich beziehe mich hier natürlich auf den Klassiker Compilers: Principles, Techniques, and Tools.

In Wahrheit ist das Kompilieren einer textbasierten Sprache gar nicht so schlimm, aber doch etwas zu umfangreich, um es in dieses Kapitel aufzunehmen. Doch darum geht es auch gar nicht. Ich schrieb, wir benötigen ein Tool – und das muss nicht notwendigerweise ein Compiler sein, der eine Textdatei als Eingabe erwartet.

Ganz im Gegenteil möchte ich Sie dazu ermuntern, eine grafische Benutzeroberfläche zu erstellen, die es den Anwendern erlaubt, das Verhalten festzulegen, insbesondere dann, wenn sie kaum über einen technischen Hintergrund verfügen. Für Leute, die nicht Jahre damit zugebracht haben, sich vom Compiler ausschimpfen zu lassen, ist es wirklich nicht ganz einfach, Texte ohne Syntaxfehler zu schreiben.

Erstellen Sie stattdessen ein Programm, das es den Benutzern ermöglicht, »Skripte« zu erzeugen, indem sie kleine Kästchen anklicken und auf dem Bildschirm verschieben, Menüs auswählen oder was auch immer bei der Art von Verhalten, das sie festlegen möchten, Sinn ergibt.

Das Skriptsystem, das ich für das Spiel Henry Hatsworth in the Puzzling Adventure entwickelt habe, funktioniert auf diese Art und Weise.

Abb. 11.7: Grafische Benutzeroberfläche zum Festlegen des Verhaltens

Besonders praktisch ist, dass die grafische Benutzeroberfläche es den Benutzern unmöglich machen kann, »ungültiges« Verhalten zu erstellen. Anstatt sie also mit Fehlermeldungen zu überschütten, können Sie proaktiv Schaltflächen deaktivieren oder Standardwerte vorgeben, um zu gewährleisten, dass das eingerichtete Verhalten jederzeit zulässig ist.

Ich möchte an dieser Stelle betonen, wie wichtig der Umgang mit Fehlern ist. Wir Programmierer neigen dazu, menschliche Fehler als beschämende persönliche Schwäche zu betrachten, die es zu eliminieren gilt.

Ein System, das die Benutzer gerne nutzen, muss ihrer Menschlichkeit entgegenkommen – und auch ihrer Fehlbarkeit. Fehler zu begehen, ist nicht nur menschlich, sondern ein fundamentaler Bestandteil kreativer Entwicklungsprozesse. Der großzügige Umgang mit Fehlern, beispielsweise durch Features wie Rückgängig/Wiederholen, verhilft den Benutzern zu größerer Kreativität und führt zu besseren Ergebnissen.

Auf diese Weise bleibt es Ihnen erspart, für eine kleine Sprache eine Grammatik zu entwerfen und einen Parser zu programmieren. Selbstverständlich ist mir schon klar, dass einige Leser die Programmierung einer grafischen Benutzeroberfläche als ebenso unangenehm empfinden – nun, in diesem Fall kann ich Ihnen leider auch nicht helfen.

Letzten Endes geht es darum, das Verhalten auf benutzerfreundliche Art und Weise und auf Anwendungsebene formulieren zu können. Dem Benutzer dies zu ermöglichen, ist Ihre Aufgabe. Um das eingerichtete Verhalten auch vernünftig ausführen zu können, müssen die Eingaben in eine maschinennahe Form übersetzt werden. Das ist zwar eine Menge Arbeit, wenn Sie sich dieser Herausforderung stellen, kann es sich jedoch auch wirklich auszahlen.

11.6  Designentscheidungen

Ich habe mich bemüht, dieses Kapitel so einfach wie möglich zu halten, aber im Grunde genommen entwerfen wir hier eine Sprache – und das ist nun ein wirklich weites und ergebnisoffenes Feld. Es zu erkunden, kann ein Riesenspaß sein – vergessen Sie also nicht, die Programmierung Ihres Spiels abzuschließen.

Offenbar sind meine eigenen diesbezüglichen Bemühungen gescheitert, denn dies ist das längste Kapitel des Buches.

11.6.1  Wie greifen Befehle auf den Stack zu?

Es gibt im Wesentlichen zwei Typen von Bytecode-VMs, nämlich Stack-basierte und registerbasierte. Eine Stack-basierte VM greift immer auf das obere Ende des Stacks zu – wie in unserem Beispielcode. Der Befehl INST_ADD entfernt beispielsweise zwei Werte vom oberen Ende des Stacks, addiert sie und legt das Ergebnis wieder auf dem oberen Ende des Stacks ab.

Auch registerbasierte VMs verwenden einen Stack. Der einzige Unterschied besteht darin, dass Befehle ihre Eingaben auch den tiefergelegenen Ebenen des Stacks entnehmen können. Anstatt wie INST_ADD immer die beiden obersten Werte vom Stack zu entfernen, gibt es zwei im Bytecode gespeicherte Indizes, die angeben, an welchen Positionen sich die Operanden im Stack befinden.

Für eine Stack-basierte VM gilt:

Die Befehle sind kurz. Die Argumente befinden sich immer am oberen Ende des Stacks. Um sie aufzufinden, müssen daher keine weiteren Daten im Bytecode kodiert werden. Aus diesem Grund sind die Befehle ziemlich kurz, für gewöhnlich handelt es sich nur um ein einziges Byte.

Die Codeerzeugung ist einfacher. Bei der Programmierung eines Compilers oder eines Hilfsprogramms zur Ausgabe von Bytecode werden Sie feststellen, dass sich das Erzeugen des Stack-basierten Bytecodes einfacher gestaltet. Da jeder Befehl auf das obere Ende des Stacks zugreift, müssen die Befehle lediglich in der richtigen Reihenfolge generiert werden, um Parameter untereinander auszutauschen.

Die Anzahl der Befehle ist größer. Alle Befehle können nur auf das obere Ende des Stacks zugreifen. Zur Erzeugung des Bytecodes für einen Ausdruck wie z.B. a = b + c sind zusätzliche Befehle erforderlich, um b und c an das obere Ende des Stacks zu bewegen, die Berechnung auszuführen und das Ergebnis in a zu speichern.

Für eine registerbasierte VM gilt:

Die Befehle sind länger. Da die Befehle die Indizes der Argumente enthalten müssen, benötigt ein einzelner Befehl mehr Bits. Beispielsweise verwendet Lua, die wohl bekannteste registerbasierte VM, dafür 32 Bits – 6 Bits geben den Befehlstyp an, die übrigen Bits enthalten die Argumente.

Die Lua-Entwickler legen das Bytecode-Format nicht fest – es ändert sich von Version zu Version. Jedenfalls ist das bei der Version 5.1 so. Wenn Sie an einem wirklich verblüffend tiefen Einblick in Luas Interna interessiert sind, sollten Sie sich A No-Frills Introduction to Lua 5.1 VM Instructions näher ansehen.

Die Anzahl der Befehle ist geringer. Es werden weniger Befehle benötigt, da einzelne Befehle mehr Aufgaben erledigen können. Manche Leute behaupten, dass sich dadurch die Geschwindigkeit erhöht, weil weniger Werte im Stack hin- und herkopiert werden müssen.

Welcher Typ ist besser geeignet? Ich empfehle, bei einer Stack-basierten VM zu bleiben. Die Implementierung ist einfacher und die Bytecode-Erzeugung ist sogar sehr viel einfacher. Seitdem Lua diesen Typ übernommen hat, stehen registerbasierte VMs in dem Ruf, ein klein wenig schneller zu sein, das hängt jedoch in sehr hohem Maß von den tatsächlich verwendeten Befehlen und vielen anderen Details der VM ab.

11.6.2  Welche Befehle gibt es?

Der Befehlssatz gibt vor, was sich durch den Bytecode formulieren lässt und was nicht. Außerdem hat er großen Einfluss auf die Geschwindigkeit der VM. Es folgt eine Liste der verschiedenen Befehlstypen, die zum Einsatz kommen können:

Externe elementare Operationen: Hiermit kann auf die außerhalb der VM befindlichen übrigen Teile des Spiels zugegriffen werden. Dadurch ist es möglich, für den Benutzer sichtbare Vorgänge auszuführen. Diese Operationen legen fest, welches Verhalten durch den Bytecode gesteuert werden kann. Ohne sie würde Ihre VM nichts weiter tun, als CPU-Rechenzeit zu verschwenden.

Interne elementare Operationen: Diese Operationen betreffen die Werte innerhalb der VM. Dazu gehören u.a. Literale, Arithmetik, Vergleichsoperatoren und Befehle zum Modifizieren des Stacks.

Ablaufsteuerung: In unserem Beispiel kommen derartige Operationen nicht vor. Sie werden benötigt, um bedingte Befehle oder Schleifen auszuführen. In dem maschinennahen Bytecode werden sie überraschend einfach abgebildet, nämlich durch einfache Sprungbefehle.

In der Schleife zur Befehlsverarbeitung hatten wir zur Nachverfolgung der Position im Bytecode einen Index verwendet. Ein Sprungbefehl braucht lediglich diese Variable zu ändern, um die Programmausführung an anderer Stelle fortzusetzen. Mit anderen Worten: Es handelt sich hier um einen GOTO-Befehl. Damit können Sie den Programmablauf auf unterschiedlichste Weise steuern.

Abstrahierung: Falls Ihre Benutzer dazu übergehen, immer mehr Verhalten in Form von Daten zu definieren, werden sie sich wünschen, Code wiederverwenden zu können, statt ihn stets kopieren zu müssen. Zu diesem Zweck sind aufrufbare Prozeduren wünschenswert.

In ihrer einfachsten Form sind Prozeduren kaum komplizierter als ein Sprungbefehl. Der Unterschied besteht darin, dass die VM einen zweiten Stack benötigt, um die Rücksprungziele zu speichern. Beim Aufruf einer Prozedur wird der Index des aktuellen Befehls auf diesem return-Stack abgelegt, bevor der aufgerufene Bytecode ausgeführt wird. Wenn die VM dann auf eine return-Anweisung stößt, entnimmt sie dem return-Stack diesen Index wieder und setzt die Programmausführung dort fort.

11.6.3  Wie werden Werte repräsentiert?

Unsere Beispiel-VM kennt nur einen Datentyp, nämlich Ganzzahlen. Die obige Frage lässt sich daher leicht beantworten: Der Stack ist ein einfaches Array aus Ganzzahlen. Eine leistungsfähigere VM müsste natürlich auch Datentypen wie Strings, Objekte, Listen usw. unterstützen. Wie diese Datentypen intern gespeichert werden, müssen Sie selbst entscheiden.

Ein einzelner Datentyp:

Das einfachste Format. Sie brauchen sich nicht um Kennzeichnungen, Konvertierungen oder Überprüfungen des Datentyps zu kümmern.

Sie können keine unterschiedlichen Datentypen verwenden. Dieser Nachteil liegt auf der Hand. Wenn man verschiedene Datentypen in eine einzige Repräsentierungsform zwängt, z.B. Zahlen als Strings speichert, sind Probleme vorprogrammiert.

Gekennzeichnete Variant-Daten:

Diese Repräsentierungsform ist in dynamisch typisierten Sprachen gebräuchlich. Jeder Wert besitzt zwei Bestandteile. Der erste ist eine Kennzeichnung – eine Enumeration, die den gespeicherten Datentyp angibt:

enum ValueType
{
  TYPE_INT,
  TYPE_DOUBLE,
  TYPE_STRING
};

Die verbleibenden Bits werden dann entsprechend des angegebenen Typs interpretiert, etwa so:

struct Value
{
  ValueType type;
  union
  {
    int    intValue;
    double doubleValue;
    char*  stringValue;
  };
};

Den Werten ist ihr Datentyp bekannt. Der Datentyp des Wertes kann zur Laufzeit überprüft werden. Bei der dynamischen Weiterleitung spielt dies eine wichtige Rolle. Außerdem lässt sich so die Anwendung nicht unterstützter Operationen auf die Werte verhindern.

Der Speichbedarf ist größer. Alle Werte benötigen ein paar zusätzliche Bits, um ihren jeweiligen Datentyp zu speichern. Bei einem maschinennahen Objekt wie einer VM kommt da schnell einiges zusammen.

Ungekennzeichnete Union-Daten:

Hier kommt wie bei der vorhergehenden Form ein Union zum Einsatz, das jedoch keine Kennzeichnung besitzt. Die gespeicherten Bits können verschiedene Datentypen repräsentieren und Sie müssen dafür Sorge tragen, dass sie nicht falsch interpretiert werden.

Auf diese Weise repräsentieren statisch typisierte Sprachen Werte im Arbeitsspeicher. Das Typisierungssystem stellt beim Kompilieren sicher, dass die Werte nicht fehlinterpretiert werden, daher ist es nicht erforderlich, dies zur Laufzeit zu prüfen.

Auch untypisierte Sprachen wie Assembler oder Forth speichern Werte auf diese Art und Weise. Derartige Sprachen überlassen es dem Benutzer, dafür Sorge zu tragen, dass der Code den Typ eines Wertes nicht fehlinterpretiert. Nichts für schwache Nerven!

#Das Format ist kompakt. Es werden nur die Bits des eigentlichen Wertes gespeichert – noch sparsamer kann ein Format nicht sein.

Der Zugriff erfolgt schnell. Der Verzicht auf die Überprüfung des Typs zur Laufzeit spart Rechenzeit. Dies ist einer der Gründe dafür, dass statisch typisierte Sprachen tendenziell schneller sind als dynamisch typisierte.

Das Format ist gefährlich. Dies ist natürlich der wesentliche Nachteil. Ein nicht wohlgeformter Bytecode-Abschnitt, der fehlinterpretiert wird und Zahlenwerte als Zeiger (oder umgekehrt) behandelt, kann die Stabilität des Spiels infrage stellen oder es zum Absturz bringen.

Man könnte denken, auf der sicheren Seite zu sein, wenn der Bytecode vom Compiler einer statisch typisierten Sprache erzeugt wird, weil der Compiler schon keinen gefährlichen Bytecode generieren wird. Das mag stimmen, aber ein böswilliger Benutzer könnte den Compiler auch umgehen und bösartigen Bytecode von Hand erstellen.

Aus diesem Grund führt beispielsweise die Java Virtual Machine beim Laden eines Programms eine Bytecode-Überprüfung durch.

Ein Interface:

Die objektorientierte Lösung zum Speichern eines Wertes, der verschiedene Datentypen enthalten kann, setzt auf den Polymorphismus. Dabei stellt ein Interface virtuelle Methoden zur Typüberprüfung und zur Konvertierung bereit. Das könnte z.B. so aussehen:

class Value
{
public:
  virtual ~Value() {}

  virtual ValueType type() = 0;

  virtual int asInt() {
    // Darf nur mit ints aufgerufen werden
    assert(false);
    return 0;
  }

  // Weitere Konvertierungsmethoden ...
};

Darüber hinaus gibt es konkrete Klassen für jeden angegebenen Datentyp, so wie hier:#

class IntValue : public Value
{
public:
  IntValue(int value)
  : value_(value)
  {}

  virtual ValueType type() { return TYPE_INT; }

  virtual int asInt() { return value_; }

private:
  int value_;
};

Das Interface ist flexibel. Sie können außerhalb der eigentlichen VM neue Datentypen definieren, sofern diese nur das grundlegende Interface implementieren.

Die Vorgehensweise ist objektorientiert. Wenn Sie sich an OOP-Prinzipien halten möchten, ist dies die »richtige« Methode, die den Polymorphismus nutzt, um typspezifisches Verhalten zu implementieren, statt eine Kennzeichnung zu verwenden.

Die Interfaces sind weitschweifig. Sie müssen für jeden Datentyp eine eigene Klasse definieren – mit allem dazugehörigen feierlichen Geschwafel. Beachten Sie dabei, dass im vorangehenden Beispiel sämtliche Datentypen berücksichtigt wurden – hier ist es nur einer!

Die Vorgehensweise ist ineffizient. Zur Implementierung des Polymorphismus ist ein Zeiger erforderlich. Das heißt, dass einfache Typen wie boolesche Werte und Zahlen in Objekte verpackt werden, für die auf dem Heap Speicherplatz reserviert werden muss. Bei jedem Zugriff ist der Aufruf einer virtuellen Methode erforderlich.

Beim Kerncode einer virtuellen Maschine summieren sich kleine Performanceeinbußen schnell. Tatsächlich hat diese Vorgehensweise mit vielen der Probleme zu kämpfen, die uns veranlasst haben, auf das Interpreter-Pattern zu verzichten – allerdings betreffen die Probleme in diesem Fall nicht den Code, sondern die Werte.

Meine Empfehlung lautet: Wenn ein einzelner Datentyp ausreicht, sollten Sie dabei bleiben. Verwenden Sie andernfalls gekennzeichnete Variant-Daten. Auf diese Art funktionieren nahezu alle Interpreter, die es gibt.

11.6.4  Wie wird der Bytecode erzeugt?

Die wichtigste Frage habe ich mir für den Abschluss dieses Abschnitts aufgehoben. Ich habe vorgeführt, wie der Bytecode gelesen und interpretiert wird, wie er jedoch erzeugt wird, bleibt Ihnen überlassen. Typischerweise schreibt man zu diesem Zweck einen Compiler, es gibt aber auch andere Möglichkeiten.

Falls Sie eine textbasierte Sprache entwickeln:

Sie müssen eine Syntax definieren. Sowohl Amateure als auch professionelle Sprachdesigner unterschätzen grundsätzlich, wie schwierig das tatsächlich ist. Eine Grammatik zu definieren, die den Parser zufriedenstellt, ist leicht – eine Grammatik zu definieren, die Benutzer zufriedenstellt, ist hingegen wirklich schwer.

Das Design der Syntax betrifft vor allem das User Interface – und dass dieses auf Zeichenketten beschränkt ist, macht die Sache nicht gerade leichter.

Sie müssen einen Parser implementieren. Anders als die meisten Leute denken, ist dieser Teil vergleichsweise einfach. Verwenden Sie entweder einen Parser-Generator wie ANTLR oder Bison oder machen Sie es wie ich: Schreiben Sie selbst einen kleinen, rekursiv arbeitenden Parser – und schon können Sie loslegen.

Sie müssen Syntaxfehler handhaben. Hierbei handelt es sich um den wichtigsten und zugleich schwierigsten Teil des Vorhabens. Wenn den Benutzern Syntax- oder Semantikfehler unterlaufen (und das wird ständig passieren), ist es Ihre Aufgabe, ihnen den rechten Weg zu weisen. Hilfreiche Rückmeldungen zu liefern, ist nicht leicht, wenn Ihnen lediglich bekannt ist, dass Ihr Parser auf einige unerwartete Zeichen gestoßen ist.

Technisch weniger versierte Benutzer werden vermutlich abgeschreckt. Wir Programmierer mögen Textdateien. Zusammen mit Kommandozeilenprogrammen betrachten wir sie als die Legobausteine der Computerwelt – einfach, aber auf millionenfache Weise leicht miteinander kombinierbar.

Die meisten Nicht-Programmierer sehen einfache Textdateien mit anderen Augen. Ihnen kommt es eher wie das Ausfüllen einer Steuererklärung unter der Aufsicht eines übel gelaunten, roboterhaften Prüfers vor, der sie anschnauzt, wenn sie ein einziges Semikolon vergessen.

Falls Sie ein grafisches Tool entwickeln:

Sie müssen eine Benutzeroberfläche implementieren. Schaltflächen, Menüs, Klicks, Drag-and-Drop und dergleichen. Manche Leute zucken schon zusammen, wenn sie nur daran denken, ich persönlich allerdings liebe es. Wenn Sie diesen Weg einschlagen, sollten Sie das Design der Benutzeroberfläche als wichtigen Bestandteil einer guten Arbeitsleistung betrachten – und nicht als unliebsame Aufgabe, durch die man sich irgendwie durchwurstelt.

Hier zahlt sich jede zusätzliche Anstrengung dadurch aus, dass Ihr Programm einfacher und angenehmer nutzbar wird – und das wiederum führt ohne Umschweife zu besseren Spielinhalten. Denken Sie einmal an die Spiele, die Ihnen besonders gut gefallen: Sie werden feststellen, dass hier nicht selten die Tools zum Erstellen und Bearbeiten der Inhalte eine ausschlaggebende Rolle spielen.

Es treten weniger Fehler auf. Da die Benutzer das Verhalten schrittweise interaktiv einrichten, hat Ihr Programm die Möglichkeit, bereits Hinweise zur Vermeidung von Fehlern zu geben, sobald diese gemacht werden.

Bei textbasierten Sprachen erscheinen dagegen sämtliche Inhalte des Benutzers auf einen Schlag, wenn die Datei eingelesen wird. Das erschwert es, Fehler zu vermeiden und handzuhaben.

Die Portierung ist schwieriger. Bei Text-Compilern ist besonders praktisch, dass Textdateien universell einsetzbar sind.

Von Zeilenenden einmal abgesehen. Und von Textkodierungen.

Ein einfacher Compiler liest einfach nur eine Datei ein und schreibt eine andere auf die Festplatte. Die Portierung auf ein anderes Betriebssystem ist trivial.

Wenn Sie eine grafische Benutzeroberfläche entwickeln, müssen Sie sich für ein Framework entscheiden. Manche davon sind nur für bestimmte Betriebssysteme verfügbar. Es gibt zwar auch plattformübergreifende Toolkits, die aber oftmals Kompromisse eingehen, um auf verschiedenen Betriebssystemen zu funktionieren – sie verhalten sich auf allen Plattformen merkwürdig fremdartig.

11.7  Siehe auch ...

Dieses Pattern ist eng mit dem Interpreter-Pattern der Gang of Four verwandt. Beide ermöglichen Ihnen, kombinierbare Verhaltensweisen als Daten zu formulieren. Tatsächlich werden Sie häufig sogar beide Patterns einsetzen. Das zum Erzeugen des Bytecodes verwendete Tool enthält intern einen Objektbaum, der den Code repräsentiert. Und genau das erwartet das Interpreter-Pattern auch.

Um aus dem Code Bytecode zu machen, wird dieser Baum wie beim Interpreter-Pattern rekursiv durchlaufen. Der einzige Unterschied besteht darin, dass die Verhaltensänderung nicht sofort ausgeführt wird, sondern stattdessen Bytecode-Befehle zur späteren Ausführung erzeugt werden.

Lua ist die im Spieleumfeld am weitesten verbreitete Skriptsprache. Sie ist intern in Form einer äußerst kompakten, registerbasierten Bytecode-VM implementiert.

Der Editor der Unreal-Engine, UnrealEd, bringt ein Tool zum Erstellen grafischer Skripte namens Kismet mit.

Meine eigene kleine Skriptsprache namens Wren ist ein einfacher, Stack-basierter Bytecode-Interpreter.

Kapitel 12: Subclass Sandbox (Unterklassen-Sandbox)

Festlegung des Verhaltens einer Unterklasse durch einen Satz von Operationen, die von der Basisklasse bereitgestellt werden.

12.1  Motivation

Jedes Kind hat schon mal davon geträumt, ein Superheld zu sein – allerdings sind kosmische Strahlen hier auf der Erde bedauerlicherweise ziemlich selten. Am nächsten kommt man dieser Vorstellung in Spielen, die uns glauben lassen, selbst Superhelden zu sein. Und da unsere Spieldesigner nie gelernt haben, »Nein« zu sagen, sollen in unserem Superheldenspiel Dutzende, wenn nicht gar Hunderte von Superkräften zur Auswahl stehen.

Der Plan ist, eine Basisklasse namens Superpower einzurichten, von der Unterklassen abgeleitet werden, die wiederum die verschiedenen Superkräfte implementieren. Die Designunterlagen werden unter dem Programmiererteam aufgeteilt, das sich umgehend an die Arbeit macht. Wenn die Aufgabe erledigt ist, stehen uns hundert Superpower-Klassen zur Verfügung.

Sollten Sie feststellen, dass Sie – wie in diesem Beispiel – sehr viele Unterklassen verwenden, ist oftmals ein datengetriebener Ansatz besser geeignet. Versuchen Sie eine Möglichkeit zu finden, die Superkräfte nicht als Code, sondern das Verhalten in Form von Daten zu definieren.

Patterns wie Type Object (Typ-Objekt, Kapitel 13), Bytecode (Kapitel 11) und Interpreter sind hier hilfreich.

Die Spieler sollen in eine Welt eintauchen, in der es vor Vielfalt nur so wimmelt. Von welcher Superkraft sie als Kind auch geträumt haben mögen, sie soll in unserem Spiel verfügbar sein. Das bedeutet, dass die Superpower-Unterklassen so ziemlich alles können müssen: Sounds abspielen, visuelle Effekte auslösen, mit der KI interagieren, Objekte oder Spielfiguren erzeugen und löschen sowie mit der Physik-Engine kommunizieren. Es gibt in der ganzen Codebasis keine Stelle, mit der sie nicht in Berührung kommen.

Nehmen wir nun an, wir lassen das Programmiererteam von der Leine und gestatten das Erstellen von Superpower-Klassen. Wie geht es dann wohl weiter?

Es gibt jede Menge redundanten Code. Auch wenn es eine große Vielfalt an Superkräften gibt, ist dennoch mit einer Vielzahl von Überschneidungen zu rechnen. Viele werden auf dieselbe Art und Weise visuelle Effekte auslösen und Sounds abspielen – letzten Endes sind ein Einfrierstrahl, ein Hitzestrahl und ein Dijonsenf-Strahl einander doch ziemlich ähnlich. Wenn sich die Programmierer bei der Implementierung nicht untereinander abstimmen, wird es zuhauf doppelten Code und doppelten Aufwand geben.

Alle Bestandteile des Spiels sind an diese Klassen gekoppelt. Weil sie es nicht besser wissen, werden die Leute Code schreiben, der Subsysteme aufruft, die überhaupt nicht dafür vorgesehen sind, direkt mit den Superpower-Klassen zu interagieren. Der Renderer ist fein säuberlich in verschiedene Schichten aufgeteilt, von denen eigentlich nur eine einzige von Code außerhalb der Grafik-Engine genutzt werden soll – aber jede Wette, dass wir früher oder später Superpower-Klassen vorfinden, die in sämtlichen Schichten herumstochern.

Wenn diese isolierten Systeme modifiziert werden müssen, besteht die Gefahr, dass irgendeine Superkraft nicht mehr funktioniert. Sobald es Superpower-Klassen gibt, die sich an verschiedene andere Teile der Spiel-Engine koppeln, ist es unvermeidlich, dass sich Modifikationen an diesen Systemen auf die Superpower-Klassen auswirken. Besonders erfreulich ist das nicht, denn die für Grafik, Audio und Benutzeroberfläche zuständigen Programmierer haben vermutlich keine Lust, auch noch für den Spielablauf verantwortlich zu sein.

Es ist schwierig, Invarianten festzulegen, die für alle Superkräfte gelten. So möchten wir etwa dafür sorgen, dass die Audiowiedergaben der verschiedenen Superkräfte ordentlich in eine Warteschlange eingereiht werden, damit sie in der richtigen Reihenfolge stattfinden. Es gibt jedoch keine einfache Möglichkeit, dies zu erreichen, wenn alle hundert Klassen eigenständig Funktionen der Sound-Engine aufrufen.

Am besten wäre es, den für den Spielablauf verantwortlichen Programmierern einer Superkraft einen Satz von Grundfunktionen zur Verfügung zu stellen, mit denen sie herumexperimentieren könnten. Die Superkraft soll einen Ton verursachen? Bitte sehr, hier ist eine playSound()-Funktion. Es sind Partikel erwünscht? Bitte schön, hier ist die spawnParticles()-Funktion. Wir sorgen dafür, dass diese Operationen alles Erforderliche leisten, damit es nicht nötig ist, dass die Programmierer via #include irgendwelche Header-Dateien einbinden und ihre Nase in Teile der Codebasis stecken, die sie nichts angehen.

Zu diesem Zweck machen wir diese Operationen zu geschützten Methoden der Superpower-Basisklasse. Sie in der Basisklasse zu platzieren, gewährt allen Superpower-Unterklassen direkten, einfachen Zugriff auf diese Methoden. Und die Tatsache, dass sie geschützt sind (und wahrscheinlich nicht virtuell), weist gleichzeitig darauf hin, dass sie insbesondere dazu gedacht sind, von Unterklassen aufgerufen zu werden.

Wenn diese Methoden verfügbar sind, benötigen wir einen Ort, um mit ihnen herumzuspielen. Dazu definieren wir eine abgeschottete Methode (engl. Sandbox Method), eine abstrakte, geschützte Methode, die von den Unterklassen implementiert werden muss. Damit kann eine neue Superkraft wie folgt implementiert werden:

Erstellen Sie eine neue Klasse, die von Superpower erbt.

Überschreiben Sie activate(), die abgeschottete Methode.

Rufen Sie in dieser Methode die von der Basisklasse Superpower bereitgestellten geschützten Methoden auf.

Auf diese Weise können wir das Problem redundanten Codes lösen, indem wir die Operationen auf der höchstmöglichen Ebene bereitstellen: In vielen Unterklassen mehrfach vorkommender Code wird nun einfach als neue Operation in die Superpower-Klasse verlagert, wo er von allen Unterklassen aufgerufen werden kann.

Das Problem der Kopplung wird dadurch gelöst, dass sie nun auf eine einzige Stelle begrenzt ist. Die Superpower-Klasse selbst bleibt letzten Endes an die verschiedenen Systeme des Spiels gekoppelt, nicht aber unsere hundert abgeleiteten Unterklassen – diese sind ausschließlich an ihre Basisklasse gekoppelt. Falls nun Änderungen an einem der Systeme des Spiels erforderlich sind, muss die Superpower-Klasse womöglich modifiziert werden, die Dutzenden von Unterklassen können jedoch unangetastet bleiben.

Dieses Pattern führt zu einer flachen, aber sehr breiten Klassenhierarchie. Die Vererbungshierarchie ist zwar nicht tief, es werden jedoch sehr viele Klassen von Superpower abgeleitet. Diese eine Klasse mit sehr vielen direkten Unterklassen bildet nun einen Ausgangspunkt in unserer Codebasis. Die in die Superpower-Klasse investierte Zeit und Mühe kann fortan einer Vielzahl von Klassen des Spiels zugute kommen.

In jüngster Zeit wird die Vererbung in objektorientierten Programmiersprachen auf breiter Front kritisiert. Tatsächlich ist Vererbung problematisch – es gibt wohl kaum eine stärkere Form der Kopplung in einer Codebasis als diejenige zwischen Basisklasse und Unterklasse. Allerdings ist eine breite Klassenhierarchie meiner Ansicht nach weniger bedenklich als eine tiefe.

12.2  Das Pattern

Eine Basisklasse definiert eine abstrakte abgeschottete Methode und stellt verschiedene Operationen bereit. Der Umstand, dass es sich um geschützte Methoden handelt, verdeutlicht, dass sie von abgeleiteten Klassen verwendet werden sollen. Jede abgeleitete abgeschottete Unterklasse implementiert die abgeschottete Methode unter Verwendung der bereitgestellten Operationen.

12.3  Anwendbarkeit

Das Pattern Subclass Sandbox ist ein einfaches, gebräuchliches Pattern, das in vielen Codebasen zu finden ist, nicht nur bei Spielen. Falls Sie nicht-virtuelle geschützte Methoden einsetzen, verwenden Sie womöglich bereits eine Variante dieses Patterns. Subclass Sandbox ist in folgenden Fällen gut geeignet:

Sie verwenden eine Basisklasse mit vielen Unterklassen.

Die Basisklasse kann alle von einer Unterklasse benötigten Operationen bereitstellen.

Das Verhalten der Unterklassen weist Überschneidungen auf und Sie möchten die gemeinsame Nutzung des Codes vereinfachen.

Sie möchten die Kopplung zwischen den Unterklassen und dem übrigen Programm minimieren.

12.4  Konsequenzen

Programmierer halten den Begriff »Vererbung« heutzutage oftmals für ein Unwort. Dass die Basisklassen tendenziell immer mehr Code anhäufen, ist einer der Gründe dafür. Dieses Pattern ist hierfür besonders anfällig.

Da Unterklassen mittels ihrer Basisklasse mit dem übrigen Spiel in Verbindung treten, ist die Basisklasse letzten Endes an alle Systeme gekoppelt, mit denen irgendeine der Unterklassen kommunizieren muss. Und natürlich sind auch die Unterklassen eng an ihre Basisklasse gekoppelt. Dieses verwobene Netz aus Kopplungen erschwert es sehr, die Basisklasse zu modifizieren, ohne dadurch ein Fehlverhalten zu verursachen – hierbei handelt es sich um das sogenannte Fragile Base Class Problem.

Die andere Seite der Medaille ist jedoch, dass die Unterklassen dank der Verlagerung der Kopplung in die Basisklasse nun viel klarer vom übrigen Programm abgesondert sind. Im Idealfall steckt der Großteil des Verhaltens in den Unterklassen – und dieser Teil der Codebasis ist isoliert und lässt sich leichter warten.

Dennoch: Wenn Sie den Eindruck haben, dass bei diesem Pattern alles in einen Topf geworfen wird, können Sie in Betracht ziehen, einige der bereitgestellten Operationen aus der Basisklasse zu entfernen und in separate Klassen zu verlagern, an die die Basisklasse dann delegiert. Hierfür ist das Pattern Component (Komponente, Kapitel 14) nützlich.

12.5  Beispielcode

Da es sich hier um ein so einfaches Pattern handelt, gibt es zum Beispielcode nicht viel zu sagen. Das soll aber nicht heißen, dass er nicht nützlich wäre – es geht bei dem Pattern um die dahinterstehende Absicht, nicht um die Komplexität der Implementierung.

Fangen wir mit der Superpower-Basisklasse an:

class Superpower
{
public:
  virtual ~Superpower() {}

protected:
  virtual void activate() = 0;

  void move(double x, double y, double z)
  {
    // Hier der Code ...
  }

  void playSound(SoundId sound)
  {
    // Hier der Code ...
  }

  void spawnParticles(ParticleType type, int count)
  {
    // Hier der Code ...
  }
};

Bei activate() handelt es sich um die abgeschottete Methode. Da sie virtuell und abstrakt ist, müssen Unterklassen sie überschreiben. Falls jemand eine Superpower-Unterklasse anlegt, ist somit klar, wo der Code hingehört.

Die anderen geschützten Methoden, nämlich move(), playSound() und spawnParticles(), sind die bereitgestellten Operationen. Sie werden von den Unterklassen in deren activate()-Implementierungen aufgerufen.

In diesem Beispiel sind die bereitgestellten Methoden nicht implementiert, aber bei einem richtigen Spiel gäbe es hier echten Code. Durch diese Methoden wird die Superpower-Klasse an die anderen Systeme des Spiels gekoppelt: move() interagiert mit der Physik-Engine, playSound() kommuniziert mit der Audio-Engine usw. Da all dies Bestandteil der Implementierung der Basisklasse ist, bleibt diese Kopplung innerhalb der Superpower-Klasse selbst gekapselt.

Gut, holen wir also die radioaktiven Spinnen hervor und erstellen eine Superkraft. Hier ist schon mal eine:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate()
  {
    move(0, 0, 20); // In die Luft springen
    playSound(SOUND_SPROING);
    spawnParticles(PARTICLE_DUST, 10);
  }
};

Man kann geteilter Meinung sein, ob das »In-die-Luft-Springen« eine Superkraft ist, ich wollte die Sache hier jedoch schlicht nicht unnötig verkomplizieren.

Der Superheld springt in die Luft, ein passender Sound wird abgespielt und es wird ein wenig Staub aufgewirbelt. Wenn alle Superkräfte so einfach wären – lediglich eine Kombination aus einem Sound, einem Partikeleffekt und einer Bewegung –, bräuchten wir dieses Pattern überhaupt nicht. Stattdessen könnte die Superpower-Klasse über eine eigene activate()-Implementierung verfügen, die auf Datenfelder für die Sound-ID, den Partikeltyp und die Bewegung zugreift. Das wäre jedoch nur dann möglich, wenn alle Superkräfte im Wesentlichen auf dieselbe Art und Weise funktionieren und sich nur durch verschiedene Daten unterscheiden würden. Sehen wir uns das einmal genauer an:

class Superpower
{
protected:
  double getHeroX() { /* Hier der Code ... */ }
  double getHeroY() { /* Hier der Code ... */ }
  double getHeroZ() { /* Hier der Code ... */ }

  // Bereits vorhandener Code ...
};

Hier haben wir einige Methoden hinzugefügt, um die Position des Superhelden zu ermitteln. Die SkyLaunch-Unterklasse kann diese nun verwenden:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate()
  {
    if (getHeroZ() == 0)
    {
      // Am Boden, also in die Luft springen
      playSound(SOUND_SPROING);
      spawnParticles(PARTICLE_DUST, 10);
      move(0, 0, 20);
    }
    else if (getHeroZ() < 10.0f)
    {
      // In Bodennähe, also doppelter Sprung
      playSound(SOUND_SWOOP);
      move(0, 0, getHeroZ() - 20);
    }
    else
    {
      // Hoch in der Luft, Hechtsprung ausführen
      playSound(SOUND_DIVE);
      spawnParticles(PARTICLE_SPARKLES, 1);
      move(0, 0, -getHeroZ());
    }
  }
};

Da wir auf mehrere Zustände zugreifen können, kann die abgeschottete Methode nun auch interessante Aktionen ausführen. In diesem Fall handelt es sich nur um ein paar einfache if-Anweisungen, Sie können jedoch im Prinzip alles Mögliche erledigen. Die abgeschottete Methode ist jetzt eine vollwertige Methode, die beliebigen Code enthalten kann – Ihnen sind keine Grenzen mehr gesetzt.

Ich habe eingangs vorgeschlagen, einen datengetriebenen Ansatz für die Superkräfte zu verwenden. Hier finden Sie einen Grund dafür, genau das nicht zu tun. Komplexes und vorherbestimmtes Verhalten ist in Form von Daten schwieriger zu definieren.

12.6  Designentscheidungen

Wie Sie sehen, ist das Pattern Subclass Sandbox ziemlich schwammig. Es beschreibt die grundlegende Idee, sieht aber kaum detaillierte Mechanismen für die Umsetzung vor. Sie müssen daher bei jeder Anwendung des Patterns einige interessante Entscheidungen treffen, wobei Sie sich die im Folgenden erörterten Fragen stellen sollten.

12.6.1  Welche Operationen sollen bereitgestellt werden?

Das ist die entscheidende Frage. Sie hat großen Einfluss darauf, welchen Eindruck dieses Pattern macht und wie gut es funktioniert. Am einen Ende des Spektrums stellt die Basisklasse überhaupt keine Operationen bereit – sie besitzt lediglich eine abgeschottete Methode. Bei der Implementierung müssen Sie Aufrufe in Systemen außerhalb der Basisklasse tätigen. Bei dieser Herangehensweise kann man vielleicht gar nicht davon sprechen, dass Sie das Pattern überhaupt verwenden.

Am anderen Ende des Spektrums stellt die Basisklasse sämtliche Operationen bereit, die von einer Unterklasse benötigt werden könnten. Die Unterklassen sind ausschließlich an die Basisklasse gekoppelt und treten mit anderen außenstehenden Systemen nicht in Verbindung.

Das bedeutet ganz konkret, dass der Quelltext jeder Unterklasse nur eine #include-Anweisung enthält, nämlich diejenige zur Einbindung der Basisklasse.

Das übrige Spektrum besteht aus einer Vielzahl von Mischformen, bei denen einige Operationen von der Basisklasse bereitgestellt werden und auf andere direkt über die außenstehenden Systeme zugegriffen wird, in denen sie definiert sind. Je mehr Operationen die Basisklasse bereitstellt, desto geringer ist die Kopplung der Unterklassen an die außenstehenden Systeme, die Kopplung der Basisklasse an diese Systeme ist jedoch umso stärker – sie wird also von den Unterklassen in die Basisklasse verlagert.

Falls Sie zuhauf an außenstehende Systeme gekoppelte Unterklassen verwenden, ist das von Vorteil. Durch die Verlagerung in bereitgestellte Operationen wird die Kopplung in der Basisklasse zentralisiert. Allerdings wird sie dadurch auch immer umfangreicher und zunehmend schwieriger zu warten.

Wo aber soll man dann die Grenze ziehen? Hier einige Faustregeln:

Falls eine der bereitgestellten Operationen nur von einer oder einigen wenigen Unterklassen genutzt wird, lohnt sich der Aufwand nicht. Die Verkomplizierung der Basisklasse betrifft alle Unterklassen, aber nur einige wenige profitieren überhaupt davon.

Wenn sich die fragliche Operation mit anderen bereitgestellten Operationen vereinheitlichen lässt, kann der Aufwand gerechtfertigt sein. Einfacher und klarer ist es jedoch, wenn die Unterklassen in diesen Spezialfällen direkt mit den außenstehenden Systemen in Verbindung treten.

Wenn Sie Methoden in anderen Teilen des Spiels aufrufen, ist es weniger störend, wenn die Methode keinerlei Zustände modifiziert. Dadurch entsteht zwar nach wie vor eine Kopplung, die aber »sicher« ist, weil sie kein Fehlverhalten des Spiels verursachen kann.

»Sicher« steht an dieser Stelle in Anführungszeichen, weil rein technisch gesehen bereits der Zugriff auf Daten Probleme verursachen kann. Sofern das Spiel Multithreading nutzt, könnten Sie Daten lesen, während sie gerade modifiziert werden. Wenn Sie nicht darauf achten, könnte das zu inkonsistenten Daten führen.

Eine weitere unschöne Situation ergibt sich, wenn der Zustand des Spiels streng deterministisch ist (was bei vielen Onlinespielen der Fall ist, damit die Spieler synchronisiert bleiben). Wenn Sie auf Daten jenseits des synchronisierten Spielzustands zugreifen, kann das zu wirklich scheußlichen nicht-deterministischen Bugs führen.

Andererseits verursachen den Zustand modifizierende Aufrufe eine engere Kopplung an diese Teile der Codebasis und Sie müssen hier sehr viel vorsichtiger sein. Solche Aufrufe sind gute Kandidaten für eine weitere Operation, die über die besser erreichbare Basisklasse bereitgestellt wird.

Wenn die Implementierung einer bereitgestellten Operation einen Aufruf einfach nur an ein außenstehendes System weiterleitet, ist damit nicht viel gewonnen. In diesem Fall ist es womöglich einfacher, die Methode direkt aufzurufen.

Dennoch kann auch einfaches Weiterleiten durchaus sinnvoll sein. Solche Methoden ändern häufig einen Zustand, den die Basisklasse den Unterklassen nicht direkt bereitstellen soll. Nehmen wir beispielsweise an, die Superpower-Klasse stellt folgende Operation bereit:

void playSound(SoundId sound)
   {
     soundEngine_.play(sound);
}

Der Aufruf wird einfach nur an das soundEngine_-Datenfeld der Superpower-Klasse weitergeleitet. Der Vorteil besteht darin, dass dieses Datenfeld in der Superpower-Klasse gekapselt ist, damit andere Unterklassen nicht darauf zugreifen können.

12.6.2  Sollen Methoden direkt oder durch Objekte, die sie enthalten, bereitgestellt werden?

Bei diesem Pattern gibt es die Schwierigkeit, dass es letztendlich allzu viele Methoden in der Basisklasse gibt. Sie können die Situation jedoch verbessern, indem Sie einige der Methoden in andere Klassen auslagern. Die von der Basisklasse bereitgestellten Operationen liefern dann eins dieser Objekte zurück.

Um beispielsweise einen Sound abzuspielen, könnten wir ihn direkt zur Superpower-Klasse hinzufügen:

class Superpower
{
protected:
  void playSound(SoundId sound) { /* Code ... */ }
  void stopSound(SoundId sound) { /* Code ... */ }
  void setVolume(SoundId sound) { /* Code ... */ }

  // Abgeschottete Methode ...
  // Weitere Operationen ...
};

Wenn die Superpower-Klasse jedoch schon umfangreich und unhandlich ist, sollte das besser vermieden werden. Stattdessen erstellen wir eine SoundPlayer-Klasse, die entsprechende Funktionalität verfügbar macht:

class SoundPlayer
{
  void playSound(SoundId sound) { /* Code ... */ }
  void stopSound(SoundId sound) { /* Code ... */ }
  void setVolume(SoundId sound) { /* Code ... */ }
};

Die Superpower-Klasse ermöglicht den Zugriff darauf:

class Superpower
{
protected:
  SoundPlayer& getSoundPlayer()
  {
    return soundPlayer_;
  }

  // Abgeschottete Methode ...
  // Weitere Operationen ...

private:
  SoundPlayer soundPlayer_;
};

Bereitgestellte Operationen auf diese Weise in Hilfsklassen zu verschieben, bringt die folgenden Vorteile:

Die Anzahl der Methoden in der Basisklasse wird reduziert. Im hier gezeigten Beispiel gibt es statt drei Methoden nur noch eine Getter-Funktion.

Der Code in der Hilfsklasse lässt sich für gewöhnlich leichter warten. Basisklassen wie Superpower sind, trotz bester Absichten, tendenziell schwierig zu modifizieren, weil es so viele Abhängigkeiten gibt. Durch die Verlagerung von Funktionalitäten in weniger stark gekoppelte weitere Klassen lässt sich der Code leichter warten, ohne dass diese Funktionalität verlorengeht.

Die Kopplung zwischen der Basisklasse und anderen Systemen wird verringert. Die Basisklasse ist direkt an SoundID und anderen Audiocode der Implementierung gekoppelt, wenn playSound() eine Methode der Superpower-Klasse ist. Die Verlagerung in die SoundPlayer-Klasse verringert die Kopplung der Superpower-Klasse an die SoundPlayer-Klasse, die wiederum all ihre anderen Abhängigkeiten kapselt.

12.6.3  Wie gelangt die Basisklasse an die benötigten Zustände?

Die Basisklasse benötigt oft Daten, die verkapselt werden sollen, um sie vor den Unterklassen zu verbergen. In unserem ersten Beispiel stellt die Superpower-Klasse eine spawnParticles()-Methode zur Verfügung. Wie könnte die Implementierung dieser Methode bei Bedarf an ein Partikelsystem-Objekt gelangen?

Übergabe an den Konstruktor der Basisklasse:

Bei der einfachsten Lösung nimmt der Konstruktor der Basisklasse das Objekt als Parameter entgegen:

class Superpower
{
public:
  Superpower(ParticleSystem* particles)
  : particles_(particles) {}

  // Abgeschottete Methode ...
  // Weitere Operationen ...

private:
  ParticleSystem* particles_;
};

Auf diese Weise ist sichergestellt, dass jeder Superpower-Instanz nach der Instanziierung ein Partikelsystem-Objekt zur Verfügung steht. Sehen wir uns eine abgeleitete Klasse an:#

class SkyLaunch : public Superpower
{
public:
  SkyLaunch(ParticleSystem* particles)
  : Superpower(particles) {}
};

Hier wird das Problem deutlich: Jede abgeleitete Klasse muss einen Konstruktor besitzen, der die Basisklasse aufruft und das Argument übergibt. Den abgeleiteten Klassen wird so ein Zustand zugänglich, der ihnen eigentlich verborgen bleiben sollte.

Auch die Wartung bereitet Kopfschmerzen. Falls wir der Basisklasse später weitere Zustände hinzufügen, müssen sämtliche Konstruktoren der abgeleiteten Klassen modifiziert werden, um ein entsprechendes Argument zu übergeben.

Zweistufige Initialisierung:

Um zu vermeiden, dass für die Übergabe stets der Konstruktor verwendet werden muss, können wir die Initialisierung in zwei Schritte aufspalten. Der Konstruktor nimmt keine Parameter entgegen – er erzeugt lediglich das Objekt. Dann wird eine in der Basisklasse separat definierte Methode aufgerufen, um die übrigen benötigten Daten zu übergeben:

Superpower* power = new SkyLaunch();
power->init(particles);

Beachten Sie hier, dass dem SkyLaunch-Konstruktor nichts übergeben wird und es keine Kopplung an irgendetwas gibt, dessen Kenntnis in der Superpower-Klasse verbleiben soll. Es gibt bei diesem Ansatz allerdings eine Schwierigkeit: Sie müssen stets daran denken, init() aufzurufen. Falls Sie es vergessen, haben Sie es mit einer Superkraft in einem halbfertigen Dämmerzustand zu tun, die nicht funktioniert.

Das Problem lässt sich beheben, indem der gesamte Vorgang folgendermaßen in einer einzigen Funktion gekapselt wird:

Superpower* createSkyLaunch(
    ParticleSystem* particles)
{
  Superpower* power = new SkyLaunch();
  power->init(particles);
  return power;
}

Mit einigen Kniffen wie privaten Konstruktoren und Friend-Klassen können Sie erreichen, dass diese createSkyLaunch()-Funktion die einzige Funktion ist, die Superkräfte erzeugen kann. Außerdem kann auf diese Weise auch keine der Initialisierungsstufen vergessen werden.

Statischer Zustand:

Im vorangehenden Beispiel wurde jede Superpower-Instanz mit einem Partikelsystem-Objekt initialisiert. Das ist auch sinnvoll, wenn jede Superkraft einen eigenen Zustand besitzt. Lassen Sie uns jedoch annehmen, dass das Partikelsystem ein Singleton (Kapitel 6) ist und alle Superkräfte denselben Zustand gemeinsam nutzen.

Dieser Zustand wird dann zu einer privaten und statischen Variablen der Basisklasse. Das Spiel muss nach wie vor den Zustand initialisieren, allerdings muss die Superpower-Klasse nur einmalig initialisiert werden – und nicht mehr jede einzelne Instanz.

class Superpower
{
public:
  static void init(ParticleSystem* particles)
  {
    particles_ = particles;
  }

  // Abgeschottete Methode ...
  // Weitere Operationen ...

private:
  static ParticleSystem* particles_;
};

Beachten Sie hier, das sowohl init() als auch particles() statisch sind. Sofern das Spiel Superpower::init() frühzeitig genug aufruft, kann jede Superkraft auf das Partikelsystem zugreifen. Darüber hinaus können nach Belieben Superpower-Instanzen erzeugt werden, indem man den Konstruktor der entsprechenden abgeleiteten Klasse aufruft.

Es kommt noch besser: particles_ ist nun eine statische Variable, die nicht mehr in jeder Superpower-Instanz gespeichert werden muss – die Klasse benötigt jetzt also weniger Speicherplatz.

Denken Sie aber auch daran, dass diese Vorgehensweise nach wie vor viele der Probleme eines Singletons mit sich bringt. Der Zustand wird von sehr vielen Objekten (allen Superpower-Instanzen) gemeinsam genutzt. Das Partikelsystem ist gekapselt und daher nicht global sichtbar. Das ist auch schön und gut, kann aber das Verständnis der Superkräfte erschweren, weil all diese Objekte auf das Singleton zugreifen können.

Dienstlokalisierung verwenden:

Die vorhergehende Option macht es erforderlich, dass außenstehender Code die Zustandsinformationen übergibt, bevor die Basisklasse sie auch tatsächlich benötigt. Für die Initialisierung ist also der die Basisklasse umgebende Code verantwortlich. Eine weitere Möglichkeit wäre es, dass sich die Basisklasse die benötigten Zustandsinformationen selbst beschafft. Zu diesem Zweck kann das Pattern Service Locator (Dienstlokalisierung, Kapitel 16) angewendet werden:

class Superpower
{
  protected:
  void spawnParticles(ParticleType type, int count)
  {
    ParticleSystem& particles =
    Locator::getParticles();
    particles.spawn(type, count);
  }

  // Abgeschottete Methode ...
  // Weitere Operationen ...
};

Hier benötigt spawnParticles() ein Partikelsystem-Objekt. Statt es sich durch außenstehenden Code übergeben zu lassen, beschafft sich die Funktion das Objekt mittels des Service Locators selbst.

12.7  Siehe auch ...

Falls Sie das Pattern Update Method (Aktualisierungsmethode, Kapitel 10) anwenden, ist die update()-Methode oftmals gleichzeitig eine abgeschottete Methode.

Bei diesem Pattern sind im Vergleich zum Pattern Template Method (Schablonenmethode) die Rollen vertauscht. Bei beiden Patterns wird eine Methode durch eine Reihe einfacher Operationen implementiert. Beim Pattern Subclass Sandbox ist diese Methode die abgeleitete Unterklasse und die Operationen gehören zur Basisklasse. Beim Pattern Template Method hingegen gehört sie zur Basisklasse und die einfachen Operationen werden in der abgeleiteten Unterklasse implementiert.

Sie können dieses Pattern auch als eine Variation des Pattern Facade (Fassade) auffassen, das verschiedene Systeme hinter einer einzigen vereinfachten API versteckt. Beim Pattern Subclass Sandbox fungiert die Basisklasse als Fassade, die die gesamte Spiel-Engine vor den Unterklassen verbirgt.

Kapitel 13: Type Object (Typ-Objekt)

Ermöglichung der flexiblen Erzeugung neuer »Klassen« durch eine Klasse, deren verschiedene Instanzen unterschiedliche Objekttypen repräsentieren.

13.1  Motivation

Stellen Sie sich vor, wir würden an einem Fantasy-Rollenspiel arbeiten. Unsere Aufgabe bestünde darin, den Code für die Horden fieser Monster zu schreiben, die darauf aus sind, unseren tapferen Helden abzuschlachten. Monster besitzen eine ganze Reihe verschiedener Attribute: Gesundheitszustand, Angriffsmethoden, grafische Darstellung, Sounds usw. In den Beispielen werden der Einfachheit halber nur die ersten beiden berücksichtigt.

Jedem Monster im Spiel ist eine Kennzahl für den Gesundheitszustand zugeordnet. Anfangs ist jedes Monster kerngesund und die Kennzahl besitzt den Höchstwert. Sie wird bei jeder Verwundung des Monsters verringert. Außerdem ist jedem Monster eine Beschreibung der Angriffsmethode zugeordnet, die dem Benutzer irgendwo angezeigt wird, falls ein Monster den Helden attackiert. (Wie das funktioniert, ist an dieser Stelle nicht von Interesse.)

Die Spieldesigner haben uns wissen lassen, dass es verschiedene Monstergattungen (Breeds) gibt, wie z.B. »Drache« oder »Troll«. Jede Gattung beschreibt die Art der im Spiel vorhandenen Monster, wobei es durchaus möglich ist, dass mehrere Monster derselben Gattung gleichzeitig durch die Verliese irrlichtern.

Durch die Gattung eines Monsters ist festgelegt, wie groß die Kennzahl des Gesundheitszustands eingangs ist. Der Wert für Drachen ist höher als derjenige für Trolle, daher ist es schwieriger, einen Drachen zu töten als einen Troll umzubringen. Die Gattung legt auch die Beschreibung der Angriffsmethode fest – alle Monster derselben Gattung greifen auf dieselbe Weise an.

13.1.1  Die typische OOP-Lösung

Jetzt ist das Spieldesign bekannt und wir können endlich unseren Texteditor starten und mit der Programmierung loslegen. Dem Spieldesign zufolge ist ein Drachen ein Monster, ein Troll ist eine andere Art Monster und mit den anderen Gattungen verhält es sich ebenso.

Hierbei handelt es sich um die sogenannte »is-a« (ist ein)-Beziehung: Gemäß der traditionellen OOP-Denkweise »ist« ein Drache »ein« Monster. Daher wird Dragon zu einer Unterklasse von Monster gemacht. Wie später noch erörtert wird, ist die Unterklassenbildung nicht die einzige Möglichkeit, eine solche Beziehung im Code zu verankern.

Der objektorientierten Denkweise entsprechend, führt dies zu der folgenden Monster-Basisklasse:

class Monster
{
public:
  virtual ~Monster() {}
  virtual const char* getAttack() = 0;

protected:
  Monster(int startingHealth)
  : health_(startingHealth) {}

private:
int health_; // Aktueller Gesundheitszustad
};

Die öffentliche getAttack()-Funktion liefert den Text, der dargestellt werden soll, wenn ein Monster den Helden angreift. Die abgeleiteten Monster-Klassen überschreiben diese Funktion, um unterschiedliche Mitteilungen zurückzugeben.

Der Konstruktor ist geschützt und nimmt den anfänglichen Gesundheitszustand als Parameter entgegen. Für jede Gattung gibt es eine abgeleitete Klasse, die jeweils einen eigenen öffentlichen Konstruktor bereitstellt, der wiederum diesen hier aufruft und den Gesundheitszustand der Gattung übergibt. Die Unterklassen für Gattungen sehen dann wie folgt aus:

class Dragon : public Monster
{
public:
  Dragon() : Monster(230) {}

  virtual const char* getAttack()
  {
    return "Der Drachen spuckt Feuer!";
  }
};

class Troll : public Monster
{
public:
  Troll() : Monster(48) {}

  virtual const char* getAttack()
  {
    return "Der Troll verprügelt dich!";
  }
};

Jede von Monster abgeleitete Klasse übergibt den anfänglichen Gesundheitszustand und überschreibt getAttack(), um eine andere Beschreibung der Angriffsmethode zurückzugeben. Alles funktioniert wie erwartet und es dauert nicht lange, bis unser Held durch die Gegend rennt und eine Vielfalt verschiedener Bestien meuchelt. Wir legen weiteren Code nach und unversehens gibt es Dutzende von Monster-Unterklassen, vom ätzenden Schleim bis hin zu untoten Ziegenböcken.

Doch plötzlich gerät die Sache merkwürdigerweise ins Stocken: Die Spieldesigner wollen Hunderte verschiedener Gattungen einsetzen und wir sind die ganze Zeit damit beschäftigt, eine siebenzeilige Unterklasse nach der anderen zu schreiben und das Spiel immer wieder neu zu kompilieren. Und es kommt noch schlimmer: Die Designer wollen die bereits programmierten Gattungen modifizieren. Unsere ehemals produktiven Arbeitstage degenerieren zu Folgendem:

Eine E-Mail empfangen, in der ein Designer darum bittet, die anfängliche Kennzahl für den Gesundheitszustand eines Trolls von 48 auf 52 zu erhöhen.

Die Datei Troll.h auschecken und ändern.

Spiel neu kompilieren.

Änderung einchecken.

Auf die E-Mail antworten.

Nun beginnt das Ganze wieder von vorn.

Wir sind letzten Endes frustriert, weil wir zu Datenaffen geworden sind – und die Designer sind genervt, weil es ewig dauert, um eine einzige simple Kennzahl zu ändern. Wir brauchen eine Möglichkeit, die Gattungen zu modifizieren, ohne dabei immer gleich auch das gesamte Spiel neu kompilieren zu müssen. Noch besser wäre es natürlich, wenn die Designer beim Erzeugen und Modifizieren von Gattungen ganz ohne die Hilfe der Programmierer auskämen.

13.1.2  Eine Klasse für eine Klasse

Auf einer sehr hohen Ebene ließe sich das Problem ziemlich einfach lösen. Es gibt im Spiel eine Reihe verschiedener Monster, die bestimmte Attribute gemeinsam haben sollen. Eine Heerschar von Monstern schlägt auf den Helden ein und einige davon sollen als Angriffsmethode denselben Text verwenden. Das definieren wir dadurch, dass sie zur selben »Gattung« gehören, denn die Gattung legt die Angriffsmethode fest.

Wir haben dieses Konzept mittels Vererbung implementiert, weil dies bei der Verwendung von Klassen unserer Intuition entspricht. Ein Drache ist ein Monster, und jeder Drache im Spiel ist eine Instanz der Monster-Klasse. Dies spiegelt sich darin wider, dass jede Gattung als Unterklasse einer abstrakten Monster-Basisklasse definiert ist und jedes Monster im Spiel wiederum eine Instanz dieser abgeleiteten Gattungsklasse ist. Das führt zu folgender Klassenhierarchie:

Abb. 13.1: Jede Menge Unterklassen. Der Pfeil bedeutet »erbt von«.

Jede Instanz eines Monsters im Spiel gehört zu einer der abgeleiteten Monstergattungen. Je mehr Gattungen es gibt, umso größer wird die Klassenhierarchie – und das ist das Problem: Das Hinzufügen neuer Gattungen bedeutet die Ergänzung neuen Codes – und jede neue Gattung muss kompiliert werden.

Das funktioniert zwar, ist jedoch nicht die einzige Möglichkeit. Wir könnten den Code auch so gestalten, dass jedem Monster eine Gattung zugeordnet wird. Anstatt für jede Gattung eine Monster-Unterklasse zu erstellen, gäbe es dann nur jeweils eine einzige Monster- und Breed-Klasse:

Abb. 13.2: Zwei Klassen, beliebig viele Gattungen. Die Raute bedeutet »wird referenziert von«.

Das war's. Zwei Klassen. Beachten Sie, dass es hier überhaupt keine Vererbung mehr gibt. Bei dieser Vorgehensweise ist jedes Monster im Spiel einfach nur eine Instanz der Monster-Klasse. Für die Gattungen gibt es eine Breed-Klasse, die Informationen enthält, die von allen Monstern derselben Gattung gemeinsam genutzt werden: den anfänglichen Gesundheitszustand und den Text der Angriffsmethode.

Zur Zuordnung von Monstern zu einer Gattung erhält jede Monster-Instanz eine Referenz auf ein Breed-Objekt, das die Informationen über die Gattung enthält. Um den Text der Angriffsmethode zu erhalten, ruft ein Monster einfach eine Methode seiner Breed-Klasse auf. Im Wesentlichen definiert die Breed-Klasse also den »Typ« eines Monsters. Jede Breed-Instanz ist ein Objekt, das einen anderen Monster-Typ repräsentiert, daher auch die Bezeichnung dieses Patterns: Type Object (Typ-Objekt).

Die besondere Leistungsfähigkeit dieses Patterns liegt darin begründet, dass wir nun neue Typen definieren können, ohne dadurch die Codebasis zu verkomplizieren. Wir haben sozusagen einen Teil des Typisierungssystems aus der fest vorgegebenen Klassenhierarchie in Daten verwandelt, die wir zur Laufzeit festlegen können.

Mittels der Instanziierung weiterer Breed-Objekte mit unterschiedlichen Werten können wir Hunderte neuer Gattungen anlegen. Wenn wir die Instanziierung anhand der aus einer Konfigurationsdatei eingelesenen Daten vornehmen, haben wir die Möglichkeit, neue Monstergattungen vollständig durch Daten zu definieren. Das ist so einfach, dass es auch die Spieldesigner erledigen können!

13.2  Das Pattern

Es müssen ein Typ-Objekt und ein typisiertes Objekt definiert werden. Jede Instanz eines Typ-Objekts repräsentiert eine andere Art von Objekt. Die typisierten Objekte speichern einen Verweis auf das Typ-Objekt, das ihren Typ beschreibt.

Instanzspezifische Daten werden in der Instanz des typisierten Objekts gespeichert, Daten oder Verhaltensweisen hingegen, die allen Instanzen desselben Typs gemeinsam sind, im Typ-Objekt. Objekte, die dasselbe Typ-Objekt referenzieren, verhalten sich, als ob sie vom selben Typ wären. Auf diese Weise können Daten und Verhaltensweisen von mehreren ähnlichen Objekten gemeinsam übernommen werden. Insofern ähnelt das Pattern der Unterklassenbildung, es ist jedoch nicht erforderlich, fest vorgegebene Unterklassen zu verwenden.

13.3  Anwendbarkeit

Dieses Pattern erweist sich immer dann als nützlich, wenn Sie viele verschiedene »Arten« von Objekten definieren müssen, das Typisierungssystem der Programmiersprache für die Aufgabe jedoch zu unflexibel ist. Es ist insbesondere in den folgenden Fällen anwendbar:

Ihnen ist vorab unbekannt, welche Typen benötigt werden. (Denken Sie beispielsweise an die Unterstützung neuer Monstergattungen, die aus dem Internet heruntergeladen werden können.)

Sie möchten neue Typen erstellen oder vorhandene modifizieren können, ohne den Code ändern oder neu kompilieren zu müssen.

13.4  Konsequenzen

Bei diesem Pattern geht es darum, die Definition eines »Typs« von der imperativen, aber starren Form des Codes in die flexiblere, wenngleich weniger verhaltensbetonte Welt der Objekte im Arbeitsspeicher zu transferieren. Die Flexibilität ist von Vorteil, es geht jedoch auch etwas verloren, wenn Typen als Daten repräsentiert werden.

13.4.1  Typ-Objekte müssen manuell gehandhabt werden

Zu den Vorteilen eines Typisierungssystems wie desjenigen von C++ gehört es, dass der Compiler die gesamte Buchführung bei der Verwendung von Klassen übernimmt. Die Daten zur Definition der Klassen werden automatisch zu statischen Speichersegmenten der ausführbaren Datei kompiliert und alles funktioniert reibungslos.

Unter der Haube werden virtuelle C++-Methoden mittels einer sogenannten »virtuellen Funktionstabelle« oder kurz »Vtable« implementiert. Die Vtable ist ein einfaches struct, das eine Reihe von Funktionszeigern enthält, einen für jede virtuelle Methode einer Klasse. Im Arbeitsspeicher befindet sich eine Vtable pro Klasse. Jede Instanz einer Klasse verfügt über einen Zeiger auf die Vtable ihrer Klasse.

Beim Aufruf einer virtuellen Funktion sucht der Code zunächst nach der Vtable des Objekts und ruft dann die Funktion auf, auf die der entsprechende Funktionszeiger verweist.

Das kommt Ihnen bekannt vor? Die Vtable entspricht unserem Breed-Objekt und der Zeiger auf die Vtable der Referenz auf die Monstergattung, über die jedes Monster verfügt. C++-Klassen sind das Resultat der vom Compiler automatisch durchgeführten Anwendung des Patterns Type Object auf C.

Beim Pattern Type Object sind wir nicht nur für die Speicherverwaltung unserer Monster verantwortlich, sondern auch für ihre Typen – wir müssen gewährleisten, dass alle Breed-Objekte instanziiert werden und verfügbar sind, solange unsere Monster sie benötigen. Bei jedem Erstellen eines Monsters müssen wir sicherstellen, dass es korrekt mit einer Referenz auf eine gültige Monstergattung initialisiert wird.

Wir sind zwar einige der Einschränkungen durch den Compiler losgeworden, wir müssen dafür jedoch den Preis zahlen, einige Dinge selbst zu implementieren, die vorher der Compiler erledigt hat.

13.4.2  Die Definition des Verhaltens der verschiedenen Typen ist schwieriger

Bei der Unterklassenbildung können Sie eine Methode überschreiben und darin anstellen, wozu Sie Lust haben – Werte prozedural berechnen, anderen Code aufrufen usw. Sie unterliegen keinen Einschränkungen. Wir könnten eine Monster-Unterklasse definieren, deren Angriffsmethode sich mit der Mondphase ändert, wenn wir es denn wollten. (Diese Klasse dürfte gut für Werwölfe geeignet sein, nehme ich an.)

Verwenden wir hingegen das Pattern Type Object, wird eine überschriebene Methode durch eine Membervariable ersetzt. Statt einer Monster-Unterklasse, die eine Methode überschreibt, welche durch unterschiedlichen Code eine Angriffsmethode berechnet, kommt ein Breed-Objekt zum Einsatz, das die Angriffsmethode in verschiedenen Variablen speichert.

Dadurch wird es sehr erleichtert, Typ-Objekte zur Definition von typspezifischen Daten zu verwenden, es wird jedoch erschwert, typspezifisches Verhalten zu definieren. Wenn beispielsweise verschiedene Monstergattungen unterschiedliche KI-Algorithmen nutzen, wird die Anwendung des Patterns schwieriger.

Es gibt verschiedene Möglichkeiten, diese Einschränkungen zu umgehen. Eine einfache Lösung wäre es, eine Reihe fest vorgegebener Verhaltensweisen zu verwenden und in den Typ-Objekten Daten zu benutzen, um irgendeine davon auszuwählen. Nehmen wir beispielsweise an, die Monster-KI gibt stets »Bleib stehen!«, »Verfolge den Helden!« oder »Wimmere und kauere nieder!« vor (schließlich kann nicht jedes Monster ein mächtiger Drache sein). Zur Implementierung solchen Verhaltens können wir Funktionen definieren. Dann ordnen wir dem Breed-Objekt einen KI-Algorithmus zu, indem wir einen Zeiger auf die zugehörige Funktion darin speichern.

Eine weitere, etwas leistungsfähigere Lösung ist die vollständige Definition des Verhaltens in Form von Daten. Die beiden Patterns Interpreter und Bytecode (Kapitel 11) ermöglichen es, Objekte zu erzeugen, die Verhalten repräsentieren. Wenn wir eine Datendatei einlesen und deren Inhalt zum Erstellen einer Datenstruktur für eins dieser Patterns verwenden, enthält der Code keine Verhaltensdefinitionen mehr – sie bestehen dann nur noch aus Daten.

Im Laufe der Zeit werden Spiele zunehmend durch Daten gesteuert. Die Hardware wird immer leistungsfähiger und inzwischen stoßen wir eher bei dem Umfang der Inhalte, die wir erstellen können, an Grenzen – und weniger dabei, das Letzte aus der Hardware herauszuholen.

Bei einem 64K-Steckmodul bestand die Herausforderung noch darin, das gesamte Spiel darin einzupferchen. Bei einer doppelseitigen DVD besteht die Herausforderung hingegen darin, sie überhaupt voll zu bekommen.

Skriptsprachen und andere eher allgemeine Verfahren zur Definition des Spielverhaltens können uns einen gern gesehenen Produktivitätsschub verleihen – allerdings auf Kosten der optimalen Performance zur Laufzeit. Die Hardware wird im Gegensatz zu unserer Denkleistung ständig besser, daher wird das Eingehen dieses Kompromisses immer sinnvoller.

13.5  Beispielcode

Beim ersten Versuch einer Implementierung sollten wir anfangs etwas Einfaches ausprobieren und richten das im Abschnitt »Motivation« beschriebene System ein. Fangen wir mit der Breed-Klasse an:

class Breed
{
public:
  Breed(int health, const char* attack)
  : health_(health),
    attack_(attack)
  {}

  int getHealth() { return health_; }
  const char* getAttack() { return attack_; }

private:
  int health_; // Anfänglicher Gesundheitszustand
  const char* attack_;
};

Ganz schlicht. Hierbei handelt es sich im Grunde genommen um einen Container für zwei Datenfelder, nämlich den anfänglichen Gesundheitszustand und den Text zur Beschreibung der Angriffsmethode. Und so können die Monster ihn verwenden:

class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  const char* getAttack()
  {
    return breed_.getAttack();
  }

private:
  // Aktueller Gesundheitszustand
  int health_;
  Breed& breed_;
};

Beim Erstellen eines Monsters erhält dieses eine Referenz auf ein Breed-Objekt. Anstelle der vorher verwendeten Unterklassen wird die Monstergattung nun durch diese Referenz definiert. Im Konstruktor der Monster-Klasse wird mittels der Referenz der anfängliche Gesundheitszustand ermittelt. Um an den Text zur Beschreibung der Angriffsmethode zu gelangen, wird der Aufruf einfach an das zugehörige Breed-Objekt weitergeleitet.

Dieser einfache kleine Codeabschnitt stellt die grundlegende Idee des Patterns dar. Alles, was dann noch folgt, sind zusätzliche Dreingaben.

13.5.1  Typartiges Verhalten von Typ-Objekten: Konstruktoren

Mit dem bisher vorhandenen Code erstellen wir unmittelbar ein Monster und sind auch dafür zuständig, ihm eine Gattung zuzuordnen. Diese Methode weicht jedoch davon ab, wie normale Objekte in den meisten OOP-Sprachen instanziiert werden – für gewöhnlich muss kein Speicher reserviert werden, dem dann eine Klasse zugeordnet wird. Stattdessen rufen wir einen Konstruktor der Klasse selbst auf, der uns eine neue Instanz zurückliefert.

Dieses Pattern wenden wir nun auf die Typ-Objekte an:

class Breed
{
public:
  Monster* newMonster()
  {
    return new Monster(*this);
  }
    // Vorheriger Code ...
};

»Pattern« ist hier die treffende Bezeichnung, denn diese Vorgehensweise entspricht einem der klassischen Patterns der Gang of Four: der Factory Method (Fabrikmethode).

In manchen Programmiersprachen werden alle Objekte auf diese Weise erzeugt. In Ruby, Smalltalk, Objective-C und anderen Sprachen, in denen Klassen Objekte sind, werden neue Instanzen durch den Aufruf einer Methode des Klassenobjekts erzeugt.

Und auf die Klasse, die sie nutzt:

class Monster
{
  friend class Breed;

public:
  const char* getAttack()
  {
    return breed_.getAttack();
  }

private:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  int health_; // Aktueller Gesundheitszustand
  Breed& breed_;
};

Der entscheidende Unterschied ist die newMonster()-Funktion der Breed-Klasse: Sie ist die »Konstruktor«-Fabrikmethode.

Es gibt noch einen weiteren kleinen Unterschied. Der Beispielcode ist ja in C++ geschrieben, daher können wir ein praktisches kleines Feature verwenden: Friend-Klassen.

Der Monster-Konstruktor ist privat – dadurch wird verhindert, dass er direkt aufgerufen wird. Mit einer Friend-Klasse lässt sich diese Einschränkung umgehen, Breed kann also darauf zugreifen. Das heißt, der Aufruf von newMonster() ist die einzige Möglichkeit, Monster zu erzeugen.

Bei der ursprünglichen Implementierung wurden Monster folgendermaßen erzeugt:

Monster* monster = new Monster(someBreed);

Nach den Änderungen erfolgt die Erzeugung so:

Monster* monster = someBreed.newMonster();

Warum das Ganze? Zum Erzeugen eines Objekts sind zwei Schritte erforderlich: Allokation und Initialisierung. Im Monster-Konstruktor können wir alle benötigten Initialisierungen vornehmen. Im Beispiel ist das nur die Zuweisung der Monstergattung, in einem richtigen Spiel hingegen müssten Grafiken geladen, die Monster-KI initialisiert und weitere Einrichtungsaufgaben erledigt werden.

Allerdings geschieht all dies nach der Allokation. Wir haben bereits einen Speicherbereich für unser Monster reserviert, bevor dessen Konstruktor aufgerufen wird. In Spielen möchte man das jedoch häufig gern selbst steuern können. Typischerweise werden selbstprogrammierte Allokationen oder das Pattern Object Pool (Objektpool, Kapitel 19) eingesetzt, um festzulegen, an welcher Stelle des Arbeitsspeichers die Objekte abgelegt werden.

Die »Konstruktor«-Funktion der Breed-Klasse stellt den passenden Ort für diese Programmlogik bereit. Statt einfach nur new aufzurufen, kann die newMonster()-Funktion Speicher in einem Objektpool oder auf einem eigenen Heap-Speicher reservieren, bevor die Kontrolle zwecks Initialisierung wieder der Monster-Klasse übergeben wird. Wenn sich diese Programmlogik innerhalb von Breed befindet, der einzigen Funktion, die Monster erzeugen kann, ist dadurch sichergestellt, dass jedes Monster der von uns gewünschten Speicherverwaltungsmethode unterliegt.

13.5.2  Gemeinsame Nutzung von Daten durch Vererbung

Uns steht nun ein absolut brauchbares System von Typ-Objekten zur Verfügung, das allerdings noch ziemlich schlicht ist: Das Spiel wird letztlich Hunderte verschiedene Monstergattungen umfassen, die alle mehrere Dutzend Attribute aufweisen – und falls ein Spieldesigner beabsichtigt, alle 30 Trollgattungen etwas stärker zu machen, steht ihm eine Menge mühsamer Tipparbeit bevor.

Praktischer wäre es, wenn auf dieselbe Weise, wie mehrere Monster die Attribute einer Monstergattung nutzen, auch mehrere Monstergattungen gemeinsame Attribute verwenden könnten. Ebenso wie bei der ursprünglichen OOP-Lösung, können wir zu diesem Zweck die Vererbung einsetzen – nur mit dem Unterschied, dass wir nicht die Vererbungsmechanismen der Programmiersprache nutzen, sondern die Vererbung mittels unserer Typ-Objekte implementieren.

Der Einfachheit halber unterstützen wir nur einfache Vererbung. Ebenso wie eine Klasse eine Elternklasse besitzen kann, soll auch eine Monstergattung eine Elterngattung besitzen können:

class Breed
{
public:
  Breed(Breed* parent, int health,
        const char* attack)
  : parent_(parent),
    health_(health),
    attack_(attack)
  {}

  int         getHealth();
  const char* getAttack();

private:
  Breed* parent_;
  int health_; // Anfänglicher Gesundheitszustand
  const char* attack_;
};

Beim Erzeugen einer Gattung übergeben wir eine Elterngattung, von der sie erbt. Einer einfachen Gattung ohne Elterngattung wird NULL übergeben.

Die Kindgattung muss steuern können, welche Attribute sie von der Elterngattung erbt und welche sie überschreibt und selbst festlegt. In unserem Beispiel soll die Gattung den Gesundheitszustand mit einem von 0 verschiedenen Wert und den Text zur Beschreibung der Angriffsmethode mit einem von NULL verschiedenen String überschreiben. Die anderen Attribute werden von der Elterngattung geerbt.

Es gibt zwei Möglichkeiten der Implementierung. Bei der ersten wird die Delegation dynamisch gehandhabt, sobald ein Attribut abgefragt wird:

int Breed::getHealth()
{
// Überschreiben
  if (health_ != 0 || parent_ == NULL)
  {
    return health_;
  }
  // Erben
    return parent_->getHealth();
}
const char* Breed::getAttack()
{
  // Überschreiben
  if (attack_ != NULL || parent_ == NULL)
  {
    return attack_;
  }
  // Erben
  return parent_->getAttack();
}

Diese Methode besitzt gegenüber der zweiten den Vorteil, dass es keine Probleme bereitet, wenn eine Gattung zur Laufzeit so modifiziert wird, dass sie ein Attribut nicht mehr überschreibt oder ererbt. Andererseits benötigt sie etwas mehr Speicher (weil ein Zeiger auf die Elterngattung gespeichert werden muss) und ist etwas langsamer, da bei jeder Abfrage eines Attributs die Vererbungshierarchie durchlaufen werden muss.

Wenn wir davon ausgehen können, dass sich die Attribute einer Gattung nicht ändern, ist die Anwendung der Vererbung zum Zeitpunkt der Erzeugung eine schnellere Lösung. Man bezeichnet diese Vorgehensweise als Copy-Down-Delegation, weil die ererbten Attribute bei der Erzeugung kopiert werden, und zwar hinab in den abgeleiteten Typ. Das sieht dann folgendermaßen aus:

Breed(Breed* parent, int health, const char* attack)
: health_(health),
  attack_(attack)
{
  // Nicht überschriebene Attribute erben
  if (parent != NULL)
  {
    if (health == 0) health_= parent->getHealth();
    if (attack == NULL)
    {
      attack_ = parent->getAttack();
    }
  }
}

Das Datenfeld für die Elterngattung wird nun nicht mehr benötigt. Sobald der Konstruktor seine Arbeit beendet hat, ist die Elterngattung nicht mehr von Interesse, da ja bereits alle Attribute kopiert worden sind. Beim Zugriff auf die Attribute der Gattung wird einfach nur das Datenfeld zurückgegeben:

int         getHealth() { return health_; }
const char* getAttack() { return attack_; }

Schick und schnell!

Nehmen wir nun an, die Spiel-Engine sei dafür eingerichtet, Monstergattungen anhand einer eingelesenen JSON-Datei zu erstellen, in der sie definiert sind. Die Datei könnte dann z.B. folgenden Inhalt haben:

{
  "Troll": {
    "health": 25,
    "attack": "Der Troll schlägt dich!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "Der Troll schießt einen Pfeil ab!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "Der Troll spricht Zauberformeln!"
  }
}

Wir hätten somit einen Codeabschnitt, der die Einträge der Gattungen liest und neue Instanzen mit den angegebenen Daten erzeugt. Anhand der Datenfelder "parent": "Troll" ist erkennbar, dass die Gattungen Troll Archer und Troll Wizard von der Gattung Troll erben.

Da die Kennzahl für den Gesundheitszustand bei beiden Gattungen den Wert 0 besitzt, werden sie von der Gattung Troll ererbt. Unser Spieldesigner braucht jetzt nur noch die Kennzahl der Gattung Troll zu modifizieren und alle drei Gattungen werden aktualisiert. Bei zunehmender Anzahl von Gattungen und wachsender Zahl der verschiedenen Attribute kann dies viel Zeit ersparen. Wir haben jetzt dank eines ziemlichen kleinen Codefragments ein unbeschränktes System, das es den Spieldesignern ermöglicht, schnell Modifizierungen vorzunehmen und ihre Zeit mit sinnvolleren Dingen zu verbringen. Und wir können uns wiederum anderen Aufgaben widmen.

13.6  Designentscheidungen

Dank des Patterns Type Object (Typ-Objekt) können wir ein Typisierungssystem einrichten, ganz so, als ob wir eine eigene Programmiersprache entwickeln würden. Es stehen viele Möglichkeiten offen, und wir können eine Reihe interessanter Dinge anstellen.

In der Praxis werden unsere Ambitionen allerdings durch einige Umstände gebremst. Zeit und Wartungsfreundlichkeit werden uns von besonders komplizierten Ideen abhalten. Noch wichtiger ist jedoch, unabhängig von der Art des entworfenen Typisierungssystems, dass es für die Benutzer (die oftmals keine Programmierer sind) leicht verständlich ist. Je einfacher wir es gestalten, umso besser lässt es sich nutzen. Wir wandeln hier also auf ausgetretenen Designpfaden und überlassen einschneidende Änderungen den Akademikern und Forschern.

13.6.1  Ist das Typ-Objekt gekapselt oder zugänglich?

Im Implementierungsbeispiel verfügt die Monster-Klasse über eine Referenz auf eine Monstergattung, macht diese jedoch nicht öffentlich zugänglich. Außenstehender Code kann also nicht an die Gattung des Monsters gelangen. Aus Sicht der übrigen Codebasis sind Monster praktisch typlos – dass sie Gattungen angehören, ist ein Implementierungsdetail.

Das können wir jedoch leicht ändern, indem wir der Monster-Klasse ermöglichen, ihre Gattung zurückzugeben:

class Monster
{
public:
  Breed& getBreed() { return breed_; }

  // Vorhandener Code ...
};

Wie auch in anderen Beispielen dieses Buches halten wir uns hier an die Konvention, Objektreferenzen statt Zeiger zurückzugeben, um die Benutzer darauf hinzuweisen, dass niemals NULL zurückgeliefert wird.

Durch diese Erweiterung wird das Design der Monster-Klasse geändert. Dass alle Monster über eine Monstergattung verfügen, ist nun öffentlich zugänglicher Bestandteil der API. Beide Ansätze besitzen ihre Vor- und Nachteile.

Falls das Typ-Objekt gekapselt ist:

Die Komplexität des Patterns Type Object (Typ-Objekt) bleibt der übrigen Codebasis verborgen. Sie wird zu einem Implementierungsdetail, um das sich nur das typisierte Objekt zu kümmern braucht.

Das typisierte Objekt kann bei Bedarf das Verhalten des Typ-Objekts überschreiben. Angenommen, wir möchten die Beschreibung der Angriffsmethode eines Monsters ändern, wenn dessen Tod kurz bevorsteht. Da der Zugriff darauf stets über Monster erfolgt, bietet sich hier ein geeigneter Ort zur Platzierung des Codes an:

const char* Monster::getAttack()
{
  if (health_ < LOW_HEALTH)
  {
    return "Das Monster schwächelt.";
  }
  return breed_.getAttack();
}

Wenn außenstehender Code getAttack() direkt über die Breed-Klasse aufrufen könnte, wäre es nicht möglich, diese Programmlogik hier einzufügen.

Wir müssen Weiterleitungsmethoden für alle vom Typ-Objekt zugänglich gemachten Variablen programmieren. Dies ist der lästige Teil des Designs. Wenn die Klasse des Typ-Objekts viele Methoden besitzt, muss die Klasse des Objekts für jede öffentlich zugängliche Methode eine eigene bereitstellen.

Falls das Typ-Objekt zugänglich ist:

Außenstehender Code kann mit Typ-Objekten interagieren, ohne über eine Instanz der typisierten Klasse zu verfügen. Falls das Typ-Objekt gekapselt ist, gibt es keine Möglichkeit, ohne eine Instanz des kapselnden Objekts darauf zuzugreifen. Das hindert uns beispielsweise daran, das Konstruktor-Pattern anzuwenden, bei dem neue Monster durch den Aufruf einer Methode der Breed-Klasse erzeugt werden. Falls die Benutzer über keine Breed-Instanz verfügen, sind sie nicht in der Lage, sie aufzurufen.

Das Typ-Objekt ist Bestandteil der öffentlichen API des typisierten Objekts. Im Allgemeinen sind knapp bemessene Interfaces wartungsfreundlicher als umfangreiche – je weniger der übrigen Codebasis zugänglich ist, umso geringer sind die Komplexität und der Wartungsaufwand, mit dem Sie es zu tun haben. Wenn das Typ-Objekt zugänglich ist, erweitern wir dadurch die API um alles, was auch das Typ-Objekt bereitstellt.

13.6.2  Wie werden Typ-Objekte erzeugt?

Bei diesem Pattern besteht jedes »Objekt« aus einem Objektpaar: dem Hauptobjekt und dem Typ-Objekt, das es nutzt. Wie also werden diese Objekte erzeugt und verknüpft?

Erzeugung des Objekts mit Übergabe des Typ-Objekts:

Außenstehender Code nimmt die Allokation vor. Da der aufrufende Code beide Objekte selbst erzeugt, legt er fest, an welcher Stelle im Arbeitsspeicher die Objekte abgelegt werden. Dadurch wird es möglich, bei der Verwendung der Objekte flexibel verschiedene Methoden zur Speicherverwaltung einzusetzen (benutzerdefinierte Allokation, auf dem Stack usw.).

Aufruf einer »Konstruktor«-Funktion des Typ-Objekts:

Das Typ-Objekt nimmt die Allokation vor. Dies ist die andere Seite der Medaille. Wenn wir nicht möchten, dass die Benutzer festlegen können, an welcher Stelle des Arbeitsspeichers die Objekte abgelegt werden, können wir dies durch die Bedingung erreichen, dass eine Fabrikmethode des Typ-Objekts aufgerufen werden muss. Das erweist sich als nützlich, wenn sichergestellt werden soll, dass sämtliche Objekte einem bestimmten Objektpool (Object Pool, Kapitel 19) entstammen oder durch eine bestimmte Methode zur Speicherallokation erzeugt werden.

13.6.3  Kann sich der Typ ändern?

Bislang sind wir stets davon ausgegangen, dass ein einmal erzeugtes Objekt mit einem Typ-Objekt verknüpft ist und dass dies auch immer so bleibt. Der Typ des Objekts ändert sich in dessen Lebenszyklus also nicht. Das muss aber nicht notwendigerweise so sein. Wir könnten es ermöglichen, den Typ im Laufe der Zeit zu ändern.

Sehen wir uns noch einmal das Beispiel an: Wenn ein Monster stirbt, soll dessen Leiche nach dem Wunsch der Spieldesigner gelegentlich als Zombie wiederauferstehen. Wir könnten dies beim Tod eines Monsters durch das Erzeugen eines neuen Monsters der Gattung »Zombie« implementieren, es gibt jedoch auch die Möglichkeit, einfach die Gattung des vorhandenen Monsters auf »Zombie« zu ändern.

Falls sich der Typ nicht ändert:

Diese Vorgehensweise ist sowohl einfacher zu programmieren als auch leichter verständlich. Vom Konzept her ist der »Typ« etwas, von dem die meisten Leute erwarten würden, dass es sich nicht ändert. Diese Annahme spiegelt sich im Code wider.

Das Debuggen ist einfacher. Auf der Suche nach einem Bug, bei dem sich ein Monster in einem merkwürdigen Zustand befindet, vereinfacht sich die Aufgabe, wenn man davon ausgehen kann, dass die aktuelle Monstergattung diejenige ist, der das Monster schon immer angehörte.

Fall sich der Typ ändern darf:

Es müssen weniger Objekte erzeugt werden. Wenn sich der Typ wie in unserem Beispiel nicht ändern darf, verbrauchen wir beim Erzeugen eines neuen Zombie-Monsters, dem Kopieren der Attribute und dem Löschen des ursprünglichen Monsters CPU-Rechenzeit. Darf der Typ sich ändern, kann all dies durch eine einfache Zuweisung ersetzt werden.

Wir müssen darauf achten, bestimmte Voraussetzungen einzuhalten. Die Kopplung zwischen einem Objekt und seinem Typ-Objekt ist ziemlich eng. Beispielsweise könnte es eine Monstergattung geben, bei der die aktuelle Kennzahl des Gesundheitszustands den anfänglichen Wert niemals überschreiten kann.

Wenn es erlaubt ist, die Gattung zu ändern, muss sichergestellt sein, dass den Voraussetzungen der neuen Gattung durch das vorhandene Objekt Rechnung getragen wird. Beim Ändern des Typs müsste dann vermutlich Code zum Überprüfen solcher Voraussetzungen ausgeführt werden, um so zu gewährleisten, dass sich das Objekt in einem Zustand befindet, der bei dem neuen Typ einen Sinn ergibt.

13.6.4  Welche Formen der Vererbung werden unterstützt?

Keine Vererbung:

Die einfachste Form. Einfach ist nicht selten am besten. Wenn nicht massenhaft Daten mit dem Typ-Objekt gemeinsam genutzt werden müssen, gibt es keinen Grund, sich das Leben unnötig schwer zu machen.

Es kann zu vervielfachtem Aufwand kommen. Mir ist noch nie ein System untergekommen, bei dem die Spieldesigner nicht nach irgendeiner Form der Vererbung verlangt haben. Falls es 50 verschiedene Elfenarten gibt, ist es wirklich ätzend, wenn man die Kennzahl für den Gesundheitszustand an 50 verschiedenen Stellen ändern muss.

Einfache Vererbung:

Immer noch ziemlich einfach. Die Implementierung ist nicht schwierig, wichtiger ist jedoch, dass sie auch leicht verständlich ist. Falls auch technisch weniger versierte Benutzer das System verwenden, sind möglichst wenige veränderliche Systembestandteile von Vorteil. Es hat schon seinen Grund, dass viele Programmiersprachen nur die einfache Vererbung unterstützen – hierbei handelt es sich offenbar um die goldene Mitte zwischen Leistungsfähigkeit und Einfachheit.

Die Abfrage von Attributen ist langsamer. Zur Abfrage bestimmter Daten von einem Typ-Objekt muss womöglich die Vererbungshierarchie durchlaufen werden, um denjenigen Typ zu finden, der letzten Endes den gesuchten Wert liefert. Bei performancekritischem Code ist dafür womöglich keine Zeit.

Mehrfache Vererbung:

Vervielfachte Daten lassen sich nahezu vollständig vermeiden. Ein System mit vernünftiger mehrfacher Vererbung ermöglicht den Aufbau einer Hierarchie von Typ-Objekten, die praktisch keine Redundanz aufweist. Beim Modifizieren kann auf jede Menge Copy-and-Paste verzichtet werden.

Diese Herangehensweise ist kompliziert. Leider scheinen die Vorteile hier eher theoretischer als praktischer Natur zu sein. Die mehrfache Vererbung ist schwer verständlich und ebenso schwierig zu durchschauen.

Wenn etwa ein Drachenzombie sowohl vom Drachen als auch vom Zombie erbt, welche Attribute stammen dann vom Drachen und welche vom Zombie? Um das System verwenden zu können, müssen die Benutzer verstehen, wie die Vererbungshierarchie durchlaufen wird und den Weitblick besitzen, eine vernünftige Hierarchie zu entwickeln.

Die meisten mir bekannten Programmiervorgaben für C++ tendieren dazu, mehrfache Vererbung zu untersagen. Und in Java und C# gibt es überhaupt keine mehrfache Vererbung. Dabei handelt es sich wohl um das Eingeständnis einer traurigen Tatsache: Es ist so schwierig, die mehrfache Vererbung korrekt einzusetzen, dass es oft am besten ist, komplett darauf zu verzichten. Es lohnt zwar durchaus, darüber nachzudenken, aber Sie werden nur in den seltensten Fällen eine mehrfache Vererbung für die Typ-Objekte in Ihren Spielen verwenden wollen. Wie so oft ist hier das Einfache auch das Bessere.

13.7  Siehe auch ...

Dieses Pattern nimmt sich des allgemeinen Problems an, mit mehreren Objekten Daten und Verhalten gemeinsam zu nutzen. Das Pattern Prototype (Prototyp, Kapitel 5) zielt ebenfalls darauf ab, dieses Problem zu lösen, verfolgt dabei jedoch einen anderen Ansatz.

Das Pattern Type Object ist eng mit dem Flyweight-Pattern (Fliegengewicht, Kapitel 3) verwandt. Beide ermöglichen es, dass mehrere Instanzen Daten gemeinsam nutzen. Dem Flyweight-Pattern liegt die Absicht zugrunde, Arbeitsspeicher einzusparen, und die gemeinsam genutzten Daten repräsentieren für gewöhnlich keinen »Typ« eines Objekts. Das Pattern Type Object fokussiert sich auf die Organisation und die Flexibilität.

Es gibt auch viele Ähnlichkeiten zum Pattern State (Zustand, Kapitel 7). Beide Patterns ermöglichen es einem Objekt, Teile dessen, was es eigentlich ausmacht, an andere Objekte zu delegieren. Im Falle eines Typ-Objekts wird für gewöhnlich delegiert, was das Objekt darstellt: unveränderliche Daten, die das Objekt umfassend beschreiben. Beim Pattern State hingegen wird delegiert, was das Objekt in diesem Augenblick darstellt: temporäre Daten, die den aktuellen Zustand des Objekts beschreiben.

Im Kontext mit den vorangegangenen Ausführungen dazu, wie ein Objekt seinen Typ ändern kann, können Sie sich das so vorstellen, dass das Pattern Type Object gleichzeitig auch als State-Pattern fungiert.

Teil V: Entkopplungsmuster (Decoupling Patterns)

In diesem Teil:

Kapitel 14

Component (Komponente)

Kapitel 15

Event Queue (Ereigniswarteschlange)

Kapitel 16

Service Locator (Dienstlokalisierung)

Wenn Sie bei einer Programmiersprache erst einmal den Bogen raushaben, ist es tatsächlich ziemlich einfach, Code zu schreiben, der wunschgemäß funktioniert. Schwierig wird es jedoch, wenn es darum geht, Code zu schreiben, der sich leicht anpassen lässt, wenn sich die Anforderungen ändern. Dass eine perfekt ausgearbeitete Liste der Funktionalitäten vorliegt, bevor der Texteditor gestartet wird, ist leider ein höchst seltener Luxus.

Uns steht jedoch ein leistungsfähiges Tool zur Verfügung, um Modifizierungen zu erleichtern: die Entkopplung. Wenn davon die Rede ist, dass zwei Codeabschnitte »entkoppelt« sind, ist damit gemeint, dass Modifikationen in einem dieser Abschnitte für gewöhnlich keine Änderungen im anderen nach sich ziehen. Und wenn der Code nur an wenigen Stellen geändert werden muss, ist es einfacher, Funktionalitäten eines Spiels zu ändern.

Das Pattern Component (Komponente, Kapitel 14) entkoppelt verschiedene Bereiche eines Spiels mittels eines einzigen Informationsobjekts, das alle Aspekte der Bereiche umfasst. Event Queues (Ereigniswarteschlangen, Kapitel 15) ermöglichen es, zwei miteinander kommunizierende Objekte zu entkoppeln, insbesondere auch zeitlich. Und ein Service Locator (Dienstlokalisierung, Kapitel 16) gestattet es, einen Dienst in Anspruch zu nehmen, ohne an den Code gebunden zu sein, der ihn bereitstellt.

Kapitel 14: Component (Komponente)

Ermöglichung der Aufnahme mehrerer nicht miteinander gekoppelten Bereiche in einem einzigen Informationsobjekt.

14.1  Motivation

Wir entwickeln wieder ein Plattformspiel – und da es italienische Klempner bereits zuhauf gibt, soll unser Held ein dänischer Bäcker namens Bjørn sein.

Brillante Spielideen wie diese sind der Grund dafür, dass ich Programmierer bin – und nicht Designer.

Logischerweise gibt es eine Klasse, die unseren freundlichen Bäckermeister repräsentiert und auch sein Verhalten im Spiel enthalten soll.

Da er vom Spieler gesteuert wird, müssen Benutzereingaben eingelesen und in Bewegungen umgesetzt werden. Und da er natürlich auch mit dem jeweiligen Level interagiert, brauchen wir eine Physik-Engine, um Kollisionen und dergleichen festzustellen. Wenn das erledigt ist, muss er auf dem Bildschirm dargestellt werden, also kommt der Code für Animationen und Rendering noch dazu. Außerdem sollen natürlich auch ein paar Sounds abgespielt werden.

Aber Moment mal, das Ganze gerät ja völlig außer Kontrolle! Das Einmaleins der Softwarearchitektur besagt, dass verschiedene Bereiche eines Programms voneinander abgegrenzt werden sollen. Bei einer Textverarbeitung soll der Code zum Drucken ja auch nicht vom Code abhängig sein, der Dokumente liest und schreibt. Bei einem Spiel kommen zwar andere Bereiche als bei geschäftlichen Programmen zum Einsatz, die Regel ist jedoch nach wie vor gültig.

KI, Physik, Grafik, Sound und andere Bereiche sollten so weit wie möglich voneinander unabhängig sein – hier wurde jedoch alles in eine einzige Klasse hineingezwängt. Und wir wissen ja, wohin das führt: zu einer Quelltext-Müllhalde mit 5.000 Zeilen Code, die so groß ist, dass sich selbst die mutigsten Code-Ninjas in Ihrem Team nicht an sie herantrauen.

Sicher, die Arbeitsplätze der wenigen Leute, die damit umgehen können, dürften wohl sicher sein, für alle anderen bedeutet Code dieser Art jedoch die Hölle auf Erden. Bei einer Klasse dieser Größe haben selbst scheinbar triviale Änderungen weitreichende Auswirkungen. Sie wird schon bald mehr Bugs als Features aufweisen.

14.1.1  Der Gordische Knoten

Noch schlimmer als die schiere Größe ist allerdings die damit einhergehende Kopplung. Die verschiedenen Systeme des Spiels sind zu einem gigantischen Code-Knäuel wie diesem verwoben:

if (collidingWithFloor() &&
    (getRenderState() != INVISIBLE))
{
  playSound(HIT_FLOOR);
}

Ein Programmierer, der an solchem Code Änderungen vornehmen möchte, benötigt Kenntnisse über Physik-, Grafik- und Audiosystem des Spiels, wenn sichergestellt sein soll, dass eine von ihm vorgenommene Modifikation kein Fehlverhalten verursacht.

Die beiden Probleme, Größe und Kopplung, wirken zusammen: Die Klassen treten mit so vielen Bereichen in Verbindung, dass alle Programmierer an ihnen arbeiten müssen – die Aufgaben sind jedoch derart umfangreich, dass sie zu einem Alptraum werden. Schlimmstenfalls fangen die Programmierer damit an, in anderen Teilen der Codebasis Hacks einzubauen, nur um nicht mit dem Code-Knäuel in Berührung zu kommen, zu dem die Bjorn-Klasse geworden ist.

Eine derartige Kopplung ist in jedem Spiel fürchterlich, aber moderne Spiele, die Parallelverarbeitung verwenden, sind besonders betroffen. Bei Hardware mit Mehrkernprozessoren ist entscheidend, dass der Code in mehreren Threads gleichzeitig läuft. Die Aufteilung des Codes auf verschiedene Threads wird üblicherweise anhand der unterschiedlichen Bereiche vorgenommen – die KI läuft auf einem Kern, die Sound-Engine auf einem weiteren, das Rendering auf einem dritten usw.

Bei dieser Vorgehensweise ist es unerlässlich, dass die verschiedenen Bereiche entkoppelt bleiben, um ein gegenseitiges Blockieren oder andere vertrackte Bugs bei der Parallelverarbeitung zu verhindern. Wenn eine einzige Klasse eine UpdateSounds()- und eine RenderGraphics()-Methode besitzt und die beiden Methoden von verschiedenen Threads aus aufgerufen werden müssen, sind solche Bugs quasi vorprogrammiert.

14.1.2  Den Knoten durchschlagen

Wir können dieses Problem wie Alexander der Große lösen – mit einem Schwert. Wir zerlegen die Bjorn-Klasse entlang der Grenzen der verschiedenen Bereiche in mehrere Teile. Beispielsweise wird sämtlicher Code zur Handhabung von Benutzereingaben in eine separate InputComponents-Klasse verlagert. Die Bjorn-Klasse erhält dann eine Instanz dieser Klasse. Dieser Vorgang wird mit sämtlichen Bereichen wiederholt, mit denen die Bjorn-Klasse in Verbindung tritt.

Wenn das erledigt ist, haben wir fast sämtlichen Code aus der Klasse entfernt. Es verbleibt lediglich etwas Code, der die verschiedenen Bereiche miteinander verbindet. Das Problem der viel zu großen Klasse haben wir also einfach durch die Aufteilung in mehrere kleinere Klassen gelöst – allerdings haben wir damit noch mehr erreicht.

14.1.3  Unerledigtes

Die Komponenten-Klassen sind nun entkoppelt. Auch wenn die Bjorn-Klasse eine PhysicsComponent und eine GraphicsComponent besitzt, sind die beiden einander nicht bekannt. Der für die Physik zuständige Entwickler kann also an seiner Komponente Modifizierungen vornehmen, ohne Kenntnisse über das Grafiksystem zu besitzen, und auch der Grafikprogrammierer benötigt keine Kenntnisse der Physik-Engine, um seine Komponente zu ändern.

In der Praxis gibt es immer auch einige Interaktionen zwischen den verschiedenen Komponenten. Beispielsweise könnte die KI-Komponente der Physikkomponente mitteilen müssen, wohin sich Bjørn gerade begibt. Allerdings können wir dies auf diejenigen Komponenten beschränken, die auch tatsächlich miteinander in Verbindung treten müssen, anstatt alle in einen Topf zu werfen.

14.1.4  Wiederverwendung

Ein weiterer Vorteil dieses Designs ist, dass die Komponenten nun wiederverwendbare Pakete sind. Bislang haben wir uns auf unseren Bäcker konzentriert, aber es gibt ja auch noch einige andere Objekte in der Spielwelt. Dekorationen sind Objekte, die der Spieler zwar sehen, mit denen er jedoch nicht interagieren kann: Buschwerk, Geröll und andere visuelle Details. Requisiten ähneln Dekorationen, können aber berührt werden: z.B. Kisten, Felsbrocken oder Bäume. Und Zonen sind schließlich das Gegenteil von Dekorationen – sie sind zwar unsichtbar, aber interaktiv. Sie können beispielsweise dafür benutzt werden, das Abspielen einer Videosequenz auszulösen, wenn Bjørn ein bestimmtes Gebiet betritt.

Als die objektorientierte Programmierung erstmals auf den Plan trat, war die Vererbung in aller Munde. Man hielt sie für eine Art Wunderwaffe zur Wiederverwendung von Code und sie wurde dementsprechend häufig eingesetzt. Seither haben wir auf die harte Tour gelernt, dass die Vererbung kein Allheilmittel ist. Sie hat durchaus ihre Berechtigung, ist bei der einfachen Wiederverwendung von Code jedoch oft zu schwerfällig.

Stattdessen geht der Trend beim Softwaredesign zunehmend dahin, anstelle der Vererbung nach Möglichkeit eine Komposition zu verwenden. Zwei Klassen nutzen den Code nicht mehr gemeinsam, indem sie von einer anderen Klasse erben, vielmehr verfügen beide Klassen über eine Instanz der anderen Klasse.

Sehen Sie sich nun einmal die Vererbungshierarchie dieser Klassen an, wenn keine Komponenten verwendet werden. Ein erster Versuch könnte folgendermaßen aussehen:

Abb. 14.1: Einfache Vererbung: Wiederverwendung nicht möglich

Es gibt eine GameObject-Klasse, die sich um die üblichen Dinge wie Position und Orientierung kümmert. Zone erbt von ihr und fügt die Kollisionserkennung hinzu. Ebenso erbt Dekoration vom GameObject und fügt das Rendering hinzu. Requisite erbt von Zone und kann den Code zur Kollisionserkennung wiederverwenden. Allerdings kann Requisite nicht zusätzlich auch von Dekoration erben und den Code zum Rendern wiederverwenden, ohne Bekanntschaft mit dem »Tödlichen Diamanten« (Deadly Diamond) zu machen.

Der »Tödliche Diamant« tritt bei Klassenhierarchien mit mehrfacher Vererbung auf, in denen es zwei verschiedene Vererbungspfade zur selben Basisklasse gibt. Die dadurch verursachten Probleme zu erläutern, würde an dieser Stelle zu weit führen, aber nehmen Sie bitte zur Kenntnis, dass die Bezeichnung nicht von ungefähr »tödlich« lautet.

Wir könnten die Sache anders aufziehen und Requisite von Dekoration erben lassen, aber dann ist letztlich der Code zur Kollisionserkennung doppelt vorhanden. Wie man es auch dreht und wendet, es gibt keine vernünftige Möglichkeit, sowohl den Code zum Rendern als auch den Code zur Kollisionserkennung wiederzuverwenden, ohne dabei auf mehrfache Vererbung zurückzugreifen. Die einzige andere Möglichkeit wäre es, den gesamten Code in die GameObject-Klasse zu verlagern, aber dann verschwenden Requisite und Dekoration Speicherplatz zum Rendern von Daten, die gar nicht benötigt werden bzw. für eine überflüssige Kollisionserkennung.

Gut, versuchen wir es also mit Komponenten. Die Unterklassen verschwinden komplett. Stattdessen gibt es nur eine GameObject-Klasse und zwei Komponenten-Klassen: PhysicsComponent und GraphicsComponent. Dekoration wird zu einem GameObject mit GraphicsComponent, aber ohne PhysicsComponent. Bei Zone ist es umgekehrt und Requisite verfügt über beide Komponenten. Kein doppelter Code, keine mehrfache Vererbung und nur drei statt vier Klassen.

Komponenten sind im Wesentlichen eine Art Plug-and-Play für Objekte. Sie ermöglichen den Aufbau komplexer Informationsobjekte mit umfangreichem Verhalten durch das Einstöpseln verschiedener Komponenten-Objekte. Kennen Sie die Zeichentrickserie Voltron? Stellen Sie sich das Ganze als Voltron für Software vor.

Die Speisekarte eines Restaurants ist hierfür eine gute Analogie. Das wäre dann in etwa so, als ob nur bestimmte fertig zusammengestellte Menüs bestellt werden könnten. Wir benötigen für alle möglichen Kombinationen von Features eine eigene Klasse. Entsprechend müsste es Dutzende von Menüs geben, um alle Restaurantbesucher zufriedenzustellen.

Komponenten à la carte sozusagen – jeder Restaurantbesucher wünscht nur bestimmte Gerichte, und die Speisekarte enthält eine Liste der zur Auswahl stehen Gerichtskombinationen.

14.2  Das Pattern

Ein einziges Informationsobjekt umfasst mehrere Bereiche. Um diese Bereiche voneinander abzugrenzen, befindet sich der zugehörige Code jeweils in einer eigenen Komponenten-Klasse. Das Objekt wird zu einem einfachen Container für Komponenten.

Ebenso wie »Objekt« gehört »Komponente« zu den Begriffen, die im Bereich der Programmierung alles Mögliche bedeuten können, daher wird er oftmals zur Beschreibung unterschiedlicher Konzepte verwendet. Für Business-Software gibt ein Design Pattern namens Component (Komponente), das über das Internet kommunizierende entkoppelte Dienste beschreibt.

Ich habe versucht, eine andere Bezeichnung für dieses in Spielen auftretende Pattern zu finden, das in keinem Zusammenhang mit dem oben genannten steht. Allerdings scheint der Begriff »Component« am gebräuchlichsten zu sein. Und da es bei Design Patterns im Allgemeinen darum geht, vorhandene Praktiken zu dokumentieren, kann ich mir nicht den Luxus erlauben, einen neuen Begriff einzuführen – also trete ich in die Fußstapfen von XNA, Delta3D und anderen und bleibe ebenfalls bei der Bezeichnung »Component«.

14.3  Anwendbarkeit

Komponenten finden sich am häufigsten in der Kernklasse, in der die Figuren eines Spiels definiert werden, sie können sich jedoch auch an anderen Stellen als nützlich erweisen. Dieses Pattern ist gut geeignet, wenn einer der nachstehenden Punkte zutrifft:

Eine Klasse tritt mit vielen Bereichen in Verbindung, die voneinander entkoppelt bleiben sollen.

Eine Klasse ist so groß geworden, dass es schwerfällt, mit ihr zu arbeiten.

Sie möchten eine Vielzahl von Objekten definieren, die verschiedene Fähigkeiten gemeinsam haben, aber die Vererbung gestattet es nicht, die zur Wiederverwendung erwünschten Teile präzise genug auszuwählen.

14.4  Konsequenzen

Im Vergleich zum einfachen Anlegen einer Klasse mit etwas Code erscheint das Component-Pattern ziemlich kompliziert. Jedes konzeptuelle »Objekt« wird zu einer Gruppe von Objekten, die instanziiert, initialisiert und korrekt miteinander verknüpft werden müssen. Die Kommunikation zwischen den verschiedenen Komponenten wird schwieriger und auch die Speicherverwaltung ist komplexer.

Bei einer großen Codebasis kann sich diese Komplexität dadurch auszahlen, dass sie Entkopplung und Wiederverwendung von Code ermöglicht – achten Sie jedoch darauf, dass Sie keine »Lösung« für ein gar nicht vorhandenes Problem entwickeln, bevor Sie dieses Pattern anwenden.

Die Verwendung von Komponenten hat außerdem zur Folge, dass Sie oft einige Umwege durch indirekten Zugriff in Kauf nehmen müssen, um bestimmte Aufgaben zu erledigen. Mittels des Container-Objekts müssen Sie zunächst an eine Komponente gelangen, bevor Sie etwas mit ihr anstellen können. In performancekritischen inneren Schleifen kann dieses Nachverfolgen der Zeiger die Leistung beeinträchtigen.

Auch diese Medaille hat zwei Seiten. In manchen Fällen verbessert das Component-Pattern die Performance und die Geschlossenheit des Caches. Außerdem erleichtern Komponenten auch die Verwendung des Patterns Data Locality (Datenlokalität, Kapitel 17) zur Anordnung Ihrer Daten in der von der CPU erwünschten Reihenfolge.

14.5  Beispielcode

Eine der größten Herausforderungen beim Schreiben dieses Buches war herauszufinden, wie sich die verschiedenen Patterns voneinander abgrenzen lassen. Viele Design Patterns verwenden Code, der selbst eigentlich gar nicht zum Pattern gehört. Um nur das Wesentliche eines Patterns herauszukristallisieren, habe ich mich bemüht, so viel wie möglich davon wegzulassen. Früher oder später gelangt man dabei allerdings an einen Punkt, an dem man sich vorkommt, als ob man die Aufteilung eines Kleiderschranks erklären solle, ohne dabei Kleidungsstücke zu erwähnen.

Das Component-Pattern ist besonders schwierig. Sie können kein Gefühl dafür entwickeln, ohne etwas Code der verschiedenen Bereiche zu betrachten, die dadurch entkoppelt werden, daher werde ich etwas mehr vom Code der Bjorn-Klasse vorstellen, als ich eigentlich wollte. Das Pattern an sich betrifft eigentlich nur die Komponenten-Klassen selbst, jedoch verdeutlicht der darin befindliche Code ihren Zweck. Es handelt sich um Pseudocode, der andere Klassen aufruft, die hier gar nicht gezeigt werden, er sollte Ihnen jedoch eine Vorstellung davon verschaffen, worum es eigentlich geht.

14.5.1  Eine monolithische Klasse

Um die Anwendung dieses Patterns zu verdeutlichen, fangen wir mit einer monolithischen Bjorn-Klasse an, die alles erledigt, was nötig ist, das Pattern jedoch nicht anwendet:

class Bjorn
{
  public:
  Bjorn() : velocity_(0), x_(0), y_(0) {}

  void update(World& world, Graphics& graphics);

private:
  static const int WALK_ACCELERATION = 1;

  int velocity_;
  int x_, y_;

  Volume volume_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Ich sollte hier darauf hinweisen, dass es für gewöhnlich keine besonders gute Idee ist, den tatsächlichen Namen einer Spielfigur in der Codebasis zu verwenden. Marketingabteilungen haben die schlechte Angewohnheit, wenige Tage vor Auslieferung des Spiels Namensänderungen zu verlangen, frei nach dem Motto »Meinungsumfragen haben ergeben, dass männliche Testpersonen im Alter von 11 bis 15 Jahren negativ auf den Namen 'Bjørn' reagieren. Verwendet stattdessen 'Sven'.«

Aus diesem Grund werden bei vielen Softwareprojekten interne Codenamen benutzt – nun ja, einerseits deswegen und andererseits, weil es viel mehr Spaß macht, den Leuten zu erzählen, dass man an der »piezoelektrischen Großkatze« arbeitet als einfach nur an »der kommenden Version von Photoshop«.

Die Bjorn-Klasse besitzt eine update()-Methode, die vom Spiel ein Mal pro Frame aufgerufen wird:

void Bjorn::update(World& world, Graphics& graphics)
{
  // Benutzereingaben auf Geschwindigkeit der Hauptfigur anwenden
  switch (Controller::getJoystickDirection())
  {
    case DIR_LEFT:
      velocity_ -= WALK_ACCELERATION;
      break;
    case DIR_RIGHT:
      velocity_ += WALK_ACCELERATION;
      break;
  }

  // Position anhand der Geschwindigkeit anpassen
  x_ += velocity_;
  world.resolveCollision(volume_, x_, y_, velocity_);

  // Entsprechendes Sprite zeichnen
  Sprite* sprite = &spriteStand_;
  if (velocity_ < 0) sprite = &spriteWalkLeft_;
  else if (velocity_ > 0) sprite = &spriteWalkRight_;
  graphics.draw(*sprite, x_, y_);
}

Die Methode fragt den Joystick ab, um festzustellen, wie der Bäcker beschleunigt werden soll. Dann wird über die Physik-Engine die neue Position ermittelt und schließlich wird Bjørn auf dem Bildschirm gezeichnet.

Diese Implementierung ist trivial einfach. Es gibt weder Gravitation, noch Animationen oder irgendwelche der Dutzenden anderen Details, die es zu einem Spaß machen, die Figur zu bewegen. Dennoch ist erkennbar, dass es sich um eine Funktion handelt, in die mehrere Programmierer unseres Teams Code einbringen werden und sie beginnt auch schon, etwas undurchsichtig zu werden. Stellen Sie sich vor, die Funktion bestünde aus 1.000 Zeilen – dann bekommen Sie einen Eindruck davon, wie mühsam das Ganze werden kann.

14.5.2  Abspalten eines Bereichs

Nun soll der erste Bereich aus der Bjorn-Klasse in eine separate Komponenten-Klasse verschoben werden. Wir fangen mit dem Bereich an, der zuerst verarbeitet wird: der Eingabe. Die Bjorn-Klasse liest zunächst die Benutzereingabe und passt die Geschwindigkeit entsprechend an. Diese Programmlogik wird in eine separate Klasse ausgelagert:

class InputComponent
{
public:
  void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;
      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }

private:
  static const int WALK_ACCELERATION = 1;
};

Das war ziemlich einfach. Wir haben einfach nur den ersten Abschnitt der update()-Methode der Bjorn-Klasse in diese Klasse übernommen. Und auch die Änderungen an der Bjorn-Klasse sind unkompliziert:

class Bjorn
{
public:
  int velocity;
  int x, y;

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);

    // Position anhand der Geschwindigkeit anpassen
    x += velocity;
    world.resolveCollision(volume_, x, y, velocity);

    // Entsprechendes Sprite zeichnen
    Sprite* sprite = &spriteStand_;
    if (velocity_ < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (velocity_ > 0)
    {
      sprite = &spriteWalkRight_;
    }
      graphics.draw(*sprite, x, y);
  }

private:
  InputComponent input_;

  Volume volume_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Die Bjorn-Klasse verfügt nun über ein InputComponent-Objekt. Die zuvor direkt erledigte Handhabung der Benutzereingabe durch die update()-Methode wird jetzt an die Komponente delegiert:

input_.update(*this);

Wir haben gerade erst angefangen, sind aber schon eine Kopplung losgeworden – die Bjorn-Klasse besitzt keine Referenz mehr auf Controller. Dies wird sich schon bald als sehr praktisch erweisen.

14.5.3  Abspalten der übrigen Bereiche

Fahren wir also fort und erledigen dieselben Cut-and-Paste-Vorgänge für den Physik- und den Grafikcode. Hier der Code der neuen PhysicsComponent:

class PhysicsComponent
{
public:
  void update(Bjorn& bjorn, World& world)
  {
    bjorn.x += bjorn.velocity;
    world.resolveCollision(volume_,
        bjorn.x, bjorn.y, bjorn.velocity);
  }

private:
  Volume volume_;
};

Wie Sie sehen haben wir nicht nur das Physik-Verhalten aus der Bjorn-Klasse entfernt, sondern auch die Daten: Das Volume-Objekt gehört nun zur Komponente.

Und der Rendering-Code befindet sich jetzt hier:

class GraphicsComponent
{
public:
  void update(Bjorn& bjorn, Graphics& graphics)
  {
    Sprite* sprite = &spriteStand_;
    if (bjorn.velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (bjorn.velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }
      graphics.draw(*sprite, bjorn.x, bjorn.y);
  }

private:
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Nun haben wir fast alles aus der Klasse entfernt. Was also bleibt von unserem ergebenen Bäckermeister? Nicht gerade viel:

class Bjorn
{
public:
  int velocity;
  int x, y;

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }

private:
  InputComponent input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

Die Bjorn-Klasse erledigt nun im Wesentlichen zwei Aufgaben: Sie speichert die Komponenten, aus denen sie eigentlich besteht, sowie den Zustand, der von mehreren Bereichen genutzt wird. Position und Geschwindigkeit sind aus zwei Gründen nach wie vor Bestandteil der Bjorn-Klasse: Erstens werden diese Informationen fast von jeder Komponente benötigt, es ist also unklar, zu welchem Bereich sie gehören sollten, wenn man sie denn aus der Bjorn-Klasse entfernen möchte.

Und zweitens, noch wichtiger: Die Komponenten können auf diese Weise miteinander interagieren, ohne aneinander gekoppelt zu sein. Können wir uns das irgendwie zunutze machen?

14.5.4  Robo-Bjørn

Das Verhalten haben wir nun in separate Komponenten-Klassen ausgelagert, wir haben es jedoch noch nicht abstrahiert. Bjorn sind noch immer die konkreten Klassen bekannt, die sein Verhalten definieren – doch das wird sich jetzt ändern.

Die Komponente zur Handhabung von Benutzereingaben wird hinter einem Interface verborgen. InputComponent wird zu einer abstrakten Basisklasse:

class InputComponent
{
public:
  virtual ~InputComponent() {}
  virtual void update(Bjorn& bjorn) = 0;
};

Dann verlagern wir den Code zur Handhabung der Benutzereingaben in eine Klasse, die dieses Interface implementiert:

class PlayerInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;

      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }

private:
  static const int WALK_ACCELERATION = 1;
};

In der Bjorn-Klasse wird die InputComponent-Instanz durch einen Zeiger darauf ersetzt:

class Bjorn
{
public:
  int velocity;
  int x, y;

  Bjorn(InputComponent* input)
  : input_(input)
  {}

  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }

private:
  InputComponent* input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

Bei der Bjorn-Instanziierung können wir nun wie folgt die zu verwendende Eingabekomponente übergeben:

Bjorn* bjorn = new Bjorn(new PlayerInputComponent());

Bei dieser Instanz kann es sich um irgendeine konkrete Klasse handeln, die unser abstraktes InputComponent-Interface implementiert. Wir nehmen dabei in Kauf, update() jetzt als virtuelle Methode aufzurufen, was etwas langsamer ist. Was erreichen wir damit?

Bei den meisten Spielkonsolen muss es einen Demo-Modus geben. Wenn das Hauptmenü angezeigt wird und der Spieler nichts unternimmt, startet das Spiel automatisch, wobei der Computer die Steuerung des Spielers übernimmt. Dadurch wird ein Einbrennen des Hauptmenüs auf dem Bildschirm verhindert – und es sieht auch besser aus, wenn das Spiel in einem Ladengeschäft im Kioskmodus läuft.

Dies wird durch das Verbergen der Eingabekomponente hinter einem Interface ermöglicht. Es gibt ja bereits die konkrete PlayerInputComponent, die beim Spielen normalerweise Verwendung findet. Hier erstellen wir eine weitere:

class DemoInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    // KI zur automatischen Steuerung von Bjørn
  }
};

Wenn das Spiel im Demo-Modus läuft, instanziieren wir die Bjorn-Klasse mit dieser neuen Komponente:

Bjorn* bjorn = new Bjorn(new DemoInputComponent());

Und schon haben wir einen voll funktionsfähigen, computergesteuerten Spieler für den Demo-Modus – nur durch den Austausch einer Komponente. Wir können den gesamten übrigen Code der Bjorn-Klasse wiederverwenden – Physik- und Grafik-Engine bemerken den Unterschied überhaupt nicht. Vielleicht halten Sie mich für verrückt, aber Dinge wie diese bringen mich dazu, morgens aufzustehen.

14.5.5  Ganz ohne Bjørn?

Wenn Sie sich die Bjorn-Klasse nun ansehen, werden Sie feststellen, dass von Bjørn nicht viel übrig geblieben ist – sie besteht nur noch aus einem Haufen von Komponenten. Tatsächlich sieht sie sogar nach einem ziemlich guten Kandidaten für eine GameObject-Klasse aus, die für jedes Objekt im Spiel verwendbar ist. Wir bräuchten lediglich alle Komponenten zu übergeben und könnten dann durch Auswahl der Bauteile wie Dr. Frankenstein alle möglichen Objekte erschaffen.

Jetzt kommen die letzten beiden konkreten Komponenten an die Reihe, Physik und Grafik, und werden wie die Benutzereingaben hinter Interfaces verborgen:

class PhysicsComponent
{
public:
  virtual ~PhysicsComponent() {}
  virtual void update(GameObject& object,
                      World& world) = 0;
};

class GraphicsComponent
{
public:
  virtual ~GraphicsComponent() {}
  virtual void update(GameObject& object,
                      Graphics& graphics) = 0;
};

Dann wird Bjorn in eine allgemeine GameObject-Klasse umgetauft, die diese Interfaces verwendet:

class GameObject
{
public:
  int velocity;
  int x, y;

  GameObject(InputComponent* input,
             PhysicsComponent* physics,
             GraphicsComponent* graphics)
  : input_(input),
    physics_(physics),
    graphics_(graphics)
  {}

  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_->update(*this, world);
    graphics_->update(*this, graphics);
  }

private:
  InputComponent* input_;
  PhysicsComponent* physics_;
  GraphicsComponent* graphics_;
};

Manche Komponentensysteme gehen sogar noch einen Schritt weiter: Statt eines GameObjects, das Komponenten enthält, ist das Spielobjekt nur eine ID, eine Zahl. Es gibt dann Sammlungen von Komponenten, denen jeweils die ID des Objekts zugeordnet ist. Solche Objektkomponentensysteme gehen bei der Entkopplung ins Extreme und ermöglichen das Hinzufügen von Komponenten, die dem zugehörigen Objekt nicht einmal bekannt sind. Mehr dazu im Kapitel 17 Data Locality (Datenlokalität).

Die bereits vorhandenen konkreten Klassen werden umbenannt und implementieren diese Interfaces:

class BjornPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // Physik-Code ...
  }
};

class BjornGraphicsComponent
    : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj,
                      Graphics& graphics)
  {
    // Grafik-Code ...
  }
};

Und nun können wir ein Objekt mit Bjørns ursprünglichem Verhalten erzeugen, ohne eine Klasse für ihn anlegen zu müssen, und zwar so:

GameObject* createBjorn()
{
  return new GameObject(
      new PlayerInputComponent(),
      new BjornPhysicsComponent(),
      new BjornGraphicsComponent());
}

Bei der createBjorn()-Funktion handelt es sich natürlich um ein Beispiel für das klassische Design Pattern Factory Method (Fabrikmethode) der Gang of Four.

Um alle möglichen im Spiel benötigten Objekte zu erzeugen, können wir nun weitere Funktionen definieren, die GameObject mit anderen Komponenten instanziieren.

14.6  Designentscheidungen

Die wichtigste Frage, die Sie sich bei diesem Pattern stellen müssen, lautet: »Welche Komponenten brauche ich?« Die Antwort wird von den Anforderungen und dem Genre Ihres Spiels abhängen: Je umfangreicher und komplexer Ihre Spiel-Engine ist, desto feiner werden Sie Ihre Komponenten vermutlich unterteilen wollen. Darüber hinaus sind auch noch einige weniger allgemeine Punkte zu beachten.

14.6.1  Wie gelangt ein Objekt an seine Komponenten?

Sobald das monolithische Objekt in einige separate Komponenten aufgeteilt ist, müssen wir entscheiden, wer die Teile wieder zusammensetzt.

Falls das Objekt die Komponenten selbst erzeugt:

Das Objekt hat stets Zugriff auf die benötigten Komponenten. Sie brauchen sich keine Sorgen zu machen, dass irgendjemand vergisst, dem Objekt die richtigen Komponenten bereitzustellen und dadurch ein Fehlverhalten des Spiels verursacht.

Das Umkonfigurieren des Objekts ist schwieriger. Dass Sie durch die Kombination verschiedener Komponenten neue Arten von Objekten erzeugen können, ist eins der leistungsfähigsten Merkmale dieses Patterns. Wenn das Objekt nur auf die fest vorgegebenen Komponenten zugreift, nutzen wir diese Flexibilität nicht zu unserem Vorteil.

Falls außenstehender Code die Komponenten bereitstellt:

Das Objekt ist flexibler. Wir können das Verhalten des Objekts komplett ändern, indem wir ihm neue Komponenten bereitstellen. Im Extremfall wird das Objekt zu einem generischen Komponenten-Container, der immer wieder für andere Aufgaben verwendet werden kann.

Das Objekt kann von den konkreten Komponenten entkoppelt werden. Wenn wir es außenstehendem Code gestatten, Komponenten zu übergeben, haben wir die Möglichkeit, auch abgeleitete Komponenten zu übergeben. Dem Objekt sind in diesem Fall nur die Interfaces bekannt, nicht die konkreten Komponenten selbst. Dadurch wird eine fein säuberlich gekapselte Architektur ermöglicht.

14.6.2  Wie kommunizieren die Komponenten untereinander?

Perfekt entkoppelte Komponenten, die isoliert funktionsfähig bleiben, sind ein erstrebenswertes Ideal, in der Praxis jedoch kaum realisierbar. Die Tatsache, dass die Komponenten Bestandteil desselben Objekts sind, schließt mit ein, dass sie Teil eines größeren Ganzen sind und aufeinander abgestimmt werden müssen.

Wie also können die Komponenten miteinander in Verbindung treten? Es gibt verschiedene Möglichkeiten, die sich im Gegensatz zu den meisten anderen Designalternativen in diesem Buch nicht gegenseitig ausschließen – Sie werden wahrscheinlich mehr als eine davon gleichzeitig in Ihrem Design einsetzen.

Durch Modifizierung des Container-Objektzustands:

Die Komponenten bleiben entkoppelt. Wenn InputComponent Bjørns Geschwindigkeit ändert und die Physik-Engine diese Änderung später beachtet, ist keiner der Komponenten die Existenz der anderen bekannt. Nach ihrem Wissen könnte Bjørns Geschwindigkeit auch durch schwarze Magie geändert worden sein.

Die von Komponenten bereitgestellten Informationen müssen auch im Container-Objekt gespeichert werden. Meist ist das Speichern eines Zustands nur für eine Teilmenge der Komponenten erforderlich. Beispielsweise könnten die Animations- und die Rendering-Komponenten grafikspezifische Informationen gemeinsam nutzen. Solche Informationen im Container-Objekt zu speichern, wo sämtliche Komponenten darauf zugreifen können, verwischt die Grenzen der Objektklasse.

Es kann sogar noch schlimmer kommen, wenn dasselbe Container-Objekt für unterschiedlich konfigurierte Komponenten verwendet wird, weil dann womöglich Speicherplatz zum Speichern von Zuständen verschwendet wird, die von keiner der Komponenten benötigt werden – wenn nämlich Rendering-Daten im Container-Objekt gespeichert werden, vergeuden alle unsichtbaren Objekte sinnlos Speicherplatz.

Die Kommunikation findet implizit statt und hängt von der Reihenfolge ab, in der die Komponenten verarbeitet werden. Im Beispielcode der ursprünglichen monolithischen update()-Methode war die Reihenfolge der Operationen sorgfältig arrangiert. Zuerst modifiziert der Benutzer die Geschwindigkeit, daraufhin wird sie von der Physik-Engine verwendet, um die Position anzupassen, worauf wiederum der Rendering-Code zugreift, um Bjørn an der neuen Position zu zeichnen. Bei der Aufspaltung in Komponenten mussten wir sorgfältig darauf achten, diese Reihenfolge beizubehalten.

Lässt man die Reihenfolge außer Acht, führt dies zu subtilen, schwer aufzuspürenden Bugs. Wenn wir beispielsweise zuerst die Grafikkomponente aktualisieren, wird Bjørn an seiner Position im letzten Frame dargestellt, nicht im aktuellen. Stellen Sie sich nun einige weitere Komponenten und eine Menge mehr Code vor und Sie bekommen einen Eindruck davon, wie schwierig es sein kann, derartige Bugs zu verhindern.

Gemeinsam genutzte veränderliche Zustände wie dieser, auf die viele verschiedene Teile des Codes lesend und schreibend zugreifen, sind bekanntermaßen schwer in den Griff zu bekommen. Unter anderem aus diesem Grund verbringen die Informatikgelehrten ihre Zeit mit der Erforschung rein funktionaler Sprachen wie Haskell, in denen es keine veränderlichen Zustände gibt.

Durch direkte Referenzierung:

Die Komponenten, die miteinander kommunizieren müssen, erhalten Referenzen aufeinander, damit sie nicht das Container-Objekt verwenden müssen. Nehmen wir an, wir wollen Bjørn springen lassen. Dazu muss der Grafik-Code wissen, ob er mittels eines speziellen Sprites dargestellt werden soll oder nicht. Das kann er feststellen, indem er die Physik-Engine befragt, ob Bjørn sich gerade am Boden befindet. Zu diesem Zweck muss die Grafikkomponente von der Physik-Engine wissen:

class BjornGraphicsComponent
{
public:
  BjornGraphicsComponent(
      BjornPhysicsComponent* physics)
  : physics_(physics)
  {}

  void Update(GameObject& obj, Graphics& graphics)
  {
    Sprite* sprite;
    if (!physics_->isOnGround())
    {
      sprite = &spriteJump_;
    }
    else
    {
      // Vorhandener Grafik-Code ...
    }

    graphics.draw(*sprite, obj.x, obj.y);
  }

private:
  BjornPhysicsComponent* physics_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
  Sprite spriteJump_;
};

Bei der Erzeugung von Bjørns GraphicsComponent übergeben wir eine Referenz auf die zugehörige PhysicsComponent.

Diese Vorgehensweise ist schnell und einfach. Die Kommunikation findet mittels eines direkten Methodenaufrufs von einem Objekt zum anderen statt. Die Komponente kann jede beliebige Methode derjenigen Komponente aufrufen, deren Referenz sie besitzt. Methoden satt!

Die beiden Komponenten sind eng gekoppelt. Dies ist der Nachteil davon, dass alle Methoden zugänglich sind. Im Grunde genommen haben wir uns einen Schritt zurück in Richtung der monolithischen Klasse bewegt. Ganz so schlimm wie bei der ursprünglichen einzelnen Klasse verhält es sich jedoch nicht, denn die Kopplung ist immerhin auf die Komponentenpaare beschränkt, die miteinander interagieren müssen.

Durch die Übermittlung von Nachrichten:

Diese Alternative ist die komplexeste. Wir können in das Container-Objekt ein kleines Benachrichtigungssystem einbauen, um es den Komponenten zu gestatten, Informationen miteinander auszutauschen.

Im Folgenden wird eine mögliche Implementierung demonstriert. Zunächst definieren wir ein einfaches Interface, das alle Komponenten implementieren müssen:

class Component
{
public:
  virtual ~Component() {}
  virtual void receive(int message) = 0;
};

Die Komponenten-Klassen müssen eine receive()-Methode implementieren, um eingehende Nachrichten zu empfangen. Wir verwenden hier einfach ein int zur Identifizierung einer Nachricht, bei einer vollständigeren Implementierung könnten natürlich weitere Daten übermittelt werden.

Dann fügen wir dem Container-Objekt eine Methode zum Versenden von Nachrichten hinzu:

class ContainerObject
{
public:
  void send(int message)
  {
    for (int i = 0; i < MAX_COMPONENTS; i++)
    {
      if (components_[i] != NULL)
      {
        components_[i]->receive(message);
      }
    }
  }

private:
  static const int MAX_COMPONENTS = 10;
  Component* components_[MAX_COMPONENTS];
};

Nun kann eine Komponente mit Zugriff auf ihren Container Nachrichten an ihn senden, die er an alle anderen Komponenten weiterleitet. (Das schließt auch die sendende Komponente ein – geben Sie Acht, dass Sie hier nicht über eine Rückkopplungsschleife stolpern!)

Wenn Sie das Benachrichtigungssystem richtig raffiniert gestalten möchten, können Sie sogar eine Nachrichtenwarteschlange einrichten. Mehr dazu im Kapitel 15 Event Queue (Ereigniswarteschlange).

Das Vorhandensein des Benachrichtigungssystems hat verschiedene Konsequenzen:

Geschwisterkomponenten werden entkoppelt. Da die Komponenten über ihr Elternobjekt (das Container-Objekt) in Verbindung treten, bleiben sie voneinander entkoppelt. Bei diesem System besteht die Kopplung nur aus den Nachrichten selbst.#

Die Gang of Four bezeichnet das als Mediator-Pattern (Vermittler): Zwei oder mehr Objekte, die indirekt miteinander kommunizieren, indem sie Nachrichten über ein zwischengeschaltetes Objekt leiten. In diesem Fall ist das Container-Objekt selbst der Vermittler.

Das Container-Objekt ist besonders einfach. Im Gegensatz zur gemeinsamen Nutzung des Zustands, bei der das Container-Objekt selbst Kenntnis von den Daten der Komponenten hat, werden hier alle Nachrichten blindlings weitergeleitet. Auf diese Weise können zwei Komponenten auch sehr bereichsspezifische Informationen untereinander austauschen, ohne das Container-Objekt damit zu belasten.

Dass es unter all diesen Möglichkeiten keine »beste« Lösung gibt, ist nicht verwunderlich – letzten Endes werden Sie wahrscheinlich alle ein wenig verwenden. Der gemeinsam genutzte Zustand ist für die wirklich elementaren Dinge geeignet, über die normalerweise jedes Objekt verfügt, wie z.B. Position und Größe.

Manche Bereiche sind zwar eigenständig, aber eng miteinander verwandt. Denken Sie dabei etwa an Animation und Rendering, Benutzereingaben und KI oder Physik-Engine und Kollisionen. Falls Sie separate Komponenten für die jeweiligen Hälften dieser Paare verwenden, werden Sie womöglich feststellen, dass es am einfachsten ist, wenn jede der Hälften Kenntnis von ihrem Gegenstück erhält.

Benachrichtigungen erweisen sich bei »weniger wichtiger« Kommunikation als nützlich. Man braucht sich nach dem Versand einer Nachricht nicht weiter um sie zu kümmern – das ist in manchen Situationen praktisch, beispielsweise wenn eine Audiokomponente einen Sound wiedergeben soll, nachdem eine Physikkomponente gemeldet hat, dass das Objekt mit irgendetwas kollidiert ist.

Ich empfehle wie üblich, mit etwas Einfachem anzufangen und dann bei Bedarf weitere Kommunikationswege hinzuzufügen.

14.7  Siehe auch ...

Die GameObject-Klasse des Unity-Frameworks ist vollständig für den Gebrauch von Komponenten ausgelegt.

Die Open-Source-Engine Delta3D bringt eine GameActor-Klasse mit, die das Pattern mittels einer passend benannten ActorComponent-Basisklasse implementiert.

Microsofts XNA-Spiele-Framework verfügt über eine Game-Klasse mit einer Sammlung von GameComponent-Objekten. Während die Komponenten in unserem Beispiel auf der Ebene der einzelnen Spielobjekte verwendet werden, implementiert XNA das Pattern auf der Ebene des Haupt-Spielobjekts, der Zweck ist jedoch derselbe.

Dieses Pattern weist Ähnlichkeiten zum Strategy (Strategie)-Pattern der Gang of Four auf. Bei beiden Patterns geht es darum, Teile des Verhaltens eines Objekts an ein separates untergeordnetes Objekt zu delegieren. Der Unterschied besteht darin, dass beim Strategy-Pattern das »Strategie«-Objekt für gewöhnlich zustandslos ist – es kapselt einen Algorithmus, aber keine Daten. Es definiert das Verhalten des Objekts, nicht dessen Inhalt.

Komponenten sind demgegenüber etwas »wichtigtuerischer«. Sie speichern häufig einen Zustand, der das Objekt beschreibt und dabei hilft, dessen eigentliche Identität zu definieren. Allerdings verschwimmen die Grenzen hier leicht. Manche Komponenten benötigen gar keinen lokalen Zustand. In diesem Fall steht es Ihnen frei, dieselbe Komponenten-Instanz in mehreren Container-Objekten zu verwenden. Unter diesen Umständen verhält sich das Pattern eher wie das Strategy-Pattern.

Kapitel 15: Event Queue (Ereigniswarteschlange)

Entkopplung des Zeitpunkts der Übermittlung einer Nachricht oder eines Ereignisses vom Zeitpunkt der Verarbeitung.

15.1  Motivation

Sofern Sie nicht gerade hinter dem Mond leben und keinen Internetzugang haben, dürfte Ihnen der Begriff »Ereigniswarteschlange« schon mal begegnet sein. Falls nicht, sagen Ihnen die Begriffe »Nachrichtenwarteschlange« oder »Ereignisschleife« vielleicht etwas. Zur Auffrischung der Erinnerung lassen wir einige der typischen Ausprägungen dieses Patterns noch mal Revue passieren.

In diesem Kapitel verwende ich »Ereignis« und »Nachricht« bzw. »Benachrichtigung« größtenteils synonym. Sollte es Unterschiede geben, weise ich darauf hin.

15.1.1  Ereignisschleife der grafischen Benutzeroberfläche

Falls Sie schon mal ein User Interface programmiert haben, dürften Ihnen Ereignisse bzw. Events wohlvertraut sein. Bei jeder Interaktion des Benutzers mit einem Programm – Mausklick, Menü aufklappen oder Tastendruck – löst das Betriebssystem ein Ereignis aus, das dem Programm übergeben wird. Ihre Aufgabe ist es, darauf zu reagieren und entsprechende Aktionen zu veranlassen.

Diese Art der Programmierung ist so verbreitet, dass sie als Leitbild betrachtet wird und die Bezeichnung ereignisgesteuerte Programmierung trägt.

Für den Empfang dieser Benachrichtigungen gibt es tief im Inneren Ihres Codes eine Ereignisschleife (engl. Event Loop), die ungefähr so aussieht:

while (running)
{
  Event event = getNextEvent();
  // Ereignis verarbeiten ...
}

Der Aufruf von getNextEvent() stellt Ihrem Programm eine noch unverarbeitete Benutzereingabe zur Verfügung, die Sie dann zur Verarbeitung an einen Event Handler weiterleiten – und Ihr Programm wird wie von Zauberhand zum Leben erweckt. Das Interessante daran ist, dass Ihr Programm selbst entscheidet, wann es die Ereignisse abruft. Das Betriebssystem führt mitnichten sofort irgendwelchen Code in Ihrem Programm aus, wenn der Benutzer an einem Peripheriegerät herumfummelt.

Im Gegensatz dazu funktionieren Interrupts tatsächlich auf diese Art und Weise: Beim Auftreten eines Interrupts unterbricht das Betriebssystem die Tätigkeit Ihres Programms und erzwingt die Ausführung des Interrupt Handlers. Diese Abruptheit macht es so schwierig, Interrupts einzusetzen.

Die Benutzereingabe muss also irgendwo zwischengelagert werden, damit sie nicht verlorengeht, bis Ihr Programm dazu kommt, sie durch den Aufruf von getNextEvent() zu verarbeiten, nachdem der Gerätetreiber sie vermeldet hat. Dieses »Irgendwo« ist eine Warteschlange.

Abb. 15.1: Eine Reihe von Betriebssystemereignissen steht bei Ihrem Programm Schlange.

Sobald eine Benutzereingabe erfolgt, reiht das Betriebssystem sie in eine Warteschlange unverarbeiteter Ereignisse ein. Der Aufruf von getNextEvent() entnimmt dieser Warteschlange das älteste Ereignis und übergibt es Ihrem Programm.

15.1.2  Zentrale Ereignissammlung

Die meisten Spiele sind nicht auf diese Weise ereignisgesteuert, allerdings ist es üblich, dass ein Spiel eine eigene Ereigniswarteschlange besitzt, die den Programmablauf regelt. Zu deren Beschreibung werden häufig Begriffe wie »zentral«, »global« oder »Haupt-xyz« verwendet. Sie dient zur Kommunikation von Programmteilen, die entkoppelt bleiben sollen.

Wenn Sie daran interessiert sind, warum Spiele nicht ereignisgesteuert sind, sollten Sie sich das Kapitel 9 Game Loop (Hauptschleife) ansehen.

Nehmen wir an, Ihr Spiel verfügt über ein Hilfesystem, das nach dem Auftreten bestimmter Ereignisse innerhalb des Spiels Hinweise anzeigen soll. Beispielsweise soll eine Sprechblase mit dem Text »Drücke X, um die Beute zu ergreifen!« angezeigt werden, sobald der Spieler erstmals ein garstiges Biest bezwungen hat.

Es ist äußerst mühsam, Hilfesysteme elegant zu implementieren und die meisten Spieler nutzen sie auch kaum, daher stellt sich die Frage, ob sich der Aufwand überhaupt lohnt. Wenn sie jedoch genutzt werden, können sie für den Spieler von unschätzbarem Wert sein, um mit Ihrem Spiel vertraut zu werden.

Der Code zur Handhabung des Spielablaufs und zur Steuerung von Gefechten ist schon kompliziert genug. Hier auch noch eine Reihe von Abfragen einzubauen, die Hilfetexte anzeigen, ist wohl das Letzte, was man sich wünscht. Verwenden Sie stattdessen eine zentrale Ereigniswarteschlange. Damit können alle Programmteile Nachrichten übermitteln und der Gefechtscode kann jedes Mal, wenn Sie einen Gegner erledigen, ein »Feind getötet«-Ereignis auslösen.

Umgekehrt können die verschiedenen Programmteile auch Nachrichten von der Ereigniswarteschlange empfangen. Das Hilfesystem kann sich bei der Warteschlange anmelden und angeben, dass es von »Feind getötet«-Ereignissen unterrichtet werden möchte. Auf diese Weise wandert die Kenntnis vom Ableben eines Feindes vom Gefechtssystem zum Hilfesystem, ohne dass die beiden Systeme voneinander wissen müssen.

Abb. 15.2: Gefecht und Hilfesystem interagieren mittels einer gemeinsam genutzten Warteschlange

Dieses Modell, bei dem es einen zentralen Ort gibt, an dem Objekte gemeinsam genutzte Informationen ablegen können und davon unterrichtet werden, ähnelt der Blackboard-Datenstruktur im KI-Umfeld.

Ich habe darüber nachgedacht, dieses Beispiel im verbleibenden Teil dieses Kapitels weiterzuverwenden, allerdings bin ich im Allgemeinen jedoch kein Anhänger großer, globaler Systeme. Es muss nicht sein, dass Ereigniswarteschlangen zur Kommunikation im Rahmen der gesamten Spiel-Engine eingesetzt werden. Sie sind in einer einzelnen Klasse oder einem einzelnen Bereich ebenso nützlich.

15.1.3  Wie bitte?

Fügen wir dem Spiel stattdessen Sound hinzu! Menschen orientieren sich zwar vornehmlich visuell, aber der Hörsinn ist eng mit Emotionen und der räumlichen Wahrnehmung verknüpft. Mit einem gut simulierten Echo wirkt ein dunkler Bildschirm wie eine riesige Höhle und ebenso kann ein zum richtigen Zeitpunkt erklingendes Adagio einer Violine die richtige Saite in Ihnen anschlagen.

Um Sound in das Spiel einzubauen, probieren wir zunächst einmal aus, wie gut der einfachste Ansatz funktioniert. Wir fügen eine kleine »Audio-Engine« hinzu, die eine API zum Abspielen eines Sounds durch Angabe eines Bezeichners und einer Lautstärke bereitstellt:

class Audio
{
public:
  static void playSound(SoundId id, int volume);
};

Eigentlich scheue ich vor dem Singleton-Pattern (Kapitel 6) fast immer zurück, in diesem Fall wäre es aber wohl durchaus geeignet, weil der Computer wahrscheinlich nur ein Paar Lautsprecher besitzt. Ich verwende hier einen einfacheren Ansatz und benutze eine statische Methode.

Sie ist dafür zuständig, die angegebene Soundressource zu finden und sie auf einem verfügbaren Audiokanal wiederzugeben. Es geht hier nicht um die Audio-API einer echten Plattform, daher werde ich eine herbeizaubern, von der wir annehmen dürfen, dass sie sonstwo implementiert ist. Damit sieht die Methode folgendermaßen aus:

void Audio::playSound(SoundId id, int volume)
{
  ResourceId resource = loadSound(id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, volume);
}

Wir checken den Code ein, erstellen einige Sounddateien und verstreuen wie eine märchenhafte Klangfee einige playSound()-Aufrufe in der Codebasis. Wir könnten beispielsweise ein kleines »Plopp« abspielen, wenn sich der ausgewählte Menüeintrag ändert:

class Menu
{
public:
  void onSelect(int index)
  {
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
    // Weiterer Code ...
  }
};

Nachdem das erledigt ist, stellen wir fest, dass hin und wieder beim Wechseln des Menüeintrags der gesamte Bildschirm einige Frames lang »einfriert«. Wir sind auf die erste Schwierigkeit gestoßen:

Problem Nr. 1: Die API blockiert den Aufrufer, bis die Audio-Engine das Abspielen des Sounds abgeschlossen hat.

Die playSound()-Methode ist synchron – sie übergibt die Kontrolle erst wieder an den Aufrufer zurück, wenn die Lautsprecher das »Plopp« schon wiedergeben. Falls eine Sounddatei erst von der Festplatte eingelesen werden muss, kann das ein Weilchen dauern. Währenddessen friert das übrige Spiel ein.

Fürs Erste belassen wir es dabei und fahren fort. Im KI-Code fügen wir einen Aufruf hinzu, der ein jammerndes Wehklagen abspielt, wenn der Spieler einen Gegner verletzt – das Herz eines jeden Spielers schlägt höher, wenn er einer virtuellen Kreatur Schmerzen hinzufügen kann.

Das funktioniert zwar, doch hin und wieder kommt es vor, dass der Spieler bei einem wilden Angriff in ein und demselben Frame zwei Gegner trifft. Das veranlasst das Spiel, den jammernden Sound doppelt und gleichzeitig abzuspielen. Falls Sie sich mit Audio auskennen, wissen Sie auch, dass beim Zusammenmischen von Tönen die Wellenformen addiert werden. Wenn die summierten Wellenformen jedoch identisch sind, wird der Sound nur einmal, dafür aber in doppelter Lautstärke wiedergegeben. Es ist fürchterlich laut.

Auf genau dieses Problem bin ich bei der Arbeit am Spiel Henry Hatsworth in the Puzzling Adventure gestoßen. Meine damalige Lösung ist der hier vorgestellten sehr ähnlich.

Ein ähnliches Problem tritt bei Massenschlägereien auf, wenn haufenweise Lakaien des Gegners herumrennen und Chaos verursachen. Die Hardware kann nur eine begrenzte Anzahl von Klängen gleichzeitig wiedergeben. Wird diese Grenze überschritten, kommt es zu abgehackten oder gar nicht abgespielten Sounds.

Um solche Schwierigkeiten zu umgehen, müssen wir die Gesamtheit der Soundaufrufe kennen, damit wir sie sammeln und priorisieren können. Leider verarbeitet unsere Sound-API alle playSound()-Aufrufe unabhängig voneinander. Sie »sieht« die Aufrufe wie durch ein Schlüsselloch – immer nur einen gleichzeitig.

Problem Nr. 2: Aufrufe können nicht zusammengefasst ausgeführt werden.

Solche Schwierigkeiten sind nur lästig im Vergleich zu dem Problem, das nun auf uns zukommt. Bislang haben wir playSound()-Aufrufe überall in der Codebasis in alle möglichen Programmteile eingestreut. Unsere Spiel-Engine läuft jedoch auf moderner Hardware mit Mehrkernprozessoren. Um Nutzen aus den zusätzlichen Kernen zu ziehen, werden die Programmteile auf verschiedene Threads verteilt – einen für das Rendering, einen weiteren für die KI usw.

Da unsere API synchron ist, wird ihr Code im aufrufenden Thread ausgeführt. Aufrufe von verschiedenen Programmteilen aus bedeuten also, dass die API von parallel laufenden Threads aus angesprochen wird. Ist für Sie im Code irgendeine Form der Threadsynchronisierung erkennbar? Für mich auch nicht.

Das Ganze ist besonders ungeheuerlich, weil wir ja eigentlich, einen separaten Audio-Thread beabsichtigt hatten – aber der fristet gelangweilt sein Dasein und dreht Däumchen, während die anderen Threads damit beschäftigt sind, sich gegenseitig in die Quere zu kommen.

Problem Nr. 3: Die Aufrufe werden im falschen Thread verarbeitet.

All diesen Problemen ist gemeinsam, dass die Audio-Engine einen playSound()-Aufruf als »Lass alles stehen und liegen und spiel unverzüglich den Sound ab« interpretiert. Diese sofortige Erledigung stellt das Problem dar. Die anderen Programmteile rufen playSound() irgendwann nach Gutdünken auf, nicht zu Zeitpunkten, die für die Audio-Engine gut geeignet wären. Um das zu ändern, müssen wir den Empfang eines Aufrufs von dessen Verarbeitung entkoppeln.

15.2  Das Pattern

Eine Warteschlange (Queue) speichert eine Sequenz von Benachrichtigungen oder Anfragen in eintreffender Reihenfolge (First In – First Out). Das Versenden einer Benachrichtigung reiht diese in die Warteschlange ein und gibt die Kontrolle sofort an den Aufrufer zurück. Die in der Warteschlange befindlichen Anfragen werden zu einem späteren Zeitpunkt verarbeitet. Anfragen können direkt verarbeitet oder weitergeleitet werden. Dadurch werden Sender und Empfänger sowohl statisch als auch zeitlich entkoppelt.

15.3  Anwendbarkeit

Wenn Sie lediglich den Empfänger der Benachrichtigung vom Absender entkoppeln möchten, können Patterns wie Observer (Beobachter, Kapitel 4) oder Command (Befehl, Kapitel 2) diese Aufgabe auf unkompliziertere Weise erledigen. Eine Warteschlange ist nur erforderlich, wenn etwas zeitlich entkoppelt werden soll.

Ich weise zwar in fast jedem Kapitel darauf hin, es lohnt jedoch, es auch an dieser Stelle erneut zu betonen: Komplexität ist hinderlich. Betrachten Sie die Einfachheit daher als kostbare Ressource.

Ich stelle mir diesen Vorgang als ein Hinterlegen und Abholen vor. Der Codeabschnitt A möchte, das Codeabschnitt B eine Aufgabe für ihn erledigt, ohne direkt mit ihm in Verbindung zu treten. Dann ist es naheliegend, dass A dies dadurch veranlasst, dass er in der Warteschlange eine Anfrage an B hinterlegt.

Für B wiederum ist es naheliegend, die Anfrage abzuholen, wenn ein günstiger Zeitpunkt gekommen ist. Bei diesem Modell wird ein Ort zur Zwischenlagerung der Anfrage benötigt, und die Warteschlange stellt anders als einfachere Entkopplungs-Patterns solch einen Ort bereit.

Warteschlangen überlassen dem Code, der die Anfragen abholt, die Kontrolle. Der Empfänger kann die Verarbeitung verzögern, Anfragen ansammeln oder sie komplett verwerfen – das geschieht jedoch auf Kosten des Absenders, dem die Kontrolle entzogen wird. Er kann lediglich eine Anfrage in die Warteschlange einreihen und auf das Beste hoffen. Daher sind Warteschlangen ungeeignet, wenn der Absender auf eine schnelle Antwort angewiesen ist.

15.4  Konsequenzen

Im Gegensatz zu einigen der genügsameren Patterns in diesem Buch sind Ereigniswarteschlangen komplex und haben tendenziell weitreichende Auswirkungen auf die Architektur eines Spiels. Sie müssen daher gründlich darüber nachdenken, wie – oder ob – Sie eine einsetzen.

15.4.1  Eine zentrale Ereigniswarteschlange ist eine globale Variable

Häufig wird dieses Pattern wie eine Art Hauptumschlagplatz genutzt, über den alle Programmteile miteinander kommunizieren können. Es ist ein wirklich mächtiges Werkzeug – wobei mächtig jedoch nicht notwendigerweise auch gut bedeutet.

Auch wenn es ein Weilchen gedauert hat, so mussten die meisten von uns doch auf die harte Tour erfahren, dass globale Variablen ein Übel sind. Wenn es einen Zustand gibt, auf den sämtliche Programmteile Einfluss nehmen können, schleichen sich alle möglichen gegenseitigen Abhängigkeiten ein. Durch dieses Pattern wird der Zustand zwar in ein schickes kleines Protokoll verpackt, er bleibt jedoch global – und damit sind bekanntermaßen verschiedene Gefahren verbunden.

15.4.2  Den Boden unter den Füßen verlieren

Nehmen wir an, dass der KI-Code ein »Feind getötet«-Ereignis in eine Warteschlange einreiht, sobald ein virtueller Lakai das Zeitliche segnet. Das Ereignis hängt nun wer weiß wie viele Frames in der Warteschlange fest, bis es irgendwann an die Reihe kommt und verarbeitet wird.

Unterdessen möchte das Erfahrungssystem des Programms wissen, wie viele Opfer unsere Heldin bereits zu verbuchen hat, damit es ihre mörderische Effizienz entsprechend honorieren kann. Also erhält es alle »Feind getötet«-Ereignisse und ermittelt die Art der erschlagenen Gegner sowie den Schwierigkeitsgrad der Meucheleien, um eine entsprechende Anerkennung der Leistung auszuteilen.

Zu diesem Zweck muss auf verschiedene Zustände der Spielwelt zugegriffen werden. Wir benötigen die Art des verstorbenen Gegners, um zu beurteilen, wie widerstandsfähig er war. Möglicherweise muss auch die Umgebung untersucht werden, um festzustellen, welche anderen Hindernisse oder Lakaien in der Nähe waren. Wenn das »Feind getötet«-Ereignis jedoch erst später verarbeitet wird, sind diese Informationen womöglich nicht mehr vorhanden: Das zugehörige Objekt wurde schon aus dem Arbeitsspeicher entfernt und die anderen in der Nähe befindlichen Feinde sind vielleicht schon abgewandert.

Wenn Sie ein Ereignis erhalten, dürfen Sie nicht dem Irrtum unterliegen, dass der aktuelle Zustand der Spielwelt derselbe ist wie zu dem Zeitpunkt, als das Ereignis ausgelöst wurde. Dies bedeutet, dass die Ereignisse in einer Warteschlange tendenziell datenlastiger sind als die Ereignisse synchroner Systeme. Bei Letzteren besagt die Nachricht »Dies und jenes ist passiert« und der Empfänger kann sich die Details näher ansehen. Im Fall einer Warteschlange müssen diese Details unmittelbar beim Auslösen des Ereignisses erfasst werden, um später darauf zugreifen zu können.

15.4.3  Steckenbleiben in Rückkopplungsschleifen

Bei auf Ereignissen oder Benachrichtigungen beruhenden Systemen muss man sich Gedanken über unbeabsichtigte Schleifen machen:

A versendet ein Ereignis.

B wird davon benachrichtigt und reagiert darauf, indem es seinerseits ein Ereignis versendet.

Zufälligerweise ist A an dem von B versendeten Ereignis interessiert und wird davon benachrichtigt. A reagiert darauf, indem es ein Ereignis sendet.

Gehe zu Punkt 2.

In synchronen Benachrichtigungssystemen sind solche Schleifen leicht aufzufinden – der Stack läuft über und das Spiel stürzt ab. Bei einer Warteschlange wird der Stack regelmäßig geleert und das Spiel läuft weiter, obwohl die dubiosen Ereignisse darin hin- und herschwappen. Um das zu verhindern, wird üblicherweise im Code, der auf Ereignisse reagiert, darauf verzichtet, Ereignisse zu versenden.

Beim Debuggen ein wenig zu protokollieren, ist vermutlich auch keine schlechte Idee.

15.5  Beispielcode

Etwas Code haben wir schon beisammen. Er ist zwar noch nicht perfekt, besitzt jedoch die richtige grundlegende Funktionalität – die erwünschte öffentliche API sowie die korrekten maschinennahen Aufrufe der Audiofunktionen. Uns bleibt nur noch, die Probleme zu beheben.

Zunächst einmal blockiert unsere API den Ablauf. Wenn ein Codeabschnitt einen Sound abspielt, können keine anderen Aufgaben erledigt werden, bis playSound() das Laden der Ressource abgeschlossen hat und die Lautsprecher zum Wackeln bringt.

Diese Tätigkeiten sollen auf später verschoben werden, damit die playSound()-Methode die Kontrolle sofort wieder an den Aufrufer übergibt. Zu diesem Zweck müssen wir die Anfrage zum Abspielen eines Sounds reifizieren. Dazu benötigen wir eine kleine Struktur, in der wir die Details einer in Wartestellung befindlichen Anfrage speichern, damit später darauf zugegriffen werden kann:

struct PlayMessage
{
  SoundId id;
  int volume;
};

Als Nächstes müssen wir der Audio-Klasse etwas Speicherplatz für die in Wartestellung befindlichen PlayMessages bereitstellen. Nun wird Ihr Professor für Algorithmen vielleicht sagen, dass hier faszinierende Datenstrukturen wie ein Fibonacci-Heap, eine Skip-Liste oder, zum Teufel auch, wenigstens eine einfache verkettete Liste angemessen seien.

Algorithmenforscher werden dafür bezahlt, dass sie Analysen neuartiger Datenstrukturen veröffentlichen – und das schafft für sie nicht gerade einen Anreiz, bei den Grundlagen zu bleiben.

In der Praxis jedoch ist zum Speichern einiger gleichartiger Objekte fast immer das gute alte Array am besten geeignet:

Keine dynamische Speicherallokation

Kein Speicherbedarf für Buchführungszwecke oder Zeiger

Cache-freundliche Verwendung zusammenhängender Speicherbereiche

Was mit »Cache-freundlich« genau gemeint ist, wird im Kapitel 17 Data Locality (Datenlokalität) ausführlich dargelegt.

Los geht's:

class Audio
{
public:
  static void init() { numPending_ = 0; }

  // Weiterer Code ...

private:
    static const int MAX_PENDING = 16;
    static PlayMessage pending_[MAX_PENDING];
    static int numPending_;
};

Die Größe des Arrays lässt sich entsprechend des Worst-Case-Szenarios anpassen. Zwecks Wiedergabe eines Sounds fügen wir einfach am Ende eine neue Methode ein:

void Audio::playSound(SoundId id, int volume)
{
  assert(numPending_ < MAX_PENDING);

  pending_[numPending_].id = id;
  pending_[numPending_].volume = volume;
  numPending_++;
}

Diese playSound()-Version gibt die Kontrolle nahezu augenblicklich an den Aufrufer zurück, allerdings muss der Sound natürlich nach wie vor auch abgespielt werden. Der dazu erforderliche Code muss irgendwo hin, und zwar in eine update()-Methode.

class Audio
{
public:
  static void update()
  {
    for (int i = 0; i < numPending_; i++)
    {
      ResourceId resource = loadSound(
                            pending_[i].id);
      int channel = findOpenChannel();
      if (channel == -1) return;
      startSound(resource, channel,
                           pending_[i].volume);
    }

    numPending_ = 0;
  }

  // Weiterer Code ...
};

Wie der Name schon sagt, handelt es sich hier um das Pattern Update Method (Aktualisierungsmethode, Kapitel 10).

Nun müssen wir die update()-Methode nur noch von einer günstigen Stelle aus aufrufen. Was »günstig« im Einzelfall konkret bedeutet, hängt von Ihrem Spiel ab. Der Aufruf könnte etwa in der Game Loop (Hauptschleife, Kapitel 9) oder in einem speziellen Audio-Thread stattfinden.

Das funktioniert tadellos, setzt jedoch voraus, dass wir jeden Sound durch einen einzigen Aufruf der update()-Methode wiedergeben können. Wenn Sie versuchen, nach dem Laden der Soundressource eine Anfrage asynchron zu verarbeiten, würde das nicht funktionieren. Damit die update()-Methode die Anfragen der Reihe nach abarbeiten kann, muss sie diese aus einem Buffer entnehmen können. Mit anderen Worten: Wir benötigen eine Warteschlange.

15.5.1  Ein Ring-Buffer

Es gibt verschiedene Möglichkeiten, eine Warteschlange zu implementieren, allerdings habe ich einen Favoriten, der als Ring-Buffer bezeichnet wird. Er bewahrt alle Vorteile eines Arrays, ermöglicht es jedoch, die Elemente nach und nach am Anfang der Warteschlange zu entnehmen.

Ich weiß, was Sie jetzt denken: »Wenn die Elemente am Anfang des Arrays entnommen werden, müssen die übrigen Elemente doch alle nachrücken. Ist das nicht langsam?«

Aus diesem Grund hat man uns den Gebrauch verketteter Listen gelehrt – Sie können Knoten entfernen, ohne die Elemente verschieben zu müssen. Wie sich zeigen wird, kann man eine Warteschlange auch mittels eines Arrays implementieren, ohne dass die Elemente verschoben werden müssen. Ich werde das gleich schrittweise vorführen, zunächst aber müssen einige Begriffe präzisiert werden:

Der Kopf (Head) bezeichnet diejenige Anfrage in der Warteschlange, die als Nächstes entnommen wird. Hier befindet sich die älteste Anfrage in Wartestellung.

Der Schweif (Tail) der Warteschlange bezeichnet diejenige Position, an der die nächste Anfrage in die Warteschlange eingereiht wird. Beachten Sie hier, dass sich diese Position unmittelbar hinter dem Ende der vorhandenen Warteschlange befindet. Wenn Sie Mathematik mögen, können Sie sich das Ganze als ein halboffenes Intervall vorstellen, falls es Ihnen hilft.

Da die playSound()-Methode neue Anfragen am Ende des Arrays anfügt, befindet sich der Kopf an der Indexposition 0 und der Schweif wandert beim Hinzufügen von Anfragen nach rechts.

Abb. 15.3: Befüllen des Arrays mit Ereignissen

Das wird nun als Code formuliert. Dazu passen wir die Datenfelder ein wenig an, um die beiden Markierungen ausdrücklich in der Klasse anzugeben:

class Audio
{
public:
  static void init()
  {
    head_ = 0;
    tail_ = 0;
  }

  // Methoden ...

private:
  static int head_;
  static int tail_;

  // Array ...

};

In der playSound()-Implementierung wird numPending_ durch tail_ ersetzt, sonst ändert sich nichts:

void Audio::playSound(SoundId id, int volume)
{
  assert(tail_ < MAX_PENDING);
  // Am Ende der Liste anfügen
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_++;
}

Die Änderungen an der update()-Methode sind da schon interessanter:

void Audio::update()
{
  // Nichts tun, wenn es keine Anfragen
  // in Wartestellung gibt
  if (head_ == tail_) return;
  ResourceId resource = loadSound(
                        pending_[head_].id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel,
                       pending_[head_].volume);
  head_++;
}

Wir verarbeiten die am Kopf befindliche Anfrage und verwerfen sie dann, indem der Zeiger auf den Kopf nach rechts verschoben wird. Eine leere Warteschlange ist daran erkennbar, dass es zwischen Kopf und Schweif keinen Abstand gibt.

Deswegen verweist der Schweif auf die Position hinter der letzten Anfrage. Die Warteschlange ist dann leer, wenn Kopf und Schweif denselben Index besitzen.

Nun steht eine Warteschlange zur Verfügung – wir können am Ende Anfragen hinzufügen und vorne Anfragen entnehmen. Es gibt allerdings offensichtlich noch ein Problem: Bei Verwendung der Warteschlange wandern Kopf und Schweif immer weiter nach rechts. Irgendwann erreicht tail_ das Ende des Arrays und der Spaß ist vorbei. An dieser Stelle wird es interessant.

Sollte der Spaß hier wirklich vorbei sein? Natürlich nicht.

Abb. 15.4: Die Warteschlange wandert allmählich nach rechts und hinterlässt dabei leere Positionen im Array.

Beachten Sie hier, dass nicht nur der Schweif, sondern auch der Kopf nach rechts wandert. Das bedeutet, dass am Anfang des Arrays verfügbare Elemente vorhanden sind. Wir verschieben daher den Schweif einfach an den Anfang des Arrays, sobald er das Ende erreicht. Dies ist auch der Grund für die Bezeichnung Ring-Buffer: Er verhält sich so, als wären die Elemente des Arrays ringförmig angeordnet.

Abb. 15.5: Der Schweif springt an den Anfang des Arrays, sobald er dessen Ende erreicht.

Die Implementierung ist verblüffend einfach: Beim Einreihen einer Anfrage müssen wir nur dafür sorgen, dass der Schweif auf den Anfang des Arrays verweist, wenn er dessen Ende erreicht hat:

void Audio::playSound(SoundId id, int volume)
{
  assert((tail_ + 1) % MAX_PENDING != head_);

  // Am Ende der Liste anfügen
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_ = (tail_ + 1) % MAX_PENDING;
}

Zu diesem Zweck wird die Anweisung tail_++ durch eine Erhöhung um eins modulo der Länge des Arrays ersetzt. Die andere Änderung betrifft die Assertion. Wir müssen dafür Sorge tragen, dass die Warteschlange nicht überlaufen kann. Solange weniger als MAX_PENDING Anfragen vorhanden sind, gibt es im Array zwischen Kopf und Schweif eine kleine Lücke mit ungenutzten Elementen. Wenn die Warteschlange länger wird, wird diese Lücke immer kleiner und verschwindet schließlich. Wie eine Katze, die sich selbst in den Schwanz beißt, würde der Schweif dann den Kopf überschreiben. Durch die Assertion wird das verhindert.

Die update()-Methode wird ebenfalls dementsprechend angepasst:

void Audio::update()
{
  // Nichts tun, wenn es keine Anfragen
  // in Wartestellung gibt
  if (head_ == tail_) return;

  ResourceId resource = loadSound(
                        pending_[head_].id);

  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel,
                       pending_[head_].volume);

  head_ = (head_ + 1) % MAX_PENDING;
}

Und siehe da: eine Warteschlange ohne dynamische Speicherallokation, ohne Verschieben der Elemente und mit der Cache-Freundlichkeit eines einfachen Arrays.

Falls Ihnen die maximale Länge des Arrays ein Dorn im Auge ist, können Sie auch ein dynamisch wachsendes Array verwenden. Legen Sie ein Array der doppelten Größe (oder eines anderen Vielfachen) an, wenn die Warteschlange voll ist. Kopieren Sie dann die vorhandenen Elemente.

Auch wenn die Elemente hier kopiert werden müssen, ist für das Hinzufügen von Anfragen nach wie vor nur eine konstante Zeitspanne erforderlich.

15.5.2  Anfragen zusammenfassen

Nach Einrichtung der Warteschlange können wir uns den anderen Problemen widmen. Mehrere Anfragen zum Abspielen desselben Sounds führen zu einer zu lauten Wiedergabe. Da uns nun aber bekannt ist, welche Anfragen auf ihre Verarbeitung warten, brauchen wir nur noch übereinstimmende zusammenzufassen:

void Audio::playSound(SoundId id, int volume)
{
  // Wartende Anfragen durchlaufen
  for (int i = head_; i != tail_;
               i = (i + 1) % MAX_PENDING)
  {
    if (pending_[i].id == id)
    {
      // Größere der beiden Lautstärken verwenden
      pending_[i].volume = max(volume,
                           pending_[i].volume);
      // Einreihen ist nicht erforderlich
      return;
    }
  }
  // Schon vorhandener Code ...
}

Zwei Anfragen, denselben Sound abzuspielen, werden zu einer Anfrage zusammengefasst, den lauteren der beiden Sounds wiederzugeben. Dieses »Zusammenfassen« ist zwar noch ziemlich unterentwickelt, aber nach diesem Prinzip ließen sich auch interessantere Ideen umsetzen.

Beachten Sie hier, dass die Zusammenfassung bei der Einreihung der Anfrage in die Warteschlange stattfindet, nicht bei deren Verarbeitung. Die Warteschlange wird dadurch entlastet, weil kein Speicherplatz für redundante Anfragen verschwendet wird, die ohnehin zusammengefasst werden. Außerdem ist die Implementierung auch einfacher.

Allerdings muss dabei der Aufrufer die Verarbeitung vornehmen. Bei jedem playSound()-Aufruf wird die gesamte Warteschlange durchlaufen, und das könnte zu einer Verlangsamung führen, wenn sie lang ist. Insofern könnte es sinnvoller sein, die Zusammenfassung in der update()-Methode vorzunehmen.

Eine andere Möglichkeit, den gemäß O(n) wachsenden Aufwand beim Durchlaufen der Warteschlange zu vermeiden, ist der Einsatz einer anderen Datenstruktur. Bei Verwendung einer Hash-Tabelle mit SoundID als Schlüssel ließen sich Duplikate in konstanter Zeit finden.

Hier ist noch ein weiterer wichtiger Punkt zu beachten: Wir können höchstens so viele »gleichzeitige« Anfragen zusammenfassen, wie in der Warteschlange enthalten sind. Wenn die Verarbeitung von Anfragen schneller stattfindet und die Warteschlange kurz bleibt, bieten sich weniger Möglichkeiten zur Zusammenfassung. Umgekehrt lassen sich mehr Anfragen zusammenfassen, wenn die Verarbeitung nicht mit dem Eintreffen neuer Anfragen Schritt hält und die Länge der Warteschlange zunimmt.

Dieses Pattern verhindert, dass dem Anfragenden bekannt ist, wann seine Anfrage verarbeitet wird. Wenn Sie die Warteschlange als Datenstruktur zur Steuerung des Spielablaufs in Echtzeit einsetzen, kann die Verzögerung zwischen Anfrage und Verarbeitung zu einer spürbaren Beeinflussung des Verhaltens führen. Vergewissern Sie sich, dass Ihnen das keine Probleme bereitet, bevor Sie dieses Pattern anwenden.

15.5.3  Threads

Das übelste der Probleme kommt zum Schluss: Bei der synchronen Audio-API wird der playSound()-Aufruf von dem Thread verarbeitet, in dem der Aufruf stattfindet – das ist jedoch oft unerwünscht.

Bei den heutigen Mehrkernprozessoren sind mehrere Threads erforderlich, um die Hardware auszureizen. Es gibt zwar unendlich viele Möglichkeiten, Code auf verschiedene Threads zu verteilen, üblicherweise verfolgt man jedoch die Strategie, die unterschiedlichen Bereiche eines Spiels in eigene Threads zu verlagern – Audio, Rendering, KI usw.

Hintereinanderweg geschriebener Code wird auf nur einem Prozessorkern gleichzeitig ausgeführt. Selbst wenn Sie den asynchronen Programmierstil einsetzen, der derzeit in Mode ist: Ohne die Verwendung von Threads lasten Sie bestenfalls einen der Prozessorkerne aus. Sie nutzen dann nur einen Bruchteil der Leistungsfähigkeit der CPU.

Die Programmierer von Serveranwendungen machen dies wett, indem sie ihre Programme in mehrere unabhängige Prozesse aufteilen. Das ermöglicht es dem Betriebssystem, sie gleichzeitig auf verschiedenen Prozessorkernen auszuführen. Spiele bestehen fast immer aus nur einem Prozess, ein wenig Multithreading ist daher äußerst nützlich.

Wir sind nun gut gerüstet, um solch eine Aufteilung vorzunehmen, denn wir haben drei wichtige Schritte erledigt:

Der Code für Anfragen zum Abspielen von Sounds ist vom Code für deren Wiedergabe entkoppelt.

Wir haben eine Warteschlange programmiert, die Anfragen und Wiedergaben koordiniert.

Die Warteschlange ist dem übrigen Programm gegenüber gekapselt.

Wir müssen jetzt nur noch threadsichere playSound()- und update()-Methoden zum Modifizieren der Warteschlange erstellen. Normalerweise würde ich an dieser Stelle konkreten Code präsentieren, da es in diesem Buch jedoch um Architektur geht, möchte ich mich nicht durch die Details einer bestimmten API oder eines Sperrmechanismus in Schwierigkeiten bringen.

Ganz allgemein gesagt müssen wir sicherstellen, dass die Warteschlange nicht gleichzeitig mehrfach modifiziert werden kann. Da die playSound()-Methode nur sehr kurz aktiv ist – es werden ja im Wesentlichen nur einige Datenfelder zugewiesen –, kann sie Sperrungen vornehmen, ohne die Verarbeitung länger zu blockieren. In der update()-Methode müssen wir darauf warten, dass sich eine Variable ändert, die einen Zustand beschreibt, damit nicht unnötig CPU-Rechenzeit verschwendet wird, solange es keine Anfragen zu verarbeiten gibt.

15.6  Designentscheidungen

Viele Spiele verwenden Ereigniswarteschlangen als wichtigen Bestandteil der Kommunikationsstruktur, und Sie können eine Menge Zeit darauf verwenden, alle möglichen komplexen Verteilungsmethoden und Filter für Benachrichtigungen zu entwerfen. Bevor Sie jedoch loslegen und womöglich etwas in der Größenordnung der Vermittlungszentrale des Berliner Telefonnetzes einrichten, empfehle ich, doch lieber mit etwas Einfachem anzufangen. Hier einige Fragen, die Sie sich in diesem Zusammenhang stellen sollten:

15.6.1  Was soll in die Warteschlange aufgenommen werden?

Ich habe die Begriffe »Ereignis« und »Nachricht« bzw. »Benachrichtigung« bislang synonym verwendet, weil es meistens keine Rolle spielt: In Bezug auf die Entkopplung und Zusammenfassung stehen Ihnen dieselben Möglichkeiten offen – unabhängig davon, was Sie in die Warteschlange aufnehmen. Es gibt jedoch einige konzeptuelle Unterschiede.

Falls die Warteschlange Ereignisse aufnimmt:

»Ereignisse« oder »Benachrichtigungen« beschreiben Dinge, die bereits geschehen sind, wie z.B. »Monster ist gestorben«. Sie werden in die Warteschlange eingereiht, damit andere Objekte, ähnlich wie beim asynchronen Observer-Pattern (Beobachter, Kapitel 4), darauf reagieren können.

Sie werden vermutlich mehrere Abonnenten zulassen. Da die Warteschlange Dinge enthält, die bereits geschehen sind, ist es dem Absender vermutlich gleichgültig, wer von ihm gesendete Benachrichtigungen erhält. Aus seiner Perspektive ist das Ereignis vergangen und vergessen.

Der Gültigkeitsbereich der Warteschlange ist tendenziell größer. Ereigniswarteschlangen werden häufig dazu verwendet, Ereignisse mittels eines Broadcasts allgemein bekanntzugeben. Um eine maximale Flexibilität hinsichtlich der erreichbaren Abonnenten zu erzielen, sind solche Warteschlangen tendenziell globaler sichtbar.

Falls die Warteschlange Nachrichten aufnimmt:

»Nachrichten« oder »Anfragen« beschreiben Aktionen, die in der Zukunft geschehen sollen, wie z.B. »Sound wiedergeben«. Sie können sich das Ganze als asynchrone API eines Dienstes vorstellen.

Statt »Anfrage« könnte man auch wie beim Pattern Command (Befehl, Kapitel 2) »Befehl« sagen. Dort ist die Nutzung von Warteschlangen ebenfalls möglich.

Es ist wahrscheinlicher, dass es nur einen einzigen Abonnenten gibt. Im Beispiel sind die Anfragen zum Abspielen von Sounds speziell an die Audio-API gerichtet. Sollten irgendwelche anderen Programmteile plötzlich anfangen, in der Warteschlange befindliche Nachrichten zu stibitzen, wäre das alles andere als zielführend.

Ich spreche hier von »wahrscheinlicher«, weil Sie Nachrichten auch in die Warteschlange einreihen können, ohne sich darum zu kümmern, durch welchen Code sie verarbeitet werden – sofern sie denn so verarbeitet werden, wie Sie es erwarten. In diesem Fall wenden Sie ein Pattern an, das einem Service Locator (Dienstlokalisierung, Kapitel 16) ähnelt.

15.6.2  Wer darf lesend auf die Warteschlange zugreifen?

In unserem Beispiel ist die Warteschlange gekapselt – nur die Audio-Klasse darf lesend darauf zugreifen. Bei einer ereignisgesteuerten Benutzeroberfläche hingegen können Sie nach Herzenslust Abonnenten einrichten. Manchmal hört man zur Unterscheidung dieser Vorgehensweisen, die beide nützlich sind, die Bezeichnungen »Single-Cast« und »Broadcast«.

Single-Cast-Warteschlange:

Dies ist die naheliegende Lösung, wenn die Warteschlange Bestandteil der API einer Klasse ist. Aus Sicht des Aufrufers gibt es wie in unserem Audio-Beispiel lediglich eine playSound()-Methode, die aufgerufen werden kann.

Die Warteschlange wird zu einem Implementierungsdetail. Dem Absender ist lediglich bekannt, dass er die Nachricht versendet hat.

Die Warteschlange ist stärker gekapselt. Wenn sich sonst nichts ändert, ist eine stärkere Kapselung für gewöhnlich besser.

Sie brauchen sich nicht um konkurrierende Abonnenten zu kümmern. Falls es mehrere Abonnenten gibt, müssen Sie entscheiden, ob allen sämtliche Nachrichten bereitgestellt werden (Broadcast) oder ob Sie die einzelnen Nachrichten nur jeweils einem Abonnenten übermitteln (was eher einer Abarbeitungswarteschlange entspricht, die sogleich erläutert wird).

In diesen beiden Fällen kann es dazu kommen, dass die Abonnenten entweder redundante Arbeit verrichten oder miteinander in Konflikt geraten – und Sie müssen sorgfältig darüber nachdenken, welches Verhalten erwünscht ist. Bei nur einem Abonnenten entfällt diese Problematik.

Broadcast-Warteschlange:

Auf diese Weise funktionieren die meisten ereignisgesteuerten Systeme. Wenn es zehn Abonnenten gibt, werden beim Auftreten eines Ereignisses auch alle zehn davon benachrichtigt.

Ereignisse können unbeachtet bleiben. Aus dem soeben erwähnten Punkt ergibt sich die Schlussfolgerung, dass keine Benachrichtigung erfolgt, wenn es keine Abonnenten gibt. Die meisten Broadcast-Systeme verwerfen ein Ereignis, wenn es zum Zeitpunkt der Verarbeitung keine Abonnenten gibt.

Sie müssen möglicherweise nach Ereignissen filtern. Broadcast-Warteschlangen sind oftmals weiten Teilen des Programms zugänglich und es kann zuhauf Abonnenten geben. Wenn Sie nun viele Ereignisse mit vielen Abonnenten multiplizieren, wird deutlich, dass massenhaft Event Handler aufgerufen werden müssen.

Um die Anzahl auf ein vernünftiges Maß zu begrenzen, gestatten die meisten ereignisgesteuerten Systeme den Abonnenten, die Spreu vom Weizen zu trennen und nach bestimmten Ereignissen zu filtern. Beispielsweise könnte ein Abonnent nur an Mausereignissen oder nur an Ereignissen innerhalb eines bestimmten Bildschirmausschnitts interessiert sein.

Abarbeitungswarteschlange:

Ebenso wie bei einer Broadcast-Warteschlange gibt es auch hier mehrere Abonnenten. Der Unterschied besteht darin, dass jedes Element in der Warteschlange nur einem Abonnenten übermittelt wird. Dabei handelt es sich um ein gebräuchliches Pattern, um Aufgaben auf einen Pool parallel laufender Threads zu verteilen.

Sie müssen die Abarbeitung planen. Da jedes Element an nur einen Abonnenten übermittelt wird, ist eine Programmlogik erforderlich, damit die Warteschlange den geeignetsten auswählen kann. Zu diesem Zweck könnte ein einfaches Rundlaufverfahren oder eine zufällige Auswahl zum Einsatz kommen, es könnte jedoch auch ein komplexeres Priorisierungssystem Verwendung finden.

15.6.3  Wer darf schreibend auf die Warteschlange zugreifen?

Hierbei handelt es sich um die Ergänzung zur vorhergehenden Designentscheidung. Das Pattern unterstützt sämtliche möglichen Lesen/Schreiben-Kombinationen: 1-zu-1, 1-zu-n, n-zu-1 oder n-zu-n.

Nur ein Objekt darf schreiben:

Diese Vorgehensweise ist dem synchronen Observer-Pattern (Beobachter, Kapitel 4) am ähnlichsten: Ein privilegiertes Objekt erzeugt Ereignisse, die andere dann empfangen können.

Implizit ist bekannt, woher das Ereignis stammt. Da es nur ein Objekt gibt, das der Warteschlange Ereignisse hinzufügen darf, kann jeder Abonnent gefahrlos davon ausgehen, dass dieses der Absender ist.

Sie werden für gewöhnlich mehrere Abonnenten zulassen. Sie können durchaus eine Warteschlange mit nur einem Sender und einem Empfänger einrichten, allerdings entspricht das weniger dem Kommunikationssystem, um das es bei diesem Pattern geht, sondern eher der 08/15-Datenstruktur einer Warteschlange.

Mehrere Objekte dürfen schreiben:

Auf diese Weise funktioniert die Audio-Engine unseres Beispiels. Da playSound() eine öffentliche Methode ist, kann jeder Teil der Codebasis eine Anfrage in die Warteschlange einreihen. Das Sammeln »globaler« oder »zentraler« Ereignisse funktioniert auf ähnliche Weise.

Sie müssen auf unbeabsichtigte Schleifen achten. Da im Prinzip sämtlicher Code etwas in die Warteschlange einreihen kann, geschieht es leichter, dass während der Verarbeitung eines Ereignisses versehentlich ein Ereignis in der Warteschlange landet, das womöglich eine Rückkopplungsschleife verursacht.

Vermutlich soll das Ereignis selbst Informationen über den Absender enthalten. Wenn einem Abonnenten ein Ereignis übermittelt wird, kennt er den Absender nicht, weil praktisch jeder beliebige Programmteil es in die Warteschlange eingereiht haben könnte. Sollte es erforderlich sein, den Absender zu kennen, können Sie ihn im Ereignisobjekt selbst speichern, damit die Abonnenten darauf zugreifen können.

15.6.4  Wie lang ist die Lebensdauer der Objekte in der Warteschlange?

Bei einer synchronen Benachrichtigung wird die Kontrolle erst dann wieder an den Absender übergeben, wenn sämtliche Empfänger die Verarbeitung der Nachricht abgeschlossen haben. Das heißt, dass die Nachricht selbst gefahrlos in einer lokalen Variablen auf dem Stack gespeichert werden kann. Bei einer Warteschlange hingegen »überlebt« die Nachricht den Aufruf, der sie in die Warteschlange eingereiht hat.

Sollten Sie eine Programmiersprache mit Garbage Collection verwenden, brauchen Sie sich darüber keine allzu großen Sorgen zu machen: Reihen Sie die Nachricht einfach in die Warteschlange ein – sie bleibt so lange im Speicher erhalten, bis sie benötigt wird. In C oder C++ sind Sie selbst dafür verantwortlich, dass ein Objekt noch vorhanden ist, wenn es gebraucht wird.

Eigentümerschaft übertragen:

Dies ist bei der manuellen Speicherverwaltung die herkömmliche Vorgehensweise. Sobald eine Nachricht in die Warteschlange eingereiht wird, übernimmt sie die Eigentümerschaft daran, die der Absender gleichzeitig aufgibt. Wenn die Nachricht verarbeitet wird, übernimmt der Empfänger die Eigentümerschaft und ist dafür verantwortlich, den Speicher freizugeben, wenn sie gelöscht wird.

In C++ steht Ihnen mit unique_ptr<T> bereits ab Werk ein entsprechendes Sprachelement zur Verfügung.

Gemeinsame Eigentümerschaft:

Heutzutage haben sich sogar C++-Programmierer mit der Garbage Collection arrangiert und eine gemeinsame Eigentümerschaft ist eher akzeptabel. In diesem Fall bleibt die Nachricht im Arbeitsspeicher erhalten, solange eine Referenz darauf existiert. Danach wird der belegte Speicherplatz automatisch freigegeben.

Der C++-Typ hierfür heißt shared_ptr<T>.

Die Warteschlange übernimmt die Eigentümerschaft:

Eine weitere Möglichkeit besteht darin, dass sich Nachrichten immer in der Warteschlange befinden. Statt selbst Speicherplatz zu reservieren, bittet der Absender die Warteschlange darum, ihm eine »neue« Nachricht bereitzustellen. Die Warteschlange gibt daraufhin eine Referenz auf eine in ihrem Speicherbereich bereits vorhandene Nachricht zurück, die der Absender mit Inhalt befüllt. Wenn sie verarbeitet wird, referenziert der Empfänger dieselbe in der Warteschlange gespeicherte Nachricht.

Mit anderen Worten: Die Warteschlange wird in Form eines Object Pools (Objektpool, Kapitel 19) gespeichert.

15.7  Siehe auch ...

Ich habe es schon mehrmals erwähnt: In vielerlei Hinsicht ist dieses Pattern ein asynchroner Verwandter des wohlbekannten Patterns Observer (Beobachter, Kapitel 4).

Ebenso wie bei vielen anderen Patterns gibt es für Ereigniswarteschlangen mehrere Bezeichnungen. »Nachrichtenwarteschlange« oder »Message Queue« sind gängige Begriffe, die sich eher auf allgemeine Ausprägungen des Patterns beziehen. Unsere Ereigniswarteschlange kommuniziert innerhalb einer Anwendung, Nachrichtenwarteschlangen hingegen dienen für gewöhnlich zur Kommunikation zwischen verschiedenen Anwendungen.

Ebenfalls gebräuchlich ist die englische Bezeichnung »Publish/Subscribe« (Veröffentlichen/Abonnieren), die gelegentlich als »pubsub« abgekürzt wird. Ebenso wie »Nachrichtenwarteschlange« bezieht sie sich für gewöhnlich auf größere verteilte Systeme, nicht auf die bescheidenen Design Patterns, mit denen wir uns hier beschäftigen.

Ein dem State-Pattern (Zustand, Kapitel 7) der Gang of Four ähnlicher Zustandsautomat benötigt Eingaben, die Sie in einer Warteschlange sammeln können, um sie asynchron zu verarbeiten.

Falls Sie mehrere Zustandsautomaten einsetzen, die einander Nachrichten übermitteln, welche jeweils eine kleine Warteschlange mit Eingaben enthalten (man bezeichnet das als Mailbox), dann haben Sie das in der Informatik geläufige Aktorenmodell neu erfunden.

Der in der Programmiersprache Go vorhandene channel-Typ stellt im Wesentlichen eine Ereignis- bzw. Benachrichtigungswarteschlange dar.

Kapitel 16: Service Locator (Dienstlokalisierung)

Bereitstellung eines globalen Zugriffspunkts für einen Dienst, ohne den Benutzer an die konkrete Klasse zu binden, die ihn implementiert.

16.1  Motivation

Manche Objekte oder Systeme in einem Spiel sind nahezu allgegenwärtig und kommen mit fast allen Teilen der Codebasis in Berührung. Es lässt sich kaum eine Stelle finden, an der nicht irgendwo Speicherreservierungen, Protokollierungen oder Zufallszahlen Verwendung finden. Derartige Systeme kann man sich als Dienste vorstellen, die im gesamten Spiel verfügbar sein sollen.

In unserem Beispiel betrachten wir das Audiosystem, das ein Spiel zwar nicht ganz so vollständig durchdringt wie maschinennahe Operationen (etwa Speicherreservierungen), aber dennoch mit vielen Bereichen des Spiels in Verbindung tritt: Ein abstürzender Felsbrocken landet mit lautem Krachen auf dem Boden (Physik-Engine). Ein NPC (Non-Player Character, Nicht-Spieler-Figur) wie ein Scharfschütze feuert mit einem Knall seine Waffe ab (KI). Der Benutzer wählt einen Menüeintrag aus, was durch ein akustisches Signal bestätigt wird (User Interface).

An all diesen Stellen soll es möglich sein, Methoden des Audiosystems aufzurufen, etwa nach einem der folgenden Muster:

// Eine statische Klasse?
AudioSystem::playSound(LOUD_BANG);

// Oder vielleicht ein Singleton?
AudioSystem::instance()->playSound(LOUD_BANG);

Das Ziel kann auf beiden Wegen erreicht werden, die allerdings mit einer heftigen Kopplung gepflastert sind. Überall dort, wo das Spiel Methoden des Audiosystems aufruft, werden die konkrete AudioSystem-Klasse oder der Zugriffsmechanismus direkt referenziert – entweder als statische Klasse oder als Singleton (Kapitel 6).

Natürlich müssen diejenigen Codeabschnitte, die solche Aufrufe tätigen, an irgendetwas gekoppelt sein, um einen Sound abspielen zu können. Wenn sie allerdings direkt auf die konkrete Implementierung des Audiosystems zugreifen, verhält es sich etwa so, als ob Sie hundert Unbekannten eine Wegbeschreibung zu Ihrem Wohnhaus zukommen lassen, damit Sie einen Brief auf Ihrer Türschwelle hinterlassen können – die Sache wird nicht nur etwas zu persönlich, sondern zu einer veritablen Belastung, wenn Sie umziehen und alle betroffenen Personen davon informieren müssen.

Es gibt eine viel bessere Lösung: ein Telefonbuch. Diejenigen Leute, die mit Ihnen Kontakt aufnehmen möchten, können Ihre aktuelle Adresse anhand Ihres Namens nachschlagen. Bei einem Umzug muss nur die Telefongesellschaft informiert werden, die das Telefonbuch aktualisiert, damit die neue Adresse verfügbar ist. Tatsächlich müssen Sie noch nicht einmal die eigentliche Adresse preisgeben. Sie könnten auch ein Postfach oder eine andere »Repräsentation« Ihrer selbst angeben. Anrufer müssen das Telefonbuch benutzen, um Sie zu finden. Damit steht eine bequeme Möglichkeit zur Verfügung, an einer einzigen Stelle festzulegen, wie Sie erreichbar sind.

Das war eine kurze Zusammenfassung des Patterns Service Locator – es entkoppelt Codeabschnitte, die auf einen Dienst angewiesen sind, sowohl von dessen Art (der konkreten Implementierung) als auch von dessen Speicherort (wie man an eine Instanz gelangt).

16.2  Das Pattern

Eine Dienst-Klasse definiert ein abstraktes Interface für eine Reihe verschiedener Operationen. Zur Bereitstellung eines konkreten Dienstes muss dieses Interface durch einen Dienstanbieter (Service Provider) implementiert werden. Eine separate Dienstlokalisierung (Service Locator) stellt den Zugriff auf den Dienst bereit, indem sie nach einem passenden Dienstanbieter sucht, dabei aber den konkreten Typ des Anbieters und den eigentlichen Suchvorgang verbirgt.

16.3  Anwendbarkeit

Falls Sie irgendetwas für sämtliche Programmteile zugänglich machen, fordern Sie Schwierigkeiten geradezu heraus. Das ist das Hauptproblem des Singleton-Patterns (Kapitel 6) und hier verhält es sich nicht anders. Mein einfachster Ratschlag zur Verwendung des Patterns Service Locator lautet daher: Setzen Sie es nur sparsam ein.

Statt einen globalen Mechanismus zu verwenden, um Codeabschnitten Zugriff auf benötigte Objekte zu gewähren, sollten Sie zunächst in Betracht ziehen, das Objekt beim Aufruf zu übergeben. Das ist nicht nur denkbar einfach, sondern macht die Kopplung vollkommen offensichtlich und sollte die meisten Ihrer Anforderungen erfüllen.

Aber ... Hin und wieder ist die manuelle Übergabe von Objekten unerwünscht oder erschwert die Verständlichkeit des Codes. So sollten etwa Protokollierung oder Speicherverwaltung keine Bestandteile der öffentlichen API eines Moduls sein. Die Parameter für den Rendering-Code sollten sich auf das Rendering beziehen, nicht auf andere Dinge wie beispielsweise die Protokollierung.

Andere Systeme bieten Fähigkeiten, die grundsätzlich von einmaliger Art sind. Ihrem Spiel steht vermutlich nur ein Typ Audiogerät oder Bildschirm zur Verfügung. Da es sich dabei um eine Eigenschaft der Umgebung handelt, würde der Code nur unnötig verkompliziert, wenn sich ein tief verschachtelter Aufruf durch zehn Schichten verschiedener Methoden durcharbeiten müsste, um das Audio- oder Grafiksystem anzusprechen.

In solchen Fällen ist dieses Pattern hilfreich. Sie werden sehen, dass es wie ein flexiblerer, besser konfigurierbarer Verwandter des Singletons fungiert. Richtig eingesetzt, kann es die Flexibilität Ihrer Codebasis mit nur geringem Aufwand zur Laufzeit erhöhen.

Wird es hingegen mangelhaft implementiert, bringt es all die Probleme des Singleton-Patterns mit sich und verlangsamt das Programm zur Laufzeit.

16.4  Konsequenzen

Die eigentliche Schwierigkeit des Patterns Service Locator besteht darin, dass es eine Abhängigkeit (sprich eine Kopplung zwischen zwei Codeabschnitten) mit sich bringt, die erst zur Laufzeit auftritt. Es ist sehr anpassungsfähig, Sie zahlen dafür jedoch den Preis, dass das Erkennen von Abhängigkeiten beim Betrachten des Codes schwieriger wird.

16.4.1  Der Dienst muss auch tatsächlich lokalisiert werden können

Bei einem Singleton oder einer statischen Klasse ist es nicht möglich, dass die benötigte Instanz nicht verfügbar ist. Der aufrufende Code kann einfach voraussetzen, dass sie vorhanden ist. Da dieses Pattern den Dienst jedoch ausfindig machen muss, sind auch solche Fälle zu berücksichtigen, in denen das scheitert. Glücklicherweise gibt es allerdings eine Strategie, die sich dieses Problems annimmt, auf die wir noch zu sprechen kommen. Sie garantiert, dass zumindest das Interface des Dienstes verfügbar ist, wenn er benötigt wird.

16.4.2  Dem Dienst ist nicht bekannt, wer ihn nutzt

Der Service Locator ist global verfügbar, daher kann sämtlicher Code des Spiels einen Dienst anfordern und ihn nutzen. Das heißt, dass der Dienst unter allen Umständen in der Lage sein muss, korrekt zu funktionieren. Sollte die Verwendung einer Klasse beispielsweise nur während der Simulationsphase der Game Loop vorgesehen sein, nicht aber beim Rendern, ist sie als Dienst womöglich ungeeignet – weil nicht gewährleistet wäre, dass sie zum richtigen Zeitpunkt genutzt würde. Wird der Zugriff auf eine Klasse also nur in einem bestimmten Kontext erwartet, ist es am besten, sie nicht mithilfe dieses Patterns der gesamten Spielwelt bereitzustellen.

16.5  Beispielcode

Wenden wir uns wieder dem Beispiel mit dem Audiosystem zu! Zunächst einmal stellen wir es mithilfe eines Service Locators der übrigen Codebasis bereit.

16.5.1  Der Dienst

Wir fangen mit der Audio-API an. Hier das Interface, das der Dienst anbietet:

class Audio
{
public:
  virtual ~Audio() {}
  virtual void playSound(int soundID) = 0;
  virtual void stopSound(int soundID) = 0;
  virtual void stopAllSounds() = 0;
};

Eine echte Audio-Engine wäre natürlich erheblich komplexer, die zugrunde liegende Idee ist jedoch durchaus erkennbar. Entscheidend ist, dass es sich um eine abstrakte Interface-Klasse ohne dazugehörige Implementierung handelt.

16.5.2  Der Dienstanbieter

Das Audio-Interface ist für sich genommen nicht besonders nützlich. Wir brauchen eine konkrete Implementierung. In diesem Buch geht es nicht darum, Audio-Code für eine Spielkonsole zu entwickeln, daher müssen Sie sich einfach vorstellen, dass die Funktionen tatsächlich richtigen Code enthalten – aber Sie verstehen schon:

class ConsoleAudio : public Audio
{
public:
  virtual void playSound(int soundID)
  {
    // Sound via Audio-API der Konsole abspielen
  }

  virtual void stopSound(int soundID)
  {
    // Sound via Audio-API der Konsole stoppen
  }

  virtual void stopAllSounds()
  {
    // Alle Sounds via Audio-API der Konsole stoppen
  }
};

Nun gibt es neben dem Interface auch eine Implementierung. Es fehlt nur noch der Service Locator – die Klasse zur Verknüpfung der beiden.

16.5.3  Ein einfacher Service Locator

Die nachstehende Implementierung dürfte die wohl einfachste Variante eines Service Locators sein:

class Locator
{
public:
  static Audio* getAudio() { return service_; }

  static void provide(Audio* service)
  {
    service_ = service;
  }

private:
  static Audio* service_;
};

Die statische getAudio()-Funktion führt die Suche nach dem Dienst aus. Sie kann überall in der Codebasis aufgerufen werden und liefert eine Instanz des Audio-Dienstes zurück:

Audio *audio = Locator::getAudio();
audio->playSound(LOUD_BANG);

Das eigentliche »Auffinden« ist äußerst einfach – dafür ist außenstehender Code zuständig, der einen Dienstanbieter einrichtet, bevor auf den Dienst zugegriffen wird. Beim Start des Spiels wird Code wie der folgende ausgeführt:

ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);

Das hier angewendete Verfahren wird als Dependency Injection (zu Deutsch etwa »Injektion einer Abhängigkeit«) bezeichnet – ein etwas unbeholfener Name für eine ganz einfache Idee. Nehmen wir an, eine Klasse hängt von einer anderen ab. In diesem Fall benötigt die Locator-Klasse eine Instanz des Audio-Dienstes. Normalerweise wäre die Locator-Klasse selbst dafür verantwortlich, diese Instanz zu erzeugen. Bei diesem Verfahren ist jedoch außenstehender Code dafür zuständig, die Instanz in das Objekt, das sie benötigt, zu injizieren.

Hier ist insbesondere zu beachten, dass dem playSound() aufrufenden Code die konkrete ConsoleAudio-Klasse nicht bekannt ist – er weiß lediglich von dem abstrakten Audio-Interface. Genauso wichtig ist, dass nicht einmal die Locator-Klasse an den konkreten Dienstanbieter gekoppelt ist. Die konkrete Klasse ist nur an einer einzigen Stelle bekannt: in dem Initialisierungscode, der den Dienst bereitstellt.

Es gibt an dieser Stelle noch eine weitere Entkopplung: Das Audio-Interface weiß nichts davon, dass meistens über den Service Locator auf sie zugegriffen wird. Ihm ist nur bekannt, dass sie eine gewöhnliche abstrakte Basisklasse ist. Das ist nützlich, weil es dadurch möglich ist, dieses Pattern auf bereits vorhandene Klassen anzuwenden, die gar nicht notwendigerweise dafür ausgelegt sind. Hier unterscheidet sich das Pattern vom Singleton (Kapitel 6), das auf das Design der »Dienst«-Klasse Einfluss nimmt.

16.5.4  Ein leerer Dienst

Die bisherige Implementierung ist zweifelsohne simpel und auch ziemlich flexibel. Sie besitzt jedoch einen Nachteil: Wenn wir versuchen, den Dienst zu nutzen, bevor der Dienstanbieter ihn bereitgestellt hat, wird NULL zurückgegeben. Falls der aufrufende Code das nicht überprüft, stürzt das Spiel ab.

Gelegentlich wird dies als »zeitliche Kopplung« bezeichnet – zwei verschiedene Codeabschnitte, die in der richtigen Reihenfolge aufgerufen werden müssen, damit ein Programm korrekt arbeitet. In gewissem Maße ist jede zustandsorientierte Software von diesem Problem betroffen, aber ebenso wie bei anderen Formen der Kopplung erleichtert eine geringere zeitliche Kopplung die Handhabung der Codebasis.

Glücklicherweise gibt es ein weiteres Design Pattern namens Null Object (Nullobjekt), das wir hier einsetzen können. Die zugrunde liegende Idee ist dabei die folgende: An Stellen, an denen NULL zurückgegeben werden müsste, weil das Auffinden oder Erzeugen eines Objekts scheitert, wird stattdessen ein spezielles Objekt zurückgeliefert, das dasselbe Interface implementiert wie das erwünschte Objekt. Die eigentliche Implementierung tut im Wesentlichen gar nichts, gestattet es jedoch dem Code, der das Objekt erhält, gefahrlos fortzufahren, ganz so, als ob er das »echte« erhalten hätte.

Zu diesem Zweck definieren wir einen »Null«-Dienstanbieter:

class NullAudio: public Audio
{
public:
  virtual void playSound(int soundID)
  virtual void stopSound(int soundID)
  virtual void stopAllSounds()
};

Wie Sie sehen, implementiert er das Dienst-Interface, ohne irgendeine Aufgabe zu erfüllen. Die Locator-Klasse wird folgendermaßen modifiziert:

class Locator
{
public:
  static void initialize()
  {
    service_ = &nullService_;
  }

  static Audio& getAudio() { return *service_; }

  static void provide(Audio* service)
  {
    // Rückfall auf den leeren Dienst
    if (service == NULL) service = &nullService_;
    service_ = service;
  }

private:
  static Audio* service_;
  static NullAudio nullService_;
};

Vielleicht ist Ihnen aufgefallen, dass der Dienst jetzt als Referenz und nicht als Zeiger zurückgegeben wird. Da Referenzen in C++ (theoretisch!) niemals NULL sind, ist die Rückgabe einer Referenz für die Benutzer des Codes ein Hinweis darauf, dass sie erwarten dürfen, stets ein gültiges Objekt zurückgeliefert zu bekommen.

Darüber hinaus ist hier anzumerken, dass in der provide()-Funktion eine Überprüfung auf NULL erfolgt, anstatt die Zugriffsfunktion zu überprüfen. Daher muss initialize() möglichst früh aufgerufen werden, damit sichergestellt ist, dass die Locator-Klasse bei Bedarf auf den »Null«-Dienstanbieter zurückgreifen kann. Im Gegenzug wird der entsprechende Zweig aus der getAudio()-Funktion entfernt, was bei jedem Zugriff auf den Dienst einige Prozessortakte an Rechenzeit einspart.

Dem aufrufenden Code bleibt unbekannt, dass der »echte« Dienst nicht aufgefunden wurde und er braucht sich auch nicht darum zu kümmern, eine Überprüfung auf NULL vorzunehmen, denn es wird garantiert ein gültiges Objekt zurückgeliefert.

Dieses Verfahren erweist sich ebenfalls als nützlich, wenn ein Dienst absichtlich nicht aufgefunden werden soll. Wenn etwa ein System vorübergehend deaktiviert werden soll, steht dafür nun eine simple Möglichkeit zur Verfügung: Stellen Sie einfach keinen Anbieter für den Dienst bereit. Die Locator-Klasse greift in diesem Fall auf den »Null«-Dienstanbieter zurück.

Das Audiosystem während der Entwicklungsarbeit zu deaktivieren, ist recht praktisch, denn dadurch wird ein wenig Arbeitsspeicher und Rechenzeit eingespart. Wichtiger ist jedoch, dass Ihre Trommelfelle verschont bleiben, falls sich plötzlich der Debugger in den Vordergrund drängelt, während gerade ein lauter Ton wiedergegeben wird. Nichts bringt das Blut des Morgens so in Wallung wie eine Endlosschleife, die einen 20 Millisekunden langen Schrei in voller Lautstärke immer wieder abspielt.

16.5.5  Protokollierender Dekorierer

Inzwischen ist unser System ziemlich robust und wir können uns einer weiteren Optimierung widmen, die dieses Pattern ermöglicht, nämlich den dekorierten Diensten. Ich erläutere das im Folgenden anhand eines Beispiels.

Bei der Entwicklung kann Ihnen ein wenig Protokollierung interessanter Ereignisse dabei helfen herauszufinden, was unter der Haube der Spiel-Engine eigentlich vor sich geht. Sollten Sie an der KI arbeiten, werden Sie gerne wissen wollen, wann sich die KI-Zustände eines Objekts ändern. Und wenn Sie für die Sounds verantwortlich zeichnen, hätten Sie sicher gern eine Liste der wiedergegebenen Sounds, damit Sie überprüfen können, ob sie in der richtigen Reihenfolge abgespielt werden.

Die typische Lösung besteht darin, den Code mit Aufrufen irgendeiner log()-Funktion zu übersäen. Allerdings kommt man damit vom Regen in die Traufe – plötzlich gibt es zu viel Protokollierung. Dem KI-Programmierer ist es gleichgültig, wann Sounds abgespielt werden, und die Person, die für die Sounds zuständig ist, hat kein Interesse an irgendwelchen KI-Zustandsübergängen – dennoch müssen beide auch die Meldungen des jeweils anderen durcharbeiten.

Ideal wäre es, wenn man die Protokollierung nur für diejenigen Vorgänge aktivieren könnte, an denen man interessiert ist. Und im abschließenden Build des Spiels gäbe es gar keine Protokollierung mehr. Sofern die verschiedenen Systeme, die nur auf Wunsch protokolliert werden sollen, in Form von Diensten bereitstehen, lässt sich diese Aufgabe mit dem Pattern Decorator (Dekorierer) lösen. Wir definieren nun eine weitere Implementierung des Audio-Dienstanbieters:

class LoggedAudio : public Audio
{
public:
  LoggedAudio(Audio &wrapped) : wrapped_(wrapped) {}
  virtual void playSound(int soundID)
  {
    log("Sound abspielen");
    wrapped_.playSound(soundID);
  }

  virtual void stopSound(int soundID)
  {
    log("Sound stoppen");
    wrapped_.stopSound(soundID);
  }

  virtual void stopAllSounds()
  {
    log("Alle Sounds stoppen");
    wrapped_.stopAllSounds();
  }

private:
  void log(const char* message)
  {
    // Code zur Protokollierung ...
  }
  Audio &wrapped_;
};

Wie Sie sehen, umschließt diese Implementierung einen weiteren Audio-Dienstanbieter und stellt dasselbe Interface bereit. Sie leitet die eigentlichen Audioaufrufe an den internen Audio-Dienstanbieter weiter, protokolliert sie aber auch. Wenn ein Programmierer die Audioprotokollierung aktivieren möchte, verwendet er Folgendes:

void enableAudioLogging()
{
  // Vorhandenen Dienst dekorieren
  Audio *service = new LoggedAudio(
                       Locator::getAudio());
  // Bereitstellen
  Locator::provide(service);
}

Nun wird jeder Aufruf des Audio-Dienstes protokolliert, bevor er wie gewohnt ausgeführt wird. Und auch der »Null«-Dienst funktioniert weiterhin wie vorgesehen, Sie können also das Audiosystem deaktivieren und dennoch protokollieren, welche Sounds abgespielt würden, wenn die Soundwiedergabe aktiviert wäre.

16.6  Designentscheidungen

Wir haben hier eine typische Implementierung betrachtet, allerdings sind verschiedene Varianten möglich, die von den Antworten auf einige grundlegende Fragen abhängen.

16.6.1  Wie wird der Dienst lokalisiert?

Der Dienst wird von außenstehendem Code bereitgestellt:

Das ist der Mechanismus, den der Beispielcode zur Lokalisierung des Dienstes verwendet. Er findet sich in Spielen am häufigsten.

Diese Vorgehensweise ist schnell und einfach. Die getAudio()-Funktion liefert einfach einen Zeiger zurück. Der Compiler nimmt hier bei der Optimierung häufig eine Inline-Ersetzung vor. Wir kommen dadurch praktisch ohne Performanceeinbußen in den Genuss einer Abstraktionsebene.

Sie legen die Funktionsweise des Dienstanbieters selbst fest. Betrachten Sie einen Dienst, der auf die Eingaben zur Steuerung des Spiels zugreift. Es gibt dann zwei konkrete Dienstanbieter: einen für reguläre Spiele und einen für Onlinespiele. Der Anbieter für Onlinespiele nimmt Steuereingaben über das Netzwerk entgegen. Für das übrige Spiel stellt sich das so dar, als ob entfernte Spieler ein lokales Eingabegerät verwenden würden.

Damit das funktioniert, muss dem konkreten Dienstanbieter für Onlinespiele die IP-Adresse des entfernten Spielers bekannt sein. Wenn die Locator-Klasse das Anbieter-Objekt selbst erzeugt, wie sollte sie dann die Adresse übergeben können? Der Locator-Klasse ist nichts von Onlinespielen bekannt, schon gar nicht die IP-Adresse eines anderen Spielers.

Wenn der Dienst von außenstehendem Code bereitgestellt wird, lässt sich dieses Problem umgehen. Statt von der Locator-Klasse wird das onlinespezifische Anbieter-Objekt vom Netzwerk-Code des Spiels instanziiert und übergibt dabei die erforderliche IP-Adresse. Diese Instanz wird dann der Locator-Klasse zur Verfügung gestellt, der lediglich das abstrakte Interface des Dienstes bekannt ist.

Der Dienst kann ausgetauscht werden, während das Spiel läuft. In der endgültigen Fassung des Spiels kommt diese Möglichkeit womöglich gar nicht zum Einsatz, sie ist jedoch bei der Entwicklung sehr nützlich. Wir könnten beim Testen beispielsweise den Audio-Dienst durch den vorhin erwähnten »Null«-Dienst ersetzen, um vorübergehend den Sound zu deaktivieren, während das Spiel läuft.

Die Locator-Klasse ist von außenstehendem Code abhängig. Das ist der große Nachteil. Sämtlicher Code, der auf den Dienst zugreift, geht davon aus, dass er anderem Code ebenfalls bereitgestellt worden ist. Ist das jedoch nicht der Fall, stürzt entweder das Spiel ab oder der Dienst funktioniert auf merkwürdige Weise nicht mehr.

Einbindung beim Kompilieren:

Hier liegt die Idee zugrunde, dass der »Lokalisierungsvorgang« schon beim Kompilieren mittels Präprozessormakros durchgeführt wird, etwa so:

class Locator
{
public:
  static Audio& getAudio() { return service_; }

private:
  #if DEBUG
    static DebugAudio service_;
  #else
    static ReleaseAudio service_;
  #endif
};

Diese Vorgehensweise hat verschiedene Konsequenzen:

Sie ist schnell. Da die eigentliche Arbeit bereits beim Kompilieren erledigt wurde, gibt es zur Laufzeit nichts mehr zu tun. Der Compiler wird beim getAudio()-Aufruf wahrscheinlich eine Inline-Ersetzung vornehmen – das ist die schnellste Lösung, die wir erwarten dürfen.

Sie können die Verfügbarkeit des Dienstes garantieren. Da nun die Locator-Klasse Eigentümer des Dienstes ist und ihn schon bei der Kompilierung auswählt, können wir uns sicher sein, dass der Dienst verfügbar ist, sofern das Spiel kompiliert.

Der Dienst lässt sich nicht ohne Weiteres modifizieren. Dies ist der größte Nachteil. Da die Einbindung bereits während des Kompilierens stattfindet, müssen Sie das Spiel bei jeder Änderung erneut kompilieren und wieder starten.

Konfiguration zur Laufzeit:

Wenn in Kakifarben gekleidete Leute im geschäftlichen Softwareumfeld den Begriff »Service Locator« hören, ist es genau das hier, woran sie denken: Sobald der Dienst angefragt wird, wirkt die Locator-Klasse zur Laufzeit irgendeinen Zauber und findet die erwünschte Implementierung.

Typischerweise wird eine Konfigurationsdatei eingelesen, die den Dienstanbieter angibt und dann wird die Klasse unter Verwendung von Introspektion zur Laufzeit instanziiert.

Introspektion oder Reflexion bezeichnet die Fähigkeit einiger Programmiersprachen, zur Laufzeit mit dem Typsystem zu interagieren. Beispielsweise können Sie eine Klasse anhand ihres Namens aufspüren, den Konstruktor finden und ihn aufrufen, um eine Instanz zu erzeugen.

Dynamisch typisierte Sprachen wie Lisp, Smalltalk oder Python wurden von vornherein dafür ausgelegt, aber auch jüngere, statisch typisierte Sprachen wie C# oder Java unterstützen die Introspektion.

Dabei bieten sich mehrere Möglichkeiten:

Der Dienst kann ohne Neukompilierung ersetzt werden. Dieses Vorgehen ist etwas flexibler als die Einbindung beim Kompilieren, jedoch nicht so flexibel wie ein durch außenstehenden Code bereitgestellter Dienst, der zur Laufzeit austauschbar ist.

Der Dienst kann durch Nicht-Programmierer ersetzt werden. Hierbei handelt es sich um eine Annehmlichkeit für unsere Designer, die gern bestimmte Features ein- und ausschalten möchten, sich aber unbehaglich fühlen, wenn sie im Code herumstochern müssen. (Wahrscheinlich sind es eher die Programmierer, die sich dabei unbehaglich fühlen.)

Ein und dieselbe Codebasis kann mehrere Konfigurationen gleichzeitig unterstützen. Da der Lokalisierungsvorgang nicht mehr Bestandteil der Codebasis ist, lässt sich derselbe Code verwenden, um mehrere Dienstkonfigurationen gleichzeitig zu unterstützen.

Dies ist einer der Gründe dafür, dass dieses Modell in geschäftlichen Umgebungen so beliebt ist: Sie können dieselbe Anwendung auf unterschiedlich ausgerüsteten Servern betreiben und brauchen nur ein paar Konfigurationsdateien zu ändern. Früher war das für Spiele kaum nützlich, da die Spielkonsolenhardware ziemlich standardisiert ist. Doch in Zeiten, in denen immer mehr Spiele auf den zunehmend größer werdenden Mischmasch mobiler Geräte abzielen, nimmt die Bedeutung zu.

Die Konfiguration ist komplex. Im Gegensatz zu den vorhergehenden Lösungen ist diese ziemlich schwergewichtig. Sie müssen ein Konfigurationssystem entwickeln, Code zum Lesen und Parsen der Datei schreiben und ganz allgemein Kram erledigen, um den Dienst aufzufinden. Die für diesen Code aufgewendete Zeit fehlt bei der Programmierung von anderen Features des Spiels.

Das Lokalisieren des Dienstes nimmt Zeit in Anspruch. Nun wird aus dem Lächeln tatsächlich ein Stirnrunzeln. Die Konfiguration zur Laufzeit bedeutet, CPU-Rechenzeit mit dem Auffinden des Dienstes zu verschwenden. Durch Caching lässt sich der Zeitverlust minimieren, beim ersten Zugriff auf den Dienst muss das Spiel jedoch Zeit aufwenden, um ihn zu lokalisieren. Spieleentwickler hassen es, CPU-Rechenzeit für Aufgaben zu vergeuden, die nicht dem Spielerlebnis des Benutzers zugute kommen.

16.6.2  Was geschieht, wenn die Lokalisierung des Dienstes scheitert?

Lassen Sie den Dienstnutzer entscheiden:

Am einfachsten ist es, den Schwarzen Peter weiterzugeben. Falls die Locator-Klasse den Dienst nicht lokalisieren kann, gibt sie einfach nur NULL zurück. Das bedeutet außerdem:

Der Dienstnutzer legt selbst fest, wie er mit dem Scheitern umgeht. Manche Dienstnutzer bewerten es vielleicht als kritischen Fehler, wenn das Auffinden eines Dienstes fehlschlägt. Andere können diesen Fehler womöglich gefahrlos ignorieren und das Spiel fortsetzen. Falls die Locator-Klasse keine pauschale Richtlinie festlegen kann, die in allen Fällen korrekt ist, ermöglicht die Weitergabe des Fehlers dem aufrufenden Code, selbst zu entscheiden, wie darauf am besten zu reagieren ist.

Die Dienstnutzer sind gezwungen, den Fehler zu handhaben. Es ist natürlich die logische Folge des vorhergehenden Punktes, dass der aufrufende Code überprüfen muss, ob die Lokalisierung des Dienstes gescheitert ist. Wenn die meisten Codeabschnitte das Scheitern auf dieselbe Weise handhaben, führt dies zu einer Menge redundantem Code, der über die gesamte Codebasis verstreut ist. Und falls auch nur einer der potenziell Hunderten von Dienstnutzern versäumt, diese Überprüfung vorzunehmen, wird das Spiel abstürzen.

Halten Sie das Spiel an:

Es ist zwar nicht beweisbar, dass der Dienst immer verfügbar ist, das hindert uns jedoch nicht daran, zu deklarieren, dass er der Locator-Klasse zur Laufzeit zur Verfügung steht. Am einfachsten lässt sich das mittels einer Assertion realisieren:

class Locator
{
public:
  static Audio& getAudio()
  {
    Audio* service = NULL;
    // Hier den Dienst lokalisieren ...

    assert(service != NULL);
    return *service;
  }
};

Für den Fall, dass sie Ihnen unbekannt sein sollte: Im Kapitel 6 über das Singleton wird die assert()-Funktion erklärt.

Kann der Dienst nicht lokalisiert werden, wird das Spiel angehalten, bevor der nachfolgende Code versuchen kann, ihn zu nutzen. Der Aufruf der assert()-Funktion löst zwar das Problem der gescheiterten Lokalisierung nicht, aber er macht deutlich, wer dafür verantwortlich ist. Die Assertion an dieser Stelle bedeutet sozusagen: »Das Scheitern der Lokalisierung des Dienstes ist ein Bug in der Locator-Klasse.«

Und was bringt uns das Ganze?

Dienstnutzer brauchen sich nicht um fehlende Dienste zu kümmern. Da ein einzelner Dienst womöglich an Hunderten von Orten genutzt wird, kann hierdurch eine beträchtliche Menge an Code eingespart werden. Durch die Deklaration, dass es Aufgabe der Locator-Klasse ist, Dienste aufzuspüren, ersparen wir es den Dienstnutzern, selbst dafür Sorge tragen zu müssen.

Das Spiel wird angehalten, falls ein Dienst nicht gefunden werden kann. In dem unwahrscheinlichen Fall, dass ein Dienst tatsächlich nicht gefunden werden kann, wird das Spiel angehalten. Das ist insofern vernünftig, als es uns dazu zwingt, den Bug zu beseitigen, der das Auffinden des Dienstes verhindert (vermutlich wird irgendwo versäumt, eine Initialisierung durchzuführen). Für alle anderen ist es nämlich wirklich lästig, weil sie nicht weiterarbeiten können, bis der Fehler behoben ist. Größere Entwicklerteams müssen dadurch ausgesprochen ärgerliche Auszeiten erleiden, wenn sich derartige Fehler einschleichen.

Rückgabe eines »Null«-Dienstes:

Diese Optimierung kennen Sie bereits aus der Implementierung des Beispiels. Ihre Verwendung bedeutet:

Dienstnutzer brauchen sich nicht um fehlende Dienste zu kümmern. Ebenso wie bei der vorangehenden Option tragen wir Sorge dafür, dass stets ein gültiges Dienst-Objekt zurückgegeben wird, was den Code des Dienstnutzers vereinfacht.

Das Spiel läuft weiter, auch wenn der Dienst nicht verfügbar ist. Dies ist Fluch und Segen zugleich: Das Spiel läuft trotz des fehlenden Dienstes weiter, was bei größeren Entwicklerteams äußerst nützlich sein kann, wenn an einem Feature gearbeitet wird, das von einem Programmteil abhängig ist, der noch fehlt.

Der Nachteil besteht darin, dass das Debugging schwieriger wird, wenn ein Dienst ungewollt fehlt. Nehmen wir an, das Spiel nutzt einen Dienst, um auf Daten zuzugreifen, anhand derer eine Entscheidung getroffen wird. Wenn dieser Dienst nun nicht bereitgestellt wird und der Code einen »Null«-Dienst erhält, wird sich das Spiel womöglich nicht wunschgemäß verhalten. Es kann einige Arbeit bereiten herauszufinden, dass der Fehler darauf zurückzuführen ist, dass der Dienst wider Erwarten nicht verfügbar war.

Dieses Problem lässt sich dadurch entschärfen, dass der »Null«-Dienst Meldungen protokolliert, wenn er genutzt wird.

Von all diesen Optionen wird meiner Erfahrung nach diejenige am häufigsten eingesetzt, bei der einfach vorausgesetzt wird, dass der Dienst gefunden wird. Zum Zeitpunkt der Auslieferung wurde das Spiel sehr gründlich getestet und es wird vermutlich auf zuverlässiger Hardware laufen. Die Chancen, dass ein Dienst dann noch immer nicht auffindbar ist, sind ziemlich gering.

Bei größeren Teams empfehle ich, einen »Null«-Dienst einzusetzen. Es macht kaum Mühe, ihn zu implementieren, er kann jedoch unproduktive Zeiten während der Entwicklung aufgrund eines nicht verfügbaren Dienstes verhindern. Außerdem bietet er die Möglichkeit, einen Dienst abzuschalten, sollte er fehlerhaft arbeiten oder Sie einfach nur von der Arbeit ablenken.

16.6.3  Wer darf auf den Dienst zugreifen?

Bislang sind wir stets davon ausgegangen, dass die Locator-Klasse den Dienst jedem beliebigem Programmteil bereitstellt. So wird das Pattern meistens auch angewendet – allerdings gibt es noch eine weitere Möglichkeit, nämlich den Zugriff wie folgt auf eine Klasse und deren Abkömmlinge zu beschränken:

class Base
{
  // Methoden zur Lokalisierung und Einrichtung
  // des Dienstes

protected:
    // Abgeleitete Klassen dürfen den Dienst nutzen
    static Audio& getAudio() { return *service_; }

private:
    static Audio* service_;
};

Auf diese Weise ist der Zugriff auf Klassen eingeschränkt, die von Base erben. Beide Vorgehensweisen haben ihre Vorteile:

Falls globaler Zugriff erlaubt ist:

Die gesamte Codebasis wird dazu angehalten, denselben Dienst zu nutzen. Die meisten Dienste sind nur für einzelne Dienstnutzer ausgelegt. Erhält jedoch die gesamte Codebasis Zugriff auf denselben Dienst, lässt sich dadurch vermeiden, dass an irgendwelchen Stellen Code eigene Dienstanbieter instanziiert, weil der »echte« nicht zugänglich ist.

Die Kontrolle über Ort und Zeitpunkt der Dienstnutzung geht verloren. Dies ist der offensichtliche Preis, der für die globale Verfügbarkeit gezahlt werden muss – der Dienst ist überall nutzbar. Im Kapitel 6 über das Singleton ist die Besetzungsliste für den Horrorfilm aufgeführt, zu dem ein globaler Gültigkeitsbereich werden kann.

Falls der Zugriff auf eine Klasse beschränkt ist:

Wir legen die Kopplung fest. Dies ist der wesentliche Vorteil. Wenn ein Dienst nur einem Zweig des Vererbungsbaums zugänglich ist, können wir gewährleisten, dass Systeme, die entkoppelt sein sollten, dies auch bleiben.

Bei dieser Vorgehensweise kann es zu mehrfachem Aufwand kommen. Dies ist der potenzielle Nachteil. Falls mehrere, nicht in Verbindung stehende Klassen auf den Dienst zugreifen müssen, ist es unumgänglich, dass jede dieser Klassen über eine eigene Dienst-Referenz verfügt. Wie genau der Dienst lokalisiert oder genutzt wird, spielt keine Rolle – der Code dafür ist in jeder dieser Klassen erforderlich.

(Es gäbe auch noch die Möglichkeit, die Klassenhierarchie so umzuarrangieren, dass die fraglichen Klassen eine gemeinsame Basisklasse besitzen, aber das lohnt vermutlich den Aufwand nicht.)

Ich halte mich an die allgemeine Regel, den Zugriff auf eine Klasse zu beschränken, wenn der Dienst seinerseits auf nur einen Bereich des Spiels beschränkt ist. Beispielsweise könnte der Zugriff auf einen Dienst, der einen Zugang zum Netzwerk bereitstellt, wahrscheinlich auf Online-Klassen beschränkt werden. Fast überall genutzte Dienste, wie etwa die Protokollierung, sollten hingegen global zugänglich sein.

16.7  Siehe auch ...

Das Pattern Service Locator ist in vielerlei Hinsicht mit dem Singleton (Kapitel 6) verwandt. Es lohnt daher oft, beide in Betracht zu ziehen und dann zu entscheiden, welches für Ihre Anforderungen besser geeignet ist.

Das Unity-Framework verwendet dieses Pattern zusammen mit dem Pattern Component (Komponente, Kapitel 14) in der GetComponent()-Methode

Microsofts XNA-Framework für die Spieleentwicklung setzt dieses Pattern in der Game-Klasse ein. Jede Instanz besitzt ein GameServices-Objekt, das zur Bereitstellung und Lokalisierung von Diensten beliebiger Art verwendet werden kann.

Teil VI: Optimierungsmuster (Optimization Patterns)

In diesem Teil:

Kapitel 17

Data Locality (Datenlokalität)

Kapitel 18

Dirty Flag (Veraltet-Flag)

Kapitel 19

Object Pool (Objektpool)

Kapitel 20

Spatial Partition (Räumliche Aufteilung)

Die Hardware wird immer schneller und für den größten Teil der darauf betriebenen Software gilt, dass sich die Programmierer hinsichtlich der Performance keine großen Gedanken zu machen brauchen. Spiele stellen hier eine der wenigen verbliebenen Ausnahmen dar. Die Anwender verlangen nach einem immer vielfältigeren, realistischeren und aufregenderen Spielerlebnis. Es gibt zuhauf Spiele, die um die Aufmerksamkeit – und das Geld! – der Spieler wetteifern. Und nicht selten macht dasjenige Programm das Rennen, das die Hardware am besten ausreizt.

Die Performanceoptimierung ist eine tiefgründige Kunst, die mit sämtlichen Aspekten der Software in Verbindung steht. Bei maschinennaher Programmierung müssen die unzähligen Eigenheiten der verschiedenen Hardwarearchitekturen gemeistert werden. Unterdessen konkurrieren Algorithmenforscher darum, mathematisch zu beweisen, wessen Verfahren am leistungsfähigsten sind.

Ich stelle in diesem Teil des Buches einige Patterns mittleren Schwierigkeitsgrads vor, die häufig eingesetzt werden, um ein Spiel zu beschleunigen. Das Kapitel 17 Data Locality (Datenlokalität) führt Sie in die Speicherhierarchie moderner Computer ein und zeigt, wie Sie sie zu Ihrem Vorteil nutzen können. Die Patterns Dirty Flag (Veraltet-Flag, Kapitel 18) und Object Pool (Objektpool, Kapitel 19) sollen Ihnen dabei helfen, überflüssige Berechnungen bzw. Speicherreservierungen zu vermeiden. Und durch Spatial Partitioning (Räumliche Aufteilung, Kapitel 20) soll schließlich die räumliche Aufteilung der Objekte sowie der Einwohner der virtuellen Welt beschleunigt werden.

Kapitel 17: Data Locality (Datenlokalität)

Beschleunigung des Speicherzugriffs durch eine Datenanordnung, die vom CPU-Caching Gebrauch macht.

17.1  Motivation

Wir werden angelogen. Immer wieder werden uns Diagramme gezeigt, in denen die CPU-Geschwindigkeit immer weiter steigt, so als ob das Moore’sche Gesetz nicht nur eine historische Beobachtung, sondern eine Art gottgegebenes Recht darstellen würde. Ohne einen Handschlag zu tun, lehnen wir Softwareentwickler uns zurück und sehen einfach nur zu, wie unsere Programme dank neuer Hardware wie von Zauberhand immer schneller werden.

Die Chips sind schneller geworden (wenngleich die Geschwindigkeitszunahme allmählich stagniert), allerdings lassen die Hardwarehersteller eine Sache gern unter den Tisch fallen: Wohl wahr, wir können die Daten schneller denn je verarbeiten, nur können wir nicht schneller an sie gelangen.

Abb. 17.1: Prozessor- und RAM-Geschwindigkeit relativ zur Geschwindigkeit im Jahr 1980

Um eine Fülle von Berechnungen durchführen zu können, muss Ihre superschnelle CPU die Daten zunächst einmal aus dem Hauptspeicher in die Register befördern. Und wie Sie der Abbildung 17.1 entnehmen können, hat die Geschwindigkeit des RAMs nicht mit der steigenden Prozessorgeschwindigkeit Schritt gehalten – nicht einmal annähernd!

Wie Sie sehen, haben CPUs rasante Fortschritte gemacht, der RAM-Zugriff hinkt jedoch weit hinterher.

Die Daten des Diagramms entstammen dem Artikel Computer Architecture: A Quantitative Approach von John L. Hennessy, David A. Patterson und Andrea C. Arpaci-Dusseau, auf den wiederum in Tony Albrechts Artikel Pitfalls of Object-Oriented Programming (Fallstricke bei der objektorientierten Programmierung) verwiesen wird.

Bei aktueller Hardware kann es Hunderte von Prozessortakten dauern, um ein Datenbyte aus dem RAM einzulesen. Wenn nun aber die meisten Befehle Daten benötigen und es Hunderte von Takten dauert, an diese Daten zu gelangen, wieso drehen dann die CPUs nicht 99% der Zeit Däumchen, während sie auf die Daten warten?

Es ist tatsächlich so, dass CPUs heutzutage einen frappierend großen Teil der Zeit damit verbringen, auf Daten zu warten. Man kann hier jedoch Abhilfe schaffen. Um zu erklären, wie das funktioniert, entführe ich Sie nun in eine Welt viel zu umständlicher Analogien ...

Die Bezeichnung Random Access Memory (RAM, Speicher mit wahlfreiem Zugriff) kommt daher, weil man im Gegensatz zu einer Festplatte theoretisch auf sämtliche Daten gleich schnell zugreifen kann. Die Daten müssen nicht wie bei der Festplatte in fortlaufender Reihenfolge gelesen werden.

Zumindest war das lange so. Wie sich zeigen wird, ist der Zugriff auf das RAM allerdings gar nicht mehr so wahlfrei.

17.1.1  Ein Datenlager

Stellen Sie sich vor, dass Sie in einem kleinen Büro als Buchhalter tätig sind. Sie erhalten einen Karton voller Unterlagen und müssen irgendwelche buchhalterischen Aufgaben damit erledigen – einen Haufen Zahlen summieren oder etwas in dieser Art. Kartons mit einer bestimmten Kennzeichnung müssen nach einem undurchschaubaren Verfahren behandelt werden, das nur für andere Buchhalter einen Sinn ergibt.

Vielleicht hätte ich für diese Analogie besser keinen Beruf auswählen sollen, von dem ich absolut keine Ahnung habe.

Dank einer Mischung aus Fleiß, natürlicher Begabung und Aufputschmitteln schaffen Sie es, einen Karton in vielleicht einer Minute durchzuarbeiten. Es gibt dabei allerdings ein kleines Problem: Die Kartons werden in einem anderen Gebäude gelagert. Um an einen Karton zu gelangen, müssen Sie den Lagerverwalter bitten, ihn zu holen. Er fährt dann mit einem Gabelstapler durch die Gänge, bis er den gewünschten Karton findet.

Dafür benötigt er allen Ernstes einen ganzen Tag. Im Gegensatz zu Ihnen wird er in absehbarer Zeit wohl kaum Mitarbeiter des Monats werden. Wie schnell Sie auch arbeiten, mehr als ein Karton pro Tag ist deswegen nicht zu schaffen. Die übrige Zeit sitzen Sie herum und stellen Ihren Lebensentwurf in Frage, der dazu geführt hat, dass Sie diesen seelenlosen Job machen müssen.

Eines Tages erscheint eine Gruppe Unternehmensberater, deren Aufgabe es ist, die Wirtschaftlichkeit der Abläufe zu verbessern – wie etwa die Fließbandfertigung zu beschleunigen. Nachdem sie Ihnen einige Tage bei der Arbeit zugesehen haben, ist ihnen Folgendes aufgefallen:

Wenn Sie mit einem Karton fertig sind, kommt es ziemlich häufig vor, dass sich der nächste zu bearbeitende Karton im selben Regal direkt neben dem gerade erledigten befindet.

Wenn man etwas verwendet, das sich in der Nähe des zuvor Verwendeten befindet, spricht man in der Informatik von einer Lokalitätseigenschaft (engl. locality of reference).

Für den Transport eines einzelnen Kartons einen Gabelstapler zu verwenden, ist ziemlich dämlich.

In einer Ecke Ihres Büros ist noch etwas Platz frei.

Die Berater machen einen cleveren Vorschlag: Wenn Sie den Lagerverwalter bitten, Ihnen einen Karton zu holen, soll er gleich die ganze Palette mitbringen. Er nimmt dann den gewünschten Karton und einige andere mit, die sich daneben befinden. Er weiß zwar nicht, ob Sie sie brauchen (und angesichts seiner Arbeitsmoral ist es ihm auch völlig egal), er nimmt einfach nur so viele mit, wie auf eine Palette passen.

Der Lagerverwalter holt Ihnen also die ganze Palette und lädt sie unter Missachtung der Sicherheitsvorschriften am Arbeitsplatz mit dem Gabelstapler in der freien Ecke Ihres Büros ab.

Wenn Sie nun einen neuen Karton benötigen, sehen Sie zunächst einmal nach, ob er sich unter den Kartons auf der Palette in Ihrem Büro befindet. Wenn ja: Klasse! Es dauert nur Sekunden ihn zu holen und Sie können sich wieder den Zahlenkolonnen widmen. Falls auf einer Palette fünfzig Kartons Platz haben und zufälligerweise alle benötigten Kartons dabei sind, können Sie fünfzig Mal mehr Arbeit erledigen als vorher.

Falls allerdings ein Karton benötigt wird, der sich nicht auf der Palette befindet, geht das ganze Spiel wieder von vorne los. Und da in Ihrem Büro nur Platz für eine Palette ist, muss der Lagerverwalter die aktuelle erst zurückbringen und Ihnen eine neue holen.

17.1.2  Eine Palette für die CPU

Es klingt merkwürdig, aber auf ähnliche Weise funktionieren CPUs aktueller Computer. Sollte es nicht offensichtlich sein: Sie spielen die Rolle der CPU. Ihr Schreibtisch repräsentiert die CPU-Register und die Kartons mit den Unterlagen die Daten, die sie aufnehmen. Das Lagerhaus ist das RAM des Computers und der Lagerverwalter der Datenbus, der die Daten vom Hauptarbeitsspeicher in die Register befördert.

Hätte ich dieses Kapitel vor dreißig Jahren geschrieben, wäre die Analogie damit abgeschlossen. Aber als die Prozessoren, anders als das RAM, immer schneller wurden, fingen die Hardwareingenieure an, nach Lösungen zu suchen. Und da dachten sie sich dann das CPU-Caching aus.

Moderne Prozessoren besitzen einen kleinen Speicherbereich, der sich direkt auf dem Chip befindet. Auf die darin befindlichen Daten kann die CPU erheblich schneller zugreifen als auf die im normalen Hauptspeicher. Dieser Speicher ist nur von geringer Größe, weil er auf den Chip passen muss und weil der dafür eingesetzte schnellere Speichertyp (statisches RAM oder SRAM) beträchtlich teurer ist.

Dieser Speicher wird als Cache bezeichnet (insbesondere heißt derjenige auf dem Chip Level-1-Cache oder kurz L1-Cache). In meiner Analogie, auf der ich ständig herumreite, entspricht er der Palette mit Kartons.

Bei moderner Hardware besteht die Speicherhierarchie aus mehreren Ebenen. Diese sind gemeint, wenn von »L1«, »L2«, »L3« usw. die Rede ist. Der Speicher jedes Levels ist größer, aber auch langsamer als derjenige des vorhergehenden. In diesem Kapitel spielt die Speicherhierarchie keine Rolle, es ist jedoch wichtig zu wissen, dass es sie gibt.

Wenn die CPU ein Datenbyte benötigt, liest sie immer gleich einen größeren zusammenhängenden Speicherbereich ein – für gewöhnlich 64 bis 128 Bytes – und legt ihn im Cache ab. Dieser Speicherbereich wird als Cache-Zeile bezeichnet.

Abb. 17.2: Ein Datenbyte und die Cache-Zeile, in der es erhalten bleibt

Falls das nächste benötigte Datenbyte in dieser Cache-Zeile enthalten ist, wird es von der CPU direkt aus dem Cache gelesen – was erheblich schneller funktioniert, als auf das RAM zuzugreifen. Werden die Daten im Cache gefunden, spricht man von einem Cache-Hit. Wird die CPU hingegen nicht fündig und muss auf den Hauptspeicher zugreifen, bezeichnet man das als Cache-Miss.

Ich habe hier (mindestens) ein Detail unter den Teppich gekehrt: Im Büro war nur Platz für eine Palette bzw. Cache-Zeile. Ein echter Cache enthält mehrere Cache-Zeilen Die Einzelheiten würden an dieser Stelle zu weit führen, aber suchen Sie im Internet ruhig mal nach »Cache-Assoziativität«, wenn Sie neugierig sind.

Wenn ein Cache-Miss auftritt, kommt die CPU zum Stillstand – sie kann den nächsten Befehl nicht ausführen, weil ihr die nötigen Daten fehlen. Daraufhin wartet sie zu Tode gelangweilt ein paar Hundert Prozessortakte vor sich hin, bis die Daten schließlich eintreffen. Das zu verhindern, ist unser Ziel. Nehmen wir an, der folgende Code einer performancekritischen Stelle im Spiel soll optimiert werden:

for (int i = 0; i < NUM_THINGS; i++)
{
  sleepFor500Cycles();
  things[i].doStuff();
}

Was würden Sie hier als Erstes ändern? Genau: Weg mit dem sinnlosen, zeitraubenden Funktionsaufruf. Er entspricht dem Performanceverlust bei einem Cache-Miss. Bei jedem Zugriff auf den Hauptarbeitsspeicher verhält sich der Code praktisch so, als ob es eine solche Verzögerung gäbe.

17.1.3  Daten = Performance?

Als ich mit der Arbeit an diesem Kapitel anfing, habe ich ein wenig Zeit damit verbracht, kleine spielartige Programme zusammenzustellen, die demonstrieren sollten, wie der Cache bestenfalls oder schlimmstenfalls genutzt wird. Ich wollte Tests programmieren, die den Cache richtiggehend piesacken, um aus eigener Erfahrung beurteilen zu können, zu welcher Überlastung es führt.

Als ich ein paar Sachen zum Laufen bekam, war ich überrascht. Mir war schon klar, dass der Cache von enormer Bedeutung sein kann, aber es geht doch nichts darüber, es mit eigenen Augen zu sehen. Ich hatte zwei Programme geschrieben, die exakt dieselbe Berechnung durchführten. Der einzige Unterschied bestand darin, wie viele Cache-Misses sie verursachten. Die Geschwindigkeiten der beiden Programme unterschieden sich um den Faktor 50!

Das hat mir wirklich die Augen geöffnet. Früher hatte ich die Performance für eine Frage des Codes gehalten, nicht der Daten. Ein Byte ist nicht schnell oder langsam, sondern einfach nur ein statisches Etwas. Aber aufgrund des Cachings wirkt sich die Anordnung der Daten unmittelbar auf die Performance aus.

Hier gibt es eine Reihe von Vorbehalten. Insbesondere unterscheiden sich die Caches verschiedener Computer, daher wird sich mein Rechner vermutlich anders verhalten als der Ihre. Außerdem sind spezielle Spielkonsolen sehr verschieden von PCs, die sich wiederum sehr von Mobilgeräten unterscheiden. Das kann von Fall zu Fall völlig anders aussehen.

Die Herausforderung besteht nun darin, das Ganze so zusammenzufassen, dass es in ein Kapitel dieses Buches passt. Die Cache-Optimierung ist ein riesiges Themengebiet und ich habe das Instruktions-Caching noch nicht einmal erwähnt. Man darf nicht vergessen, dass der Code sich ebenfalls im Speicher befindet und vor der Ausführung zur CPU befördert werden muss. Jemand, der sich besser damit auskennt, könnte ein eigenes Buch darüber schreiben.

Tatsächlich hat sogar jemand ein Buch darüber geschrieben: Data-Oriented Design von Richard Fabian.

Da Sie aber gerade schon dieses Buch lesen, möchte ich einige grundlegende Vorgehensweisen vorstellen, die Ihnen für den Anfang dabei helfen können, sich Gedanken über den Einfluss von Datenstrukturen auf die Performance zu machen.

Letzten Endes ist es eigentlich ganz einfach: Sobald der Chip auf den Arbeitsspeicher zugreift, erhält er eine komplette Cache-Zeile. Je mehr Inhalt dieser Cache-Zeile verwendbar ist, desto schneller arbeitet der Prozessor. Ziel muss es also sein, die Datenstrukturen so aufzubauen, dass sich die zu verarbeitenden Daten im Speicher nebeneinander befinden.

Hier gibt es allerdings eine entscheidende Voraussetzung: nur ein Thread. Falls Sie nebeneinander befindliche Daten in mehreren Threads modifizieren, findet die Verarbeitung schneller statt, wenn sich die Daten in verschiedenen Cache-Zeilen befinden. Wenn nämlich zwei Threads dieselbe Cache-Zeile modifizieren, müssen beide beteiligten Prozessorkerne zeitraubende Synchronisierungen ihrer Caches durchführen.

Mit anderen Worten: Wenn Ihr Code zunächst Jetzt verarbeitet, danach Sofort und schließlich Sogleich, sollten die Daten folgendermaßen im Speicher angeordnet sein:

Abb. 17.3: Drei Objekte kuscheln sich im Speicher aneinander

Beachten Sie, dass es sich hier nicht um Zeiger auf Jetzt, Sofort und Sogleich handelt, sondern um die tatsächlichen Daten, die fein säuberlich der Reihe nach aufeinanderfolgen. Wenn die CPU Jetzt gelesen hat, wird sie auch Sofort und Sogleich gelesen haben (sofern die Objekte nicht zu groß sind und in die Cache-Zeile passen). Wenn sie verarbeitet werden, befinden sie sich bereits im Cache. Die CPU ist zufrieden und Sie hoffentlich auch.

17.2  Das Pattern

Moderne CPUs verwenden Caches zur Beschleunigung des Speicherzugriffs. Die CPUs können auf Speicherbereiche in der Nähe des zuvor verwendeten Speichers erheblich schneller zugreifen. Nutzen Sie das zu Ihrem Vorteil, indem Sie die Datenlokalität erhöhen: Speichern Sie die Daten in zusammenhängenden Speicherbereichen in der Reihenfolge ihrer Verarbeitung.

17.3  Anwendbarkeit

Wie bei den meisten Optimierungen gilt für das Pattern Data Locality (Datenlokalität) die Goldene Regel, es nur anzuwenden, wenn ein Performanceproblem vorliegt. Verschwenden Sie keine Zeit damit, es auf irgendwelchen selten ausgeführten Code anzuwenden. Überflüssigerweise optimierter Code macht Ihnen nur das Leben schwer, weil er fast immer komplizierter und weniger flexibel ist.

Gerade bei diesem Pattern sollten Sie sich vergewissern, dass die Performanceprobleme auch tatsächlich durch Cache-Misses verursacht werden. Wenn der Code aus anderen Gründen langsam ist, hilft es Ihnen nicht weiter. Die einfachste Methode, ein Performanceprofil zu erstellen, ist das Hinzufügen von ein wenig Code, der hoffentlich mittels eines präzisen Timers die Zeitspanne misst, die für das Ausführen bestimmter Codeabschnitte erforderlich ist. Das Aufspüren von Cache-Misses ist etwas aufwendiger. Bei Performanceproblemen herauszufinden, wo und wie viele Cache-Misses auftreten, ist beinahe unverzichtbar.

Glücklicherweise gibt es Programme zur Profilerstellung, die dergleichen überprüfen können. Es lohnt sich, eins davon zum Laufen zu bringen und die (überraschend komplexen) Kennzahlen interpretieren zu können, die es Ihnen liefert, bevor Sie größere Änderungen an Ihren Datenstrukturen vornehmen.

Leider sind die meisten derartigen Programme nicht ganz billig. Falls Sie zu einem Entwicklerteam von Konsolenspielen gehören, sind Sie vermutlich bereits im Besitz einer Lizenz.

Wenn nicht, ist Cachegrind eine hervorragende kostenlose Alternative. Ihr Programm läuft dabei auf einer simulierten CPU mit Cache-Hierarchie und Cachegrind zeigt die stattfindenden Cache-Operationen an.

17.4  Konsequenzen

Eins der Merkmale der Softwarearchitektur ist die Abstraktion. Ein großer Teil dieses Buches befasst sich mit Patterns, die der Entkopplung von Codeabschnitten dienen, damit sie leichter modifiziert werden können. In objektorientierten Sprachen bedeutet dies fast immer die Verwendung von Interfaces.

In C++ ist damit stets der Zugriff auf Objekte mittels Zeigern oder Referenzen verbunden. Der Einsatz von Zeigern bedeutet jedoch, im Arbeitsspeicher hin- und herzuspringen, wodurch Cache-Misses verursacht werden, die dieses Pattern ja gerade verhindern soll.

Bei der Anwendung dieses Patterns werden Sie kaum umhinkommen, einige der geschätzten Abstraktionen aufzugeben. Je stärker Ihr Programm auf Datenlokalität ausgerichtet ist, desto häufiger müssen Sie auf Vererbung, Interfaces und andere Vorteile verzichten. Einen Königsweg gibt es hier nicht, sondern nur herausfordernde Kompromisse – und das macht ja gerade den Reiz aus!

Das Gegenstück zu Interfaces sind Aufrufe virtueller Methoden. Sie machen es erforderlich, dass die CPU in der Vtable des Objekts nach dem Zeiger für den eigentlichen Aufruf der Methode suchen muss. Es müssen also wieder Zeiger nachverfolgt werden, was wiederum zu Cache-Misses führen kann.

17.5  Beispielcode

Wenn Sie sich damit befassen, eine Optimierung zwecks erhöhter Datenlokalität vorzunehmen, werden Sie feststellen, dass es zahllose Möglichkeiten gibt, die Datenstrukturen in kleine Häppchen aufzuteilen, die Ihre CPU besser verdauen kann. Zum Einstieg stelle ich für einige der gebräuchlichsten Methoden zur Strukturierung der Daten jeweils ein Beispiel vor. Diese Beispiele beziehen sich hier auf einen bestimmten Programmteil einer Spiel-Engine. Die allgemeine Vorgehensweise lässt sich jedoch wie auch bei anderen Patterns generell anwenden.

17.5.1  Aneinandergereihte Arrays

Fangen wir mit einer Game Loop (Hauptschleife, Kapitel 9) an, die verschiedene Objekte des Spiels verarbeitet. Die Objekte sind unter Verwendung des Patterns Component (Komponente, Kapitel 14) aus verschiedenen Bereichen zusammengesetzt – KI, Physik und Rendering. Hier die GameEntity-Klasse:

class GameEntity
{
public:
  GameEntity(AIComponent* ai,
             PhysicsComponent* physics,
             RenderComponent* render)
  : ai_(ai), physics_(physics), render_(render)
  {}

  AIComponent* ai() { return ai_; }
  PhysicsComponent* physics() { return physics_; }
    RenderComponent* render() { return render_; }

private:
  AIComponent* ai_;
  PhysicsComponent* physics_;
  RenderComponent* render_;
};

Die einzelnen Komponenten besitzen nur einige wenige Zustände, kaum mehr als ein paar Vektoren oder eine Matrix sowie eine Methode zur Aktualisierung. Die Einzelheiten sind nicht von Bedeutung, aber Sie können sich das in etwa wie folgt vorstellen:

class AIComponent
{
public:
  void update()
  {
    // Zustand verarbeiten und ändern ...
  }

private:
    // Ziele, Stimmung usw.
};

class PhysicsComponent
{
public:
  void update()
  {
    // Zustand verarbeiten und ändern ...
  }

private:
  // Starre Körper, Geschwindigkeit, Masse usw.
};

class RenderComponent
{
public:
  void render()
  {
    // Zustand verarbeiten und ändern ...
  }

private:
  // Netz, Texturen, Shader usw.
};

Wie der Name bereits vermuten lässt, ist die update()-Methode ein Beispiel für das Pattern Update Method (Aktualisierungsmethode, Kapitel 10). Auch render() arbeitet nach diesem Muster, wenn auch unter einer anderen Bezeichnung.

Das Programm führt in Form eines großen Arrays mit Zeigern Buch über sämtliche Objekte in der Spielwelt. Bei jedem Durchlauf der Game Loop müssen die folgenden Aufgaben erledigt werden:

Aktualisierung der KI-Komponenten aller Spielobjekte.

Aktualisierung der Physik-Komponenten der Spielobjekte.

Rendering mittels der Render-Komponente der Spielobjekte.

Viele Spiel-Engines implementieren das folgendermaßen:

while (!gameOver)
{
  for (int i = 0; i < numEntities; i++)
  {
    entities[i]->ai()->update();
  }

  for (int i = 0; i < numEntities; i++)
  {
    entities[i]->physics()->update();
  }

  for (int i = 0; i < numEntities; i++)
  {
    entities[i]->render()->render();
  }

  // Weitere Mechanismen der Game Loop ...
}

Das scheint völlig harmlos zu sein, wenn man noch nie etwas vom CPU-Cache gehört hat. Aber mittlerweile beschleicht Sie eine Ahnung, dass hier irgendetwas nicht stimmt: Dieser Code überlastet und piesackt den Cache nicht nur, sondern packt ihn am Schlafittchen und schlägt ihn zu Brei! Sehen Sie sich an, was hier vor sich geht:

Das entities-Array speichert Zeiger auf die Spielobjekte, daher muss bei jedem Element des Arrays ein Zeiger nachverfolgt werden – und das führt zu einem Cache-Miss.

Die GameEntity-Klasse verwendet einen Zeiger für die jeweilige Komponente – ein weiterer Cache-Miss.

Dann werden die Komponenten aktualisiert.

Jetzt wird der gesamte Vorgang wiederholt – mit jeder einzelnen Komponente sämtlicher Objekte des Spiels.

Das Erschreckende daran ist, dass wir keine Ahnung haben, wie diese Objekte im Speicher abgelegt werden. Wir sind vollständig der Speicherverwaltung ausgeliefert. Durch die Reservierung und Freigabe von Speicherplatz für die Objekte wird der Heap im Laufe der Zeit zunehmend durcheinandergewürfelt.

Abb. 17.4: Bei jedem Frame muss die Game Loop den Pfeilen folgen, um an die benötigten Daten zu gelangen.

Wäre es unser Ziel, in Windeseile den gesamten Adressraum des Spiels wie bei einer Stadtrundfahrt nach dem Motto »256 MB RAM in 4 Stunden!« aufzusuchen, wäre das ein fantastisches Angebot. Das Ziel ist jedoch ein schnell laufendes Spiel – und den gesamten Hauptarbeitsspeicher abzuklappern, bringt uns dabei überhaupt nicht weiter. Erinnern Sie sich an die sleepFor500Cycles()-Funktion? Der obige Code ruft sie praktisch ständig auf.

Der Fachbegriff für die Zeitverschwendung durch das Nachverfolgen von Zeigern lautet Pointer Chasing (»Zeiger jagen«) – das klingt spaßiger als es tatsächlich ist.

Das können wir besser. Zunächst einmal ist festzustellen, dass es nur einen Grund dafür gibt, einem Zeiger zu folgen, um an ein Spielobjekt zu gelangen, nämlich unmittelbar einem weiteren Zeiger zu folgen, der auf die Komponente verweist. Die GameEntity-Klasse selbst verfügt über keinen interessanten Zustand und besitzt auch keine nützlichen Methoden. Die Game Loop benötigt nur die Komponenten. Anstatt eine riesige Ansammlung von Spielobjekten und Komponenten in der tiefschwarzen Finsternis des Adressraums zu verteilen, kommen wir auf den Boden der Tatsachen zurück. Wir verwenden für jeden Komponententyp ein großes eigenes Array – eins für KI-Komponenten, ein weiteres für Physik-Komponenten und ein drittes für Render-Komponenten:

AIComponent* aiComponents =
    new AIComponent[MAX_ENTITIES];
PhysicsComponent* physicsComponents =
    new PhysicsComponent[MAX_ENTITIES];
RenderComponent* renderComponents =
    new RenderComponent[MAX_ENTITIES];

Was mir an Komponenten am wenigsten gefällt, ist die Länge des Wortes »Komponenten«.

Ich möchte betonen, dass die Arrays Komponenten enthalten – keine Zeiger auf Komponenten. Die Daten befinden sich an Ort und Stelle, Byte für Byte, und die Game Loop kann sie direkt durchlaufen:

while (!gameOver)
{
  for (int i = 0; i < numEntities; i++)
  {
    aiComponents[i].update();
  }

  for (int i = 0; i < numEntities; i++)
  {
    physicsComponents[i].update();
  }

  for (int i = 0; i < numEntities; i++)
  {
    renderComponents[i].render();
  }

  // Weitere Mechanismen der Game Loop ...
}

Dass im neuen Code kaum noch »->«-Operatoren vorhanden sind, ist ein Anzeichen dafür, dass diese Lösung besser ist. Falls Sie die Datenlokalität erhöhen möchten, sollten Sie nach Dereferenzierungsoperatoren Ausschau halten, die Sie loswerden können.

Das Pointer Chasing wurde somit aus dem Weg geräumt. Statt im Arbeitsspeicher hin- und herzuspringen, werden die drei aneinandergereihten Arrays direkt der Reihe nach durchlaufen.

Jetzt werden kompakte Datenströme in den hungrigen Rachen der CPU geschaufelt. Bei meinen Tests wurde die Aktualisierungsschleife durch diese Änderung gegenüber der vorhergehenden Version um den Faktor 50 (!) beschleunigt.

Abb. 17.5: Ordentliche Reihen gleichartiger Komponenten

Interessanterweise ist hier kaum etwas von der Verkapselung verlorengegangen. Die Game Loop aktualisiert die Komponenten nun zwar direkt, statt über die GameEntity-Klasse darauf zuzugreifen, allerdings geschah das vorher auf diese Weise, um die richtige Reihenfolge der Verarbeitung zu gewährleisten. Die Komponenten selbst sind jedoch noch immer vernünftig gekapselt. Jede davon verfügt nach wie vor über ihre eigenen Daten und Methoden – wir haben lediglich die Art der Verwendung modifiziert.

Das bedeutet jedoch nicht, dass wir die GameEntity-Klasse loswerden müssen. Wir belassen sie so, wie sie ist, denn die Zeiger auf die Komponenten sind auch in anderen Programmteilen nützlich, etwa um konzeptuelle GameEntity-Objekte mit allem, was dazugehört, herumzureichen. Entscheidend ist, dass die Game Loop das Pointer Chasing umgeht und direkt auf die Daten zugreift.

17.5.2  Gebündelte Daten

Nehmen wir an, wir programmieren ein Partikelsystem. Nach den im vorangehenden Abschnitt gemachten Erfahrungen verwenden wir zu diesem Zweck ein großes zusammenhängendes Array, das mithilfe einer kleinen Klasse verwaltet wird:

class Particle
{
public:
  void update() { /* Gravitation usw. */ }
  // Position, Geschwindigkeit usw.
};

class ParticleSystem
{
  public:
  ParticleSystem()
  : numParticles_(0)
  {}
  void update();

private:
  static const int MAX_PARTICLES = 100000;
  int numParticles_;
  Particle particles_[MAX_PARTICLES];
};

Die ParticleSystem-Klasse ist ein Beispiel für einen speziellen Objektpool (Object Pool, Kapitel 19), der nur einen Objekttyp enthält.

Eine denkbar einfache Aktualisierungsmethode dieses Systems könnte folgendermaßen aussehen:

void ParticleSystem::update()
{
  for (int i = 0; i < numParticles_; i++)
  {
      particles_[i].update();
  }
}

Es zeigt sich jedoch, dass wir gar nicht immer sämtliche Partikel aktualisieren müssen. Das Partikelsystem besteht aus einem Objektpool fester Größe, doch für gewöhnlich flimmern nicht alle darin enthaltene Objekte auch gleichzeitig über den Bildschirm. Hierfür gibt es eine einfache Lösung:

for (int i = 0; i < numParticles_; i++)
{
  if (particles_[i].isActive())
  {
    particles_[i].update();
  }
}

Wir richten für Particle ein Flag ein, das angibt, ob ein Partikel in Benutzung ist oder nicht. In der Aktualisierungsschleife wird der Zustand bei jedem Partikel überprüft. Dadurch gelangt der Status zusammen mit allen anderen Partikeldaten in den Cache. Ist ein Partikel nicht aktiv, wird er übersprungen und wir fahren mit dem nächsten fort. Die sonstigen in den Cache beförderten Partikeldaten sind überflüssig.

Mit maschinennaher Programmierung vertraute Entwickler entdecken hier ein weiteres Problem: Die Ausführung einer if-Anweisung bei jedem einzelnen Partikel kann zu fehlerhaften Sprungvorhersagen und Pipeline-Hazards führen. Bei aktuellen Prozessoren benötigt eine einzelne Instruktion tatsächlich mehrere Taktzyklen. Um die CPU auf Trab zu halten, werden die Instruktionen in Form von Pipelines ausgeführt, damit mit der Abarbeitung der nachfolgenden Instruktionen schon begonnen werden kann, bevor die erste abgeschlossen ist.

Zu diesem Zweck muss die CPU die nachfolgend auszuführende Instruktion vorhersagen. Bei hintereinanderweg geschriebenem Code ist das einfach, bei einem bedingten Programmablauf hingegen wird es problematisch. Während die Instruktionen der if-Anweisung noch ausgeführt werden, muss die CPU erraten, ob ein Partikel aktiv ist oder nicht und dann anfangen, entweder die Instruktionen des update()-Aufrufs oder diejenigen zur Fortsetzung der Schleife ausführen.

Um diese Entscheidung zu treffen, nimmt die CPU eine Sprungvorhersage vor und orientiert sich dabei daran, wie ihr Code beim vorhergehenden Mal abgelaufen ist. Wenn nun aber die Schleife ständig abwechselnd aktive und inaktive Partikel verarbeitet, ist diese Sprungvorhersage häufig falsch.

Sollte dieser Fall eintreten, muss die CPU die irrtümlich schon ausgeführten Instruktionen verwerfen (Entleerung der Pipeline) und von vorn anfangen. Der hierdurch verursachte Geschwindigkeitsverlust ist stark maschinenabhängig, aber dieses Verhalten ist der Grund dafür, dass manche Entwickler es vermeiden, in häufig ausgeführten Schleifen bedingte Abläufe zu programmieren.

Je weniger Partikel aktiv sind, desto größer sind die übersprungenen Speicherbereiche und umso mehr überflüssige Partikeldaten landen im Cache. Zwischen dem Erledigen sinnvoller Arbeit beim Aktualisieren aktiver Partikel kommt es also zu immer mehr Cache-Misses. Falls das Array sehr groß ist und viele inaktive Partikel enthält, kommt das wieder der bekannten Überlastung des Caches gleich.

Dass sich die Objekte in einem zusammenhängenden Array befinden, bewirkt nur wenig, wenn die tatsächlich verarbeiteten Objekte darin nicht aneinandergereiht sind. Wenn es von inaktiven Objekten durchsetzt ist, die übersprungen werden müssen, sind wir wieder beim ursprünglichen Problem angekommen.

Angesichts der Überschrift dieses Abschnitts haben Sie die Lösung dieses Problems vermutlich schon erraten: Statt einer Überprüfung des Flags führen wir eine Sortierung danach durch. Alle aktiven Partikel befinden sich dann am Anfang der Liste. Und wenn bekannt ist, dass all diese Partikel aktiv sind, kann auch die Überprüfung des Flags entfallen.

Außerdem können wir leicht feststellen, wie viele aktive Partikel vorhanden sind. Mit diesen Modifizierungen wird unsere Aktualisierungsschleife zu einer wahren Schönheit:

for (int i = 0; i < numActive_; i++)
{
  particles[i].update();
}

Nun werden überhaupt keine Daten mehr übersprungen. Jedes Byte, das in den Cache befördert wird, gehört zu einem aktiven Partikel, der verarbeitet werden muss.

Ich will damit natürlich nicht sagen, dass Sie in jedem Frame sämtliche Partikel sortieren sollen – das würde den Performancegewinn mehr als aufwiegen. Wir sorgen vielmehr dafür, dass ein bereits sortiertes Array auch sortiert bleibt.

Vorausgesetzt, das Array ist schon sortiert – und das ist der Fall, weil anfangs alle Partikel inaktiv sind –, kann es nur bei der Aktivierung oder Deaktivierung eines Partikels durcheinandergeraten. Diese beiden Fälle lassen sich ziemlich leicht handhaben. Sobald ein Partikel aktiviert wird, verschieben wir ihn ans Ende der Liste aktiver Partikel, indem wir seine Position im Array mit derjenigen des ersten inaktiven Partikels vertauschen:

void ParticleSystem::activateParticle(int index)
{
  // Darf noch nicht aktiv sein!
  assert(index >= numActive_);

  // Mit dem ersten inaktiven Partikel direkt
  // nach den aktiven vertauschen.
  Particle temp = particles_[numActive_];
  particles_[numActive_] = particles_[index];
  particles_[index] = temp;

  numActive_++;
}

Zum Deaktivieren eines Partikels kehren wir den Vorgang um:

void ParticleSystem::deactivateParticle(int index)
{
  // Darf noch nicht inaktiv sein!
  assert(index < numActive_);

  numActive_--;

  // Mit dem letzten aktiven Partikel direkt
  // vor den inaktiven vertauschen.
  Particle temp = particles_[numActive_];
  particles_[numActive_] = particles_[index];
  particles_[index] = temp;
}

Viele Programmierer (mich eingeschlossen) reagieren allergisch darauf, wenn Objekte im Arbeitsspeicher hin- und herbewegt werden. Einen Haufen Bytes mitzuschleppen, mag gegenüber der Zuweisung eines Zeigers schwerfällig erscheinen, wenn man jedoch den zur Nachverfolgung des Zeigers erforderlichen Aufwand berücksichtigt, dann wird deutlich, dass wir mit unserer Intuition mitunter falsch liegen. In manchen Fällen ist es günstiger, ein paar Bytes mitzuschleppen, wenn der Cache dadurch gefüllt bleibt.

Dies ist eine freundliche Erinnerung, bei solchen Entscheidungsfindungen ein Profil zu erstellen.

Die Sortierung der Partikel nach ihrem Aktivitätszustand bringt noch einen weiteren Vorteil mit sich: Es ist überhaupt nicht mehr erforderlich, für jeden Partikel ein Flag zu speichern. Der Zustand lässt sich anhand der Position im Array und der Variablen numActive_ ermitteln. Die Partikel-Objekte werden dadurch kleiner, die Cache-Zeile kann daher mehr Objekte als zuvor aufnehmen und die Partikelverarbeitung wird noch schneller.

Allerdings gibt es auch Nachteiliges. An der API ist erkennbar, dass etwas Objektorientierung verlorengegangen ist. Die Particle-Klasse ist nicht mehr selbst für den Aktivitätszustand verantwortlich und es ist auch nicht möglich, eine activate()-Methode aufzurufen, da sie ihren Index nicht kennt. Stattdessen benötigt jeglicher Code, der Partikel aktivieren möchte, Zugang zum Partikelsystem.

In diesem Fall stört es mich nicht, dass Particle und ParticleSystem so eng miteinander verknüpft sind. Ich stelle mir das Ganze als ein einziges Konzept vor, das eben auf zwei Klassen aufgeteilt ist. Ich akzeptiere damit lediglich den Gedanken, dass Partikel ausschließlich im Rahmen eines Partikelsystems einen Sinn ergeben. Darüber hinaus wird in diesem Fall vermutlich ohnehin das Partikelsystem für die Erzeugung und das Löschen von Partikeln verantwortlich sein.

17.5.3  Hot/Cold Splitting

Jetzt ist das letzte Beispiel an der Reihe, das eine einfache Vorgehensweise zur Verbesserung des Cachings vorstellt. Nehmen wir an, es gäbe eine KI-Komponente, die für ein Spielobjekt zuständig wäre, dass verschiedene Zustände besitzt – eine Animation, die gerade abgespielt wird, eine Zielposition, auf die es zusteuert, Energieniveaus usw. All diese Dinge werden in jedem Frame überprüft und modifiziert, etwa in dieser Art:

class AIComponent
{
public:
void update() { /* ... */ }

private:
  Animation* animation_;
  double energy_;
  Vector goalPos_;
};

Das Spielobjekt besitzt weitere Datenfelder für seltenere Vorkommnisse. Es speichert beispielsweise, welche Art Beute fallengelassen wird, wenn es zu einer unerfreulichen Begegnung mit dem lauteren Ende einer Schusswaffe kommt. Diese Informationen werden im Lebenszyklus des Spielobjekts nur ein einziges Mal verwendet, nämlich bei dessen bitteren Ende:

class AIComponent
{
public:
void update() { /* ... */ }

private:
  // Vorherige Datenfelder ...
  LootType drop_;
  int minDrops_;
  int maxDrops_;
  double chanceOfDrop_;
};

Wenn wir den vorhergehenden Patterns treu geblieben sind, wird bei der Aktualisierung dieser KI-Komponente ein kompaktes, zusammenhängendes Array mit Daten durchlaufen. Diese Daten enthalten jedoch auch die Information über die Beute, die fallengelassen werden soll. Dadurch wird jede Komponente größer und es passen weniger davon in eine Cache-Zeile. Außerdem kommt es häufiger zu Cache-Misses, weil die Größe des insgesamt durchgearbeiteten Speicherbereichs zunimmt. Die Beute-Daten aller Komponenten werden in jedem Frame in den Cache befördert, obwohl sie gar nicht benötigt werden.

Die Lösung dieses Problems wird als Hot/Cold Splitting bezeichnet und besteht darin, die Datenstruktur in zwei Teile aufzuspalten. Der erste Teil enthält die »heißen« Daten, die in jedem Frame erforderlich sind. Der zweite Teil besteht aus den »kalten« Daten, die weniger oft benötigt werden.

Der »heiße« Teil verbleibt in der eigentlichen KI-Komponente, die am häufigsten verwendet wird und daher nicht über einen Zeiger angesprochen werden soll. Die »kalte« Komponente wird ausgelagert, dennoch müssen wir auf sie zugreifen können. Also erhält die »heiße« Komponente einen Zeiger darauf:

class AIComponent
{
public:
  // Methoden ...

private:
  Animation* animation_;
  double energy_;
  Vector goalPos_;
  LootDrop* loot_;
};

class LootDrop
{
  friend class AIComponent;
  LootType drop_;
  int minDrops_;
  int maxDrops_;
  double chanceOfDrop_;
};

Beim Durchlaufen der Komponenten in jedem Frame werden jetzt nur noch Daten in den Cache befördert, die auch tatsächlich verarbeitet werden (mit Ausnahme des Zeigers auf die »kalten« Daten).

Es wäre durchaus möglich, auch auf diesen einen Zeiger zu verzichten und für die »heiße« und die »kalte« Komponente jeweils ein eigenes Array zu verwenden. Auf die »kalten« Daten könnte leicht zugegriffen werden, weil sie sich an derselben Indexposition wie die »heißen« befinden.

Man sieht hier allerdings schon, wie die Grenzen anfangen zu verschwimmen. In meinem Beispiel liegt auf der Hand, welche Daten »heiß« und welche »kalt« sein sollten, doch bei einem echten Spiel ist eine so eindeutige Eingrenzung nur selten möglich. Denken Sie beispielsweise an Datenfelder, die von einem Spielobjekt in einem bestimmten Modus genutzt werden, in anderen Modi jedoch nicht. Oder an Spielobjekte, die auf gewisse Daten nur in einem bestimmten Spielabschnitt zugreifen.

Optimierungen dieser Art durchzuführen, ist irgendwo zwischen einem schwarzen Loch und schwarzer Magie einzuordnen: Man kann leicht hineingezogen werden und sich stundenlang damit beschäftigen, Datenfelder hin- und herzuschieben, um herauszubekommen, wie sich das auf die Performance auswirkt. Um hier einen Ansatzpunkt für Ihre Anstrengungen zu finden, ist Erfahrung nötig.

17.6  Designentscheidungen

Dieses Pattern ist eigentlich eine Frage der Mentalität – es bringt Sie dazu, die Anordnung der Daten im Arbeitsspeicher als entscheidenden Bestandteil der Leistungsfähigkeit Ihres Spiels zu betrachten. Bei der Umsetzung stehen Ihnen alle Möglichkeiten offen: Sie können die Datenlokalität in der gesamten Architektur berücksichtigen oder nur gezielt einsetzen und das Pattern lediglich auf einige grundlegende Datenstrukturen anwenden.

In Noel Llopis berühmtem Artikel, der viele Leute dazu gebracht hat, über ein auf die Cache-Verwendung ausgerichtetes Spieledesign nachzudenken, wird das als »datenorientiertes Design« bezeichnet.

Die entscheidende Frage lautet, wann und wo Sie dieses Pattern anwenden. Darüber hinaus werden Ihnen womöglich auch die folgenden Fragen durch den Kopf gehen.

17.6.1  Wie wird Polymorphismus gehandhabt?

Bislang haben wir auf Unterklassenbildung und virtuelle Methoden verzichtet. Wir sind davon ausgegangen, dass es sich um kompakte Arrays gleichartiger Objekte handelt, daher war klar, dass sie alle von exakt gleicher Größe sind. Aber auch Polymorphismus und dynamische Weiterleitung sind nützliche Werkzeuge. Wie lässt sich das unter einen Hut bringen?

Verzichten Sie darauf:

Die einfachste Antwort lautet, auf Unterklassenbildung zu verzichten oder sie zumindest dort zu vermeiden, wo Sie Cache-Optimierungen vornehmen. Der üppige Gebrauch von Vererbung ist ohnehin auf dem absteigenden Ast.

Durch die Nutzung des Patterns Type Object (Typ-Objekt, Kapitel 13) können Sie einen Großteil der Flexibilität des Polymorphismus ohne Unterklassenbildung bewahren.

Diese Vorgehensweise ist sicher und einfach. Sie wissen genau, mit welcher Klasse Sie es zu tun haben und alle Objekte sind von gleicher Größe.

Diese Vorgehensweise ist schneller. Dynamische Weiterleitungen machen es erforderlich, die Methoden in der Vtable nachzuschlagen und den Zeigern zu folgen, um an den eigentlichen Code zu gelangen. Sie sind jedenfalls nicht umsonst zu haben, auch wenn der Performanceverlust stark von der Hardware abhängig ist.

Wie üblich ist nur sicher, dass nichts sicher ist. In den meisten Fällen wird ein C++-Compiler bei einer virtuellen Methode eine dynamische Weiterleitung erzeugen. In manchen Fällen ist der Compiler jedoch in der Lage, eine Entvirtualisierung vorzunehmen und die richtige Methode statisch aufrufen, sofern ihm ihr konkreter Typ bekannt ist. Bei JIT-Compilern für Sprachen wie Java oder JavaScript kommen Entvirtualisierungen häufiger vor.

Diese Vorgehensweise ist unflexibel. Wir verwenden ja schließlich dynamische Weiterleitungen, weil sie eine leistungsfähige Möglichkeit bieten, das Verhalten von Objekten zu variieren. Wenn Sie verschiedenen Spielobjekten in Ihrem Programm unterschiedliche Rendering-Stile oder spezielle Bewegungen und Angriffsmethoden zuweisen möchten, sind virtuelle Methoden ein bewährter Weg, dies umzusetzen. Solche Funktionalitäten stattdessen in eine einzige nicht-virtuelle Methode zu verlagern, die dann z.B. aus einer großen switch-Anweisung besteht, führt schnell zu einem ziemlichen Durcheinander.

Verwenden Sie für jeden Typ ein eigenes Array:

Wir verwenden Polymorphismus, um Verhaltensweisen von Objekten nutzen zu können, deren Typ nicht bekannt ist. Mit anderen Worten: Wir haben es mit einer Art »Wundertüte« zu tun und jedes darin befindliche Objekt soll seine Aufgabe erledigen können, wenn wir es dazu auffordern.

Nun stellt sich natürlich die Frage, wieso man die Objekte dann überhaupt in diese Wundertüte einpackt. Warum nicht stattdessen separate Sammlungen gleichartiger Objekte für die verschiedenen Typen verwenden?

Die Objekte werden kompakt zusammengefasst. Da jedes Array nur gleichartige Objekte enthält, gibt es keine Unregelmäßigkeiten oder andere Merkwürdigkeiten.

Sie können statische Aufrufe tätigen. Wenn die Objekte nach Typen getrennt verfügbar sind, benötigen Sie den Polymorphismus gar nicht mehr. Benutzen Sie einfach reguläre, nicht-virtuelle Methodenaufrufe.

Sie müssen mehrere Objektsammlungen verwalten. Falls Sie viele verschiedene Objekttypen verwenden, kann der Verwaltungsaufwand für die dafür notwendigen Arrays wirklich lästig werden.

Sie müssen sämtliche Typen berücksichtigen. Da Sie für jeden Typ eine eigene Objektsammlung pflegen müssen, ist eine Entkopplung von all diesen Klassen nicht möglich. Das ist ja unter anderem das Schöne am Polymorphismus: Hier gibt es solche Beschränkungen nicht – über ein Interface ansprechbarer Code kann vollständig von all den womöglich zahlreichen Typen entkoppelt werden, die dieses Interface implementieren.

Verwenden Sie ein Zeiger-Array:

Wenn das Caching an sich keine Schwierigkeiten bereitet, ist dies die naheliegende Lösung. Verwenden Sie einfach ein Array mit Zeigern, die auf eine Basisklasse oder abgeleitete Klassen verweisen. Polymorphismus steht zur Verfügung und die Objekte können von beliebiger Größe sein.

Diese Vorgehensweise ist sehr flexibel. Der Code, der das Array nutzt, kann auf sämtliche Typen zugreifen, sofern sie das Interface besitzen, das Sie benutzen möchten.

Dieses Verfahren ist weniger Cache-freundlich. Der nicht gerade Cache-freundliche Umweg über Zeiger ist ja der eigentliche Grund dafür, dass wir uns hier mit Alternativen befassen. Falls die Geschwindigkeit jedoch nur eine untergeordnete Rolle spielt, ist diese Lösung durchaus brauchbar.

17.6.2  Wie werden Spielobjekte definiert?

Falls Sie dieses Pattern zusammen mit dem Pattern Component (Komponente, Kapitel 14) einsetzen, können Sie für die Komponenten, aus denen die Spielobjekte aufgebaut sind, ein kompaktes, zusammenhängendes Array verwenden. Die Game Loop durchläuft die Komponenten direkt, daher ist die GameEntity-Klasse weniger wichtig, aber dennoch in anderen Programmteilen nützlich, in denen Sie ein Objekt nach dem Muster eines GameEntity-Objekts verwenden möchten.

Nun stellt sich die Frage, wie es repräsentiert wird. Wie behält es den Überblick über die Komponenten?

Falls Spielobjekte Klassen sind, die Zeiger auf ihre Komponenten besitzen:

Dies entspricht unserem ersten Beispiel und ist mehr oder weniger die objektorientierte 08/15-Lösung. Die GameEntity-Klasse besitzt Zeiger auf ihre Komponenten. Da es sich einfach nur um Zeiger handelt, spielt es keine Rolle, wo und wie diese Komponenten im Speicher abgelegt werden.

Sie können die Komponenten in zusammenhängenden Arrays speichern. Da es für die GameEntity-Klasse keine Rolle spielt, wo die Komponenten gespeichert sind, können Sie sie in kompakten Arrays speichern, um das Durchlaufen der Komponenten zu optimieren.

Sie können anhand eines Spielobjekts auf dessen Komponenten zugreifen. Sie brauchen nur dem Zeiger zu folgen.

Das Verschieben von Komponenten im Speicher wird erschwert. Beim Aktivieren oder Deaktivieren einer Komponente möchten Sie sie womöglich im Arbeitsspeicher verschieben, damit die aktiven Komponenten am Anfang des Arrays aneinandergereiht gespeichert bleiben. Falls Sie eine Komponente verschieben und ein Spielobjekt einen Zeiger darauf besitzt, müssen Sie ihn aktualisieren, da er sonst ungültig wird.

Falls Spielobjekte Klassen sind, die IDs für ihre Komponenten verwenden:

Die Verwendung von Zeigern auf die Komponenten erschwert es, sie im Speicher zu verschieben. Um hier Abhilfe zu schaffen, können Sie eine Abstrahierung vornehmen und stattdessen eine ID (einen Index) einsetzen, die es ermöglicht, sie aufzufinden.

Der Aufbau der ID und die Vorgehensweise zum Auffinden bleiben dabei Ihnen überlassen. Eine einfache Möglichkeit wäre es, jeder Komponente eine eindeutige ID zuzuweisen und diese IDs in einem Array zu speichern. Etwas komplizierter wäre der Einsatz einer Hash-Tabelle, die jeder ID den aktuellen Index im Komponenten-Array zuordnet.

Diese Vorgehensweise ist komplizierter. Ihr ID-System braucht nicht hochkomplex zu sein, dennoch bereitet es mehr Aufwand als die Verwendung einfacher Zeiger. Sie müssen es implementieren und debuggen, außerdem entsteht durch die Verwaltung zusätzlicher Speicherbedarf.

Dieses Verfahren ist langsamer. Hinsichtlich der Geschwindigkeit sind einfache Zeiger kaum zu schlagen. Um über ein Spielobjekt an eine der Komponenten zu gelangen, ist ein Suchvorgang oder ein vergleichbarer Mechanismus erforderlich.

Sie müssen auf den Komponenten-»Manager« zugreifen können. Die grundlegende Idee ist ja, dass die Komponenten anhand einer abstrakten ID identifiziert werden, die dazu benutzt wird, eine Referenz auf die eigentliche Komponente zu erhalten. Dazu müssen Sie die ID irgendwelchem Code übergeben, der die Komponente ausfindig macht. Dabei handelt es sich um diejenige Klasse, die das zusammenhängende Array der Komponentenobjekte beinhaltet.

Bei der Nutzung einfacher Zeiger benötigen Sie lediglich das Spielobjekt, um dessen Komponenten ausfindig zu machen. Bei dieser Vorgehensweise benötigen Sie das Spielobjekt und das Objekt, das die Komponenten verwaltet.

Sie denken jetzt vielleicht: »Dann mach ich daraus ein Singleton – Problem gelöst!« Nun ja, mehr oder weniger. Werfen Sie doch mal einen Blick in das Kapitel 6 über Singletons.

Falls das Spielobjekt selbst nur aus einer ID besteht:

Hierbei handelt es sich um eine neue Vorgehensweise, die einige Spiel-Engines anwenden. Sobald Sie alle Zustände und Verhaltensweisen des Spielobjekts aus der Hauptklasse in Komponenten verlagert haben, was bleibt dann schon noch übrig? Nicht viel, wie sich herausstellt. Das Spielobjekt verknüpft im Grunde nur noch die verschiedenen Komponenten miteinander. Seine Aufgabe besteht lediglich darin, einer Kreatur der Spielwelt genau diese KI- und Physik-Komponente und exakt jene Rendering-Komponente zuzuordnen.

Das ist wichtig, weil die Komponenten miteinander interagieren. Die Rendering-Komponente muss den Aufenthaltsort des Spielobjekts kennen, der womöglich eine Eigenschaft der Physik-Komponente ist. Wenn die KI-Komponente ein Spielobjekt bewegen möchte, muss sie mittels der Physik-Komponente eine Kraft ausüben können. Jede Komponente muss an die anderen Komponenten des Spielobjekts gelangen können, dessen Bestandteil sie ist.

Einigen schlauen Köpfen ist aufgefallen, dass dazu nur eine ID notwendig ist. Dem Spielobjekt sind nicht mehr seine Komponenten bekannt, vielmehr kennen die Komponenten ihr Spielobjekt: Jeder Komponente ist die ID des Spielobjekts bekannt, zu der sie gehört. Wenn nun die KI-Komponente die Physik-Komponente benötigt, sucht sie einfach nach derjenigen Physik-Komponente mit derselben Spielobjekt-ID, die sie selbst besitzt.

Die Spielobjekt-Klassen verschwinden völlig und werden durch eine hübsch verpackte Zahl ersetzt.

Die Spielobjekte sind verschwindend klein. Zur Übergabe einer Referenz auf ein Spielobjekt genügt ein einzelner Wert.

Die Spielobjekte enthalten nichts mehr. Wenn alles in Komponenten ausgelagert wird, gibt es keinen Ort mehr, an dem Zustände oder Verhaltensweisen gespeichert werden können, die sich keiner Komponente zuordnen lassen. Siehe hierzu auch das Pattern Component (Komponente, Kapitel 14).

Sie brauchen sich nicht um den Lebenszyklus zu kümmern. Die Spielobjekte gehören zu den elementaren Datentypen; Reservierung und Freigabe von Speicherplatz ist nicht notwendig. Ein Spielobjekt wird automatisch gelöscht, sobald keine Komponenten mehr vorhanden sind.

Das Auffinden einer Komponente des Spielobjekts geht womöglich langsam vonstatten. Hier handelt es sich um dieselbe Problematik wie bei der vorangehenden Antwort – nur in umgekehrter Richtung. Um eine Komponente des Spielobjekts aufzufinden, müssen Sie einer ID ein Objekt zuordnen. Dieser Vorgang kann aufwendig sein.

In diesem Fall kommt es jedoch auf die Performance an. Komponenten müssen während einer Aktualisierung oft mit anderen Komponenten ihres Spielobjekts interagieren, daher werden Sie häufig Komponenten auffinden müssen. Den Index der Komponente in ihrem Array als ID des Spielobjekts zu verwenden, ist hier eine mögliche Lösung.

Falls alle Spielobjekte dieselben Komponenten besitzen, sind auch die Komponenten-Arrays identisch aufgebaut: Die Komponente an der dritten Position des KI-Komponenten-Arrays gehört zum selben Spielobjekt wie die Physik-Komponente an dritter Position des Physik-Komponenten-Arrays.

Beachten Sie jedoch, dass Sie dann gezwungen sind, diese Anordnung der Arrays auch beizubehalten – und das kann schwierig werden, falls Sie anfangen, sie anhand unterschiedlicher Kriterien zu sortieren oder umzuordnen. Es könnte beispielsweise Spielobjekte geben, deren Physik-Komponente deaktiviert ist und andere, die unsichtbar sind. Physik- und Rendering-Komponenten-Array für diese beiden Fälle optimal zu sortieren, ist nicht mehr möglich, wenn die Arrays synchronisiert bleiben müssen.

17.7  Siehe auch ...

In weiten Teilen dieses Kapitels geht es um das Pattern Component (Komponente, Kapitel 14), das zweifelsohne zu den am häufigsten genutzten Datenstrukturen gehört, die für die Verwendung mit Caches optimiert werden. Tatsächlich erleichtert die Anwendung des Component-Patterns diese Optimierung sogar. Da die verschiedenen Bereiche (KI, Physik usw.) eines Spielobjekts der Reihe nach aktualisiert werden, ermöglicht die Auslagerung in Komponenten eine Cache-freundliche Aufspaltung der Bestandteile des Spielobjekts.

Das heißt aber nicht, dass dieses Pattern ausschließlich mit Komponenten nutzbar ist! Wenn Sie es mit performancekritischem Code zu tun haben, der große Datenmengen bearbeiten muss, sollten Sie sich Gedanken über die Datenlokalität machen.

Tony Albrechts Artikel Pitfalls of Object-Oriented Programming ist vermutlich die am häufigsten gelesene Einführung in den Aufbau Cache-freundlicher Datenstrukturen. Er hat vielen Leuten (mich eingeschlossen) erst bewusst gemacht, wie bedeutsam sie hinsichtlich der Performance sind.

Ungefähr zur gleichen Zeit schrieb Noel Llopis einen äußerst einflussreichen Blogeintrag zum selben Thema, der den Titel Data-Oriented Design (Or Why You Might Be Shooting Yourself in The Foot With OOP) trägt (Datenorientiertes Design (oder: Warum man sich bei der objektorientierten Programmierung leicht selbst ein Bein stellt)).

Bei diesem Pattern ist es fast unvermeidlich, ein zusammenhängendes Array gleichartiger Objekte zu verwenden. Höchstwahrscheinlich werden Sie schon bald Objekte aus diesem Array entfernen bzw. ihm Objekte hinzufügen. Und genau darum geht es beim Pattern Object Pool (Objektpool, Kapitel 19).

Die Spiel-Engine Artemis gehört zu den ersten und bekannteren Frameworks, die einfache IDs für Spielobjekte nutzen.

Kapitel 18: Dirty Flag (Veraltet-Flag)

Vermeidung überflüssiger Arbeit, indem sie erst dann erledigt wird, wenn das Ergebnis tatsächlich benötigt wird.

18.1  Motivation

Bei vielen Spielen kommt ein sogenannter Szenengraph zum Einsatz. Dabei handelt es sich um eine große Datenstruktur, die sämtliche Objekte der Spielwelt beinhaltet. Die Rendering-Engine greift auf sie zurück, um zu ermitteln, an welcher Bildschirmposition etwas dargestellt werden muss.

Im einfachsten Fall ist ein Szenengraph eine eindimensionale Liste von Objekten. Zu jedem Objekt gehören ein Modell oder ein anderer Grafikbaustein sowie eine Transformationsmatrix. Die Transformationsmatrix enthält die Koordinaten des Objekts und beschreibt außerdem dessen Drehung und Skalierung. Um ein Objekt zu bewegen oder zu drehen, muss lediglich die Transformationsmatrix modifiziert werden.

Die Mechanismen zum Speichern und Ändern der Transformationsmatrix würden an dieser Stelle leider zu weit führen – und die lächerlich kurze Zusammenfassung lautet, dass es sich um eine 4x4-Matrix handelt. Zwei einzelne Transformationen, z.B. eine Verschiebung und eine Drehung des Objekts, können mittels Matrizenmultiplikation zu einer einzigen Transformation kombiniert werden.

Wie und warum das funktioniert, bleibt als interessante Übung dem geneigten Leser zur Erkundung überlassen.

Beim Zeichnen eines Objekts wendet der Renderer die Transformationsmatrix auf das Modell des Objekts an und stellt das Resultat dar. Ginge es hier statt um einen Szenengraphen um eine Szenenkiste, wär's das gewesen und alles wäre schön und gut.

Tatsächlich sind die meisten Szenengraphen jedoch hierarchisch: Ein Objekt im Graphen kann ein Elternobjekt besitzen, mit dem es fest verankert ist. In diesem Fall beziehen sich die Koordinaten auf die Position relativ zum Elternobjekt und geben nicht die absolute Position in der Spielwelt an.

Nehmen wir beispielsweise an, dass es in unserem Spiel ein Piratenschiff auf hoher See gibt. Am oberen Ende des Masts befindet sich das Krähennest, in dem ein Pirat kauert. Auf seiner Schulter krallt sich ein Papagei fest. Die Koordinaten des Schiffs geben dessen Position auf See an, die Koordinaten des Krähennests geben dessen Position auf dem Schiff an usw.

Abb. 18.1: Ahoi!

Auf diese Weise werden die Kindobjekte automatisch mitbewegt, wenn das Elternobjekt bewegt wird. Wenn wir die Koordinaten des Schiffs ändern, begeben sich auch Krähennest, Pirat und Papagei auf die Reise. Es würde ja auch ziemliche Kopfschmerzen bereiten, wenn wir bei einer Bewegung des Schiffs die Koordinaten aller darauf befindlicher Objekte von Hand anpassen müssten, um zu verhindern, dass sie über Bord gehen.

Genau genommen muss man auf hoher See tatsächlich ständig seine Position anpassen, um nicht über Bord zu gehen. Vielleicht hätte ich ein trockeneres Beispiel wählen sollen.

Um nun aber den Papagei auch auf dem Bildschirm darzustellen, benötigen wir seine absolute Position in der Spielwelt. Ich bezeichne die Koordinaten, die sich auf das Elternobjekt beziehen, als lokale Koordinaten und die Transformationsmatrix, die sie enthält als lokale Transformationsmatrix. Zur Darstellung eines Objekts benötigen wir allerdings die Weltkoordinaten.

18.1.1  Lokale Koordinaten und Weltkoordinaten

Die Ermittlung der Weltkoordinaten eines Objekts geschieht auf ziemlich geradlinige Weise: Sie traversieren einfach, vom Wurzelobjekt ausgehend, alle Elternobjekte und kombinieren dabei die jeweilige Transformationsmatrix mit der vorhergehenden. Die Transformationsmatrix mit den Weltkoordinaten des Papageis ergibt sich so:

Abb. 18.2: Berechnung der Weltkoordinaten des Papageis aus den lokalen Koordinaten der Elternobjekte

In jedem Frame werden die Weltkoordinaten aller Objekte der Spielwelt benötigt, daher ist der hierfür verwendete Code performancekritisch – auch wenn nur einige wenige Matrizenmultiplikationen pro Modell auszuführen sind. Die Aktualisierung der Weltkoordinaten ist knifflig, denn wenn sich ein Elternobjekt bewegt, ändern sich dessen Weltkoordinaten und rekursiv auch diejenigen aller Kindobjekte.

Hier der einfachste Ansatz, die Koordinaten während des Renderings zu berechnen: In jedem Frame wird der Szenengraph, ausgehend von der obersten Hierarchieebene, rekursiv traversiert. Wir berechnen dabei die Weltkoordinaten der einzelnen Objekte und stellen sie auf dem Bildschirm dar.

Leider geht damit ein verschwenderischer Umgang mit der kostbaren CPU-Rechenzeit einher – viele Objekte in der Spielwelt bewegen sich nämlich gar nicht in jedem Frame. Denken Sie nur an all die statischen geometrischen Objekte, aus denen die verschiedenen Level bestehen. Ihre Weltkoordinaten neu zu berechnen, obwohl sie sich gar nicht bewegen, wäre Zeitverschwendung.

18.1.2  Gecachete Weltkoordinaten

Die naheliegende Lösung ist das Cachen der Weltkoordinaten. Jedes Objekt speichert seine lokalen Koordinaten und die daraus abgeleiteten Weltkoordinaten. Beim Rendering greifen wir ausschließlich auf die vorherberechneten Weltkoordinaten zurück. Wenn sich das Objekt nie bewegt, bleiben die gecacheten Koordinaten immer aktuell und alle sind zufrieden.

Wenn sich ein Objekt allerdings doch bewegt, wäre der einfachste Ansatz, die Weltkoordinaten in diesem Moment zu aktualisieren. Denken Sie aber an die Hierarchie! Wenn sich ein Elternobjekt bewegt, müssen wir nicht nur dessen Weltkoordinaten neu berechnen, sondern auch rekursiv diejenigen sämtlicher Kindobjekte.

Stellen Sie sich eine belebte Spielszene vor. Das Schiff dümpelt auf dem Ozean, der Wind schaukelt das Krähennest hin und her, der Pirat lehnt sich über dessen Rand und der Papagei hüpft auf seinen Kopf. Wir haben somit vier Mal lokale Koordinaten geändert. Was würde uns blühen, wenn wir bei jeder Änderung lokaler Koordinaten die Weltkoordinaten neu berechneten?

Wir haben nur vier Objekte bewegt, aber zehn Berechnungen von Weltkoordinaten ausgeführt. Diese sechs sinnlosen Berechnungen werden verworfen und vom Renderer überhaupt nicht benutzt. Die Weltkoordinaten des Papageis werden vier Mal berechnet, er wird jedoch nur ein Mal neu gezeichnet.

Abb. 18.3: Jede Menge redundante Berechnungen: An den mit einem Stern markierten Zeilen ist erkennbar, dass die Weltkoordinaten des Papageis vier Mal berechnet werden, obwohl nur das Resultat der letzten Berechnung benötigt wird.

Das Problem beruht darauf, dass die Weltkoordinaten von mehreren lokalen Koordinaten abhängig sein können. Wenn die Koordinaten sofort neu berechnet werden, sobald sich eine der lokalen Koordinaten ändert, führt das dazu, dass dieselben Weltkoordinaten mehrmals berechnet werden, sofern sich im selben Frame mehr als eine der lokalen Koordinaten ändert, von denen sie abhängen.

18.1.3  Verzögerte Berechnung

Wir lösen dieses Problem, indem wir die Änderung lokaler Koordinaten von der Aktualisierung der Weltkoordinaten entkoppeln. Auf diese Weise können wir mehrere lokale Koordinaten »in einem Rutsch« modifizieren und erst danach, wenn diese Änderungen erledigt sind, die betroffenen Weltkoordinaten einmalig direkt vor der Darstellung berechnen.

Es ist bemerkenswert, wie oft bei der Softwarearchitektur solche Umwege genommen werden müssen.

Zu diesem Zweck fügen wir jedem Objekt im Graphen ein Flag hinzu. In diesem Zusammenhang sind »Flag« und »Bit« gleichbedeutend – gemeint ist eine Markierung, die nur einen von zwei Zuständen annehmen kann. Wir bezeichnen diese Zustände als »true« oder »false«, manchmal auch als »gesetzt« oder »gelöscht«. Ich werde die Begriffe in austauschbarer Weise verwenden.

Beim Ändern der lokalen Koordinaten wird das Flag gesetzt. Wenn nun die Weltkoordinaten benötigt werden, überprüfen wir das Flag. Falls es gesetzt ist, berechnen wir die Weltkoordinaten neu und löschen es. Das Flag beantwortet sozusagen die Frage »Sind die Weltkoordinaten veraltet?« Aus ungeklärten Gründen wird dieser veraltete Zustand im Englischen als »dirty« (»schmutzig«) bezeichnet, daher der Name Dirty Flag. Der Begriff »Dirty Bit« ist ebenso gebräuchlich, ich habe mir allerdings gedacht, ich bleibe lieber bei dem weniger anzüglich klingenden Ausdruck.

Wikipedia zeigt da weniger Selbstbeherrschung: Der Titel des entsprechenden Artikels lautet »Dirty Bit«.

Wenn wir das Pattern anwenden und die vier Objekte des vorangehenden Beispiels bewegen, ergibt sich folgender Ablauf:

Abb. 18.4: Keine redundanten Berechnungen mehr

Besser geht es nicht: Die Weltkoordinaten der betroffenen Objekte werden jeweils genau ein Mal berechnet. Dank nur einiger weniger zusätzlicher Daten leistet das Pattern Folgendes:

Statt bei der Traversierung aller Elternobjekte mehrfach Änderungen an den lokalen Koordinaten vorzunehmen, wird alles in einer einzigen Berechnung zusammengefasst.

Bei Objekten, die sich nicht bewegen, findet keine Neuberechnung statt.

Ein kleiner Bonus: Bei vor dem Rendering gelöschten Objekten findet überhaupt keine Neuberechnung der Weltkoordinaten statt.

18.2  Das Pattern

Eine Ausgangsdatenmenge ändert sich im Laufe der Zeit. Durch eine aufwendige Berechnung wird daraus eine abgeleitete Datenmenge erzeugt. Ein Dirty Flag zeigt an, ob sich die abgeleiteten Daten gegenüber den Ausgangsdaten verändert haben. Es wird gesetzt, wenn die Ausgangsdaten modifiziert werden. Ist das Flag beim Zugriff auf die abgeleiteten Daten gesetzt, werden sie erneut verarbeitet und das Flag wird gelöscht. Andernfalls werden die gecacheten abgeleiteten Daten verwendet.

18.3  Anwendbarkeit

Im Vergleich zu einigen anderen Patterns in diesem Buch löst dieses hier ein ziemlich spezielles Problem. Außerdem sollten Sie, wie bei den meisten Optimierungen, nur darauf zurückgreifen, wenn die Performanceprobleme groß genug sind, um die zusätzliche Komplexität des Codes zu rechtfertigen.

Dirty Flags kommen insbesondere bei Berechnungen und Synchronisierungsvorgängen zum Einsatz. In beiden Fällen ist der Vorgang, der aus den Ausgangsdaten die abgeleiteten Daten erzeugt, zeitraubend oder auf andere Art aufwendig.

Beim Szenengraphen geht dieser Vorgang langsam vonstatten, weil viele mathematische Berechnungen erforderlich sind. Bei Synchronisationsvorgängen hingegen verhält es sich oft so, dass sich die abgeleiteten Daten andernorts befinden – entweder auf der Festplatte oder auf einem anderen Rechner im Netzwerk – und der Aufwand besteht schlicht und einfach darin, die Daten von A nach B zu befördern.

Und es gibt noch einige weitere Anforderungen:

Die Ausgangsdaten müssen sich häufiger ändern als die abgeleiteten Daten verwendet werden. Das Pattern vermeidet die Erzeugung abgeleiteter Daten, wenn sie vor ihrer Verwendung durch nachfolgende Änderungen an den Ausgangsdaten ungültig werden würden. Sollten Sie feststellen, dass Sie nach jeder Änderung an den Ausgangsdaten die abgeleiteten Daten benötigen, erweist sich das Pattern als nützlich.

Schrittweise Aktualisierungen sollten aufwendig sein. Nehmen wir an, das Piratenschiff kann nur eine begrenzte Beutemenge transportieren. Wir benötigen also das Gesamtgewicht der Objekte im Laderaum. Wir könnten das Pattern anwenden und für das Gesamtgewicht ein Dirty Flag benutzen. Wenn Beute hinzugefügt oder entfernt wird, setzen wir das Flag. Wird das Gesamtgewicht benötigt, summieren wir das Gewicht der Beutegüter und löschen es.

Es wäre jedoch viel einfacher, eine laufende Summe zu verwenden. Beim Hinzufügen oder Entfernen einer Beute, wird deren Gewicht zum Gesamtgewicht addiert bzw. davon abgezogen. Falls es möglich ist, die abgeleiteten Daten auf diese Weise quasi nebenbei zu aktualisieren, ist das oft eine bessere Wahl als dieses Pattern anzuwenden und die abgeleiteten Daten komplett neu zu berechnen.

Das erweckt den Eindruck, dass Dirty Flags nur selten angebracht sind, aber hier und da sind sie doch sehr hilfreich. Wenn Sie in einer typischen Codebasis nach dem Begriff »dirty« suchen, werden Sie das Pattern häufig vorfinden.

Meiner Erfahrung nach werden Sie dabei auch des Öfteren auf Kommentare mit Entschuldigungen für üble Hacks stoßen.

18.4  Konsequenzen

Auch wenn Sie sich davon überzeugt haben, dass dieses Pattern für Ihren Zweck gut geeignet ist, gibt es doch noch das ein oder andere Haar in der Suppe, das für Unannehmlichkeiten sorgen kann.

18.4.1  Nicht zu lange verzögern

Das Pattern schiebt langwierige Vorgänge auf, bis deren Ergebnisse tatsächlich benötigt werden – allerdings werden sie dann oft sofort benötigt. Der eigentliche Grund für die Anwendung dieses Patterns ist aber ja gerade, dass die Berechnung der Ergebnisse langsam vonstattengeht!

In unserem Beispiel tritt dieses Problem nicht auf, weil die Berechnung der Weltkoordinaten schnell genug erfolgt, dass sie in einem Frame stattfinden kann, Sie können sich aber sicher auch andere Fälle vorstellen, in denen die erforderlichen Berechnungen beträchtliche Zeit in Anspruch nehmen. Wenn das Spiel diese Berechnungen erst dann vornimmt, wenn der Spieler erwartet, das Resultat zu sehen, kann das zu einer unschönen sichtbaren Pause führen.

Hier spiegeln sich die verschiedenen Strategien in Systemen mit Garbage Collection (GC, Speicherbereinigung) wider. Beim Reference Counting (Abzählen der Referenzen) wird der Speicherplatz sofort freigegeben, wenn er nicht mehr benötigt wird, allerdings wird dafür auch bei jeder Änderung von Referenzen durch das dienstbeflissene Abzählen CPU-Rechenzeit benötigt.

Eine einfache Garbage Collection verzögert die Freigabe des Speichers so lange, bis sie wirklich unvermeidbar ist. Das wird jedoch durch die gefürchtete »Speichbereinigungs-Gedenksekunde« erkauft, die das gesamte Spiel lahmlegen kann, während der Garbage Collector den Heap durchforstet.

Irgendwo dazwischen liegen komplexere Systeme wie das verzögerte Reference Counting oder die schrittweise GC, die den Speicher weniger eifrig freigeben als das reine Reference Counting, aber häufiger als die alles ausbremsende Garbage Collection.

Die verzögerte Ausführung bringt ein weiteres Problem mit sich: Wenn irgendetwas schiefgeht, wird die Aufgabe womöglich überhaupt nicht erledigt. Das ist insbesondere dann problematisch, wenn Sie das Pattern nutzen, um Zustände dauerhaft zu speichern.

Beispielsweise erkennen Texteditoren, dass Ihr Dokument »ungesicherte Änderungen« enthält. Der kleine Punkt oder das Sternchen in der Titelzeile des Dokumentfensters visualisieren das Dirty Flag. Das geöffnete Dokument im Arbeitsspeicher entspricht den Ausgangsdaten und die abgeleiteten Daten der Datei auf der Festplatte.

Abb. 18.5: Grafische Benutzeroberfläche für ein Dirty Flag

Viele Programme speichern die Daten erst dann auf der Festplatte, wenn entweder das Dokument geschlossen oder das Programm beendet wird. Das ist normalerweise auch schön und gut, aber wenn Sie dann versehentlich das Stromkabel aus der Steckdose ziehen, ist Ihr Meisterwerk dahin.

Texteditoren, die im Hintergrund automatisch eine Kopie des Dokuments speichern, nehmen sich speziell dieses Problems an. Die Häufigkeit der automatischen Speicherung ist dabei ein Kompromiss zwischen möglichst geringem Datenverlust im Fall eines Absturzes und der Belastung des Dateisystems durch ständiges Speichern.

18.4.2  Das Flag bei jedem Zustandswechsel ändern

Da die abgeleiteten Daten anhand der Ausgangsdaten erzeugt werden, handelt es sich im Wesentlichen um einen Cache. Und wenn es um einen Cache geht, ist einer der kniffligsten Aspekte die Cache-Entwertung (Cache Invalidation) – korrekt zu erkennen, wenn die Daten im Cache nicht mehr mit den ursprünglichen Daten übereinstimmen. Für dieses Pattern bedeutet das, bei jeder Änderung der Ausgangsdaten das Dirty Flag zu setzen.

Von Phil Karlton stammt das berühmte Zitat: »In der Informatik sind nur zwei Dinge wirklich schwierig: Cache-Entwertung und die Benennung von Dingen.«

Wenn Sie auch nur an einer Stelle übersehen, das Flag zu setzen, greift Ihr Programm irrtümlich auf veraltete Daten zu. Das verwirrt nicht nur die Spieler, solche Bugs sind auch sehr schwer aufzuspüren. Bei der Anwendung dieses Patterns müssen Sie sicherstellen, dass jeglicher Code, der die Ausgangsdaten modifiziert, auch das Dirty Flag setzt.

Eine Möglichkeit, dieses Problem zu entschärfen, ist die Kapselung der Änderungen an den Ausgangsdaten durch ein Interface. Wenn jeglicher die Ausgangsdaten modifizierende Code auf eine einzige API zugreift, können Sie dort das Dirty Flag setzen und sich somit sicher sein, es nicht zu übersehen.

18.4.3  Vorherige abgeleitete Daten verbleiben im Speicher

Wenn die abgeleiteten Daten benötigt werden und das Dirty Flag nicht gesetzt ist, werden die vorher erzeugten Daten verwendet. Das ist zwar offensichtlich, bedeutet jedoch, dass die abgeleiteten Daten im Arbeitsspeicher verbleiben müssen – für den Fall, dass sie später gebraucht werden.

Falls Sie das Pattern zur Synchronisierung der Ausgangsdaten an einem anderen Speicherort verwenden, ist das unproblematisch: In diesem Fall befinden sich die abgeleiteten Daten für gewöhnlich gar nicht im Arbeitsspeicher.

Ohne den Einsatz dieses Patterns könnten Sie die abgeleiteten Daten bei Bedarf berechnen und sie nach Gebrauch wieder löschen. Das erspart den Aufwand, sie im Arbeitsspeicher zu behalten, wird jedoch dadurch erkauft, dass Sie die Berechnung jedes Mal erneut ausführen müssen, wenn Sie das Resultat benötigen.

Letzten Endes wird bei diesem Pattern – wie bei vielen Optimierungen – Arbeitsspeicher gegen Geschwindigkeit eingetauscht. Die berechneten Daten im Arbeitsspeicher zu belassen, zahlt sich dadurch aus, dass sie nicht erneut berechnet werden müssen, sofern sie sich nicht geändert haben. Dieser Tausch ist sinnvoll, wenn die Berechnungen langwierig sind und genügend Arbeitsspeicher bereitsteht. Falls Ihnen mehr Zeit als Arbeitsspeicher zur Verfügung steht, ist es besser, die Berechnungen bei Bedarf auszuführen.

Bei Komprimierungsalgorithmen findet ein solcher Tausch in umgekehrter Richtung statt – sie sparen Speicherplatz auf Kosten der zur Komprimierung bzw. Entkomprimierung erforderlichen Zeit ein.

18.5  Beispielcode

Nehmen wir an, dass alle Anforderungen auf der erstaunlich langen Liste erfüllt sind. Wie sieht das Pattern als Code aus? Wie bereits erwähnt, übersteigt die Transformationsmatrizen zugrunde liegende Mathematik die bescheidenen Ziele dieses Buches bei Weitem, daher verkapsele ich sie in einer Klasse, von deren Implementierung Sie annehmen müssen, dass sie irgendwo im Äther vorhanden ist.

class Transform
{
public:
  static Transform origin();

  Transform combine(Transform& other);
};

Wir benötigen hier lediglich die Operation combine(), um die Weltkoordinaten in der Transformationsmatrix durch Kombination der lokalen Koordinaten in den Transformationsmatrizen der Elternobjekte zu ermitteln. Außerdem gibt es eine origin()-Methode, die eine Art Einheitsmatrix zurückliefert, welche keine Verschiebung, keine Drehung und keine Skalierung bewirkt.

Nun skizzieren wir die Klasse zur Beschreibung eines Objekts im Szenengraphen, das nur die allernotwendigsten Bestandteile besitzt, um das Pattern anwenden zu können:

class GraphNode
{
public:
  GraphNode(Mesh* mesh)
  : mesh_(mesh),
    local_(Transform::origin()) {}

private:
  Transform local_;
  Mesh* mesh_;

  GraphNode* children_[MAX_CHILDREN];
  int numChildren_;
};

Jeder Knoten besitzt eine lokale Transformationsmatrix, die beschreibt, wo er sich relativ zum Elternobjekt befindet. Außerdem gehört zu jedem Knoten ein mesh-Objekt, das die eigentliche Grafik des Objekts enthält. (mesh_ kann auch NULL sein, wenn es sich um unsichtbare Knoten handelt, die nur zur Gruppierung der Kindobjekte dienen.) Schließlich verfügt jeder Knoten über eine (möglicherweise leere) Liste seiner Kindknoten.

Der Szenengraph besteht hier also an der Wurzel nur aus einem einzelnen GraphNode-Objekt, dessen Kindobjekte (und Enkelobjekte usw.) die Objekte der Spielwelt sind:

GraphNode* graph_ = new GraphNode(NULL);
// Hinzufügen eines Kindobjekts zum
// Wurzelknoten des Szenengraphen ...

Um nun einen Szenengraphen zu rendern, brauchen wir nur, ausgehend vom Wurzelknoten, den aus den verschiedenen Knoten bestehenden Baum zu traversieren und jeweils die folgende Funktion mit dem mesh-Objekt und der Transformationsmatrix aufzurufen, in der die Weltkoordinaten enthalten sind:

void renderMesh(Mesh* mesh, Transform transform);

Wir werden das hier nicht weiter verfolgen, aber die Implementierung müsste dem Renderer die Informationen bereitstellen, die er bräuchte, um das Objekt am angegebenen Ort zeichnen zu können. Wenn das mit jedem Knoten im Szenengraphen korrekt und effizient funktioniert, ist schon viel gewonnen.

18.5.1  Nicht-optimierte Traversierung

Um Nägel mit Köpfen zu machen, programmieren wir nun zum Rendern des Szenengraphen eine Traversierung, die die Weltkoordinaten »on the fly« berechnet. Das ist zwar nicht optimal, aber dafür einfach. Wir fügen der GraphNode-Klasse folgende Methode hinzu:

void GraphNode::render(Transform parentWorld)
{
  Transform world = local_.combine(parentWorld);
  if (mesh_) renderMesh(mesh_, world);
  for (int i = 0; i < numChildren_; i++)
  {
    children_[i]->render(world);
  }
}

Hier übergeben wir mittels parentWorld die Transformationsmatrix mit den Weltkoordinaten des Elternobjekts des Knotens. Um die Transformationsmatrix mit den Weltkoordinaten dieses Knotens zu erhalten, muss nur noch parentWorld mit der lokalen Transformationsmatrix des Knotens kombiniert werden. Wir brauchen dann die Kette der Elternobjekte nicht mehr hinaufzuwandern, weil wir die Berechnung der Transformationsmatrizen schon beim Hinabsteigen erledigen.

Wir berechnen die Transformationsmatrix mit den Weltkoordinaten des Knotens, speichern sie in world und stellen das mesh-Objekt dar, falls vorhanden. Schließlich übergeben wir die Transformationsmatrix mit den Weltkoordinaten dieses Knotens an den nachfolgenden. Alles in allem handelt es sich um eine kompakte, einfache rekursive Methode.

Zum Rendern des gesamten Szenengraphen starten wir den Vorgang mit dem Wurzelknoten:

graph_->render(Transform::origin());

18.5.2  Let’s Get Dirty

Der Code macht alles richtig – er rendert sämtliche mesh-Objekte am richtigen Ort. Allerdings ist er dabei nicht effizient, denn er ruft in jedem Frame für jeden Knoten im Graphen local_.combine(parentWorld) auf. Das Pattern kann hier Abhilfe schaffen. Zunächst einmal fügen wir der GraphNode-Klasse zwei Datenfelder hinzu:

class GraphNode
{
public:
  GraphNode(Mesh* mesh)
  : mesh_(mesh),
    local_(Transform::origin()),
    dirty_(true)
  {}

  // Weitere Methoden ...

private:
  Transform world_;
  bool dirty_;

  // Weitere Datenfelder ...
};

Das Datenfeld world_ cachet die zuvor berechnete Transformationsmatrix mit den Weltkoordinaten und dirty_ ist natürlich das Dirty Flag. Beachten Sie, dass es anfangs den Wert true besitzt. Bei der Erzeugung eines neuen Knotens wurde die Transformationsmatrix mit Weltkoordinaten ja noch gar nicht berechnet. Zu diesem Zeitpunkt unterscheidet sie sich daher von der lokalen Transformationsmatrix.

Wir benötigen das Pattern nur, weil Objekte sich bewegen können. Das unterstützen wir folgendermaßen:

void GraphNode::setTransform(Transform local)
{
  local_ = local;
  dirty_ = true;
}

Entscheidend ist dabei, dass auch hier das Dirty Flag gesetzt wird. Haben wir nicht etwas vergessen? Genau: die Kindknoten!

Wenn ein Elternknoten bewegt wird, sind auch die Weltkoordinaten der Kindobjekte nicht mehr gültig. Dennoch setzen wir ihr Dirty Flag nicht auf true – das wäre zwar machbar, aber rekursiv und entsprechend langsam. Stattdessen nutzen wir beim Rendern einen cleveren Trick:

void GraphNode::render(Transform parentWorld,
                       bool dirty)
{
  dirty |= dirty_;
  if (dirty)
  {
    world_ = local_.combine(parentWorld);
      dirty_ = false;
  }
  if (mesh_) renderMesh(mesh_, world_);
  for (int i = 0; i < numChildren_; i++)
  {
    children_[i]->render(world_, dirty);
  }
}

Hier wird stillschweigend davon ausgegangen, dass die if-Anweisung schneller ist als eine Matrizenmultiplikation. Man sollte meinen, dass die Überprüfung eines einzelnen Bits schneller funktioniert als ein Haufen Fließkommaarithmetik.

Allerdings sind moderne CPUs unglaublich komplex. Sie nutzen in hohem Maße Pipelining – die Einreihung von Befehlssequenzen in verschiedene Warteschlangen. Eine Verzweigung wie die if-Anweisung kann zu einer fehlerhaften Sprungvorhersage führen und die CPU dazu zwingen, ihre Pipeline leeren zu müssen, wodurch Taktzyklen verlorengehen.

Im Kapitel 17 Data Locality (Datenlokalität) wird ausführlicher erläutert, wie moderne CPUs versuchen, die Verarbeitungsgeschwindigkeit zu erhöhen und wie Sie vermeiden können, dass es zu Verzögerungen wie der genannten kommt.

Der Code ist der ursprünglichen naiven Implementierung ziemlich ähnlich. Die entscheidenden Unterschiede bestehen darin, dass wir prüfen, ob der Knoten veraltet ist, bevor wir die Transformationsmatrix mit den Weltkoordinaten berechnen und dass wir das Resultat nicht einer lokalen Variablen, sondern einem Datenfeld zuweisen. Wenn der Knoten aktuell ist, überspringen wir combine() komplett und verwenden den zwar alten, aber nach wie vor korrekten Wert von world_.

Das Clevere ist der dirty-Parameter. Er besitzt den Wert true, sofern irgendein Knoten veraltet ist, der sich in der Kette der Elternknoten oberhalb dieses Knotens befindet. Auf ähnliche Weise wie parentWorld die Weltkoordinaten beim Hinabsteigen der Hierarchie schrittweise aktualisiert, kann dirty nachverfolgen, ob einer der Knoten in der Kette der Elternobjekte veraltet ist.

Beachten Sie, dass dieser Trick nur funktioniert, weil in der GraphNode-Klasse nur render() aktuelle Weltkoordinaten benötigt. Würden auch andere Programmteile darauf zugreifen, müssten wir nach einer anderen Lösung suchen.

Auf diese Weise können wir vermeiden, in der setTransform()-Funktion das Dirty Flag der Kindobjekte rekursiv setzen zu müssen. Stattdessen übergeben wir beim Rendern das Dirty Flag des Elternknotens an die Kindknoten und überprüfen es, um festzustellen, ob eine Neuberechnung der Weltkoordinaten nötig ist.

Das Endresultat ist genau das erwünschte: Zum Ändern der lokalen Koordinaten sind nur einige Zuweisungen erforderlich und beim Rendern wird nur das Minimum an Berechnungen von Weltkoordinaten ausgeführt, die sich seit dem letzten Frame geändert haben.

18.6  Designentscheidungen

Dieses Pattern ist ziemlich spezialisiert, daher sind auch nur wenige Entscheidungen zu treffen.

18.6.1  Wann wird das Dirty Flag gelöscht?

Wenn das Resultat benötigt wird:

Berechnungen werden komplett vermieden, wenn das Resultat gar nicht verwendet wird. Bei Ausgangsdaten, die sich sehr viel häufiger ändern, als Zugriffe auf die abgeleiteten Daten erfolgen, kann das große Vorteile bringen.

Zeitraubende Berechnungen können zu einer merklichen Pause führen. Das Erledigen einer Aufgabe so lange hinauszuzögern, bis der Spieler das Ergebnis zu sehen erwartet, kann das Spielerlebnis negativ beeinflussen. Oftmals ist die Geschwindigkeit hinreichend und stellt kein Problem dar. Andernfalls müssen die Berechnungen früher ausgeführt werden.

Wenn wohldefinierte Zustände eintreten:

In manchen Fällen gibt es im Spielablauf naheliegende Zeitpunkte zur Durchführung der verzögerten Verarbeitung. Vielleicht soll der Spielstand nur gespeichert werden, wenn der Pirat einen Hafen anläuft. Oder ein Synchronisierungsvorgang ist gar nicht Bestandteil der Spielmechanik, wenn die Arbeit z.B. während der Anzeige eines Startbildschirms oder der Wiedergabe einer Videosequenz erledigt wird.

Das Erledigen der Arbeit beeinträchtigt das Spielerlebnis nicht. Im Gegensatz zur vorhergehenden Option ist es oft möglich, den Spieler abzulenken, während das Spiel seine Berechnungen durchführt.

Es liegt nicht in Ihrer Hand, wann die Verarbeitung durchgeführt wird. Gewissermaßen handelt es sich hier um das Gegenteil des vorigen Punktes. Sie können auf unterster Ebene steuern, wann die Verarbeitung stattfindet und dafür sorgen, dass alles reibungslos funktioniert.

Sie können jedoch nicht gewährleisten, dass der Spieler die wohldefinierten Zustände überhaupt erreicht oder andere von Ihnen festgelegte Kriterien erfüllt, die eine Verarbeitung auslösen würden. Wenn Spieler sich verirren oder das Spiel in einen eigentümlichen Zustand gerät, dauert es womöglich länger als erwartet, bis die aufgeschobenen Aufgaben erledigt werden.

Im Hintergrund:

Normalerweise startet man zum Zeitpunkt der ersten Änderung einen Timer mit konstanter Wartezeit und verarbeitet die aufgelaufenen Änderungen, sobald er auslöst.

Im Bereich der Mensch-Maschine-Interaktion gibt es für die absichtliche Verzögerung zwischen Benutzereingabe und der Reaktion eines Programms darauf den Fachbegriff Hysterese.

Sie können steuern, wie häufig die Verarbeitung stattfindet. Durch die Anpassung des Timerintervalls können Sie festlegen, wie oft (oder selten) er auslöst.

Womöglich werden redundante Aufgaben erledigt. Wenn sich der Ausgangszustand während der Dauer eines Timerintervalls kaum ändert, verarbeiten Sie möglicherweise große Mengen nahezu unveränderter Daten.

Sie müssen Aufgaben asynchron erledigen können. Die Verarbeitung von Daten im Hintergrund schließt ein, dass der Spieler im Vordergrund weiterhin beliebige Aktionen ausführen kann. Sie benötigen daher Multithreading oder einen anderen Mechanismus zur parallelen Verarbeitung von Daten, damit das Spiel weitergespielt werden kann.

Da der Anwender beim Spielen höchstwahrscheinlich die Ausgangsdaten modifiziert, die Sie verarbeiten, müssen Sie dafür sorgen, dass dies gefahrlos möglich ist.

18.6.2  Wie feingranular ist Ihr »Dirty-Tracking«?

Nehmen wir an, das Piratenspiel gestattet es den Spielern, Piratenschiffe zu bauen und zu modifizieren. Die Schiffe werden automatisch online gespeichert, damit die Spieler nach einer Unterbrechung den Bau eines Schiffs fortsetzen können. Wir verwenden Dirty Flags, um festzustellen, welche Decks des Schiffs schon ausgerüstet wurden und an den Server gesendet werden müssen. Die an den Server übermittelten Datenblöcke enthalten die modifizierten Schiffsdaten sowie einige Metadaten, die beschreiben, an welchen Teilen des Schiffs Änderungen vorgenommen wurden.

Wenn sie sehr feingranular ist:

In diesem Fall erhält dann z.B. jede einzelne Planke ein Dirty Flag.

Es werden nur Daten verarbeitet, die sich tatsächlich geändert haben. Sie übermitteln exakt diejenigen Aspekte an den Server, die modifiziert wurden.

Wenn sie weniger feingranular ist:

Alternativ könnte z.B. nur jedes Deck ein Dirty Flag erhalten. Wenn irgendetwas an dem Deck verändert wird, gilt das gesamte Deck als veraltet.

Sie werden letztlich auch unveränderte Daten verarbeiten. Fügen Sie dem Deck auch nur ein einziges Rumfass hinzu, wird das gesamte Deck an den Server übertragen.

Mir liegt ein wirklich dummer Witz auf der Zunge, der mit »Stand da so Rum ...« beginnt, aber den verkneife ich mir.

Für die Dirty Flags ist weniger Speicherplatz erforderlich. Wenn Sie zehn Rumfässer hinzufügen, wird nur ein Bit benötigt, um alle zu berücksichtigen.

Es wird weniger Zeit für Standardaufgaben benötigt. Neben der eigentlichen Verarbeitung geänderter Daten sind oftmals Standardaufgaben zu erledigen. In unserem Beispiel wäre das die Auswertung der Metadaten, die angeben, an welchem Teil des Schiffs Änderungen vorgenommen wurden. Je größer die zu verarbeitenden Datenblöcke sind, desto weniger davon gibt es, was wiederum weniger Aufwand bedeutet.

18.7  Siehe auch ...

Dieses Pattern ist außerhalb des Spielumfelds in browserseitigen Web-Frameworks wie Angular gebräuchlich. Das Dirty Flag kennzeichnet diejenigen Daten im Browser, die geändert wurden und an den Server übermittelt werden müssen.

Physik-Engines müssen feststellen können, welche Objekte sich bewegen und welche ruhen. Da sich ruhende Objekte nicht bewegen, sofern keine Kräfte auf sie einwirken, müssen sie auch nicht verarbeitet werden. Dieses »Objekt-bewegt-sich«-Bit ist ein Dirty Flag, das festhält, auf welche Objekte Kräfte einwirken. Die physikalischen Eigenschaften dieser Objekte werden dann neu berechnet.

Kapitel 19: Object Pool (Objektpool)

Verbesserung der Performance und Speichernutzung durch die Wiederverwendung von Objekten aus einem festen Pool, statt der individuellen Speicherreservierung und -freigabe.

19.1  Motivation

Wir arbeiten an den visuellen Effekten unseres Spiels. Wenn der Held einen Zauber wirkt, sollen auf dem Bildschirm Funken sprühen. Dafür brauchen wir ein Partikelsystem, das es ermöglicht, kleine glitzernde Grafiken zu erzeugen und zu animieren, bis die Partikel irgendwann verglühen.

Da schon ein einziges Schwingen des Zauberstabs zur Erzeugung von Hunderten von Partikeln führen kann, muss das System sie sehr schnell bereitstellen können. Noch wichtiger ist, dass es beim Erzeugen und Löschen der Partikel nicht zu einer Speicherfragmentierung kommt.

19.1.1  Der Fluch der Fragmentierung

Software für eine Spielkonsole oder ein Mobilgerät zu entwickeln, ähnelt in vielerlei Hinsicht eher der Embedded- als der PC-Programmierung: Der Arbeitsspeicher ist knapp, die Benutzer erwarten hundertprozentig zuverlässige Spiele, und effizient arbeitende Möglichkeiten zur Speicherverwaltung sind kaum verfügbar. In solch einer Umgebung wäre eine Speicherfragmentierung fatal.

Fragmentierung bedeutet, dass der freie Speicherplatz des Heaps aus vielen kleinen Speicherabschnitten besteht statt aus einem einzelnen großen Block. Der insgesamt verfügbare Speicherplatz mag groß sein, der größte zusammenhängende Speicherbereich kann dennoch viel zu klein sein. Nehmen wir an, dass vierzehn Bytes frei sind, die aber durch einen belegten Speicherbereich in zwei jeweils sieben Byte große Abschnitte getrennt werden. Es ist dann nicht möglich, zwölf Bytes Speicher zu reservieren – keine weiteren Funken auf dem Bildschirm.

Der Vorgang ähnelt dem Einparken in einer vielbefahrenen Straße, in deren Parkstreifen der Abstand zwischen den schon geparkten Autos etwas zu groß ist. Würden sie zusammenrücken, würden noch weitere Autos Platz zum Parken finden, so ist der vorhandene Platz jedoch in zu kleine Parklücken zwischen lediglich einem halben Dutzend Autos fragmentiert.

Hier ist dargestellt, wie der Heap allmählich fragmentiert wird und wie es dadurch zum Scheitern einer Speicherreservierung kommt, obwohl theoretisch ausreichend Speicherplatz vorhanden ist:

Abb. 19.1: Für ein zweites 'BAR'-Objekt ist nicht genug zusammenhängender Speicherplatz vorhanden, obwohl insgesamt genug Kapazität verfügbar ist.

Auch wenn es nur selten zu einer Fragmentierung kommt, macht sie den Heap allmählich zu einem unbrauchbaren Schaum offener Löcher und gefüllter Spalten, der das Spiel letzten Endes komplett zerschießt.

Die meisten Spielkonsolenhersteller führen Stresstests durch, bei denen ein Spiel mehrere Tage lang im Demo-Modus laufen muss. Wenn es abstürzt, untersagen sie die Auslieferung. Manchmal scheitert der Test aufgrund eines nur selten auftretenden Bugs, meist jedoch führen eine schleichende Fragmentierung oder Speicherlecks zum Absturz.

19.1.2  Das Beste beider Welten

Aufgrund der Fragmentierung und womöglich langsamer Speicherreservierung müssen Spiele bei der Speicherverwaltung besonders achtsam sein. Oftmals ist eine einfache Lösung am besten – reservieren Sie beim Start des Spiels hinreichend Speicher und geben Sie ihn erst beim Beenden des Spiels wieder frei. Bei Systemen, die es erforderlich machen, Dinge im laufenden Betrieb zu erzeugen und zu löschen, kann das jedoch zur Qual werden.

Ein Objektpool liefert uns das Beste beider Welten. Für die Speicherverwaltung sieht es so aus, als ob wir anfangs einen großen Speicherbereich reservieren und ihn während des Spiels nicht freigeben. Die Benutzer des Pools können jedoch nach Herzenslust Objekte erzeugen und löschen.

19.2  Das Pattern

Es wird eine Pool-Klasse zur Verwaltung einer Sammlung wiederverwendbarer Objekte definiert. Alle Objekte unterstützen eine »In Gebrauch«-Abfrage, mit der geprüft werden kann, ob ein Objekt gerade aktiv ist. Bei der Initialisierung des Pools wird die gesamte Objektsammlung vorab erzeugt (für gewöhnlich mittels einer einzigen Reservierung zusammenhängenden Speichers). Dabei wird sämtlichen Objekten der Zustand »Nicht in Gebrauch« zugewiesen.

Falls Sie ein Objekt verwenden möchten, bitten Sie einfach den Pool darum. Er sucht nach einem verfügbaren Objekt, weist ihm den Zustand »In Gebrauch« zu und liefert es zurück. Sobald ein Objekt nicht mehr benötigt wird, erhält es wieder den Status »Nicht in Gebrauch«. Auf diese Weise können Objekte erzeugt und gelöscht werden, ohne dass zusätzlicher Speicherplatz oder andere Ressourcen erforderlich sind.

19.3  Anwendbarkeit

Das Pattern wird in Spielen für naheliegende Dinge wie Spielfiguren und visuelle Effekte eingesetzt, findet jedoch auch bei weniger sichtbaren Datenstrukturen Anwendung, beispielsweise beim Abspielen des aktuellen Sounds. Verwenden Sie in den folgenden Fällen einen Objektpool:

Sie müssen häufig Objekte erzeugen und löschen.

Die Objekte sind von gleicher Größe.

Speicherreservierungen auf dem Heap sind langsam oder könnten zu einer Speicherfragmentierung führen.

Jedes Objekt kapselt eine Ressource, an die man nur mit gewissem Aufwand gelangt und die wiederverwendbar ist, beispielsweise eine Datenbank oder eine Netzwerkverbindung.

19.4  Konsequenzen

Normalerweise setzt man die Garbage Collection ein oder verwendet new und delete zur Speicherverwaltung. Durch die Nutzung eines Objektpools stellen Sie sozusagen fest: »Ich weiß besser, wie diese Bytes gehandhabt werden sollten.« Daher liegt es dann auch an Ihnen, mit den Beschränkungen dieses Patterns zurechtzukommen.

19.4.1  Der Pool verschwendet möglicherweise Speicherplatz für ungenutzte Objekte

Die Größe des Objektpools muss an den Bedarf des Spiels angepasst werden. Dabei ist es für gewöhnlich offensichtlich, wenn der Pool zu klein ist (es geht doch nichts über einen Absturz, um sich daran zu erinnern). Achten Sie jedoch auch darauf, dass der Pool nicht zu groß ist. Ein kleinerer Pool gibt Speicherplatz frei, der für andere Dinge genutzt werden kann.

19.4.2  Es steht nur eine feste Anzahl von Objekten zur Verfügung

In mancher Hinsicht ist das gar nicht schlecht. Durch die Aufteilung des Arbeitsspeichers in separate Pools für verschiedene Objekttypen ist sichergestellt, dass es beispielsweise bei einer Vielzahl von Explosionen nicht dazu kommt, dass Ihr Partikelsystem den gesamten verfügbaren Speicherplatz belegt und dadurch Wichtigeres verhindert, wie z.B. das Erzeugen eines neuen Gegners.

Dessen ungeachtet müssen Sie auf die Möglichkeit vorbereitet sein, dass der Versuch scheitert, ein Objekt aus dem Pool wiederzuverwenden, weil schon alle in Gebrauch sind. Für den Umgang hiermit gibt es verschiedene Strategien:

Verhindern Sie es. Dies ist die gebräuchlichste »Lösung«: Passen Sie die Größe der Pools so an, dass es gar nicht erst zu einer vollständigen Auslastung kommen kann. Bei den Pools wichtiger Objekte wie Kontrahenten oder Spielgegenständen ist dies oft die richtige Vorgehensweise. Unter Umständen gibt es auch gar keine einzig wahre Lösung zum Erzeugen des finalen Gegners, wenn keine Objekte mehr verfügbar sind und der Spieler das Ende des Levels erreicht. Daher ist es am vernünftigsten dafür zu sorgen, dass dieser Fall nicht eintreten kann.

Der Nachteil dabei ist natürlich, dass eine Menge Speicherplatz für Objekte reserviert ist, die nur in wenigen Ausnahmefällen tatsächlich gebraucht werden. Aus diesem Grund ist ein Pool fester Größe nicht für alle Spielzustände die beste Lösung. So könnte beispielsweise ein Level besonders aufwendige visuelle Effekte zeigen, ein anderes dagegen großen Wert auf Sound legen. In solchen Fällen sollten Sie in Betracht ziehen, für verschiedene Szenarien Pools unterschiedlicher Größe zu verwenden.

Erzeugen Sie das Objekt einfach nicht. Das klingt ziemlich barsch, kann aber wie im Fall des Partikelsystems durchaus sinnvoll sein. Wenn sämtliche Partikel in Gebrauch sind, ist der Bildschirm vermutlich ohnehin mit blinkenden und flimmernden Grafiken übersät – der Spieler wird gar nicht bemerken, dass die nächste Explosion nicht ganz so beeindruckend ist wie die jetzige.

Löschen Sie ein vorhandenes Objekt zwangsweise. Stellen Sie sich einen Pool mit den aktuell wiedergegebenen Sounds vor und nehmen Sie an, Sie wollen einen weiteren Sound abspielen, aber alle Objekte sind in Gebrauch. Sie wollen den neuen Sound jedoch auch keinesfalls einfach übergehen – der Anwender wird sehr wohl bemerken, dass sein Zauberstab manchmal ein dramatisches Sirren von sich gibt, hin und wieder aber auch trotzig stumm bleibt. Da ist es schon besser, nach dem leisesten Sound zu suchen, der bereits abgespielt wird und ihn durch den neuen zu ersetzen. Das abrupte Ende der Wiedergabe des alten Sounds wird dabei praktischerweise durch den neuen übertönt.

Allgemein gesagt: Wenn das Verschwinden eines vorhandenen Objekts weniger merklich ist als das Fehlen eines neuen, ist diese Vorgehensweise die richtige Wahl.

Vergrößern Sie den Pool. Wenn Sie hinsichtlich des Arbeitsspeichers ein wenig Spielraum haben, können Sie den Pool zur Laufzeit vergrößern oder einen zweiten Pool erzeugen. Wenn Sie dabei zusätzlichen Speicherplatz reservieren, müssen Sie entscheiden, ob der Pool wieder verkleinert werden soll, wenn die erweiterte Kapazität nicht mehr benötigt wird.

19.4.3  Die Objekte sind von fester Größe

Die meisten Pool-Implementierungen speichern die Objekte in einem Array, in dem sie aneinandergereiht werden. Das ist auch schön und gut, sofern alle Objekte vom selben Typ sind. Wenn Sie allerdings Objekte verschiedenen Typs oder Instanzen von Unterklassen mit zusätzlichen Datenfeldern im Pool speichern möchten, muss jeder Speicherplatz im Pool das größtmögliche Objekt aufnehmen können. Andernfalls überlappt ein unerwartet großes Objekt das nachfolgende und bringt den Speicher durcheinander.

Die Verwendung unterschiedlich großer Objekte verschwendet außerdem Speicherplatz. Wie gesagt muss jeder Speicherplatz das größtmögliche Objekt aufnehmen können. Wenn nun aber nur die wenigsten Objekte von dieser Größe sind, wird bei jedem kleineren Objekt Speicherplatz vergeudet. Es verhält sich etwa so, als ob Sie in der Kantine ein quadratmetergroßes Tablett benutzen, auf dem ein ganzes Menü Platz fände, um eine Tasse Kaffee und ein Stück Kuchen zu Ihrem Sitzplatz zu transportieren, obwohl es auch ein viel kleineres täte.

Falls Sie feststellen, dass Sie auf diese Weise Speicherplatz verschwenden, sollten Sie in Betracht ziehen, den Pool in mehrere Pools für Objekte unterschiedlicher Größe aufzuspalten – große Tabletts für Menüs, kleine Tabletts für Kaffee und Kuchen.

Hierbei handelt es sich um ein gängiges Pattern zur Implementierung einer schnellen Speicherverwaltung, die Pools verschiedener Blockgrößen anbietet. Wenn Sie einen Speicherblock anfordern, sucht sie einen freien Block im Pool der angeforderten Größe und stellt ihn zur Verfügung.

19.4.4  Wiederverwendete Objekte werden nicht automatisch zurückgesetzt

Die meisten Speicherverwaltungen ermöglichen das Zurücksetzen neu angeforderter oder freigegebener Speicherbereiche auf irgendwelche offensichtlich »magischen« Werte wie 0xdeadbeef. Dadurch wird es erleichtert, hartnäckige Bugs aufzuspüren, die durch nicht initialisierte Variablen oder den Zugriff auf schon wieder freigegebene Speicherbereiche verursacht werden.

Da unser Objektpool bei der Wiederverwendung von Objekten auf die Dienste der Speicherverwaltung verzichtet, verlieren wir auch diesen Schutzschirm. Schlimmer ist jedoch, dass der für ein »neues« Objekt verwendete Speicherbereich bereits ein Objekt desselben Typs enthielt. Dadurch wird es fast unmöglich zu erkennen, ob man beim Erzeugen des neuen Objekts irgendeine Initialisierung vergessen hat, weil der Speicherbereich, in dem das Objekt abgelegt wird, dank seines Vorlebens schon fast korrekte Daten enthält.

Aus diesem Grund müssen Sie besonders darauf achtgeben, dass der Code zur Initialisierung neuer Objekte diese Aufgabe auch wirklich vollständig erledigt. Es könnte sich sogar lohnen, ein wenig Zeit dafür aufzuwenden, ein Feature zum Debuggen hinzuzufügen, das bei der Wiederverwendung des Objekts den Speicherbereich zurücksetzt.

Ich würde mich geehrt fühlen, wenn Sie es auf den Wert 0x1deadb0b zurücksetzen.

19.4.5  Unbenutzte Objekte verbleiben im Arbeitsspeicher

Objektpools sind bei Systemen mit Garbage Collection weniger gebräuchlich, weil sie sich auch um die Fragmentierung kümmert. Pools sind dennoch nützlich, um den Aufwand zur Reservierung und Freigabe von Speicherbereichen zu vermeiden, insbesondere bei der Entwicklung für Mobilgeräte mit langsameren CPUs und einfacher Garbage Collection.

Falls Sie einen Objektpool zusammen mit der Garbage Collection verwenden, müssen Sie vor einem möglichen Konflikt auf der Hut sein: Da der Pool die Speicherbereiche, die von nicht mehr in Gebrauch befindlichen Objekten belegt werden, nicht freigibt, verbleiben sie im Arbeitsspeicher. Sollten sie jedoch Referenzen auf andere Objekte enthalten, wird dadurch auch die Freigabe der von diesen Objekten belegten Speicherbereiche verhindert. Sie können das vermeiden, indem Sie sämtliche Referenzen auf andere Objekte löschen, sobald ein Objekt des Pools nicht mehr in Gebrauch ist.

19.5  Beispielcode

In richtigen Partikelsystemen spielen oft Gravitation, Wind, Reibung und andere physikalische Effekte eine Rolle. Unser sehr viel einfacheres Beispiel soll lediglich in einer bestimmten Anzahl von Frames einige Partikel entlang einer geraden Linie bewegen und sie dann entfernen – nicht gerade ganz großes Kino, aber es illustriert die Verwendung eines Objektpools.

Wir fangen mit der einfachsten möglichen Implementierung an. Zunächst einmal die Particle-Klasse:

class Particle
{
public:
  Particle()
  : framesLeft_(0)
  {}
  void init(double x, double y, double xVel,
            double yVel, int lifetime);
  void animate();
  bool inUse() const { return framesLeft_ > 0; }

private:
  int framesLeft_;
  double x_, y_;
  double xVel_, yVel_;
};

Der Standardkonstruktor initialisiert das Partikel und weist ihm den Zustand »Nicht in Gebrauch« zu. Ein nachfolgender init()-Aufruf aktiviert es:

void Particle::init(double x, double y,
     double xVel, double yVel, int lifetime)
{
  x_ = x;
  y_ = y;
  xVel_ = xVel;
  yVel_ = yVel;
  framesLeft_ = lifetime;
}

Die Partikel werden durch eine wenig überraschend mit animate() benannte Funktion animiert, die ein Mal pro Frame aufgerufen werden sollte:

void Particle::animate()
{
  if (!inUse()) return;
  framesLeft_--;
  x_ += xVel_;
  y_ += yVel_;
}

Die animate()-Methode ist ein Beispiel für das Pattern Update Method (Aktualisierungsmethode, Kapitel 10).

Der Pool muss feststellen können, welche Partikel zur Wiederverwendung verfügbar sind. Dafür gibt es die inUse()-Funktion, die sich zunutze macht, dass Partikel nur eine begrenzte Lebensdauer besitzen und die framesLeft_-Variable verwendet, um herauszufinden, welche Partikel in Gebrauch sind, ohne ein eigenes Flag dafür speichern zu müssen.

Die Pool-Klasse ist ebenso einfach:

class ParticlePool
{
public:
  void create(double x, double y, double xVel,
              double yVel, int lifetime);
  void animate();

private:
  static const int POOL_SIZE = 100;
  Particle particles_[POOL_SIZE];
};

Die create()-Funktion ermöglicht es außenstehendem Code, neue Partikel zu erzeugen. Das Spiel ruft ein Mal pro Frame die animate()-Methode auf, die wiederum die Partikel im Pool animiert:

void ParticlePool::animate()
{
  for (int i = 0; i < POOL_SIZE; i++)
  {
    particles_[i].animate();
  }
}

Die Partikel selbst sind in einem Array fester Größe in der Klasse gespeichert. In dieser Beispielimplementierung ist die Größe des Pools in der Klassendeklaration fest vorgegeben. Sie könnte jedoch auch durch Verwendung eines dynamischen Arrays mit vorgegebenen Größen oder durch einen Parameter extern festgelegt werden.

Das Erzeugen eines neuen Partikels ist schnörkellos:

void ParticlePool::create(double x, double y,
                   double xVel, double yVel, int lifetime)
{
  for (int i = 0; i < POOL_SIZE; i++)
  {
    if (!particles_[i].inUse())
    {
      particles_[i].init(x, y, xVel, yVel, lifetime);
      return;
    }
  }
}

Wir durchlaufen den Pool und suchen nach dem ersten verfügbaren Partikel. Nachdem es gefunden wurde, wird es initialisiert – und das war's. Beachten Sie, dass bei dieser Implementierung einfach kein Partikel erzeugt wird, falls bereits alle in Gebrauch sind.

Mehr ist für ein einfaches Partikelsystem nicht erforderlich – vom Rendern der Partikel einmal abgesehen. Wir sind nun in der Lage, einen Pool einzurichten und mit seiner Hilfe einige Partikel zu erzeugen. Die Partikel deaktivieren sich automatisch selbst, sobald ihre Lebensdauer abgelaufen ist.

Für die Auslieferung eines Spiels reicht das aus – aber Adleraugen haben vielleicht bemerkt, dass beim Erzeugen eines Partikels (potenziell) die gesamte Objektsammlung durchlaufen werden muss, bis ein verfügbares Objekt gefunden wird. Wenn der Pool sehr groß ist und intensiv genutzt wird, kann das zu einer Verlangsamung führen. Sehen wir uns an, wie sich dies verbessern lässt.

Das Erzeugen von Partikeln ist von der Ordnung O(n) – für diejenigen, die sich noch an ihren Algorithmenkurs erinnern.

19.5.1  Eine kostenlose Liste

Wenn wir keine Zeit damit verschwenden wollen, verfügbare Partikel zu suchen, liegt es auf der Hand, sie gar nicht erst aus den Augen zu verlieren. Wir könnten z.B. eine separate Liste von Zeigern auf die nicht in Gebrauch befindlichen Partikel erstellen. Falls dann ein Partikel erzeugt werden soll, entfernen wir den ersten Zeiger aus der Liste und verwenden das Partikel, auf das er verweist.

Leider ist dazu ein komplett eigenständiges Array notwendig, das für jedes verfügbare Objekt im Pool einen Zeiger enthält. Anfangs sind jedoch alle Objekte im Pool nicht in Gebrauch – das Array besäße ebenso viele Zeiger, wie der Pool Objekte enthält.

Es wäre schön, das Problem beheben zu können, ohne dafür Arbeitsspeicher opfern zu müssen. Praktischerweise gibt es tatsächlich brachliegenden Speicher, den wir verwenden können: den Speicherplatz für die nicht in Gebrauch befindlichen Objekte selbst.

Während ein Partikel nicht verwendet wird, ist der Großteil seines Zustands irrelevant. Position und Geschwindigkeit sind nicht von Interesse. Das Einzige, was benötigt wird, sind die Informationen, die festlegen, ob es aktiv ist. In unserem Beispiel ist das die Membervariable framesLeft_. Die anderen Bestandteile können wiederverwendet werden. Hier eine überarbeitete Particle-Klasse:

class Particle
{
public:
  // Vorheriger Code ...
  Particle* getNext() const { return state_.next; }
  void setNext(Particle* next)
  {
    state_.next = next;
  }

private:
  int framesLeft_;

  union
  {
    // Zustand, falls in Gebrauch
    struct
    {
      double x, y, xVel, yVel;
    } live;

    // Zustand, falls verfügbar
    Particle* next;
  } state_;
};

Wir haben mit Ausnahme von framesLeft_ alle Membervariablen in ein struct namens live innerhalb einer union namens state_ verschoben. live enthält den Zustand des Partikels, während es animiert wird. Wenn das Partikel nicht in Gebrauch ist, wird der zweite Fall der Union benutzt, die Membervariable next. Sie enthält einen Zeiger auf das nachfolgende verfügbare Partikel.

Unions werden heutzutage offenbar nur noch selten verwendet, daher ist Ihnen die Syntax womöglich nicht vertraut. Wenn Sie Mitglied eines Spieleentwicklerteams sind, gibt es unter Ihren Kollegen vermutlich einen »Arbeitsspeicher-Guru«. Das sind vielgefragte Zeitgenossen, deren Aufgabe es ist, eine Lösung zu finden, wenn das Spiel das ihm zugestandene Arbeitsspeicherbudget wie üblich heillos überzogen hat. Sprechen Sie sie auf das Thema »Unions« an. Sie wissen alles darüber und kennen auch den einen oder anderen Trick, um Arbeitsspeicher einzusparen.

Wir können diese Zeiger dazu verwenden, eine verkettete Liste zu erstellen, die sämtliche nicht in Gebrauch befindliche Partikel des Pools enthält. Damit steht eine Liste der verfügbaren Partikel bereit, ohne dass zusätzlicher Arbeitsspeicher erforderlich ist. Stattdessen wird der Speicherplatz der nicht in Gebrauch befindlichen Partikel benutzt, um diese Liste zu speichern.

Eine auf diese clevere Weise gespeicherte Liste wird auch als »kostenlose Liste« (engl. Free List) bezeichnet. Damit das funktioniert, muss sichergestellt sein, dass die Zeiger beim Erzeugen und Löschen von Partikeln korrekt initialisiert und aktualisiert werden. Und selbstverständlich müssen wir den Anfang der Liste genau im Auge behalten:

class ParticlePool
{
  // Vorheriger Code ...

private:
  Particle* firstAvailable_;
};

Nach dem Anlegen eines Pools sind alle Partikel verfügbar, daher sollte die Liste den gesamten Pool beinhalten. Der Konstruktor richtet dies ein:

ParticlePool::ParticlePool()
{
  // Das erste verfügbare Partikel
  firstAvailable_ = &particles_[0];

  // Jedes Partikel verweist auf das nächste
  for (int i = 0; i < POOL_SIZE - 1; i++)
  {
    particles_[i].setNext(&particles_[i + 1]);
  }

  // Das letzte Partikel schließt die Liste ab
  particles_[POOL_SIZE - 1].setNext(NULL);
}

Um nun ein neues Partikel zu erzeugen, greifen wir unmittelbar auf das erste verfügbare zu:

void ParticlePool::create(double x, double y,
           double xVel, double yVel, int lifetime)
{
  // Verfügbarkeit prüfen
  assert(firstAvailable_ != NULL);

  // Aus der Liste verfügbarer Partikel entfernen
  Particle* newParticle = firstAvailable_;
  firstAvailable_ = newParticle->getNext();

  newParticle->init(x, y, xVel, yVel, lifetime);
}

Dieses Verfahren ist nur noch von der Ordnung O(1)! So macht man das!

Wir müssen feststellen können, wann die Lebensdauer eines Partikels abgelaufen ist, um es wieder der Liste der nicht in Gebrauch befindlichen Partikel hinzuzufügen. Deshalb soll die animate()-Funktion true zurückgeben, wenn das Partikel im aktuellen Frame seinen Geist aufgegeben hat:

bool Particle::animate()
{
  if (!inUse()) return false;
  framesLeft_--;
  x_ += xVel_;
  y_ += yVel_;
  return framesLeft_ == 0;
}

Wenn es soweit ist, wird das Partikel wieder zu der Liste ergänzt:

void ParticlePool::animate()
{
  for (int i = 0; i < POOL_SIZE; i++)
  {
    if (particles_[i].animate())
    {
      // Partikel am Anfang der Liste hinzufügen
      particles_[i].setNext(firstAvailable_);
      firstAvailable_ = &particles_[i];
    }
  }
}

Bitte sehr: Ein hübscher kleiner Objektpool, dem in einer konstanten Zeitspanne Objekte entnommen und hinzugefügt werden können.

19.6  Designentscheidungen

Wie Sie gesehen haben, ist die Implementierung des einfachsten Objektpools nahezu trivial: Man erzeugt ein Array mit Objekten und initialisiert sie bei Bedarf neu. Richtiger Code ist allerdings nur selten so schlicht. Es gibt verschiedene Erweiterungsmöglichkeiten, um die Nutzung des Pools flexibler, einfacher und wartungsfreundlicher zu machen. Hinsichtlich der Implementierung eines Pools in Ihrem Spiel sollten Sie sich folgende Fragen stellen:

19.6.1  Sind Objekte an den Pool gekoppelt?

Bei der Programmierung eines Objektpools stellt sich zunächst einmal die Frage, ob den Objekten selbst bekannt ist, dass sie Teil eines Pools sind. Meist ist das der Fall – diesen Luxus können Sie sich jedoch bei einer allgemeinen Objektpool-Klasse, die beliebige Objekte aufnimmt, nicht leisten.

Falls Objekte an den Pool gekoppelt sind:

Die Implementierung ist einfacher. Sie brauchen lediglich ein »In Gebrauch«-Flag oder eine entsprechende Funktion einzurichten – das ist schon alles.

Sie können gewährleisten, dass Objekte ausschließlich vom Pool erzeugt werden. In C++ gibt es dafür die einfache Möglichkeit, den Pool zu einer Friend-Klasse der Objektklasse zu machen und den Konstruktor des Objekts als privat zu deklarieren.

class Particle
{
  friend class ParticlePool;

private:
  Particle() : inUse_(false) {}
  bool inUse_;
};

class ParticlePool
{
  Particle pool_[100];
};

Diese Beziehung dokumentiert die beabsichtigte Verwendung der Klasse und stellt sicher, dass Benutzer keine Objekte erzeugen, die nicht vom Pool verwaltet werden.

Unter Umständen können Sie auf ein »In Gebrauch«-Flag verzichten. Viele Objekte besitzen bereits irgendwelche Zustände, anhand derer sich ermitteln lässt, ob sie in Gebrauch sind oder nicht. So wäre es beispielsweise möglich, ein Partikel wiederzuverwenden, wenn es sich außerhalb des sichtbaren Bildschirms befindet. Wenn der Objektklasse bekannt ist, dass die Objekte Teil eines Pools sind, könnte sie eine inUse()-Methode zur Abfrage des Zustands bereitstellen. Der Pool wird dadurch davor bewahrt, Speicherplatz für einen Haufen überflüssige »In Gebrauch«-Flags zu verschwenden.

Falls Objekte nicht an den Pool gekoppelt sind:

Der Pool kann Objekte beliebigen Typs enthalten. Dies ist der größte Vorteil. Durch die Entkopplung der Objekte vom Pool wird es möglich, eine allgemeine, wiederverwendbare Pool-Klasse zu implementieren.

Der »In Gebrauch«-Status muss außerhalb des Objekts gespeichert werden. Die einfachste Möglichkeit ist die Verwendung eines eigenen Bit-Datenfeldes:

template <class TObject>
class GenericPool
{
private:
  static const int POOL_SIZE = 100;
  TObject pool_[POOL_SIZE];
  bool    inUse_[POOL_SIZE];
};

19.6.2  Wer ist für die Initialisierung der wiederverwendeten Objekte verantwortlich?

Um ein bereits vorhandenes Objekt wiederverwenden zu können, muss es mit neuen Werten initialisiert werden. Die entscheidende Frage ist, ob dies innerhalb oder außerhalb der Pool-Klasse geschieht.

Falls der Pool das Objekt intern initialisiert:

Der Pool kann die Objekte vollständig kapseln. Es hängt von den anderen benötigten Fähigkeiten der Objekte ab, ob es möglich ist, die Objekte komplett im Pool zu kapseln. In diesem Fall ist sichergestellt, dass anderer Code keine Referenzen auf Objekte besitzt, die dann unerwartet wiederverwendet werden.

Der Pool muss berücksichtigen, wie Objekte initialisiert werden. Ein Objekt im Pool stellt möglicherweise mehrere Funktionen zur Initialisierung bereit:

class Particle
{
  // Mehrere Möglichkeiten zur Initialisierung
  void init(double x, double y);
  void init(double x, double y, double angle);
  void init(double x, double y,
            double xVel, double yVel);
};

Falls der Pool die Initialisierung handhabt, muss das Interface sämtliche Initialisierungsmöglichkeiten unterstützen und die Parameter an das Objekt weiterleiten:

class ParticlePool
{
public:
   void create(double x, double y)
  {
    // Weiterleitung an Partikel ...
  }

  void create(double x, double y, double angle)
  {
    // Weiterleitung an Partikel ...
  }

void create(double x, double y,
            double xVel, double yVel)
  {
    // Weiterleitung an Partikel ...
  }
};

Falls außenstehender Code das Objekt initialisiert:

Das Interface der Pool-Klasse ist womöglich einfacher. Anstatt mehrere Funktionen für die verschiedenen Initialisierungsmöglichkeiten anzubieten, kann der Pool einfach eine Referenz auf das neue Objekt zurückgeben:

class Particle
{
public:
  // Mehrere Möglichkeiten zur Initialisierung
  void init(double x, double y);
  void init(double x, double y, double angle);
  void init(double x, double y, double xVel,
                                 double yVel);
};

class ParticlePool
{
public:
  Particle* create()
  {
    // Referenz auf das Partikel zurückgeben
  }
private:
  Particle pool_[100];
};

Der Aufrufer kann das Objekt dann mittels aller von ihm bereitgestellten Methoden initialisieren:

ParticlePool pool;
pool.create()->init(1, 2);
pool.create()->init(1, 2, 0.3);
pool.create()->init(1, 2, 3.3, 4.4);

Das Scheitern des Erzeugens eines neuen Objekts muss von außenstehendem Code gehandhabt werden. In obigem Beispiel wird vorausgesetzt, dass create() stets erfolgreich einen Zeiger auf ein Objekt zurückliefert. Wenn jedoch keine Objekte im Pool verfügbar sind, gibt er stattdessen womöglich NULL zurück. Um auf Nummer sicher zu gehen, muss das vor der Initialisierung des Objekts überprüft werden:

Particle* particle = pool.create();
if (particle != NULL) particle->init(1, 2);

19.7  Siehe auch ...

Das Pattern weist viele Ähnlichkeiten mit dem Flyweight-Pattern (Fliegengewicht, Kapitel 3) auf. Beide verwenden Sammlungen wiederverwendbarer Objekte. Der Unterschied besteht darin, was »wiederverwendbar« genau bedeutet. Fliegengewicht-Objekte werden wiederverwendet, indem dieselbe Instanz mehreren Objekten gleichzeitig bereitgestellt wird. Das Flyweight-Pattern verhindert einen mehrfachen Speicherplatzbedarf durch die Nutzung desselben Objekts in unterschiedlichen Kontexten.

Die Objekte eines Pools werden ebenfalls wiederverwendet, jedoch nur nacheinander. Bei einem Objektpool bedeutet »Wiederverwendung«, dass der Speicherbereich des Objekts erst dann erneut in Anspruch genommen wird, nachdem der ursprüngliche Eigentümer sich des Objekts entledigt hat. Es ist nicht zu erwarten, dass Objekte eines Objektpools während ihrer Lebensdauer gemeinsam genutzt werden.

Objekte gleichen Typs gemeinsam im Arbeitsspeicher abzulegen, sorgt beim Durchlaufen dieser Objekte für einen gut gefüllten CPU-Cache. Genau darum geht es beim Pattern Data Locality (Datenlokalität, Kapitel 17).

Kapitel 20: Spatial Partition (Räumliche Aufteilung)

Effiziente Lokalisierung von Objekten durch deren positionsbasierte Anordnung in einer Datenstruktur.

20.1  Motivation

In Spielen können wir andere Welten aufsuchen, die sich oft gar nicht so sehr von unserer eigenen unterscheiden. Häufig sind ihnen die grundlegenden physikalischen Gesetze und die Konkretheit unseres Universums gemeinsam. Aus diesem Grund können sie realistisch wirken, obwohl sie lediglich aus Bits und Pixeln bestehen.

Wir konzentrieren uns hier auf einen Aspekt der simulierten Realität: die Position bzw. die Örtlichkeit von Objekten. Auch Spielwelten sind räumlich, und irgendwo in diesem Raum befinden sich die Objekte. Das zeigt sich auf vielfältige Art und Weise. Bei der Physik eines Spiels ist das ganz offensichtlich: Objekte bewegen sich, kollidieren und interagieren miteinander. Es gibt jedoch noch andere Beispiele. So kann etwa die Audio-Engine berücksichtigen, an welcher Position sich Schallquellen relativ zum Spieler befinden – entfernte Geräusche erklingen dann leiser. Manchmal sind auch Online-Chats auf andere Spieler in geografischer Nähe beschränkt.

Ihre Spiel-Engine muss daher häufig feststellen, welche Objekte sich in der näheren Umgebung einer bestimmten Örtlichkeit befinden. Wenn dies in jedem Frame oft genug geschieht, kann es hinsichtlich der Performance schnell zu Engpässen kommen.

20.1.1  Kampfeinheiten auf dem Schlachtfeld

Stellen Sie sich vor, wir würden ein Echtzeit-Strategiespiel entwickeln. Die gegnerischen Armeen mit Hunderten von Kampfeinheiten geraten auf dem Schlachtfeld aneinander. Die Krieger müssen feststellen können, welche Gegner sich in der Nähe befinden, um ihre Schwerter zu schwingen. Bei einem naiven Ansatz würden alle Kampfeinheitenpaarungen untersucht und geprüft, wie weit sie voneinander entfernt sind:

void handleMelee(Unit* units[], int numUnits)
{
  for (int a = 0; a < numUnits - 1; a++)
    {
      for (int b = a + 1; b < numUnits; b++)
      {
        if (units[a]->position() ==
            units[b]->position())
        {
          handleAttack(units[a], units[b]);
        }
      }
    }
}

Hierbei handelt es sich um zwei verschachtelte Schleifen, die jeweils sämtliche auf dem Schlachtfeld befindliche Kampfeinheiten durchlaufen. Das bedeutet, dass die Anzahl der paarweisen Überprüfungen in jedem Frame mit dem Quadrat der Kampfeinheiten steigt. Wenn eine weitere Kampfeinheit hinzugefügt wird, muss deren Position mit den Positionen aller anderen Kampfeinheiten verglichen werden. Bei einer größeren Zahl von Einheiten gerät das schnell außer Kontrolle.

Genau genommen durchläuft die innere Schleife nicht alle Kampfeinheiten, sondern nur diejenigen, die von der ersten Schleife noch nicht berücksichtigt wurden. Dadurch wird vermieden, dass jede Paarung doppelt (in beiden möglichen Reihenfolgen) überprüft wird: Wenn die Paarung A und B bereits überprüft wurde, kann man sich die Prüfung der Paarung B und A sparen.

Die Komplexität ist jedoch nach wie vor von der Ordnung O(n2).

20.1.2  Schlachtreihen zeichnen

Wir stoßen hier auf das Problem, dass die Anordnung der Kampfeinheiten im Array keiner Regel folgt. Um eine Kampfeinheit in der Nähe einer bestimmten Position zu finden, muss das gesamte Array durchlaufen werden. Vereinfachen wir das Spiel also ein wenig. Nehmen Sie doch mal an, es handelte sich nicht um ein zweidimensionales Schlachtfeld, sondern um eine eindimensionale Schlachtreihe.

Abb. 20.1: Zahlenstrahl einer Schlacht

In diesem Fall könnten wir das Problem vereinfachen, indem wir das Array nach der Position der Kampfeinheiten auf der Schlachtreihe sortieren. Sobald das erledigt ist, können wir z.B. eine binäre Suche verwenden, um in der Nähe befindliche Kampfeinheiten aufzuspüren, ohne das gesamte Array durchforsten zu müssen.

Die Komplexität einer binären Suche ist von der Ordnung O(log n) – die Komplexität der gesamten Suche sinkt dann von O(n2) auf O(n log n). Mit einem Pigeonhole-Sortieralgorithmus (Schubfachprinzip) ließe sich das weiter auf O(n) senken.

Daraus kann man Folgendes lernen: Wenn wir die Objekte in einer Datenstruktur speichern, die anhand der Objektpositionen organisiert ist, lassen sie sich erheblich schneller finden. Bei diesem Pattern geht es darum, diese Idee auf Räume mit mehr als einer Dimension zu übertragen.

20.2  Das Pattern

Eine Menge von Objekten, die jeweils eine Position im Raum einnehmen, wird in einer Datenstruktur gespeichert, die sie anhand der Objektpositionen anordnet. Diese Datenstruktur ermöglicht eine effiziente Suche nach Objekten an oder in der Nähe einer gegebenen Position. Falls sich die Position eines Objekts ändert, wird die Datenstruktur aktualisiert, damit das Objekt weiterhin gefunden werden kann.

20.3  Anwendbarkeit

Dieses Pattern findet beim Speichern sowohl aktiver, sich bewegender Spielobjekte als auch statischer, kunstvoll gestalteter oder geometrischer Objekte Anwendung. Hochentwickelte Spiele verwenden für unterschiedliche Inhalte oft mehrere verschiedene räumliche Aufteilungen.

Die grundsätzliche Anforderung dieses Patterns ist das Vorhandensein von Objekten, die eine Position im Raum einnehmen, nach denen Sie so häufig suchen müssen, dass die Performance darunter leidet.

20.4  Konsequenzen

Räumliche Aufteilungen sind dazu gedacht, Operationen der Ordnung O(n) oder O(n2) in leichter handhabbare Aufgaben umzuwandeln. Je mehr Objekte es gibt, desto nützlicher ist dieses Pattern. Umgekehrt lohnt der Aufwand womöglich nicht, wenn das n klein genug ist.

Da bei diesem Pattern Objekte anhand ihrer räumlichen Positionen angeordnet werden, ist der Umgang mit Objekten, deren Position sich ändert, schwieriger. Sie müssen die Datenstruktur dann umordnen, um die neue Position eines Objekts zu berücksichtigen. Der Code wird dadurch verkompliziert und es wird mehr CPU-Rechenzeit benötigt. Vergewissern Sie sich also lieber, dass sich der Aufwand lohnt.

Stellen Sie sich eine Hash-Tabelle vor, bei der sich die Schlüssel der Objekte plötzlich ändern, um einen Eindruck davon zu bekommen, wie knifflig das sein kann.

Eine räumliche Aufteilung benötigt außerdem Speicherplatz, um über ihre Datenstrukturen Buch zu führen. Wie bei vielen Optimierungen, wird hier Arbeitsspeicher gegen Performance eingetauscht. Sollte der Arbeitsspeicher knapper als die verfügbare Rechenleistung sein, wäre die Anwendung des Patterns brotlose Kunst.

20.5  Beispielcode

Es liegt in der Natur von Design Patterns, dass sie wandelbar sind – jede Implementierung sieht ein wenig anders aus. Räumliche Aufteilungen bilden da keine Ausnahme. Im Gegensatz zu anderen Patterns sind allerdings viele dieser Variationen gut dokumentiert. Die akademische Welt veröffentlicht gern Beweise für Performancegewinne. Da ich hingegen nur am zugrunde liegenden Konzept des Patterns interessiert bin, werde ich nachfolgend die einfachste aller räumlichen Aufteilungen demonstrieren: ein Gitternetz fester Größe.

Am Ende dieses Kapitels finden Sie eine Liste der in Spielen gebräuchlichsten räumlichen Aufteilungen.

20.5.1  Ein Bogen Millimeterpapier

Stellen Sie sich das gesamte Schlachtfeld vor. Legen Sie nun im Geiste ein Gitternetz fester Größe wie einen Bogen Millimeterpapier darüber. Statt die Einheiten in einem einzelnen Array zu speichern, weisen wir sie den verschiedenen Gitterzellen zu. Jede Zelle speichert eine Liste der Kampfeinheiten, deren Positionen sich innerhalb der Grenzen dieser Zelle befinden.

Abb. 20.2: Das in Zellen aufgeteilte Schlachtfeld

Während der Schlacht finden nur Kampfeinheiten innerhalb derselben Zelle Beachtung. Statt die Position jeder Kampfeinheit mit derjenigen aller anderen zu vergleichen, haben wir das Schlachtfeld in einen Haufen Mini-Schlachtfelder aufgeteilt, die jeweils sehr viel weniger Kampfeinheiten beherbergen.

20.5.2  Ein Gitternetz verketteter Einheiten

Es geht wieder ans Programmieren! Hier die grundlegende Unit-Klasse:

class Unit
{
  friend class Grid;

public:
  Unit(Grid* grid, double x, double y)
  : grid_(grid),
    x_(x),
    y_(y)
  {}

  void move(double x, double y);

private:
  double x_, y_;
  Grid* grid_;
};

Jede Einheit besitzt eine (zweidimensionale) Position und einen Zeiger auf die Gitterzelle, zu der sie gehört. Wir machen Grid zu einer Friend-Klasse, weil beim Wechsel in eine andere Zelle einige komplizierte Verrenkungen notwendig sind, um sicherzustellen, dass alles korrekt aktualisiert wird.

Hier der Code für das Gitternetz:

class Grid
{
  public:
  Grid()
  {
    // Gitterzellen löschen
    for (int x = 0; x < NUM_CELLS; x++)
    {
      for (int y = 0; y < NUM_CELLS; y++)
        {
          cells_[x][y] = NULL;
        }
    }
  }
  static const int NUM_CELLS = 10;
  static const int CELL_SIZE = 20;

private:
    Unit* cells_[NUM_CELLS][NUM_CELLS];
};

Beachten Sie, dass jede Zelle nur ein Zeiger auf eine Einheit ist. Nun erweitern wir die Unit-Klasse um Zeiger auf eine vorhergehende und nachfolgende Einheit:

class Unit
{
  // Vorheriger Code ...

private:
  Unit* prev_;
  Unit* next_;
};

Ich habe im gesamten Buch darauf verzichtet, die verschiedenen Collection-Klassen der C++-Standardbibliothek zu verwenden, damit möglichst wenig zusätzliches Wissen erforderlich ist, um die Beispiele zu verstehen. Außerdem möchte ich – wie ein Zauberer, der demonstriert, dass er kein »Ass im Ärmel« hat – völlig deutlich machen, was der Code bewirkt. Dabei sind die Details wichtig, insbesondere bei Patterns zur Verbesserung der Performance.

Dieser Verzicht betrifft allerdings nur die Erklärungen der Patterns. Bei der Anwendung in echtem Code können Sie sich diese Mühe sparen und auf die praktischen Funktionen zur Verwaltung von Objektsammlungen zurückgreifen, die heutzutage so ziemlich jede Programmiersprache mitbringt. Das Leben ist zu kurz, um verkettete Listen von Grund auf selbst zu entwickeln.

Auf diese Weise können wir die Einheiten mittels einer doppelt verketteten Liste anordnen:

Abb. 20.3: Eine Zelle ist ein Zeiger auf den Kopf einer verketteten Liste von Einheiten.

Jede Zelle des Gitternetzes verweist auf die erste Einheit in der Liste der innerhalb dieser Zelle befindlichen Einheiten. Jede Einheit wiederum besitzt Zeiger auf die vor bzw. hinter ihr befindliche Einheit. Warum dem so ist, wird gleich klar.

20.5.3  Betreten des Schlachtfeldes

Zunächst einmal müssen wir dafür sorgen, dass neue Kampfeinheiten bei ihrer Erzeugung im Gitternetz platziert werden. Die Unit-Klasse erledigt diese Aufgabe in ihrem Konstruktor:

Unit::Unit(Grid* grid, double x, double y)
: grid_(grid),
  x_(x),
  y_(y),
  prev_(NULL),
  next_(NULL)
{
  grid_->add(this);
}

Die add()-Methode wird folgendermaßen definiert:

void Grid::add(Unit* unit)
{
  // Gitterzelle ermitteln
  int cellX = (int)(unit->x_ / Grid::CELL_SIZE);
  int cellY = (int)(unit->y_ / Grid::CELL_SIZE);

  // Am Anfang der Liste der zugehörigen
  // Zelle hinzufügen
  unit->prev_ = NULL;
  unit->next_ = cells_[cellX][cellY];
  cells_[cellX][cellY] = unit;

  if (unit->next_ != NULL)
  {
    unit->next_->prev_ = unit;
  }
}

Das Teilen durch die Zellengröße CELL_SIZE konvertiert die Weltkoordinaten in Zellkoordinaten. Die Typumwandlung durch (int) entfernt die Nachkommastellen und wir erhalten den Index der Zelle.

Der Code ist wie immer bei verketteten Listen etwas fummelig, die zugrunde liegende Idee ist jedoch ganz einfach. Wir ermitteln die Zelle, in der sich die Kampfeinheit befindet und fügen sie am Anfang der Liste der in dieser Zelle befindlichen Einheiten hinzu. Falls es diese Liste bereits gibt, wird sie an die neu hinzugefügte Einheit angehängt.

20.5.4  Klirrende Schwerter

Sobald alle Kampfeinheiten es sich in ihren Zellen gemütlich gemacht haben, können wir sie aufeinander loslassen. Mit dem neuen Gitternetz sieht die Methode zur Handhabung eines Handgemenges folgendermaßen aus:

void Grid::handleMelee()
{
  for (int x = 0; x < NUM_CELLS; x++)
  {
    for (int y = 0; y < NUM_CELLS; y++)
    {
      handleCell(cells_[x][y]);
    }
  }
}

Sie durchläuft sämtliche Zellen und ruft handleCell() mit ihnen auf. Wie Sie sehen, haben wir das Schlachtfeld tatsächlich in kleine, isolierte Scharmützel aufgeteilt. Die einzelnen Zellen handhaben ihr jeweiliges Gefecht dann so:

void Grid::handleCell(Unit* unit)
{
  while (unit != NULL)
  {
    Unit* other = unit->next_;
    while (other != NULL)
    {
      if (unit->x_ == other->x_ &&
          unit->y_ == other->y_)
      {
        handleAttack(unit, other);
      }
      other = other->next_;
    }
    unit = unit->next_;
  }
}

Abgesehen von den Tricksereien beim Durchlaufen der verketteten Listen entspricht dies genau dem ursprünglichen naiven Ansatz zur Handhabung der Schlacht: Es wird jeweils geprüft, ob sich zwei Kampfeinheiten an derselben Position befinden.

Der einzige Unterschied besteht darin, dass wir nicht mehr alle Kampfeinheiten im Gefecht überprüfen – nur diejenigen, die sich in ein und derselben Zelle aufhalten. Dies ist die eigentliche Optimierung.

Auf den ersten Blick könnte man denken, dass sich die Geschwindigkeit tatsächlich verringert, weil nun statt einer zweifach verschachtelten Schleife zum Durchlaufen der Einheiten eine dreifach verschachtelte zum Einsatz kommt, die neben den Einheiten auch noch die Zellen durchläuft. Entscheidend ist hier jedoch, dass die beiden inneren Schleifen deutlich weniger Einheiten bearbeiten müssen – und das reicht aus, um den für die äußere Schleife zum Durchlaufen der Zellen erforderlichen Aufwand mehr als wettzumachen.

Allerdings hängt dieser Aufwand von der Größe der Zellen ab: Sind sie zu klein, kann die äußere Schleife durchaus eine Rolle spielen.

20.5.5  Vormarschieren

Das Performanceproblem ist gelöst, aber nun taucht eine andere Schwierigkeit auf: Die Einheiten hängen in ihren Zellen fest. Wenn wir eine Einheit über die Grenze ihrer Zelle hinaus bewegen, wird sie von den anderen in der Zelle befindlichen Einheiten nicht mehr berücksichtigt. Tatsächlich wird sie dann überhaupt nicht mehr berücksichtigt – da haben wir wohl über das Ziel hinausgeschossen.

Um dieses Problem zu beheben, müssen wir beim Bewegen einer Einheit ein paar kleine Aufgaben erledigen. Wenn eine Einheit die Grenze ihrer Zelle überquert, muss sie aus dieser Zelle entfernt und der neuen hinzugefügt werden. Zunächst einmal erhält die Unit-Klasse eine Methode zum Ändern ihrer Position:

void Unit::move(double x, double y)
{
  grid_->move(this, x, y);
}

Diese Methode wird dann vermutlich vom KI-Code aufgerufen, um computergesteuerte Einheiten zu bewegen und vom Code, der Benutzereingaben verarbeitet, um die Kampfeinheiten des Spielers zu steuern. Sie übergibt die Kontrolle an die Grid-Klasse, die dann Folgendes erledigt:

void Grid::move(Unit* unit, double x, double y)
{
  // In welcher Zelle befindet sich die Einheit?
  int oldCellX = (int)(unit->x_ / Grid::CELL_SIZE);
  int oldCellY = (int)(unit->y_ / Grid::CELL_SIZE);

  // In welche Zelle wird sie bewegt?
  int cellX = (int)(x / Grid::CELL_SIZE);
  int cellY = (int)(y / Grid::CELL_SIZE);

  unit->x_ = x;
  unit->y_ = y;

  // Keine neue Zelle – fertig!
  if (oldCellX == cellX && oldCellY == cellY) return;

  // Aus der Liste der alten Zelle entfernen
  if (unit->prev_ != NULL)
  {
    unit->prev_->next_ = unit->next_;
  }
  if (unit->next_ != NULL)
  {
    unit->next_->prev_ = unit->prev_;
  }

  // Entfernen, wenn am Anfang der Liste
    if (cells_[oldCellX][oldCellY] == unit)
    {
      cells_[oldCellX][oldCellY] = unit->next_;
}
    // Einheit dem Gitternetz an der neuen
    // Position wieder hinzufügen
add(unit);
}

Das ist zwar eine Menge Code, dafür ist er aber völlig unkompliziert: Der erste Abschnitt prüft, ob überhaupt eine Zellengrenze überschritten wurde – wenn nicht, muss nur die Position der Einheit aktualisiert werden und das war's dann schon.

Hat die Einheit jedoch ihre Zelle verlassen, wird sie aus der verketteten Liste der alten Zelle gelöscht und dem Gitternetz wieder hinzugefügt. Wie beim Hinzufügen einer neuen Einheit wird sie in die verkettete Liste der neuen Zelle eingefügt.

Aus diesem Grund wird eine doppelt verkettete Liste verwendet: Sie ermöglicht es, durch einige Zeigerzuweisungen schnell und einfach Einheiten hinzuzufügen oder zu entfernen. Sollten in jedem Frame viele Einheiten hin- und herbewegt werden, kann das durchaus eine Rolle spielen.

20.5.6  Um Armeslänge

Das Ganze scheint ziemlich einfach zu sein, allerdings habe ich an einer Stelle geschummelt: Im vorgestellten Beispiel interagieren die Kampfeinheiten nur dann, wenn sie sich exakt an derselben Position befinden. Für Brettspiele wie Schach oder Dame ist das auch korrekt, für realistischere Spiele aber eher nicht, denn in solchen Spielen muss für gewöhnlich eine Art Angriffsreichweite berücksichtigt werden.

Das Pattern funktioniert dennoch tadellos, nur muss statt der Überprüfung einer exakten Überstimmung der Position Folgendes getestet werden:

if (distance(unit, other) < ATTACK_DISTANCE)
{
  handleAttack(unit, other);
}

Wenn es um den Abstand geht, ist ein spezieller Grenzfall zu beachten: Kampfeinheiten in verschiedenen – aber benachbarten – Zellen sind einander womöglich nah genug, um aufeinander losgehen zu können.

Abb. 20.4: So nah und doch so fern

In Abbildung 20.4 befindet sich die Einheit B in Angriffsreichweite der Einheit A, obwohl die beiden zu verschiedenen Zellen gehören. Deshalb müssen wir nicht nur die Positionen der Einheiten in derselben Zelle miteinander vergleichen, sondern auch diejenigen der Einheiten in angrenzenden Zellen. Zu diesem Zweck verlagern wir zunächst einmal die innere Schleife aus handleCell() in eine eigene Methode:

void Grid::handleUnit(Unit* unit, Unit* other)
{
  while (other != NULL)
  {
    if (distance(unit, other) < ATTACK_DISTANCE)
    {
      handleAttack(unit, other);
    }
    other = other->next_;
  }
}

Damit steht eine Funktion zur Verfügung, die überprüft, ob sich in einer Liste von Einheiten irgendwelche in Angriffsreichweite einer einzelnen Einheit befinden. Dies wird nun in handleCell() aufgerufen:

void Grid::handleCell(int x, int y)
{
  Unit* unit = cells_[x][y];
  while (unit != NULL)
  {
    // Andere Einheiten dieser Zelle verarbeiten
    handleUnit(unit, unit->next_);
    unit = unit->next_;
  }
}

Beachten Sie außerdem, dass wir handleCell() nun die Zellkoordinaten übergeben, nicht nur die Liste der Einheiten. Noch hat sich dadurch nichts geändert, aber die Methode wird ein wenig erweitert:

void Grid::handleCell(int x, int y)
{
  Unit* unit = cells_[x][y];
  while (unit != NULL)
  {
    // Andere Einheiten dieser Zelle verarbeiten
    handleUnit(unit, unit->next_);

    // Benachbarte Zellen überprüfen
    if (x > 0) handleUnit(unit, cells_[x - 1][y]);
    if (y > 0) handleUnit(unit, cells_[x][y - 1]);
    if (x > 0 && y > 0)
        handleUnit(unit, cells_[x - 1][y - 1]);
    if (x > 0 && y < NUM_CELLS - 1)
        handleUnit(unit, cells_[x - 1][y + 1]);

    unit = unit->next_;
  }
}

Die zusätzlichen handleUnit()-Aufrufe suchen in vier der acht benachbarten Zellen nach in Angriffsreichweite der aktuellen Einheit befindlichen Einheiten. Falls eine davon nah genug am Rand der Zelle ist, um in Angriffsreichweite zu liegen, wird das entdeckt.

Abb. 20.5: Die Hälfte der an die Einheit E angrenzenden Zellen wird untersucht (X).

Die Untersuchung lediglich der Hälfte der benachbarten Zellen erfolgt hier aus demselben Grund, aus dem auch die innere Schleife beim Durchlaufen der Einheiten erst nach der aktuellen Einheit beginnt – um zu vermeiden, dass jede Einheitenpaarung doppelt überprüft wird. Überlegen Sie mal, was passieren würde, wenn alle acht angrenzenden Zellen untersucht würden.

Nehmen wir an, wir haben es wie im vorangehenden Beispiel mit zwei Kampfeinheiten zu tun, die hinreichend nah beieinander sind, um ins Gefecht zu geraten. Wenn nun alle acht umliegenden Zellen untersucht werden, findet folgender Ablauf statt:

Auf der Suche nach in Angriffsreichweite von A liegenden Einheiten wird auch der rechte Nachbar untersucht. B wird entdeckt, also soll ein Gefecht zwischen A und B stattfinden.

Auf der nachfolgenden Suche nach in Angriffsreichweite von B liegenden Einheiten wird der linke Nachbar untersucht. Nun wird A entdeckt, also müsste ein zweites Gefecht zwischen A und B stattfinden.

Nur die Hälfte der benachbarten Zellen zu untersuchen behebt dieses Problem. Welche Hälfte untersucht wird, spielt dabei keine Rolle.

Und es gibt noch einen weiteren Grenzfall, der zu berücksichtigen ist: Wir haben hier stillschweigend vorausgesetzt, dass die maximale Angriffsreichweite kleiner ist als die Größe einer Zelle. Bei kleinen Zellen und großer Angriffsreichweite müssten womöglich weitere Zellen untersucht werden, die sich über mehrere Reihen erstrecken könnten.

20.6  Designentscheidungen

Die Liste der für räumliche Aufteilungen geeigneten wohldefinierten Datenstrukturen ist relativ kurz. Man könnte sie einfach der Reihe nach aufzählen, stattdessen habe ich jedoch versucht, sie anhand ihrer charakteristischen Merkmale zu kategorisieren. Ich hoffe, dass dadurch die Funktionsweisen der im Folgenden vorgestellten Quadtrees, BSP-Bäume (Binary Space Partitioning, binäre Raumaufteilung) und dergleichen deutlicher werden und Sie besser verstehen können, warum die eine Datenstruktur der anderen möglicherweise vorzuziehen ist.

20.6.1  Ist die Aufteilung hierarchisch oder gleichmäßig?

In unserem Beispiel wird der »Raum« in eine Reihe gleich große zweidimensionale Zellen aufgeteilt. Eine hierarchische Aufteilung hingegen unterteilt ihn in nur einige wenige Regionen. Falls eine der Regionen noch immer viele Objekte enthält, wird sie ein weiteres Mal aufgeteilt. Dieser Vorgang wird rekursiv so lange wiederholt, bis alle Regionen weniger als eine vorgegebene maximale Anzahl von Objekten enthalten.

Für eine gleichmäßige Aufteilung gilt:

Sie ist einfacher. Gleichmäßig aufgebaute Datenstrukturen sind leichter verständlich und einfacher zu implementieren.

Diesen Punkt habe ich in fast jedem Kapitel erwähnt – und das aus gutem Grund: Wählen Sie nach Möglichkeit immer die einfachere Lösung. Ein Großteil der Softwareentwicklung besteht aus dem Kampf gegen die Komplexität.

Der Speicherbedarf ist konstant. Da das Hinzufügen neuer Objekte keine neue Aufteilung erfordert, kann der für die räumliche Aufteilung benötigte Speicher meist vorab festgelegt werden.

Die Aktualisierung bei Änderungen der Objektpositionen geht oft schneller vonstatten. Wenn Objekte sich bewegen, muss die Datenstruktur aktualisiert werden, damit diese Objekte auch an ihren neuen Positionen aufgefunden werden. Bei hierarchischen Aufteilungen müssen unter Umständen mehrere Hierarchieebenen aktualisiert werden.

Für eine hierarchische Aufteilung gilt:

Leere Bereiche werden effizienter gehandhabt. Stellen Sie sich vor, dass in unserem Beispiel die eine Hälfte des Schlachtfeldes komplett leer wäre. Dann gäbe es eine Vielzahl leerer Zellen, die Speicherplatz belegten und in jedem Frame durchlaufen werden müssten.

Da bei einer hierarchischen Aufteilung leere Regionen nicht weiter unterteilt werden, wird für große leere Bereiche nur eine einzige Region benötigt. Statt vieler kleiner muss dann auch nur diese eine durchlaufen werden.

Dicht bevölkerte Bereiche werden effizienter gehandhabt. Hier kommt der umgekehrte Effekt zum Tragen: Sollten sich in manchen Regionen viele dicht gedrängte Objekte befinden, ist eine nicht-hierarchische Aufteilung oft nahezu wirkungslos. Letzten Endes verwenden Sie dann Zellen mit so vielen Objekten, dass Sie sich die Aufteilung auch hätten sparen können. Eine hierarchische Aufteilung passt sich daran an, unterteilt dicht bevölkerte Bereiche in immer kleinere Zellen, und Sie brauchen schließlich wieder nur einige wenige Objekte pro Zelle zu berücksichtigen.

20.6.2  Hängt die Zellengröße von der Verteilung der Objekte ab?

Im Beispielcode wurde die konstante Zellengröße vorab festgelegt und die Kampfeinheiten wurden den Zellen zugewiesen. Andere Aufteilungsverfahren sind adaptiv – sie wählen die Zellengröße anhand der tatsächlichen Positionen der Objekte in der Spielwelt.

Es wird stets eine ausgewogene Aufteilung angestrebt, bei der sich in jeder Region ungefähr gleich viele Objekte befinden, weil dadurch die höchste Performance erzielt wird. Stellen Sie sich vor, dass in unserem Beispiel sämtliche Kampfeinheiten in einer einzigen Ecke des Schlachtfeldes versammelt sind. Sie befänden sich alle in einer einzelnen Zelle und wir hätten es wieder mit dem ursprünglichen O(n2)-Problem zu tun, das wir ja eigentlich lösen wollen.

Falls die Zellengröße von der Verteilung der Objekte unabhängig ist:

Objekte können schrittweise hinzugefügt werden. Beim Hinzufügen eines Objekts muss die richtige Zelle gefunden und dem Objekt zugeordnet werden. Das ist problemlos schrittweise machbar, ohne dass Sie sich Gedanken über die Performance machen müssen.

Objekte können schnell und einfach bewegt werden. Bei einer festen Zellengröße bedeutet das Bewegen einer Einheit, dass sie einer anderen Zelle zugeordnet wird. Wenn sich jedoch wie bei einer adaptiven Aufteilung die Zellengröße selbst in Abhängigkeit von der Verteilung der Objekte ändert, kann das Bewegen eines Objekts zu einer Verschiebung der Zellengrenzen führen, was wiederum zur Folge haben kann, dass viele andere Objekte ebenfalls in andere Zellen bewegt werden müssen.

Dieses Verhalten ähnelt dem sortierter binärer Suchbäume wie AVL- oder Rot-Schwarz-Bäumen: Das Hinzufügen eines einzelnen Objekts kann eine Neusortierung des Baums und die Verschiebung von Knoten erforderlich machen.

Die Aufteilung ist womöglich unausgewogen. Das ist natürlich der Nachteil der festen Zellengröße: Sie können kaum steuern, wie gleichmäßig die Objekte verteilt sind. Sollte es zu Anhäufungen von Objekten kommen, beeinträchtigt das die Performance und Sie verschwenden in leeren Bereichen Speicherplatz.

Falls sich die Zellengröße an die Verteilung der Objekte anpasst:

Räumliche Aufteilungen wie die binäre Raumaufteilung und k-d-Bäume teilen die Spielwelt rekursiv in Hälften auf, die jeweils ungefähr die gleiche Anzahl von Objekten enthalten. Zu diesem Zweck müssen bei der Grenzziehung die Objekte in beiden Hälften gezählt werden. Eine Hüllkörper-Hierarchie (engl. Bounding Volume Hierarchy oder kurz BVH) ist eine weitere Vorgehensweise zur Optimierung der räumlichen Aufteilung bei einer gegebenen Verteilung der Objekte in der Spielwelt.

Sie können eine ausgewogene Aufteilung sicherstellen. Die Performance ist nicht nur gut, sondern auch (nahezu) konstant: Wenn jede Zelle dieselbe Anzahl an Objekten enthält, dauert auch das Auffinden eines Objekts in etwa gleich lang. Sofern Sie eine stabile Framerate beibehalten möchten, ist diese Konstanz vielleicht wichtiger als die reine Performance.

Die Aufteilung »in einem Rutsch« vorzunehmen, ist effizienter. Wenn die Verteilung der Objekte die Zellengröße beeinflusst, ist es am besten, vor der Durchführung der Aufteilung alle Objekte vorab in der Spielwelt zu platzieren. Aus diesem Grund wird diese Aufteilungsvariante meist für kunstvoll gestaltete oder geometrische Objekte verwendet, die sich während des Spiels nicht bewegen.

Falls die Zellengröße von der Verteilung der Objekte unabhängig ist, die Hierarchie jedoch nicht:

Eine räumliche Aufteilung verdient besondere Erwähnung, weil sie einige der besten Merkmale gleichmäßiger und hierarchischer Aufteilungen in sich vereint: Quadtrees. Bei einem Quadtree ist anfangs der gesamte Raum eine einzelne quadratische Zelle. Übersteigt die Anzahl der darin enthaltenen Objekte einen gegebenen Grenzwert, wird die Zelle in vier kleinere Quadrate unterteilt. Die Zellengröße dieser vier Quadranten ist damit festgelegt: Sie beträgt genau ein Viertel der ursprünglichen Zelle.

Dieser Vorgang wird nun mit den vier neuen Zellen rekursiv wiederholt, bis jede Zelle nur noch einige wenige Objekte enthält. Da bei der Rekursion nur diejenigen Zellen neu unterteilt werden, die dicht bevölkert sind, passt sich die Zellengröße an die Verteilung der Objekte an, die Zellen verschieben sich jedoch nicht.

Abb. 20.6: Jede Zelle, die mehr als zwei Objekte enthält, wird rekursiv unterteilt.

In Abbildung 20.6 können Sie (von links nach rechts) die Aufteilung in Aktion sehen.

Mittels eines Quadtrees werden zweidimensionale Flächen in vier Quadranten unterteilt. Das 3D-Pendant heißt Octree und unterteilt Volumina in acht Würfel. Von der zusätzlichen Dimension einmal abgesehen, ist die Funktionsweise mit derjenigen des flachen Geschwisters identisch.

Objekte können schrittweise hinzugefügt werden. Um ein neues Objekt hinzuzufügen, muss es dem richtigen Quadrat zugeordnet werden. Wenn dieses daraufhin mehr als die maximal vorgesehene Anzahl von Objekten enthält, wird es erneut unterteilt. Die bereits vorhandenen Objekte müssen dann auf die neu entstandenen kleineren Quadrate verteilt werden. Dazu ist ein wenig Arbeit erforderlich, die aber übersichtlich bleibt, weil stets weniger als die maximal für ein Quadrat vorgesehene Anzahl von Objekten bewegt werden muss. Das Hinzufügen eines einzelnen Objekts verursacht höchstens eine neue Unterteilung.

Das Entfernen von Objekten ist ebenso einfach: Sie entfernen es aus seinem Quadrat und wenn die Anzahl der im umgebenden Quadrat vorhandenen Objekte dadurch unter den Grenzwert sinkt, können Sie die Unterteilung wieder aufheben.

Objekte können schnell und einfach bewegt werden. Dies folgt aus der soeben genannten Eigenschaft, weil ein »Bewegen« in diesem Fall ja nichts anderes als ein Entfernen und erneutes Hinzufügen ist. Beides ist mit einem Quadtree ziemlich schnell machbar.

Die Aufteilung ist ausgewogen. Da jedes beliebige Quadrat weniger als eine vorgegebene Anzahl von Objekten enthält, kann es auch bei dicht gedrängten Objekten nicht dazu kommen, dass ein einzelnes Quadrat überproportional viele Objekte enthält.

20.6.3  Werden die Objekte nur in den Zellen gespeichert?

Sie können die Zellen zum Speichern der Objekte verwenden oder aber sie nur als eine Art Cache einsetzen, der das Auffinden beschleunigt, und die Objekte an sich in einer eigenständigen Objektsammlung speichern.

Falls die Objekte nur hier gespeichert werden:

Sie sparen Speicherplatz und umgehen die Komplexität zweier Objektsammlungen. Es ist natürlich immer weniger Speicherplatz erforderlich, wenn Objekte nur einmalig statt doppelt gespeichert werden. Außerdem müssen Sie bei Verwendung zweier Objektsammlungen jeweils beide aktualisieren, wenn ein Objekt hinzugefügt oder entfernt wird.

Falls eine zweite Objektsammlung verwendet wird:

Das Durchlaufen der Objekte geht schneller vonstatten. Wenn die fraglichen Objekte »aktiv« sind und verarbeitet werden müssen, muss man oftmals sämtliche Objekte durchlaufen, und zwar unabhängig von ihren Positionen. Nun stellen Sie sich vor, dass in unserem Beispiel die meisten Zellen leer sind. Das gesamte Gitternetz von Zellen zu durchlaufen, nur um diejenigen zu finden, die nicht leer sind, wäre reine Zeitverschwendung.

Eine zweite Objektsammlung, die nur diese Objekte speichert, ermöglicht dagegen ein direktes Durchlaufen. Es gibt also zwei Datenstrukturen, die für den jeweiligen Anwendungsfall optimiert sind.

20.7  Siehe auch ...

Ich habe die verschiedenen Datenstrukturen für räumliche Aufteilungen ganz bewusst nicht im Detail erläutert, damit dieses Kapitel möglichst allgemein bleibt (und nicht zu lang wird!) – allerdings sollte Ihr nächster Schritt sein, sich mit den gebräuchlichsten dieser Strukturen vertraut zu machen. Trotz der manchmal beängstigenden Bezeichnungen sind sie überraschend unkompliziert. Die wichtigsten davon sind:

Gitternetz

Quadtree

Binäre Raumaufteilung (Binary Space Partitioning, BSP)

k-d-Bäume

Hüllkörper-Hierarchie (Bounding Volume Hierarchy, BVH)

Im Wesentlichen erweitern all diese räumlichen Datenstrukturen wohlbekannte eindimensionale Strukturen auf mehrere Dimensionen. Die Kenntnis der linearen Pendants hilft bei der Beurteilung, ob sie für eine gegebene Aufgabe geeignet sind:

Ein Gitternetz stellt im Grunde genommen ein persistentes Bucketsort dar.

BSP- und k-d-Bäume sowie BVHs sind Varianten binärer Suchbäume.

Quadtrees und Octrees sind Tries (Präfixbäume).