Lernziele
- Programme mit Hilfe von Zuständen besser strukturieren (unter Verwendung von Konstanten und Switch)
- Eine verkettete Liste mit Hilfe eigener Klasse erstellen
- Einsatz von Listen (ArrayList) und der erweiterten For-Schleife
In diesem Kapitel lernen wir zunächst das wichtige Konzept von Zustandsmaschinen kennen. Damit können wir besser und übersichtlicher verschiedene Game-Screens und Nichtspieler-Charaktere programmieren. In diesem Zusammenhang lernen wir auch die switch-Anweisung kennen, eine Art spezielles If für Fallunterscheidungen. Wir verwenden Konstanten, um unsere Zustände zu benennen.
Anschließend lernen wir Listen kennen, das sind die besseren Arrays. Man kann dort beliebig Elemente hinzufügen und entfernen. Die For-each-Schleife erlaubt ein elegantes Durchlaufen von Listen. Um Zahlen aufzulisten benötigen wir allerdings Wrapperklassen, aber eins nach dem anderen...
12.1 Zustandsmaschinen
In der theoretischen Informatik werden sogenannte Endliche Automaten (auch Zustandsmaschinen genannt) eingesetzt, um Beweise darüber zu führen, welche Probleme mit welchen Mechanismen in welcher Zeit lösbar sind.
In der Praxis werden Zustandsmaschinen eingesetzt, um einfaches Verhalten zu programmieren. Ein gewünschtes Verhalten wird zunächst als Zustandsmaschine entworfen, man sagt auch "modelliert", und anschließend in Code übersetzt. Der resultierende Code ist oft verständlicher und besser erweiterbar als Code, der ohne eine solche Vorplanung geschrieben wird.
Eine Zustandsmaschine besteht aus Punkten (Zustände) und Pfeilen (Übergänge). Die Pfeile stehen für Ereignisse (events) oder Aktionen. Die Maschine befindet sich in einem der Zustände und geht über einen Pfeil in einen anderen Zustand über, sofern das Ereignis oder die Aktion des Pfeils ausgelöst wird.
Ein Video-Player lässt sich als Zustandsmaschine modellieren: Zu Beginn ist der Player im Zustand "Video läuft nicht". Sobald die Play-Taste gedrückt wird, geht der Player in den Zustand "Video läuft" über. Wenn die Pause-Taste gedrückt wird, geht der Player wieder in den Zustand "Video läuft nicht" über.
Zustand mit boolescher Variable repräsentieren
Schauen wir uns ein Code-Beispiel an. Statt eines Videoplayers nehmen wir einen Ball, der von links nach rechts und zurück fliegt. Auch der Ball kann zwei Zustände haben: (1) fliegen und (2) stehen. Wenn Sie die Leertaste drücken, steht der Ball (Pause). Wenn Sie die Enter-Taste drücken, fliegt der Ball weiter (Play).
Das Basisprogramm lässt den Ball hin und her laufen:
int x = 0; int speed = 1; void draw() { background(100); noStroke(); ellipse(x, height/2, 30, 30); x = x + speed; if (x < 0 || x > width) { speed = -speed; } }
Da wir nur zwei Zustände haben, merken wir uns den Zustand in einer booleschen Variable "play". Wenn die Variable true ist, ist der Zustand "play". Wenn sie false ist, ist der Zustand "pause". Der Übergang in einen anderen Zustand wird durch den jeweiligen Tastendruck ausgelöst.
int x = 0; int speed = 1; boolean play = true; void draw() { background(100); noStroke(); ellipse(x, height/2, 30, 30); // Was im Zustand "play" gemacht werden soll: if (play) { x = x + speed; if (x < 0 || x > width) { speed = -speed; } } } void keyPressed() { // Was im Zustand "play" für ein Übergang erlaubt ist if (play) { if (key == ' ') { play = false; // Gehe über zu "pause" } } else { // Was im Zustand "pause" für ein Übergang erlaubt ist if (keyCode == ENTER) { play = true; // Gehe über zu "play" } } }
(interaktives Feld, Sie müssen auf das Feld klicken, damit ihre Tastendrücke erkannt werden)
Beachten Sie, dass der Ball in jedem der Zustände andere Tasten annimmt. Der Ball nimmt sozusagen eine "andere Persönlichkeit" an, sobald er sich in einem anderen Zustand befindet. Im Code sind die Zustände klar ersichtlich.
Wir unterscheiden dabei, wie der Zustand (play vs. pause) verwirklich wird -
dies steht in
draw()
- und wie der Übergang geregelt wird -
dies steht in
keyPressed()
(bzw. allgemein: im interaktiven Teil
des Programms).
Zustand als Zahl
Aber was, wenn wir mehr als zwei Zustände haben? Dann ist es mit einer booleschen Variable nicht getan, denn die kann nur zwei Werte annehmen. Stattdessen nehmen wir eine Integer-Variable, dann können wir eine große Menge Zustände speichern, jeder Zustand bekommt eine eigene Zahl. Wir schreiben unser Beispiel von oben so um, dass eine Zahl den Zustand speichert. Die 0 steht für Zustand "play", die 1 für Zustand "pause".
int x = 0; int speed = 1; int state = 0; // 0: play, 1: pause void draw() { background(100); noStroke(); ellipse(x, height/2, 30, 30); // Was im Zustand "play" gemacht werden soll: if (state == 0) { x = x + speed; if (x < 0 || x > width) { speed = -speed; } } } void keyPressed() { // NEU: Die switch-Anweisung switch (state) { case 0: if (key == ' ') { state = 1; // Gehe über zu "pause" } break; case 1: if (keyCode == ENTER) { state = 0; // Gehe über zu "play" } break; } }
Fallunterscheidung mit Switch
Sie sehen hier ein neues Konstrukt von Processing/Java: die Switch-Anweisung.
Eine Switch-Anweisung ist eine Fallunterscheidung, wie Sie sie normalerweise
mit If erledigen würden. Sie geben dem Switch eine Variable (hier: state) und
listen dann mit case
(engl. für "Fall") auf, welche Code-Teile ausgeführt
werden sollen, wenn die Variable den Wert 0 bzw. 1 enthält. Jeder Code-Teil
muss mit break
abgeschlossen werden, ansonsten würde der darunterliegende Code
auch ausgeführt werden (wenn wir z.B. das erste break im Code oben weglassen,
dann wird im Fall von 0 erst der Code unter "case 0" ausgeführt und anschließend
der Code von "case 1"). Das zweite break könnte man auch weglassen, da darunter kein
Code mehr folgt, man schreibt es
aber dennoch, falls man das Switch später um einen weiteren Fall erweitert.
Neben den "case"-Optionen gibt es noch das
default
. Der
Code hier wird ausgeführt, wenn keiner der anderen Fälle greift.
Im Beispiel könnte man den Fall abfangen, dass
state
einen falschen Wert (also nicht 0 oder 1) enthält.
void keyPressed() { switch (state) { case 0: if (key == ' ') { state = 1; // Gehe über zu "pause" } break; case 1: if (keyCode == ENTER) { state = 0; // Gehe über zu "play" } break; default: println("Unbekannter Zustand: " + state); } }
Die allgemeine Form der Switch-Anweisung ist
switch (<VARIABLE>) { case <WERT 1>: ... break; case <WERT 2>: ... break; ... case <WERT N>: ... break; default: ... } }
Es können natürlich beliebig viele Fälle unterschieden werden. Das
default
am Ende kann weggelassen werden, ist also optional.
Zustandsmaschinen in Spielen
In Spielen werden Zustandsmaschinen z.B. eingesetzt, um Nichtspielercharaktere (NSC) zu steuern. Bei einem einfachen Shooter könnte ein NSC wie folgt modelliert werden:
Hier kann man sich vorstellen, dass der NSC in jedem Zustand ein komplett anderes Verhalten zeigt, also eine andere "Persönlichkeit" annimmt.
Wenn wir in Processing unserem Ball eine "andere Persönlichkeit" je nach Zustand geben wollen, ändern wir einfach seine Form zu Quadrat und Dreieck. Den Zustand schalten wir mit der Leertaste von 0 zu 1 zu 2 und wieder zu 0.
int x = 0; int speed = 1; int state = 0; // 0: circle, 1: square, 2: triangle void draw() { background(100); noStroke(); rectMode(CENTER); switch (state) { case 0: ellipse(x, height/2, 30, 30); break; case 1: rect(x, height/2, 30, 30); break; case 2: triangle(x - 15, height/2 + 15, x, height/2 - 15, x + 15, height/2 + 15); break; } x = x + speed; if (x < 0 || x > width) { speed = -speed; } } void keyPressed() { if (key == ' ') { state++; if (state > 2) { state = 0; } } }(interaktives Feld, Sie müssen auf das Feld klicken, damit ihre Tastendrücke erkannt werden)
In Spielen wird das Zustandskonzept auch verwendet, um zwischen verschiedenen "Modi" oder "Screens" zu unterscheiden. Nehmen wir an, bei unserem langweiligen "Ballspiel" ist das Spiel vorbei, sobald der Ball 3x die Wand berührt.
Dann unterscheiden wir drei Spielzustände:
- play
- pause
- game over
Probieren Sie es aus:
Beachten Sie zum Beispiel, dass Sie im Spiel (play) die Leertaste verwenden können, um es zu pausieren. Im Zustand Game Over hingegen hat die Leertaste keine Funktion. Solche Unterschiede lassen sich mit der gezeigten Methode (Zustand als Integer, Fallunterscheidung mit Switch) leicht und vor allem verständlich realisieren.
int x = 0; int speed = 1; int counter = 0; // Wie oft Ball gegen Wand int state = 0; // 0: play, 1: pause, 2: game over void draw() { background(100); noStroke(); ellipse(x, height/2, 30, 30); // Steuert Verhalten im Zustand switch (state) { case 0: x = x + speed; if (x < 0 || x > width) { speed = -speed; counter++; if (counter > 2) { state = 2; // Gehe zu "game over" } } break; case 2: background(0); textAlign(CENTER); textSize(14); text("Game Over\n(press ENTER)", width/2, height/2); break; } } // Steuert Übergange void keyPressed() { switch (state) { case 0: if (key == ' ') { state = 1; // Gehe über zu "pause" } break; case 1: if (keyCode == ENTER) { state = 0; // Gehe über zu "play" } break; case 2: if (keyCode == ENTER) { counter = 0; state = 0; // Gehe über zu "play" } break; } }
Konstanten
Ein unschöner Aspekt unserer bisherigen Methodik ist die Tatsache, dass Zahlen nicht sehr aussagekräftig sind. Bedeutet 0 jetzt Play oder Pause? Beginnt unsere Zustandszählung bei 1 oder bei 0?
Am liebsten würden wir unseren Zuständen Namen geben! Wir könnten doch für jeden Zustand eine Variable definieren, die die Zustandszahl enthält und einen aussagekräftigen Namen hat, also zum Beispiel:
int play = 0; int pause = 1;
Prinzipiell eine gute Idee, aber theoretisch könnte man diese Variablen auch im Programmcode ändern (z.B. indem man play = 42 schreibt). Nun erlaubt Processing/Java, dies zu verbieten, indem man ein final
vor die Variablendeklaration setzt. Dies ist dann eine Konstante.
Wichtig: Der Name einer Konstanten wird üblicherweise in GROSSBUCHSTABEN geschrieben. Wenn Ihre Namen aus mehreren Teilwörtern bestehen, verwenden Sie den Unterschrich zur Trennung, z.B. MOVE_UP oder LEVEL_ONE.
In unserem Beispiel:
// Zustände als Konstanten final int PLAY = 0; final int PAUSE = 1;
Wenn Sie jetzt versuchen, an einer anderen Stelle im Code die Variable PLAY auf einen neuen Wert zu setzen, bekommen Sie eine Fehlermeldung, denn Java besteht darauf, dass der Wert konstant bleibt.
Jetzt können wir die Konstanten einbauen. Dank der Konstanten wird der Code praktisch selbsterklärend.
final int PLAY = 0; final int PAUSE = 1; int x = 0; int speed = 1; int state = PLAY; void draw() { background(100); noStroke(); ellipse(x, height/2, 30, 30); // Was im Zustand "play" gemacht werden soll: if (state == PLAY) { x = x + speed; if (x < 0 || x > width) { speed = -speed; } } } void keyPressed() { switch (state) { case PLAY: if (key == ' ') { state = PAUSE; } break; case PAUSE: if (keyCode == ENTER) { state = PLAY; } break; } }
Sie kennen übrigens bereits einige Konstanten. Wenn Sie schreiben rectMode(CENTER)
, ist CENTER eine Konstante, welche die Processing-Macher definiert haben. Sie können das testen:
println(CENTER);
3
Konstanten haben hier den Vorteil, dass Sie sich nicht merken müssen, dass ausgerechnet die "3" für das Zentrieren steht. Und - vielleicht noch wichtiger - wenn sich die Processing-Macher mal entscheiden sollten, dass jetzt die "5" für das Zentrieren steht, müssen Sie Ihren Code nicht ändern.
Auch bei Tastaturabfragen wie if (keyCode == LEFT)
haben Sie Konstanten wie LEFT, welche den Tastencode (ganze Zahl) der Cursortaste-links enthält.
Übungsaufgaben
12.1 a) Switch
Schreiben Sie eine Switch-Anweisung, die die Variable zahl
prüft und folgendes tut. Bei 1 schreibt sie "one"
auf die Konsole, bei 2 schreibt
sie "two", bei 3 schreibt sie "three", bei allen anderen Werten
schreibt sie "something else".
Ihr Basiscode ist
int zahl = 3;
12.1 b) Farbwechsel
Zeichnen Sie einen Kreis, der rot/grün/blau wird, je nachdem, ob der Zustand 0, 1 oder 2 ist. Mit der Leertaste wechseln Sie von 0 zu 1 zu 2 zu 0 usw.
Verwenden Sie Konstanten für die Benennung der Zustände. Zum Beispiel ROT, GRUEN, BLAU.
12.1 c) switch für Cursortasten
Geben Sie einfach "links", "rechts" usw. auf der Konsole aus, wenn die entsprechende Cursortaste (Pfeiltaste) gedrückt wird. Verwenden Sie Switch in der keyPressed-Methode.
Hinweis: Für diese Aufgabe benötigen Sie keine Variable "state" (oder ähnlich). Ihr draw() bleibt leer und Sie schreiben ein switch-Konstrukt in keyPressed(). Welche Variable testen Sie dort und auf welche Werte?
keyCode
.
12.1 d) Vier Ecken
Lassen Sie einen Kreis von der linken oberen Ecke nach rechts fliegen. Wenn der Kreis die rechte obere Ecke erreicht, soll er nach unten fliegen. Wenn er die rechte untere Ecke erreicht, nach links. Dann wieder nach oben. Und von vorn.
Lösen Sie das Problem mit möglichst einfachen If-Abfragen, indem Sie Zustände vergeben (für "nach rechts", "nach unten" usw.).
Verwenden Sie Konstanten für die Benennung der Zustände.
12.1 e) Vier Richtungen
Lassen Sie einen Kreis von links nach rechts fliegen. Wenn er rechts rausfliegt, erscheint er von links wieder.
Sobald Sie eine Taste drücken, fliegt der Ball in die umgekehrte Richtung (rechts nach links). Wenn Sie wieder eine Taste drücken, fliegt er nach oben. Wenn Sie wieder eine Taste drücken nach unten. Beim nächsten Tastendruck dann wieder wie zu Beginn (nach rechts).
Kurz gesagt: Bei jedem Tastendruck verändert der Ball seine Richtung. Die Reihenfolge ist (1) nach rechts, (2) nach links, (3) nach oben, (4) nach unten.
(Interaktives Feld, zuerst anklicken, dann Tasten probieren.)
12.1 f) Spiel
Schreiben Sie ein einfaches Shooter-Spiel, bei dem sich ein Ball über den Bildschirm bewegt und an den Wänden abprallt. Der Spieler muss mit der Maus auf den Ball klicken, um ihn abzuschießen. Dieses Spiel ist Zustand 1.
In Zustand 2 bleibt der Ball stehen und vergrößert sich stetig. Der Spieler muss den Ball abschießen bevor er eine bestimmte Größe erreicht, sonst ist das Spiel vorbei (Game Over wäre Zustand 0).
In Zustand 3 bleibt der Ball ebenfalls stehen und verkleinert sich stetig. Der Spieler muss den Ball abschießen bevor er verschwindet, sonst ist das Spiel vorbei (=> Zustand 0).
Ihr Spiel sollte in Zustand 1 starten und jedes Mal, wenn der Spieler den Ball trifft, zufällig in einen anderen Spielzustand (1, 2 oder 3) wechseln. Man sollte nicht zwei Mal hintereinander im gleichen Zustand landen. Bei Game Over (Zustand 0) sollte man per Leertaste wieder in Zustand 1 kommen.
Zusatzaufgabe: Überlegen Sie sich ein oder zwei weitere Zustände (Spiele). Passen Sie die Auswahl des nächsten Zustands so an, dass ein Zustand erst dann wiedergewählt wird, wenn alle anderen Zustände vorgekommen sind.
Verwenden Sie Konstanten für die Benennung der Zustände.
Zusammenfassung
Eine Zustandsmaschine hat Zustände (Punkte) und Übergänge (Pfeile). Jeder Zustand repräsentiert ein bestimmtes Verhalten. Ein Übergang wird durch ein Event ausgelöst, z.B. Maustaste oder Kollision.
Verwende einen Zustand, um Verhalten zu programmieren, zum Beispiel die verschiedenen Modi eines Spiels (Play, Pause, Game Over).
Den Zustand lässt sich mit Hilfe einer Integer-Variablen kodieren. Mit der Switch-Anweisung lässt sich die Fallunterscheidung übersichtlich programmieren (alternativ auch mit If-Anweisungen).
Konstanten sind Variablen, denen bei Deklaration das Schlüsselwort final
vorangestellt wird und die nach erstmaliger Zuweisung nicht mehr verändert werden können. Konstanten sollten mit GROSSBUCHSTABEN geschrieben werden und eigenen sich u.a., um Zustände zu benennen.
12.2 Listen im Eigenbau
Listen sind so ähnlich wie Arrays. Der große Nachteil eines Arrays ist, dass man seine Länge nicht ändern kann. Bei Listen ist das anders. In diesem Abschnitt sehen wir uns an, wie man eine Liste selbst implementiert, um uns die interne Funktionsweise von Listen klar zu machen.
Klassen und Objekte im Diagramm
Ein kurzer Ausflug vorab: Um Klassen und Objekte grafisch darzustellen, gibt es sogenannte Klassen- und Objektdiagramme.
Klassendiagramm
Ein Klasse wird als gelber Kasten dargestellt, der drei Bereiche hat. Ganz oben steht der Klassennamen, in der Mitte alle Instanzvariablen und unten die Methoden - also in der gleichen Reihenfolge wie im Code.
Schauen wir uns eine konkrete Klasse "Person" an:
Randbemerkung: Die Instanzvariablen und Methoden werden normalerweise nicht wie hier "Java-Code-ähnlich" hingeschrieben, sondern etwas anders. Wir machen es dennoch, weil es so für Sie leichter zu lesen ist.
Klassendiagramme enthalten i.d.R. mehrere Klassen und sollen u.a. die Beziehungen zwischen den Klassen darstellen. Das wird erst im nächsten Kapitel wirklich relevant.
Objektdiagramm
Aus einer Klasse können wir beliebig viele Objekte erzeugen, indem wir die Klasse instanziieren. Diese konkreten Objekte wollen wir auch darstellen, wobei wir uns immer bewusst sein müssen, dass Objekte etwas anderes sind als Klassen. Es gibt z.B. genau eine Klasse Person, aber potenziell viele Objekte vom Typ Person.
Objekte werden als Boxen mit runden Kanten dargestellt. Ganz oben steht der Typ des Objekts (also die Klasse, aus der dies Objekt instanziiert wurde) und darunter stehen alle Instanzvariablen und die aktuellen Werte.
Stellen wir z.B. zwei Person-Objekte her, könnten diese so aussehen:
Objektdiagramme setzt man ein, um die Entwicklung der Werte darzustellen oder - noch wichtiger - die Beziehungen zwischen den Objekten. Eine "Beziehung" tritt dann auf, wenn eine Instanzvariable eines Objekts auf ein anderes Objekt "zeigt".
Vorüberlegungen
Überlegen wir uns nochmal kurz, wie Arrays funktionieren. Ein Array muss zunächst mal erzeugt werden. Dabei werden zwei Dinge festgelegt: die Länge des Arrays und der Typ der Elemente (hier: String). Sie können in diesem Array also keine Zahlen und keine Objekte vom Typ Person oder Spaceship ablegen. Nur Strings!
String[] namen = new String[3];
Das Befüllen des Arrays findet über Indices statt:
namen[0] = "Superman"; namen[1] = "Batman"; namen[2] = "Catwoman";
Werden die Element gebraucht, läuft man oft mit einer For-Schleife durch alle Indices und holt sich mit namen[i] die Elemente.
for (int i = 0; i < namen.length; i++) { println(namen[i]); }
Für unsere Listen wünschen wir uns ähnliche Funktionen: Liste herstellen, Liste befüllen, auf Elemente zugreifen (über einen Index).
Einfach verkettete Liste
Die Grundidee einer sogenannten einfach verketteten Liste ist die einer Eisenbahn: jedes Element der Liste wird "eingepackt" in einen Waggon und die Waggons hängen aneinander.
Ein "Waggon" ist ein Objekt mit zwei Eigenschaften (Instanzvariablen):
- Inhalt: ein String, eine Zahl oder ein anderes Objekt (Person, Spaceship etc.)
- Verweis auf den nächsten Waggon
Die "Lokomotive" ist ein Objekt, das die Liste als ganzes repräsentiert. Dies Objekt muss lediglich auf das erste Listenelement verweisen. Als Objektdiagramm sieht unsere Listen wie folgt aus:
Wir formulieren zunächst die "Waggons" als Klasse. Diese Klasse erzeugt also Objekte, die ein einzelnes Listenelement repräsentieren. Nennen wir die Klasse also ListItem
mit zwei Instanzvariablen:
Der Inhalt (content) wird in einer Variablen vom Typ Object
gespeichert. Dies erlaubt uns, beliebige Objekte (String, Person, Spaceship etc.) dort zu speichern. Warum das funktioniert, erfahren wir in einem späteren Kapitel mit dem Thema "Klassenhierarchie".
In next
wird ein Verweis auf das nächste Listenelement gespeichert oder null, wenn es keines gibt (d.h. dies ist das letzte Element).
Wir setzen das next zunächst auf null (d.h. es gibt anfangs keinen Nachfolger) und spendieren unserer Klasse gleich einen Konstruktor:
class ListItem { Object content; // Inhalt, z.B. String ListItem next = null; // nächstes Element // Konstruktor ListItem(Object x) { content = x; } }
Ein frisch erzeugtes Objekt könnte so aussehen:
Als nächstes müssen wir noch die Liste als Ganzes repräsentieren. Dazu erstellen wir eine eigene Klasse MyList
, die lediglich auf das erste Element zeigen muss - oder auf null, wenn die Liste leer ist.
class MyList { ListItem first = null; // erstes Element oder null }
Diese Klasse erlaubt es uns auch, alle Methoden unterzubringen, die man sich so wünschen würde:
- add: Element hinzufügen
- printAll: alle Elemente ausgeben
- get: Element abfragen
- remove: Element löschen
Ein neues Listenobjekt, das noch keine Elemente hat, sieht so aus (eine Lokomotive ohne Waggons):
Beginnen wir mit add
, um unsere Liste befüllen zu können. Wenn wir ein neues Element hinzufügen, erzeugen wir ein ListItem
Objekt und hängen es hinten an die Liste an.
Wir erzeugen also ein neues ListItem-Objekt mit dem entsprechenden Inhalt (hier: x).
Ganz zu Beginn ist die Liste leer, d.h. wir setzen einfach first
auf das neue Objekt:
void add(Object x) { ListItem li = new ListItem(x); if (first == null) { // Fall 1: dies ist das erste Element überhaupt first = li; } else { // TODO } }
Für den Fall, dass die Liste nicht leer ist, müssen wir zunächst das letzte Element suchen, und den Zeiger next
dieses letzten Elements auf das neu erzeugte ListItem-Objekt setzen:
Dazu schreiben wir eine neue Hilfsmethode getLastItem
, die das für uns erledigt:
ListItem getLastItem() { // zur Sicherheit: Liste darf nicht leer sein // ... sonst bekommen Sie unten bei item.next einen Fehler! if (first == null) { return null; } // Schleife: solange Nachfolger vorhanden, // gehe zum Nachfolger ListItem item = first; // beginne beim ersten Item // Hat das Item einen Nachfolger... ? while (item.next != null) { item = item.next; // ... dann gehe zum Nachfolger } // Dieses Item hatte keinen Nachfolger // => muss das letzte sein return item; }
Jetzt können wir add
vervollständigen: Wir lassen uns das letzte Element raussuchen
und hängen an das next dieses letzten Elements unser neues Element:
void add(Object x) { ListItem li = new ListItem(x); if (first == null) { // Fall 1: dies ist das erste Element überhaupt first = li; } else { // Fall 2: es gibt bereits Elemente ListItem last = getLastItem(); last.next = li; } }
Eine Liste baut sich also wie folgt auf:
Wenn wir alle Listenelemente auf der Konsole drucken wollen, verwenden wir wieder eine While-Schleife. Wir benutzen außerdem einen Zähler, um die Elemente durchzunumerieren. Als gute Informatiker beginnen wir bei Null, wenn wir zählen. Ähnlich wie bei Arrays sprechen wir auch vom Index eines Listen-Elements.
void printAll() { ListItem item = first; int counter = 0; while (item != null) { println(counter + ": " + item.content); item = item.next; counter++; } }
Um unsere Liste zu testen, können wir jetzt die Liste befüllen und ausdrucken:
void setup() { MyList list = new MyList(); list.add("Superman"); list.add("Batman"); list.add("Catwoman"); list.printAll(); }
0: Superman 1: Batman 2: Catwoman
Jetzt möchten wir auf beliebige Elemente der Liste zugreifen. Dazu verwenden wir den Index eines Elements, also Null für das erste Element, 1 für das zweite, 2 für das dritte usw.
Zunächst stellen wir sicher, dass kein Unfug getrieben wird (negativer Index) und dass die Liste nicht leer ist. In diesen Fällen geben wir null zurück.
Object get(int index) { if (first == null || index < 0) { return null; } // TODO }
Jetzt können wir mit Hilfe einer For-Schleife so oft zum jeweils nächsten Element springen, wie uns der Index vorgibt. Wir müssen natürlich die Möglichkeit in Betracht ziehen, dass der Index größer ist als die Liste (Fehler des Programierers), also testen wir jedes Mal, ob es überhaupt einen Nachfolger gibt:
Object get(int index) { if (first == null || index < 0) { return null; } // For-Schleife: springe so oft zum Nachfolger, // wie der Index vorgibt ListItem li = first; // beginne beim ersten Element for (int i = 0; i < index; i++) { // Prüfe, ob es überhaupt einen Nachfolger gibt if (li.next != null) { li = li.next; } else { // wenn nicht, war der Index zu groß // gibt null zurück return null; } } // gibt den Inhalt des gefundenen Elements zurück return li.content; }
Die Methoden zum Feststellen der Größe einer Liste (size()
) und zum Löschen eines Elements (remove(int index)
) sollten Sie als Übung selbst versuchen zu implementieren.
Unterschiede: Liste vs. Array
Listen haben in modernen Hochsprachen die Arrays weitestgehend ersetzt. Dennoch finden Sie immer noch Arrays sowohl in alten wie auch in neuen Sprachen. Warum eigentlich? Der Grund ist, dass Arrays sehr sehr efffizient sind.
Ein Array wird hintereinander weg im Speicher abgelegt. Da der Typ der Elemente von Beginn an klar ist, kennt Java die notwendige Speichergröße pro Element. Da auch die Länge des Arrays von Beginn an klar ist, wird einfach ein zusammenhängender Speicherbereich von (Größe x Länge) reserviert. Wenn jetzt Element mit Index N gesucht wird, muss Java nur Startadresse + N * Größe rechnen, um das entsprechende Element zu finden.
Im Vergleich dazu muss bei einer einfach verketteten Liste beim get()
eine Schleife durchlaufen werden. Beim 1000-sten Element sind das 1000 Operationen. Im Vergleich muss beim Array eine einzige Operation (obige Formel) durchgeführt werden.
Es gibt allerdings auch alternative Listen-Implementierungen. Statt der einfach verketteten Liste kann man sich auch vorstellen, intern einen Array zu erstellen, der zu Beginn eine bestimmte Größe hat (z.B. 100 Elemente). In der Klasse merkt man sich einfach die Anzahl der besetzten Zellen, z.B. mit currentSize
.
class MyArrayList { Object[] content = new Object[100]; int currentSize = 0; }
Beim add()
verwendet man die Zahl currentSize, um das "letzte" Element zu besetzen:
void add(Object x) { content[currentSize] = x; currentSize++; }
Beim get()
greift man auf die entsprechende Zelle im Array zu. Ein printAll()
bekommt man auch schnell hin:
Object get(int index) { return content[index]; } void printAll() { for (int i = 0; i < currentSize; i++) { println(i + ": " + content[i]); } }Schon hat man die Vorteile eines Arrays mit denen einer Liste kombiniert!
Wo ist der Haken? Wenn wir bei add()
das 101-ste Element hinzufügen wollen, müssen wir einen komplett neuen Array erstellen (z.B. mit 200 Elementen) und den alten Array in den neuen kopieren, bevor wir das neue Element anhängen. Das kostet natürlich Zeit.
(Sie können sich auch überlegen, was passiert, wenn Sie das 50-ste Element entfernen müssen.)
void add(Object x) { if (currentSize < content.length) { content[currentSize] = x; currentSize++; } else { // Fall: Array ist zu klein! // TODO: neues Array, Inhalte kopieren, ... } }
Dennoch wird diese alternative Implementierung häufig genutzt und heißt sinnvollweise ArrayList
. Wir lernen die fertige Implementierung, die Java von Haus aus anbietet, in einem späteren Kapitel kennen.
Übungsaufgaben
12.2 a) Liste leer?
Schreiben Sie für eine einfach verkettete Liste (Klasse MyList) die Methode isEmpty()
. Die Methode gibt einen Wahrheitswert zurück: true, wenn es mindestens ein Element gibt und false, wenn es keine Elemente gibt.
Die Methode benötigt nur eine Zeile Code.
12.2 b) Größe einer Liste
Schreiben Sie für eine einfach verkettete Liste (Klasse MyList) die Methode size()
, welche die Anzahl der enthaltenen Elemente (int) zurückgibt.
12.2 c) Element entfernen
Schreiben Sie die Methode remove(int index)
für eine einfach verkettete Liste (MyList), die das Element mit dem
gegebenen Index aus der Liste entfernt.
An welcher Stelle müssen Sie das "next" ändern? Sehen Sie sich dazu die Abbildung oben mit den drei Listenelementen an - noch besser: zeichnen Sie es ab und überlegen sich, wie Sie etwaige Pfeile umbiegen müssen.
Beginnen Sie mit einem "gewöhnlichen" Beispiel, also z.B. eine Liste mit 3 Elementen, bei der Sie Element mit Index 1 entfernen.
Besonders wichtig: Überlegen Sie, welche Sonderfälle Sie testen müssen und tun Sie dies.
get()
in etwas
abgeänderter Form sinnvoll einsetzen könnten. Sie könnten
eine Methode getListItem()
schreiben, die
statt einem Object (Inhalt) das ListItem zurückgibt.
12.2 d) Ganze Liste hinzufügen
Schreiben Sie die Methode addAll(MyList otherList)
.
Die Idee ist, dass Sie eine komplette Liste (otherList) übergeben können und alle Elemente
von otherList hinzugefügt werden. Testen Sie den Sonderfall, dass otherList leer ist.
Sie können folgenden Test verwenden:
void setup() { MyList ls = new MyList(); ls.add("foo"); ls.add("bar"); MyList list2 = new MyList(); list2.add("one"); list2.add("two"); ls.addAll(list2); ls.printAll(); }
Sie sollten sehen:
foo bar one two
Wichtig: Es gibt hier zwei Möglichkeiten. Die ganz einfache Möglichkeit ist, das "next" des letzten Elements der einen Liste auf das erste Element der zweiten List zu biegen. Dann haben Sie aber das Problem, dass jede weitere Änderung der zweiten Liste auch ihre erste Liste betrifft. Besser ist es, die Elemente der zweiten Liste zu kopieren. Testen Sie dies dann wie folgt:
void setup() { MyList ls = new MyList(); ls.add("foo"); ls.add("bar"); MyList list2 = new MyList(); list2.add("one"); list2.add("two"); ls.addAll(list2); ls.printAll(); println("+ + +") list2.add("three"); ls.printAll(); }
Richtig ist, wenn Sie sehen:
foo bar one two + + + foo bar one two
Falsch ist:
foo bar one two + + + foo bar one two three
12.2 e) Element einfügen
Schreiben Sie die Methode add(int position, Object x)
, welche den Inhalt x auf diejenige Position setzt, die durch position
angegeben ist. Alle hinteren Elemente werden entsprechend verschoben. Verwenden Sie auch hier die Abbildung oben mit den Objekten und überlegen Sie, wo Sie die "next"-Pointer umbiegen müssen. Denken Sie wie bei remove() an die verschiedenen Sonderfälle.
12.3 ArrayList und Generics
Wir sehen uns jetzt die Klasse
ArrayList
genauer an, die Java/Processing "von Haus aus" anbietet und dem Programmierer erlaubt, Listen anzulegen und dort beliebig Elemente hinzuzufügen oder zu entfernen. Java bietet eine ganze Reihe von Klassen zur Verwaltung von mehreren Objekten an (z.B. LinkedList, HashMap, TreeSet etc.) diese Klassen laufen unter dem Oberbegriff Collections.
Eine solche Liste ist - ähnlich wie MyList - ein Objekt, das zunächst mal erzeugt werden muss. Im Gegensatz zu unserer Implementierung im Eigenbau können wir bei ArrayList den Typ der Elemente angeben, die wir in der Liste speichern wollen:
ArrayList<String> namensListe = new ArrayList<String>();
Wie Sie sehen, wird der Element-Typ in spitzen Klammern angegeben. Klassen, bei denen man einen zusätzlichen Typen in spitzen Klammern angibt, nennt man Generics. Für uns ist wichtig: in dieser Liste kann man nur Strings speichern!
Jetzt wollen wir die Liste befüllen. Das machen wir mit der Methode
add()
der Klasse ArrayList:
namensListe.add("Superman"); namensListe.add("Batman"); namensListe.add("Catwoman");
Sie sehen hier keine Indices. Die Methode add() hängt das übergebene Objekt immer hinten
an die Liste an. Dabei werden automatisch Indices vergeben, d.h. am Ende haben wir wieder Element 0, 1 und 2.
Wenn wir auf diese Elemente zugreifen wollen, verwenden wird die Methode
get()
:
for (int i = 0; i < namensListe.size(); i++) { println(namensListe.get(i)); }
Wichtig: Um die aktuelle Länge der Liste zu bekommen, verwendet man die Methode size(). Das ist ein großer Unterschied zu Arrays, wo eine Instanzvariable namens length diese Aufgabe hat.
Erweiterte For-Schleife (For-each)
Eigens für die Listen wurde eine vereinfachte For-Schleife entwickelt, die den Index i überflüssig macht. Stattdessen stelle man sich vor, man wolle "alle String-Elemente der List namensListe" durchlaufen. In jeden Durchlauf möchte man das durchlaufene Element in der Variable element zur Verfügung haben:
for (String element: namensListe) { println(element); }
Das kann man lesen als "Für alle Strings element aus der Liste namensListe...". In unserem Beispiel läuft die Schleife dreimal durch den Code. Beim ersten Mal enthält sie "Superman", beim zweiten Mal "Batman", beim dritten Mal "Catwoman". Ein Index i wird in dem Beispiel nicht benötigt.
Allgemein ist die Form der erweiterten For-Schleife:
for (<ELEMENT-TYP> <LAUFVARIABLE> : <LISTE>) { ... }
Man nennt dies auch For-each-Schleife, weil man im englischen lesen kann: "for each element of type <ELEMENT-TYP> out of <LISTE> do the following ..."
Unbegrenzte Länge
Wichtig ist, dass man jederzeit
add()
aufrufen kann, um die Liste zu verlängern.
Man kann mit der Methode
remove()
auch Elemente entfernen:
// String-Liste erzeugen ArrayList<String> namensListe = new ArrayList<String>(); // Liste füllen namensListe.add("Superman"); namensListe.add("Batman"); namensListe.add("Catwoman"); // Zugriff for (int i = 0; i < namensListe.size(); i++) { println(namensListe.get(i)); } // Liste ändern namensListe.remove("Batman"); namensListe.add("Grüne Laterne"); namensListe.add("Krümelmonster"); println("- - -"); // Zugriff mit erweiterter For-Schleife for (String element: namensListe) { println(element); }
Superman Batman Catwoman - - - Superman Catwoman Grüne Laterne Krümelmonster
Listen verwendet man immer dann, wenn Mengen sich verändern, was so oft vorkommt, dass man teils keine Arrays mehr verwendet. Zum Beispiel sind Listen auch sinnvoll, wenn Sie Objekte einlesen. Listen verbrauchen mehr Speicher als Arrays und sind evtl. auch langsamer im Zugriff. Solange man nicht zeit- oder speicherkritisch arbeiten muss, sollte man den Komfort von Listen auf jeden Fall nutzen.
Beispiel mit der Klasse Person
Listen können natürlich nicht nur mit Strings verwendet werden. Listen werden oft mit eigenen Klassen verwendet, zum Beispiel Person:
class Person { String name; int age; Person (String n, int a) { name = n; age = a; } }
Machen wir uns eine Liste mit Personen. In die spitzen Klammer kommt der Typ der Elemente, die von der Liste gespeichert werden sollen.
void setup() { ArrayList<Person> plist = new ArrayList<Person>(); plist.add(new Person("Harry", 15)); plist.add(new Person("Sally", 5)); plist.add(new Person("Joe", 3)); }
Jetzt wollen wir eine Funktion schreiben, die uns für eine Person das entsprechende Alter ausgibt. Denken Sie daran, dass der Name eine Eigenschaft der Klasse Person ist, so dass Sie per Punktoperator auf die Eigenschaft "name" zugreifen müssen. Hier sehen Sie auch, dass Sie Listen als Parameter verwenden können.
int findAge(String searchName, ArrayList<Person> liste) { // erweiterte For-Schleife for (Person p: liste) { if (p.name.equals(searchName)) { return p.age; } } return -1; }
Jetzt rufen wir die Funktion in setup() auf:
void setup() { ArrayList<Person> plist = new ArrayList<Person>(); plist.add(new Person("Harry", 15)); plist.add(new Person("Sally", 5)); plist.add(new Person("Joe", 3)); println(findAge("Sally", plist)); }
5
Elemente innerhalb einer Schleife entfernen
Elemente können mit der Methode remove()
aus der Liste entfernt werden. Beachten Sie, dass das Objekt durchaus noch existiert, es ist lediglich nicht mehr in der Liste enthalten. Häufig wollen wir Elemente entfernen, während wir eine Schleife durchlaufen, zum Beispiel, um die Liste zu filtern (nicht mehr sichtbare Schüsse entfernen, alle nicht-aktiven Vereinsmitglieder entfernen etc.).
Eine wichtige Regel ist, dass Elemente nicht innerhalb einer For-each-Schleife entfernt werden dürfen.
Folgender Code ist also fehlerhaft:
ArrayList<String> namensListe = new ArrayList<String>(); namensListe.add("Superman"); namensListe.add("Batman"); namensListe.add("Catwoman"); for (String element: namensListe) { // Name, der mit S beginnt if (element.startsWith("S")) { namensListe.remove(element); // NICHT ERLAUBT IN SCHLEIFE } }
Processing gibt in der Regel eine ConcurrentModificationException und beendet das Programm. Der Grund ist, dass die Verwaltung der Schleife aus dem Tritt kommt, wenn ein Element entfernt wird.
Selbst wenn der Fehler bei Ihnen nicht vorkommt, kann es sein, dass er in der Zukunft auftritt, deshalb sollten Sie ein remove() in der For-each-Schleife vermeiden.
Man darf innerhalb einer For-each-Schleife nicht Elemente einer Liste (mit remove) entfernen!
Da es aber häufig vorkommt, dass man Elemente in einer Schleife entfernen möchte, müssen wir auf die konventionelle For-Schleife zurückgreifen. Wir haben aber ein Problem, wenn wir vorwärts durch die Schleife gehen: wenn wir in der Schleife bei Element i
sind und in diesem Durchlauf das Element i
löschen, dann rutscht das Element i+1
auf Position i
. Aber dies Element wird im nächsten Schleifendurchlauf nicht mehr untersucht, da wir dann bei i+1
sind! Wir überspringen also bei jedem Löschen das jeweils nächste Element (Ausnahme: wir löschen das letzte Element).
Lösung 1: Liste rückwärts durchlaufen
Als Lösung durchlaufen wir die List rückwärts. Wenn wir jetzt Element i
löschen, macht es nichts, wenn i+1
nach vorn rutscht, da wir als nächstes ja i-1
untersuchen.
Codebeispiel (alle Namen, die mit S beginnen, entfernen):
ArrayList<String> namensListe = new ArrayList<String>(); namensListe.add("Superman"); namensListe.add("Superman"); // 2x Superman! namensListe.add("Batman"); namensListe.add("Catwoman"); for (int i = namensListe.size()-1; i >= 0; i--) { if (namensListe.get(i).contains("S")) { namensListe.remove(i); } } // Ausgeben for (String element: namensListe) { println(element); }
Ausgabe:
Batman Catwoman
Zum Vergleich sehen wir uns die normale Vorwärtsschleife an:
for (int i = 0; i < namensListe.size(); i++) { if (namensListe.get(i).contains("S")) { namensListe.remove(i); // Element i+1 rutscht nach! } }
Jetzt bekommen wir:
Superman Batman Catwoman
Denn das zweite "Superman" wurde übersprungen.
Lösung 2: Iterator verwenden (empfohlen)
Alternativ können Sie einen sog. Iterator verwenden, um eine Liste zu durchlaufen und gleichzeitig Elemente zu löschen. Ein Iterator ist ein Objekt, welches das Durchlaufen einer Liste "verwaltet". Man kann es sich als eine Art Zeiger auf das aktuelle Element vorstellen. Einen Iterator zu verwenden, ist die empfohlene Methode in Java, auf die wir an dieser Stelle aber nicht tiefer eingehen (siehe z.B. javabeginners). Wichtig ist hier, dass das Iterator-Objekt die Löschung eines Elements vornehmen kann und entsprechend seinen Zeiger anpasst.
Nur damit Sie das mal gesehen haben, der obige Code mit Hilfe eines Iterators. Beachten Sie, dass Sie das Paket java.util.*
importieren müssen.
import java.util.*; ArrayList<String> namensListe = new ArrayList<String>(); namensListe.add("Superman"); namensListe.add("Superman"); namensListe.add("Batman"); namensListe.add("Catwoman"); Iterator<String> it = namensListe.iterator(); while (it.hasNext()) { String element = it.next(); if (element.startsWith("S")) { it.remove(); } } // Ausgeben for (String element: namensListe) { println(element); }
Wichtige ArrayList-Methoden
Da Listen im Grunde die Arrays im Programmieralltag ersetzt haben, lohnt es sich, die vielen Methoden anzuschauen, die ArrayList
zu bieten hat.
Hier stellen wir einige überblicksartig vor. Das T
steht im folgenden für den Typ, den Sie bei Generics in spitzen Klammern angeben. Wenn Sie eine Liste mit ArrayList<Person>
angeben, wäre T
also die Klasse Person:
Methode | Beschreibung |
---|---|
add(T x)
|
fügt Objekt x ans Ende der Liste an |
add(int index, T x)
|
fügt Objekt x in Position index ein |
clear()
|
entleert die Liste |
contains(Object x)
|
prüft, ob x in Liste enthalten (true oder false) |
indexOf(Object x)
|
prüft, ob x in Liste enthalten und gibt die Position zurück (oder -1 falls nicht enthalten) |
addAll(ArrayList a)
|
fügt alle Elemente der übergebenen Liste a dieser Liste hinzu |
isEmpty()
|
prüft, ob Liste leer ist (true oder false) |
remove(int index)
|
entfernt das Element mit Position index |
remove(Object x)
|
entfernt Element x |
Wrapperklassen
Eine Liste kann nur Objekte speichern, keine primitiven Datentypen wie z.B. ganze Zahlen. Das ist ein herber Nachteil, denn vielleicht möchten Sie Geldbeträge, Jahreszahlen oder Indexzahlen in Listen speichern.
Eine Liste kann keine Elemente primitiver Datentypen (z.B. float, int, boolean) beinhalten.
Dies geht also nicht:
ArrayList<int> zahlen = new ArrayList<int>(); // PRIMITIV!
Um dem abzuhelfen bietet Java für jeden primitiven Datentyp wie int, float, boolean eine entsprechende sogenannte Wrapperklasse an: Integer, Float und Boolean. Man kann sich eine Klasse so vorstellen:
// Diese Klasse brauchen Sie nicht zu implementieren, // denn Java/Processing stellt sie bereits zur // Verfügung... class Integer { int value; // Konstruktor Integer(int v) { value = v; } // Zugriff auf den Wert int intValue() { return value; } }
Die Klasse "wickelt" (engl. to wrap) einen int-Wert einfach in ein Objekt. Dieses Objekt macht nichts anderes, als diesen einen Wert zu speichern. Jetzt kann man eine Liste für solche Objekte anlegen:
ArrayList<Integer> zahlen = new ArrayList<Integer>();
Um Zahlen hinzuzufügen, nutzen wir den Konstruktor von Integer:
zahlen.add(new Integer(10)); zahlen.add(new Integer(-5)); zahlen.add(new Integer(42000));
Um die Zahlen auszulesen, verwenden wir die Methode intValue
:
for (Integer z: zahlen) { println(z.intValue()); }
Autoboxing
Die Verwendung von Konstruktor und intValue() macht die Sache sehr sperrig, daher wurde in Java das sog. Autoboxing eingeführt. Java erkennt selbst, wenn eine Zahl "eingepackt" (engl. boxing) werden sollte. Sie können also folgendes schreiben und Java fügt selbständig das "new Integer(..)" hinzu:
zahlen.add(10); // Zahl wird automatisch in neues Objekt gepackt zahlen.add(-5); zahlen.add(42000);
Autounboxing
Analog gibt es das Autounboxing, d.h. die Werte werden aus den Objekten ausgepackt, ohne dass die Methode intValue() explizit aufgerufen werden muss:
for (Integer z: zahlen) { println(z); // Zahl wird aus Objekt rausgezogen }
Hier ein weiteres Code-Beispiel, das zeigt, wie Java mit den Wrapperklassen umgeht. Im Grunde kann man sagen, dass Objekte von Wrapperklassen genauso behandeln können, als ob Sie einen primitiven Datentypen vor sich hätten:
Float f1 = 12.5; // Autoboxing println(f1 - 2.5); // Autounboxing Float f2 = f1 - 10; // Autounboxing + Autoboxing float x = f1 + f2; // 2x Autounboxing println(x);
Eigene Generics programmieren (*)
Im vorigen Abschnitt haben wir eine eigene Liste MyList
programmiert.
Diese konnte beliebige Objekte speichern und war somit nicht typisiert,
d.h. man könnte verschiedene Objekte mischen und muss zur Laufzeit casten,
um auf spezifische Objekteigenschaften zuzugreifen.
Weiterhin haben wir Generics am Beispiel der ArrayList
kennen gelernt. Hier können Sie in spitzen Klammern den Typ der Objekte
angeben, die in der Liste gespeichert werden können. Wie können
wir selbst eine solche generische Klasse programmieren?
In Java können Sie bei Definition einer Klasse einen generischen Typen
als Variable übergeben. Konvention ist, diesen Typ mit einem einzigen
Großbuchstaben zu benennen. Bei MyList
wollen wir den
Typ Object
ersetzen durch eine Variable:
class MyList<T> { ... }
Genauso wie bei ArrayList
möchten Sie dadurch erlauben,
dass die Liste mit einem spezifischen Typ erstellt werden kann:
MyList<String> l = new MyList<String>();
Zurück zum Code. Ab sofort können Sie überall, wo Sie den spezifischen Typ erzwingen
möchten, die Variable T
einsetzen. Die Methode add()
etwa, möchte natürlich nur Inhalte vom Typ T
als Parameter
zulassen:
void add(T x) { ... }
Auch die Hilfsklasse ListItem
sollte ein Generic sein:
class ListItem<T> { T content; ListItem next = null; ListItem(T x) { content = x; } }
Jetzt können wir uns die Klasse MyList
in ihrer Gesamtheit
ansehen:
class MyList<T> { ListItem<T> first; void add(T x) { ListItem<T> li = new ListItem<T>(); li.content = x; if (first == null) { first = li; } else { ListItem<T> last = getLastItem(); last.next = li; } } ListItem<T> getLastItem() { if (first == null) { return null; } ListItem<T> last = first; while (last.next != null) { last = last.next; } return last; } void printAll() { ListItem<T> li = first; int counter = 0; while (li != null) { println(counter + ": " + li.content); li = li.next; counter++; } } }
Der einzige Fall, der hier nicht auftaucht, ist, dass ein generischer Typ als Rückgabetyp einer Methode verwendet wird. Auch das ist natürlich möglich.
Übungsaufgaben
12.3 a) String-Liste anlegen und ausgeben
Erstellen Sie eine Liste mit 5 Strings. Geben Sie die Listenelemente auf der Konsole aus und verwenden Sie dazu die erweiterte For-Schleife.
12.3 b) Liste von Person-Objekten anlegen und ausgeben
Erstellen Sie eine Liste mit 3 Objekten der Klasse "Person". Geben Sie die Namen der Personen auf der Konsole aus und verwenden Sie dazu die erweiterte For-Schleife.
Hinweis: Sobald Sie Klassen benutzen, müssen Sie im aktiven Modus sein. Schreiben Sie also Ihren Hauptcode in setup().
12.3 c) Liste kopieren
Erstellen Sie eine Liste von Strings mit drei Einträgen
und binden Sie sie an die Variable liste1
.
Erstellen Sie eine Kopie dieser Liste in Variable liste2
. Kopie bedeutet, dass Sie zwei Listen haben, die die gleichen Inhalte haben, aber unabhängig voneinander existieren.
Prüfen Sie die Kopie, indem Sie liste1
nach dem
Kopieren um einen weiteren String ergänzen. Dieser sollte in
liste2
nicht auftauchen.
Es gibt mehrere Möglichkeiten, diese Aufgabe zu lösen:
- Erstellen Sie eine neue Liste und kopieren Sie die Liste elementweise
- In der Dokumentation von ArrayList finden Sie alternative Möglichkeiten, eine Liste zu kopieren.
12.3 d) Listen zusammenführen
Erstellen Sie zwei String-Listen a
und b
und erstellen Sie eine dritte Liste c
,
welche alle Elemente von a
und b
enthält.
addAll()
(gehört der Klasse ArrayList)
erlaubt das Hinzufügen einer kompletten Liste zu einer Liste.
Als Argument übergeben Sie eine andere Liste. Für die vorliegende Aufgabe schlage ich vor, dass Sie zunächst versuchen, die Elemente einzeln - mit add()
- zu kopieren. Im Anschluss probieren Sie
addAll()
, was die Sache sehr leicht macht.
12.3 e) Fliegende Bälle
Schreiben Sie ein Programm mit einer Ball-Klasse. Zu Beginn gibt es drei Bälle, die durch das Fenster fliegen und von den Rändern abprallen (Sie sollten im Skript ausreichend Code für diese Art Aufgabe finden). Verwenden Sie jetzt eine Liste, um die Bälle zu speichern (ähnlich wie vorher den Array). Verwenden Sie die erweiterte For-Schleife, um die ursprünglichen drei Bälle zu zeichnen und zu animieren.
Jetzt können Sie auf Tastendruck (Leertaste) ein neues Ball-Objekt erzeugen und hinzufügen. Genauso können Sie mit Tastendruck (Backspace) einen Ball entfernen. Probieren Sie es...
Hinweis: Überlegen Sie, was Sie in setup() und was in draw() tun müssen. Sie benötigen zwei Foreach-Schleifen.
12.3 f) Suchen
Erstellen Sie eine Liste von Person-Objekten (siehe Beispiel oben im Skript). Fügen Sie einige Namen hinzu.
Sobald Sie eine Buchstaben-Taste drücken, sollen alle Namen auf der Konsole ausgegeben werden, die (als Person) in der Liste gespeichert sind, sofern der Namen mit diesem Buchstaben beginnt.
Wichtig: Wenn Sie die Funktion keyPressed() verwenden wollen, müssen Sie auch ein draw() haben (kann leer sein), damit Processing seine draw-Schleife ausführt und die Tasten entsprechend prüft.
Hinweise:
Die String-Methode startsWith("x")
gibt true oder false zurück, je nachdem, ob der String mit "x" beginnt ("x" ist nur ein Beispiel). Sie finden mehr dazu in Kap. 17.3.
char
haben, zum Beispiel in Variable a
, dann können Sie mit "" + a
daraus einen String machen.
12.3 g) Wrapperklassen 1
Erstellen Sie eine Liste von int-Werten, indem Sie die entsprechende Wrapperklasse verwenden. Fügen Sie die Werte 5, 10, 15, 30, 40 hinzu.
Berechnen Sie die Summe mit Hilfe einer neuen Variablen summe
und mit einer For-each-Schleife und geben Sie das Ergebnis auf der Konsole aus. Testen Sie Ihren Code, indem Sie die Werte der Liste verändern.
12.3 h) Wrapperklassen 2
Erstellen Sie eine Liste von Person-Objekten (nicht Strings, siehe Beispiel oben im Skript) und fügen Sie mindestens drei Personen hinzu.
Sie sollen jetzt aus Ihrer Personenliste heraus
eine neue Liste altersliste
befüllen, die nur aus Zahlen besteht und alle Altersangaben enthält (anonym sozusagen).
Wenn Ihre Liste zum Beispiel drei Personen mit Alter 18, 42, 12 enthält, dann soll die neue Liste nur die Zahlen 18, 42, 12 enthalten.
12.3 i) Shooter
Schreiben Sie ein Spiel, bei dem Bälle abgeschossen werden müssen, was wiederum Punkte gibt.
Gehen Sie wie folgt vor:
Erstellen Sie eine Klasse Target
für die Bälle. Sie speichern dort Position, Geschwindigkeit,
Größe und Punkte - wählen Sie zur Initialisierung passende Zufallswerte. Schreiben
Sie die Methoden render
und update
, um die Bälle
zu zeichnen und zu animieren.
Verwenden Sie eine ArrayList, um die Target-Objekte zu speichern, und eine globale Score-Variable für den Punktestand. Durchlaufen Sie die Liste zum Zeichnen und Animieren der Bälle.
In mousePressed()
prüfen Sie alle Target-Objekte
auf Kollision mit dem Mauszeiger. Bei Kollision löschen Sie das
Objekt aus der Liste und erhöhen Sie den score.
Wichtig: Denken Sie daran, dass Sie innerhalb einer Foreach-Schleife keine Objekte der Liste löschen dürfen. Stattdessen könnten Sie außerhalb der Schleife eine Variable definieren, die Sie im Fall der Kollision mit dem Listenelement befüllen und anschließend aus der Liste entfernen.
Zusammenfassung
Die Klasse
ArrayList
erlaubt das Speichern von mehreren Elementen
desselben Typs. Diesen Element-Typ gibt man in spitzen Klammern an. Zum Beispiel
ArrayList<String>
für eine Liste von String oder
ArrayList<FlyingThing>
für eine Liste von Objekten vom Typ FlyingThing.
Ein Datentyp, bei dem - wie bei ArrayList
- ein zweiter Datentyp in spitzen Klammern angegeben wird, nennt man einen Generic.
Jedes Element einer Liste hat, wie bei Arrays, eine Indexzahl, beginnend bei 0. Die Länge der Liste kann - anders als bei Arrays - jederzeit verändert werden.
-
Element an Stelle
i
mit Methodeget(i)
abrufen -
aktuelle Länge der Liste
mit
size()
abrufen -
mit Methode
add()
neues Element hinten anfügen -
mit Methode
remove()
beliebiges Element löschen
Die erweiterte For-Schleife (auch: Foreach-Schleife) erlaubt ein elegantes Durchlaufen von Listen ohne Verwendung eines Index:
for (<ELEMENT-TYP> <LAUFVARIABLE> : <LISTE>) { ... }
Sie dürfen nicht innerhalb einer For-each-Schleife Elemente der Liste entfernen (mit remove). Stattdessen verwenden Sie die normale For-Schleife (rückwärts durchlaufen!) oder Iteratoren.
Sie können keine Listen von primitiven Typen anlegen, d.h. ArrayList<int> oder ArrayList<float> geht nicht. Wenn Sie eine solche Liste benötigen, verwenden Sie die jeweiligen Wrapperklassen (Integer, Float, Boolean etc.) wie z.B. bei ArrayList<Integer>. Mit Hilfe von Autoboxing/Autounboxing können Sie die Objekte der Wrapperklassen ähnlich zu primitiven Daten verwenden.