Subsections of Fortgeschrittene Java-Themen und Umgang mit JVM
Serialisierung von Objekten und Zuständen
Objekte lassen sich mit der Methode void writeObject(Object)
in ObjectOutputStream
einfach in einen Datenstrom schreiben. Dies kann beispielsweise eine Datei o.ä. sein.
Mit Hilfe von Object readObject()
in ObjectInputStream
lassen sich Objekte aus dem
Datenstrom auch wieder herstellen. Dies nennt man Serialisierung und De-Serialisierung.
Um Objekte einer Klasse serialisieren zu können, muss diese das leere Interface
Serializable
implementieren ("Marker-Interface"). Damit wird quasi die Unterstützung
in Object*Stream
freigeschaltet.
Wenn ein Objekt serialisiert wird, werden alle Attribute in den Datenstrom geschrieben, d.h. die Typen der Attribute müssen ihrerseits serialisierbar sein. Dies gilt für alle primitiven Typen und die meisten eingebauten Typen. Die Serialisierung erfolgt ggf. rekursiv, Zirkelreferenzen werden erkannt und aufgebrochen.
static
und transient
Attribute werden nicht serialisiert.
Beim De-Serialisieren wird das neue Objekt von der Laufzeitumgebung aus dem Datenstrom rekonstruiert. Dies geschieht direkt, es wird kein Konstruktor involviert.
Beim Serialisieren wird für die Klasse des zu schreibenden Objekts eine serialVersionUID
berechnet und mit gespeichert. Beim Einlesen wird dann geprüft, ob die serialisierten
Daten zur aktuellen Version der Klasse passen. Da dies relativ empfindlich gegenüber
Änderungen an einer Klasse ist, wird empfohlen, selbst eine serialVersionUID
pro
Klasse zu definieren.
- (K2) Was ist ein Marker-Interface und warum ist dies eine der großen Design-Sünden in Java?
- (K2) Erklären Sie den Prozess der Serialisierung und De-Serialisierung. Worauf müssen Sie achten?
- (K3) Serialisierung von Objekten und Programmzuständen
- (K3) Serialisierung eigener Klassen und Typen
Motivation: Persistierung von Objekten und Spielzuständen
public class Studi {
private final int credits = 42;
private String name = "Hilde";
...
}
Wie kann ich Objekte speichern und wieder laden?
Ich möchte ein Spiel (einen Lauf) im Dungeon abspeichern, um es später fortsetzen zu können. Wie kann ich den aktuellen Zustand (also Level, Monster, Held, Inventar, XP/Health/...) so speichern, dass ich später das Spiel nach einem Neustart einfach fortsetzen kann?
Serialisierung von Objekten
-
Klassen müssen Marker-Interface
Serializable
implementieren"Marker-Interface": Interface ohne Methoden. Ändert das Verhalten des Compilers, wenn eine Klasse dieses Interface implementiert: Weitere Funktionen werden "freigeschaltet", beispielsweise die Fähigkeit, Klone zu erstellen (
Cloneable
) oder beiSerializable
Objekte serialisierbar zu machen.Das ist in meinen Augen eine "Design-Sünde" in Java (neben der Einführung von
null
): Normalerweise definieren Interfaces eine Schnittstelle, die eine das Interface implementierende Klasse dann erfüllen muss. Damit agiert das Interface wie ein Typ. Hier ist das Interface aber leer, es wird also keine Schnittstelle definiert. Aber es werden damit stattdessen Tooling-Optionen aktiviert, was Interfaces vom Konzept her eigentlich nicht machen sollten/dürften - dazu gibt es Annotationen! -
Schreiben von Objekten (samt Zustand) in Streams
ObjectOutputStream: void writeObject(Object)
Die Serialisierung erfolgt dabei für alle Attribute (außer
static
undtransient
, s.u.) rekursiv.Dabei werden auch Zirkelreferenzen automatisch aufgelöst/unterbrochen.
-
Lesen und "Wiedererwecken" der Objekte aus Streams
ObjectInputStream: Object readObject()
Dabei erfolgt KEIN Konstruktor-Aufruf!
Einfaches Beispiel
public class Studi implements Serializable {
private final int credits = 42;
private String name = "Hilde";
public static void writeObject(Studi studi, String filename) {
try (FileOutputStream fos = new FileOutputStream(filename);
ObjectOutputStream oos = new ObjectOutputStream(fos)) {
oos.writeObject(studi); oos.close();
} catch (IOException ex) {}
}
public static Studi readObject(String filename) {
Studi studi = null;
try (FileInputStream fis = new FileInputStream(filename);
ObjectInputStream ois = new ObjectInputStream(fis)) {
studi = (Studi) ois.readObject(); ois.close();
} catch (IOException | ClassNotFoundException ex) {}
return studi;
}
}
Bedingungen für Objekt-Serialisierung
- Klassen implementieren Marker-Interface
Serializable
- Alle Attribute müssen ebenfalls serialisierbar sein (oder Deklaration "
transient
") - Alle primitiven Typen sind per Default serialisierbar
- Es wird automatisch rekursiv serialisiert, aber jedes Objekt nur einmal (bei Mehrfachreferenzierung)
- Serialisierbarkeit vererbt sich
Ausnahmen
- Als
static
deklarierte Attribute werden nicht serialisiert - Als
transient
deklarierte Attribute werden nicht serialisiert - Nicht serialisierbare Attribut-Typen führen zu
NotSerializableException
Version-UID
static final long serialVersionUID = 42L;
- Dient zum Vergleich der serialisierten Version und der aktuellen Klasse
- Über IDE generieren oder manuell vergeben
- Wenn das Attribut fehlt, wird eine Art Checksumme von der Runtime-Umgebung berechnet (basierend auf diversen Eigenschaften der Klasse)
Dieser Wert wird beim Einlesen verglichen: Das Objekt wird nur dann wieder de-serialisiert,
wenn die serialVersionUID
mit der einzulesenden Klasse übereinstimmt!
Bei automatischer Berechnung der serialVersionUID
durch die JVM kann jede kleine Änderung an
der Klasse (beispielsweise Refactoring: Änderung der Methodennamen) eine neue serialVersionUID
zur Folge haben. Das würde bedeuten, dass bereits serialisierte Objekte nicht mehr eingelesen
werden können, auch wenn sich nur Methoden o.ä. verändert haben und die Attribute noch so vorhanden
sind. Deshalb bietet es sich an, hier selbst eine serialVersionUID
zu definieren - dann muss
man aber auch selbst darauf achten, diese zu verändern, wenn sich wesentliche strukturelle
Änderungen an der Klasse ergeben!
Bemerkungen
Es existieren diverse weitere Fallstricke und Probleme, siehe [Bloch2018] Kapitel 11 "Serialization".
Man kann in den ObjectOutputStream
nicht nur ein Objekt schreiben, sondern mehrere Objekte und
Variablen schreiben lassen. In dieser Reihenfolge muss man diese dann aber auch wieder aus dem
Stream herauslesen (vgl. Object Streams).
Man kann die zu serialisierenden Attribute mit der Annotation @Serial
markieren. Dies ist in
der Wirkung ähnlich zu @Override
: Der Compiler prüft dann, ob die markierten Attribute wirklich
serialisierbar sind und würde sonst zur Compile-Zeit einen Fehler werfen.
Weitere Links:
- Tutorials:
- API:
Wrap-Up
-
Markerinterface
Serializable
schaltet Serialisierbarkeit frei -
Objekte schreiben:
ObjectOutputStream
:void writeObject(Object)
-
Objekte lesen:
ObjectInputStream
:Object readObject()
-
Wichtigste Eigenschaften:
- Attribute müssen serialisierbar sein
transient
undstatic
Attribute werden nicht serialisiert- De-Serialisierung: KEIN Konstruktor-Aufruf!
- Serialisierbarkeit vererbt sich
- Objekt-Referenz-Graph wird automatisch beachtet
Implementieren Sie die beiden Klassen entsprechend dem UML-Diagram:
Objekte vom Typ Person
sowie Address
sollen serialisierbar sein (vgl. Vorlesung).
Dabei soll das Passwort nicht serialisiert bzw. gespeichert werden, alle anderen
Eigenschaften von Person
sollen serialisierbar sein.
Hinweis: Verwenden Sie zur Umsetzung java.io.Serializable.
Erstellen Sie in Ihrem main()
einige Instanzen von Person und speichern Sie diese in
serialisierter Form und laden (deserialisieren) Sie diese anschließend in neue Variablen.
Betrachten Sie die ursprünglichen und die wieder deserialisierten Objekte mit Hilfe des Debuggers. Alternativ können Sie die Objekte auch in übersichtlicher Form über den Logger ausgeben.
- [Bloch2018] Effective Java
Bloch, J., Addison-Wesley, 2018. ISBN 978-0-13-468599-1. - [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Essential Java Classes \> Basic I/O \> Object Streams
Java Collections Framework
Die Collection-API bietet verschiedene Sammlungen an, mit denen man Objekte speichern kann: Listen, Queues, Mengen, ... Für diese Typen gibt es jeweils verschiedene Implementierungen mit einem spezifischen Verhalten. Zusätzlich gibt es noch Maps für das Speichern von Key/Value-Paaren, dabei wird für die Keys eine Hash-Tabelle eingesetzt.
Die Hilfs-Klasse Collections
bietet statische Hilfs-Methoden, die auf Collection<T>
s anwendbar sind.
Wenn man eigene Klassen in der Collection-API oder in Map benutzen möchte, sollte man den "equals-hashCode-Contract" berücksichtigen.
- (K2) Was ist der Unterschied zwischen
Collection<T>
undList<T>
? - (K2) Was ist der Unterschied zwischen einer
List<T>
, einerQueue<T>
und einerSet<T>
? - (K2) Nennen Sie charakteristische Merkmale von
ArrayList<T>
,LinkedList<T>
undVector<T>
. - (K2) Was ist der Unterschied zwischen einer
Queue<T>
und einemStack<T>
? - (K2) Was ist eine
Map<K,V>
? Welche Vertreter kennen Sie? - (K3) Erklären Sie die 'Spielregeln' für die eigene Implementierung von
equals()
. - (K3) Erklären Sie die 'Spielregeln' für die eigene Implementierung von
hashCode()
. - (K3) Erklären Sie die 'Spielregeln' für die eigene Implementierung von
compareTo()
. - (K3) Wie müssen und wie sollten
equals()
,hashCode()
undcompareTo()
miteinander arbeiten?
Motivation: Snippet aus einer Klasse im PM-Dungeon
private List<Entity> entities = new ArrayList<>();
public void add(Entity e){
if (!entities.contains(e)) entities.add(e);
}
Die war ein reales Beispiel aus der Entwicklung des PM-Dungeon.
Es wurde eine ArrayList<T>
zum Verwalten der Entitäten genutzt. Allerdings sollte
jedes Element nur einmal in der Liste vorkommen, deshalb wurde beim Einfügen einer
Entität geprüft, ob diese bereits in der Liste ist.
Hier wird die falsche Datenstruktur genutzt!
Eine Liste kann ein Objekt mehrfach enthalten, eine Menge (Set) hingegen kann ein Objekt nur einmal enthalten.
Collection-API in Java
Hinweis: Die abstrakten (Zwischen-) Klassen wurden im obigen UML aus Gründen der Übersichtlichkeit nicht aufgeführt. Aus den selben Gründen sind auch nur ausgewählte Methoden aufgenommen worden.
Hinweis: Blau = Interface, Grün = Klasse.
Collection<T>
ist ein zentrales Interface im JDK und stellt die gemeinsame API der
Collection-Klassen dar. Klassen, die Collection<T>
implementieren, speichern und
verwalten eine Menge an Objekten.
Unter anderem gibt es die aus dem Modul "ADS" bekannten Datentypen wie Listen, Sets, Queues etc.
Man unterscheidet zwischen "sorted" (geordnete) Collections, welche eine bestimmte Reihenfolge der Elemente halten (Reihenfolge des Einfügens, aufsteigende Werte etc.) und "unsorted" (ungeordnete) Collections, welche keine bestimmte Reihenfolge halten.
Eine Übersicht, welche Collection welche Datenstruktur implementiert, kann unter "Collection Implementations" eingesehen werden.
List<T>
-Collections sind eine geordnete Liste an Objekten. Per Index-Zugriff können Objekte an jeder Stelle der Liste zugegriffen (oder hinzugefügt) werden.Queue<T>
-Collections sind eine geordnete Sammlung von Objekten. Objekte können nur am Ende der Queue hinzugefügt werden und nur am Anfang der Queue (der Head) gelesen oder entnommen werden ("first in first out").Set<T>
-Collections sind eine (i.d.R.!) ungeordnete Menge an Objekten, die stets nur einmal in der Set enthalten sein können. In einem Set kann nicht direkt auf ein Objekt zugegriffen werden. Es kann aber geprüft werden, ob ein spezifisches Objekt in einer Set gespeichert ist.
Wichtig: List<T>
, Set<T>
, Queue<T>
und Map<K,V>
sind Interfaces, definieren
also bestimmte Schnittstellen, die sich so wie aus ADS her bekannt verhalten. Diese können
jeweils mit sehr unterschiedlichen Datenstrukturen implementiert werden und können dadurch
auch intern ein anderes Verhalten haben (sortiert vs. nicht sortiert, Zugriffszeiten, ...).
Siehe auch Interface Collection.
Listen: ArrayList
private List<Entity> entities = new ArrayList<>();
Link zu einer netten Animation
Eine ArrayList<T>
ist von außen betrachtet ein sich dynamisch vergrößerndes Array.
Intern wird allerdings ein statisches(!) Array benutzt. Wenn dieses Array voll ist, wird es um 50% vergrößert und alle Inhalte in das neue Array kopiert. Davon merkt man als Nutzer aber nichts.
Dank es Arrays kann auf ein Element per Index mit O(1) zugegriffen werden.
Wird ein Element aus der Liste gelöscht, rücken alle Nachfolgenden Einträge in der Liste einen Index auf (interner Kopiervorgang).
Deshalb ist eine ArrayList<T>
effizient in der Abfrage und Manipulation von Einträgen,
aber deutlich weniger effizient beim Hinzufügen und Löschen von Einträgen.
Per Default wird eine ArrayList<T>
mit einem Array der Länge 10 angelegt, sobald das
erste Element eingefügt wird. Man kann die Startgröße auch im Konstruktoraufruf der
ArrayList<T>
bestimmen: beispielsweise new ArrayList<>(20)
.
Die Methoden einer ArrayList<T>
sind nicht synchronized
.
Listen: LinkedList
Link zu einer netten Animation
Eine LinkedList<T>
ist eine Implementierung einer doppelt verketteten Liste (diese
kennen Sie bereits aus ADS) in Java.
Jeder Eintrag wird als Knoten repräsentiert, der den eigentlichen Wert speichert und zusätzlich je einen Verweis auf den Vorgänger- und Nachfolger-Knoten hat.
Der Head der LinkedList<T>
zeigt auf den Anfang der Liste, der Nachfolger des letzten
Eintrag ist immer null
.
Für den Zugriff auf ein Element muß man die LinkedList<T>
traversieren und beginnt
dabei am Anfang der Liste, deshalb ist ein Zugriff O(n).
Neue Elemente können effizient an das Ende der Liste eingefügt werden, indem der letzte Eintrag einen Verweis auf den neuen Knoten bekommt: O(1) (sofern man sich nicht nur den Start der Liste merkt, sondern auch das aktuelle Ende).
Wenn ein Element aus der Liste gelöscht wird, muss dieses zunächst gefundenen werden und die Liste danach neu verkettete werden: O(n).
Die Methoden einer LinkedList<T>
sind nicht synchronized
.
Vector und Stack
-
Vector<T>
:- Ein
Vector<T>
ähnelt einerArrayList<T>
- Das Array eines Vector wird jedoch verdoppelt, wenn es vergrößert wird
- Die Methoden von
Vector<T>
sindsynchronized
- Ein
-
Stack<T>
:- Schnittstelle: "last in first out"-Prinzip
push(T)
: Pushe Element oben auf den Stackpop(): T
: Hole oberstes Element vom Stack
- Tatsächlich aber:
class Stack<E> extends Vector<E>
- Schnittstelle: "last in first out"-Prinzip
Iterierbarkeit: Iterable und Iterator
private List <Entity> entities = new ArrayList<>();
for (Entity e : entities) { ... }
entities.forEach(x -> ...);
Die Klassen aus der Collection-API implementieren das Interface Iterable<T>
und sind damit
iterierbar. Man kann sie darüber in einer klassischen for
-Schleife nutzen, oder mit der
Methode forEach()
direkt über die Sammlung laufen.
Intern wird dabei ein passender Iterator<T>
erzeugt, der die Elemente der Sammlung schrittweise
mit der Methode next()
zurückgibt. Mithilfe eines Cursor merkt sich der Iterator, bei welchem
Eintrag der Datenstruktur er aktuell ist. Mit der Methode hasNext()
kann geprüft werden, ob noch
ein weiteres Element über den Iterator aus der Datenstruktur verfügbar ist.
Mit remove()
kann das letzte zurückgegebene Element aus der Datenstruktur entfernt werden. Diese
Methode ist im Interface als Default-Methode implementiert.
Damit kann man die Datenstrukturen auf eine von der Datenstruktur vorgegebene Weise ablaufen, beispielsweise einen Binärbaum.
Link zu einer netten Animation
Man kann auch selbst für eigene Klassen einen passenden Iterator<T>
implementieren, der zum Ablaufen
der Elemente der eigenen Klasse genutzt werden kann. Damit die eigene Klasse auch in einer for
-Schleife
genutzt werden kann, muss sie aber auch noch Iterable<T>
implementieren.
Hilfsklasse Collections
Collections
ist eine Utility-Klasse mit statischen Methoden, die auf Collection<T>
s ausgeführt werden.
Diese Methoden nutzen das Collection<T>
-Interface und/oder die Iterable<T>
-Schnittstelle.
Siehe auch Class Collections.
Der Hintergrund für diese in Java nicht unübliche Aufsplittung in ein Interface und eine Utility-Klasse
ist, dass bis vor kurzem Interface nur Schnittstellen definieren konnten. Erst seit einigen Java-Versionen
kann in Interfaces auch Verhalten definiert werden (Default-Methoden). Aus heutiger Sicht würde man also
vermutlich die statischen Methoden in der Klasse Collections
eher direkt als Default-Methoden im Interface
Collection<T>
implementieren und bereitstellen, statt eine separate Utility-Klasse zu definieren.
Map
Hinweis: Die abstrakten (Zwischen-) Klassen wurden im obigen UML aus Gründen der Übersichtlichkeit nicht aufgeführt. Aus den selben Gründen sind auch nur ausgewählte Methoden aufgenommen worden.
Hinweis: Blau = Interface, Grün = Klasse.
Hinweis: Tatsächlich ist der Typ des Keys in den Methoden get()
und remove()
mit Object
spezifiziert und nicht mit dem Typ-Parameter K
. Das ist aus meiner Sicht eine Inkonsistenz in
der API.
Eine Map<K,V>
speichert Objekte als Key/Value-Paar mit den Typen K
(Key) und V
(Value).
Dabei sind die Keys in einer Map einzigartig und werden verwendet, um auf das jeweilige Value zuzugreifen. Ein Value kann entsprechend (mit unterschiedlichen Keys) mehrfach im einer Map enthalten sein.
Es gibt eine Reihe verschiedener Implementierungen, die unterschiedliche Datenstrukturen einsetzen, beispielsweise:
HashMap<K,V>
hält keine Ordnung in den Einträgen. Verwendet den Hashwert, um Objekte zu speichern. Zugriff auf Einträge in einerHashMap
ist O(1).LinkedHashMap<K,V>
hält die Einträge in der Reihenfolge, in der sie eingefügt wurden.TreeMap<K,V>
hält die Einträge in aufsteigender Reihenfolge.
Siehe auch Interface Map.
HashMap
Eine HashMap<K,V>
speichert die Elemente in mehreren einfach verketteten Listen. Dafür
verwendet sie die innere Klasse Node<K,V>
.
Die Heads, die auf den Anfang einer Liste zeigen, werden in "Buckets" gespeichert. Initial besitzt eine HashMap 12 Buckets, diese werden bei Bedarf erweitert.
Um einen Eintrag hinzufügen, wird zunächst aus dem hashCode()
des Key-Objektes mithilfe der
Hash-Funktion der Index des Buckets berechnet. Ist der Bucket gefunden, wird geprüft, ob das
Objekt dort schon vorkommt: Mit dem hashCode()
des Key-Objektes werden alle Objekte in der
Liste des Buckets verglichen. Wenn es Einträge mit dem selben hashCode()
in der Liste gibt,
wird mit equals
geprüft, ob die Key-Objekte identisch sind. Ist dies der Fall, wird der
existierende Eintrag überschrieben, anderenfalls wird der neue Eintrag an das Ende der Liste
hinzugefügt.
Implementierungsdetail: Wenn die Listen zu groß werden, wird die Hashtabelle neu angelegt mit ungefähr der doppelten Anzahl der Einträge (Buckets) und die alten Einträge per Re-Hash neu verteilt (vgl. Class HashMap).
HashMap<K,V>
Methoden sind nicht synchronized
.
HashMap<K,V>
unterstützt einen null
-Key. Es darf beliebig viele null
-Values geben.
Die Unterklasse LinkedHashMap<K,V>
kann Ordnung zwischen den Elementen halten. Dafür wird
eine doppelt verkettete Liste verwendet.
Hashtable
- Nicht zu verwechseln mit der Datenstruktur: Hash-Tabellen (!)
Hashtable<K,V>
ist vergleichbar mit einerHashMap<K,V>
Hashtable<K,V>
-Methoden sindsynchronized
- Kein Key oder Value darf
null
sein
Spielregeln für equals(), hashCode() und compareTo()
equals()
boolean equals(Object o)
ist eine Methode Klasse Object
und wird genutzt, um Objekte auf Gleichheit zu
prüfen. Die Default-Implementierung von equals()
in Object
vergleicht die beiden Objekte mit ==
, gibt
also nur dann true
zurück, wenn die beiden zu vergleichenden Objekte die selbe Objekt-ID haben.
In der Praxis kann es sich anbieten, diese Methode zu überschreiben und eigene Kriterien für Gleichheit aufzustellen.
Dabei sind Spielregeln zu beachten (für nicht-null
Objekte x
, y
und z
):
- Reflexivität:
x.equals(x) == true
- Symmetrie:
x.equals(y) == y.equals(x)
- Transitivität: Wenn
x.equals(y) == true
undy.equals(z) == true
, dann auchx.equals(z) == true
- Konsistenz: Mehrfache Aufrufe von
equals()
mit den selben Werten müssen immer das selbe Ergebnis liefern x.equals(null) == false
hashCode()
Die Methode int hashCode()
gibt den Hash-Wert eines Objektes zurück. Der Hash-Wert eins Objektes wird genutzt,
um dieses in einen Hash-basierten Container abzulegen bzw. zu finden.
Der Rückgabewert der Methode hashCode()
für ein Objekt bleibt über die Laufzeit einer Anwendung immer identisch,
solange sich die zur Prüfung der Gleichheit genutzten Attribute nicht ändern.
compareTo()
Die Methode int compareTo()
(Interface Comparable<T>
) vergleicht Objekte und definiert damit eine Ordnung
auf den Objekten. Während equals()
für die Prüfung auf Gleichheit eingesetzt wird, wird compareTo()
für die
Sortierung von Objekten untereinander verwendet.
Spielregeln:
x.compareTo(y) < 0
wennx
"kleiner" alsy
istx.compareTo(y) > 0
wennx
"größer" alsy
istx.compareTo(y) = 0
wennx
"gleich" alsy
ist- Symmetrie:
signum(x.compareTo(y)) == -signum(y.compareTo(x))
- Transitivität: Wenn
x.compareTo(y) > 0
undy.compareTo(z) > 0
, dann auchx.compareTo(z) > 0
- Wenn
x.compareTo(y) == 0
, dann auchsignum(x.compareTo(z)) == signum(y.compareTo(z))
Der equals()-hashCode()-compareTo()-Vertrag
Wird equals()
überschrieben, sollte auch hashCode()
(passend) überschrieben werden.
-
Wenn
x.equals(y) == true
, dann muss auchx.hashCode() == y.hashCode()
-
Wenn
x.equals(y) == false
, solltex.hashCode() != y.hashCode()
sein (UnterschiedlichehashCode()
-Werte für unterschiedliche Objekte verbessern allerdings die Leistung von Hash-Berechnungen, etwa in einerHashMap<K,V>
!) -
Es wird sehr empfohlen, dass
equals()
undcompareTo()
konsistente Ergebnisse liefern:x.compareTo(y) == 0
gdw.x.equals(y) == true
(Dies muss aber nicht zwingend eingehalten werden, sorgt dann aber u.U. für unerwartete Nebeneffekte beim Umgang mitCollection<T>
undMap<K,V>
!)
Überblick
Komplexitätswerte beziehen sich auf den Regelfall. Sonderfälle wie das Vergrößern des Array einer
ArrayList<T>
können für temporär erhöhte Komplexität sorgen (das ist dem O-Kalkül aber egal).
Wrap-Up
- Interface
Collection<T>
: Schnittstelle für Datenstrukturen/Sammlungen zur Verwaltung einer Menge von Objekten - Klasse
Collections
: Statische Hilfs-Methoden (anwendbar aufCollection<T>
s) Iterable<T>
liefert einenIterator<T>
zur Iteration über eineCollection<T>
- Interface
Map<K,V>
: Speichern von Key/Value-Paaren equals()
-hashCode()
-compareTo()
-Vertrag beachten
- [LernJava] Learn Java
Oracle Corporation, 2022.
Tutorials \> Mastering the API \> The Collections Framework
Reguläre Ausdrücke
Mit Hilfe von regulären Ausdrücken kann man den Aufbau von Zeichenketten formal beschreiben. Dabei lassen sich direkt die gewünschten Zeichen einsetzen, oder man nutzt Zeichenklassen oder vordefinierte Ausdrücke. Teilausdrücke lassen sich gruppieren und über Quantifier kann definiert werden, wie oft ein Teilausdruck vorkommen soll. Die Quantifier sind per Default greedy und versuchen so viel wie möglich zu matchen.
Auf der Java-Seite stellt man reguläre Ausdrücke zunächst als String
dar. Dabei
muss darauf geachtet werden, dass ein Backslash im regulären Ausdruck im Java-String
geschützt (escaped) werden muss, indem jeweils ein weiterer Backslash voran gestellt
wird. Mit Hilfe der Klasse java.util.regex.Pattern
lässt sich daraus ein Objekt
mit dem kompilierten regulären Ausdruck erzeugen, was insbesondere bei mehrfacher
Verwendung günstiger in der Laufzeit ist. Dem Pattern-Objekt kann man dann den
Suchstring übergeben und bekommt ein Objekt der Klasse java.util.regex.Matcher
(dort sind regulärer Ausdruck/Pattern und der Suchstring kombiniert). Mit den
Methoden Matcher#find
und Matcher#matches
kann dann geprüft werden, ob das Pattern
auf den Suchstring passt: find
sucht dabei nach dem ersten Vorkommen des Patterns
im Suchstring, match
prüft, ob der gesamte String zum Pattern passt.
- (K1) Wichtigste Methoden von
java.util.regex.Pattern
undjava.util.regex.Matcher
- (K2) Unterschied zwischen
Matcher#find
undMatcher#matches
- (K2) Unterscheidung zwischen greedy und non-greedy Verhalten
- (K3) Bildung einfacher regulärer Ausdrücke
- (K3) Nutzung von Zeichenklassen und deren Negation
- (K3) Nutzung der vordefinierten regulären Ausdrücke
- (K3) Nutzung von Quantifizierern
- (K3) Zusammenbauen von komplexen Ausdrücken (u.a. mit Gruppen)
Suchen in Strings
Gesucht ist ein Programm zum Extrahieren von Telefonnummern aus E-Mails.
=> Wie geht das?
Leider gibt es unzählig viele Varianten, wie man eine Telefonnummer (samt Vorwahl und ggf. Ländervorwahl) aufschreiben kann:
030 - 123 456 789, 030-123456789, 030/123456789,
+49(30)123456-789, +49 (30) 123 456 - 789, ...
Definition Regulärer Ausdruck
Ein regulärer Ausdruck ist eine Zeichenkette, die zur Beschreibung von Zeichenketten dient.
Anwendungen
- Finden von Bestandteilen in Zeichenketten
- Aufteilen von Strings in Tokens
- Validierung von textuellen Eingaben => "Eine Postleitzahl besteht aus 5 Ziffern"
- Compilerbau: Erkennen von Schlüsselwörtern und Strukturen und Syntaxfehlern
Einfachste reguläre Ausdrücke
Zeichenkette | Beschreibt |
---|---|
x |
"x" |
. |
ein beliebiges Zeichen |
\t |
Tabulator |
\n |
Newline |
\r |
Carriage-return |
\\ |
Backslash |
Beispiel
abc
=> "abc"A.B
=> "AAB" oder "A2B" oder ...a\\bc
=> "a\bc"
Anmerkung
In Java-Strings leitet der Backslash eine zu interpretierende Befehlssequenz ein.
Deshalb muss der Backslash i.d.R. geschützt ("escaped") werden.
=> Statt "\n
" müssen Sie im Java-Code "\\n
" schreiben!
Zeichenklassen
Zeichenkette | Beschreibt |
---|---|
[abc] |
"a" oder "b" oder "c" |
[^abc] |
alles außer "a", "b" oder "c" (Negation) |
[a-zA-Z] |
alle Zeichen von "a" bis "z" und "A" bis "Z" (Range) |
[a-z&&[def]] |
"d","e" oder "f" (Schnitt) |
[a-z&&[^bc]] |
"a" bis "z", außer "b" und "c": [ad-z] (Subtraktion) |
[a-z&&[^m-p]] |
"a" bis "z", außer "m" bis "p": [a-lq-z] (Subtraktion) |
Beispiel
[abc]
=> "a" oder "b" oder "c"[a-c]
=> "a" oder "b" oder "c"[a-c][a-c]
=> "aa", "ab", "ac", "ba", "bb", "bc", "ca", "cb" oder "cc"A[a-c]
=> "Aa", "Ab" oder "Ac"
Vordefinierte Ausdrücke
Zeichenkette | Beschreibt |
---|---|
^ |
Zeilenanfang |
$ |
Zeilenende |
\d |
eine Ziffer: [0-9] |
\w |
beliebiges Wortzeichen: [a-zA-Z_0-9] |
\s |
Whitespace (Leerzeichen, Tabulator, Newline) |
\D |
jedes Zeichen außer Ziffern: [^0-9] |
\W |
jedes Zeichen außer Wortzeichen: [^\w] |
\S |
jedes Zeichen außer Whitespaces: [^\s] |
Beispiel
\d\d\d\d\d
=> "12345"\w\wA
=> "aaA", "a0A", "a_A", ...
Nutzung in Java
-
java.lang.String
:public String[] split(String regex) public boolean matches(String regex)
-
java.util.regex.Pattern
:public static Pattern compile(String regex) public Matcher matcher(CharSequence input)
- Schritt 1: Ein Pattern compilieren (erzeugen) mit
Pattern#compile
=> liefert ein Pattern-Objekt für den regulären Ausdruck zurück - Schritt 2: Dem Pattern-Objekt den zu untersuchenden Zeichenstrom
übergeben mit
Pattern#matcher
=> liefert ein Matcher-Objekt zurück, darin gebunden: Pattern (regulärer Ausdruck) und die zu untersuchende Zeichenkette
- Schritt 1: Ein Pattern compilieren (erzeugen) mit
-
java.util.regex.Matcher
:public boolean find() public boolean matches() public int groupCount() public String group(int group)
-
Schritt 3: Mit dem Matcher-Objekt kann man die Ergebnisse der Anwendung des regulären Ausdrucks auf eine Zeichenkette auswerten
Bedeutung der unterschiedlichen Methoden siehe folgende Folien
Matcher#group
: Liefert die Sub-Sequenz des Suchstrings zurück, die erfolgreich gematcht wurde (siehe unten "Fangende Gruppierungen")
-
Hinweis:
In Java-Strings leitet der Backslash eine zu interpretierende Befehlssequenz ein. Deshalb muss der Backslash i.d.R. extra geschützt ("escaped") werden.
=> Statt "\n
" (regulärer Ausdruck) müssen Sie im Java-String "\\n
" schreiben!
=> Statt "a\\bc
" (regulärer Ausdruck, passt auf die Zeichenkette "a\bc") müssen
Sie im Java-String "a\\\\bc
" schreiben!
Unterschied zw. Finden und Matchen
-
Matcher#find
:Regulärer Ausdruck muss im Suchstring enthalten sein. => Suche nach erstem Vorkommen
-
Matcher#matches
:Regulärer Ausdruck muss auf kompletten Suchstring passen.
Beispiel
- Regulärer Ausdruck:
abc
, Suchstring: "blah blah abc blub"Matcher#find
: erfolgreichMatcher#matches
: kein Match - Suchstring entspricht nicht dem Muster
Quantifizierung
Zeichenkette | Beschreibt |
---|---|
X? |
ein oder kein "X" |
X* |
beliebig viele "X" (inkl. kein "X") |
X+ |
mindestens ein "X", ansonsten beliebig viele "X" |
X{n} |
exakt $n$ Vorkommen von "X" |
X{n,} |
mindestens $n$ Vorkommen von "X" |
X{n,m} |
zwischen $n$ und $m$ Vorkommen von "X" |
Beispiel
\d{5}
=> "12345"-?\d+\.\d*
=> ???
Interessante Effekte
Pattern p = Pattern.compile("A.*A");
Matcher m = p.matcher("A 12 A 45 A");
if (m.matches())
String result = m.group(); // ???
Matcher#group
liefert die Inputsequenz, auf die der Matcher angesprochen hat.
Mit Matcher#start
und Matcher#end
kann man sich die Indizes des ersten und
letzten Zeichens des Matches im Eingabezeichenstrom geben lassen. D.h. für einen
Matcher m
und eine Eingabezeichenkette s
ist m.group()
und
s.substring(m.start(), m.end())
äquivalent.
Da bei Matcher#matches
das Pattern immer auf den gesamten Suchstring passen
muss, verwundert das Ergebnis für Matcher#group
nicht. Bei Matcher#find
wird im Beispiel allerdings ebenfalls der gesamte Suchstring "gefunden" ...
Dies liegt am "greedy" Verhalten der Quantifizierer.
Nicht gierige Quantifizierung mit "?"
Zeichenkette | Beschreibt |
---|---|
X*? |
non-greedy Variante von X* |
X+? |
non-greedy Variante von X+ |
Beispiel
- Suchstring "A 12 A 45 A":
-
A.*A
findet/passt auf "A 12 A 45 A"normale greedy Variante
-
A.*?A
- findet "A 12 A"
- passt auf "A 12 A 45 A" (!)
non-greedy Variante der Quantifizierung;
Matcher#matches
muss trotzdem auf den gesamten Suchstring passen!
-
(Fangende) Gruppierungen
Studi{2}
passt nicht auf "StudiStudi" (!)
Quantifizierung bezieht sich auf das direkt davor stehende Zeichen. Ggf. Gruppierungen durch Klammern verwenden!
Zeichenkette | Beschreibt |
---|---|
X|Y |
X oder Y |
(C) |
Gruppierung |
Beispiel
(A)(B(C))
- Gruppe 0:
ABC
- Gruppe 1:
A
- Gruppe 2:
BC
- Gruppe 3:
C
- Gruppe 0:
Die Gruppen heißen auch "fangende" Gruppen (engl.: "capturing groups").
Damit erreicht man eine Segmentierung des gesamten regulären Ausdrucks, der in seiner Wirkung aber nicht durch die Gruppierungen geändert wird. Durch die Gruppierungen von Teilen des regulären Ausdrucks erhält man die Möglichkeit, auf die entsprechenden Teil-Matches (der Unterausdrücke der einzelnen Gruppen) zuzugreifen:
-
Matcher#groupCount
: Anzahl der "fangenden" Gruppen im regulären Ausdruck -
Matcher#group(i)
: Liefert die Subsequenz der Eingabezeichenkette zurück, auf die die jeweilige Gruppe gepasst hat. Dabei wird von links nach rechts durchgezählt, beginnend bei 1(!).Konvention: Gruppe 0 ist das gesamte Pattern, d.h.
m.group(0) == m.group();
...
Hinweis: Damit der Zugriff auf die Gruppen klappt, muss auch erst ein Match gemacht
werden, d.h. das Erzeugen des Matcher-Objekts reicht noch nicht, sondern es muss auch
noch ein matcher.find()
oder matcher.matches()
ausgeführt werden. Danach kann man
bei Vorliegen eines Matches auf die Gruppen zugreifen.
(Studi){2}
=> "StudiStudi"
Gruppen und Backreferences
Matche zwei Ziffern, gefolgt von den selben zwei Ziffern
(\d\d)\1
-
Verweis auf bereits gematchte Gruppen:
\num
num
Nummer der Gruppe (1 ... 9)=> Verweist nicht auf regulären Ausdruck, sondern auf jeweiligen Match!
Anmerkung: Laut Literatur/Doku nur 1 ... 9, in Praxis geht auch mehr per Backreference ...
-
Benennung der Gruppe:
(?<name>X)
X
ist regulärer Ausdruck für Gruppe, spitze Klammern wichtig=> Backreference:
\k<name>
Beispiel Gruppen und Backreferences
Regulärer Ausdruck: Namen einer Person matchen, wenn Vor- und Nachname identisch sind.
Lösung: ([A-Z][a-zA-Z]*)\s\1
Umlaute und reguläre Ausdrücke
-
Keine vordefinierte Abkürzung für Umlaute (wie etwa
\d
) -
Umlaute nicht in
[a-z]
enthalten, aber in[a-ü]
"helloüA".matches(".*?[ü]A"); "azäöüß".matches("[a-ä]"); "azäöüß".matches("[a-ö]"); "azäöüß".matches("[a-ü]"); "azäöüß".matches("[a-ß]");
-
Strings sind Unicode-Zeichenketten
=> Nutzung der passenden Unicode Escape Sequence
\uFFFF
System.out.println("\u0041 :: A"); System.out.println("helloüA".matches(".*?A")); System.out.println("helloüA".matches(".*?\u0041")); System.out.println("helloü\u0041".matches(".*?A"));
-
RegExp vordefinieren und mit Variablen zusammenbauen ala Perl nicht möglich => Umweg String-Repräsentation
Wrap-Up
- RegExp: Zeichenketten, die andere Zeichenketten beschreiben
java.util.regex.Pattern
undjava.util.regex.Matcher
- Unterschied zwischen
Matcher#find
undMatcher#matches
! - Quantifizierung ist möglich, aber greedy (Default)
In den Vorgaben
finden Sie in der Klasse Lexer
eine einfache Implementierung
eines Lexers, worin ein einfaches
Syntax-Highlighting für Java-Code realisiert ist.
Dazu arbeitet der Lexer mit sogenannten "Token" (Instanzen der Klasse Token
).
Diese haben einen regulären Ausdruck, um bestimmte Teile im Code zu erkennen,
beispielsweise Keywords oder Kommentare und anderes. Der Lexer wendet alle Token
auf den aktuellen Eingabezeichenstrom an (Methode Token#test()
), und die Token
prüfen mit "ihrem" regulären Ausdruck, ob die jeweils passende Eingabesequenz
vorliegt. Die regulären Ausdrücke übergeben Sie dem Token
-Konstruktor als
entsprechendes Pattern
-Objekt.
Neben dem jeweiligen Pattern kennt jedes Token noch eine matchingGroup
: Dies
ist ein Integer, der die relevante Matching-Group im regulären Ausdruck bezeichnet.
Wenn Sie keine eigenen Gruppen in einem regulären Ausdruck eingebaut haben, nutzen
Sie hier einfach den Wert 0.
Zusätzlich kennt jedes Token noch die Farbe für das Syntax-Highlighting in der
von uns als Vorgabe realisierten Swing-GUI (Instanz von Color
).
Erstellen Sie passende Token
-Instanzen mit entsprechenden Pattern für die
folgenden Token:
- Einzeiliger Kommentar: beginnend mit
//
bis zum Zeilenende - Mehrzeiliger Kommentar: alles zwischen
/*
und dem nächsten*/
- Javadoc-Kommentar: alles zwischen
/**
und dem nächsten*/
- Strings: alles zwischen
"
und dem nächsten"
- Character: genau ein Zeichen zwischen
'
und'
- Keywords:
package
,import
,class
,public
,private
,final
,return
,null
,new
(jeweils freistehend, also nicht "newx" o.ä.) - Annotation: beginnt mit
@
, enthält Buchstaben oder Minuszeichen
Die Token-Objekte fügen Sie im Konstruktor der Klasse Lexer
durch den
Aufruf der Methode tokenizer.add(mytoken)
hinzu. Sie können Sich an den
Kommentaren im Lexer
-Konstruktor orientieren.
Sollten Token ineinander geschachtelt sein, erkennt der Lexer dies automatisch. Sie brauchen sich keine Gedanken dazu machen, in welcher Reihenfolge die Token eingefügt und abgearbeitet werden. Beispiel: Im regulären Ausdruck für den einzeiligen Kommentar brauchen Sie keine Keywords, Annotationen, Strings usw. erkennen.
- [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Essential Java Classes \> Regular Expressions
Annotationen
Annotationen sind Metadaten zum Programm: Sie haben keinen (direkten) Einfluss auf die Ausführung des annotierten Codes, sondern enthalten Zusatzinformationen über ein Programm, die selbst nicht Teil des Programms sind. Verschiedene Tools werten Annotationen aus, beispielsweise der Compiler, Javadoc, JUnit, ...
Annotationen können auf Deklarationen (Klassen, Felder, Methoden) angewendet werden und werden meist auf eine eigene Zeile geschrieben (Konvention).
Annotationen können relativ einfach selbst erstellt werden: Die Definition ist fast wie bei einem Interface. Zusätzlich kann man noch über Meta-Annotationen die Sichtbarkeit, Verwendbarkeit und Dokumentation einschränken. Annotationen können zur Übersetzungszeit mit einem Annotation-Processor verarbeitet werden oder zur Laufzeit über Reflection ausgewertet werden.
- (K2) Begriff der Annotation erklären können am Beispiel
- (K3) Anwendung von
@Override
sowie der Javadoc-Annotationen - (K3) Erstellen eigener Annotationen sowie Einstellen der Sichtbarkeit und Verwendbarkeit
- (K3) Erstellen eigener einfacher Annotation-Processors
Was passiert hier?
public class A {
public String getInfo() { return "Klasse A"; }
}
public class B extends A {
public String getInfo(String s) { return s + "Klasse B"; }
public static void main(String[] args) {
B s = new B();
System.out.println(s.getInfo("Info: "));
}
}
Hilft @Override
?
Tja, da sollte wohl die Methode B#getInfo
die geerbte Methode A#getInfo
überschreiben. Dummerweise wird hier die Methode aber nur überladen
(mit entsprechenden Folgen beim Aufruf)!
Ein leider relativ häufiges Versehen, welches u.U. schwer zu finden ist.
Annotationen (hier @Override
) können dagegen helfen - der Compiler "weiß"
dann, dass wir überschreiben wollen und meckert, wenn wir das nicht tun.
IDEs wie Eclipse können diese Annotation bereits beim Erstellen einer Klasse
generieren: Preferences > Java > Code Style > Add @Override annotation ...
.
Annotationen: Metadaten für Dritte
-
Zusatzinformationen für Tools, Bibliotheken, ...
-
Kein direkter Einfluss auf die Ausführung des annotierten Codes
-
Beispiele:
- Compiler (JDK):
@Override
,@Deprecated
, ... - Javadoc:
@author
,@version
,@see
,@param
,@return
, ... - JUnit:
@Test
,@Before
,@BeforeClass
,@After
,@AfterClass
- IntelliJ:
@NotNull
,@Nullable
- Checker Framework:
@NonNull
,@Nullable
, ... - Project Lombok:
@Getter
,@Setter
,@NonNull
, ... - Webservices:
@WebService
,@WebMethod
- ...
- Compiler (JDK):
Jetzt schauen wir uns erst einmal die Auswirkungen von @Override
und @Deprecated
auf den Compiler (via Eclipse) an.
Anschließend lernen Sie die Dokumentation mittels Javadoc-Annotationen kennen.
Das Thema JUnit ist in einer anderen VL dran. Webservices ereilen Sie dann in späteren Semestern :-)
@Override
Die mit @Override
annotierte Methode überschreibt eine Methode aus der Oberklasse oder implementiert eine
Methode einer Schnittstelle. Dies wird durch den Compiler geprüft und ggf. mit einer Fehlermeldung quittiert.
@Override
ist eine im JDK im Paket java.lang
enthaltene Annotation.
@Deprecated
Das mit @Deprecated
markierte Element ist veraltet ("deprecated") und sollte nicht mehr benutzt werden.
Typischerweise werden so markierte Elemente in zukünftigen Releases aus der API entfernt ...
Die Annotation @Deprecated
wird direkt im Code verwendet und entspricht der Annotation @deprecated
im Javadoc. Allerdings kann letzteres nur von Javadoc ausgewertet werden.
@Deprecated
ist eine im JDK im Paket java.lang
enthaltene Annotation.
Weitere Annotationen aus java.lang
Im Paket java.lang
finden sich weitere Annotationen. Mit Hilfe von @SuppressWarnings
lassen sich bestimmte
Compilerwarnungen unterdrücken (so etwas sollte man NIE tun!), und mit @FunctionalInterface
lassen sich Schnittstellen auszeichnen, die genau eine (abstrakte) Methode besitzen (Verweis auf spätere Vorlesung).
Weitere Annotationen aus dem JDK finden sich in den Paketen java.lang.annotation
und javax.annotation
.
Dokumentation mit Javadoc
/**
* Beschreibung Beschreibung Beschreibung
*
* @param date Tag, Wert zw. 1 .. 31
* @return true, falls Datum gesetzt wurde; false sonst
* @see java.util.Calendar
* @deprecated As of JDK version 1.1
*/
public boolean setDate(int date) {
setField(Calendar.DATE, date);
}
Die Dokumentation mit Javadoc hatten wir uns bereits in der Einheit “Javadoc” angesehen.
Hier noch einmal exemplarisch die wichtigsten Elemente, die an
"public
" sichtbaren Methoden verwendet werden.
@NotNull mit IntelliJ
IntelliJ bietet im Paket
org.jetbrains.annotations
u.a. die Annotation @NotNull
an.
Damit lassen sich Rückgabewerte von Methoden sowie Variablen (Attribute, lokale Variablen, Parameter) markieren:
Diese dürfen nicht null
werden.
IntelliJ prüft beim Compilieren, dass diese Elemente nicht null
werden und warnt gegebenenfalls (zur Compilezeit).
Zusätzlich baut IntelliJ entsprechende Assertions in den Code ein, die zur Laufzeit einen null
-Wert abfangen
und dann das Programm abbrechen.
Dadurch können entsprechende Dokumentationen im Javadoc und/oder manuelle Überprüfungen im Code entfallen.
Außerdem hat man durch die Annotation gewissermaßen einen sichtbaren Vertrag (Contract) mit den Nutzern
der Methode. Bei einem Aufruf mit null
würde dieser Contract verletzt und eine entsprechende Exception
geworfen (automatisch) statt einfach das Programm und die JVM "abzuschießen".
Nachteil: Die entsprechende Bibliothek muss bei allen Entwicklern vorhanden und in das Projekt eingebunden sein.
/* o should not be null */
public void bar(Object o) {
int i;
if (o != null) {
i = o.hashCode();
}
}
/* o must not be null */
public void foo(@NotNull Object o) {
// assert(o != null); // Wirkung (von IntelliJ eingefügt)
int i = o.hashCode();
}
IntelliJ inferiert mit @NotNull mögliche null-Werte
IntelliJ baut bei @NotNull passende Assertions ein
Eigene Annotationen erstellen
public @interface MyFirstAnnotation {}
public @interface MyThirdAnnotation {
String author();
int vl() default 1;
}
@MyFirstAnnotation
@MyThirdAnnotation(author = "Carsten Gips", vl = 3)
public class C {}
Definition einer Annotation
Definition einer Annotation wie Interface, aber mit "@
"-Zeichen vor dem interface
-Schlüsselwort
Parameter für Annotation
Parameter für Annotation werden über entsprechende Methoden-Deklaration realisiert
-
"Rückgabetyp" der deklarierten "Methode" ist der erlaubte Typ der später verwendeten Parameter
-
Name der "Methoden" wird bei der Belegung der Parameter verwendet, beispielsweise
author = ...
-
Vereinfachung: "Methodenname"
value
erlaubt das Weglassen des Schlüsselworts bei der Verwendung:public @interface MySecondAnnotation { String value(); } @MySecondAnnotation("wuppie") public class D {} @MySecondAnnotation(value = "wuppie") public class E {}
-
Defaultwerte mit dem nachgestellten Schlüsselwort
default
sowie dem Defaultwert selbst
Javadoc
Soll die Annotation in der Javadoc-Doku dargestellt werden, muss sie mit
der Meta-Annotation @Documented
ausgezeichnet werden (aus
java.lang.annotation.Documented
)
Hinweis: Die Annotation wird lediglich in die Doku aufgenommen, d.h. es erfolgt keine weitere Verarbeitung oder Hervorhebung o.ä.
Wann ist eine Annotation sichtbar (Beschränkung der Sichtbarkeit)
Annotationen werden vom Compiler und/oder anderen Tools ausgewertet. Man kann entsprechend die Sichtbarkeit einer Annotation beschränken: Sie kann ausschließlich im Source-Code verfügbar sein, sie kann in der generierten Class-Datei eingebettet sein oder sie kann sogar zur Laufzeit (mittels Reflection, vgl. spätere Vorlesung) ausgelesen werden.
Beschränkung der Sichtbarkeit: Meta-Annotation @Retention
aus
java.lang.annotation.Retention
RetentionPolicy.SOURCE
: Nur Bestandteil der Source-Dateien, wird nicht in kompilierten Code eingebettetRetentionPolicy.CLASS
: Wird vom Compiler in die Class-Datei eingebettet, steht aber zur Laufzeit nicht zur Verfügung (Standardwert, wenn nichts angegeben)RetentionPolicy.RUNTIME
: Wird vom Compiler in die Class-Datei eingebettet und steht zur Laufzeit zur Verfügung und kann via Reflection1 ausgelesen werden
Ohne explizite Angabe gilt für die selbst definierte Annotation die
Einstellung RetentionPolicy.CLASS
.
Wo darf eine Annotation verwendet werden
Anwendungsmöglichkeiten von Annotationen im Code
@ClassAnnotation
public class Wuppie {
@InstanceFieldAnnotation
private String foo;
@ConstructorAnnotation
public Wuppie() {}
@MethodAnnotation1
@MethodAnnotation2
@MethodAnnotation3
public void fluppie(@ParameterAnnotation final Object arg1) {
@VariableAnnotation
final String bar = (@TypeAnnotation String) arg1;
}
}
Einschränkung des Einsatzes eines Annotation
Für jede Annotation kann eingeschränkt werden, wo (an welchen Java-Elementen) sie verwendet werden darf.
Beschränkung der Verwendung: Meta-Annotation @Target
aus
java.lang.annotation.Target
ElementType.TYPE
: alle Typdeklarationen: Klassen, Interfaces, Enumerations, ...ElementType.CONSTRUCTOR
: nur KonstruktorenElementType.METHOD
: nur MethodenElementType.FIELD
: nur statische Variablen und ObjektvariablenElementType.PARAMETER
: nur ParametervariablenElementType.PACKAGE
: nur an Package-Deklarationen
Ohne explizite Angabe ist die selbst definierte Annotation für alle Elemente verwendbar.
Annotationen bei Compilieren bearbeiten: Java Annotation-Prozessoren
Der dem javac
-Compiler vorgelegte Source-Code wird eingelesen und in einen
entsprechenden Syntax-Tree (AST) transformiert (dazu mehr im Master im Modul
"Compilerbau" :)
Anschließend können sogenannte "Annotation Processors" über den AST laufen und ihre Analysen machen und/oder den AST modifizieren. (Danach kommen die üblichen weiteren Analysen und die Code-Generierung.)
(Vgl. OpenJDK: Compilation Overview.)
An dieser Stelle kann man sich einklinken und einen eigenen Annotation-Prozessor ausführen lassen. Zur Abgrenzung: Diese Auswertung der Annotationen findet zur Compile-Zeit statt! In einer späteren Vorlesung werden wir noch über die Auswertung zur Laufzeit sprechen: Reflection.
Im Prinzip muss man lediglich das Interface javax.annotation.processing.Processor
implementieren oder die abstrakte Klasse javax.annotation.processing.AbstractProcessor
erweitern. Für die Registrierung im javac
muss im Projekt (oder Jar-File) die
Datei META-INF/services/javax.annotation.processing.Processor
angelegt werden,
die den vollständigen Namen des Annotation-Prozessors enthält. Dieser Annotation-Prozessor
wird dann vom javac
aufgerufen und läuft in einer eigenen JVM. Er kann die
Annotationen, für die er registriert ist, auslesen und verarbeiten und neue Java-Dateien
schreiben, die wiederum eingelesen und compiliert werden.
Im nachfolgenden Beispiel beschränke ich mich auf das Definieren und Registrieren eines einfachen Annotation-Prozessors, der lediglich die Annotationen liest.
@SupportedAnnotationTypes("annotations.MySecondAnnotation")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class Foo extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> as, RoundEnvironment re) {
for (TypeElement annot : as) {
for (Element el : re.getElementsAnnotatedWith(annot)) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE,
"found @MySecondAnnotation at " + el);
}
}
return true;
}
}
- Der Annotation-Processor sollte von
AbstractProcessor
ableiten - Über
@SupportedAnnotationTypes
teilt man mit, für welche Annotationen sich der Prozessor interessiert (d.h. für welche er aufgerufen wird); "*
" oder eine Liste mit String ist auch möglich - Mit
@SupportedSourceVersion
wird die (höchste) unterstützte Java-Version angegeben (neuere Versionen führen zu einer Warnung) - Die Methode
process
erledigt die Arbeit:- Der erste Parameter enthält alle gefundenen Annotationen, für die der Processor registriert ist
- Der zweite Parameter enthält die damit annotierten Elemente
- Iteration: Die äußere Schleife läuft über alle gefundenen Annotationen, die innere über die mit der jeweiligen Annotation versehenen Elemente
- Jetzt könnte man mit den Elementen etwas sinnvolles anfangen, beispielsweise alle Attribute
sammeln, die mit
@Getter
markiert sind und für diese neuen Code generieren - Im Beispiel wird lediglich der eigene Logger (
processingEnv.getMessager()
) aufgerufen, um beim Compiliervorgang eine Konsolenmeldung zu erzeugen ...
- Der Annotation-Processor darf keine Exception werfen, da sonst der Compiliervorgang abgebrochen würde. Zudem wäre der Stack-Trace der des Annotation-Processors und nicht der des compilierten Programms ... Stattdessen wird ein Boolean zurückgeliefert, um anzudeuten, ob die Verarbeitung geklappt hat.
Für ein umfangreicheres Beispiel mit Code-Erzeugung vergleiche beispielsweise die Artikelserie unter cloudogu.com/en/blog/Java-Annotation-Processors_1-Intro. Siehe auch OpenJDK: Compilation Overview.
Im Projekt muss jetzt noch der Ordner META-INF/services/
angelegt werden mit der Datei
javax.annotation.processing.Processor
. Deren Inhalt ist für das obige Beispiel die Zeile
annotations.Foo
. Damit ist der Annotation-Processor annotations.Foo
für das Übersetzen im eigenen
Projekt registriert.
Zum Compilieren des Annotation-Processors selbst ruft man beispielsweise folgenden Befehl auf:
javac -cp . -proc:none annotations/Foo.java
Die Option -proc:none
sorgt für das Beispiel dafür, dass beim Compilieren des Annotation-Processors
dieser nicht bereits aufgerufen wird (was sonst wg. der Registrierung über
META-INF/services/javax.annotation.processing.Processor
passieren würde).
Zum Compilieren der Klasse C
kann man wie sonst auch den Befehl nutzen:
javac -cp . annotations/C.java
Dabei läuft dann der Annotation-Processor annotations.Foo
und erzeugt beim Verarbeiten von annotations.C
die folgende Ausgabe:
Note: found @MySecondAnnotation at main(java.lang.String[])
Wrap-Up
-
Annotationen: Metadaten zum Programm
- Zusatzinformationen über ein Programm, aber nicht selbst Teil des Programms
- Kein (direkter) Einfluss auf die Ausführung des annotierten Codes
-
Typische Anwendungen: Compiler-Hinweise, Javadoc, Tests
- Compiler: Erkennen von logischen Fehlern, Unterdrücken von
Warnungen =>
java.lang
:@Override
,@Deprecated
,@SuppressWarnings
- Javadoc: Erkennen von Schlüsselwörtern (
@author
,@return
,@param
, ...) - JUnit: Erkennen von Tests-Methoden (
@Test
) - ...
- Compiler: Erkennen von logischen Fehlern, Unterdrücken von
Warnungen =>
-
Annotationen können auf Deklarationen (Klassen, Felder, Methoden) angewendet werden
-
Annotationen können relativ einfach selbst erstellt werden
- Definition fast wie ein Interface
- Einstellung der Sichtbarkeit und Verwendbarkeit und Dokumentation über Meta-Annotationen
-
Verarbeitung von Annotationen zur Compilier-Zeit mit Annotation-Processor
-
Verarbeitung von Annotationen zur Laufzeit mit Reflection (siehe spätere VL)
-
Reflection ist Thema einer späteren Vorlesung ↩︎
Schreiben Sie drei eigene Annotationen:
@MeineKlasse
darf nur an Klassendefinitionen stehen und speichert den Namen des Autoren ab.@MeineMethode
darf nur an Methoden stehen.@TODO
darf an Methoden und Klassen stehen, ist aber nur in den Source-Dateien sichtbar.
Implementieren Sie einen Annotation-Prozessor, welcher Ihren Quellcode nach der @MeineKlasse
-Annotation
durchsucht und dann den Namen der Klasse und den Namen des Autors ausgibt.
Zeigen Sie die Funktionen anhand einer Demo.
- [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Getting to Know the Language \> Annotations - [LernJava] Learn Java
Oracle Corporation, 2022. - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Kap. 10.8: Annotationen in der Java SE, 23.4: Dokumentationskommentare mit Javadoc
Reflection
Mit Hilfe der Reflection-API kann man Programme zur Laufzeit inspizieren
und Eigenschaften von Elementen wie Klassen oder Methoden abfragen, aber auch
Klassen instantiieren und Methoden aufrufen, die eigentlich auf private
gesetzt sind oder die beispielsweise mit einer bestimmten Annotation markiert
sind.
Die Laufzeitumgebung erzeugt zu jedem Typ ein Objekt der Klasse
java.lang.Class
. Über dieses Class
-Objekt einer Klasse können dann Informationen
über diese Klasse abgerufen werden, beispielsweise welche Konstruktoren,
Methoden und Attribute es gibt.
Man kann über auch Klassen zur Laufzeit nachladen, die zur Compile-Zeit nicht bekannt waren. Dies bietet sich beispielsweise für User-definierte Plugins an.
Reflection ist ein mächtiges Werkzeug. Durch das Arbeiten mit Strings und die Interaktion/Inspektion zur Laufzeit verliert man aber viele Prüfungen, die der Compiler normalerweise zur Compile-Zeit vornimmt. Auch das Refactoring wird dadurch eher schwierig.
- (K2) Probleme beim Einsatz von Reflection
- (K2) Bedeutung der verschiedenen Exceptions beim Aufruf von Methoden per Reflection
- (K3) Inspection von Klassen zur Laufzeit mit Reflection
- (K3) Einbindung von zur Compilezeit unbekannten Klassen, Aufruf von Konstruktoren und Methoden (mit und ohne Parameter/Rückgabewerte)
Ausgaben und Einblicke zur Laufzeit
public class FactoryBeispielTest {
@Test
public void testGetTicket() {
fail("not implemented");
}
}
@Target(value = ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
public @interface Wuppie {}
Reflection wird allgemein genutzt, um zur Laufzeit von Programmen Informationen über Klassen/Methoden/... zu bestimmen. Man könnte damit auch das Verhalten der laufenden Programme ändern oder Typen instantiieren und/oder Methoden aufrufen ...
Wenn Sie nicht (mehr) wissen, wie man eigene Annotationen definiert, schauen Sie doch bitte einfach kurz im Handout zu Annotationen nach :-)
Wer bin ich? ... Informationen über ein Programm (zur Laufzeit)
java.lang.Class
: Metadaten über Klassen
// usual way of life
Studi heiner = new Studi();
heiner.hello();
// let's use reflection
try {
Object eve = Studi.class.getDeclaredConstructor().newInstance();
Method m = Studi.class.getDeclaredMethod("hello");
m.invoke(eve);
} catch (ReflectiveOperationException ignored) {}
Für jeden Typ instantiiert die JVM eine nicht veränderbare Instanz
der Klasse java.lang.Class
, über die Informationen zu dem Typ
abgefragt werden können.
Dies umfasst u.a.:
- Klassenname
- Implementierte Interfaces
- Methoden
- Attribute
- Annotationen
- ...
java.lang.Class
bildet damit den Einstiegspunkt in die Reflection.
Vorgehen
-
Gewünschte Klasse über ein
Class
-Objekt laden -
Informationen abrufen (welche Methoden, welche Annotationen, ...)
-
Eine Instanz dieser Klasse erzeugen, und
-
Methoden aufrufen
Das Vorgehen umfasst vier Schritte: Zunächst die gewünschte Klasse über ein Class
-Objekt laden
und anschließend Informationen abrufen (etwa welche Methoden vorhanden sind, welche Annotationen
annotiert wurden, ...) und bei Bedarf eine Instanz dieser Klasse erzeugen sowie Methoden aufrufen.
Ein zweiter wichtiger Anwendungsfall (neben dem Abfragen von Informationen und Aufrufen von Methoden) ist das Laden von Klassen, die zur Compile-Zeit nicht mit dem eigentlichen Programm verbunden sind. Auf diesem Weg kann beispielsweise ein Bildbearbeitungsprogramm zur Laufzeit dynamisch Filter aus einem externen Ordner laden und nutzen, oder der Lexer kann die Tokendefinitionen zur Laufzeit einlesen (d.h. er könnte mit unterschiedlichen Tokensätzen arbeiten, die zur Compile-Zeit noch gar nicht definiert sind). Damit werden die Programme dynamischer.
Schritt 1: Class-Objekt erzeugen und Klasse laden
// Variante 1 (package.MyClass dynamisch zur Laufzeit laden)
Class<?> c = Class.forName("package.MyClass");
// Variante 2 (Objekt)
MyClass obj = new MyClass();
Class<?> c = obj.getClass();
// Variante 3 (Klasse)
Class<?> c = MyClass.class;
=> Einstiegspunkt der Reflection API
Eigentlich wird nur in Variante 1 die über den String angegebene Klasse
dynamisch von der Laufzeitumgebung (nach-) geladen (muss also im gestarteten
Programm nicht vorhanden sein). Die angegebene Klasse muss aber in Form von
Byte-Code an der angegebenen Stelle (Ordner package
, Dateiname MyClass.class
)
vorhanden sein.
Die anderen beiden Varianten setzen voraus, dass die jeweilige Klasse bereits
geladen ist (also ganz normal mit den restlichen Sourcen zu Byte-Code
(.class
-Dateien) kompiliert wurde und mit dem Programm geladen wurde).
Alle drei Varianten ermöglichen die Introspektion der jeweiligen Klassen zur Laufzeit.
Schritt 2: In die Klasse reinschauen
// Studi-Klasse dynamisch (nach-) laden
Class<?> c = Class.forName("reflection.Studi");
// Parametersatz für Methode zusammenbasteln
Class<?>[] paramT = new Class<?>[] { String.class };
// public Methode aus dem **Class**-Objekt holen
Method pubMethod = c.getMethod("setName", paramT);
// beliebige Methode aus dem **Class**-Objekt holen
Method privMethod = c.getDeclaredMethod("setName", paramT);
Method[] publicMethods = c.getMethods(); // all public methods (incl. inherited)
Method[] allMethods = c.getDeclaredMethods(); // all methods (excl. inherited)
public
Methode laden (auch von Superklasse/Interface geerbt):Class<?>.getMethod(String, Class<?>[])
- Beliebige (auch
private
) Methoden (in der Klasse selbst deklariert):Class<?>.getDeclaredMethod(...)
Anmerkung: Mit Class<?>.getDeclaredMethods()
erhalten Sie alle Methoden,
die direkt in der Klasse deklariert werden (ohne geerbte Methoden!), unabhängig
von deren Sichtbarkeit. Mit Class<?>.getMethods()
erhalten Sie dagegen alle
public
Methoden, die in der Klasse selbst oder ihren Superklassen bzw. den
implementierten Interfaces deklariert sind.
Vgl. Javadoc getMethods
und getDeclaredMethods
.
Die Methoden-Arrays können Sie nach bestimmten Eigenschaften durchsuchen, bzw. auf
das Vorhandensein einer bestimmten Annotation prüfen (etwa mit isAnnotationPresent()
)
etc.
Analog können Sie weitere Eigenschaften einer Klasse abfragen, beispielsweise Attribute
(Class<?>.getDeclaredFields()
) oder Konstruktoren (Class<?>.getDeclaredConstructors()
).
Schritt 3: Instanz der geladenen Klasse erzeugen
// Class-Objekt erzeugen
Class<?> c = Class.forName("reflection.Studi");
// Variante 1
Studi s = (Studi) c.newInstance();
// Variante 2
Constructor<?> ctor = c.getConstructor();
Studi s = (Studi) ctor.newInstance();
// Variante 3
Class<?>[] paramT = new Class<?>[] {String.class, int.class};
Constructor<?> ctor = c.getDeclaredConstructor(paramT);
Studi s = (Studi) ctor.newInstance("Beate", 42);
Parameterlose, öffentliche Konstruktoren:
Class<?>.newInstance()
(seit Java9 deprecated!)Class<?>.getConstructor()
=>Constructor<?>.newInstance()
Sonstige Konstruktoren:
Passenden Konstruktor explizit holen: Class<?>.getDeclaredConstructor(Class<?>[])
,
Parametersatz zusammenbasteln (hier nicht dargestellt)
und aufrufen Constructor<?>.newInstance(...)
Unterschied new und Constructor.newInstance():
new
ist nicht identisch zu Constructor.newInstance()
: new
kann Dinge wie Typ-Prüfung oder Auto-Boxing mit erledigen, während
man dies bei Constructor.newInstance()
selbst explizit angeben
oder erledigen muss.
Vgl. docs.oracle.com/javase/tutorial/reflect/member/ctorTrouble.html.
Schritt 4: Methoden aufrufen ...
// Studi-Klasse dynamisch (nach-) laden
Class<?> c = Class.forName("reflection.Studi");
// Studi-Objekt anlegen (Defaultkonstruktor)
Studi s = (Studi) c.newInstance();
// Parametersatz für Methode zusammenbasteln
Class<?>[] paramT = new Class<?>[] { String.class };
// Methode aus dem **Class**-Objekt holen
Method method = c.getMethod("setName", paramT);
// Methode auf dem **Studi**-Objekt aufrufen
method.invoke(s, "Holgi");
Die Reflection-API bietet neben dem reinen Zugriff auf (alle) Methoden noch viele weitere Möglichkeiten. Beispielsweise können Sie bei einer Methode nach der Anzahl der Parameter und deren Typ und Annotationen fragen etc. ... Schauen Sie am besten einmal selbst in die API hinein.
Hinweis: Klassen außerhalb des Classpath laden
File folder = new File("irgendwo");
URL[] ua = new URL[]{folder.toURI().toURL()};
URLClassLoader ucl = URLClassLoader.newInstance(ua);
Class<?> c1 = Class.forName("org.wuppie.Fluppie", true, ucl);
Class<?> c2 = ucl.loadClass("org.wuppie.Fluppie");
Mit Class.forName("reflection.Studi")
können Sie die Klasse Studi
im
Package reflection
laden. Dabei muss sich aber die entsprechende
.class
-Datei (samt der der Package-Struktur entsprechenden Ordnerstruktur
darüber) im Java-Classpath befinden!
Mit einem weiteren ClassLoader
können Sie auch aus Ordnern, die sich
nicht im Classpath befinden, .class
-Dateien laden. Dies geht dann entweder
wie vorher über Class.forName()
, wobei hier der neue Class-Loader als
Parameter mitgegeben wird, oder direkt über den neuen Class-Loader mit
dessen Methode loadClass()
.
Licht und Schatten
Nützlich:
- Erweiterbarkeit: Laden von "externen" (zur Kompilierzeit unbekannter) Klassen in eine Anwendung
- Klassen-Browser, Debugger und Test-Tools
Nachteile:
- Verlust von Kapselung, Compiler-Unterstützung und Refactoring
- Performance: Dynamisches Laden von Klassen etc.
- Sicherheitsprobleme/-restriktionen
Reflection ist ein nützliches Werkzeug. Aber: Gibt es eine Lösung ohne Reflection, wähle diese!
Wrap-Up
- Inspektion von Programmen zur Laufzeit: Reflection
java.lang.Class
: Metadaten über Klassen- Je Klasse ein
Class
-Objekt - Informationen über Konstruktoren, Methoden, Felder
- Anwendung: Laden und Ausführen von zur Compile-Zeit unbekanntem Code
- Vorsicht: Verlust von Refactoring und Compiler-Zusicherungen!
In den Vorgaben
finden Sie eine einfache Implementierung für einen Taschenrechner mit Java-Swing.
Dieser Taschenrechner kann nur mit int
-Werten rechnen.
Der Taschenrechner verfügt über keinerlei vordefinierte mathematische Operationen (Addieren, Subtrahieren etc.).
Erstellen Sie eigene mathematische Operationen, die IOperation
implementieren. Jede Ihrer Klassen soll mit einer Annotation vermerkt werden, in welcher der Name der jeweiligen Operation gespeichert wird.
Der Taschenrechner lädt seine Operationen dynamisch über die statische Methode OperationLoader.loadOperations
ein.
In den Vorgaben ist diese Methode noch nicht ausimplementiert. Implementieren Sie die Funktion so, dass sie mit Hilfe von Reflection Ihre Operationen einliest. Geben Sie dazu den Ordner an, in dem die entsprechenden .class
-Dateien liegen. (Dieser Ordner soll sich außerhalb Ihres Java-Projekts befinden!)
Verändern Sie nicht die Signatur der Methode.
Ihre Operation-Klassen dürfen Sie nicht vorher bekannt machen. Diese müssen in einem vom aktuellen Projekt separierten Ordner/Projekt liegen.
- [Inden2013] Der Weg zum Java-Profi
Inden, M., dpunkt.verlag, 2013. ISBN 978-3-8649-1245-0.
Reflection: Kapitel 8 - [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Specialized Trails and Lessons \> The Reflection API
Exception-Handling
Man unterscheidet in Java zwischen Exceptions und Errors. Ein Error ist ein Fehler im System (OS, JVM), von dem man sich nicht wieder erholen kann. Eine Exception ist ein Fehlerfall innerhalb des Programmes, auf den man innerhalb des Programms reagieren kann.
Mit Hilfe von Exceptions lassen sich Fehlerfälle im Programmablauf deklarieren und
behandeln. Methoden können/müssen mit dem Keyword throws
gefolgt vom Namen der
Exception deklarieren, dass sie im Fehlerfall diese spezifische Exception werfen
(und nicht selbst behandeln).
Zum Exception-Handling werden die Keywords try
, catch
und finally
verwendet.
Dabei wird im try
-Block der Code geschrieben, der einen potenziellen Fehler wirft.
Im catch
-Block wird das Verhalten implementiert, dass im Fehlerfall ausgeführt
werden soll, und im finally
-Block kann optional Code geschrieben werden, der sowohl
im Erfolgs- als auch Fehlerfall ausgeführt wird.
Es wird zwischen checked Exceptions und unchecked Exceptions unterschieden. Checked Exceptions sind für erwartbare Fehlerfälle gedacht, die nicht vom Programm ausgeschlossen werden können, wie das Fehlen einer Datei, die eingelesen werden soll. Checked Exceptions müssen deklariert oder behandelt werden. Dies wird vom Compiler überprüft.
Unchecked Exceptions werden für Fehler in der Programmlogik verwendet, etwa das Teilen
durch 0 oder Index-Fehler. Sie deuten auf fehlerhafte Programmierung, fehlerhafte Logik
oder beispielsweise mangelhafte Eingabeprüfung in. Unchecked Exceptions müssen nicht
deklariert oder behandelt werden. Unchecked Exceptions leiten von RuntimeException
ab.
Als Faustregel gilt: Wenn der Aufrufer sich von einer Exception-Situation erholen kann, sollte man eine checked Exception nutzen. Wenn der Aufrufer vermutlich nichts tun kann, um sich von dem Problem zu erholen, dann sollte man eine unchecked Exception einsetzen.
- (K2) Unterschied zwischen Error und Exception
- (K2) Unterschied zwischen checked und unchecked Exceptions
- (K3) Umgang mit Exceptions
- (K3) Eigene Exceptions schreiben
Fehlerfälle in Java
int div(int a, int b) {
return a / b;
}
div(3, 0);
Problem: Programm wird abstürzen, da durch '0' geteilt wird ...
Lösung?
Optional<Integer> div(int a, int b) {
if (b == 0) return Optional.empty();
return Optional.of(a / b);
}
Optional<Integer> x = div(3, 0);
if (x.isPresent()) {
// do something
} else {
// do something else
}
Probleme:
- Da
int
nichtnull
sein kann, muss einInteger
Objekt erzeugt und zurückgegeben werden: Overhead wg. Auto-Boxing und -Unboxing! - Der Aufrufer muss auf
null
prüfen. - Es wird nicht kommuniziert, warum
null
zurückgegeben wird. Was ist das Problem? - Was ist, wenn
null
ein gültiger Rückgabewert sein soll?
Vererbungsstruktur Throwable
Exception vs. Error
Error
:- Wird für Systemfehler verwendet (Betriebssystem, JVM, ...)
StackOverflowError
OutOfMemoryError
- Von einem Error kann man sich nicht erholen
- Sollten nicht behandelt werden
- Wird für Systemfehler verwendet (Betriebssystem, JVM, ...)
Exception
:- Ausnahmesituationen bei der Abarbeitung eines Programms
- Können "checked" oder "unchecked" sein
- Von Exceptions kann man sich erholen
Unchecked vs. Checked Exceptions
- "Checked" Exceptions:
- Für erwartbare Fehlerfälle, deren Ursprung nicht im Programm selbst liegt
FileNotFoundException
IOException
- Alle nicht von
RuntimeException
ableitende Exceptions - Müssen entweder behandelt (
try
/catch
) oder deklariert (throws
) werden: Dies wird vom Compiler überprüft!
- Für erwartbare Fehlerfälle, deren Ursprung nicht im Programm selbst liegt
- "Unchecked" Exceptions:
- Logische Programmierfehler ("Versagen" des Programmcodes)
IndexOutOfBoundException
NullPointerException
ArithmeticException
IllegalArgumentException
- Leiten von
RuntimeException
oder Unterklassen ab - Müssen nicht deklariert oder behandelt werden
- Logische Programmierfehler ("Versagen" des Programmcodes)
Beispiele checked Exception:
- Es soll eine Abfrage an eine externe API geschickt werden. Diese ist aber aktuell nicht zu erreichen. "Erholung": Anfrage noch einmal schicken.
- Es soll eine Datei geöffnet werden. Diese ist aber nicht unter dem angegebenen Pfad zu finden oder die Berechtigungen stimmen nicht. "Erholung": Aufrufer öffnet neuen File-Picker, um es noch einmal mit einer anderen Datei zu versuchen.
Beispiele unchecked Exception:
- Eine
for
-Loop über ein Array ist falsch programmiert und will auf einen Index im Array zugreifen, der nicht existiert. Hier kann der Aufrufer nicht Sinnvolles tun, um sich von dieser Situation zu erholen. - Argumente oder Rückgabewerte einer Methode können
null
sein. Wenn man das nicht prüft, sondern einfach Methoden auf dem vermeintlichen Objekt aufruft, wird eineNullPointerException
ausgelöst, die eine Unterklasse vonRuntimeException
ist und damit eine unchecked Exception. Auch hier handelt es sich um einen Fehler in der Programmlogik, von dem sich der Aufrufer nicht sinnvoll erholen kann.
Throws
int div(int a, int b) throws ArithmeticException {
return a / b;
}
Alternativ:
int div(int a, int b) throws IllegalArgumentException {
if (b == 0) throw new IllegalArgumentException("Can't divide by zero");
return a / b;
}
Exception können an an den Aufrufer weitergeleitet werden oder selbst geworfen werden.
Wenn wie im ersten Beispiel bei einer Operation eine Exception entsteht und nicht
gefangen wird, dann wird sie automatisch an den Aufrufer weitergeleitet. Dies wird
über die throws
-Klausel deutlich gemacht (Keyword throws
plus den/die Namen der
Exception(s), angefügt an die Methodensignatur). Bei unchecked Exceptions kann man
das tun, bei checked Exceptions muss man dies tun.
Wenn man wie im zweiten Beispiel selbst eine neue Exception werfen will, erzeugt man
mit new
ein neues Objekt der gewünschten Exception und "wirft" diese mit throw
.
Auch diese Exception kann man dann entweder selbst fangen und bearbeiten (siehe nächste
Folie) oder an den Aufrufer weiterleiten und dies dann entsprechend über die
throws
-Klausel deklarieren: nicht gefangene checked Exceptions müssen deklariert
werden, nicht gefangene unchecked Exceptions können deklariert werden.
Wenn mehrere Exceptions an den Aufrufer weitergeleitet werden, werden sie in der
throws
-Klausel mit Komma getrennt: throws Exception1, Exception2, Exception3
.
Anmerkung: In beiden obigen Beispielen wurde zur Verdeutlichung, dass die Methode
div()
eine Exception wirft, diese per throws
-Klausel deklariert. Da es sich bei
den beiden Beispielen aber jeweils um unchecked Exceptions handelt, ist dies im
obigen Beispiel nicht notwendig. Der Aufrufer muss auch nicht ein passendes
Exception-Handling einsetzen!
Wenn wir stattdessen eine checked Exception werfen würden oder in div()
eine
Methode aufrufen würden, die eine checked Exception deklariert hat, muss diese
checked Exception entweder in div()
gefangen und bearbeitet werden oder aber per
throws
-Klausel deklariert werden. Im letzteren Fall muss dann der Aufrufer analog
damit umgehen (fangen oder selbst auch deklarieren). Dies wird vom Compiler geprüft!
Try-Catch
int a = getUserInput();
int b = getUserInput();
try {
div(a, b);
} catch (IllegalArgumentException e) {
e.printStackTrace(); // Wird im Fehlerfall aufgerufen
}
// hier geht es normal weiter
- Im
try
Block wird der Code ausgeführt, der einen Fehler werfen könnte. - Mit
catch
kann eine Exception gefangen und imcatch
Block behandelt werden.
Anmerkung: Das bloße Ausgeben des Stacktrace via e.printStackTrace()
ist
noch kein sinnvolles Exception-Handling! Hier sollte auf die jeweilige Situation
eingegangen werden und versucht werden, den Fehler zu beheben oder dem Aufrufer
geeignet zu melden!
_Try_und mehrstufiges Catch
try {
someMethod(a, b, c);
} catch (IllegalArgumentException iae) {
iae.printStackTrace();
} catch (FileNotFoundException | NullPointerException e) {
e.printStackTrace();
}
Eine im try
-Block auftretende Exception wird der Reihe nach mit den catch
-Blöcken
gematcht (vergleichbar mit switch case
).
Wichtig: Dabei muss die Vererbungshierarchie beachtet werden. Die spezialisierteste
Klasse muss ganz oben stehen, die allgemeinste Klasse als letztes. Sonst wird eine
Exception u.U. zu früh in einem nicht dafür gedachten catch
-Zweig aufgefangen.
Wichtig: Wenn eine Exception nicht durch die catch
-Zweige aufgefangen wird, dann
wird sie an den Aufrufer weiter geleitet. Im Beispiel würde eine IOException
nicht durch
die catch
-Zweige gefangen (IllegalArgumentException
und NullPointerException
sind
im falschen Vererbungszweig, und FileNotFoundException
ist spezieller als IOException
)
und entsprechend an den Aufrufer weiter gereicht. Da es sich obendrein um eine checked
Exception handelt, müsste man diese per throws IOException
an der Methode deklarieren.
Finally
Scanner myScanner = new Scanner(System.in);
try {
return 5 / myScanner.nextInt();
} catch (InputMismatchException ime) {
ime.printStackTrace();
} finally {
// wird immer aufgerufen
myScanner.close();
}
Der finally
Block wird sowohl im Fehlerfall als auch im Normalfall aufgerufen. Dies
wird beispielsweise für Aufräumarbeiten genutzt, etwa zum Schließen von Verbindungen
oder Input-Streams.
Try-with-Resources
try (Scanner myScanner = new Scanner(System.in)) {
return 5 / myScanner.nextInt();
} catch (InputMismatchException ime) {
ime.printStackTrace();
}
Im try
-Statement können Ressourcen deklariert werden, die am Ende sicher geschlossen
werden. Diese Ressourcen müssen java.io.Closeable
implementieren.
Eigene Exceptions
// Checked Exception
public class MyCheckedException extends Exception {
public MyCheckedException(String errorMessage) {
super(errorMessage);
}
}
// Unchecked Exception
public class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String errorMessage) {
super(errorMessage);
}
}
Eigene Exceptions können durch Spezialisierung anderer Exception-Klassen realisiert
werden. Dabei kann man direkt von Exception
oder RuntimeException
ableiten oder
bei Bedarf von spezialisierteren Exception-Klassen.
Wenn die eigene Exception in der Vererbungshierarchie unter RuntimeException
steht,
handelt es sich um eine unchecked Exception, sonst um eine checked Exception.
In der Benutzung (werfen, fangen, deklarieren) verhalten sich eigene Exception-Klassen wie die Exceptions aus dem JDK.
Stilfrage: Wie viel Code im Try?
int getFirstLineAsInt(String pathToFile) {
FileReader fileReader = new FileReader(pathToFile);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String firstLine = bufferedReader.readLine();
return Integer.parseInt(firstLine);
}
Hier lassen sich verschiedene "Ausbaustufen" unterscheiden.
Handling an den Aufrufer übergeben
int getFirstLineAsIntV1(String pathToFile) throws FileNotFoundException, IOException {
FileReader fileReader = new FileReader(pathToFile);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String firstLine = bufferedReader.readLine();
return Integer.parseInt(firstLine);
}
Der Aufrufer hat den Pfad als String übergeben und ist vermutlich in der Lage, auf
Probleme mit dem Pfad sinnvoll zu reagieren. Also könnte man in der Methode selbst
auf ein try
/catch
verzichten und stattdessen die FileNotFoundException
(vom
FileReader
) und die IOException
(vom bufferedReader.readLine()
) per throws
deklarieren.
Anmerkung: Da FileNotFoundException
eine Spezialisierung von IOException
ist,
reicht es aus, lediglich die IOException
zu deklarieren.
Jede Exception einzeln fangen und bearbeiten
int getFirstLineAsIntV2(String pathToFile) {
FileReader fileReader = null;
try {
fileReader = new FileReader(pathToFile);
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace(); // Datei nicht gefunden
}
BufferedReader bufferedReader = new BufferedReader(fileReader);
String firstLine = null;
try {
firstLine = bufferedReader.readLine();
} catch (IOException ioe) {
ioe.printStackTrace(); // Datei kann nicht gelesen werden
}
try {
return Integer.parseInt(firstLine);
} catch (NumberFormatException nfe) {
nfe.printStackTrace(); // Das war wohl kein Integer
}
return 0;
}
In dieser Variante wird jede Operation, die eine Exception werfen kann, separat in
ein try
/catch
verpackt und jeweils separat auf den möglichen Fehler reagiert.
Dadurch kann man die Fehler sehr einfach dem jeweiligen Statement zuordnen.
Allerdings muss man nun mit Behelfsinitialisierungen arbeiten und der Code wird sehr in die Länge gezogen und man erkennt die eigentlichen funktionalen Zusammenhänge nur noch schwer.
Anmerkung: Das "Behandeln" der Exceptions ist im obigen Beispiel kein gutes Beispiel für das Behandeln von Exceptions. Einfach nur einen Stacktrace zu printen und weiter zu machen, als ob nichts passiert wäre, ist kein sinnvolles Exception-Handling. Wenn Sie solchen Code schreiben oder sehen, ist das ein Anzeichen, dass auf dieser Ebene nicht sinnvoll mit dem Fehler umgegangen werden kann und dass man ihn besser an den Aufrufer weiter reichen sollte (siehe nächste Folie).
Funktionaler Teil in gemeinsames Try und mehrstufiges Catch
int getFirstLineAsIntV3(String pathToFile) {
try {
FileReader fileReader = new FileReader(pathToFile);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String firstLine = bufferedReader.readLine();
return Integer.parseInt(firstLine);
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace(); // Datei nicht gefunden
} catch (IOException ioe) {
ioe.printStackTrace(); // Datei kann nicht gelesen werden
} catch (NumberFormatException nfe) {
nfe.printStackTrace(); // Das war wohl kein Integer
}
return 0;
}
Hier wurde der eigentliche funktionale Kern der Methode in ein gemeinsames try
/catch
verpackt und mit einem mehrstufigen catch
auf die einzelnen Fehler reagiert. Durch die
Art der Exceptions sieht man immer noch, wo der Fehler herkommt. Zusätzlich wird die
eigentliche Funktionalität so leichter erkennbar.
Anmerkung: Auch hier ist das gezeigte Exception-Handling kein gutes Beispiel. Entweder man macht hier sinnvollere Dinge, oder man überlässt dem Aufrufer die Reaktion auf den Fehler.
Stilfrage: Wo fange ich die Exception?
private static void methode1(int x) throws IOException {
JFileChooser fc = new JFileChooser();
fc.showDialog(null, "ok");
methode2(fc.getSelectedFile().toString(), x, x * 2);
}
private static void methode2(String path, int x, int y) throws IOException {
FileWriter fw = new FileWriter(path);
BufferedWriter bw = new BufferedWriter(fw);
bw.write("X:" + x + " Y: " + y);
}
public static void main(String... args) {
try {
methode1(42);
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
Prinzipiell steht es einem frei, wo man eine Exception fängt und behandelt. Wenn im
main()
eine nicht behandelte Exception auftritt (weiter nach oben geleitet wird),
wird das Programm mit einem Fehler beendet.
Letztlich scheint es eine gute Idee zu sein, eine Exception so nah wie möglich am Ursprung der Fehlerursache zu behandeln. Man sollte sich dabei die Frage stellen: Wo kann ich sinnvoll auf den Fehler reagieren?
Stilfrage: Wann checked, wann unchecked
"Checked" Exceptions
- Für erwartbare Fehlerfälle, deren Ursprung nicht im Programm selbst liegt
- Aufrufer kann sich von der Exception erholen
"Unchecked" Exceptions
- Logische Programmierfehler ("Versagen" des Programmcodes)
- Aufrufer kann sich von der Exception vermutlich nicht erholen
Vergleiche "Unchecked Exceptions — The Controversy".
Wrap-Up
-
Error
undException
: System vs. Programm -
Checked und unchecked Exceptions:
Exception
vs.RuntimeException
-
try
: Versuche Code auszuführen -
catch
: Verhalten im Fehlerfall -
finally
: Verhalten im Erfolgs- und Fehlerfall -
throw
: Wirft eine Exception -
throws
: Deklariert eine Exception an Methode -
Eigene Exceptions durch Ableiten von anderen Exceptions (werden je nach Vererbungshierarchie automatisch checked oder unchecked)
Betrachten Sie die Vorgaben.
Verbessern Sie das Exception-Handling
Im package better_try_catch
finden Sie die Klasse BetterTryCatchMain
, in der
verschiedene Methoden der Klasse MyFunctions
aufgerufen werden.
Erklären Sie, warum das dort implementierte Exception-Handling nicht gut ist und verbessern Sie es.
Checked vs. unckecked Exceptions
Erklären Sie den Unterschied zwischen checked und unchecked Exceptions.
Im Folgenden werden verschiedene Exceptions beschrieben. Erklären Sie, ob diese jeweils "checked" oder "unchecked" sein sollten.
IntNotBetweenException
soll geworfen werden, wenn ein Integer-Parameter nicht im definierten Wertebereich liegt.NoPicturesFoundException
soll geworfen werden, wenn in einem übergebenen Verzeichnis keine Bilddateien gefunden werden konnten.NotAPrimeNumberException
soll geworfen werden, wenn eine vom User eingegebene Zahl keine Primzahl ist.
Freigeben von Ressourcen
Im Package finally_resources
finden Sie die Klasse MyResource
.
Rufen Sie die Methode MyResource#doSomething
auf, im Anschluss müssen Sie immer
die Methode MyResource#close
aufrufen.
- Zeigen Sie den Aufruf mit
try-catch-finally
. - Verändern Sie die Vorgaben so, dass Sie den Aufruf mit der "try-with-resources"-Technik ausführen können.
Where to catch?
Erklären Sie, wann und wo eine Exception gefangen und bearbeitet werden sollte.
Im Package where_to_catch
finden Sie die Klasse JustThrow
. Alle Methoden in der Klasse
werfen aufkommende Exceptions bis zur main
hoch.
Verändern Sie die Vorgaben so, dass die Exceptions an den passenden Stellen gefangen und sinnvoll bearbeitet werden. Begründen Sie Ihre Entscheidungen.
- [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Trail: Essential Java Classes, Lesson: Exceptions - [LernJava] Learn Java
Oracle Corporation, 2022.
Tutorials \> Exceptions - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Kap. 8: Ausnahmen müssen sein
Aufzählungen (Enumerations)
Mit Hilfe von enum
lassen sich Aufzählungstypen definieren (der Compiler erzeugt intern
passende Klassen). Dabei wird den Konstanten eine fortlaufende Nummer zugeordnet, auf die
mit ordinal()
zugegriffen werden kann. Mit der Methode values()
kann über die Konstanten
iteriert werden, und mit name()
kann eine Stringrepräsentation einer Konstanten erzeugt
werden. Es sind keine Instanzen von Enum-Klassen erzeugbar, und die Enum-Konstanten sind
implizit final
und static
.
Es lassen sich auch komplexe Enumerations analog zu Klassendefinition definieren, die eigene Konstruktoren, Felder und Methoden enthalten.
- (K2) Vorgänge beim Initialisieren von Enum-Klassen (Hinweis:
static
) - (K3) Erstellung komplexer Enumerationen mit Feldern und Konstruktoren
- (K3) Nutzung von
name()
,ordinal()
undvalues()
in Enum-Klassen
Motivation
public class Studi {
public static final int IFM = 0;
public static final int ELM = 1;
public static final int ARC = 2;
public Studi(String name, int credits, int studiengang) {
// Wert für studiengang muss zwischen 0 und 2 liegen
// Erwünscht: Konstanten nutzen
}
public static void main(String[] args) {
Studi rainer = new Studi("Rainer", 10, Studi.IFM);
Studi holger = new Studi("Holger", 3, 4); // Laufzeit-Problem!
}
}
Probleme:
- Keine Typsicherheit
- Konstanten gehören zur Klasse
Studi
, obwohl sie in anderem Kontext vermutlich auch interessant sind
Verbesserung: Einfache Aufzählung
public enum Fach {
IFM, ELM, ARC
}
public class Studi {
public Studi(String name, int credits, Fach studiengang) {
// Typsicherheit für studiengang :-)
}
public static void main(String[] args) {
Studi rainer = new Studi("Rainer", 10, Fach.IFM);
Studi holger = new Studi("Holger", 3, 4); // Syntax-Fehler!
}
}
Einfache Aufzählungen: Eigenschaften
public enum Fach {
IFM, ELM, ARC
}
- Enum-Konstanten (
IFM
, ...) sind implizitstatic
undfinal
- Enumerations (
Fach
) nicht instantiierbar - Enumerations stellen einen neuen Typ dar: hier der Typ
Fach
- Methoden:
name()
,ordinal()
,values()
,toString()
Wiederholung static
Attribute:
static
Attribute sind Eigenschaften/Zustände der Klasse- Gelten in jedem von der Klasse erzeugten Objekt
- Unterschiedliche Lebensdauer:
- Objektattribute (Instanzvariablen): ab
new
bis zum Garbage Collector - Statische Variablen: Laufzeitumgebung (JVM) lädt und initialisiert die Klasse
(
static
Attribute existieren, bis die JVM die Klasse entfernt)
- Objektattribute (Instanzvariablen): ab
Methoden:
static
deklarierte Methoden sind Klassenmethoden- Können direkt auf der Klasse aufgerufen werden
- Beispiele:
Math.max()
,Math.sin()
,Integer.parseInt()
- Achtung: In Klassenmethoden nur Klassenattribute nutzbar (keine Instanzattribute!),
d.h. keine
this
-Referenz nutzbar
Wiederholung final: Attribute/Methoden/Klassen nicht änderbar
-
Attribute:
final
Attribute können nur einmal gesetzt werdenvoid foo() { int i = 2; final int j = 3; final int k; i = 3; j = 4; // Compilerfehler k = 5; k = 6; // Compilerfehler }
-
Methoden:
final
deklarierte Methoden können bei Vererbung nicht überschrieben werden -
Klassen: von
final
deklarierten Klassen können keine Unterklassen gebildet werden
Einfache Aufzählungen: Eigenschaften (cnt.)
// Referenzen auf Enum-Objekte können null sein
Fach f = null;
f = Fach.IFM;
// Vergleich mit == möglich
// equals() unnötig, da Vergleich mit Referenz auf statische Variable
if (f == Fach.IFM) {
System.out.println("Richtiges Fach :-)");
}
// switch/case
switch (f) {
case IFM: // Achtung: *NICHT* Fach.IFM
System.out.println("Richtiges Fach :-)");
break;
default:
throw new IllegalArgumentException("FALSCHES FACH: " + f);
}
Außerdem können wir folgende Eigenschaften nutzen (u.a., s.u.):
- Enumerations haben Methode
String toString()
für die Konstanten - Enumerations haben Methode
final T[] values()
für die Iteration über die Konstanten
Enum: Genauer betrachtet
public enum Fach { IFM, ELM, ARC }
Compiler sieht (in etwa):
public class Fach extends Enum {
public static final Fach IFM = new Fach("IFM", 0);
public static final Fach ELM = new Fach("ELM", 1);
public static final Fach ARC = new Fach("ARC", 2);
private Fach( String s, int i ) { super( s, i ); }
}
=> Singleton-Pattern für Konstanten
Enum-Klassen: Eigenschaften
public enum Fach {
IFM,
ELM("Elektrotechnik Praxisintegriert", 1, 30),
ARC("Architektur", 4, 40),
PHY("Physik", 3, 10);
private final String description;
private final int number;
private final int capacity;
Fach() { this("Informatik Bachelor", 0, 60); }
Fach(String descr, int number, int capacity) {
this.description = descr; this.number = number; this.capacity = capacity;
}
public String getDescription() {
return "Konstante: " + name() + " (Beschreibung: " + description
+ ", Kapazitaet: " + capacity + ", Nummer: " + number
+ ", Ordinal: " + ordinal() + ")";
}
}
Konstruktoren und Methoden für Enum-Klassen definierbar
- Kein eigener Aufruf von
super
(!) - Konstruktoren implizit
private
Compiler fügt automatisch folgende Methoden hinzu (Auswahl):
- Strings:
public final String name()
=> Name der Konstanten (final
!)public String toString()
=> Ruftname()
auf, überschreibbar
- Konstanten:
public final T[] values()
=> Alle Konstanten der Aufzählungpublic final int ordinal()
=> Interne Nummer der Konstanten (Reihenfolge des Anlegens der Konstanten!)public static T valueOf(String)
=> Zum String passende Konstante (vianame()
)
Hinweis: Diese Methoden gibt es auch bei den "einfachen" Enumerationen (s.o.).
Wrap-Up
-
Aufzählungen mit Hilfe von
enum
(Compiler erzeugt intern Klassen) -
Komplexe Enumerations analog zu Klassendefinition: Konstruktoren, Felder und Methoden (keine Instanzen von Enum-Klassen erzeugbar)
-
Enum-Konstanten sind implizit
final
undstatic
-
Compiler stellt Methoden
name()
,ordinal()
undvalues()
zur Verfügung- Name der Konstanten
- Interne Nummer der Konstanten (Reihenfolge des Anlegens)
- Array mit allen Konstanten der Enum-Klasse
- [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Trail: Learning the Java Language :: Classes and Objects :: Enum Types - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Abschnitt 6.4.3: Aufzählungstypen, Abschnitt 10.7: Die Spezial-Oberklasse Enum
Konfiguration eines Programms
Zu Konfiguration von Programmen kann man beim Aufruf Kommandozeilenparameter mitgeben. Diese
sind in der über den ParameterString[] args
in der main(String[] args)
-Methode zugreifbar.
Es gibt oft eine Kurzversion ("-x") und/oder eine Langversion ("--breite"). Zusätzlich können Parameter noch ein Argument haben ("-x 12" oder "--breite=12"). Parameter können optional oder verpflichtend sein.
Um dies nicht manuell auswerten zu müssen, kann man beispielsweise die Bibliothkek Apache Commons CLI benutzen.
Ein anderer Weg zur Konfiguration sind Konfigurationsdateien, die man entsprechend einliest.
Hier findet man häufig das "Ini-Format", also zeilenweise "Key=Value"-Paare. Diese kann man
mit der Klasse java.util.Properties
einlesen, bearbeiten und speichern (auch als XML).
- (K3) Auswertung von Kommandozeilenparametern in einem Programm
- (K3) Apache Commons CLI zur Verarbeitung von Kommandozeilenparametern
- (K3) Laden von Konfigurationsdaten mit
java.util.Properties
Wie kann man Programme konfigurieren?
-
Parameter beim Start mitgeben: Kommandozeilenparameter (CLI)
-
Konfigurationsdatei einlesen und auswerten
Varianten von Kommandozeilenparameter
-
Fixe Reihenfolge
java MyApp 10 20 hello debug
-
Benannte Parameter I
java MyApp -x 10 -y 20 -answer hello -d
-
Benannte Parameter II
java MyApp --breite=10 --hoehe=20 --answer=hello --debug
Häufig Mischung von Kurz- und Langformen
Häufig hat man eine Kurzform der Optionen, also etwa "-x". Dabei ist der Name der Option
in der Regel ein Zeichen lang. Es gibt aber auch Abweichungen von dieser Konvention, denken
Sie beispielsweise an java -version
.
In der Langform nutzt man dann einen aussagekräftigen Namen und stellt zwei Bindestriche voran, also beispielsweise "--breite" (als Alternative für "-x").
Wenn Optionen Parameter haben, schreibt man in der Kurzform üblicherweise "-x 10" (trennt also den Parameter mit einem Leerzeichen von der Option) und in der Langform "--breite=10" (also mit einem "=" zwischen Option und Parameter). Das sind ebenfalls Konventionen, d.h. man kann prinzipiell auch in der Kurzform das "=" nutzen, also "-x=10", oder in der Langform mit einem Leerzeichen trennen, also "--breite 10".
Hinweis IntelliJ: "Edit Configurations
" => Kommandozeilenparameter unter "Build and run
" im entsprechenden Feld eintragen
Auswertung Kommandozeilenparameter
-
Kommandozeilenparameter werden als String-Array an
main()
-Methode übergeben:public static void main(String[] args) { } public static void main(String... argv) { }
=> Müssen "händisch" ausgewertet werden
Anmerkung: Nur Parameter! Nicht Programmname als erster Eintrag wie in C ...
Beispiel Auswertung Kommandozeilenparameter
public static void main(String[] args) {
int x = 100;
String answer = "";
boolean debug = false;
// Parameter: -x=10 -answer=hello -debug
// => args = ["-x=10", "-answer=hello", "-debug"]
for (String param : args) {
if (param.startsWith("-x")) { x = Integer.parseInt(param.substring(3)); }
if (param.startsWith("-a")) { answer = param.substring(8); }
if (param.startsWith("-d")) { debug = true; }
}
}
Kritik an manueller Auswertung Kommandozeilenparameter
- Umständlich und unübersichtlich
- Große
if-else
-Gebilde inmain()
- Kurz- und Langform müssen getrennt realisiert werden
- Optionale Parameter müssen anders geprüft werden als Pflichtparameter
- Überlappende Parameternamen schwer aufzufinden
- Prüfung auf korrekten Typ nötig bei Parametern mit Werten
- Hilfe bei Fehlern muss separat realisiert und gepflegt werden
Apache Commons: CLI
Rad nicht neu erfinden!
- Apache Commons bietet die CLI-Bibliothek zum Umgang mit Kommandozeilenparametern an: commons.apache.org/cli
Annäherung an fremde API:
- Lesen der verfügbaren Doku (PDF, HTML)
- Lesen der verfügbaren Javadoc
- Herunterladen der Bibliothek
- Einbinden ins Projekt
Exkurs: Einbinden fremder Bibliotheken/APIs
Eclipse
- Lib von commons.apache.org herunterladen und auspacken
- Neuen Unterordner im Projekt anlegen:
libs/
- Bibliothek (
.jar
-Files) hinein kopieren - Projektexplorer, Kontextmenü auf
.jar
-File: "Add as Library
" - Alternativ Menü-Leiste: "
Project > Properties > Java Build Path > Libraries > Add JARs
"
IntelliJ
- Variante 1:
- Lib von commons.apache.org herunterladen und auspacken
- Neuen Unterordner im Projekt anlegen:
libs/
- Bibliothek (
.jar
-Files) hinein kopieren - Variante 1 (a):Projektexplorer, Kontextmenü auf
.jar
-File: "Build Path > Add to Build Path
" - Variante 1 (b): Projekteigenschaften, Eintrag "Libraries", "+", "New Project Library", "Java" und Jar-File auswählen
- Variante 2:
- Projekteigenschaften, Eintrag "Libraries", "+", "New Project Library", "From Maven" und "commons-cli:commons-cli:1.5.0" als Suchstring eingeben und die Suche abschließen
Gradle oder Ant oder Maven
- Lib auf Maven Central suchen: "commons-cli:commons-cli" als Suchstring eingeben
- Passenden Dependency-Eintrag in das Build-Skript kopieren
Kommandozeilenaufruf
-
Class-Path bei Aufruf setzen:
- Unix:
java -cp .:<jarfile>:<jarfile> <mainclass>
- Windows:
java -cp .;<jarfile>;<jarfile> <mainclass>
Achtung: Unter Unix (Linux, MacOS) wird ein Doppelpunkt zum Trennen der Jar-Files eingesetzt, unter Windows ein Semikolon!
- Unix:
Beispiel: java -classpath .:/home/user/wuppy.jar MyApp
Vorgriff auf Build-Skripte (spätere VL): Im hier gezeigten Vorgehen werden die Abhängigkeiten manuell aufgelöst, d.h. die Jar-Files werden manuell heruntergeladen (oder selbst kompiliert) und dem Projekt hinzugefügt.
Alle später besprochenen Build-Skripte (Ant, Gradle) beherrschen die automatische Auflösung von Abhängigkeiten. Dazu muss im Skript die Abhängigkeit auf geeignete Weise beschrieben werden und wird dann beim Kompilieren des Programms automatisch von spezialisierten Servern in der im Skript definierten Version heruntergeladen. Dies funktioniert auch bei rekursiven Abhängigkeiten ...
Überblick Umgang mit Apache Commons CLI
Paket: org.apache.commons.cli
- Definition der Optionen
- Je Option eine Instanz der Klasse
Option
- Alle Optionen in Container
Options
sammeln
- Je Option eine Instanz der Klasse
- Parsen der Eingaben mit
DefaultParser
- Abfragen der Ergebnisse:
CommandLine
- Formatierte Hilfe ausgeben:
HelpFormatter
Die Funktionsweise der einzelnen Klassen wird in der Demo kurz angerissen. Schauen Sie bitte zusätzlich in die Dokumentation.
Laden und Speichern von Konfigurationsdaten
#ola - ein Kommentar
hoehe=2
breite=9
gewicht=12
-
Konfigurationsdaten sind i.d.R. Schlüssel-Wert-Paare (
String
/String
)=>
java.util.Properties
Tatsächlich verbirgt sich ein
Hashtable
dahinter:public class Properties extends Hashtable<Object,Object>;
Laden und Speichern von Konfigurationsdaten (cnt.)
-
Properties anlegen und modifizieren
Properties props = new Properties(); props.setProperty("breite", "9"); props.setProperty("breite", "99"); String value = props.getProperty("breite");
-
Properties speichern:
Properties#store
undProperties#storeToXML
public void store(Writer writer, String comments) public void store(OutputStream out, String comments) public void storeToXML(OutputStream os, String comment, String encoding)
-
Properties laden:
Properties#load
undProperties#loadFromXML
public void load(Reader reader) public void load(InputStream inStream) public void loadFromXML(InputStream in)
java.util.Properties
sind eine einfache und im JDK bereits eingebaute Möglichkeit,
mit Konfigurationsdateien zu hantieren. Deutlich umfangreichere Möglichkeiten bieten
aber externe Bibliotheken, beispielsweise "Apache Commons Configuration"
(commons.apache.org/configuration).
Wrap-Up
- Kommandozeilenparameter als
String[]
inmain()
-Methode - Manuelle Auswertung komplex => Apache Commons CLI
- Schlüssel-Wert-Paare mit
java.util.Properties
aus/in Dateien laden/speichern
- [Java-SE-Tutorial] The Java Tutorials
Oracle Corporation, 2022.
Essential Java Classes \> The Platform Environment \> Configuration Utilities