Einführung
Seit JSE 5.0 gehören Generics zum Arsenal von Java.
Was sind Generics in Java?
Generics sind ein spezieller Mechanismus von Java, um generische Programmierung zu implementieren. Generics sind eine Art, Daten und Algorithmen zu beschreiben, die es dir erlaubt, mit verschiedenartigen Datentypen zu arbeiten, ohne die Beschreibung der Algorithmen zu ändern. Auf der Oracle-Website gibt es ein eigenes Tutorial zu Generics: „
Lektion“. Wenn du Generics verstehen willst, musst du zuerst herausfinden, warum sie gebraucht werden und was sie leisten.
Im Abschnitt
„Warum Generics verwenden?“ des Tutorials heißt es, dass eine stärkere Typprüfung zur Kompilierzeit und die Vermeidung expliziter Casts (also Typumwandlungen) einige der Gründe dafür sind.
Bereiten wir uns auf einige Tests in unserem geliebten
Tutorialspoint Online-Java-Compiler vor.
Angenommen, du hast den folgenden Code:
import java.util.*;
public class HelloWorld {
public static void main(String []args) {
List list = new ArrayList();
list.add("Hello");
String text = list.get(0) + ", world!";
System.out.print(text);
}
}
Dieser Code wird einwandfrei funktionieren. Aber was ist, wenn der Chef zu uns kommt und sagt, dass „Hallo, Welt!“ eine abgedroschene Phrase ist und dass du nur „Hallo“ zurückgeben darfst? Wir entfernen den Code, der
", Welt!" anhängt. Das scheint harmlos zu sein, oder? Aber wir bekommen tatsächlich eine Fehlermeldung ZUR KOMPILIERZEIT:
error: incompatible types: Object cannot be converted to String
Das Problem ist, dass in unserer Liste Objekte gespeichert werden.
String erbt von
Object (da alle Java-Klassen implizit von
Object erben), was bedeutet, dass wir einen expliziten Cast brauchen, aber wir haben keinen hinzugefügt. Während des Verkettungsvorgangs wird die statische Methode
String.valueOf(obj) mit dem Objekt aufgerufen. Schließlich wird die Methode
toString der Klasse
Object aufgerufen.
Mit anderen Worten: Unsere
List enthält ein
Object. Das bedeutet, dass wir überall dort, wo wir einen bestimmten Typ (nicht
Object) benötigen, die Typumwandlung selbst vornehmen müssen:
import java.util.*;
public class HelloWorld {
public static void main(String []args) {
List list = new ArrayList();
list.add("Hello!");
list.add(123);
for (Object str : list) {
System.out.println("-" + (String)str);
}
}
}
In diesem Fall kann
List jedoch nicht nur
Strings, sondern auch
Integerspeichern, da sie Objekte aufnehmen kann. Aber das Schlimmste ist, dass der Compiler hier keinen Fehler erkennt. Und jetzt bekommen wir einen Fehler ZUR LAUFZEIT (bekannt als „Laufzeitfehler“).
Der Fehler:
java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
Du stimmst sicher zu, dass das nicht sehr gut ist.
Und das alles nur, weil der Compiler keine künstliche Intelligenz ist, die in der Lage ist, die Absichten des Programmierers immer richtig zu erraten. Mit Java SE 5 wurden Generics eingeführt, damit wir dem Compiler unsere Absichten mitteilen können – welche Typen wir verwenden werden.
Wir korrigieren unseren Code, indem wir dem Compiler sagen, was wir wollen:
import java.util.*;
public class HelloWorld {
public static void main(String []args) {
List<String> list = new ArrayList<>();
list.add("Hello!");
list.add(123);
for (Object str : list) {
System.out.println("-" + str);
}
}
}
Wie du sehen kannst, brauchen wir keinen Cast in einen
String mehr. Außerdem haben wir spitze Klammern, die das Typ-Argument umgeben. Der Compiler lässt uns die Klasse erst dann kompilieren, wenn wir die Zeile entfernen, die 123 zur Liste hinzufügt, da dies ein
Integer ist.
Und er wird es uns sagen. Viele Menschen nennen Generics „syntaktischen Zucker“. Und sie haben Recht, denn nachdem Generics kompiliert wurden, werden sie wirklich zu denselben Typumwandlungen. Schauen wir uns den Bytecode der kompilierten Klassen an: eine, die einen expliziten Cast verwendet, und eine, die Generics verwendet:
Nach der Kompilierung werden alle Generics gelöscht. Dies wird als „
Typlöschung“ bezeichnet.
Typlöschung (auch Type Erasure) und Generics sind so konzipiert, dass sie mit älteren Versionen des JDK abwärtskompatibel sind und gleichzeitig dem Compiler erlauben, bei Typdefinitionen in neuen Versionen von Java zu helfen.
Raw-Types
Wenn wir von Generics sprechen, gibt es immer zwei Kategorien: parametrisierte Typen und Raw-Types.
Raw-Types sind Typen, bei denen die Typangabe in spitzen Klammern weggelassen wird:
Parametrisierte Typen hingegen beinhalten diese Angabe.
Wie du siehst, haben wir ein ungewöhnliches Konstrukt verwendet, das im Screenshot durch einen Pfeil markiert ist. Dies ist eine spezielle Syntax, die in Java SE 7 hinzugefügt wurde. Sie wird „
Diamant“ genannt. Warum? Die spitzen Klammern bilden eine Raute:
<>.
Du solltest auch wissen, dass die Diamantsyntax mit dem Konzept der „
Typinferenz“ verknüpft ist. Wenn der Compiler auf der rechten Seite
<> sieht, schaut er auf die linke Seite des Zuweisungsoperators, wo er den Typ der Variablen findet, deren Wert zugewiesen wird.
Anhand dessen, was er in diesem Teil findet, versteht es den Typ des Wertes auf der rechten Seite. Wenn ein generischer Typ auf der linken Seite angegeben ist, aber nicht auf der rechten Seite, kann der Compiler auf den Typ schließen:
import java.util.*;
public class HelloWorld {
public static void main(String []args) {
List<String> list = new ArrayList();
list.add("Hello, World");
String data = list.get(0);
System.out.println(data);
}
}
Dabei wird aber der neue Stil mit Generics und der alte Stil ohne Generics vermischt. Und das ist höchst unerwünscht. Wenn wir den obigen Code kompilieren, erhalten wir die folgende Meldung:
Note: HelloWorld.java uses unchecked or unsafe operations
Es ist eigentlich unverständlich, warum hier eine Raute eingefügt werden muss. Aber hier ist ein Beispiel:
import java.util.*;
public class HelloWorld {
public static void main(String []args) {
List<String> list = Arrays.asList("Hello", "World");
List<Integer> data = new ArrayList(list);
Integer intNumber = data.get(0);
System.out.println(data);
}
}
Du wirst dich daran erinnern, dass
ArrayList einen zweiten Konstruktor hat, der eine Collection als Argument akzeptiert. Und genau hier liegt etwas Unheimliches verborgen. Ohne die Diamantsyntax versteht der Compiler nicht, dass er getäuscht wird. Mit der Diamantsyntax schon.
Regel Nr. 1 lautet also: Verwende bei parametrisierten Typen immer die Diamantsyntax. Andernfalls besteht die Gefahr, dass wir nicht wissen, wo wir Raw-Types verwenden.
Um die Warnungen „verwendet ungeprüfte oder unsichere Operationen“ zu beseitigen, können wir die Annotation
@SuppressWarnings("unchecked") für eine Methode oder Klasse verwenden.
Aber denke darüber nach, warum du dich dafür entschieden hast, es zu benutzen. Denke an Regel Nr. 1. Vielleicht musst du ein Typargument hinzufügen.
Generische Methoden in Java
Mit Generics kannst du Methoden erstellen, deren Parametertypen und Rückgabetyp parametrisiert sind. Dieser Fähigkeit ist ein eigener Abschnitt im Oracle-Tutorial gewidmet: „
Generische Methoden“.
Es ist wichtig, dass du dir die Syntax merkst, die in diesem Tutorial gelehrt wird:
- sie umfasst eine Liste von Typparametern in spitzen Klammern;
- die Liste der Typparameter steht vor dem Rückgabetyp der Methode.
Sehen wir uns ein Beispiel an:
import java.util.*;
public class HelloWorld {
public static class Util {
public static <T> T getValue(Object obj, Class<T> clazz) {
return (T) obj;
}
public static <T> T getValue(Object obj) {
return (T) obj;
}
}
public static void main(String []args) {
List list = Arrays.asList("Author", "Book");
for (Object element : list) {
String data = Util.getValue(element, String.class);
System.out.println(data);
System.out.println(Util.<String>getValue(element));
}
}
}
Wenn du dir die Klasse
Util ansiehst, wirst du sehen, dass sie zwei generische Methoden hat. Dank der Möglichkeit der Typinferenz können wir den Typ entweder direkt dem Compiler mitteilen oder ihn selbst angeben. Beide Optionen werden in dem Beispiel vorgestellt.
Die Syntax ergibt übrigens sehr viel Sinn, wenn man darüber nachdenkt. Wenn wir eine generische Methode deklarieren, geben wir den Typparameter VOR der Methode an, denn wenn wir den Typparameter nach der Methode deklarieren würden, könnte die JVM nicht herausfinden, welcher Typ verwendet werden soll. Dementsprechend deklarieren wir zuerst, dass wir den Parameter vom Typ
T verwenden werden, und dann sagen wir, dass wir diesen Typ zurückgeben werden.
Natürlich schlägt
Util.<Integer>getValue(element, String.class) mit einer Fehlermeldung fehl:
incompatible types: Class<String> cannot be converted to Class<Integer>.
Wenn du generische Methoden verwendest, solltest du immer an die Typlöschung denken. Sehen wir uns ein Beispiel an:
import java.util.*;
public class HelloWorld {
public static class Util {
public static <T> T getValue(Object obj) {
return (T) obj;
}
}
public static void main(String []args) {
List list = Arrays.asList(2, 3);
for (Object element : list) {
System.out.println(Util.<Integer>getValue(element) + 1);
}
}
}
Das wird schon klappen. Aber nur, solange der Compiler versteht, dass der Rückgabetyp der aufgerufenen Methode
Integer ist.
Ersetze die Anweisung für die Konsolenausgabe durch die folgende Zeile:
System.out.println(Util.getValue(element) + 1);
Wir erhalten eine Fehlermeldung:
bad operand types for binary operator '+', first type: Object, second type: int.
Mit anderen Worten: Die Typlöschung ist erfolgt. Der Compiler sieht, dass niemand den Typ angegeben hat, also wird der Typ als
Object angegeben und die Methode schlägt mit einem Fehler fehl.
Generische Klassen
Nicht nur Methoden können parametrisiert werden. Klassen können es auch. Der Abschnitt
„Generische Typen“ des Oracle-Tutorials ist diesem Thema gewidmet. Sehen wir uns ein Beispiel an:
public static class SomeType<T> {
public <E> void test(Collection<E> collection) {
for (E element : collection) {
System.out.println(element);
}
}
public void test(List<Integer> collection) {
for (Integer element : collection) {
System.out.println(element);
}
}
}
Hier ist alles einfach. Wenn wir die generische Klasse verwenden, wird der Typparameter nach dem Klassennamen angegeben.
Erzeugen wir nun eine Instanz dieser Klasse in der
main-Methode:
public static void main(String []args) {
SomeType<String> st = new SomeType<>();
List<String> list = Arrays.asList("test");
st.test(list);
}
Dieser Code wird problemlos laufen. Der Compiler sieht, dass es eine
List von Zahlen und eine
Collection von
Strings gibt. Aber was ist, wenn wir den Typparameter weglassen und Folgendes tun?
SomeType st = new SomeType();
List<String> list = Arrays.asList("test");
st.test(list);
Wir erhalten eine Fehlermeldung:
java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
Auch hier handelt es sich um die Typlöschung. Da die Klasse keinen Typparameter mehr verwendet, entscheidet der Compiler, dass die Methode mit
List<Integer> am besten geeignet ist, da wir eine
List übergeben haben. Und wir erhalten Fehlermeldung.
Deshalb haben wir Regel Nr. 2: Wenn du eine generische Klasse hast, gib immer die Typparameter an.
Einschränkungen
Wir können die in generischen Methoden und Klassen angegebenen Typen einschränken. Nehmen wir zum Beispiel an, dass ein Container nur eine
Number als Typargument akzeptieren soll.
Diese Funktion wird im Abschnitt
Gebundene Typparameter des Oracle-Tutorials beschrieben.
Sehen wir uns ein Beispiel an:
import java.util.*;
public class HelloWorld {
public static class NumberContainer<T extends Number> {
private T number;
public NumberContainer(T number) { this.number = number; }
public void print() {
System.out.println(number);
}
}
public static void main(String []args) {
NumberContainer number1 = new NumberContainer(2L);
NumberContainer number2 = new NumberContainer(1);
NumberContainer number3 = new NumberContainer("f");
}
}
Wie du siehst, haben wir den Typparameter auf die Klasse/das Interface
Number oder Nachkommen beschränkt. Beachte, dass du nicht nur eine Klasse, sondern auch Interfaces angeben kannst. So wie hier:
public static class NumberContainer<T extends Number & Comparable> {
Generics unterstützen auch
Wildcards.
Sie werden in drei Arten unterteilt:
Bei der Verwendung von Wildcards solltest du dich an das
Get-Put-Prinzip halten. Es kann wie folgt ausgedrückt werden:
- Verwende eine extend-Wildcard, wenn du nur Werte aus einer Struktur abrufst.
- Verwende eine super-Wildcard, wenn du nur Werte in eine Struktur einfügst.
- Und verwende keine Wildcard, wenn du sowohl aus einer Struktur abrufen als auch in eine Struktur einfügen möchtest.
Dieses Prinzip wird auch als Producer Extends Consumer Super (PECS) bezeichnet. Hier ist ein kleines Beispiel aus dem Quellcode der
Collections.copy-Methode von Java:
Und hier ist ein kleines Beispiel dafür, was NICHT funktioniert:
public static class TestClass {
public static void print(List<? extends String> list) {
list.add("Hello, World!");
System.out.println(list.get(0));
}
}
public static void main(String []args) {
List<String> list = new ArrayList<>();
TestClass.print(list);
}
Aber wenn du
extends durch
super ersetzt, dann ist alles in Ordnung. Da wir die
Liste mit einem Wert auffüllen, bevor wir ihren Inhalt anzeigen, ist sie ein
Consumer. Dementsprechend verwenden wir super.
Vererbung
Generics haben eine weitere interessante Eigenschaft: Vererbung. Wie die Vererbung bei Generics funktioniert, wird unter „
Generics, Vererbung und Subtypen“ im Tutorial von Oracle beschrieben. Es ist wichtig, dass du dir Folgendes merkst erinnerst und dir bewusst machst. Das können wir nicht tun:
List<CharSequence> list1 = new ArrayList<String>();
Denn Vererbung funktioniert bei Generics anders:
Und hier ist ein weiteres gutes Beispiel, das mit einem Fehler fehlschlagen wird:
List<String> list1 = new ArrayList<>();
List<Object> list2 = list1;
Fazit
So, jetzt haben wir also unser Gedächtnis für Generics aufgefrischt. Wenn du ihre Möglichkeiten nur selten voll ausnutzt, gehen manche Details leicht verloren. Ich hoffe, dieser kurze Rückblick hat deinem Gedächtnis auf die Sprünge geholfen.
GO TO FULL VERSION