Java-Kurs: Klasse als Datenstruktur
Willemers Informatik-Ecke
Strings Objektorientierte Programmierung

Klassendefinition

Programme modellieren die Welt. Programme beschreiben weniger die Schönheit der Natur, sondern betrachten Datenwerte. Je nach Zweck des Programms werden Aspekte der Objekte dieser Welt nachgebildet. Dazu reichen die bisher bekannten Datentypen nicht aus, aber durch Kombination von diesen lassen sich passable Nachbildungen erstellen.

Modellbau

Die Objekte lassen sich modellieren, indem einfache Datentypen zu komplexeren zusammengestellt werden. Um einen Mensch, ein Auto oder ein Datum zu modellieren, wird eine Klasse definiert.

public class Auto {
    String marke = "";
    String modell = "";
    int ps = 0;
    int hubraum = 0;
    int baujahr = 0;
    double preis = 0.0
    double verbrauch = 0.0;
}
Wir könnten noch beliebige andere Bestandteile hinzufügen. Dabei sollte die bildhauerische Leistung nicht so im Mittelpunkt stehen, wie die Frage, welche Daten das Programm wirklich braucht. Der Zulassungsstelle ist beispielsweise der Preis egal, während er beim Autohandel Dreh- und Angelpunkt ist.

Jede Klasse wird in Java in einer eigenen Datei abgelegt, die den Klassennamen trägt, der um die Endung .java erweitert wird. Für die Klasse Auto wird die Datei Auto.java angelegt.

Unter Eclipse werden Klassen über das Menü mit File|New|Class erzeugt. Dazu muss das Projekt angewählt sein, zu dem die Klasse gehören soll.

Der Java-Compiler wandelt jede Klassendatei einzeln in Zwischencode um und hängt dieser Datei die Endung .class an.

Lokale Klassen

Als Sonderfall ist es möglich lokale Klassen innerhalb einer Klasse anzulegen. Das sollte nur dann gemacht werden, wenn die innere Klasse nur in der umschließenden Klasse benötigt wird und der Umfang der inneren Klasse begrenzt ist.

Eine solche Vorgehensweise findet man normalerweise nur bei nicht objektorientierten Programmen. Da in diesem Fall die Methoden static definiert sind, muss dann auch die lokale Klasse static definiert sein.

public class Hauptprogramm {
    
    static class Auto {
        String marke = "", modell = "";
        int ps = 0, hubraum = 0, baujahr = 0;
        double preis = 0.0, verbrauch = 0.0;
    }
    
    public static void main(String[] args) {
        Auto r4 = new Auto();
    }
}

Objekte sind Instanzen

Die Klasse dient der Blaupause zu Objekten. Mein Auto in der Garage ist eine Instanz der Klasse Auto. Das meines Nachbarn ist eine weitere Instanz.

Eine Instanz einer Klasse Auto wird mit der folgenden Anweisung erzeugt:

public static void main(String[] args) {
    Auto r4 = new Auto();
    // ...
Die Referenzvariable r4 verweist auf die Instanz der Klasse Auto, die durch den Befehl new erzeugt wird. Hinter dem new steht der Name der Klasse. Achten Sie besonders auf die runden Klammern hinter dem Klassennamen.

Die Instanz wird auch Objekt genannt und liegt irgendwo im Speicher. Die Referenz enthält die Speicheradresse, wo das Objekt liegt.

Zugriff auf die Attribute

Um auf die Bestandteile des Autos zuzugreifen, wird an den Variablennamen ein Punkt und der Bestandteil angehängt.

Auto r4 = new Auto();
r4.marke = "Renault";
Die Bestandteile einer Klasse werden Attribute genannt. Im Beispiel wird das Attribut marke mit einem String besetzt, da dieses Attribut in der Klassendefinition als String definiert ist. Auf analoge Weise kann das Attribut ps zugegriffen werden.

r4.ps = 34;
Beim Zugriff auf Objekt-Elemente kann es manchmal zu einer NullPointerException kommen. Fast immer deutet dieser Fehler darauf hin, dass für die verwendete Referenz kein new aufgerufen wurde, also keine Instanz existiert.

Mehrere Instanzen

Von einer Klasse können beliebig viele Instanzen geschaffen werden.

Auto r4 = new Auto();
Auto a4 = new Auto();

Jede Referenz hat daraufhin ein eigenes Objekt, weil für jede Referenz ein eigenes new aufgerufen wurde. Im Speicher ergibt sich das folgende Bild:

Kopie einer Referenz

Referenzen verweisen auf eine Instanz. Bei einer Zuweisung einer Referenz auf eine andere wird nur die Referenz, also die Speicheradresse der Instanz kopiert, nicht aber die Instanz selbst. Im folgenden Beispiel gibt es nur einen R4, der mir und Dir gehört:

Auto meinR4 = new Auto();
Auto deinR4 = meinR4;  // die Referenzen zeigen auf eine gemeinsame Instanz

Die Konsequenz daraus ist, dass die Änderungen an meinem R4 auch deinen R4 betreffen. Zeigen wir das ganze an einem a4, der ja mehr PS hat.

Auto meinR4 = new Auto();
meinR4.ps = 34;
Auto a4 = meinR4; // die Referenzen zeigen auf eine Instanz
a4.ps = 100;      // Änderung an a4 ist eine Änderung an meinR4

Kopie einer Instanz

Will man wirklich eine zweite Instanz von einem Auto mit den gleichen Attributen, muss man diese kopieren.

Auto meinR4 = new Auto();
meinR4.marke = "Renault";
meinR4.ps = 34;
meinR4.baujahr = 1978;
Auto deinR4 = new Auto();
deinR4.marke   = meinR4.marke;
deinR4.ps      = meinR4.ps;
deinR4.baujahr = meinR4.baujahr;
Ändert man nun das Baujahr an deinR4, bleibt das Baujahr von meinR4 erhalten.
deinR4.baujahr = 1982;
System.out.println(meinR4.baujahr); // 1978
Für das Kopieren einfacher Daenstrukturen, also unverschachtelter Klassen, können Sie die Standardmethode clone verwenden, müssen sich dann aber mit der Exception CloneNotSupportedException herumschlagen. Bei diesen Einschränkungen ist eine eigene Kopiermethode in der Regel die sinnvollere Lösung.

Array von Referenzen

Sie können die selbstgeschaffenen Typen auch in Arrays organisieren. Dabei werden nicht die Instanzen, sondern die Referenzen auf die Instanzen im Array abgelegt.

final int FAHRZEUGE = 4;
Auto[] fuhrpark = new Auto[FAHRZEUGE];

Die Variable fuhrpark ist ein Array von Referenzen auf Auto-Instanzen. Die Auto-Instanzen liegen aber zu diesem Zeitpunkt nicht vor. Es fehlt noch das new für jede der Referenzen in dem Array.

Da die Referenzen ohne Instanz unweigerlich zu einer NullPointerException führen würden, müssen die Referenzen entweder aus dem Programm belegt werden oder man muss neue Instanzen über den Befehl new anlegen. Um die Instanzen zu den im Array abgelegten Referenzen zu erzeugen, muss das Array mit einer Schleife durchlaufen werden.

for (int i=0; i<FAHRZEUGE; i++) {
    fuhrpark[i] = new Auto();
}
Soll auf die Instanzattribute einer Referenz im Array zugegriffen werden, wird zunächst die rechteckige Klammer des Arrays mit dem zugehörigen Index angegeben. Es folgt der Punkt mit dem Namen des Attributs. Der Zugriff auf die PS-Zahl des dritten Fahrzeugs gestaltet sich so:
fuhrpark[2].ps = 34;

Verschachtelte Klassen

Ein Objekt kann andere Objekte enthalten. So kann ein Auto ein Datum als Erstzulassung haben. Dieses Datum besteht seinerseits aus Tag, Monat und Jahr.
class Datum {
    int tag=0, monat=0, jahr=0;
}

class Auto {
    String marke = "", modell = "";
    int ps = 0, hubraum = 0, baujahr = 0;
    double preis = 0.0, verbrauch = 0.0;
    Datum erstzulassung = new Datum();
}
Das Attribut erstzulassung ist eine Referenz mit allen Konsequenzen. Vor allem muss die Variable initialisiert werden. Das kann wie oben zu sehen, direkt in der Klasse erfolgen.

Der Zugriff erfolgt über die Referenz des Autos auf die Referenz erstzulassung und dann auf die Attribute der Klasse Datum.

Auto a4 = new Auto();
a4.erstzulassung.tag = 12;

Referenzen und Methoden

Klassen können als Parameter und als Rückgabetyp für Methoden eingesetzt werden.

Referenz als Parameter

Wird eine Klasse als Parametertyp für eine Methode angegeben, wird eine Referenz auf die Instanz übergeben und damit kopiert, nicht aber die Instanz selbst.

Über diese Referenz greift die Methode aber auf das Objekt zu, das der Aufrufer übergeben hat.

static void tuneAuto(Auto a) {
    a.ps += 100;
}
Änderungen innerhalb der Methode ändern darum auch das Objekt, dessen Instanz der Aufrufer übergeben hat.
Auto r4 = new Auto();
r4.ps = 34;
tuneAuto(r4);
System.out.println(r4.ps); // 134

Rückgabe einer Referenz

Besonders interessant ist die Rückgabe eines Objekts, weil eine Methode ja nur einen Wert zurückgeben kann. Im Beispiel wird die X- und die Y-Koordinate zurückgegeben. Das ist kein Problem, weil sich beide in einem Objekt der Klasse Koordinate befinden.
static public Koordinate eingabe() {
        Koordinate koord = new Koordinate();
        // Benutzereingabe
        koord.x = x;
        koord.y = y;
        return koord;        
    }
Zunächst wird ein Objekt der Klasse Koordinate erzeugt und dessen Referenz in der lokalen Variable koord gespeichert. Anschließend werden die Werte gefüllt. Mit return wird die Referenz an den Aufrufer zurückgegeben. Das per new erzeugte Objekt stirbt nicht bei Methodenende, sondern erst dann, wenn es keine Referenz mehr darauf gibt.

Selbstreferenz: Rekursive Datentypen

Wenn eine Klasse eine Referenz auf sich selbst besitzt, verweist dies in der Regel auf ein anderes Objekt derselben Klasse. Auf diese Weise lässt sich eine verkettete Liste bauen.

static class Liste {
    String data;
    Liste next = null;
}
Im folgenden Beispiel ist es eine Liste, die als Daten einen String mit einem Namen enthält. Das Hauptprogramm füllt diese Liste aus einem Array, das mit Namen vorbesetzt ist.

Der Anfang der Liste, den man auch als Anker bezeichnet, ist über die Variable anker greifbar. Ist anker==null, gibt es kein Element. Das folgende Programmstück füllt die Liste mit Clownsnamen:

Liste anker = null;
String[] clowns = { "August", "Arnold", "Willemer" };
for (int i=0; i<clowns.length; i++) {
    Liste alt = anker;
    anker = new Liste();
    anker.data = clowns[i];
    anker.next = alt;
}
Jeder neue Namen wird beim Anker eingehängt und die bisherige Liste hinten an das neue Element angehängt. Dadurch liegen die erzeugten Elemente in umgekehrter Reichenfolge in der Liste.

Um die Liste auszugeben, wird eine Referenz lauf mit dem Anker initialisiert. Solange lauf nicht das Ende der Liste darstellt, wird das Element, auf das lauf zeigt, ausgegeben und durch Zuweisung von next auf das nächste Element der Liste weitergewechselt.

Liste lauf = anker;
while (lauf!=null) {
    System.out.println(lauf.data);
    lauf = lauf.next;
}
Wichtig ist, dass man eine Kopie von anker zum Laufen verwendet. Wenn anker einmal den Anfang der Liste verliert, ist der Kopf der Liste weg.

Der Vorteil einer verketteten Liste gegenüber einem Array ist die Möglichkeit, die Liste bei Bedarf dynamisch zu verlängern. Auch das Einfügen oder Löschen von Elementen in der Mitte ist bei einer verketteten Liste wesentlich effizienter als bei einem Array.

Dagegen ist der direkte Zugriff auf das Element an der x-ten Position im Array deutlich schneller, weil man sich in einer verketteten Liste von Element zu Element durchhangeln muss.

Bäume haben mindestens zwei Selbstreferenzen

Wenn eine Klasse zwei Referenzen auf die eigene Klasse besitzt, liegt der Verdacht nahe, dass es sich um einen Baum handelt. Eine typische Anwendung eines solchen Baums ist das sortierte Ablegen von Daten.

Sie kennen vieleicht das Kinderspiel, in dem sich ein Kind eine Zahl ausdenkt, die das andere Kind raten muss. Als Hilfestellung verrät das erste Kind, ob die gedachte Zahl größer oder kleiner als die geratene Zahl ist. Besonders effizient findet das ratende Kind die Zahl, indem es den Zahlenraum halbiert. So kann es nach 10 Versuchen eine Zahl zwischen 1 und 1000 finden.

Beim Baum legt man die Werte gleich entsprechend ab. Hier die Definition der Klasse:

static class Baum {
        int wert = 0;
        Baum kleiner = null;
        Baum groesser = null;
    }

Auslesen eines Sortierbaums

Es ist schwierig, einen kompletten Baum sortiert mit Schleifen auszugeben. Solange es ein kleineres Element gibt, verzweigen Sie immer zum kleineren Ast ab. Ist die unterste Variable ausgegeben, müssen Sie eine Stufe zurück, dort den größeren Ast nehmen und dann wieder möglichst tief über den kleineren Ast hinunter ... Das wollen Sie nicht programmieren!

Stattdessen verwenden Sie eine knackig kurze Rekursion. Sie tut genau das oben beschriebene und das auf wenigen Zeilen.

static void ausgeben(Baum baum) {
    if (baum != null) {
        ausgeben(baum.kleiner);
        System.out.print(" " + baum.wert);
        ausgeben(baum.groesser);
    }
}

Einfügen in Sortierbaum

Etwas mehr Aufwand müssen Sie beim Einfügen in den Sortierbaum treiben. Aber auch hier ist die Lösung rekursiv. Die Vorraussetzung für das Funktionieren der Methode einsortieren ist, dass die Variable baum ungleich null ist.
static void einsortieren(Baum baum, int zahl) {
    if (baum.wert > zahl) {
        if (baum.kleiner != null) {
            einsortieren(baum.kleiner, zahl);
        } else {
            baum.kleiner = new Baum();
            baum.kleiner.wert = zahl;
        }
    } else {
        if (baum.groesser != null) {
            einsortieren(baum.groesser, zahl);
        } else {
            baum.groesser = new Baum();
            baum.groesser.wert = zahl;
        }
    }
}

Videos

Klassen als Datenverbund

Rekursive Datentypen


Strings Objektorientierte Programmierung