Sitzung: Jeden Freitag in der Vorlesungszeit ab 16 Uhr c. t. im MAR 0.005. In der vorlesungsfreien Zeit unregelmäßig (Jemensch da?). Macht mit!

Martin Häcker/Java Kurs/Tag 4/LE7 Handout

Wie kommt man von einer Idee zur Umsetzung?

Am Beispiel von Vier Gewinnt zeigen.

Es ist immer das gleiche Vorgehen:

  • Man schaut sich die Idee an
  • Man zerlegt sie in kleinere Teile
  • Man Programmiert diese Teile - jedes Teil für sich angefangen bei den kleinen Teilen (Je mehr Erfahrung man gewinnt, desto größer können die Teile sein mit denen man beginnt, es ist aber wichtig das man in der Lage ist zu erkennen, wenn man sich übernommen hat - das passiert jedermann immer wieder - und dann mit einem kleineren Stück beginnt.

Das heißt, anstatt einfach Anzufangen folgen wir diesem Muster und machen uns damit immer zuerst einige Gedanken:

  1. Welche Teile muss unser Programm haben?
  2. Gibt es da Teile die nicht von anderen Teilen abhängen? (Die wir noch nicht Programmiert haben)
  3. Wie lassen sich diese Teile möglichst einfach Implementieren?
  4. Weitermachen am Schritt 2 bis das Programm fertig ist

Um ein Programm in Teile zu Zerlegen muss man sich Gedanken über die Teile machen, die es enthält. Gerade am Anfang macht es Sinn Dinge zu Programmieren die es wirklich gibt - wie Vier Gewinnt - da man die real existierenden Teile der Sache als Vorgabe nutzen kann. Beispielsweise bei Vier Gewinnt: Zwei Spieler, ein Spielbrett, Steine, Spielregeln.

Damit scheint zuerst einmal das Design dieses Programms völlig klar zu sein.

Das ist aber nicht ganz richtig. Ihr müsst beherzigen, dass NIEMALS dieser erste Gedanke den man über das Design des Programms hat der beste ist. Er ist immer zu kompliziert. (Es sei denn ihr wollt "Hello, World!" programmieren, da könnte das klappen.) Mit mehr Erfahrung werdet ihr vielleicht in der Lage sein euch nur noch mäßig zu komplizierte Systeme zu überlegen - aber sie sind immer noch zu Kompliziert.

Daher ist es unglaublich Wichtig, das ihr beim Programmieren mit einem kleinen zentralen Teilproblem beginnt, damit ihr von diesem Teil des Problems über den Rest des Problems lernen könnt, wie es am einfachsten umzusetzen ist. - Oder, was wahrscheinlicher ist, das ihr Erkennt wie ihr dass, was ihr schon Programmiert habt, anders hättet machen sollen, damit das jetzt zu lösende Problem einfacher zu lösen ist. Nutzt diese Chance und verändert das was ihr schon gemacht habt, das es dieser neuen Erkenntnis entspricht. Mit Techniken wie dem UnitTesting habt ihr alles notwendige Wissen an der Hand, damit das leicht wird. (TODO Etwas über Design großer Systeme bzw. Methodologien erzählen?)

Spieler, Spielfeld und Regeln sind natürlich nur die ersten paar Dinge die mir in den Sinn kamen, aber trotzdem zeigen sich dabei schon zwei Arten von Objekten. Einerseits Konkrete, wie Spieler und Spielbrett, andererseits Abstrakte wie Spielregeln. (Anfangs ist es völlig normal das man die abstrakten Objekte in der Regel nicht erkennt oder findet. Das ist völlig Normal und ändert sich mit wachsender Erfahrung).

Vorsicht aber vor dieser Präsentation! Ich habe Absichtlich diese Objekte ausgewählt bevor ich das Programm selber Programmiert habe - und ich habe letztlich herausgefunden das viele dieser Objekte gar keinen Sinn machen und man Vier Gewinnt viel einfacher programmieren kann.

Eine Daumenregel, die ihr aber absolut und immer beherzigen solltet wenn ihr euer Programm in Teile zerlegt ist das man zwei Dinge immer trennt:

  • Alles was mit dem Benutzer zu tun hat: Eingabe vom und Ausgabe auf den Bildschirm
  • Den ganzen Rest

Üblich ist sogar das man den Rest noch einmal in zwei Teile trennt:

  • der Teil der Konkrete Objekte umsetzt, wie bei uns z.B. das Spielfeld, die Spielregeln oder die Spieler
  • und den anderen Teil der diese Objekte mit dem Anzeige-Teil verbindet

Das ist aber Fortgeschritten und ihr müsst es euch selber beibringen.

Für uns hier ist nur die Daumenregel wichtig, das ihr die Benutzerschnittstelle in einer eigenen Klasse habt, die sonst nichts tut und dafür leicht gegen eine andere (Beispielsweise Graphische) ausgetauscht werden kann. Wieso das so wichtig ist, wird auch gleich bei der Implementierung klarer.

Der nächste Schritt ist immer die Überlegung welches dieser Teile - nennen wir sie einmal Objekte - man Programmieren könnte ohne den ganzen Rest schon fertig haben zu müssen.

  • Bei den Regeln ist das z.B. schwierig, da sie sich auf das Spielbrett und auf Spieler beziehen.
  • Spieler haben auch dieses Problem da sie sich ebenfalls auf das Spielfeld beziehen, auf das sie Steine ablegen wollen / sollen.
  • Steine sind ein Konzept das von nichts anderem Abhängt. Aber, Steine haben für sich gesehen überhaupt keine Funktionalität. Darum erzeugen viele Programmierer für so ein Ding keine Klasse, sondern sagen einfach "wenn in dem Spielfeld da eine "1" steht, dann heißt das Weiss hat da einen Stein und wenn da eine "2" steht, dann bedeutet das das Schwarz da einen Stein hat.

Sollte sich später herausstellen, das Steine aber doch auch Funktionalität haben sollten, muss man seine Meinung eben Ändern und doch eine Stein-Klasse erzeugen. (Diesen Vorgang nennt man Refactoring, aber darüber müsst ihr Selber lesen)

  • Das Spielfeld erfüllt unsere Bedingung am Besten. Einerseits braucht es keine Spieler oder Regeln um zu Existieren, andererseits braucht es aber eine gewisse Funkionalität. Z.B. die Möglichkeit Steine darauf zu legen.

Damit ist der nächste Schritt eingeleitet: Die Stückweise Umsetzung des Programms. Das ist natürlich immer etwas komplizierter als man es sich vorstellt. Irgendwann merkt man immer beim Programmieren das das was man sich gedacht hat wie ein Programm in Teile zerfallen sollte nicht optimal ist. Merkt man das, ist das aber kein Problem, weil man kann es ja ändern! Problematisch wird es erst wenn man es nicht tut, denn dann wird es kompliziert zu Programmieren und man braucht seine ganze Intelligenz um Teile des Programms zu entwickeln. Was dumm ist, denn wie jeder weiss ist es mindestens doppelt so schwer Fehler in einem Programm zu finden wie es ist es zu schreiben. Wer also schon seine ganze Intelligenz benötigt um ein Programm zu schreiben, der ist schon per Definition zu dumm seine Eigenen Fehler darin zu finden.

Daher merkt euch: Komplexität die nicht absolut notwendig ist, ist euer größter Feind!

Zurück zum Programmieren. Damit man auch nur einen Teil eines Programms schreiben kann, muss man sich Gedanken machen was es denn tun soll - oder genauer woran man erkennt das man fertig ist und tatsächlich das Programmiert hat was man Programmieren wollte.

Dafür habt ihr aber gestern schon eine Technik gelernt UnitTests, oder Test Driven Development, die wir jetzt verwenden.

Das heißt:

  • Was ist das einfachste was ein Spielfeld tun kann?
  • Wie kann man im Programm herausfinden dass das Passiert ist?
  • Was ist der einfachste Weg das umzusetzen?

Das Einfachste was ein Spielfeld tun kann, ist einfach nur dazuliegen. (Zumindest fällt mir nichts einfacheres ein). Herausfinden ob das passiert ist lässt sich eigentlich auch recht einfach. Wenn ein Spielfeld noch nicht benutzt wird, sondern einfach nur da liegt, dann ist es - leer.

Das einfachste was wir also tun könnten wäre eine Spielfeld-klasse zu definieren die eine Methode boolean isEmpty() besitzt die genau das aussagt.

Der sinn dieser Methode ist nicht das man danach Fertig ist, sondern das man eine erste Methode hat, man sitzt also nicht mehr vor dem "leeren Blatt Papier", das es so schwierig macht zu einer Lösung zu kommen. Gleichzeitig gibt es so eine "leere" Funktion aber auch für so gut wie alle Programme. Man hat also immer einen Anfang.

Wie ihr euch Erinnert programmiert man im TDD immer so das man erst einen Test schreibt und dann die Funktion. Wenn einem während dem Programmieren etwas auffällt, schreibt man es sich einfach auf eine TODO-Liste und programmiert es dann als nächstes, sobald man mit dem was man macht Fertig ist.

Gerade das letzte ist absolut wichtig, weil ihr so eure Gedanken auf das Konzentrieren könnt was ihr gerade tut ohne das ihr Abgelenkt werden könnt.

Ok, also die Implementierung ist einfach. Wie immer schreibt man zuerst das assert, und dann das drumherum das man benötigt um das Assert so schreiben zu können:

class BoardTest extends TestCase {
	public void testEmptyBoard() {
		Board board = new Board();
		assertTrue(board.isEmpty());
	}
}
class Board {
	public boolean isEmpty() {
		return true;
	}
}

Dabei fällt sofort auf, das isEmpty() sobald der erste Stein eingeworfen wurde nicht mehr Lehr sein darf. Daraus ergibt sich dann auch sofort der nächste Test:

// in file BoardTest.java
public void testBoardWithOneStone() {
	Board board = new Board();
	board.placeWhiteStoneInRow(5);
	assertFalse(board.isEmpty());
}

Um diesen Test zu implementieren braucht man aber schon eine Weitere Funktion: man muss Spielsteine auf das Spielbrett legen können. Daher gleich weiter:

// in file BoardTest.java
public void testWhiteStonePersists() {
	Board board = new Board();
	board.placeWhiteStoneInRow(5);
	assertTrue(board.hasWhiteStoneAtPosition(5, 0));
	assertFalse(board.hasWhiteStoneAtPosition(0, 0));
}

Um das zu implementieren müsst ihr euch für eine Darstellung des Spielfeldes in eurem Programm entscheiden. Ich empfehle für den start diese Darstellung:

class Board {
    final static int NO_STONE = 0;
    final static int WHITE_STONE = 1;
    final static int BLACK_STONE = 2;

    private int [][] board;
    final static int BOARD_ROWS = 6;
    final static int BOARD_COLUMNS = 7;

    public Board() {
        board = new int [BOARD_ROWS][BOARD_COLUMNS];
        // sets all array-members to 0 <=> NO_STONE
    }
}

  • Damit hat jeder Teilnehmer die gleiche und die Tutoren haben es einfacher
  • Diese Darstellung funktioniert (es ist aber ein bisschen Erfahrungssache darauf zu kommen das das genau so geht)
  • Wenn sich diese Darstellung als schlecht erweist, könnt bzw. sollt ihr sie gegen eine bessere Austauschen!

Und so weiter. Die nächsten Tests wären vermutlich das gleiche mit Schwarzen Steinenen und das herausfinden welche Farbe ein Stein an einer bestimmten Stelle auf dem Spielfeld hat.

Wenn man so weit gekommen ist, also das man Steine auf das Spielfeld legen kann, dann ist man so weit das man die Anzeige des Spielfeldes implementieren kann und eine einfache Automatik, die abwechselnd die verschiedenen Spieler nach einer Position fragt wo sie ihre Steine einwerfen wollen.

Vorsicht aber beim Implementieren der Anzeige. Meist ist es so, das es sehr schwierig ist Benutzerschnittstellen oder ein und Ausgabe zu testen. Man verwendet daher für die Eingabe am liebsten ein schon getestetes Objekt, so wie das "IOUtility" das ihr in den letzten Tagen geschrieben habt und trennt die Ausgabe von dem eigentlichen Programm, damit man nur einen sehr kleinen Teil des Programms von Hand testen muss. Manchmal testet man so etwas wie die Ausgabe einer 3D-Engine, indem man ein Screenshot macht und hinterher vergleicht, ob sich etwas an der Ausgabe geändert hat, wenn man das Programm ändert. Das bringt zwar nicht so viel wie den Test vorher zu schreiben, aber manchmal geht es eben nicht anders. Hier würde ich ganz darauf verzichten die Ausgabe Automatisch zu testen, sondern das es von Hand testen. (Schleichen sich hier aber Fehler ein, dann macht es sinn das Problem in kleinere Probleme zu zerlegen und diese automatisch zu Testen!)

Damit ist das Spiel zwar noch mitnichten fertig, aber man kann es schon Spielen! Hooray!

Als nächstes würden Tests kommen, die testen, das man einen Stein nur dann in ein Spielfeld einwerfen kann, wenn das Spielfeld in dieser Reihe noch Platz hat. Danach muss das Programm nur noch feststellen können das einer der Spieler gewonnen hat - und es ist fertig.

Genau so kann man auch an die Entwicklung komplexerer Probleme herangehen. Beispielsweise einer Künstlichen Intelligenz für dieses Spiel. Das einfachste was man von einer Künstlichen Intelligenz erwarten würde, ist das sie irgend einen Zug machen kann. Im nächsten Schritt kann man das einschränken und sagen, das die Engine, wenn es einen Zug gibt der sie Gewinnen lässt, diesen machen soll. Wieder als nächstes dann das sie einen Zug der sie im nächsten Zug verlieren lässt vermeiden soll.

Hat man eine KI nur so weit implementiert, kann man schon gegen sie Spielen - und es kann sehr Interessant sein, was für Spiele dabei entstehen.

Jetzt aber zurück zur Übung. Ihr sollt Vier Gewinnt implementieren. Dafür sollt ihr wie folgt vorgehen:

  1. Überlegt euch welche Teile dieses Programm haben kann
  2. Wählt euch einen Teil aus, den ihr unabhängig vom Rest Programmieren könnt
  3. Programmiert ihn (auch wenn er wirklich einfach ist) indem ihr zu jeder Funktion die ihr schreibt, zuerst einen Test schreibt der definiert was ihr von der entsprechenden Funktion haltet.
  4. Wiederholt diese Schritte bis ihr etwas habt was funktioniert und führt es euren Nachbarn und den Tutoren vor!

Man kann diese Aufgabe natürlich beliebig Weit treiben, so zum Beispiel...