Home > Informatik > Begriffe und Konzepte > Generische Klasse

Generics

Diese Webseite wurde im ständigen Dialog mit ChatGPT erstellt und von mir dann ständig überarbeitet.

Beispiel ohne Generics

Wir wollen eine ganz einfache Klasse schreiben, in der wir Objekte verschiedener Klassen speichern können. Dazu soll diese Klasse einen Array mit Elementen der Klasse Objekt enthalten, der nach dem Stack-Prinzip LIFO (Last In - First Out) organisiert ist:

public class Karton
{
    private Object[] inhalt;
    private int anzahl;
    
    public Karton()
    {
        inhalt = new Object[32];
        anzahl = 0;
    }
    
    public boolean istLeer()
    {
        return anzahl == 0;
    }
    
    public boolean istVoll()
    {
        return anzahl == inhalt.length;
    }
    
    public void hineinstecken(Object neu)
    {
        if (!istVoll())
            inhalt[anzahl++] = neu;
    }
    
    public Object herausholen()
    {
        if (!istLeer())
            return inhalt[--anzahl];
        return null;
    }
}

Neue Elemente werden jeweils oben auf den Stack gelegt, und es kann stets nur das oberste Element wieder entnommen werden.

Stellen wir uns jetzt drei einfache Java Klassen Buch, Schraube und Apfel vor, auf deren Implementierung wir hier nicht weiter eingehen wollen, um den Rahmen nicht zu sprengen. Betrachten wir stattdessen einmal die Klasse KartonTest:

public class KartonTestFalsch
{
    public static void main(String[] args)
    {
        Karton karton = new Karton();

        // Drei unterschiedliche Objekte hineinstecken
        karton.hineinstecken(new Buch("Java – Eine Einführung"));
        karton.hineinstecken(new Apfel(180));
        karton.hineinstecken(new Schraube(4.5));

        // Herausholen der Objekte (LIFO-Reihenfolge)
        Object o1 = karton.herausholen();
        Object o2 = karton.herausholen();
        Object o3 = karton.herausholen();

        // Fehlendes Typecasting:
        Schraube s = o1;
        Apfel a = o2;
        Buch b = o3;
    }
}

Dieser Quelltext lässt sich nicht kompilieren. Beim Übersetzen meldet der Compiler einen Typfehler:

Fehlermeldung des Compilers

Der Grund ist folgender: Die aus dem Karton zurückgelieferten Objekte werden zunächst in Variablen vom Typ Object gespeichert. Der Compiler hat keine Kenntnis darüber, dass es sich bei o1 tatsächlich um ein Schraube-Objekt, bei o2 um ein Apfel-Objekt und bei o3 um ein Buch-Objekt handelt. Eine direkte Zuweisung an entsprechend typisierte Variablen ist daher nicht erlaubt.

Typecasting

Wir müssen dem Compiler diese wichtigen Informationen explizit mitteilen. In Java geschieht dies über das sogenannte Typecasting:

        // Herausholen der Objekte (LIFO-Reihenfolge)
        Object o1 = karton.herausholen();
        Object o2 = karton.herausholen();
        Object o3 = karton.herausholen();

        // Korrektes Typecasting:
        Schraube s = (Schraube) o1;
        Apfel a = (Apfel) o2;
        Buch b = (Buch) o3;

Mit der Anweisung

Schraube s = (Schraube) o1;

teilen wir dem Compiler mit, dass das Objekt o1 als Objekt der Klasse Schraube behandelt werden soll. Erst durch dieses explizite Typecasting ist die Zuweisung typkorrekt und der Quelltext lässt sich erfolgreich kompilieren.

Probleme mit dem Typecasting

Bei unserem kleinen Programm hatte der Entwickler Kenntnis darüber, dass das erste Objekt ein Schraube-Objekt war, das zweite ein Apfel-Objekt und das dritte ein Buch-Objekt. Daher konnte ein Typecasting erfolgreich angewendet werden.

In realen Programmen ist diese Annahme jedoch oft nicht mehr zulässig. Die Reihenfolge der Objekte kann sich ändern, etwa durch unterschiedliche Programmabläufe, Schleifen, Sortiermethoden oder Benutzerinteraktionen. Das Programm weiß dann beim Herausholen eines Objekts aus dem Stack nicht mehr, welcher konkreten Klasse das oberste Objekt angehört.

Wie lässt sich zur Laufzeit feststellen, um welchen konkreten Objekttyp es sich handelt?

Typprüfung mit instanceof

Java stellt hierfür den Operator instanceof zur Verfügung. Mit ihm kann geprüft werden, ob ein Objekt eine Instanz einer bestimmten Klasse (oder einer ihrer Unterklassen) ist. Ein typisches Vorgehen sieht dann wie folgt aus:

Object o = karton.herausholen();

if (o instanceof Schraube)
    Schraube s = (Schraube) o;
else if (o instanceof Apfel)
    Apfel a = (Apfel) o;
else if (o instanceof Buch)
    Buch b = (Buch) o;

Zunächst wird also der konkrete Laufzeittyp des Objekts überprüft. Erst wenn feststeht, dass das Objekt tatsächlich eine Instanz der gewünschten Klasse ist, erfolgt das entsprechende Typecasting. Auf diese Weise lassen sich Laufzeitfehler vermeiden.

Überleitung zu Generics

Dieses Beispiel zeigt deutlich eine typische Schwäche von Sammlungen (Arrays, ArrayLists etc.), die mit dem allgemeinen Typ Object arbeiten: Der Compiler verliert die Kenntnis über den konkreten Typ der gespeicherten Objekte, und der Entwickler muss diese Information zur Laufzeit mühsam rekonstruieren.

Genau aus diesem Grund spielen Generics in Java eine zentrale Rolle. Sie ermöglichen es, Sammlungen so zu typisieren, dass der konkrete Objekttyp bereits zur Übersetzungzeit bekannt ist – Typecasts und Laufzeitprüfungen werden dann weitgehend überflüssig.

Beispiel mit Generics

Die Klasse Karton

Betrachten wir nun die folgende Version der Klasse Karton. Hier werden jetzt Generics eingesetzt:

public class Karton < T >
{
    private T[] inhalt;
    private int anzahl;

    public Karton()
    {
        inhalt = (T[]) new Object[32];
        anzahl = 0;
    }

    public boolean istLeer()
    {
        return anzahl == 0;
    }

    public boolean istVoll()
    {
        return anzahl == inhalt.length;
    }

    public void hineinstecken(T neu)
    {
        if (!istVoll())
            inhalt[anzahl++] = neu;
    }

    public T herausholen()
    {
        if (!istLeer())
            return inhalt[--anzahl];
        return null;
    }
}

Vergleichen wir einmal die Deklaration der Instanzvariablen:

Ohne Generics:

public class Karton
{
   private Object[] inhalt; 
   private int anzahl;

Mit Generics:

public class Karton < T >
{
   private T[] inhalt; 
   private int anzahl;

Das in spitzen Klammern stehende <T> ist ein sogenannter Typparameter. Dieser Typparameter steht für einen beliebigen Typ, der beim Erzeugen eines Karton-Objektes festgelegt wird. Allerdings dürfen hierbei nur Referenztypen angegeben werden und keine primitiven Datentypen.

Der Array, der zum Speichern der Objekte dient, ist nun auch kein Object-Array mehr, sondern ein T[]-Array.

Die Methode hineinstecken() erhält jetzt keinen Parameter der Klasse Object, sondern einen Parameter des noch festzulegenden Typs T.

Entsprechend liefert die Methode herausholen() kein Objekt der Klasse Object mehr zurück, sondern ein Objekt der Klasse T.

Betrachten wir zum Schluss noch den Konstruktor näher:

    public Karton()
    {
        inhalt = (T[]) new Object[32];
        anzahl = 0;
    }

Das Typecasting (T[]) im Konstruktor ist technisch notwendig, da Java leider keine generischen Arrays erlaubt - wohl aber generische ArrayLists oder andere Sammlungen.

Die Test-Klasse

Betrachten wir nun eine Klasse, mit der wir die generische Karton-Klasse testen können:

public class KartonTest
{
    public static void main(String[] args)
    {
        Karton< Schraube> schraubenKarton = new Karton< Schraube>();
        Karton< Buch>     buchKarton = new Karton< Buch>();
        Karton< Apfel>    apfelKarton = new Karton< Apfel>();

        schraubenKarton.hineinstecken(new Schraube(4.5));
        schraubenKarton.hineinstecken(new Schraube(6.0));

        buchKarton.hineinstecken(new Buch("Herr der Ringe"));
        buchKarton.hineinstecken(new Buch("Der kleine Hobbit"));        
        
        apfelKarton.hineinstecken(new Apfel(430));
        apfelKarton.hineinstecken(new Apfel(530));        
        
        Schraube s1 = schraubenKarton.herausholen();
        System.out.println("Länge der Schraube = " + s1.getLaenge());
        Schraube s2 = schraubenKarton.herausholen();
        System.out.println("Länge der Schraube = " + s2.getLaenge());
        
        Buch b1 = buchKarton.herausholen();
        System.out.println("Titel des Buchs = " + b1.getTitel());
        Buch b2 = buchKarton.herausholen();
        System.out.println("Titel des Buchs = " + b2.getTitel());
        
        Apfel a1 = apfelKarton.herausholen();
        System.out.println("Gewicht des Apfels = " + a1.getGewicht());
        Apfel a2 = apfelKarton.herausholen();
        System.out.println("Gewicht des Apfels = " + a2.getGewicht());
    }
}

Hier wird ein Karton-Objekt karton angelegt, in dem nur Objekte der Klasse Schraube gespeichert werden können. Da der Compiler jetzt weiß, welche Objekte in dem Karton vorhanden sind, ist kein Typecasting mehr notwendig.

Der Nachteil ist allerdings, dass jetzt nur noch Objekte der Klasse Schraube in dem Karton gespeichert werden können. Das ursprüngliche Beispiel erlaubte aber das Mischen von Buch-, Apfel- und Schraube-Objekten - mit den damit verbundenen Problemen.

Wollen wir nicht nur Schrauben speichern, sondern auch Bücher und Äpfel, müssen wir zwei weitere Objekte der Klasse Karton erzeugen, ein Objekt speziell für Bücher, und ein weiteres Objekt speziell für Äpfel.

Das hört sich auf den ersten Blick umständlich an, erhöht aber die Übersichtlichkeit erheblich; wir wissen jetzt genau, dass sich in dem ersten Karton nur Schrauben befinden, in dem zweiten Karton nur Bücher und in dem dritten Karton nur Äpfel. Und - jetzt kommt der größte Vorteil - wir mussten nur eine einzige Klasse Karton implementieren.

Gemeinsame Oberklasse Gegenstand

Wenn alle drei Klassen Buch, Apfel und Schraube aber einer gemeinsamen Oberklasse Gegenstand angehören, dann ist das Mischen verschiedener Objekte in dem Karton kein Problem mehr. Dann können wir nämlich schreiben

Karton <Gegenstand> karton = new Karton<Gegenstand>();

Der Karton kann jetzt alle Objekte der Klasse Gegenstand aufnehmen, auch Objekte der Unterklassen Buch, Apfel oder Schraube. Wenn die Anwendung dann allerdings auf klassenspezifische Eigenschaften von Buch-Objekten zurückgreifen will, ist doch wieder eine Typprüfung notwendig:

Gegenstand g = karton.herausholen();

if (g instanceof Buch)
{
    Buch b = (Buch) g;
}

Generics verhindern also nicht jede Form von Typecasting, aber sie begrenzen das Problem auf fachlich sinnvolle Fälle.

Quellen:

  1. Lahres et al.: Objektorientierte Programmierung, Rheinwerk Computing 2021.
  2. Ullenboom: Java ist auch eine Insel, Rheinwerk Computing 2023.
  3. Abts: Grundkurs Java, 12. Auflage, Springer-Vieweg 2024.
  4. ChatGPT