Klassen

Inhalt

 

 

Einführung

 

Vererbung

 

Public und Private

 

Konstruktoren

 

Statische Variablen und Methoden

 

this

 

Goldene Regeln für Klassen und Vererbung

 

Zusammenfassung

 

Beispiele

 

Arrays von Objekten

 

Exceptions

 

Übungsaufgaben

Einführung

Klassen sind spezielle Datentypen. Klassen sind Zusammenfassungen von

Da Klassen Datentypen sind, können Variablen von diesem Datentyp angelegt werden. Diese Variablen heißen Instanzen der Klasse, oder auch Objekte. Die Objekte werden auf dem Heap angelegt. Auf dem Stack werden nur Referenzen auf die Objekte gespeichert. Dies ist das gleiche Verhalten wie bei Arrays, die in diesem Sinn auch Objekte sind. Ein im Hintergrund laufender Prozess (garbage collector) gibt den Speicherplatz für die Objekte wieder frei, wenn sie nicht mehr benötigt werden (in C ist der Programmierer dafür verantwortlich).

Eine einfache Klasse zur Demonstration ist beispielsweise ein Flag. Es soll

Hier ist die Deklaration einer solchen Klasse Flag

class Flag
{   boolean F; // Variable fuer die Eigenschaft
    
    void set () // Methode Setzen
    {   
        F=true;
    }

    void clear () // Methode Loeschen
    {   
        F=false;
    }

    boolean isSet () // Abfrage des Status
    {   
        return F;
    }
}

Allgemein sieht diese Deklaration also so aus

class name
{   
     Deklarationen
}

Dabei ist name der Name des Datentyps. Deklarationen sind entweder Variablendeklarationen oder Funktionen. In diesem Beispiel ist set dazu da, das Flag zu setzen, und clear, das Flag zu löschen. isSet fragt den Zustand des Flags ab.

Die obige Klassendefinition kann in derselben Datei stehen, wie unsere Test-Klasse, also in Test.java. Oder sie kann in einer eigenen Datei stehen, die dann allerdings Flag.java heißen muss. Falls die Klasse als public deklariert ist, muss sie in einer eigenen Datei stehen.

Wie verwendet man nun diese Klasse Flag? Dazu ein Beispiel:

public class Test
{   
    static public void main (String args[])
    {   
        Flag f=new Flag();
        f.set(); // setzt f.F auf true
        System.out.println(f.isSet()); // druckt true
        f.clear(); // setzt f.F auf false
        if (!f.isSet()) // ist wahr!
            System.out.println("f ist nicht gesetzt!");
    }
}

Man deklariert eine Referenz auf ein Objekt vom Typ Flag also wie gewöhnlich mittels

Flag f;

Um aber das Objekt auf dem Heap anzulegen, muss man es mit new erzeugen. Dazu dient das Kommando

new Flag();

das eine Referenz auf ein Flag-Objekt zum Ergebnis hat. Diese Referenz wird f zugewiesen. Der Zweck der runden Klammern () wird später klar.

Wie ruft man nun eine Methode von f auf? Dazu dient die Notation

f.set();

Dies ist ein Funktionsaufruf set, der sich auf f bezieht. Dieser Funktionsaufruf scheint keine Parameter zu haben. Aber implizit bekommt er eine Referenz auf f als Parameter. Innerhalb von set wird ja die Variable F angesprochen, und set muß wissen, in welcher Instanz diese Variable liegt.

Man kann die Variablen von f auch direkt ansprechen, also etwa

f.F=true;

anstatt f.set(). Wie wir sehen werden, entspricht dies aber nicht den ursprünglichen  Intentionen des objekt-orientierten Programmierens.

Vererbung

Angenommen, wir wollen die Klasse Flag erweitern. Natürlich könnten wir einfach den Source-Code von Flag ändern. Nehmen wir aber weiter an, dass dieser Source-Code schon überall verwendet wird und wir ihn daher nicht anrühren wollen, oder dass uns nur der kompilierte Code zur Verfügung steht. Vielleicht wollen wir auch nur die Klasse möglichst einfach halten, und benötigen die Erweiterung nur für einen Spezialzweck.

Dies ist eine Aufgabe für die Vererbung. Dabei wird die Klasse Flag erweitert und behält dabei ihre alten Eigenschaften und Methoden.

Als Beispiel erweitern wir Flag um die Methode toggle, die Flag wahr macht, wenn es falsch ist und umgekehrt. Dies sieht so aus.

class ToggleFlag extends Flag
{   
    void toggle ()
    {   
        if (isSet()) clear();
        else set();
    }
}

ToggleFlag erbt alles von Flag, und fügt eine Methode toggle hinzu.

Das Hauptprogramm, das diese Klasse verwendet, sieht fast genauso aus wie vorher.

public class Test
{   
    static public void main (String args[])
    {   
        ToggleFlag f=new ToggleFlag();
        f.set();
        System.out.println(f.isSet());
        f.clear();
        if (!f.isSet())
            System.out.println("f ist nicht gesetzt!");
        f.toggle(); // hier wird die neue Methode benutzt
        if (!f.isSet())
            System.out.println("f ist nicht gesetzt!");
    }
}

ToggleFlag funktioniert also weitestgehend wie Flag. Es kann nur etwas Zusätzliches, nämlich die Methode toggle().

Natürlich lassen sich auch neue Variablen in Kinderklassen aufnehmen.

Vererbung ist eine der wichtigsten Eigenschaften für die Wiederverwendung von Code.
Alter Code bleibt bestehen und es wird neuer hinzugefügt.

Es ist allerdings auch möglich, alten Code zu verwerfen und mit neuem zu überlagern. Als Beispiel betrachen wir eine Klasse Flag, die jeden Versuch clear() aufzurufen, dokumentiert, aber das Flag nicht setzt.

class StrangeFlag extends Flag
{   
    void clear ()
    {   
       System.out.println("clear aufgerufen");
    }
}

Die Methode clear von StrangeFlag (genannt StrangeFlag.clear()) druckt zwar etwas, löscht F aber nicht. Sie macht also etwas Anderes als die Methode mit demselben Namen der Elternklasse. Man nennt diese Art der Neudefinition von Funktionen Überlagerung.

Die Überlagerung von Variablen ist auch möglich, aber nicht üblich. Alle neuen Methoden der vererbten Klasse beziehen sich dann auf die neue Variable. Es gibt sozusagen zwei Variablen desselben Namens in der vererbten Klasse. Offensichtlich kann das zu Verwirrungen führen.

Man kann die Variablen und Methoden der Vaterklasse explizit ansprechen, indem man ihrem Namen super. vorne anhängt. In der folgenden Variante rufen wir zunächst die Methode clear von Flag auf, bevor wir den Aufruf protokollieren. Danach funktioniert clear wieder wie vorher, druckt aber zusätzlich etwas.

class StrangeFlag extends Flag
{   
    void clear ()
    {   
        super.clear(); // ruft Flag.clear auf
        System.out.println("clear aufgerufen");
    }
}

Public und Private

Zweck des objektorientierten Ansatzes ist es

Insbesondere das zweite Ziel erfordert es, Implementationsdetails der Klasse zu verstecken. Nehmen wir an, wir hätten ToggleFlag folgendermaßen definiert

class ToggleFlag extends Flag
{   
    void toggle ()
    {   
        F=!F;
    }
}

Dies funktioniert, solange die Basisklasse Flag sich nicht ändert und etwa F wegrationalisiert. Es ist viel logischer, die Methoden von Flag zu verwenden. Um zu verhindern, dass Kinder oder andere Klassen Details verwenden, die sich ändern könnten, kann man Daten oder Methoden privatisieren. Im Falle von Flag sieht dies so aus:

class Flag
{   
    private boolean F;
    
    public void set ()
    {   
        F=true;
    }

    public void clear ()
    {   
        F=false;
    }

    public boolean isSet ()
    {   
        return F;
    }
}

Nun kann man auf F nur noch über die Schnittstellen set, clear und isSet zugreifen.

Will man erreichen, dass nur Kinder auf F zugreifen können, kann man F als protected erklären, also

... protected boolean F;

Konstruktoren

Es ist bisher noch unklar, ob unser Flag am Anfang gesetzt oder gelöscht ist. Java stellt zwar sicher, daß boolean-Variablen anfangs gelöscht sind, aber man kann dies auch mit Hilfe eines Konstruktors explizit erreichen.

Dies sieht so aus.

class Flag
{   
    protected boolean F;
    
    public Flag ()
    {   
        F=false;
    }

    ...
}

Ein Konstruktor ist also eine Methode, die so heißt wie die Klasse. Sie hat keinen Ergebnistyp. Der Konstruktor wird aufgerufen, kurz nachdem eine Instanz von Flag erzeugt wurde.

Es ist nun auch möglich, den Konstruktor mit einem Parameter zu versehen. Sogar beides in derselben Klasse ist mit Überladen möglich.

class Flag
{   
    protected boolean F;
    
    public Flag ()
    {   
        F=false;
    }
    
    public Flag (boolean f)
    {   
        F=f;
    }
    ...
}

Der Parameter wird bei new übergeben.

Flag f=new Flag(true);

definiert ein Flag, das anfangs wahr ist. Das Flag

Flag f=new Flag();

ist dagegen anfangs nicht gesetzt, weil der Konstruktor ohne Parameter (der sogenannte Default-Konstruktor) aufgerufen wird.

Konstruktoren mit Parametern vererben sich nicht automatisch. Sie müssen separat im Kind deklariert werden. Sie können aber einfach den Konstruktor der Elternklasse aufrufen. Dies geschieht mit der reservierten Methode super, die gleich am Anfang des Konstruktors stehen muss.

class ToggleFlag extends Flag
{   
    public ToggleFlag (boolean f)
    {   
        super(f);
        ...
    }
    ...
}

Der Default-Konstruktor vererbt sich allerdings und braucht nicht neu deklariert zu werden.

Man kann Variablen auch initialisieren, indem man ihnen bei der Deklaration einen Wert zuweist. Solche Initialisierungen werden noch vor dem Konstruktor durchgeführt. Die Klasse Flag sieht damit so aus.

class Flag
{   
    private boolean F=false; // Intialisierung vor dem Konstruktor
    
    public void set ()
    {   
        F=true;
    }

    public void clear ()
    {   
        F=false;
    }

    public boolean isSet ()
    {   
        return F;
    }
}

Statische Variablen und Methoden

Wir hatten auch bisher schon eine Klassendeklaration verwendet, wie etwa in HelloWorld.

public class HelloWorld
{   
    public static void main (String args[])
    {   
        System.out.println("Hello World");
    }
}

Dies ist eine Klassendefinition. Sie ist public und muss deswegen in einer Datei HelloWorld.java deklariert werden, was aber nur im Zusammenhang mit Paketen wichtig ist.

Die Methode main von HelloWorld ist als static deklariert. Das bedeutet, dass sie nur einmal pro Klasse existiert und auch dann, wenn keine Instanz der Klasse erzeugt wurde. Sie kann daher auch ohne Instanz aufgerufen werden. Es wird ihr keine implizite Referenz auf ein Objekt übergeben.

Statische Methoden (Funktionen) und Eigenschaften (Variablen) einer Klasse existieren nur einmal pro Klasse.
Nicht-statische Methoden und Eingenschaften existieren einmal pro Instanz.

Was passiert denn beim Eingeben der Kommandozeile

java HelloWorld

eigentlich? Der Java-Interpreter sucht in der Klasse, die in der Kommandozeile angegeben wurde (hier also in HelloWorld), nach einer Methode main und ruft diese auf. Diese Methode muß ebenfalls public sein. Wir haben im Kapitel über Unterprogramme von main aus andere Unterprogramme aufgerufen, die alle static sein müssen, weil ja keine Instanz von HelloWorld erzeugt wurde.

Man kann in jeder Klasse static Variablen und Methoden deklarieren. Diese Methoden werden mit dem Klassennamen anstatt mit dem Namen eines Objektes aufgerufen. Wir haben solche Methoden schon kennengelernt. Beispiel:

double x=Math.sqrt(2.0);

sqrt ist nämlich eine statische Methode der Klasse Math. Ein anderes Beispiel ist

System.out.println("Hello World");

Hier sieht man eine Variable out in der Klasse System. Diese Variable out ist vom Typ PrintWriter und hat eine Methode namens println, die letztendlich aufgerufen wird.

Man kann sogar statisch Dinge ausführen lassen, sobald die Klasse geladen wird. Dieser Anweisungsblock ist dann wie ein Konstruktor, der einmal pro Klasse ausgeführt wird. Insbesondere kann man dort statische Variablen initialisieren. Dies sieht etwa so aus:

class Flag
{   
    protected boolean F;
    static
    {   
        System.out.println("Klasse Flag wurde erstmals benutzt!");
    }
    ...
}

Es ist aber auch möglich, statische Variablen direkt zu initialisieren

 static double PI=3.14151...;

this

Wie bereits erklärt, bekommt jede Methode beim Aufruf implizit eine Referenz auf die Instanz der Klasse mitgeliefert, auch die Konstruktoren. (Dies gilt nicht für statische Methoden.) Man kann nun diese Referenz unter dem Namen this weiterverwenden. Das kann beispielsweise notwendig sein, wenn man die aktuelle Instanz (deren Methode aufgerufen wurde) an ein anderes Unterprogramm als Parameter übergeben will.

Als Beispiel diene folgender Auszug:

class AClass
{   
    void funcA (BClass B)
    {   
        ...
    }
}

class BClass
{   
    void funcB (AClass A)
    {   
        ...
        A.funcA(this);
    }
}

Beim Aufruf einer anderen Methode derselben Klasse ist es nicht erforderlich, die Instanz explizit anzugeben. Man kann dies aber tun, indem man this als Instanz verwendet, also etwa

 this.func();

Eine weitere Verwendung von this ist der Aufruf eines anderen Konstruktors derselben Klasse. Die Klasse Flag, hätte auch so aussehen können

class Flag
{   
    protected boolean F;
    
    public Flag ()
    {   
        this(false); // ruft den Konstruktor mit Parameter auf.
    }
    public Flag (boolean f)
    {   
        F=f;
    }
    ...
}

Hier ruft this(false); den Konstruktor mit Parameter auf.

Goldene Regeln

Unter der Annahme, dass die Klasse Kind von der Klasse Vater abstammt, lassen sich folgende Regeln für die Vererbung festhalten.

Zusammenfassung

Wir wollen die Vorteile zusammenfassen, die die Verwendung von Klassen bietet.

Beispiele

Ein Stapelspeicher

Wir programmieren einen Stapelspeicher (last in, first out) für int-Werte. Der Einfachheit halber habe der Stapel ein maximales Fassungsvermögen. Man beachte, dass Java eigentlich schon eine Klasse Stack mitbringt, die man sofort benutzen kann. Diese hat sogar kein Limit. Allerdings kann man dort int-Werte nur über Objekte speichern. Dazu später mehr.

Der Stapelspeicher braucht natürlich Methoden, um Zahlen auf ihm abzulegen oder Zahlen von ihm herunterzunehmen. Außerdem geben wir ihm eine Methode, mit der die oberste Zahl angesehen werden kann, ohne sie zu entfernen.

Nun der Code für die Klasse Stack:

class Stack
{   private int Size; // aktuelle Groesse
    private int V[]; // der eigentliche Speicher
    
    public Stack (int capacity)
    {   V=new int[capacity];
        Size=0;
    }

    // lege ein Element auf den Stack
    public void push (int value)
    {   V[Size++]=value; // speichern und Size erhoehen
    }
    
    // ziehe ein Element from Stack
    public int pull ()
    {   Size--; // Size erniedrigen
        return V[Size]; // Element nach dem letzten zurückgeben
    }
    
    // gib Anzahl der Elemente zurueck
    public int size ()
    {   return Size;
    }
    
    // lies oberstes Element
    public int peek ()
    {   return V[Size-1];
    }
}

Eine Fehlerbehandlung ist nicht vorhanden. Insbesondere funktioniert peek nur bei nicht-leerem Stack. Dennoch ist diese Klasse schon sehr brauchbar.

Nun behandeln wir das Problem der Türme von Hanoi nochmals. Da jeder Turm einen Stapel darstellt, liegt es nahe, die Türme als Kinder der Klasse Stack anzulegen. Allerdings soll noch überprüft werden, ob das Auflegen einer Scheibe überhaupt zulässig ist.

class Tower extends Stack
{   private int Number; // Die Nummer des Turms (1, 2 oder 3)

    public Tower (int capacity, int number)
    {   super(capacity);
        Number=number;
    }
    
    // push mit Test, ob oberstes Element kleiner ist
    public void push (int value)
    {   if (size()==0 || peek()>value) super.push(value);
        else System.out.println("Illegal move");
    }
    
    public int number ()
    {   return Number;
    }
}

Man beachte den Aufruf der Methode Stack.push mittels super.push(). Jeder Turm hat außerdem eine Nummer, die mit number() abgefragt werden kann.

Das eigentliche Hauptprogramm ist ähnlich wie der Code ohne Objekte.

public class Hanoi
{   static Tower A,B,C;

    static public void main (String args[])
    {   int n=4;
        // Tuerme beschaffen:
        A=new Tower(n,1); B=new Tower(n,2); C=new Tower(n,3);
        // Turm A auffuellen
        for (int i=n; i>=1; i--) A.push(i);
        // Problem loesen:
        move(n,A,B,C);
    }

    // Rekursive move-Methode
    static void move (int n, Tower a, Tower b, Tower c)
    {   if (n==1)
        {   b.push(a.pull()); // tatsaechliche Simulation der Bewegung
            System.out.println(
				"Move disk from "+a.number()+" to "+b.number());
                // Ausdrucken der Bewegung
        }
        else
        {   move(n-1,a,c,b);
            move(1,a,b,c);
            move(n-1,c,b,a);
        }
    }
}

Nun müssen die Türme erst initialisiert werden. Außerdem muss der erste Turm mit Scheiben versehen werden. Zudem wurde hier ausgenutzt, dass der Code für eine Scheibe schon in move steht. Hier finden Sie den gesamten Code dieses Beispiels. Hier ist der Code, der die vordefinierte Stack-Klasse verwendet.

Verwendung vordefinierter Klassen

Java bietet eine große Anzahl vordefinierter Klassen. Diese sind in Pakete organisiert und müssen importiert werden (außer das Paket java.lang, das zum Beispiel Math oder System enthält). Mehr über Pakete erfahren wir später.

Wir verwenden nun als Beispiel die Klasse Random, die einen Zufallsgenerator bereitstellt. Man beachte an dieser Stelle, wie einfach sich diese Klasse einsetzen lässt, sobald man ihren Konstruktor und ihre Methoden kennt. Diese Informationen sind nun ausführlich in der Dokumentation von Java enthalten.

import java.util.Random; // noetig, um Random verwenden zu koennen

public class Test
{   
    static public void main (String args[])
    {   
        Random r=new Random(1017); 
            // initalisiere den Generator mit 1017
        for (int i=0; i<10; i++)
            System.out.println(r.nextGaussian());
                // erzeuge 10 normalverteilte Zufallszahlen
    }
}

Statische Klassen

Die folgende Klasse stellt einen Temperatur-Umrechner zur Verfügung. Sie ist gar nicht dazu gedacht, Instanzen zu erzeugen, da sie nur statische Dinge enthält.

class Temperatur
{   
    private static double Factor;
    
    static
    {   Factor=9.0/5.0;
    }

    public static double fahrenheit (double c)
    {   return c*Factor+32.0;
    }

    public static double celsius (double f)
    {   return (f-32.0)/Factor;
    }
}

Ein Aufruf sieht etwa folgendermaßen aus:

public class Test
{   
    static public void main (String args[])
    {   
        System.out.println(Temperatur.fahrenheit(20));
    }
}

Statische Importe

Dies ist eine Abkürzung. Benötigt man in einer Datei mehrmals die Variable PI, so muss man jedes mal Math.PI schreiben, Stattdessen ist es möglich, die Variable PI statisch zu importieren.

import static java.lang.Math.PI; 

Man kann nun einfach PI schreiben.

Arrays von Objekten

Legt man ein Array von Instanzen einer Klasse an, so muss man die einzelnen Objekte in einer Schleife intialisieren. Das Array wird zwar mit new angelegt, enthält aber zunächst nur leere Referenzen (mit Wert null).

Um etwa 10 Flags anzulegen, benötigt man folgenden Code:

Flag f[] = new Flag[10];
for (int i=0; i<10; i++) Flag[i] = new Flag();

Exceptions

In Java werden Fehlerzustände durch Exceptions kontrolliert. Man kann diese Exception durch eine try-Anweisung abfangen. Als Beispiel fangen wir eine Division durch 0 ab:

public class Test
{   
    public static void main (String args[])
    {   try
        {   
            int n=0;
            n=1/n;
        }
        catch (Exception e)
        {   
            System.out.println("Abgefangen: "+e);
        }
    }
}

Die Ausgabe dieses Programms ist

Abgefangen: java.lang.ArithmeticException: / by zero

Das Programm kann nach dem Abfangen der Exception normal fortgesetzt werden. Insbesondere kann man den aufgetretenen Fehler behandeln und eventuell korrigieren.

Allgemein hat die try-Anweisung folgende Gestalt:

try
Anweisungsblock
catch (ExceptionTyp Name) Anweisungsblock
...
catch (ExceptionTyp Name) Anweisungsblock
finally Anweisungsblock

Dabei ist ExceptionTyp irgendeine Kindklasse von Exception. Man kann auch, wie oben, generell alle Exceptions abfangen. Der Name dient nur zur Bezeichnung der Exception im Abfangblock. Der Block bei finally wird immer ausgeführt - egal, welche Exception aufgetreten ist. finally kann fehlen.

Eine Exception braucht nicht abgefangen zu werden. Sie kann auch von einer Methode nach oben weitergereicht werden. Dazu dient die throws-Anweisung. Die Anweisung zeigt an, dass die Exception in einer Methode möglich ist. Das aufrufende Programm muss dann die Exception abfangen. Als Beispiel definieren wir eine neue Kindklasse von Exception, die anzeigt, dass ein Array-Index ungerade ist.

class IndexOddException extends Exception
{   
}

public class Test
{   
    public static void main (String args[])
    {   
        try
        {   
            test(new double[20],11);
        }
        catch (IndexOddException e)
        {   
            System.out.println("Odd");
        }
        catch (ArrayIndexOutOfBoundsException e)
        {   
            System.out.println("Out of bounds");
        }
    }

    static double test (double a[], int i) throws IndexOddException
    {   
        if (i%2==1) throw new IndexOddException();
        return a[i];
    }
}

Die neue Exception dient nur zur Unterscheidung für die beiden catch-Anweisungen.

Ein idealer Kandidat für eine Exception wäre die Fehlerbehandlung in einem Stack. Z.B. könnte unser Beispiel mit den Türmen von Hanoi eine Exception auslösen, wenn versucht wird, eine Scheibe auf eine größere zu legen.

Wichtige Details

Übungsaufgaben

  1. Schreiben Sie eine Klasse Koord, die eine x- und eine y-Koordinate aufnehmen kann. Die Klasse soll einen Konstruktor Koord(x,y) bekommen.
  2. Versehen Sie die Klasse mit Methoden zum Auslesen von x und y, und zum Setzen von x und y (simultan), also x(), y() und set(x,y). Testen Sie mit einem Hauptprogramm.
  3. Schreiben Sie Punkt und Kreis als Kinder von Koord. Kreis soll eine zusätzliche Variable r haben, einschließlich Methoden zum Setzen des Radius.
  4. Schreiben Sie ein Unterprogamm der Test-Klasse, das testet, ob ein Punkt in einem Kreis liegt, nämlich boolean contains(Kreis k, Punkt p).
  5. Erzeugen Sie 1000000 Zufallspunkte und testen Sie, wie viele in einem Kreis mit Radius 1/2 um (1/2,1/2) liegen. Speichern Sie die Punkte nicht ab!

Lösung.

Aufgaben ohne Lösung

  1. Schreiben Sie eine Klasse für komplexe Zahlen. Der Konstruktor sieht also so aus
    Complex z=new Complex(x,y);
  2. Schreiben Sie die Operatoren +,-,* und / und die Funktion abs für komplexe Zahlen als statische Funktionen der Klasse Complex, also z.B.
    static public Complex plus (Complex a, Complex b);
  3. Schreiben Sie eine Umwandlung toString() als Methode von Complex. Versuchen Sie dann, komplexe Zahlen direkt mit System.out.println() auszugeben.
  4. Generieren Sie einen Array, das komplexe Zahlen enthält.
  5. Schreiben Sie ein Unterprogramm, das das Skalarprodukt zweier komplexer Vektoren berechnet.
  6. Schreiben Sie die Klasse Stack so um, dass sie im Falle eines Fehlers eine Exception auslöst.

Zurück zum Java-Kurs