Gutes Klassendesign

...am Beispiel des Lab #2 "The Quest" von "Head First C#"

Zurzeit darf ich mich dem widmen, wovon wohl die meisten angehenden Programmierer träumen: Ein eigenes Spiel von Grund auf programmieren. Diese Herausforderung ist Teil des Buches "Head First: C#" und tritt unter der Bezeichnung "C# Lab - The Quest" auf. In der Beschreibung dieses Labs werden viele Hinweise nicht nur zu den Spezifikationen sondern auch zur Klassenstruktur und auch einige fast komplett vorprogrammierte Klassen gegeben. Ich hatte mich von vielen dieser Vorgaben gelöst, und eigene Lösungen erarbeitet. Nun möchte ich zurückblicken und einige Aspekte, die ich anders angegangen bin, mit den Vorgaben vergleichen und meine Wahl begründen.

The Quest

Beim umzusetzenden Spiel handelt es sich um ein rundenbasiertes Adventure-Game, bei welchem der Spieler sich in einem Raum zusammen mit einigen Monstern befindet, gegen welche er sich durchsetzen muss. Als Hilfe dazu dienen ihm Waffen und Tränke, welche er vom Boden aufnehmen und dann verwenden kann. Sind alle Gegner eines Raumes besiegt, wird zum nächsten, schwierigeren Level gewechselt.

Klassendesign: Nicht der Einfachheit halber das Grundprinzip von OOP über Bord werfen!

Die Angaben des Buches schlagen folgende Struktur für die zentralen Klassen "Player", "Enemy" und "Weapon" vor:

Das Grundprinzip des objektorientierten Programmierens ist, die Realität so nahe wie möglich im Code darzustellen, und durch die Prinzipien der Vererbung so viele Redundanzen wie möglich zu verhindern. Dies wurde in dieser Klassenstruktur an ein paar Orten nicht optimal umgesetzt:

  • In diesem Klassendiagramm haben ein Spieler ("Player") und ein Gegner ("Enemy") gleich viele Gemeinsamkeiten wie ein Spieler und eine Waffe ("Weapon"). Dabei ist sowohl anschaulich (Spieler und Gegner/Monster sind beides Lebewesen) als auch in der Umsetzung gut möglich, Gemeinsamkeiten zwischen Spieler und Gegner zu finden, z.B. dass beide eine bestimmte Zahl an Lebenspunkten ("HitPoints") haben. Deshalb ist es durchaus empfehlenswert, eine weitere Klasse "Character" einzuführen, von welcher "Player" und "Enemy" erben, und welche wiederum von "Mover" erbt.

    Generell ist es eher unintuitiv, Waffen und Lebewesen in einen Topf zu werfen, anstelle der Klasse "Mover" würde ein Interface "IMoveable" eine ähnliche Funktion erfüllen, und dabei die Lebewesen von den Waffen getrennt lassen.
  • Die beiden Tränke "RedPotion" / "BluePotion" implementieren das "IPotion"-Interface, welches diese als Tränke identifizierbar machen soll. Dabei ist die Funktion dieses Interfaces, dass ein Gegenstand verbraucht werden kann, eine Eigenschaft die, obwohl hier nicht der Fall, generell nicht nur für Tränke gilt. Ein passenderer Name wäre also zum Beispiel "IConsumeable".

    Die Tränke erben beide von "Weapon". Natürlich kann man sich Tränke denken, welche Schaden verursachen und somit quasi Waffen sind, aber generell ist das Verhalten von solchen Gegenständen grundverschieden von "klassischen" Waffen. Alternativ sinnvoller wäre wohl eine Klasse "Potion", von welcher diese beiden Tränke erben, und eine weitere Überklasse "Item", von welcher "Weapon" und "Potion" erben.
  • Die Klassen "RedPotion" und "BluePotion" stellen zwei verschiedene Sorten von Heiltränken dar, also zwei, welche unterschiedlich viele Lebenspunkte auffüllen. Das Verhalten von beiden Tränken ist exakt identisch, nur die Menge der Lebenspunkte ist unterschiedlich. Dies ist kein struktureller Unterschied und deshalb würde es weit mehr Sinn machen, beide Tranksorten in einer Klasse "HealingPotion" zusammenzufassen.
  • Weiter könnte man das Attribut "Name" aller Waffen problemlos in die Klasse "Waffe" verschieben, um diese Redundanz zu vermeiden.

Meine Lösung dieses Teils der Klassenstruktur sieht demnach wie folgt aus:

Ich habe hier bewusst einige Felder und Methoden, welche die Spezifikationen des Buches übersteigen, mit aufgeführt. Denn so sieht man gut, wie viele Felder z.B. in die "Character"-Klasse verlegt werden können, anstatt redundant in der Player und Enemy-Klasse genannt zu werden.

Namen von Methoden

Guter Code ist Code, welcher durch die Namensgebung der Variablen und Methoden selbsterklärend wird. In der Vorlage des Buches gibt es ein paar Namensgebungen, welche nur beschränkt intuitiv sind:

  • Die Methode "Hit": Es ist zwar leicht verständlich, dass die Methode mit dem Angriffsprozedere zu tun hat, aber ob es darum geht, ob Schaden ausgeteilt oder erleidet werden muss, ist unklar. Ein besser Name wäre "TakeDamage", womit klar ist, wohin der Schaden geht.
  • Die Methoden "DamageEnemy" der Weapon-Klasse und die Methoden "Attack" der einzelnen Waffen-Klassen: Irgendwie bedeuten beide Namen dasselbe, und somit ist unklar, worin sie sich genau unterscheiden. Besser wäre wohl nur ein Methodenname, wobei in der Weapon-Klasse das allgemeine Verhalten, welches für alle Waffen gilt, beschrieben wird, und in den Waffen-Klassen eine Überschreibung derselben Methode, welche die Spezialitäten dieser Waffe berücksichtigt werden. Diese überschriebene Methode kann dann nach Möglichkeit die Methode der Basisklasse aufrufen.

Erweiterbarkeit

Die Lab-Vorlage ist in vielen Aspekten sehr eingeschränkt, was die Erweiterbarkeit und Flexibilität der Programms angeht. So sind viele Teile, wie Attribute aber auch Formularelemente so ausgelegt, dass z.B. immer nur je eine Instanz von einem Monster auf einmal im Spiel vorkommen kann; oder kann immer nur eine einzige Waffe im Raum auftauchen. Würde man nun weitere Level hinzufügen, in dem mehr Gegner vorkommen sollen, müssen grosse Teile der Codes grundlegend überarbeitet werden. Deshalb hatte ich mich dazu entschieden, eine gewisse Steigerung der Komplexität in Kauf zu nehmen, um diese und weitere Flexibilitäten zu erlangen, z.B. auch so, dass die Raumgrösse nach Belieben angepasst werden kann.