Objektorientierte Programmierung: Teil 1 – OOP in der Praxis
Ein erstes Beispiel
Als (anfangs schlechtes) Beispiel soll der vierte Programmierwettwerb von freiesMagazin herhalten. Für diesen wurde ein Beispiel-Bot geschrieben, der den Teilnehmern als Sparringpartner diente. Dieser soll hier etwas vereinfacht nachprogrammiert werden, sodass er folgende Anforderungen erfüllt:
- Spielverwaltung und Bot getrennt
- Einlesen von Benutzereingaben (Zahlen zwischen 0 und 1000)
- Antwort des Bots nach einer bestimmten Strategie, die im Programm per Kommandozeile ausgelesen wird
- Drei vordefinierte Strategien:
- immer annehmen
- immer ablehnen
- Annahme, wenn größer gleich 200. Wenn dreimal nacheinander kleiner 200, dann immer Ablehnung; wenn dreimal nacheinander größer als 700, dann immer Annahme.
- Bot soll angenommene Punkte zählen und am Ende ausgeben
Da bei der Entwicklung eher das Software-Design im Vordergrund steht, und damit die Beispielprogramme nicht zu lang werden, wird weniger Gewicht auf ausführliche Dokumentation oder Fehlerbehandlung gelegt.
Wenn man sich keine großen Gedanken über eine Umsetzung macht bzw. einfach sehr faul ist, erstellt man zwei Klassen. Eine Klasse Game
, welche die Spielverwaltung und Erstellung des Bots übernimmt, und eine Klasse Bot
, welche die Strategie bestimmt und die Punkte zählt. Dies war auch der erste Ansatz für den Beispielbot des Programmierwettbewerbs, woraus dann auch die Idee für die Artikelreihe entstanden ist.
Um sich klar zu machen, welche Aufgabe eine Klasse hat, kann man sogenannte CRC-Karten einsetzen (Class-Responsibility-Collaboration-Karten). Diese enthalten den Klassennamen, die Verantwortlichkeit und alle anderen Objekte, die die Klasse benötigt. Für das erste Beispiel sähen diese so aus:
- Klasse:
Bot
- Benötigt:
- –
- Verantwort.:
- nimmt Angebote an oder lehnt sie je nach Strategie ab; zählt die angenommenen Punkte
- Klasse:
Game
- Benötigt:
Bot
- Verantwort.:
- erstellt Bot und setzt die Strategie (aus Konsolenparameter); liest Benutzereingabe und fragt Bot nach Annahme oder Ablehnung
Abhängigkeitsanalyse
Wieso sind Abhängigkeiten überhaupt wichtig? Stark vernetzte Software (das heißt eine mit vielen Verbindungen zwischen den Klassen) führt früher oder später zu einem nicht mehr überschaubaren Wust an Code. So kann man irgendwann nicht mehr sagen, welche Teile einer Software unabhängig voneinander arbeiten können – zum Beispiel in Form von Bibliotheken. Daher bemüht man sich in der Regel, die Abhängigkeiten zwischen einzelnen Softwareteilen (Komponenten) gering zu halten.
Nicht in allen Sprachen wirken sich die Abhängigkeiten der Software unbedingt negativ aus. C++ ist aber ein sehr schönes Beispiel, da hier die Abhängigkeiten direkten Einfluss auf die Generierung nehmen. Hat man eine Software, die aus 50 Bibliotheken und Hunderten von Klassen besteht, die alle kreuz und quer miteinander vernetzt sind, führt die Änderung einer einzigen Header-Datei oft dazu, dass alle Klassen und alle Bibliotheken neu übersetzt werden müssen. Dies versucht man normalerweise zu vermeiden, da es unnötigen Zeitaufwand bedeutet (was mit hinausgeworfenem Geld gleichzusetzen ist).
Zusätzlich ist eine Software mit vielen Abhängigkeiten nur noch schwer zu verstehen, da es bei kleinen Änderungen bereits unerwünschte Seiteneffekte geben kann. Auch unerwünschte zyklische Abhängigkeiten können so leichter auftreten. Ebenso werden Unit-Tests (oder Modul-Tests) durch zu viele Abhängigkeiten erschwert.
Hinweis: Mit Abhängigkeit ist jede Verbindung einer Klasse zu einer anderen gemeint und nicht eine Abhängigkeit im strengen UML-Sprachgebrauch.
Welche Abhängigkeiten ergeben sich also aus den zwei Klassen?
Die Klasse Bot
hat nur Abhängigkeiten zu Standard-Datentypen wie string
und int
, die nicht ins Gewicht fallen.
Die Klasse Game
ist direkt von Bot
abhängig, da der Bot von ihr erstellt und danach benutzt wird. Im UML-Modell wird dies dann mittels einer Abhängigkeitsbeziehung vom Stereotyp instantiate
dargestellt.
Wie man an den CRC-Karten sieht, haben die zwei Klassen Bot
und Game
nicht nur eine Aufgabe, sondern mehrere. Dies ist nicht immer, aber oft ein Hinweis darauf, dass man seine Klassen falsch »geschnitten« hat.
Man hätte es aber sogar noch schlimmer machen und die Benutzereingabe auch direkt in Bot
auslesen können, ebenso wie die komplette Spielverwaltung. Dann wäre die Klasse aber ein echter Gemischtwarenladen an Aufgaben und es gäbe gar keine klare Verantwortlichkeit der Klasse Bot
mehr. (So ein Objekt nennt man gemeinhin Gott-Objekt.)
Der Nachteil des obigen Designs ist also, dass die Klassen zu viele Aufgaben übernehmen. So ist es eher seltsam, dass die Klasse Game
den Bot erstellt. Ebenso kann man überlegen, ob es sinnvoll ist, dass Game
die Interpretation der Benutzereingaben übernimmt. In der Artikelreihe soll sich aber mehr auf den Bot und dessen Strategien konzentriert werden. Aber auch dieser hat mehrere Verantwortlichkeiten, wie man sieht.
Ein weiterer Nachteil ist, dass die Übersicht in der Klasse Bot
leidet, wenn es sehr viele Strategien gibt. Diese werden durch zig if
-Abfragen anhand der Zeichenkette mStrategy
in der Operation acceptOffer
unterschieden. Wenn man also 30 Strategien hat, die mitunter um einiges komplexer sind als die Beispielstrategien, wird das extrem unübersichtlich. Daneben muss man z.B. für die Strategie, dass man ein Angebot immer annimmt, das Attribut mNumAcceptInRow
speichern, auch wenn man es in dem Fall gar nicht benötigt.
Der dritte Nachteil ist, dass die Bibliothek libgame
neu übersetzt werden muss, wenn sich in der Deklaration von Bot
etwas ändert. Dies ergibt sich aus der direkten Abhängigkeit der Klassen. (Dies spielt aber in manchen Sprachen, wie oben geschrieben, keine große Rolle.) Als Folge davon wäre auch ein Unit-Test der Klasse Game
nur schwer machbar, d.h. wenn man die Klasse Game
testet (z.B. die Operation start
), testet man automatisch auch immer die Klasse Bot
mit. (In C++ hätte man nur die Möglichkeit, den Include- und Library-Pfad anzupassen, um eine neue Implementierung unterzuschieben. Bei anderen Sprachen kann man die Klasse Bot
und dessen Methoden zur Laufzeit überschreiben.)
Der Vorteil der Implementierung ist natürlich ihre Einfachheit. Es gibt nur zwei Klassen und alles ist schön übersichtlich. Leicht erweiterbar oder wartbar ist es auf Dauer aber nicht.
Die C++-Implementierung der zwei Klassen kann als Archiv heruntergeladen werden: oop1-beispiel.tar.gz.