Subsections of Fortgeschrittene Java-Themen und Umgang mit JVM

Serialisierung von Objekten und Zuständen

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (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 bei Serializable 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 und transient, 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:

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 und static Attribute werden nicht serialisiert
    • De-Serialisierung: KEIN Konstruktor-Aufruf!
    • Serialisierbarkeit vererbt sich
    • Objekt-Referenz-Graph wird automatisch beachtet
Challenges

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.

Quellen
  • [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

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Was ist der Unterschied zwischen Collection<T> und List<T>?
  • (K2) Was ist der Unterschied zwischen einer List<T>, einer Queue<T> und einer Set<T>?
  • (K2) Nennen Sie charakteristische Merkmale von ArrayList<T>, LinkedList<T> und Vector<T>.
  • (K2) Was ist der Unterschied zwischen einer Queue<T> und einem Stack<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() und compareTo() 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 einer ArrayList<T>
    • Das Array eines Vector wird jedoch verdoppelt, wenn es vergrößert wird
    • Die Methoden von Vector<T> sind synchronized
  • Stack<T>:

    • Schnittstelle: "last in first out"-Prinzip
      • push(T): Pushe Element oben auf den Stack
      • pop(): T: Hole oberstes Element vom Stack
    • Tatsächlich aber: class Stack<E> extends Vector<E>

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 einer HashMap 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 einer HashMap<K,V>
  • Hashtable<K,V>-Methoden sind synchronized
  • 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):

  1. Reflexivität: x.equals(x) == true
  2. Symmetrie: x.equals(y) == y.equals(x)
  3. Transitivität: Wenn x.equals(y) == true und y.equals(z) == true, dann auch x.equals(z) == true
  4. Konsistenz: Mehrfache Aufrufe von equals() mit den selben Werten müssen immer das selbe Ergebnis liefern
  5. 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:

  1. x.compareTo(y) < 0 wenn x "kleiner" als y ist
  2. x.compareTo(y) > 0 wenn x "größer" als y ist
  3. x.compareTo(y) = 0 wenn x "gleich" als y ist
  4. Symmetrie: signum(x.compareTo(y)) == -signum(y.compareTo(x))
  5. Transitivität: Wenn x.compareTo(y) > 0 und y.compareTo(z) > 0, dann auch x.compareTo(z) > 0
  6. Wenn x.compareTo(y) == 0, dann auch signum(x.compareTo(z)) == signum(y.compareTo(z))

Der equals()-hashCode()-compareTo()-Vertrag

Wird equals() überschrieben, sollte auch hashCode() (passend) überschrieben werden.

  1. Wenn x.equals(y) == true, dann muss auch x.hashCode() == y.hashCode()

  2. Wenn x.equals(y) == false, sollte x.hashCode() != y.hashCode() sein (Unterschiedliche hashCode()-Werte für unterschiedliche Objekte verbessern allerdings die Leistung von Hash-Berechnungen, etwa in einer HashMap<K,V>!)

  3. Es wird sehr empfohlen, dass equals() und compareTo() 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 mit Collection<T> und Map<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 auf Collection<T>s)
  • Iterable<T> liefert einen Iterator<T> zur Iteration über eine Collection<T>
  • Interface Map<K,V>: Speichern von Key/Value-Paaren
  • equals()-hashCode()-compareTo()-Vertrag beachten
Quellen
  • [LernJava] Learn Java
    Oracle Corporation, 2022.
    Tutorials \> Mastering the API \> The Collections Framework

Reguläre Ausdrücke

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (K1) Wichtigste Methoden von java.util.regex.Pattern und java.util.regex.Matcher
  • (K2) Unterschied zwischen Matcher#find und Matcher#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
  • 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: erfolgreich
    • Matcher#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

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 und java.util.regex.Matcher
  • Unterschied zwischen Matcher#find und Matcher#matches!
  • Quantifizierung ist möglich, aber greedy (Default)
Challenges

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.

Quellen
  • [Java-SE-Tutorial] The Java Tutorials
    Oracle Corporation, 2022.
    Essential Java Classes \> Regular Expressions

Annotationen

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (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
    • ...

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 eingebettet
  • RetentionPolicy.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 Konstruktoren
  • ElementType.METHOD: nur Methoden
  • ElementType.FIELD: nur statische Variablen und Objektvariablen
  • ElementType.PARAMETER: nur Parametervariablen
  • ElementType.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;
    }
}
  1. Der Annotation-Processor sollte von AbstractProcessor ableiten
  2. Ü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
  3. Mit @SupportedSourceVersion wird die (höchste) unterstützte Java-Version angegeben (neuere Versionen führen zu einer Warnung)
  4. 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 ...
  5. 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)
    • ...
  • 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)


  1. Reflection ist Thema einer späteren Vorlesung ↩︎

Challenges

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.

Quellen

Reflection

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (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

  1. Gewünschte Klasse über ein Class-Objekt laden

  2. Informationen abrufen (welche Methoden, welche Annotationen, ...)

  3. Eine Instanz dieser Klasse erzeugen, und

  4. 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!
Challenges

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.

Quellen
  • [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

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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 nicht null sein kann, muss ein Integer 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
  • 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!
  • "Unchecked" Exceptions:
    • Logische Programmierfehler ("Versagen" des Programmcodes)
      • IndexOutOfBoundException
      • NullPointerException
      • ArithmeticException
      • IllegalArgumentException
    • Leiten von RuntimeException oder Unterklassen ab
    • Müssen nicht deklariert oder behandelt werden

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 eine NullPointerException ausgelöst, die eine Unterklasse von RuntimeException 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 im catch 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 und Exception: 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)

Challenges

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.

  1. Zeigen Sie den Aufruf mit try-catch-finally.
  2. 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.

Quellen

Aufzählungen (Enumerations)

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Vorgänge beim Initialisieren von Enum-Klassen (Hinweis: static)
  • (K3) Erstellung komplexer Enumerationen mit Feldern und Konstruktoren
  • (K3) Nutzung von name(), ordinal() und values() 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
}
  1. Enum-Konstanten (IFM, ...) sind implizit static und final
  2. Enumerations (Fach) nicht instantiierbar
  3. Enumerations stellen einen neuen Typ dar: hier der Typ Fach
  4. 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)

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 werden

    void 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() => Ruft name() auf, überschreibbar
  • Konstanten:
    • public final T[] values() => Alle Konstanten der Aufzählung
    • public final int ordinal() => Interne Nummer der Konstanten (Reihenfolge des Anlegens der Konstanten!)
    • public static T valueOf(String) => Zum String passende Konstante (via name())

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 und static

  • Compiler stellt Methoden name(), ordinal() und values() zur Verfügung

    • Name der Konstanten
    • Interne Nummer der Konstanten (Reihenfolge des Anlegens)
    • Array mit allen Konstanten der Enum-Klasse
Quellen
  • [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

TL;DR

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).

Videos (HSBI-Medienportal)
Lernziele
  • (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?

  1. Parameter beim Start mitgeben: Kommandozeilenparameter (CLI)

  2. 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 in main()
  • 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!

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!

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

  1. Definition der Optionen
    • Je Option eine Instanz der Klasse Option
    • Alle Optionen in Container Options sammeln
  2. Parsen der Eingaben mit DefaultParser
  3. Abfragen der Ergebnisse: CommandLine
  4. 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 und Properties#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 und Properties#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[] in main()-Methode
  • Manuelle Auswertung komplex => Apache Commons CLI
  • Schlüssel-Wert-Paare mit java.util.Properties aus/in Dateien laden/speichern
Quellen
  • [Java-SE-Tutorial] The Java Tutorials
    Oracle Corporation, 2022.
    Essential Java Classes \> The Platform Environment \> Configuration Utilities