IFM 2.1: Programmieren 2 (PO23, Sommer 2024)

... And, lastly, there's the explosive growth in demand, which has led to many people doing it who aren't any good at it. Code is merely a means to an end. Programming is an art and code is merely its medium. Pointing a camera at a subject does not make one a proper photographer. There are a lot of self-described coders out there who couldn't program their way out of a paper bag.

-- John Gruber auf daringfireball.net

Kursbeschreibung

Sie haben letztes Semester in Prog1 die wichtigsten Elemente und Konzepte der Programmiersprache Java kennen gelernt.

In diesem Modul geht es darum, diese Kenntnisse sowohl auf der Java- als auch auf der Methoden-Seite so zu erweitern, dass Sie gemeinsam größere Anwendungen erstellen und pflegen können. Sie werden fortgeschrittene Konzepte in Java kennenlernen und sich mit etablierten Methoden in der Softwareentwicklung wie Versionierung von Code, Einhaltung von Coding Conventions, Grundlagen des Softwaretests, Anwendung von Refactoring, Einsatz von Build-Tools und Logging auseinander setzen. Wenn uns dabei ein Entwurfsmuster "über den Weg läuft", werden wir die Gelegenheit nutzen und uns dieses genauer anschauen.

Überblick Modulinhalte

  1. Fortgeschrittene Konzepte in Java
    • Funktionale Programmierung: Default-Methoden, Funktionsinterfaces, Methodenreferenzen, Lambdas, Stream-API
    • Generische Programmierung: Generics
    • Parallele Programmierung: Threads
    • Reguläre Ausdrücke, Annotationen, Reflection
    • CLI, Konfiguration, fremde APIs nutzen
    • Graphische Oberflächen mit Swing
  2. Fortgeschrittenes OO-Design
    • Entwurfsmuster: Strategy, Template-Method, Factory-Method, Singleton, Observer, Visitor, Command, ...
  3. Programmiermethoden
    • Versionskontrolle: Git
    • Testen, Coding Conventions, Refactoring
    • Logging, Build-Tools, CI

(durchgestrichene Themen nicht im Sommersemester 2024)

Team

  • Carsten Gips (Sprechstunde nach Vereinbarung)
  • Tutoren (siehe ILIAS-Mitgliederliste)

Kursformat

Vorlesung (2 SWS)

Mi, 08:00 - 09:30 Uhr (online)

Durchführung als Flipped Classroom.

Praktikum (2 SWS)

Praktikumsgruppe Zeit Raum
Gruppe 1 Mi, 11:30 - 13:00 Uhr online
Gruppe 2 Mi, 09:45 - 11:15 Uhr D320
Gruppe 3 Mi, 09:45 - 11:15 Uhr online
Gruppe 4 Mi, 11:30 - 13:00 Uhr D320

Praktika Gruppen 2 und 4 in Präsenz.

Online-Sitzungen per Zoom (Zugangsdaten siehe ILIAS). Sie können hierzu den Raum J104 nutzen.

Prüfungsform, Note und Credits

Parcoursprüfung, 5 ECTS (PO23)

  • Stationen:
    • Praktikum: 10x Übungsblätter mit jeweils 1P (Einzelbearbeitung, mindestens 60% bearbeitet, fristgerechte Abgabe der Lösungen im ILIAS, Vorstellung der Lösungen im Praktikum => Punkte)
    • Schriftliche Prüfung (digitale Klausur) mit 100P; Prüfungsvorbereitung
  • Gesamtnote: 4.0: ab 50P, alle 5P nächste Teilnote, 1.0: ab 95P
  • Stationen:
  • Gesamtnote: 4.0: ab 50P, alle 5P nächste Teilnote, 1.0: ab 95P

Materialien

Literatur

  1. "Java ist auch eine Insel". Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
  2. "Pro Git (Second Edition)". Chacon, S. und Straub, B., Apress, 2014. ISBN 978-1-4842-0077-3.
  3. "The Java Tutorials". Oracle Corporation, 2023.
  4. "Learn Java". Oracle Corporation, 2023.

Tools

Fahrplan

News

Die nächste Klausur für "Programmieren 2" (IFM 2.1, PO23) wird am Mittwoch, 02. Oktober 2024 angeboten. Die Klausur wird als digitale Klausur auf dem Prüfungs-ILIAS der HSBI in Präsenz vor Ort in Minden im Raum B40 durchgeführt. Die Prüfung beginnt um 08:00 Uhr und dauert 90 Minuten. Ein DIN-A4-Zettel ist als Hilfsmittel zugelassen. Der geprüfte Stoff bezieht sich auf den zuletzt durchgeführten Kurs (Sommer 2024). Weitere Informationen siehe Prüfungsvorbereitung.

Hier finden Sie einen abonnierbaren Google Kalender mit allen Terminen der Veranstaltung zum Einbinden in Ihre Kalender-App.

Bitte geben Sie uns Feedback: Nehmen Sie bitte an der anonymen Umfrage zu "Programmieren 2" teil.

Monat Tag Vorlesung Praktikum
April 17. Orga (Zoom), FAQ
24. Generics: Klassen und Methoden, Bounds und Wildcards, Type Erasure, Polymorphie; Gradle B01
Mai 01. Mai-Feiertag Mai-Feiertag
08. Einführung Versionierung, Git Basics; Lambda-Ausdrücke; Javadoc B02
15. Dienstbesprechung B03
22. Git-Branches, Branching-Strategien; Methodenreferenzen; Logging B04
29. Git-Remotes, Git-Workflows; Stream-API; Record-Klassen; Intro Dungeon B05
Juni 05. Einführung Testen, JUnit-Basics; Optional; Visitor-Pattern B06
12. Testfallermittlung, Mocking; Default-Methoden; Observer-Pattern; Serialisierung B07
19. Code-Smells, Coding-Rules, Refactoring; Continuous Integration (CI) B08
26. RegExp; Template-Method-Pattern, Command-Pattern; Enumerationen B09
Juli 03. Intro Threads, Synchronisierung, Highlevel Threadkonzepte; Konfiguration B10
10. Rückblick (Zoom), Prüfungsvorbereitung
tbd Klausur (Campus Minden, B40)

Abgabe der Übungsblätter jeweils bis 08:00 Uhr im ILIAS.

Förderungen und Kooperationen

Förderung durch DH.NRW (Digi Fellowships)

Die Überarbeitung dieser Lehrveranstaltung wurde vom Ministerium für Kultur und Wissenschaft (MKW) in NRW im Einvernehmen mit der Digitalen Hochschule NRW (DH.NRW) gefördert: "Fellowships für Innovationen in der digitalen Hochschulbildung" (Digi Fellowships).

Kooperation mit dem DigikoS-Projekt

Diese Vorlesung wird zudem vom Projekt "Digitalbaukasten für kompetenzorientiertes Selbststudium" (DigikoS) unterstützt. Ein vom DigikoS-Projekt ausgebildeter Digital Learning Scout hat insbesondere die Koordination der digitalen Gruppenarbeiten, des Peer-Feedbacks und der Postersessions in ILIAS technisch und inhaltlich begleitet. DigikoS wird als Verbundprojekt von der Stiftung Innovation in der Hochschullehre gefördert.

Subsections of IFM 2.1: Programmieren 2 (PO23, Sommer 2024)

Subsections of Generics: Umgang mit parametrisierten Typen

Generische Klassen & Methoden

TL;DR

Generische Klassen und Methoden sind ein wichtiger Baustein in der Programmierung mit Java. Dabei werden Typ-Variablen eingeführt, die dann bei der Instantiierung der generischen Klassen oder beim Aufruf von generischen Methoden mit existierenden Typen konkretisiert werden ("Typ-Parameter").

Syntaktisch definiert man die Typ-Variablen in spitzen Klammern hinter dem Klassennamen bzw. vor dem Rückgabetyp einer Methode: public class Stack<E> { } und public <T> T foo(T m) { }.

Videos (HSBI-Medienportal)
Lernziele
  • (K1) Begriffe generischer Typ, parametrisierter Typ, formaler Typ-Parameter, Typ-Parameter
  • (K3) Erstellen und Nutzen von generischen Klassen und Interfaces
  • (K3) Erstellen und Nutzen von generischen Methoden

Generische Strukturen

Vector speicher = new Vector();
speicher.add(1); speicher.add(2); speicher.add(3);
speicher.add("huhu");

int summe = 0;
for (Object i : speicher) { summe += (Integer)i; }

Problem: Nutzung des "raw" Typs Vector ist nicht typsicher!

  • Mögliche Fehler fallen erst zur Laufzeit und u.U. erst sehr spät auf: Offenbar werden im obigen Beispiel int-Werte erwartet, d.h. das Hinzufügen von "huhu" ist vermutlich ein Versehen (wird vom Compiler aber nicht bemerkt)
  • Die Iteration über speicher kann nur allgemein als Object erfolgen, d.h. in der Schleife muss auf den vermuteten/gewünschten Typ gecastet werden: Hier würde dann der String "huhu" Probleme zur Laufzeit machen
Vector<Integer> speicher = new Vector<Integer>();
speicher.add(1); speicher.add(2); speicher.add(3);
speicher.add("huhu");

int summe = 0;
for (Integer i : speicher) { summe += i; }

Vorteile beim Einsatz von Generics:

  • Datenstrukturen/Algorithmen nur einmal implementieren, aber für unterschiedliche Typen nutzen
  • Keine Vererbungshierarchie nötig
  • Nutzung ist typsicher, Casting unnötig
  • Geht nur für Referenztypen
  • Beispiel: Collections-API

Generische Klassen/Interfaces definieren

  • Definition: "<Typ>" hinter Klassennamen

    public class Stack<E> {
        public E push(E item) {
            addElement(item);
            return item;
        }
    }
    • Stack<E> => Generische (parametrisierte) Klasse (auch: "generischer Typ")
    • E => Formaler Typ-Parameter (auch: "Typ-Variable")
  • Einsatz:

    Stack<Integer> stack = new Stack<Integer>();
    • Integer => Typ-Parameter
    • Stack<Integer> => Parametrisierter Typ

Generische Klassen instantiieren

  • Typ-Parameter in spitzen Klammern hinter Klasse bzw. Interface

    ArrayList<Integer> il = new ArrayList<Integer>();
    ArrayList<Double>  dl = new ArrayList<Double>();

Beispiel I: Einfache generische Klassen

class Tutor<T> {
    // T kann in Tutor *fast* wie Klassenname verwendet werden
    private T x;
    public T foo(T t) { ... }
}
Tutor<String>  a = new Tutor<String>();
Tutor<Integer> b = new Tutor<>();  // ab Java7: "Diamond Operator"

a.foo("wuppie");
b.foo(1);
b.foo("huhu");  // Fehlermeldung vom Compiler

Typ-Inferenz

Typ-Parameter kann bei new() auf der rechten Seite oft weggelassen werden => Typ-Inferenz

Tutor<String> x = new Tutor<>();  // <>: "Diamantoperator"

(gilt seit Java 1.7)

Beispiel II: Vererbung mit Typparametern

interface Fach<T1, T2> {
    public void machWas(T1 a, T2 b);
}

class SHK<T> extends Tutor<T> { ... }

class PM<X, Y, Z> implements Fach<X, Z> {
    public void machWas(X a, Z b) { ... }
    public Y getBla() { ... }
}

class Studi<A,B> extends Person { ... }
class Properties extends Hashtable<Object,Object> { ... }

Auch Interfaces und abstrakte Klassen können parametrisierbar sein.

Bei der Vererbung sind alle Varianten bzgl. der Typ-Variablen denkbar. Zu beachten ist dabei vor allem, dass die Typ-Variablen der Oberklasse (gilt analog für Interfaces) entweder durch Typ-Variablen der Unterklasse oder durch konkrete Typen spezifiziert sind. Die Typ-Variablen der Oberklasse dürfen nicht "in der Luft hängen" (siehe auch nächste Folie)!

Beispiel III: Überschreiben/Überladen von Methoden

class Mensch { ... }

class Studi<T extends Mensch> {
    public void f(T t) { ... }
}

class Prof<T> extends Mensch { ... }

class Tutor extends Studi<Mensch> {
    public void f(Mensch t) { ... }      // Ueberschreiben
    public void f(Tutor t) { ... }       // Ueberladen
}

Vorsicht: So geht es nicht!

class Foo<T> extends T { ... }

class Fluppie<T> extends Wuppie<S> { ... }
  • Generische Klasse Foo<T> kann nicht selbst vom Typ-Parameter T ableiten (warum?)
  • Bei Ableiten von generischer Klasse Wuppie<S> muss deren Typ-Parameter S bestimmt sein: etwa durch den Typ-Parameter der ableitenden Klasse, beispielsweise Fluppie<S> (statt Fluppie<T>)

Generische Methoden definieren

  • "<Typ>" vor Rückgabetyp

    public class Mensch {
        public <T> T myst(T m, T n) {
            return Math.random() > 0.5 ? m : n;
        }
    }
  • "Mischen possible":

    public class Mensch<E> {
        public <T> T myst(T m, T n) { ... }
        public String myst(String m, String n) { ... }
    }

Aufruf generischer Methoden

Aufruf

  • Aufruf mit Typ-Parameter vor Methodennamen, oder
  • Inferenz durch Compiler

Finden der richtigen Methode durch den Compiler

  1. Zuerst Suche nach exakt passender Methode,
  2. danach passend mit Konvertierungen => Compiler sucht gemeinsame Oberklasse in Typhierarchie

Beispiel

class Mensch {
    <T> T myst(T m, T n) { ... }
}
Mensch m = new Mensch();


m.<String>myst("Essen", "lecker");  // Angabe Typ-Parameter


m.myst("Essen", 1);          // String, Integer => T: Object
m.myst("Essen", "lecker");   // String, String  => T: String
m.myst(1.0, 1);              // Double, Integer => T: Number

Reihenfolge der Suche nach passender Methode gilt auch für nicht-generisch überladene Methoden

class Mensch {
    public <T> T myst(T m, T n) {
        System.out.println("X#myst: T");
        return m;
    }

    // NICHT gleichzeitig erlaubt wg. Typ-Löschung (s.u.):
/*
    public <T1, T2> T1 myst(T1 m, T2 n) {
        System.out.println("X#myst: T");
        return m;
    }
*/

    public String myst(String m, String n) {
        System.out.println("X#myst: String");
        return m;
    }

    public int myst(int m, int n) {
        System.out.println("X#myst: int");
        return m;
    }
}


public class GenericMethods {
    public static void main(String[] args) {
        Mensch m = new Mensch();

        m.myst("Hello World", "m");
        m.myst("Hello World", 1);
        m.myst(3, 4);
        m.myst(m, m);
        m.<Mensch>myst(m, m);
        m.myst(m, 1);
        m.myst(3.0, 4);
        m.<Double>myst(3, 4);
    }
}

Wrap-Up

  • Begriffe:

    • Generischer Typ: Stack<T>
    • Formaler Typ-Parameter: T
    • Parametrisierter Typ:Stack<Long>
    • Typ-Parameter: Long
    • Raw Type: Stack
  • Generische Klassen: public class Stack<E> { }

    • "<Typ>" hinter Klassennamen
  • Generische Methoden: public <T> T foo(T m) { }

    • "<Typ>" vor Rückgabewert
Quellen

Bounds & Wildcards

TL;DR

Typ-Variablen können weiter eingeschränkt werden, in dem man einen verpflichtenden Ober- oder Untertyp angibt mit extends bzw. super. Damit muss der später bei der Instantiierung verwendete Typ-Parameter entweder die Oberklasse selbst sein oder davon ableiten (bei extends) bzw. der Typ-Parameter muss eine Oberklasse der angegebenen Schranke sein (super).

Durch die Einschränkung mit extends können in der Klasse/Methode auf der Typ-Variablen alle Methoden des angegebenen Obertyps verwendet werden.

Ein Wildcard (?) als Typ-Parameter steht für einen beliebigen Typ, wobei die Typ-Variable keinen Namen bekommt und damit innerhalb der Klasse/Methode nicht zugreifbar ist.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Umgang mit Wildcards und Bounds bei generischen Klassen/Methoden

Bounds: Einschränken der generischen Typen

public class Cps<E extends Number> {
    // Obere Schranke: E muss Number oder Subklasse sein
    // => Zugriff auf Methoden aus Number moeglich
}
Cps<Double> a;
Cps<Number> b;
Cps<String> c;  // Fehler!!!
  • Schlüsselwort extends gilt hier auch für Interfaces

  • Mehrere Interfaces: nach extends Klasse oder Interface, danach mit "&" getrennt die restlichen Interfaces:

    class Cps<E extends KlasseOderInterface & I1 & I2 & I3> {}

Anmerkung: Der Typ-Parameter ist analog auch mit super (nach unten) einschränkbar

Wildcards: Dieser Typ ist mir nicht so wichtig

Wildcard mit "?" => steht für unbestimmten Typ

public class Wuppie {
    public void m1(List<?> a) { ... }
    public void m2(List<? extends Number> b) { ... }
}
  • m1: List beliebig parametrisierbar => In m1 für Objekte in Liste a nur Methoden von Object nutzbar!

  • m2: List muss mit Number oder Subklasse parametrisiert werden. => Dadurch für Objekte in Liste b alle Methoden von Number nutzbar ...

Weitere Eigenschaften:

  • Durch Wildcard kein Zugriff auf den Typ
  • Wildcard kann durch upper bound eingeschränkt werden
  • Geht nicht bei Klassen-/Interface-Definitionen

[Bloch2018]: Nur für Parameter und nicht für Rückgabewerte nutzen!

Hands-On: Ausgabe für generische Listen

Ausgabe für Listen gesucht, die sowohl Elemente der Klasse A als auch Elemente der Klasse B enthalten können

class A { void printInfo() { System.out.println("A"); } }
class B extends A { void printInfo() { System.out.println("B"); } }

public class X {
    public static void main(String[] args) {
        List<A> x = new ArrayList<A>();
        x.add(new A());  x.add(new B());
        printInfo(x);    // Klassenmethode in X, gesucht
        List<B> y = new ArrayList<B>();
        y.add(new B());  y.add(new B());
        printInfo(y);    // Klassenmethode in X, gesucht
    }
}

Hinweis: Dieses Beispiel beinhaltet auch Polymorphie bei/mit generischen Datentypen, bitte vorher auch das Video zum vierten Teil "Generics und Polymorphie" anschauen

Erster Versuch (A und B und main() wie oben)

public class X {
    public static void printInfo(List<A> list) {
        for (A a : list) { a.printInfo(); }
    }
}

=> So gehts nicht! Eine List<B> ist keine List<A> (auch wenn ein B ein A ist, vgl. spätere Sitzung zu Generics und Vererbung ...)!

Zweiter Versuch mit Wildcards (A und B und main() wie oben)

public class X {
    public static void printInfo(List<?> list) {
        for (Object a : list) { a.printInfo(); }
    }
}

=> So gehts auch nicht! Im Prinzip passt das jetzt für List<A> und List<B>. Dummerweise hat man durch das Wildcard keinen Zugriff mehr auf den Typ-Parameter und muss für den Typ der Laufvariablen in der for-Schleife dann Object nehmen. Aber Object kennt unser printInfo nicht ... Außerdem könnte man die Methode X#printInfo dank des Wildcards auch mit allen anderen Typen aufrufen ...

Dritter Versuch (Lösung) mit Wildcards und Bounds (A und B und main() wie oben)

public class X {
    public static void printInfo(List<? extends A> list) {
        for (A a : list) { a.printInfo(); }
    }
}

Das ist die Lösung. Man erlaubt als Argument nur List-Objekte und fordert, dass sie mit A oder einer Unterklasse von A parametrisiert sind. D.h. in der Schleife kann man sich auf den gemeinsamen Obertyp A abstützen und hat dann auch wieder die printInfo-Methode zur Verfügung ...

Wrap-Up

  • Ein Wildcard (?) als Typ-Parameter steht für einen beliebigen Typ

    • Ist in Klasse oder Methode dann aber nicht mehr zugreifbar
  • Mit Bounds kann man Typ-Parameter nach oben oder nach unten einschränken (im Sinne einer Vererbungshierarchie)

    • extends: Der Typ-Parameter muss eine Unterklasse eines bestimmten Typen sein
    • super: Der Typ-Parameter muss eine Oberklasse eines bestimmten Typen sein
Challenges

Spieler, Mannschaften und Ligen Modellieren Sie in Java verschiedene Spielertypen sowie generische Mannschaften und Ligen, die jeweils bestimmte Spieler (-typen) bzw. Mannschaften aufnehmen können.

  1. Implementieren Sie die Klasse Spieler, die das Interface ISpieler erfüllt.

    public interface ISpieler {
        String getName();
    }
  2. Implementieren Sie die beiden Klassen FussballSpieler und BasketballSpieler und sorgen Sie dafür, dass beide Klassen vom Compiler als Spieler betrachtet werden (geeignete Vererbungshierarchie).

  3. Betrachten Sie das nicht-generische Interface IMannschaft. Erstellen Sie daraus ein generisches Interface IMannschaft mit einer Typ-Variablen. Stellen Sie durch geeignete Beschränkung der Typ-Variablen sicher, dass nur Mannschaften mit von ISpieler abgeleiteten Spielern gebildet werden können.

    public interface IMannschaft {
        boolean aufnehmen(ISpieler spieler);
        boolean rauswerfen(ISpieler spieler);
    }
  4. Betrachten Sie das nicht-generische Interface ILiga. Erstellen Sie daraus ein generisches Interface ILiga mit einer Typvariablen. Stellen Sie durch geeignete Beschränkung der Typvariablen sicher, dass nur Ligen mit von IMannschaft abgeleiteten Mannschaften angelegt werden können.

    public interface ILiga {
        boolean aufnehmen(IMannschaft mannschaft);
        boolean rauswerfen(IMannschaft mannschaft);
    }
  5. Leiten Sie von ILiga das generische Interface IBundesLiga ab. Stellen Sie durch geeignete Formulierung der Typvariablen sicher, dass nur Ligen mit Mannschaften angelegt werden können, deren Spieler vom Typ FussballSpieler (oder abgeleitet) sind.

    Realisieren Sie nun noch die Funktionalität von IBundesLiga als nicht-generisches Interface IBundesLiga2.

Quellen

Type Erasure

TL;DR

Generics existieren eigentlich nur auf Quellcode-Ebene. Nach der Typ-Prüfung etc. entfernt der Compiler alle generischen Typ-Parameter und alle <...> (=> "Type-Erasure"), d.h. im Byte-Code stehen nur noch Raw-Typen bzw. die oberen Typ-Schranken der Typ-Parameter, in der Regel Object. Zusätzlich baut der Compiler die nötigen Casts ein. Als Anwender merkt man davon nichts, muss das "Type-Erasure" wegen der Auswirkungen aber auf dem Radar haben!

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Typ-Löschung und Auswirkungen

Typ-Löschung (Type-Erasure)

Der Compiler ersetzt nach Prüfung der Typen und ihrer Verwendung alle Typ-Parameter durch

  1. deren obere (Typ-)Schranke und
  2. passende explizite Cast-Operationen (im Byte-Code).

Die obere Typ-Schranke ist in der Regel der Typ der ersten Bounds-Klausel oder Object, wenn keine Einschränkungen formuliert sind.

Bei parametrisierten Typen wie List<T> wird der Typ-Parameter entfernt, es entsteht ein sogenannter Raw-Typ (List, quasi implizit mit Object parametrisiert).

=> Ergebnis: Nur eine (untypisierte) Klasse! Zur Laufzeit gibt es keine Generics mehr!

Hinweis: In C++ ist man den anderen möglichen Weg gegangen und erzeugt für jede Instantiierung die passende Klasse. Siehe Modul "Systemprogrammierung" :)

Beispiel: Aus dem folgenden harmlosen Code-Fragment:

class Studi<T> {
    T myst(T m, T n) { return n; }

    public static void main(String[] args) {
        Studi<Integer> a = new Studi<>();
        int i = a.myst(1, 3);
    }
}

wird nach der Typ-Löschung durch Compiler (das steht dann quasi im Byte-Code):

class Studi {
    Object myst(Object m, Object n) { return n; }

    public static void main(String[] args) {
        Studi a = new Studi();
        int i = (Integer) a.myst(1, 3);
    }
}

Die obere Schranke meist Object => new T() verboten/sinnfrei (s.u.)!

Type-Erasure bei Nutzung von Bounds

vor der Typ-Löschung durch Compiler:

class Cps<T extends Number> {
    T myst(T m, T n) {
        return n;
    }

    public static void main(String[] args) {
        Cps<Integer> a = new Cps<>();
        int i = a.myst(1, 3);
    }
}

nach der Typ-Löschung durch Compiler:

class Cps {
    Number myst(Number m, Number n) {
        return n;
    }

    public static void main(String[] args) {
        Cps a = new Cps();
        int i = (Integer) a.myst(1, 3);
    }
}

Raw-Types: Ich mag meine Generics "well done" :-)

Raw-Types: Instanziierung ohne Typ-Parameter => Object

Stack s = new Stack(); // Stack von Object-Objekten
  • Wegen Abwärtskompatibilität zu früheren Java-Versionen noch erlaubt.
  • Nutzung wird nicht empfohlen! (Warum?)

Anmerkung

Raw-Types darf man zwar selbst im Quellcode verwenden (so wie im Beispiel hier), sollte die Verwendung aber vermeiden wegen der Typ-Unsicherheit: Der Compiler sieht im Beispiel nur noch einen Stack für Object, d.h. dort dürfen Objekte aller Typen abgelegt werden - es kann keine Typprüfung durch den Compiler stattfinden. Auf einem Stack<String> kann der Compiler prüfen, ob dort wirklich nur String-Objekte abgelegt werden und ggf. entsprechend Fehler melden.

Etwas anderes ist es, dass der Compiler im Zuge von Type-Erasure selbst Raw-Types in den Byte-Code schreibt. Da hat er vorher bereits die Typsicherheit geprüft und er baut auch die passenden Casts ein.

Das Thema ist eigentlich nur noch aus Kompatibilität zu Java5 oder früher da, weil es dort noch keine Generics gab (wurden erst mit Java6 eingeführt).

Folgen der Typ-Löschung: new

new mit parametrisierten Klassen ist nicht erlaubt!

class Fach<T> {
    public T foo() {
        return new T();  // nicht erlaubt!!!
    }
}

Grund: Zur Laufzeit keine Klasseninformationen über T mehr

Im Code steht return (CAST) new Object();. Das neue Object kann man anlegen, aber ein Cast nach irgendeinem anderen Typ ist sinnfrei: Jede Klasse ist ein Untertyp von Object, aber eben nicht andersherum. Außerdem fehlt dem Objekt vom Typ Object auch sämtliche Information und Verhalten, die der Cast-Typ eigentlich mitbringt ...

Folgen der Typ-Löschung: static

static mit generischen Typen ist nicht erlaubt!

class Fach<T> {
    static T t;                    // nicht erlaubt!!!
    static Fach<T> c;              // nicht erlaubt!!!
    static void foo(T t) { ... };  // nicht erlaubt!!!
}

Fach<String>  a;
Fach<Integer> b;

Grund: Compiler generiert nur eine Klasse! Beide Objekte würden sich die statischen Attribute teilen (Typ zur Laufzeit unklar!).

Hinweis: Generische (statische) Methoden sind erlaubt.

Folgen der Typ-Löschung: instanceof

instanceof mit parametrisierten Klassen ist nicht erlaubt!

class Fach<T> {
    void printType(Fach<?> p) {
        if (p instanceof Fach<Number>)
            ...
        else if (p instanceof Fach<String>)
            ...
    }
}

Grund: Unsinniger Code nach Typ-Löschung:

class Fach {
void printType(Fach p) {
    if (p instanceof Fach)
        ...
    else if (p instanceof Fach)
        ...
    }
}

Folgen der Typ-Löschung: .class

.class mit parametrisierten Klassen ist nicht erlaubt!

boolean x;
List<String>  a = new ArrayList<String>();
List<Integer> b = new ArrayList<Integer>();

x = (List<String>.class == List<Integer>.class);  // Compiler-Fehler
x = (a.getClass() == b.getClass());               // true

Grund: Es gibt nur List.class (und kein List<String>.class bzw. List<Integer>.class)!

Wrap-Up

  • Generics existieren eigentlich nur auf Quellcode-Ebene
  • "Type-Erasure":
    • Compiler entfernt nach Typ-Prüfungen etc. generische Typ-Parameter etc. => im Byte-Code nur noch Raw-Typen bzw. die oberen Typ-Schranken der Typ-Parameter, in der Regel Object
    • Compiler baut passende Casts in Byte-Code ein
    • Transparent für User; Auswirkungen beachten!
Quellen

Generics und Polymorphie

TL;DR

Auch mit generischen Klassen stehen die Mechanismen Vererbung und Überladen zur Verfügung. Dabei muss aber beachtet werden, dass generische Klassen sich "invariant" verhalten: Der Typ selbst folgt der Vererbungsbeziehung, eine Vererbung des Typ-Parameters begründet keine Vererbungsbeziehung! D.h. aus U extends O folgt nicht A<U> extends A<O>.

Bei Arrays ist es genau anders herum: Wenn U extends O dann gilt auch U[] extends O[] ... (Dies nennt man "kovariantes" Verhalten.)

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K3) Vererbungsbeziehungen mit generischen Klassen
  • (K3) Umgang mit Arrays und generischen Typen

Generische Polymorphie

B<E> extends A<E>

class A<E> { ... }
class B<E> extends A<E> { ... }

A<Double> ad = new B<Double>();
A<String> as = new B<String>();
class Vector<E> { ... }
class Stack<E> extends Vector<E> { ... }

Vector<Double> vd = new Stack<Double>();
Vector<String> vs = new Stack<String>();

=> Polymorphie bei Generics bezieht sich auf Typ (nicht Typ-Parameter)

Invarianz: Generics sind invariant, d.h. ein HashSet<String> ist ein Untertyp von Set<String>. Bei der Vererbung muss der Typ-Parameter identisch sein.

Polymorphie bei Generics bezieht sich nur auf Typ!

"B extends A" bedeutet nicht "C<B> extends C<A>"

Stack<Number> s = new Stack<Integer>(); // DAS GEHT SO NICHT!

// Folgen (wenn obiges gehen wuerde):
s.push(new Integer(3)); // das ginge sowieso ...

// Folgen (wenn obiges gehen wuerde):
// Stack<Number> waere Oberklasse auch von Stack<Double>
s.push(new Double(2.0)); // waere dann auch erlaubt ...

// Das Objekt (Stack<Integer>) kann aber keine Double speichern!
// Zur Laufzeit keine Typ-Informationen mehr!
  • Typ-Löschung => zur Laufzeit keine Typinformationen vorhanden
  • Compiler muss Typen prüfen (können)!

Abgrenzung: Polymorphie bei Arrays

Wenn "B extends A" dann "B[] extends A[]"

Object[] x = new String[] {"Hello", "World", ":-)"};
x[0] = "Hallo";
x[0] = new Double(2.0);  // Laufzeitfehler
String[] y = x;  // String[] ist KEIN Object[]!!!
  • Arrays besitzen Typinformationen über gespeicherte Elemente
  • Prüfung auf Typ-Kompatibilität zur Laufzeit (nicht Kompilierzeit!)

Arrays gab es sehr früh, Generics erst relativ spät (ab Java6) => bei Arrays fand man das Verhalten natürlich und pragmatisch (trotz der Laufzeit-Überprüfung).

Bei der Einführung von Generics musste man Kompatibilität sicherstellen (alter Code soll auch mit neuen Compilern übersetzt werden können - obwohl im alten Code Raw-Types verwendet werden). Außerdem wollte man von Laufzeit-Prüfung hin zu Compiler-Prüfung. Da würde das von Arrays bekannte Verhalten Probleme machen ...

Kovarianz: Arrays sind kovariant, d.h. ein Array vom Typ String[] ist wegen String extends Object ein Untertyp von Object[].

Arrays vs. parametrisierte Klassen

=> Keine Arrays mit parametrisierten Klassen!

Foo<String>[] x = new Foo<String>[2];   // Compilerfehler

Foo<String[]> y = new Foo<String[]>();  // OK :)

Arrays mit parametrisierten Klassen sind nicht erlaubt! Arrays brauchen zur Laufzeit Typinformationen, die aber durch die Typ-Löschung entfernt werden.

Diskussion Vererbung vs. Generics

Vererbung:

  • IS-A-Beziehung
  • Anwendung: Vererbungsbeziehung vorliegend, Eigenschaften verfeinern
  • Beispiel: Ein Student ist eine Person

Generics:

  • Schablone (Template) für viele Datentypen
  • Anwendung: Identischer Code für unterschiedliche Typen
  • Beispiel: Datenstrukturen, Algorithmen generisch realisieren

Wrap-Up

  • Generics: Vererbung und Überladen möglich, aber: Aus "U extends O" folgt nicht "A<U> extends A<O>"

  • Achtung: Bei Arrays gilt aber: Wenn "U extends O" dann gilt auch "U[] extends O[]" ...

Quellen

Subsections of Bauen von Programmen, Automatisierung, Continuous Integration

Build-Systeme: Gradle

TL;DR

Um beim Übersetzen und Testen von Software von den spezifischen Gegebenheiten auf einem Entwicklerrechner unabhängig zu werden, werden häufig sogenannte Build-Tools eingesetzt. Mit diesen konfiguriert man sein Projekt abseits einer IDE und übersetzt, testet und baut seine Applikation damit entsprechend unabhängig. In der Java-Welt sind aktuell die Build-Tools Ant, Maven und Gradle weit verbreitet.

In Gradle ist ein Java-Entwicklungsmodell quasi eingebaut. Über die Konfigurationsskripte müssen nur noch bestimmte Details wie benötigte externe Bibliotheken oder die Hauptklasse und sonstige Projektbesonderheiten konfiguriert werden. Über "Tasks" wie build, test oder run können Java-Projekte übersetzt, getestet und ausgeführt werden. Dabei werden die externen Abhängigkeiten (Bibliotheken) aufgelöst (soweit konfiguriert) und auch abhängige Tasks mit erledigt, etwa muss zum Testen vorher der Source-Code übersetzt werden.

Gradle bietet eine Fülle an Plugins für bestimmte Aufgaben an, die jeweils mit neuen Tasks einher kommen. Beispiele sind das Plugin java, welches weitere Java-spezifische Tasks wie classes mitbringt, oder das Plugin checkstyle zum Überprüfen von Coding-Style-Richtlinien.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Schreiben und Verstehen einfacher Gradle-Skripte

Automatisieren von Arbeitsabläufen

Works on my machine ...

Einen häufigen Ausspruch, den man bei der Zusammenarbeit in Teams zu hören bekommt, ist "Also, bei mir läuft der Code." ...

Das Problem dabei ist, dass jeder Entwickler eine andere Maschine hat, oft ein anderes Betriebssystem oder eine andere OS-Version. Dazu kommen noch eine andere IDE und/oder andere Einstellungen und so weiter.

Wie bekommt man es hin, dass Code zuverlässig auch auf anderen Rechnern baut? Ein wichtiger Baustein dafür sind sogenannte "Build-Systeme", also Tools, die unabhängig von der IDE (und den IDE-Einstellungen) für das Übersetzen der Software eingesetzt werden und deren Konfiguration dann mit im Repo eingecheckt wird. Damit kann die Software dann auf allen Rechnern und insbesondere dann auch auf dem Server (Stichwort "Continuous Integration") unabhängig von der IDE o.ä. automatisiert gebaut und getestet werden.

  • Build-Tools:
    • Apache Ant
    • Apache Maven
    • Gradle

Das sind die drei am häufigsten anzutreffenden Build-Tools in der Java-Welt.

Ant ist von den drei genannten Tools das älteste und setzt wie Maven auf XML als Beschreibungssprache. In Ant müssen dabei alle Regeln stets explizit formuliert werden, die man benutzen möchte.

In Maven wird dagegen von einem bestimmten Entwicklungsmodell ausgegangen, hier müssen nur noch die Abweichungen zu diesem Modell konfiguriert werden.

In Gradle wird eine DSL basierend auf der Skriptsprache Groovy (läuft auf der JVM) eingesetzt, und es gibt hier wie in Maven ein bestimmtes eingebautes Entwicklungsmodell. Gradle bringt zusätzlich noch einen Wrapper mit, d.h. es wird eine Art Gradle-Starter im Repo konfiguriert, der sich quasi genauso verhält wie ein fest installiertes Gradle (s.u.).

Achtung: Während Ant und Maven relativ stabil in der API sind, verändert sich Gradle teilweise deutlich zwischen den Versionen. Zusätzlich sind bestimmte Gradle-Versionen oft noch von bestimmten JDK-Versionen abhängig. In der Praxis bedeutet dies, dass man Gradle-Skripte im Laufe der Zeit relativ oft überarbeiten muss (einfach nur, damit das Skript wieder läuft - ohne dass man dabei irgendwelche neuen Features oder sonstige Vorteile erzielen würde). Ein großer Vorteil ist aber der Gradle-Wrapper (s.u.).

Gradle: Eine DSL in Groovy

DSL: Domain Specific Language

// build.gradle
plugins {
    id 'java'
    id 'application'
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'junit:junit:4.13.2'
}

application {
    mainClass = 'fluppie.App'
}

Dies ist mit die einfachste Build-Datei für Gradle.

Über Plugins wird die Unterstützung für Java und das Bauen von Applikationen aktiviert, d.h. es stehen darüber entsprechende spezifische Tasks zur Verfügung.

Abhängigkeiten sollen hier aus dem Maven-Repository MavenCentral geladen werden. Zusätzlich wird hier als Abhängigkeit für den Test (testImplementation) die JUnit-Bibliothek in einer Maven-artigen Notation angegeben (vgl. mvnrepository.com). (Für nur zur Übersetzung der Applikation benötigte Bibliotheken verwendet man stattdessen das Schlüsselwort implementation.)

Bei der Initialisierung wurde als Package fluppie angegeben. Gradle legt darunter per Default die Klasse App mit einer main()-Methode an. Entsprechend kann man über den Eintrag application den Einsprungpunkt in die Applikation konfigurieren.

Gradle-DSL

Ein Gradle-Skript ist letztlich ein in Groovy geschriebenes Skript. Groovy ist eine auf Java basierende und auf der JVM ausgeführte Skriptsprache. Seit einigen Versionen kann man die Gradle-Build-Skripte auch in der Sprache Kotlin schreiben.

Dateien

Für das Bauen mit Gradle benötigt man drei Dateien im Projektordner:

  • build.gradle: Die auf der Gradle-DSL beruhende Definition des Builds mit den Tasks (und ggf. Abhängigkeiten) eines Projekts.

    Ein Multiprojekt hat pro Projekt eine solche Build-Datei. Dabei können die Unterprojekte Eigenschaften der Eltern-Buildskripte "erben" und so relativ kurz ausfallen.

  • settings.gradle: Eine optionale Datei, in der man beispielsweise den Projektnamen oder bei einem Multiprojekt die relevanten Unterprojekte festlegt.

  • gradle.properties: Eine weitere optionale Datei, in der projektspezifische Properties für den Gradle-Build spezifizieren kann.

Gradle Init

Um eine neue Gradle-Konfiguration anlegen zu lassen, geht man in einen Ordner und führt darin gradle init aus. Gradle fragt der Reihe nach einige Einstellungen ab:

$ gradle init

Select type of project to generate:
  1: basic
  2: application
  3: library
  4: Gradle plugin
Enter selection (default: basic) [1..4] 2

Select implementation language:
  1: C++
  2: Groovy
  3: Java
  4: Kotlin
  5: Scala
  6: Swift
Enter selection (default: Java) [1..6] 3

Split functionality across multiple subprojects?:
  1: no - only one application project
  2: yes - application and library projects
Enter selection (default: no - only one application project) [1..2] 1

Select build script DSL:
  1: Groovy
  2: Kotlin
Enter selection (default: Groovy) [1..2] 1

Select test framework:
  1: JUnit 4
  2: TestNG
  3: Spock
  4: JUnit Jupiter
Enter selection (default: JUnit Jupiter) [1..4] 1

Project name (default: tmp): wuppie
Source package (default: tmp): fluppie

Typischerweise möchte man eine Applikation bauen (Auswahl 2 bei der ersten Frage). Als nächstes wird nach der Sprache des Projekts gefragt sowie nach der Sprache für das Gradle-Build-Skript (Default ist Groovy) sowie nach dem Testframework, welches verwendet werden soll.

Damit wird die eingangs gezeigte Konfiguration angelegt.

Ordner

Durch gradle init wird ein neuer Ordner wuppie/ mit folgender Ordnerstruktur angelegt:

drwxr-xr-x 4 cagix cagix 4096 Apr  8 11:43 ./
drwxrwxrwt 1 cagix cagix 4096 Apr  8 11:43 ../
-rw-r--r-- 1 cagix cagix  154 Apr  8 11:43 .gitattributes
-rw-r--r-- 1 cagix cagix  103 Apr  8 11:43 .gitignore
drwxr-xr-x 3 cagix cagix 4096 Apr  8 11:43 app/
drwxr-xr-x 3 cagix cagix 4096 Apr  8 11:42 gradle/
-rwxr-xr-x 1 cagix cagix 8070 Apr  8 11:42 gradlew*
-rw-r--r-- 1 cagix cagix 2763 Apr  8 11:42 gradlew.bat
-rw-r--r-- 1 cagix cagix  370 Apr  8 11:43 settings.gradle

Es werden Einstellungen für Git erzeugt (.gitattributes und .gitignore).

Im Ordner gradle/ wird der Gradle-Wrapper abgelegt (s.u.). Dieser Ordner wird normalerweise mit ins Repo eingecheckt. Die Skripte gradlew und gradlew.bat sind die Startskripte für den Gradle-Wrapper (s.u.) und werden normalerweise ebenfalls ins Repo mit eingecheckt.

Der Ordner .gradle/ (erscheint ggf. nach dem ersten Lauf von Gradle auf dem neuen Projekt) ist nur ein Hilfsordner ("Cache") von Gradle. Hier werden heruntergeladene Dateien etc. abgelegt. Dieser Order sollte nicht ins Repo eingecheckt werden und ist deshalb auch per Default im generierten .gitignore enthalten. (Zusätzlich gibt es im User-Verzeichnis auch noch einen Ordner .gradle/ mit einem globalen Cache.)

In settings.gradle finden sich weitere Einstellungen. Die eigentliche Gradle-Konfiguration befindet sich zusammen mit dem eigentlichen Projekt im Unterordner app/:

drwxr-xr-x 4 root root 4096 Apr  8 11:50 ./
drwxr-xr-x 5 root root 4096 Apr  8 11:49 ../
drwxr-xr-x 5 root root 4096 Apr  8 11:50 build/
-rw-r--r-- 1 root root  852 Apr  8 11:43 build.gradle
drwxr-xr-x 4 root root 4096 Apr  8 11:43 src/

Die Datei build.gradle ist die durch gradle init erzeugte (und eingangs gezeigte) Konfigurationsdatei, vergleichbar mit build.xml für Ant oder pom.xml für Maven. Im Unterordner build/ werden die generierten .class-Dateien etc. beim Build-Prozess abgelegt.

Unter src/ findet sich dann eine Maven-typische Ordnerstruktur für die Sourcen:

$ tree src/
src/
|-- main
|   |-- java
|   |   `-- fluppie
|   |       `-- App.java
|   `-- resources
`-- test
    |-- java
    |   `-- fluppie
    |       `-- AppTest.java
    `-- resources

Unterhalb von src/ ist ein Ordner main/ für die Quellen der Applikation (Sourcen und Ressourcen). Für jede Sprache gibt es einen eigenen Unterordner, hier entsprechend java/. Unterhalb diesem folgt dann die bei der Initialisierung angelegte Package-Struktur (hier fluppie mit der Default-Main-Klasse App mit einer main()-Methode). Diese Strukturen wiederholen sich für die Tests unterhalb von src/test/.

Wer die herkömmlichen, deutlich flacheren Strukturen bevorzugt, also unterhalb von src/ direkt die Java-Package-Strukturen für die Sourcen der Applikation und unterhalb von test/ entsprechend die Strukturen für die JUnit-Test, der kann dies im Build-Skript einstellen:

sourceSets {
    main {
        java {
            srcDirs = ['src']
        }
        resources {
            srcDirs = ['res']
        }
    test {
        java {
            srcDirs = ['test']
        }
    }
}

Ablauf eines Gradle-Builds

Ein Gradle-Build hat zwei Hauptphasen: Konfiguration und Ausführung.

Während der Konfiguration wird das gesamte Skript durchlaufen (vgl. Ausführung der direkten Anweisungen eines Tasks). Dabei wird ein Graph erzeugt: welche Tasks hängen von welchen anderen ab etc.

Anschließend wird der gewünschte Task ausgeführt. Dabei werden zuerst alle Tasks ausgeführt, die im Graphen auf dem Weg zu dem gewünschten Task liegen.

Mit gradle tasks kann man sich die zur Verfügung stehenden Tasks ansehen. Diese sind der Übersicht halber noch nach "Themen" sortiert.

Für eine Java-Applikation sind die typischen Tasks gradle build zum Bauen der Applikation (inkl. Ausführen der Tests) sowie gradle run zum Starten der Anwendung. Wer nur die Java-Sourcen compilieren will, würde den Task gradle compileJava nutzen. Mit gradle check würde man compilieren und die Tests ausführen sowie weitere Checks durchführen (gradle test würde nur compilieren und die Tests ausführen), mit gradle jar die Anwendung in ein .jar-File packen und mit gradle javadoc die Javadoc-Dokumentation erzeugen und mit gradle clean die generierten Hilfsdateien aufräumen (löschen).

Plugin-Architektur

Für bestimmte Projekttypen gibt es immer wieder die gleichen Aufgaben. Um hier Schreibaufwand zu sparen, existieren verschiedene Plugins für verschiedene Projekttypen. In diesen Plugins sind die entsprechenden Tasks bereits mit den jeweiligen Abhängigkeiten formuliert. Diese Idee stammt aus Maven, wo dies für Java-basierte Projekte umgesetzt ist.

Beispielsweise erhält man über das Plugin java den Task clean zum Löschen aller generierten Build-Artefakte, den Task classes, der die Sourcen zu .class-Dateien kompiliert oder den Task test, der die JUnit-Tests ausführt ...

Sie können sich Plugins und weitere Tasks relativ leicht auch selbst definieren.

Auflösen von Abhängigkeiten

Analog zu Maven kann man Abhängigkeiten (etwa in einer bestimmten Version benötigte Bibliotheken) im Gradle-Skript angeben. Diese werden (transparent für den User) von einer ebenfalls angegeben Quelle, etwa einem Maven-Repository, heruntergeladen und für den Build genutzt. Man muss also nicht mehr die benötigten .jar-Dateien der Bibliotheken mit ins Projekt einchecken. Analog zu Maven können erzeugte Artefakte automatisch publiziert werden, etwa in einem Maven-Repository.

Für das Projekt benötigte Abhängigkeiten kann man über den Eintrag dependencies spezifizieren. Dabei unterscheidet man u.a. zwischen Applikation und Tests: implementation und testImplementation für das Compilieren und Ausführen von Applikation bzw. Tests. Diese Abhängigkeiten werden durch Gradle über die im Abschnitt repositories konfigurierten Repositories aufgelöst und die entsprechenden .jar-Files geladen (in den .gradle/-Ordner).

Typische Repos sind das Maven-Repo selbst (mavenCentral()) oder das Google-Maven-Repo (google()).

Die Einträge in dependencies erfolgen dabei in einer Maven-Notation, die Sie auch im Maven-Repo mvnrepository.com finden.

Beispiel mit weiteren Konfigurationen (u.a. Checkstyle und Javadoc)

plugins {
    id 'java'
    id 'application'
    id 'checkstyle'
}


repositories {
    mavenCentral()
}

application {
    mainClass = 'hangman.Main'
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

run {
    standardInput = System.in
}

sourceSets {
    main {
        java {
            srcDirs = ['src']
        }
        resources {
            srcDirs = ['res']
        }
    }
}

checkstyle {
    configFile = file(${rootDir}/google_checks.xml)
    toolVersion = '8.32'
}

dependencies {
    implementation group: 'org.apache.poi', name: 'poi', version: '4.1.2'
}

javadoc {
    options.showAll()
}

Hier sehen Sie übrigens noch eine weitere mögliche Schreibweise für das Notieren von Abhängigkeiten: implementation group: 'org.apache.poi', name: 'poi', version: '4.1.2' und implementation 'org.apache.poi:poi:4.1.2' sind gleichwertig, wobei die letztere Schreibweise sowohl in den generierten Builds-Skripten und in der offiziellen Dokumentation bevorzugt wird.

Gradle und Ant (und Maven)

Vorhandene Ant-Buildskripte kann man nach Gradle importieren und ausführen lassen. Über die DSL kann man auch direkt Ant-Tasks aufrufen. Siehe auch "Using Ant from Gradle".

Gradle-Wrapper

project
|-- app/
|-- build.gradle
|-- gradlew
|-- gradlew.bat
`-- gradle/
    `-- wrapper/
        |-- gradle-wrapper.jar
        `-- gradle-wrapper.properties

Zur Ausführung von Gradle-Skripten benötigt man eine lokale Gradle-Installation. Diese sollte für i.d.R. alle User, die das Projekt bauen wollen, identisch sein. Leider ist dies oft nicht gegeben bzw. nicht einfach lösbar.

Zur Vereinfachung gibt es den Gradle-Wrapper gradlew (bzw. gradlew.bat für Windows). Dies ist ein kleines Shellskript, welches zusammen mit einigen kleinen .jar-Dateien im Unterordner gradle/ mit ins Repo eingecheckt wird und welches direkt die Rolle des gradle-Befehls einer Gradle-Installation übernehmen kann. Man kann also in Konfigurationskripten, beispielsweise für Gitlab CI, alle Aufrufe von gradle durch Aufrufe von gradlew ersetzen.

Beim ersten Aufruf lädt gradlew dann die spezifizierte Gradle-Version herunter und speichert diese in einem lokalen Ordner .gradle/. Ab dann greift gradlew auf diese lokale (nicht "installierte") gradle-Version zurück.

gradle init erzeugt den Wrapper automatisch in der verwendeten Gradle-Version mit. Alternativ kann man den Wrapper nachträglich über gradle wrapper --gradle-version 6.5 in einer bestimmten (gewünschten) Version anlegen lassen.

Da der Gradle-Wrapper im Repository eingecheckt ist, benutzen alle Entwickler damit automatisch die selbe Version, ohne diese auf ihrem System zuvor installieren zu müssen. Deshalb ist der Einsatz des Wrappers einem fest installierten Gradle vorzuziehen!

Wrap-Up

  • Automatisieren von Arbeitsabläufen mit Build-Tools/-Skripten

  • Einstieg in Gradle (DSL zur Konfiguration)

    • Typisches Java-Entwicklungsmodell eingebaut
    • Konfiguration der Abweichungen (Abhängigkeiten, Namen, ...)
    • Gradle-Wrapper: Ersetzt eine feste Installation
Challenges

Betrachten Sie das Buildskript gradle.build aus Dungeon-CampusMinden/Dungeon.

Erklären Sie, in welche Abschnitte das Buildskript unterteilt ist und welche Aufgaben diese Abschnitte jeweils erfüllen. Gehen Sie dabei im Detail auf das Plugin java und die dort bereitgestellten Tasks und deren Abhängigkeiten untereinander ein.

Quellen

Continuous Integration (CI)

TL;DR

In größeren Projekten mit mehreren Teams werden die Beteiligten i.d.R. nur noch "ihre" Codestellen compilieren und testen. Dennoch ist es wichtig, das gesamte Projekt regelmäßig zu "bauen" und auch umfangreichere Testsuiten regelmäßig laufen zu lassen. Außerdem ist es wichtig, das in einer definierten Umgebung zu tun und nicht auf einem oder mehreren Entwicklerrechnern, die i.d.R. (leicht) unterschiedlich konfiguriert sind, um zuverlässige und nachvollziehbare Ergebnisse zu bekommen. Weiterhin möchte man auf bestimmte Ereignisse reagieren, wie etwa neue Commits im Git-Server, oder bei Pull-Requests möchte man vor dem Merge automatisiert sicherstellen, dass damit die vorhandenen Tests alle "grün" sind und auch die Formatierung etc. stimmt.

Dafür hat sich "Continuous Integration" etabliert. Hier werden die angesprochenen Prozesse regelmäßig auf einem dafür eingerichteten System durchgeführt. Aktivitäten wie Übersetzen, Testen, Style-Checks etc. werden in sogenannten "Pipelines" oder "Workflows" zusammengefasst und automatisiert durch Commits, Pull-Requests oder Merges auf dem Git-Server ausgelöst. Die Aktionen können dabei je nach Trigger und Branch unterschiedlich sein, d.h. man könnte etwa bei PR gegen den Master umfangreichere Tests laufen lassen als bei einem PR gegen einen Develop-Branch. In einem Workflow oder einer Pipeline können einzelne Aktionen wiederum von anderen Aktionen abhängen. Das Ergebnis kann man dann auf dem Server einsehen oder bekommt man komfortabel als Report per Mail zugeschickt.

Wir schauen uns hier exemplarisch GitHub Actions und GitLab CI/CD an. Um CI sinnvoll einsetzen zu können, benötigt man Kenntnisse über Build-Tools. "CI" tritt üblicherweise zusammen mit "CD" (Continuous Delivery) auf, also als "CI/CD". Der "CD"-Teil ist nicht Gegenstand der Betrachtung in dieser Lehrveranstaltung.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Arbeitsweise von/mit CI

Motivation: Zusammenarbeit in Teams

Szenario

  • Projekt besteht aus diversen Teilprojekten
  • Verschiedene Entwicklungs-Teams arbeiten (getrennt) an verschiedenen Projekten
  • Tester entwickeln Testsuiten für die Teilprojekte
  • Tester entwickeln Testsuiten für das Gesamtprojekt

Manuelle Ausführung der Testsuiten reicht nicht

  • Belastet den Entwicklungsprozess
  • Keine (einheitliche) Veröffentlichung der Ergebnisse
  • Keine (einheitliche) Eskalation bei Fehlern
  • Keine regelmäßige Integration in Gesamtprojekt

Continuous Integration

  • Regelmäßige, automatische Ausführung: Build und Tests
  • Reporting
  • Weiterführung der Idee: Regelmäßiges Deployment (Continuous Deployment)

Continuous Integration (CI)

Vorgehen

  • Entwickler und Tester committen ihre Änderungen regelmäßig (Git, SVN, ...)
  • CI-Server arbeitet Build-Skripte ab, getriggert durch Events: Push-Events, Zeit/Datum, ...
    • Typischerweise wird dabei:
      • Das Gesamtprojekt übersetzt ("gebaut")
      • Die Unit- und die Integrationstests abgearbeitet
      • Zu festen Zeiten werden zusätzlich Systemtests gefahren
    • Typische weitere Builds: "Nightly Build", Release-Build, ...
    • Ergebnisse jeweils auf der Weboberfläche einsehbar (und per E-Mail)

Einige Vorteile

  • Tests werden regelmäßig durchgeführt (auch wenn sie lange dauern oder die Maschine stark belasten)
  • Es wird regelmäßig ein Gesamt-Build durchgeführt
  • Alle Teilnehmer sind über aktuellen Projekt(-zu-)stand informiert

Beispiele für verbreitete CI-Umgebungen

GitLab CI/CD

Siehe auch "Get started with Gitlab CI/CD". (Für den Zugriff wird VPN benötigt!)

Übersicht über Pipelines

  • In Spalte "Status" sieht man das Ergebnis der einzelnen Pipelines: "pending" (die Pipeline läuft gerade), "cancelled" (Pipeline wurde manuell abgebrochen), "passed" (alle Jobs der Pipeline sind sauber durchgelaufen), "failed" (ein Job ist fehlgeschlagen, Pipeline wurde deshalb abgebrochen)
  • In Spalte "Pipeline" sind die Pipelines eindeutig benannt aufgeführt, inkl. Trigger (Commit und Branch)
  • In Spalte "Stages" sieht man den Zustand der einzelnen Stages

Wenn man mit der Maus auf den Status oder die Stages geht, erfährt man mehr bzw. kann auf eine Seite mit mehr Informationen kommen.

Detailansicht einer Pipeline

Wenn man in eine Pipeline in der Übersicht klickt, werden die einzelnen Stages dieser Pipeline genauer dargestellt.

Detailansicht eines Jobs

Wenn man in einen Job einer Stage klickt, bekommt man quasi die Konsolenausgabe dieses Jobs. Hier kann man ggf. Fehler beim Ausführen der einzelnen Skripte oder die Ergebnisse beispielsweise der JUnit-Läufe anschauen.

GitLab CI/CD: Konfiguration mit YAML-Datei

Datei .gitlab-ci.yml im Projekt-Ordner:

stages:
    - my.compile
    - my.test

job1:
    script:
        - echo "Hello"
        - ./gradlew compileJava
        - echo "wuppie!"
    stage: my.compile
    only:
        - wuppie

job2:
    script: "./gradlew test"
    stage: my.test

job3:
    script:
        - echo "Job 3"
    stage: my.compile

Stages

Unter stages werden die einzelnen Stages einer Pipeline definiert. Diese werden in der hier spezifizierten Reihenfolge durchgeführt, d.h. zuerst würde my.compile ausgeführt, und erst wenn alle Jobs in my.compile erfolgreich ausgeführt wurden, würde anschließend my.test ausgeführt.

Dabei gilt: Die Jobs einer Stage werden (potentiell) parallel zueinander ausgeführt, und die Jobs der nächsten Stage werden erst dann gestartet, wenn alle Jobs der aktuellen Stage erfolgreich beendet wurden.

Wenn keine eigenen stages definiert werden, kann man (lt. Doku) auf die Default-Stages build, test und deploy zurückgreifen. Achtung: Sobald man eigene Stages definiert, stehen diese Default-Stages nicht mehr zur Verfügung!

Jobs

job1, job2 und job3 definieren jeweils einen Job.

  • job1 besteht aus mehreren Befehlen (unter script). Alternativ kann man die bei job2 gezeigte Syntax nutzen, wenn nur ein Befehl zu bearbeiten ist.

    Die Befehle werden von GitLab CI/CD in einer Shell ausgeführt.

  • Die Jobs job1 und job2 sind der Stage my.compile zugeordnet (Abschnitt stage). Einer Stage können mehrere Jobs zugeordnet sein, die dann parallel ausgeführt werden.

    Wenn ein Job nicht explizit einer Stage zugeordnet ist, wird er (lt. Doku) zur Default-Stage test zugewiesen. (Das geht nur, wenn es diese Stage auch gibt!)

  • Mit only und except kann man u.a. Branches oder Tags angeben, für die dieser Job ausgeführt (bzw. nicht ausgeführt) werden soll.

Durch die Kombination von Jobs mit der Zuordnung zu Stages und Events lassen sich unterschiedliche Pipelines für verschiedene Zwecke definieren.

Hinweise zur Konfiguration von GitLab CI/CD

Im Browser in den Repo-Einstellungen arbeiten:

  1. Unter Settings > General > Visibility, project features, permissions das CI/CD aktivieren
  2. Prüfen unter Settings > CI/CD > Runners, dass unter Available shared Runners mind. ein shared Runner verfügbar ist (mit grün markiert ist)
  3. Unter Settings > CI/CD > General pipelines einstellen:
    • Git strategy: git clone
    • Timeout: 10m
    • Public pipelines: false (nicht angehakt)
  4. YAML-File (.gitlab-ci.yml) in Projektwurzel anlegen, Aufbau siehe oben
  5. Build-Skript erstellen, lokal lauffähig bekommen, dann in Jobs nutzen
  6. Im .gitlab-ci.yml die relevanten Branches einstellen (s.o.)
  7. Pushen, und unter CI/CD > Pipelines das Builden beobachten
    • in Status reinklicken und schauen, ob und wo es hakt
  8. README.md anlegen in Projektwurzel (neben .gitlab-ci.yml), Markdown-Schnipsel aus Settings > CI/CD > General pipelines > Pipeline status auswählen und einfügen .…

Optional:

  1. Ggf. Schedules unter CI/CD > Schedules anlegen
  2. Ggf. extra Mails einrichten: Settings > Integrations > Pipeline status emails

GitHub Actions

Siehe "GitHub Actions: Automate your workflow from idea to production" und auch "GitHub: CI/CD explained".

Übersicht über Workflows

Hier sieht man das Ergebnis der letzten Workflows. Dazu sieht man den Commit und den Branch, auf dem der Workflow gelaufen ist sowie wann er gelaufen ist. Über die Spalten kann man beispielsweise nach Status oder Event filtern.

In der Abbildung ist ein Workflow mit dem Namen "GitHub CI" zu sehen, der aktuell noch läuft.

Detailansicht eines Workflows

Wenn man in einen Workflow in der Übersicht anklickt, werden die einzelnen Jobs dieses Workflows genauer dargestellt. "job3" ist erfolgreich gelaufen, "job1" läuft gerade, und "job2" hängt von "job1" ab, d.h. kann erst nach dem erfolgreichen Lauf von "job2" starten.

Detailansicht eines Jobs

Wenn man in einen Job anklickt, bekommt man quasi die Konsolenausgabe dieses Jobs. Hier kann man ggf. Fehler beim Ausführen der einzelnen Skripte oder die Ergebnisse beispielsweise der JUnit-Läufe anschauen.

GitHub Actions: Konfiguration mit YAML-Datei

Workflows werden als YAML-Dateien im Ordner .github/workflows/ angelegt.

name: GitHub CI

on:
  # push on master branch
  push:
    branches: [master]
  # manually triggered
  workflow_dispatch:

jobs:

  job1:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      - uses: gradle/wrapper-validation-action@v1
      - run: echo "Hello"
      - run: ./gradlew compileJava
      - run: echo "wuppie!"

  job2:
    needs: job1
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      - uses: gradle/wrapper-validation-action@v1
      - run: ./gradlew test

  job3:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Job 3"

Workflowname und Trigger-Events

Der Name des Workflows wird mit dem Eintrag name spezifiziert und sollte sich im Dateinamen widerspiegeln, also im Beispiel .github/workflows/github_ci.yml.

Im Eintrag on können die Events definiert werden, die den Workflow triggern. Im Beispiel ist ein Push-Event auf dem master-Branch definiert sowie mit workflow_dispatch: das manuelle Triggern (auf einem beliebigen Branch) freigeschaltet.

Jobs

Die Jobs werden unter dem Eintrag jobs definiert: job1, job2 und job3 definieren jeweils einen Job.

  • job1 besteht aus mehreren Befehlen (unter steps), die auf einem aktuellen virtualisierten Ubuntu-Runner ausgeführt werden.

    Es wird zunächst das Repo mit Hilfe der Checkout-Action ausgecheckt (uses: actions/checkout@v4), das JDK eingerichtet/installiert (uses: actions/setup-java@v3) und der im Repo enthaltene Gradle-Wrapper auf Unversehrtheit geprüft (uses: gradle/wrapper-validation-action@v1).

    Die Actions sind vordefinierte Actions und im Github unter github.com/ + Action zu finden, d.h. actions/checkout oder actions/setup-java. Actions können von jedermann definiert und bereitgestellt werden, in diesem Fall handelt es sich um von GitHub selbst im Namespace "actions" bereit gestellte direkt nutzbare Actions. Man kann Actions auch selbst im Ordner .github/actions/ für das Repo definieren (Beispiel: plfa.github.io).

    Mit run werden Befehle in der Shell auf dem genutzten Runner (hier Ubuntu) ausgeführt.

  • Die Jobs job2 ist von job1 abhängig und wird erst gestartet, wenn job1 erfolgreich abgearbeitet ist.

    Ansonsten können die Jobs prinzipiell parallel ausgeführt werden.

Durch die Kombination von Workflows mit verschiedenen Jobs und Abhängigkeiten zwischen Jobs lassen sich unterschiedliche Pipelines ("Workflows") für verschiedene Zwecke definieren.

Es lassen sich auch andere Runner benutzen, etwa ein virtualisiertes Windows oder macOS. Man kann auch über einen "Matrix-Build" den Workflow auf mehreren Betriebssystemen gleichzeitig laufen lassen.

Man kann auch einen Docker-Container benutzen. Dabei muss man beachten, dass dieser am besten aus einer Registry (etwa von Docker-Hub oder aus der GitHub-Registry) "gezogen" wird, weil das Bauen des Docker-Containers aus einem Docker-File in der Action u.U. relativ lange dauert.

Hinweise zur Konfiguration von GitHub Actions

Im Browser in den Repo-Einstellungen arbeiten:

  1. Unter Settings > Actions > General > Actions permissions die Actions aktivieren (Auswahl, welche Actions erlaubt sind)

  2. Unter Settings > Actions > General > Workflow permissions ggf. bestimmen, ob die Actions das Repo nur lesen dürfen oder auch zusätzlich schreiben dürfen

  3. Unter Actions > <WORKFLOW> den Workflow ggf. deaktivieren:

Wrap-Up

Überblick über Continuous Integration:

  • Konfigurierbare Aktionen, die auf dem Gitlab-/GitHub-Server ausgeführt werden
  • Unterschiedliche Trigger: Commit, Merge, ...
  • Aktionen können Branch-spezifisch sein
  • Aktionen können von anderen Aktionen abhängen
Challenges

Betrachten Sie erneut das Projekt Theatrical Players Refactoring Kata. Erstellen Sie für dieses Projekt einen GitHub-Workflow, der das Projekt kompiliert und die Testsuite ausführt (nur für den Java-Teil, den restlichen Code können Sie ignorieren).

Dabei soll das Ausführen der JUnit-Tests nur dann erfolgen, wenn das Kompilieren erfolgreich durchgeführt wurde.

Der Workflow soll automatisch für Commits in den Hauptbranch sowie für Pull-Requests loslaufen. Es soll zusätzlich auch manuell aktivierbar sein.

Quellen

Subsections of Versionierung mit Git

Intro: Versionskontrolle in der Softwareentwicklung

TL;DR

In der Softwareentwicklung wird häufig ein Versionsmanagementsystem (VCS) eingesetzt, welches die Verwaltung von Versionsständen und Änderungen ermöglicht. Ein Repository sammelt dabei die verschiedenen Änderungen (quasi wie eine Datenbank der Software-Versionsstände). Die Software Git ist verbreiteter Vertreter und arbeitet mit dezentralen Repositories.

Ein neues lokales Repository kann man mit git init anlegen. Der Befehl legt den Unterordner .git/ im aktuellen Ordner an, darin befindet sich das lokale Repository und weitere von Git benötigte Dateien (FINGER WEG!). Die Dateien und anderen Unterordner im aktuellen Ordner können nun der Versionskontrolle hinzugefügt werden.

Den lokal vorliegenden (Versions-) Stand der Dateien im aktuellen Ordner nennt man auch "Workingcopy".

Ein bereits existierendes Repo kann mit git clone <url> geklont werden.

GitHub ist nicht Git, sondern ein kommerzieller Anbieter, der das Hosten von Git-Repositories und weitere Features anbietet.

Videos (HSBI-Medienportal)
Lernziele
  • (K1) Varianten der Versionierung
  • (K1) Begriffe Workingcopy und Repository
  • (K2) Github ist nicht Git
  • (K2) Erstellung von lokalen Git-Repositories
  • (K3) Umgang mit entsprechenden Git-Befehlen auf der Konsole

Typische Probleme bei SW-Entwicklung

  • Was hat wer wann (und wo) geändert? Und warum?
  • Ich brauche den Stand von gestern/letzter Woche/...
  • Ich will schnell mal eine neue Idee ausprobieren ...
  • Ich arbeite an mehreren Rechnern (Synchronisation)
  • Wir müssen gemeinsam an der gleichen Codebasis arbeiten.
  • Wir arbeiten am Release v42, aber Kunde braucht schnell einen Fix für v40

Folgen SW-Entwicklung ohne Versionsverwaltung

  • Filesystem müllt voll mit manuell versionierten Dateien/Sicherungen ala file_20120507_version2_cagi.txt
  • Ordner/Projekte müssen dupliziert werden für neue Ideen
  • Code müllt voll mit auskommentierten Zeilen ("Könnte ja noch gebraucht werden")
  • Unklar, wann welche Änderung von wem warum eingeführt wurde
  • Unbeabsichtigtes Überschreiben mit älteren Versionen beim Upload in gemeinsamen Filesharing-Bereich

Prinzip Versionsverwaltung

  • Repository: Datenbank mit verschiedenen Versionsständen, Kommentaren, Tags etc.

  • Workingcopy: Arbeitskopie eines bestimmten Versionsstandes

Varianten: Zentrale Versionsverwaltung (Beispiel SVN)

Es gibt ein zentrales Repository (typischerweise auf einem Server), von dem die Developer einen bestimmten Versionsstand "auschecken" (sich lokal kopieren) und in welches sie Änderungen wieder zurück "pushen".

Zur Abfrage der Historie und zum Veröffentlichen von Änderungen benötigt man entsprechend immer eine Verbindung zum Server.

Varianten: Verteilte Versionsverwaltung (Beispiel Git)

In diesem Szenario hat jeder Developer nicht nur die Workingcopy, sondern auch noch eine Kopie des Repositories. Zusätzlich kann es einen oder mehrere Server geben, auf denen dann nur das Repository vorgehalten wird, d.h. dort gibt es normalerweise keine Workingcopy. Damit kann unabhängig voneinander gearbeitet werden.

Allerdings besteht nun die Herausforderung, die geänderten Repositories miteinander abzugleichen. Das kann zwischen dem lokalen Rechner und dem Server passieren, aber auch zwischen zwei "normalen" Rechnern (also zwischen den Developern).

Hinweis: GitHub ain't no Git! Git ist eine Technologie zur Versionsverwaltung. Es gibt verschiedene Implementierungen und Plugins für IDEs und Editoren. GitHub ist dagegen ein Dienstleister, wo man Git-Repositories ablegen kann und auf diese mit Git (von der Konsole oder aus der IDE) zugreifen kann. Darüber hinaus bietet der Service aber zusätzliche Features an, beispielsweise ein Issue-Management oder sogenannte Pull-Requests. Dies hat aber zunächst mit Git nichts zu tun. Weitere populäre Anbieter sind beispielsweise Bitbucket oder Gitlab oder Gitea, wobei einige auch selbst gehostet werden können.

Versionsverwaltung mit Git: Typische Arbeitsschritte

  1. Repository anlegen (oder clonen)

  2. Dateien neu erstellen (und löschen, umbenennen, verschieben)

  3. Änderungen einpflegen ("committen")

  4. Änderungen und Logs betrachten

  5. Änderungen rückgängig machen

  6. Projektstand markieren ("taggen")

  7. Entwicklungszweige anlegen ("branchen")

  8. Entwicklungszweige zusammenführen ("mergen")

  9. Änderungen verteilen (verteiltes Arbeiten, Workflows)

(Globale) Konfiguration

Minimum:

  • git config --global user.name <name>
  • git config --global user.email <email>

Diese Konfiguration muss man nur einmal machen.

Wenn man den Schalter --global weglässt, gelten die Einstellungen nur für das aktuelle Projekt/Repo.

Zumindest Namen und EMail-Adresse muss man setzen, da Git diese Information beim Anlegen der Commits speichert (== benötigt!).

Aliase:

  • git config --global alias.ci commit
  • git config --global alias.co checkout
  • git config --global alias.br branch
  • git config --global alias.st status
  • git config --global alias.ll 'log --all --graph --decorate --oneline'

Zusätzlich kann man weitere Einstellungen vornehmen, etwa auf bunte Ausgabe umschalten: git config --global color.ui auto oder Abkürzungen (Aliase) für Befehle definieren: git config --global alias.ll 'log --all --oneline --graph --decorate' ...

Git (und auch GitHub) hat kürzlich den Namen des Default-Branches von master auf main geändert. Dies kann man in Git ebenfalls selbst einstellen: git config --global init.defaultBranch <name>.

Anschauen kann man sich die Einstellungen in der Textdatei ~/.gitconfig oder per Befehl git config --global -l.

Neues Repo anlegen

  • git init

    => Erzeugt neues Repository im akt. Verzeichnis

  • git clone <url>

    => Erzeugt (verlinkte) Kopie des Repos unter <url>

Wrap-Up

  • Git: Versionsmanagement mit dezentralen Repositories
  • Anlegen eines lokalen Repos mit git init
  • Clonen eines existierenden Repos mit git clone <url>
Quellen

Basics der Versionsverwaltung mit Git (lokale Repos)

TL;DR

Änderungen an Dateien (in der Workingcopy) werden mit git add zum "Staging" (Index) hinzugefügt. Dies ist eine Art Sammelbereich für Änderungen, die mit dem nächsten Commit in das Repository überführt werden. Neue (bisher nicht versionierte Dateien) müssen ebenfalls zunächst mit git add zum Staging hinzugefügt werden.

Änderungen kann man mit git log betrachten, dabei erhält man u.a. eine Liste der Commits und der jeweiligen Commmit-Messages.

Mit git diff kann man gezielt Änderungen zwischen Commits oder Branches betrachten.

Mit git tag kann man bestimmte Commits mit einem "Stempel" (zusätzlicher Name) versehen, um diese leichter finden zu können.

Wichtig sind die Commit-Messages: Diese sollten eine kurze Zusammenfassung haben, die aktiv formuliert wird (was ändert dieser Commit: "Formatiere den Java-Code entsprechend Style"; nicht aber "Java-Code nach Style formatiert"). Falls der Kommentar länger sein soll, folgt eine Leerzeile auf die erste Zeile (Zusammenfassung) und danach ein Block mit der längeren Erklärung.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Umgang mit Dateien: Hinzufügen zum und Löschen aus Repo
  • (K3) Umgang mit Änderungen: Hinzufügen zum Staging und Commit
  • (K3) Herausfinden von Unterschieden, Ansehen der Historie
  • (K3) Ignorieren von Dateien und Ordnern

Versionsverwaltung mit Git: Typische Arbeitsschritte

  1. Repository anlegen (oder clonen)

  2. Dateien neu erstellen (und löschen, umbenennen, verschieben)

  3. Änderungen einpflegen ("committen")

  4. Änderungen und Logs betrachten

  5. Änderungen rückgängig machen

  6. Projektstand markieren ("taggen")

  7. Entwicklungszweige anlegen ("branchen")

  8. Entwicklungszweige zusammenführen ("mergen")

  9. Änderungen verteilen (verteiltes Arbeiten, Workflows)

Dateien unter Versionskontrolle stellen

  1. git add . (oder git add <file>)

    => Stellt alle Dateien (bzw. die Datei <file>) im aktuellen Verzeichnis unter Versionskontrolle

  2. git commit

    => Fügt die Dateien dem Repository hinzu

Abfrage mit git status

Änderungen einpflegen

  • Abfrage mit: git status
  • "Staging" von modifizierten Dateien: git add <file>
  • Committen der Änderungen im Stage: git commit

Anmerkung: Alternativ auch mit git commit -m "Kommentar", um das Öffnen des Editors zu vermeiden ... geht einfach schneller ;)

Das "staging area" stellt eine Art Zwischenebene zwischen Working Copy und Repository dar: Die Änderungen sind temporär "gesichert", aber noch nicht endgültig im Repository eingepflegt ("committed").

Man kann den Stage dazu nutzen, um Änderungen an einzelnen Dateien zu sammeln und diese dann (in einem Commit) gemeinsam einzuchecken.

Man kann den Stage in der Wirkung umgehen, indem man alle in der Working Copy vorliegenden Änderungen per git commit -a -m "Kommentar" eincheckt. Der Schalter "-a" nimmt alle vorliegenden Änderungen an bereits versionierten Dateien, fügt diese dem Stage hinzu und führt dann den Commit durch. Das ist das von SVN bekannte Verhalten. Achtung: Nicht versionierte Dateien bleiben dabei außen vor!

Letzten Commit ergänzen

  • git commit --amend -m "Eigentlich wollte ich das so sagen"

    Wenn keine Änderungen im Stage sind, wird so die letzte Commit-Message geändert.

  • git add <file>; git commit --amend

    Damit können vergessene Änderungen an der Datei <file> zusätzlich im letzten Commit aufgezeichnet werden.

    In beiden Fällen ändert sich die Commit-ID!

Weitere Datei-Operationen: hinzufügen, umbenennen, löschen

  • Neue (unversionierte) Dateien und Änderungen an versionierten Dateien zum Staging hinzufügen: git add <file>
  • Löschen von Dateien (Repo+Workingcopy): git rm <file>
  • Löschen von Dateien (nur Repo): git rm --cached <file>
  • Verschieben/Umbenennen: git mv <fileAlt> <fileNeu>

Aus Sicht von Git sind zunächst alle Dateien "untracked", d.h. stehen nicht unter Versionskontrolle.

Mit git add <file> (und git commit) werden Dateien in den Index (den Staging-Bereich, d.h. nach dem Commit letztlich in das Repository) aufgenommen. Danach stehen sie unter "Beobachtung" (Versionskontrolle). So lange, wie eine Datei identisch zur Version im Repository ist, gilt sie als unverändert ("unmodified"). Eine Änderung führt entsprechend zum Zustand "modified", und ein git add <file> speichert die Änderungen im Stage. Ein Commit überführt die im Stage vorgemerkte Änderung in das Repo, d.h. die Datei gilt wieder als "unmodified".

Wenn eine Datei nicht weiter versioniert werden soll, kann sie aus dem Repo entfernt werden. Dies kann mit git rm <file> geschehen, wobei die Datei auch aus der Workingcopy gelöscht wird. Wenn die Datei erhalten bleiben soll, aber nicht versioniert werden soll (also als "untracked" markiert werden soll), dann muss sie mit git rm --cached <file> aus der Versionskontrolle gelöscht werden. Achtung: Die Datei ist dann nur ab dem aktuellen Commit gelöscht, d.h. frühere Revisionen enthalten die Datei noch!

Wenn eine Datei umbenannt werden soll, geht das mit git mv <fileAlt> <fileNeu>. Letztlich ist dies nur eine Abkürzung für die Folge git rm --cached <fileAlt>, manuelles Umbenennen der Datei in der Workingcopy und git add <fileNeu>.

Commits betrachten

  • Liste aller Commits: git log

    • git log -<n> oder git log --since="3 days ago" Meldungen eingrenzen ...
    • git log --stat Statistik ...
    • git log --author="pattern" Commits eines Autors
    • git log <file> Änderungen einer Datei
  • Inhalt eines Commits: git show

Änderungen und Logs betrachten

  • git diff [<file>]

    Änderungen zwischen Workingcopy und letztem Commit (ohne Stage)

    Das "staging area" wird beim Diff von Git behandelt, als wären die dort hinzugefügten Änderungen bereits eingecheckt (genauer: als letzter Commit im aktuellen Branch im Repo vorhanden). D.h. wenn Änderungen in einer Datei mittels git add <datei> dem Stage hinzugefügt wurden, zeigt git diff <datei> keine Änderungen an!

  • git diff commitA commitB

    Änderungen zwischen Commits

  • Blame: git blame <file>

    Wer hat was wann gemacht?

Dateien ignorieren: .gitignore

  • Nicht alle Dateien gehören ins Repo:
    • generierte Dateien: .class
    • temporäre Dateien
  • Datei .gitignore anlegen und committen
    • Wirkt auch für Unterordner
    • Inhalt: Reguläre Ausdrücke für zu ignorierende Dateien und Ordner
    # Compiled source #
    *.class
    *.o
    *.so

    # Packages #
    *.zip

    # All directories and files in a directory #
    bin/**/*

Zeitmaschine

  • Änderungen in Workingcopy rückgängig machen

    • Änderungen nicht in Stage: git checkout <file> oder git restore <file>
    • Änderungen in Stage: git reset HEAD <file> oder git restore --staged <file>

    => Hinweise von git status beachten!

  • Datei aus altem Stand holen:

    • git checkout <commit> <file>, oder
    • git restore --source <commit> <file>
  • Commit verwerfen, Geschichte neu: git revert <commit>

Hinweis: In den neueren Versionen von Git ist der Befehl git restore hinzugekommen, mit dem Änderungen rückgängig gemacht werden können. Der bisherige Befehl git checkout steht immer noch zur Verfügung und bietet über git restore hinaus weitere Anwendungsmöglichkeiten.

  • Stempel (Tag) vergeben: git tag <tagname> <commit>
  • Tags anzeigen: git tag und git show <tagname>

Wann und wie committen?

Jeder Commit stellt einen Rücksetzpunkt dar!

Typische Regeln:

  • Kleinere "Häppchen" einchecken: ein Feature oder Task (das nennt man auch atomic commit: das kleinste Set an Änderungen, die gemeinsam Sinn machen und die ggf. gemeinsam zurückgesetzt werden können)
  • Logisch zusammenhängende Änderungen gemeinsam einchecken
  • Projekt muss nach Commit compilierbar sein
  • Projekt sollte nach Commit lauffähig sein

Ein Commit sollte in sich geschlossen sein, d.h. die kleinste Menge an Änderungen enthalten, die gemeinsam einen Sinn ergeben und die (bei Bedarf) gemeinsam zurückgesetzt oder verschoben werden können. Das nennt man auch atomic commit.

Wenn Sie versuchen, die Änderungen in Ihrem Commit zu beschreiben (siehe nächste Folie "Commit-Messages"), dann werden Sie einen atomic commit mit einem kurzen Satz (natürlich im Imperativ!) beschreiben können. Wenn Sie mehr Text brauchen, haben Sie wahrscheinlich keinen atomic commit mehr vor sich.

Lesen Sie dazu auch How atomic Git commits dramatically increased my productivity - and will increase yours too.

Schreiben von Commit-Messages: WARUM?!

Schauen Sie sich einmal einen Screenshot eines git log --oneline 61e48f0..e2c8076 im Dungeon-CampusMinden/Dungeon an:

Nun stellen Sie sich vor, Sie sind auf der Suche nach Informationen, suchen einen bestimmten Commit oder wollen eine bestimmte Änderung finden ...

Wenn man das genauer analysiert, dann stören bestimmte Dinge:

  • Mischung aus Deutsch und Englisch
  • "Vor-sich-hin-Murmeln": "Layer system 5"
  • Teileweise werden Tags genutzt wie [BUG], aber nicht durchgängig
  • Mischung zwischen verschiedenen Formen: "Repo umbenennen", "Benenne Repo um", "Repo umbenannt"
  • Unterschiedliche Groß- und Kleinschreibung
  • Sehr unterschiedlich lange Zeilen/Kommentare

Das Beachten einheitlicher Regeln ist enorm wichtig!

Leider sagt sich das so leicht - in der Praxis macht man es dann doch schnell wieder unsauber. Dennoch, auch im Dungeon-Repo gibt es einen positiven Trend (git log --oneline 8039d6c..7f49e89):

Typische Regeln und Konventionen tauchen überall auf, beispielsweise in [Chacon2014] oder bei Tim Pope (siehe nächstes Beispiel) oder bei "How to Write a Git Commit Message".

Short (50 chars or less) summary of changes

More detailed explanatory text, if necessary.  Wrap it to about
72 characters or so.  In some contexts, the first line is treated
as the subject of an email and the rest of the text as the body.
The blank line separating the summary from the body is critical
(unless you omit the body entirely); tools like rebase can get
confused if you run the two together.

Further paragraphs come after blank lines.

 - Bullet points are okay, too
 - Typically a hyphen or asterisk is used for the bullet, preceded
   by a single space, with blank lines in between, but conventions
   vary here

Quelle: "A Note About Git Commit Messages" by Tim Pope on tbaggery.com

Denken Sie sich die Commit-Message als E-Mail an einen zukünftigen Entwickler, der das in fünf Jahren liest!

Vom Aufbau her hat eine E-Mail auch eine Summary und dann den eigentlichen Inhalt ... Erklären Sie das "WARUM" der Änderung! (Das "WER", "WAS", "WANN" wird bereits automatisch von Git aufgezeichnet ...)

Lesen (und beachten) Sie unbedingt auch "How to Write a Git Commit Message"!

Ausflug "Conventional Commits"

Die Commit-Messages dienen vor allem der Dokumentation und werden von Entwicklern gelesen.

Wenn man die Messages ein wenig stärker formalisieren würde, dann könnte man diese aber auch mit Tools verarbeiten und beispielsweise automatisiert Changelogs oder Release-Texte verfassen!

Betrachten Sie einmal das Projekt ConventionalCommits.org. Dies ist ein solcher Versuch, die Commit-Messages (a) einheitlicher und lesbarer zu gestalten und (b) auch eine Tool-gestützte Auswertung zu erlauben.

Das Projekt schlägt als Erweitung der üblichen Regeln zum Formatieren von Commit-Messages vor, dass in der ersten Zeile der Summary noch eine Abkürzung für die in diesem Commit erfolgte Änderung (Bug-Fix, neues Feature, ...) vorangestellt wird. Dieser Abkürzung kann in Klammern noch der Scope der Änderung hinzugefügt werden, beispielsweise den Bereich im Projekt, der von diesem Commit berührt wird. Wenn es eine breaking change ist, also alter Code nach dieser Änderung sich anders verhält oder vielleicht sogar nicht mehr kompiliert, wird noch ein "!" hinter dem Typ der Änderung ergänzt.

Beispiel: Stellen Sie sich vor, im Dungeon-Projekt wurde ein neues Verhalten hinzugefügt.

  1. Normalerweise hätten Sie vielleicht diese Message geschrieben (angepasste Version aus Dungeon-CampusMinden/Dungeon/pull/469):

    add fight skill
    
    -   `DamageProjectileSkill` creates a new entity which causes `HealthDamage` when hitting another entity
    -   `FireballSkill` is a more concrete implementation of this
    -   Melee skills can be created with `DamageProjectileSkill` using a customised range
        -   Example: the `FireballSkill` has a range of 10, a melee would have a considerably smaller range
    
    fixes #24
    fixes #126
    fixes #224
    
  2. Mit ConventionalCommits.org könnte das dann so aussehen:

    feat: add fight skill
    
    -   `DamageProjectileSkill` creates a new entity which causes `HealthDamage` when hitting another entity
    -   `FireballSkill` is a more concrete implementation of this
    -   Melee skills can be created with `DamageProjectileSkill` using a customised range
        -   Example: the `FireballSkill` has a range of 10, a melee would have a considerably smaller range
    
    fixes #24
    fixes #126
    fixes #224
    

    Da es sich um ein neues Feature handelt, wurde der Summary in der ersten Zeile ein feat: vorangestellt.

    Die zu verwendenden Typen/Abkürzungen sind im Prinzip frei definierbar. Das Projekt ConventionalCommits.org schlägt eine Reihe von Abkürzungen vor. Auf diese Weise sollen in möglichst allen Projekten, die Conventional Commits nutzen, die selben Abkürzungen/Typen eingesetzt werden und so eine Tool-gestützte Auswertung möglich werden.

  3. Oder zusätzlich mit dem Scope der Änderung:

    feat(game): add fight skill
    
    -   `DamageProjectileSkill` creates a new entity which causes `HealthDamage` when hitting another entity
    -   `FireballSkill` is a more concrete implementation of this
    -   Melee skills can be created with `DamageProjectileSkill` using a customised range
        -   Example: the `FireballSkill` has a range of 10, a melee would have a considerably smaller range
    
    fixes #24
    fixes #126
    fixes #224
    

    Der Typ feat wurde hier noch ergänzt um einen frei definierbaren Identifier für den Projektbereich. Dieser wird in Klammern direkt hinter den Typ notiert (hier feat(game):).

    Im Beispiel habe ich als Bereich "game" genommen, weil die Änderung sich auf den Game-Aspekt des Projekts bezieht. Im konkreten Projekt wären andere Bereiche eventuell "dsl" (für die im Projekt entwickelte Programmiersprache plus Interpreter) und "blockly" (für die Integration von Google Blockly zur Programmierung des Dungeons mit LowCode-Ansätzen). Das ist aber letztlich vom Projekt abhängig und weitestgehend Geschmackssache.

  4. Oder zusätzlich noch als Auszeichnung "breaking change" (hier mit scope, geht aber auch ohne scope):

    feat(game)!: add fight skill
    
    -   `DamageProjectileSkill` creates a new entity which causes `HealthDamage` when hitting another entity
    -   `FireballSkill` is a more concrete implementation of this
    -   Melee skills can be created with `DamageProjectileSkill` using a customised range
        -   Example: the `FireballSkill` has a range of 10, a melee would have a considerably smaller range
    
    fixes #24
    fixes #126
    fixes #224
    

    Angenommen, das neue Feature muss in der API etwas ändern, so dass existierender Code nun nicht mehr funktionieren würde. Dies wird mit dem extra Ausrufezeichen hinter dem Typ/Scope kenntlich gemacht (hier feat(game)!:).

    Zusätzlich kann man einen "Footer" in die Message einbauen, also eine extra Zeile am Ende, die mit dem String "BREAKING CHANGE:" eingeleitet wird. (vgl. Conventional Commits > Examples)

Es gibt noch viele weitere Initiativen, Commit-Messages lesbarer zu gestalten und zu vereinheitlichen. Schauen Sie sich beispielsweise einmal gitmoji.dev an. (Mit einem Einsatz in einem professionellen Umfeld wäre ich hier aber sehr ... vorsichtig.)

Wrap-Up

  • Änderungen einpflegen zweistufig (add, commit)
  • Status der Workingcopy mit status ansehen
  • Logmeldungen mit log ansehen
  • Änderungen auf einem File mit diff bzw. blame ansehen
  • Projektstand markieren mit tag
  • Ignorieren von Dateien/Ordnern: Datei .gitignore
Challenges

Versionierung 101

  1. Legen Sie ein Repository an.
  2. Fügen Sie Dateien dem Verzeichnis hinzu und stellen Sie einige davon unter Versionskontrolle.
  3. Ändern Sie eine Datei und versionieren Sie die Änderung.
  4. Was ist der Unterschied zwischen "git add .; git commit" und "git commit -a"?
  5. Wie finden Sie heraus, welche Dateien geändert wurden?
  6. Entfernen Sie eine Datei aus der Versionskontrolle, aber nicht aus dem Verzeichnis!
  7. Entfernen Sie eine Datei komplett (Versionskontrolle und Verzeichnis).
  8. Ändern Sie eine Datei und betrachten die Unterschiede zum letzten Commit.
  9. Fügen Sie eine geänderte Datei zum Index hinzu. Was erhalten Sie bei git diff <datei>?
  10. Wie können Sie einen früheren Stand einer Datei wiederherstellen? Wie finden Sie überhaupt den Stand?
  11. Legen Sie sich ein Java-Projekt in Ihrer IDE an an. Stellen Sie dieses Projekt unter Git-Versionskontrolle. Führen Sie die vorigen Schritte mit Ihrer IDE durch.
Quellen

Git Branches: Features unabhängig entwickeln und mit Git verwalten

TL;DR

Die Commits in Git bauen aufeinander auf und bilden dabei eine verkettete "Liste". Diese "Liste" nennt man auch Branch (Entwicklungszweig). Beim Initialisieren eines Repositories wird automatisch ein Default-Branch angelegt, auf dem die Commits dann eingefügt werden.

Weitere Branches kann man mit git branch anlegen, und die Workingcopy kann mit git switch oder git checkout auf einen anderen Branch umgeschaltet werden. Auf diese Weise kann man an mehreren Features parallel arbeiten, ohne dass die Arbeiten sich gegenseitig stören.

Zum Mergen (Vereinigen) von Branches gibt es git merge. Dabei werden die Änderungen im angegebenen Branch in den aktuell in der Workingcopy ausgecheckten Branch integriert und hier ggf. ein neuer Merge-Commit erzeugt. Falls es in beiden Branches inkompatible Änderungen an der selben Stelle gab, entsteht beim Mergen ein Merge-Konflikt. Dabei zeigt Git in den betroffenen Dateien jeweils an, welche Änderung aus welchem Branch stammt und man muss diesen Konflikt durch Editieren der Stellen manuell beheben.

Mit git rebase kann die Wurzel eines Branches an eine andere Stelle verschoben werden. Dies wird später bei Workflows eine Rolle spielen.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Erzeugen von Branches
  • (K3) Mergen von Branches, Auflösen möglicher Konflikte
  • (K3) Rebasen von Branches

Neues Feature entwickeln/ausprobieren

A---B---C  master
  • Bisher nur lineare Entwicklung: Commits bauen aufeinander auf (lineare Folge von Commits)
  • master ist der (Default-) Hauptentwicklungszweig
    • Pointer auf letzten Commit
    • Default-Name: "master" (muss aber nicht so sein bzw. kann geändert werden)

Anmerkung: Git und auch Github haben den Namen für den Default-Branch von master auf maingeändert. Der Name an sich ist aber für Git bedeutungslos und kann mittels git config --global init.defaultBranch <name> geändert werden. In Github hat der Default-Branch eine gewisse Bedeutung, beispielsweise ist der Default-Branch das automatische Ziel beim Anlegen von Pull-Requests. In Github kann man den Default-Namen global in den User-Einstellungen (Abschnitt "Repositories") und für jedes einzelne Repository in den Repo-Einstellungen (Abschnitt "Branches") ändern.

Entwicklung des neuen Features soll stabilen master-Branch nicht beeinflussen => Eigenen Entwicklungszweig für die Entwicklung des Features anlegen:

  1. Neuen Branch erstellen: git branch wuppie
  2. Neuen Branch auschecken: git checkout wuppie oder git switch wuppie

Alternativ: git checkout -b wuppie oder git switch -c wuppie (neuer Branch und auschecken in einem Schritt)

A---B---C  master, wuppie

Startpunkt: prinzipiell beliebig (jeder Commit in der Historie möglich).

Die gezeigten Beispiel zweigen den neuen Branch direkt vom aktuell ausgecheckten Commit/Branch ab. Also aufpassen, was gerade in der Workingcopy los ist!

Alternativ nutzen Sie die Langform: git branch wuppie master (mit master als Startpunkt; hier kann jeder beliebige Branch, Tag oder Commit genutzt werden).

Nach Anlegen des neuen Branches zeigen beide Pointer auf den selben Commit.

Anmerkung: In neueren Git-Versionen wurde der Befehl "switch" eingeführt, mit dem Sie in der Workingcopy auf einen anderen Branch wechseln können. Der bisherige Befehl "checkout" funktioniert aber weiterhin.

Arbeiten im Entwicklungszweig ...

          D  wuppie
         /
A---B---C  master
  • Entwicklung des neuen Features erfolgt im eigenen Branch: beeinflusst den stabilen master-Branch nicht
  • Wenn in der Workingcopy der Feature-Branch ausgecheckt ist, gehen die Commits in den Feature-Branch; der master bleibt auf dem alten Stand
  • Wenn der master ausgecheckt wäre, würden die Änderungen in den master gehen, d.h. der master würde sich ab Commit C parallel zu wuppie entwickeln

Problem: Fehler im ausgelieferten Produkt

          D  wuppie
         /
A---B---C  master

Fix für master nötig:

  1. git checkout master
  2. git checkout -b fix
  3. Änderungen in fix vornehmen ...

Das führt zu dieser Situation:

          D  wuppie
         /
A---B---C  master
         \
          E  fix

git checkout <branchname> holt den aktuellen Stand des jeweiligen Branches in die Workingcopy. (Das geht in neueren Git-Versionen auch mit git switch <branchname>.)

Man kann weitere Branches anlegen, d.h. hier im Beispiel ein neuer Feature-Branch fix, der auf dem master basiert. Analog könnte man auch Branches auf der Basis von wuppie anlegen ...

Fix ist stabil: Integration in master

          D  wuppie
         /
A---B---C  master
         \
          E  fix
  1. git checkout master
  2. git merge fix => fast forward von master
  3. git branch -d fix

Der letzte Schritt entfernt den Branch fix.

          D  wuppie
         /
A---B---C---E  master
  • Allgemein: git merge <branchname> führt die Änderungen im angegebenen Branch <branchname> in den aktuell in der Workingcopy ausgecheckten Branch ein. Daraus resultiert für den aktuell ausgecheckten Branch ein neuer Commit, der Branch <branchname> bleibt dagegen auf seinem bisherigen Stand.

    Beispiel:

    • Die Workingcopy ist auf A
    • git merge B führt A und B zusammen: B wird in A gemergt
    • Wichtig: Der Merge-Commit (sofern nötig) findet hierbei in A statt!

    In der Abbildung ist A der master und B der fix.

  • Nach dem Merge existieren beide Branches weiter (sofern sie nicht explizit gelöscht werden)

  • Hier im Beispiel findet ein sogenannter "Fast forward" statt.

    "Fast forward" ist ein günstiger Spezialfall beim Merge: Beide Branches liegen in einer direkten Kette, d.h. der Zielbranch kann einfach "weitergeschaltet" werden. Ein Merge-Commit ist in diesem Fall nicht notwendig und wird auch nicht angelegt.

Feature weiter entwickeln ...

          D---F  wuppie
         /
A---B---C---E  master
  1. git switch wuppie
  2. Weitere Änderungen im Branch wuppie ...

git switch <branchname> holt den aktuellen Stand des jeweiligen Branches in die Workingcopy. Man kann also jederzeit in der Workingcopy die Branches wechseln und entsprechend weiterarbeiten.

Hinweis: Während der neue git switch-Befehl nur Branches umschalten kann, funktioniert git checkout sowohl mit Branchnamen und Dateinamen - damit kann man also auch eine andere Version einer Datei in der Workingcopy "auschecken". Falls gleiche Branch- und Dateinamen existieren, muss man für das Auschecken einer Datei noch "--" nutzen: git checkout -- <dateiname>.

Feature ist stabil: Integration in master

          D---F  wuppie                            D---F  wuppie
         /                     =>                 /     \
A---B---C---E  master                    A---B---C---E---G  master
  1. git checkout master
  2. git merge wuppie => Kein fast forward möglich: Git sucht nach gemeinsamen Vorgänger

Hier im Beispiel ist der Standardfall beim Mergen dargestellt: Die beiden Branches liegen nicht in einer direkten Kette von Commits, d.h. hier wurde parallel weitergearbeitet.

Git sucht in diesem Fall nach dem gemeinsamen Vorgänger beider Branches und führt die jeweiligen Änderungen (Differenzen) seit diesem Vorgänger in einem Merge-Commit zusammen.

Im master entsteht ein neuer Commit, da kein fast forward beim Zusammenführen der Branches möglich!

Anmerkung: git checkout wuppie; git merge master würde den master in den wuppie mergen, d.h. der Merge-Commit wäre dann in wuppie.

Beachten Sie dabei die "Merge-Richtung":

  • Die Workingcopy ist auf A
  • git merge B führt A und B zusammen: B wird in A gemergt
  • Wichtig: Der Merge-Commit (sofern nötig) findet hierbei in A statt!

In der Abbildung ist A der master und B der wuppie.

Achtung: Richtung beachten! git checkout A; git merge B führt beide Branches zusammen, genauer: führt die Änderungen von B in A ein, d.h. der entsprechende Merge-Commit ist in A!

Konflikte beim Mergen

(Parallele) Änderungen an selber Stelle => Merge-Konflikte

$ git merge wuppie
Auto-merging hero.java
CONFLICT (content): Merge conflict in hero.java
Automatic merge failed; fix conflicts and then commit the result.

Git fügt Konflikt-Marker in die Datei ein:

<<<<<<< HEAD:hero.java
public void getActiveAnimation() {
    return null;
=======
public Animation getActiveAnimation() {
    return this.idleAnimation;
>>>>>>> wuppie:hero.java
  • Der Teil mit HEAD ist aus dem aktuellen Branch in der Workingcopy
  • Der Teil aus dem zu mergenden Branch ist unter wuppie notiert
  • Das ======= trennt beide Bereiche

Merge-Konflikte auflösen

Manuelles Editieren nötig (Auflösung des Konflikts):

  1. Entfernen der Marker
  2. Hinzufügen der Datei zum Index
  3. Analog für restliche Dateien mit Konflikt
  4. Commit zum Abschließen des Merge-Vorgangs

Alternativ: Nutzung graphischer Oberflächen mittels git mergetool

Rebasen: Verschieben von Branches

          D---F  wuppie                            D---F  wuppie
         /                     =>                 /     \
A---B---C---E  master                    A---B---C---E---G  master

Bisher haben wir Branches durch Mergen zusammengeführt. Dabei entsteht in der Regel ein extra Merge-Commit (im Beispiel G), außer es handelt sich um ein fast forward. Außerdem erkennt man in der Historie sehr gut, dass hier in einem separaten Branch gearbeitet wurde, der irgendwann in den master gemergt wurde.

Leider wird dieses Vorgehen in großen Projekten recht schnell sehr unübersichtlich. Außerdem werden Merges in der Regeln nur von besonders berechtigten Personen (Manager) durchgeführt, die im Falle von Merge-Konflikten diese dann selbst auflösen müssten (ohne aber die fachliche Befähigung zu haben). Hier greift man dann häufig zur Alternative Rebase. Dabei wird der Ursprung eines Branches auf einen bestimmten Commit verschoben. Im Anschluss ist dann ein Merge mit fast forward, also ohne die typischen rautenförmigen Ketten in der Historie und ohne extra Merge-Commit möglich. Dies kann aber auch als Nachteil gesehen werden, da man in der Historie den früheren Branch nicht mehr erkennt! Ein weiterer schwerwiegender Nachteil ist, dass alle Commits im verschobenen Branch umgeschrieben werden und damit neue Commit-IDs bekommen. Das verursacht bei der Zusammenarbeit in Projekten massive Probleme! Als Vorteil gilt, dass man mögliche Merge-Konflikte bereits beim Rebasen auflösen muss, d.h. hier muss derjenige, der den Merge "beantragt", durch einen vorherigen Rebase den konfliktfreien Merge sicherstellen. Mehr dazu in “Branching-Strategien” und “Workflows”.

git rebase master wuppie

führt zu

              D'---F'  wuppie
             /
A---B---C---E  master

Nach dem Rebase von wuppie auf master sieht es so aus, als ob der Branch wuppie eben erst vom master abgezweigt wurde. Damit ist dann ein fast forward Merge von wuppie in den master möglich, d.h. es gibt keine Raute und auch keinen extra Merge-Commit (hier nicht gezeigt).

Man beachte aber die Änderung der Commit-IDs von wuppie: Aus D wird D'! (Datum, Ersteller und Message bleiben aber erhalten.)

Don't lose your HEAD

  • Branches sind wie Zeiger auf letzten Stand (Commit) eines Zweiges

  • HEAD: Spezieller Pointer

    • Zeigt auf den aktuellen Branch der Workingcopy
  • Früheren Commit auschecken (ohne Branch): "headless state"

    • Workingcopy ist auf früherem Commit

    • Kein Branch => Änderungen gehen verloren!

      Eventuelle Änderungen würden ganz normal als Commits auf dem HEAD-Branch aufgezeichnet. Sobald man aber einen anderen Branch auscheckt, wird der HEAD auf diesen anderen Branch gesetzt, so dass die eben gemachten Commits "in der Luft hängen". Sofern man die SHA's kennt, kommt man noch auf die Commits zurück. Allerdings laufen von Zeit zu Zeit interne Aufräum-Aktionen, so dass die Chance gut steht, dass die "kopflosen" Commits irgendwann tatsächlich verschwinden.

Wrap-Up

  • Anlegen von Branches mit git branch
  • Umschalten der Workingcopy auf anderen Branch: git checkout oder git switch
  • Mergen von Branches und Auflösen von Konflikten: git merge
  • Verschieben von Branches mit git rebase
Challenges

Branches und Merges

  1. Legen Sie in Ihrem Projekt einen Branch an. Ändern Sie einige Dateien und committen Sie die Änderungen. Checken Sie den Master-Branch aus und mergen Sie die Änderungen. Was beobachten Sie?

  2. Legen Sie einen weiteren Branch an. Ändern Sie einige Dateien und committen Sie die Änderungen. Checken Sie den Master-Branch aus und ändern Sie dort ebenfalls:

    • Ändern Sie eine Datei an einer Stelle, die nicht bereits im Branch modifiziert wurde.
    • Ändern Sie eine Datei an einer Stelle, die bereits im Branch manipuliert wurde.

    Committen Sie die Änderungen.

    Mergen Sie den Branch jetzt in den Master-Branch. Was beobachten Sie? Wie lösen Sie Konflikte auf?

Mergen am Beispiel

Sie verwalten Ihr Projekt mit Git. Es existieren zwei Branches: master (zeigt auf Commit $C$) und feature (zeigt auf Version $F$). In Ihrer Workingcopy haben Sie den Branch feature ausgecheckt:

(1) Mit welcher Befehlsfolge können Sie den Branch feature in den Branch master mergen, so dass nach dem Merge die im folgenden Bild dargestellte Situation entsteht?

(Der Merge läuft ohne Konflikte ab. Es ist irrelevant, welcher Branch am Ende in der Workingcopy ausgecheckt ist.)

(2) Wie können Sie erreichen, dass es keinen Merge-Commit gibt, sondern dass die Änderungen in $D$ und $F$ im master als eine lineare Folge von Commits erscheinen?

Interaktive Git-Tutorials: Schaffen Sie die Rätsel?

Quellen

Branching-Strategien mit Git

TL;DR

Das Erstellen und Mergen von Branches ist in Git besonders einfach. Dies kann man sich in der Entwicklung zunutze machen und die einzelnen Features unabhängig voneinander in eigenen Hilfs-Branches ausarbeiten.

Es haben sich zwei grundlegende Modelle etabliert: "Git-Flow" und "GitHub Flow".

In Git-Flow gibt es ein umfangreiches Konzept mit verschiedenen Branches für feste Aufgaben, welches sich besonders gut für Entwicklungmodelle mit festen Releases eignet. Es gibt zwei langlaufende Branches: master enthält den stabilen veröffentlichten Stand, in develop werden die Ergebnisse der Entwicklung gesammelt. Features werden in kleinen Feature-Branches entwickelt, die von develop abzweigen und dort wieder hineinmünden. Für Releases wird von develop ein eigener Release-Branch angelegt und nach Finalisierung in den master und in develop gemergt. Fixes werden vom master abgezweigt, und wieder in den master und auch nach develop integriert. Dadurch stehen auf dem master immer die stabilen Release-Stände zur Verfügung, und im develop sammeln sich die Entwicklungsergebnisse.

Der GitHub Flow basiert auf einem deutlich schlankeren Konzept und passt gut für die kontinuierliche Entwicklung ohne echte Releases. Hier hat man auch wieder einen master als langlaufenden Branch, der die stabilen Release-Stände enthält. Vom master zweigen direkt die kleinen Feature-Branches ab und werden auch wieder direkt in den master integriert.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K3) Einsatz von Themenbranches in der Entwicklung
  • (K3) Git-Flow-Modell anwenden
  • (K3) GitHub Flow-Modell anwenden

Nutzung von Git in Projekten: Verteiltes Git (und Workflows)

Git ermöglicht ein einfaches und schnelles Branchen. Dies kann man mit entsprechenden Branching-Strategien sinnvoll für die SW-Entwicklung einsetzen.

Im Folgenden sollen also die Frage betrachtet werden: Wie setze ich Branches sinnvoll ein?

Umgang mit Branches: Themen-Branches

                I---J---K  wuppieV1
               /
          D---F  wuppie
         /
A---B---C---E  master
             \
              G---H  test

Branchen ist in Git sehr einfach und schnell. Deshalb wird (gerade auch im Vergleich mit SVN) gern und viel gebrancht.

Ein häufiges anzutreffendes Modell ist dabei die Nutzung von Themen-Branches: Man hat einen Hauptzweig (master). Wann immer eine neue Idee oder ein Baustein unabhängig entwickelt werden soll/kann, wird ein entsprechender Themen-Branch aufgemacht. Dabei handelt es sich normalerweise um kleine Einheiten!

Themenbranches haben in der Regel eine kurze Lebensdauer: Wenn die Entwicklung abgeschlossen ist, wird die Idee bzw. der Baustein in den Hauptzweig integriert und der Themenbranch gelöscht.

  • Vorteil: Die Entwicklung im Themenbranch ist in sich gekapselt und stört nicht die Entwicklung in anderen Branches (und diese stören umgekehrt nicht die Entwicklung im Themenbranch).

  • Nachteil:

    • Mangelnder Überblick durch viele Branches
    • Ursprung der Themenbranches muss überlegt gewählt werden, d.h. alle dort benötigten Features müssen zu dem Zeitpunkt im Hauptzweig vorhanden sein

Umgang mit Branches: Langlaufende Branches

A---B---D  master
     \
      C---E---I  develop
           \
            F---G---H  topic

Häufig findet man in (größeren) Projekten Branches, die über die gesamte Lebensdauer des Projekts existieren, sogenannte "langlaufende Branches".

Normalerweise gibt es einen Branch, in dem stets der stabile Stand des Projekts enthalten ist. Dies ist häufig der master. In diesem Branch gibt es nur sehr wenige Commits: normalerweise nur Merges aus dem develop-Branch (etwa bei Fertigstellung einer Release-Version) und ggf. Fehlerbehebungen.

Die aktive Entwicklung findet in einem separaten Branch statt: develop. Hier nutzt man zusätzlich Themen-Branches für die Entwicklung einzelner Features, die nach Fertigstellung in den develop gemergt werden.

Kleinere Projekte kommen meist mit den zwei langlaufenden Branches in der obigen Darstellung aus. Bei größeren Projekten finden sich häufig noch etliche weitere langlaufende Branches, beispielsweise "Proposed Updates" etc. beim Linux-Kernel.

  • Vorteile:
    • Mehr Struktur im Projekt durch in ihrer Semantik wohldefinierte Branches
    • Durch weniger Commits pro Branch lässt sich die Historie leichter verfolgen (u.a. auch aus bestimmter Rollen-Perspektive: Entwickler, Manager, ...)
  • Nachteile: Bestimmte "ausgezeichnete" Branches; zusätzliche Regeln zum Umgang mit diesen beachten

Komplexe Branching-Strategie: Git-Flow

A---B---------------------G---J1  master
     \                   / \ /
      \                 /   X  fix
       \               /     \
        C-------------F----I--J2  develop
         \           / \  /
          \         /   H1  featureB
           \       /
            D1----D2  featureA
             \
              E1---E2---E3---E4---E5  featureC

Das Git-Flow-Modell von Vincent Driessen (nvie.com/posts/a-successful-git-branching-model) zeigt einen in der Praxis überaus bewährten Umgang mit Branches. Lesen Sie an der angegebenen Stelle nach, besser kann man die Nutzung dieses eleganten Modells eigentlich nicht erklären :-)

Git-Flow: Hauptzweige master und develop

A---B-------E---------------J  master
     \     /               /
      C---D---F---G---H---I---K  develop

Bei Git-Flow gibt es zwei langlaufende Branches: Den master, der immer den stabilen Stand enthält und in den nie ein direkter Commit gemacht wird, sowie den develop, wo letztlich (ggf. über Themenbranches) die eigentliche Entwicklung stattfindet.

Änderungen werden zunächst im develop erstellt und getestet. Wenn die Features stabil sind, erfolgt ein Merge von develop in den master. Hier kann noch der Umweg über einen release-Branch genommen werden: Als "Feature-Freeze" wird vom develop ein release-Branch abgezweigt. Darin wird das Release dann aufpoliert, d.h. es erfolgen nur noch kleinere Korrekturen und Änderungen, aber keine echte Entwicklungsarbeit mehr. Nach Fertigstellung wird der release dann sowohl in den master als auch develop gemergt.

Git-Flow: Weitere Branches als Themen-Branches

A---B---------------------I-------------K  master
     \                   /             /
      C------------F----H-------------J---L  develop
       \          / \  /             /
        \        /   G1  featureB   /
         \      /                  /
          D1---D2  featureA       /
           \                     /
            E1---E2---E3---E4---E5  featureC

Für die Entwicklung eigenständiger Features bietet es sich auch im Git-Flow an, vom develop entsprechende Themenbranches abzuzweigen und darin jeweils isoliert die Features zu entwickeln. Wenn diese Arbeiten eine gewisse Reife haben, werden die Featurebranches in den develop integriert.

Git-Flow: Merging-Detail

---C--------E  develop
    \      /                 git merge --no-ff
     D1---D2  featureA

vs.

---C---D1---D2  develop      git merge

Wenn beim Mergen ein "fast forward" möglich ist, würde Git beim Mergen eines (Feature-) Branches in den develop (oder allgemein in einen anderen Branch) keinen separaten Commit erzeugen (Situation rechts in der Abbildung).

Damit erscheint der develop-Branch wie eine lineare Folge von Commits. In manchen Projekten wird dies bevorzugt, weil die Historie sehr übersichtlich aussieht.

Allerdings verliert man die Information, dass hier ein Feature entwickelt wurde und wann es in den develop integriert wurde (linke Seite in obiger Abbildung). Häufig wird deshalb ein extra Merge-Commit mit git merge --no-ff <branch> (extra Schalter "--no-ff") erzwungen, obwohl ein "fast forward" möglich wäre.

Anmerkung: Man kann natürlich auch über Konventionen in den Commit-Kommentaren eine gewisse Übersichtlichkeit erzwingen. Beispielsweise könnte man vereinbaren, dass alle Commit-Kommentare zu einem Feature "A" mit "feature a:" starten müssen.

Git-Flow: Umgang mit Fehlerbehebung

A---B---D--------F1  master
     \   \      /
      \   E1---E2  fix
       \        \
        C1-------F2  develop

Wenn im stabilen Branch (also dem master) ein Problem bekannt wird, darf man es nicht einfach im master fixen. Stattdessen wird ein extra Branch vom master abgezweigt, in dem der Fix entwickelt wird. Nach Fertigstellung wird dieser Branch sowohl in den master als auch den develop gemergt, damit auch im Entwicklungszweig der Fehler behoben ist.

Dadurch entspricht jeder Commit im master einem Release.

Vereinfachte Braching-Strategie: GitHub Flow

A---B---C----D-----------E  master
     \   \  /           /
      \   ta1  topicA  /
       \              /
        tb1---tb2---tb3  topicB

Github verfolgt eine deutlich vereinfachte Strategie: "GitHub Flow" (vgl. "GitHub Flow" (S. Chacon) bzw. "GitHub flow" (GitHub, Inc.)).

Hier ist der stabile Stand ebenfalls immer im master. Features werden ebenso wie im Git-Flow-Modell in eigenen Feature-Branches entwickelt.

Allerdings zweigen Feature-Branches immer direkt vom master ab und werden nach dem Test auch immer dort wieder direkt integriert (es gibt also keine weiteren langlaufenden Branches wie develop oder release).

In der obigen Abbildung ist zu sehen, dass für die Entwicklung eines Features ein entsprechender Themenbranch vom master abgezweigt wird. Darin erfolgt dann die Entwicklung des Features, d.h. mehrere Commits. Das Mergen des Features in den master erfolgt dann aber nicht lokal, sondern mit einem "Pull-Request" auf dem Server: Sobald man im Feature-Branch einen "diskussionswürdigen" Stand hat, wird ein Pull-Request (PR) über die Weboberfläche aufgemacht (streng genommen gehört dies in die Kategorie “Zusammenarbeit” bzw. “Workflows”; außerdem gehört ein PR nicht zu Git selbst, sondern zum Tooling von Github). In einem PR können andere Entwickler den Code kommentieren und ergänzen. Jeder weitere Commit auf dem Themenbranch wird ebenfalls Bestandteil des Pull-Requests. Parallel laufen ggf. automatisierte Tests etc. und durch das Akzeptieren des PR in der Weboberfläche erfolgt schließlich der Merge des Feature-Branches in den master.

Diskussion: Git-Flow vs. GitHub Flow

In der Praxis zeigt sich, dass das Git-Flow-Modell besonders gut geeignet ist, wenn man tatsächlich so etwas wie "Releases" hat, die zudem nicht zu häufig auftreten.

Das GitHub-Flow-Vorgehen bietet sich an, wenn man entweder keine Releases hat oder diese sehr häufig erfolgen (typisch bei agiler Vorgehensweise). Zudem vermeidet man so, dass die Feature-Branches zu lange laufen, womit normalerweise die Wahrscheinlichkeit von Merge-Konflikten stark steigt. Achtung: Da die Feature-Branches direkt in den master, also den stabilen Produktionscode gemergt werden, ist es hier besonders wichtig, vor dem Merge entsprechende Tests durchzuführen und den Merge erst zu machen, wenn alle Tests "grün" sind.

Hier ein paar Einstiegsseiten für die Diskussion, die teilweise sehr erbittert (und mit ideologischen Zügen) geführt wird (erinnert an die Diskussionen, welche Linux-Distribution die bessere sei):

Wrap-Up

  • Einsatz von Themenbranches für die Entwicklung
  • Unterschiedliche Modelle:
    • Git-Flow: umfangreiches Konzept, gut für Entwicklung mit festen Releases
    • GitHub Flow: deutlich schlankeres Konzept, passend für kontinuierliche Entwicklung ohne echte Releases
Quellen

Arbeiten mit Git Remotes (dezentrale Repos)

TL;DR

Eine der Stärken von Git ist das Arbeiten mit verteilten Repositories. Zu jeder Workingcopy gehört eine Kopie des Repositories, wodurch jederzeit alle Informationen einsehbar sind und auch offline gearbeitet werden kann. Allerdings muss man für die Zusammenarbeit mit anderen Entwicklern die lokalen Repos mit den "entfernten" Repos (auf dem Server oder anderen Entwicklungsrechnern) synchronisieren.

Beim Klonen eines Repositories mit git clone <url> wird das fremde Repo mit dem Namen origin im lokalen Repo festgehalten. Dieser Name wird auch als Präfix für die Branches in diesem Repo genutzt, d.h. die Branches im Remote-Repo tauchen als origin/<branch> im lokalen Repo auf. Diese Remote-Branches kann man nicht direkt bearbeiten, sondern man muss diese Remote-Branches in einem lokalen Branch auschecken und dann darin weiterarbeiten. Es können beliebig viele weitere Remotes dem eigenen Repository hinzugefügt werden.

Änderungen aus einem Remote-Repo können mit git fetch <remote> in das lokale Repo geholt werden. Dies aktualisiert nur die Remote-Branches <remote>/<branch>! Die Änderungen können anschließend mit git merge <remote>/<branch> in den aktuell in der Workingcopy ausgecheckten Branch gemergt werden. (Anmerkung: Wenn mehrere Personen an einem Branch arbeiten, will man die eigenen Arbeiten in dem Branch vermutlich eher auf den aktuellen Stand des Remote rebasen statt mergen!) Eigene Änderungen können mit git push <remote> <branch> in das Remote-Repo geschoben werden.

Um den Umgang mit den Remote-Branches und den davon abgeleiteten lokalen Branches zu vereinfachen, gibt es das Konzept der "Tracking Branches". Dabei "folgt" ein lokaler Branch einem Remote-Branch. Ein einfaches git pull oder git push holt dann Änderungen aus dem Remote-Branch in den ausgecheckten lokalen Branch bzw. schiebt Änderungen im lokalen Branch in den Remote-Branch.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Erzeugen eines Clones von fremden Git-Repositories
  • (K3) Holen der Änderungen vom fremden Repo
  • (K3) Aktualisierung der lokalen Branches
  • (K3) Pushen der lokalen Änderungen ins fremde Repo
  • (K3) Anlegen von lokalen Branches vs. Anlegen von entfernten Branches
  • (K3) Anlegen eines Tracking Branches zum Vereinfachen der Arbeit

Nutzung von Git in Projekten: Verteiltes Git (und Workflows)

Git ermöglicht eine einfaches Zusammenarbeit in verteilten Teams. Nachdem wir die verschiedenen Branching-Strategien betrachtet haben, soll im Folgenden die Frage betrachtet werden: Wie arbeite ich sinnvoll über Git mit anderen Kollegen und Teams zusammen? Welche Modelle haben sich etabliert?

Clonen kann sich lohnen ...

https://github.com/Programmiermethoden-CampusMinden/Prog2-Lecture

---C---D---E  master

=> git clone https://github.com/Programmiermethoden-CampusMinden/Prog2-Lecture

./Prog2-Lecture/  (lokaler Rechner)

---C---D---E  master
           ^origin/master

Git-Repository mit der URL <URL-Repo> in lokalen Ordner <directory> auschecken:

  • git clone <URL-Repo> [<directory>]
  • Workingcopy ist automatisch über den Namen origin mit dem remote Repo auf dem Server verbunden
  • Lokaler Branch master ist mit dem remote Branch origin/master verbunden ("Tracking Branch", s.u.), der den Stand des master-Branches auf dem Server spiegelt

Für die URL sind verschiedene Protokolle möglich, beispielsweise:

  • "file://" für über das Dateisystem erreichbare Repositories (ohne Server)
  • "https://" für Repo auf einem Server: Authentifikation mit Username und Passwort (!)
  • "git@" für Repo auf einem Server: Authentifikation mit SSH-Key (diese Variante wird im Praktikum im Zusammenspiel mit dem Gitlab-Server im SW-Labor verwendet)

Eigener und entfernter master entwickeln sich weiter ...

https://github.com/Programmiermethoden-CampusMinden/Prog2-Lecture

---C---D---E---F---G  master

./Prog2-Lecture/  (lokaler Rechner)

---C---D---E---H  master
           ^origin/master

Nach dem Auschecken liegen (in diesem Beispiel) drei master-Branches vor:

  1. Der master auf dem Server,
  2. der lokale master, und
  3. die lokale Referenz auf den master-Branch auf dem Server: origin/master.

Der lokale master ist ein normaler Branch und kann durch Commits verändert werden.

Der master auf dem Server kann sich ebenfalls ändern, beispielsweise weil jemand anderes seine lokalen Änderungen mit dem Server abgeglichen hat (git push, s.u.).

Der Branch origin/master lässt sich nicht direkt verändern! Das ist lediglich eine lokale Referenz auf den master-Branch auf dem Server und zeigt an, welchen Stand man bei der letzten Synchronisierung hatte. D.h. erst mit dem nächsten Abgleich wird sich dieser Branch ändern (sofern sich der entsprechende Branch auf dem Server verändert hat).

Anmerkung: Dies gilt analog für alle anderen Branches. Allerdings wird nur der origin/master beim Clonen automatisch als lokaler Branch ausgecheckt.

Zur Abbildung: Während man lokal arbeitet (Commit H auf dem lokalen master), kann es passieren, dass sich auch das remote Repo ändert. Im Beispiel wurden dort die beiden Commits F und G angelegt (durch git push, s.u.).

Wichtig: Da in der Zwischenzeit das lokale Repo nicht mit dem Server abgeglichen wurde, zeigt der remote Branch origin/master immer noch auf den Commit E!

Änderungen im Remote holen und Branches zusammenführen

https://github.com/Programmiermethoden-CampusMinden/Prog2-Lecture

---C---D---E---F---G  master

=> git fetch origin

./Prog2-Lecture/  (lokaler Rechner)

---C---D---E---H  master
            \
             F---G  origin/master

Änderungen auf dem Server mit dem eigenen Repo abgleichen

Mit git fetch origin alle Änderungen holen

  • Alle remote Branches werden aktualisiert und entsprechen den jeweiligen Branches auf dem Server: Im Beispiel zeigt jetzt origin/master ebenso wie der master auf dem Server auf den Commit G.
  • Neue Branches auf dem Server werden ebenfalls "geholt", d.h. sie liegen nach dem Fetch als entsprechende remote Branches vor
  • Auf dem Server gelöschte Branches werden nicht automatisch lokal gelöscht; dies kann man mit git fetch --prune origin automatisch erreichen

Wichtig: Es werden nur die remote Branches aktualisiert, nicht die lokalen Branches!

master-Branch nach "git fetch origin" zusammenführen

  1. Mit git checkout master Workingcopy auf eigenen master umstellen
  2. Mit git merge origin/master Änderungen am origin/master in eigenen master mergen
  3. Mit git push origin master eigene Änderungen ins remote Repo pushen

https://github.com/Programmiermethoden-CampusMinden/Prog2-Lecture

---C---D---E---H---I  master
            \     /
             F---G

./Prog2-Lecture/  (lokaler Rechner)

---C---D---E---H---I  master
            \     /^origin/master
             F---G

Anmerkung: Schritt (2) kann man auch per git pull origin master erledigen ... Ein pull fasst fetch und merge zusammen (s.u.).

Anmerkung Statt dem merge in Schritt (2) kann man auch den lokalen master auf den aktualisierten origin/master rebasen und vermeidet damit die "Raute". Der pull kann mit der Option "--rebase" auf "rebase" umgestellt werden (per Default wird bei pull ein "merge" ausgeführt).

Auf dem Server ist nur ein fast forward merge möglich

Sie können Ihre Änderungen in Ihrem lokalen master auch direkt in das remote Repo pushen, solange auf dem Server ein fast forward merge möglich ist.

Wenn aber (wie in der Abbildung) der lokale und der remote master divergieren, müssen Sie den Merge wie beschrieben lokal durchführen (fetch/merge oder pull) und das Ergebnis wieder in das remote Repo pushen (dann ist ja wieder ein fast forward merge möglich, es sei denn, jemand hat den remote master in der Zwischenzeit weiter geschoben - dann muss die Aktualisierung erneut durchgeführt werden).

Branches und Remotes

  • Eigenen (neuen) lokalen Branch ins remote Repo schicken

    • git push <remote> <branch>
  • Neuer Branch im remote Repo

    • git fetch <remote> holt (auch) alle neuen Branches
    • Lokale Änderungen an remote Branches nicht möglich! => Remote Branch in lokalen Branch mergen (oder auschecken)

Zusammenfassung: Arbeiten mit Remotes

  1. Änderungen vom Server holen: git fetch <remote> => Holt alle Änderungen vom Repo <remote> ins eigene Repo (Workingcopy bleibt unangetastet!)

  2. Aktuellen lokalen Branch auffrischen: git merge <remote>/<branch> (oder alternativ git pull <remote> <branch>)

  3. Eigene Änderungen hochladen: git push <remote> <branch>

Anmerkung: push geht nur, wenn

  1. Ziel ein "bare"-Repository ist, und
  2. keine Konflikte entstehen

=> im remote Repo nur "fast forward"-Merge möglich

=> bei Konflikten erst fetch und merge, danach push

Anmerkung: Ein "bare"-Repository enthält keine Workingcopy, sondern nur das Repo an sich. Die ist bei Repos, die Sie auf einem Server wie Gitlab oder Github anlegen, automatisch der Fall. Sie können aber auch lokal ein solches "bare"-Repo anlegen, indem Sie beim Initialisieren den Schalter --bare mitgeben: git init --bare ...

Beispiel

git fetch origin           # alle Änderungen vom Server holen
git checkout master        # auf lokalen Master umschalten
git merge origin/master    # lokalen Master aktualisieren

... # Herumspielen am lokalen Master

git push origin master     # lokalen Master auf Server schicken

Vereinfachung: Tracking Branches

  • Tracking Branch: lokaler Branch, der remote Branch "verfolgt"

    • Beispiel: lokaler master-Branch folgt origin/master per Default
  • Vereinfachung im Workflow:

    • git pull entspricht
      1. git fetch <remote> plus
      2. git merge <remote>/<branch>
    • git push entspricht git push <remote> <branch>

Vorsicht: pull und push beziehen sich nur auf ausgecheckten Tracking Branch

Einrichten von Tracking Branches

  • git clone: lokaler master trackt automatisch origin/master

  • Remote Branch als Tracking Branch einrichten:

    1. Änderungen aus remote Repo holen: git fetch <remote>
    2. Tracking Branch anlegen: git checkout -t <remote>/<branch> (=> Option -t richtet den remote Branch als Tracking Branch ein)
  • Lokalen neuen Branch ins remote Repo schicken und als Tracking Branch einrichten:

    1. Lokalen Branch erzeugen: git checkout -b <branch>
    2. Lokalen Branch ins Repo schicken: git push -u <remote> <branch> (=> Option -u richtet den lokalen Branch als Tracking Branch ein)

Hinzufügen eines (weiteren) Remote Repository

Sie können einem Repo beliebig viele Remotes hinzufügen:

git remote add <name> <url>

Beispiel: git remote add andi git@github.com:andi/repo.git

  • Remote origin wird bei clone automatisch angelegt
  • Ansehen der Remotes mit git remote -v
  • fetch, push und pull jeweils über den vergebenen Namen

Beispiel: git fetch andi oder git push origin master

Wrap-Up

  • Synchronisierung des lokalen Repos mit anderen Repos

    • Repo kopieren: git clone <url>
    • Interner Name fürs fremde Repo: origin
    • Änderungen vom fremden Repo holen: git fetch <remote>
    • Änderungen in lokalen Branch einpflegen: git merge <remote>/<branch>
    • Eigene Änderungen ins fremde Repo schieben: git push <remote> <branch>
  • Tracking Branches (Konzept, Anwendung)

    • Remote Branches können lokal nicht verändert werden:
      • In lokale Branches mergen, oder
      • Tracking Branches anlegen => einfaches pull und push nutzen
    • Tracking Branches sind lokale Branches, die remote Branches verfolgen ("tracken")
Challenges

Synchronisierung mit Remote-Repos

Sie haben ein Repo von github.com geklont. Beide Repos, das Original auf dem Server als auch Ihre lokale Kopie, haben sich danach unabhängig voneinander weiter entwickelt (siehe Skizze).

Wie können Sie Ihre Änderung im lokalen Repo auf den Server pushen? Analysieren Sie die Situation und erklären Sie zwei verschiedene Lösungsansätze und geben Sie jeweils die entsprechenden Git-Befehle an.

Interaktive Git-Tutorials: Schaffen Sie die Rätsel?

Quellen

Zusammenarbeit: Git-Workflows und Merge-/Pull-Requests

TL;DR

Git erlaubt unterschiedliche Formen der Zusammenarbeit.

Bei kleinen Teams kann man einen einfachen zentralen Ansatz einsetzen. Dabei gibt es ein zentrales Repo auf dem Server, und alle Team-Mitglieder dürfen direkt in dieses Repo pushen. Hier muss man sich gut absprechen und ein vernünftiges Branching-Schema ist besonders wichtig.

In größeren Projekten gibt es oft ein zentrales öffentliches Repo, wo aber nur wenige Personen Schreibrechte haben. Hier forkt man sich dieses Repo, erstellt also eine öffentliche Kopie auf dem Server. Diese Kopie klont man lokal und arbeitet hier und pusht die Änderungen in den eigenen öffentlich sichtbaren Fork. Um die Änderungen ins Projekt-Repo integrieren zu lassen, wird auf dem Server ein sogenannter Merge-Request (Gitlab) bzw. Pull-Request (GitHub) erstellt. Dies erlaubt zusätzlich ein Review und eine Diskussion direkt am Code. Damit man die Änderungen im Hauptprojekt in den eigenen Fork bekommt, trägt man das Hauptprojekt als weiteres Remote in die Workingcopy ein und aktualisiert regelmäßig die Hauptbranches, von denen dann auch die eigenen Feature-Branches ausgehen sollten.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Git-Workflows für die Zusammenarbeit
  • (K2) Unterschied zwischen einem Pull/Merge und einem Pull/Rebase
  • (K2) Welche Commits werden Bestandteil eines Merge-Requests (und warum)
  • (K3) Einsatz des zentralisierten Workflows
  • (K3) Einsatz des einfachen verteilten Workflows mit unterschiedlichen Repos
  • (K3) Aktualisieren Ihres Clones und Ihres Forks mit Änderungen aus dem blessed Repo
  • (K3) Erstellen von Beiträgen zu einem Projekt über Merge-Requests
  • (K3) Aktualisierung von Merge-Requests
  • (K3) Diskussion über den Code in Merge-Requests

Nutzung von Git in Projekten: Verteiltes Git (und Workflows)

Git ermöglicht ein einfaches und schnelles Branchen. Dies kann man mit entsprechenden Branching-Strategien sinnvoll für die SW-Entwicklung einsetzen.

Auf der anderen Seite ermöglicht Git ein sehr einfaches verteiltes Arbeiten. Auch hier ergeben sich verschiedene Workflows, wie man mit anderen Entwicklern an einem Projekt arbeiten will/kann.

Im Folgenden sollen also die Frage betrachtet werden: Wie gestalte ich die Zusammenarbeit? Antwort: Workflows mit Git ...

Zusammenarbeit: Zentraler Workflow mit Git (analog zu SVN)

In kleinen Projektgruppen wie beispielsweise Ihrer Arbeitsgruppe wird häufig ein einfacher zentralisierter Workflow bei der Versionsverwaltung genutzt. Im Mittelpunkt steht dabei ein zentrales Repository, auf dem alle Teammitglieder gleichberechtigt und direkt pushen dürfen.

  • Vorteile:

    • Einfachstes denkbares Modell
    • Ein gemeinsames Repo (wie bei SVN)
    • Alle haben Schreibzugriff auf ein gemeinsames Repo
  • Nachteile:

    • Definition und Umsetzung von Rollen mit bestimmten Rechten ("Manager", "Entwickler", "Gast-Entwickler", ...) schwierig bis unmöglich (das ist kein Git-Thema, sondern hängt von der Unterstützung durch den Anbieter des Servers ab)
    • Jeder darf überall pushen: Enge und direkte Abstimmung nötig
    • Modell funktioniert meist nur in sehr kleinen Teams (2..3 Personen)

Zusammenarbeit: Einfacher verteilter Workflow mit Git

In großen und/oder öffentlichen Projekten wird üblicherweise ein Workflow eingesetzt, der auf den Möglichkeiten von verteilten Git-Repositories basiert.

Dabei wird zwischen verschiedenen Rollen ("Integrationsmanager", "Entwickler") unterschieden.

Sie finden dieses Vorgehen beispielsweise beim Linux-Kernel und auch häufig bei Projekten auf Github.

  • Es existiert ein geschütztes ("blessed") Master-Repo

    • Stellt die Referenz für das Projekt dar
    • Push-Zugriff nur für ausgewählte Personen ("Integrationsmanager")
  • Entwickler

    • Forken das Master-Repo auf dem Server und klonen ihren Fork lokal
    • Arbeiten auf lokalem Klon: Unabhängige Entwicklung eines Features
    • Pushen ihren Stand in ihren Fork (ihr eigenes öffentliches Repo): Veröffentlichung des Beitrags zum Projekt (sobald fertig bzw. diskutierbar)
    • Lösen Pull- bzw. Merge-Request gegen das Master-Repo aus: Beitrag soll geprüft und ins Projekt aufgenommen werden (Merge ins Master-Repo durch den Integrationsmanager)
  • Integrationsmanager

    • Prüft die Änderungen im Pull- bzw. Merge-Request und fordert ggf. Nacharbeiten an bzw. lehnt Integration ab (technische oder politische Gründe)
    • Führt Merge der Entwickler-Zweige mit den Hauptzweigen durch Akzeptieren der Pull- bzw. Merge-Requests durch: Beitrag der Entwickler ist im Projekt angekommen und ist beim nächsten Pull in deren lokalen Repos vorhanden

Den hier gezeigten Zusammenhang kann man auf weitere Ebenen verteilen, vgl. den im Linux-Kernel gelebten "Dictator and Lieutenants Workflow" (siehe Literatur).

Hinweis: Hier wird nur die Zusammenarbeit im verteilten Team dargestellt. Dazu kommt noch das Arbeiten mit verschiedenen Branches!

Anmerkung: In der Workingcopy wird das eigene (öffentliche) Repo oft als origin und das geschützte ("blessed") Master-Repo als upstream referenziert.

Anmerkungen zum Forken

Sie könnten auch das Original-Repo direkt clonen. Allerdings würden dann die push dort aufschlagen, was in der Regel nicht erwünscht ist (und auch nicht erlaubt ist).

Deshalb forkt man das Original-Repo auf dem Server, d.h. auf dem Server wird eine Kopie des Original-Repos im eigenen Namespace angelegt. Auf diese Kopie hat man dann uneingeschränkten Zugriff.

Anmerkungen zu den Namen für die Remotes: origin und upstream

Üblicherweise checkt man die Kopie lokal aus (d.h. erzeugt einen Clone). In der Workingcopy verweist dann origin auf die Kopie. Um Änderungen am Original-Repo zu erhalten, fügt man dieses unter dem Namen upstream als weiteres Remote-Repo hinzu. Dies ist eine nützliche Konvention.

Um Änderungen aus dem Original-Repo in den eigenen Fork (und die Workingcopy) zu bringen, führt man dann einfach folgendes aus (im Beispiel für den master):

$ git checkout master         # Workingcopy auf master
$ git pull upstream master    # Aktualisiere lokalen master mit master aus Original-Repo
$ git push origin master      # Pushe lokalen master in den Fork

Feature-Branches aktualisieren: Mergen mit master vs. Rebase auf master

Im Netz finden sich häufig Anleitungen, wonach man Änderungen im master mit einem Merge in den Feature-Branch holt, also sinngemäß:

$ git checkout master         # Workingcopy auf master
$ git pull upstream master    # Aktualisiere lokalen master mit master aus Vorgabe-Repo
$ git checkout feature        # Workingcopy auf feature
$ git merge master            # Aktualisiere feature: Merge master in feature
$ git push origin feature     # Push aktuellen feature ins Team-Repo

Das funktioniert rein technisch betrachtet.

Allerdings spielt in den meisten Git-Projekten der master üblicherweise eine besondere Rolle (vgl. Branching-Strategien) und ist üblicherweise stets das Ziel eines Merge, aber nie die Quelle! D.h. per Konvention geht der Fluß von Änderungen stets in den master (und nicht heraus).

Wenn man sich nicht an diese Konvention hält, hat man später möglicherweise Probleme, die Merge-Historie zu verstehen (welche Änderung kam von woher)!

Um die Änderungen im master in einen Feature-Branch zu bekommen, sollte deshalb ein Rebase (des Feature-Branches auf den master) vor einem Merge (des master in den Feature-Branch) bevorzugt werden.

Merk-Regel: Merge niemals nie den master in Feature-Branches!

Achtung: Ein Rebase bei veröffentlichten Branches ist problematisch, da Dritte auf diesem Branch arbeiten könnten und entsprechend auf die Commit-IDs angewiesen sind. Nach einem Rebase stimmen diese Commit-IDs nicht mehr, was normalerweise mindestens zu Verärgerung führt ... Die Dritten müssten ihre Arbeit dann auf den neuen Feature-Branch (d.h. den Feature-Branch nach dessen Rebase) rebasen ... vgl. auch "The Perils of Rebasing" in Abschnitt "3.6 Rebasing" in [Chacon2014].

Mögliches Szenario im Praktikum

Im Praktikum haben Sie das Vorgabe-Repo. Dieses könnten Sie als upstream in Ihre lokale Workingcopy einbinden.

Mit Ihrem Team leben Sie vermutlich einen zentralen Workflow, d.h. Sie binden Ihr gemeinsames Repo als origin in Ihre lokale Workingcopy ein.

Dann müssen Sie den lokalen master aus beiden Remotes aktualisieren. Zusätzlich wollen Sie Ihren aktuellen Themenbranch auf den aktuellen master rebasen.

$ git checkout master         # Workingcopy auf master
$ git pull upstream master    # Aktualisiere lokalen master mit master aus Vorgabe-Repo
$ git pull origin master      # Aktualisiere lokalen master mit master aus Team-Repo
$ git push origin master      # Pushe lokalen master in das Team-Repo zurück
$ git rebase master feature   # Rebase feature auf den aktuellen lokalen master
$ git push -f origin feature  # Push aktuellen feature ins Team-Repo ("-f" wg. geänderter IDs durch rebase)

Anmerkung: Dabei können in Ihrem master die unschönen "Rauten" entstehen. Wenn Sie das vermeiden wollen, tauschen Sie den zweiten und den dritten Schritt und führen den Pull gegen den Upstream-master als pull --rebase durch. Dann müssen Sie Ihren lokalen master allerdings auch force-pushen in Ihr Team-Repo und die anderen Team-Mitglieder sollten darüber informiert werden, dass sich der master auf inkompatible Weise geändert hat ...

Kommunikation: Merge- bzw. Pull-Requests

Mergen kann man auf der Konsole (oder in der IDE) und anschließend die (neuen) Branches auf den Server pushen.

Die verschiedenen Git-Server erlauben ebenfalls ein GUI-gestütztes Mergen von Branches: "Merge-Requests" (MR, Gitlab) bzw. "Pull-Requests" (PR, Github). Das hat gegenüber dem lokalen Mergen wichtige Vorteile: Andere Entwickler sehen den beabsichtigten Merge (frühzeitig) und können direkt den Code kommentieren und die vorgeschlagenen Änderungen diskutieren, aber auch allgemeine Kommentare abgeben.

Falls möglich, sollte man einen MR/PR immer dem Entwickler zuweisen, der sich weiter um diesen MR/PR kümmern wird (also zunächst ist man das erstmal selbst). Zusätzlich kann man einen Reviewer bestimmen, d.h. wer soll sich den Code ansehen.

Hier ein Screenshot der Änderungsansicht unseres Gitlab-Servers (SW-Labor):

Nachfolgend für den selben MR aus der letzten Abbildung noch die reine Diskussionsansicht:

Best Practices bei Merge-/Pull-Requests

  1. MR/PR so zeitig wie möglich aufmachen
    • Am besten sofort, wenn ein neuer Branch auf den Server gepusht wird!
    • Ggf. mit dem Präfix "WIP" im Titel gegen unbeabsichtigtes vorzeitiges Mergen sperren ... (bei GitHub als "Draft"-PR öffnen)
  2. Auswahl Start- und Ziel-Branch (und ggf. Ziel-Repo)
    • Es gibt verschiedene Stellen, um einen MR/PR zu erstellen. Manchmal kann man nur noch den Ziel-Branch einstellen, manchmal beides.
    • Bitte auch darauf achten, welches Ziel-Repo eingestellt ist! Bei Forks wird hier immer das Original-Repo voreingestellt!
    • Den Ziel-Branch kann man ggf. auch nachträglich durch Editieren des MR/PR anpassen (Start-Branch und Ziel-Repo leider nicht, also beim Erstellen aufpassen!).
  3. Titel (Summary): Das ist das, was man in der Übersicht sieht!
    • Per Default wird die letzte Commit-Message eingesetzt.
    • Analog zur Commit-Message: Bitte hier unbedingt einen sinnvollen Titel einsetzen: Was macht der MR/PR (kurz)?
  4. Beschreibung: Was passiert, wenn man diesen MR/PR akzeptiert (ausführlicher)?
    • Analog zur Commit-Message sollte hier bei Bedarf die Summary ausformuliert werden und beschreiben, was der MR/PR ändert.
  5. Assignee: Wer soll sich drum kümmern?
    • Ein MR/PR sollte immer jemanden zugewiesen sein, d.h. nicht "unassigned" sein. Ansonsten ist nicht klar, wer den Request durchführen/akzeptieren soll.
    • Außerdem taucht ein nicht zugewiesener MR/PR nicht in der Übersicht "meiner" MR/PR auf, d.h. diese MR/PR haben die Tendenz, vergessen zu werden!
  6. Diskussion am (und neben) dem Code
    • Nur die vorgeschlagenen Code-Änderungen diskutieren!
    • Weitergehende Diskussionen (etwa über Konzepte o.ä.) besser in separaten Issues erledigen, da sonst die Anzeige des MR/PR langsam wird (ist beispielsweise ein Problem bei Gitlab).
  7. Weitere Commits auf dem zu mergenden Branch gehen automatisch mit in den Request
  8. Weitere Entwickler kann man mit "@username" in einem Kommentar auf "CC" setzen und in die Diskussion einbinden

Anmerkung: Bei Gitlab (d.h. auch bei dem Gitlab-Server im SW-Labor) gibt es "Merge-Requests" (MR). Bei Github gibt es "Pull-Requests" (PR) ...

Wrap-Up

  • Git-Workflows für die Zusammenarbeit:

    • einfacher zentraler Ansatz für kleine Arbeitsgruppen vs.
    • einfacher verteilter Ansatz mit einem "blessed" Repo (häufig in Open-Source-Projekten zu finden)
  • Aktualisieren Ihres Clones und Ihres Forks mit Änderungen aus dem "blessed" Repo

  • Unterschied zwischen einem Pull/Merge und einem Pull/Rebase

  • Erstellen von Beiträgen zu einem Projekt über Merge-Requests

    • Welche Commits werden Bestandteil eines Merge-Requests (und warum)
    • Diskussion über den Code in Merge-Requests
Quellen

Subsections of Modern Java: Funktionaler Stil und Stream-API

Lambda-Ausdrücke und funktionale Interfaces

TL;DR

Mit einer anonymen inneren Klasse erstellt man gewissermaßen ein Objekt einer "Wegwerf"-Klasse: Man leitet on-the-fly von einem Interface ab oder erweitert eine Klasse und implementiert die benötigten Methoden und erzeugt von dieser Klasse sofort eine Instanz (Objekt). Diese neue Klasse ist im restlichen Code nicht sichtbar.

Anonyme innere Klassen sind beispielsweise in Swing recht nützlich, wenn man einer Komponente einen Listener mitgeben will: Hier erzeugt man eine anonyme innere Klasse basierend auf dem passenden Listener-Interface, implementiert die entsprechenden Methoden und übergibt das mit dieser Klasse erzeugte Objekt als neuen Listener der Swing-Komponente.

Mit Java 8 können unter gewissen Bedingungen diese anonymen inneren Klassen zu Lambda-Ausdrücken (und Methoden-Referenzen) vereinfacht werden. Dazu muss die anonyme innere Klasse ein sogenanntes funktionales Interface implementieren.

Funktionale Interfaces sind Interfaces mit genau einer abstrakten Methode. Es können beliebig viele Default-Methoden im Interface enthalten sein, und es können public sichtbare abstrakte Methoden von java.lang.Object geerbt/überschrieben werden.

Die Lambda-Ausdrücke entsprechen einer anonymen Methode: Die Parameter werden aufgelistet (in Klammern), und hinter einem Pfeil kommt entweder ein Ausdruck (Wert - gleichzeitig Rückgabewert des Lambda-Ausdrucks) oder beliebig viele Anweisungen (in geschweiften Klammern, mit Semikolon):

  • Form 1: (parameters) -> expression
  • Form 2: (parameters) -> { statements; }

Der Lambda-Ausdruck muss von der Signatur her genau der einen abstrakten Methode im unterliegenden funktionalen Interface entsprechen.

Lernziele
  • (K2) Funktionales Interfaces (Definition)
  • (K3) Einsatz innerer und anonymer Klassen
  • (K3) Erstellen eigener funktionaler Interfaces
  • (K3) Einsatz von Lambda-Ausdrücken

Problem: Sortieren einer Studi-Liste

List<Studi> sl = new ArrayList<>();

// Liste sortieren?
sl.sort(???);  // Parameter: java.util.Comparator<Studi>
public class MyCompare implements Comparator<Studi> {
    @Override  public int compare(Studi o1, Studi o2) {
        return o1.getCredits() - o2.getCredits();
    }
}
// Liste sortieren?
MyCompare mc = new MyCompare();
sl.sort(mc);

Da Comparator<T> ein Interface ist, muss man eine extra Klasse anlegen, die die abstrakte Methode aus dem Interface implementiert und ein Objekt von dieser Klasse erzeugen und dieses dann der sort()-Methode übergeben.

Die Klasse bekommt wie in Java üblich eine eigene Datei und ist damit in der Package-Struktur offen sichtbar und "verstopft" mir damit die Strukturen: Diese Klasse ist doch nur eine Hilfsklasse ... Noch schlimmer: Ich brauche einen Namen für diese Klasse!

Den ersten Punkt könnte man über verschachtelte Klassen lösen: Die Hilfsklasse wird innerhalb der Klasse definiert, die das Objekt benötigt. Für den zweiten Punkt brauchen wir mehr Anlauf ...

Erinnerung: Verschachtelte Klassen ("Nested Classes")

Man kann Klassen innerhalb von Klassen definieren: Verschachtelte Klassen.

  • Implizite Referenz auf Instanz der äußeren Klasse, Zugriff auf alle Elemente
  • Begriffe:
    • "normale" innere Klassen: "inner classes"
    • statische innere Klassen: "static nested classes"
  • Einsatzzweck:
    • Hilfsklassen: Zusätzliche Funktionalität kapseln; Nutzung nur in äußerer Klasse
    • Kapselung von Rückgabewerten

Sichtbarkeit: Wird u.U. von äußerer Klasse "überstimmt"

Innere Klassen ("Inner Classes")

  • Objekt der äußeren Klasse muss existieren
  • Innere Klasse ist normales Member der äußeren Klasse
  • Implizite Referenz auf Instanz äußerer Klasse
  • Zugriff auf alle Elemente der äußeren Klasse
  • Sonderfall: Definition innerhalb von Methoden ("local classes")
    • Nur innerhalb der Methode sichtbar
    • Kennt zusätzlich final Attribute der Methode

Beispiel:

public class Outer {
    ...
    private class Inner {
        ...
    }

    Outer.Inner inner = new Outer().new Inner();
}

Statische innere Klassen ("Static Nested Classes")

  • Keine implizite Referenz auf Objekt
  • Nur Zugriff auf Klassenmethoden und -attribute

Beispiel:

class Outer {
    ...
    static class StaticNested {
        ...
    }
}

Outer.StaticNested nested = new Outer.StaticNested();

Lösung: Comparator als anonyme innere Klasse

List<Studi> sl = new ArrayList<>();

// Parametrisierung mit anonymer Klasse
sl.sort(
        new Comparator<Studi>() {
            @Override
            public int compare(Studi o1, Studi o2) {
                return o1.getCredits() - o2.getCredits();
            }
        });  // Semikolon nicht vergessen!!!

=> Instanz einer anonymen inneren Klasse, die das Interface Comparator<Studi> implementiert

  • Für spezielle, einmalige Aufgabe: nur eine Instanz möglich
  • Kein Name, kein Konstruktor, oft nur eine Methode
  • Müssen Interface implementieren oder andere Klasse erweitern
    • Achtung Schreibweise: ohne implements oder extends!
  • Konstruktor kann auch Parameter aufweisen
  • Zugriff auf alle Attribute der äußeren Klasse plus alle final lokalen Variablen
  • Nutzung typischerweise bei GUIs: Event-Handler etc.

Vereinfachung mit Lambda-Ausdruck

List<Studi> sl = new ArrayList<>();

// Parametrisierung mit anonymer Klasse
sl.sort(
        new Comparator<Studi>() {
            @Override
            public int compare(Studi o1, Studi o2) {
                return o1.getCredits() - o2.getCredits();
            }
        });  // Semikolon nicht vergessen!!!


// Parametrisierung mit Lambda-Ausdruck
sl.sort( (Studi o1, Studi o2) -> o1.getCredits() - o2.getCredits() );

Anmerkung: Damit für den Parameter alternativ auch ein Lambda-Ausdruck verwendet werden kann, muss der erwartete Parameter vom Typ her ein "funktionales Interface" (s.u.) sein!

Syntax für Lambdas

(Studi o1, Studi o2)  ->  o1.getCredits() - o2.getCredits()

Ein Lambda-Ausdruck ist eine Funktion ohne Namen und besteht aus drei Teilen:

  1. Parameterliste (in runden Klammern),
  2. Pfeil
  3. Funktionskörper (rechte Seite)

Falls es genau einen Parameter gibt, können die runden Klammern um den Parameter entfallen.

Dabei kann der Funktionskörper aus einem Ausdruck ("expression") bestehen oder einer Menge von Anweisungen ("statements"), die dann in geschweifte Klammern eingeschlossen werden müssen (Block mit Anweisungen).

Der Wert des Ausdrucks ist zugleich der Rückgabewert des Lambda-Ausdrucks.

Varianten:

  • (parameters) -> expression

  • (parameters) -> { statements; }

Quiz: Welches sind keine gültigen Lambda-Ausdrücke?

  1. () -> {}
  2. () -> "wuppie"
  3. () -> { return "fluppie"; }
  4. (Integer i) -> return i + 42;
  5. (String s) -> { "foo"; }
  6. (String s) -> s.length()
  7. (Studi s) -> s.getCredits() > 300
  8. (List<Studi> sl) -> sl.isEmpty()
  9. (int x, int y) -> { System.out.println("Erg: "); System.out.println(x+y); }
  10. () -> new Studi()
  11. s -> s.getCps() > 100 && s.getCps() < 300
  12. s -> { return s.getCps() > 100 && s.getCps() < 300; }

Auflösung:

(4) und (5): return ist eine Anweisung, d.h. bei (4) fehlen die geschweiften Klammern. "foo" ist ein String und als solcher ein Ausdruck, d.h. hier sind die geschweiften Klammern zu viel (oder man ergänze den String mit einem return, also return "foo"; ...).

Definition "Funktionales Interface" ("functional interfaces")

@FunctionalInterface
public interface Wuppie<T> {
    int wuppie(T obj);
    boolean equals(Object obj);
    default int fluppie() { return 42; }
}

Wuppie<T> ist ein funktionales Interface ("functional interface") (seit Java 8)

  • Hat genau eine abstrakte Methode
  • Hat evtl. weitere Default-Methoden
  • Hat evtl. weitere abstrakte Methoden, die public Methoden von java.lang.Object überschreiben

Die Annotation @FunctionalInterface selbst ist nur für den Compiler: Falls das Interface kein funktionales Interface ist, würde er beim Vorhandensein dieser Annotation einen Fehler werfen. Oder anders herum: Allein durch das Annotieren mit @FunctionalInterface wird aus einem Interface noch kein funktionales Interface! Vergleichbar mit @Override ...

Während man für eine anonyme Klasse lediglich ein "normales" Interface (oder eine Klasse) benötigt, braucht man für Lambda-Ausdrücke zwingend ein passendes funktionales Interface!

Anmerkung: Es scheint keine einheitliche deutsche Übersetzung für den Begriff functional interface zu geben. Es wird häufig mit "funktionales Interface", manchmal aber auch mit "Funktionsinterface" übersetzt.

Das in den obigen Beispielen eingesetzte Interface java.util.Comparator<T> ist also ein funktionales Interface: Es hat nur eine eigene abstrakte Methode int compare(T o1, T o2);.

Im Package java.util.function sind einige wichtige funktionale Interfaces bereits vordefiniert, beispielsweise Predicate (Test, ob eine Bedingung erfüllt ist) und Function (verarbeite einen Wert und liefere einen passenden Ergebniswert). Diese kann man auch in eigenen Projekten nutzen!

Quiz: Welches ist kein funktionales Interface?

public interface Wuppie {
    int wuppie(int a);
}

public interface Fluppie extends Wuppie {
    int wuppie(double a);
}

public interface Foo {
}

public interface Bar extends Wuppie {
    default int bar() { return 42; }
}

Auflösung:

  • Wuppie hat genau eine abstrakte Methode => funktionales Interface
  • Fluppie hat zwei abstrakte Methoden => kein funktionales Interface
  • Foo hat gar keine abstrakte Methode => kein funktionales Interface
  • Bar hat genau eine abstrakte Methode (und eine Default-Methode) => funktionales Interface

Lambdas und funktionale Interfaces: Typprüfung

interface java.util.Comparator<T> {
    int compare(T o1, T o2);    // abstrakte Methode
}
// Verwendung ohne weitere Typinferenz
Comparator<Studi> c1 = (Studi o1, Studi o2) -> o1.getCredits() - o2.getCredits();

// Verwendung mit Typinferenz
Comparator<Studi> c2 = (o1, o2) -> o1.getCredits() - o2.getCredits();

Der Compiler prüft in etwa folgende Schritte, wenn er über einen Lambda-Ausdruck stolpert:

  1. In welchem Kontext habe ich den Lambda-Ausdruck gesehen?
  2. OK, der Zieltyp ist hier Comparator<Studi>.
  3. Wie lautet die eine abstrakte Methode im Comparator<T>-Interface?
  4. OK, das ist int compare(T o1, T o2);
  5. Da T hier an Studi gebunden ist, muss der Lambda-Ausdruck der Methode int compare(Studi o1, Studi o2); entsprechen: 2x Studi als Parameter und als Ergebnis ein int
  6. Ergebnis: a) Cool, passt zum Lambda-Ausdruck c1. Fertig. b) D.h. in c2 müssen o1 und o2 vom Typ Studi sein. Cool, passt zum Lambda-Ausdruck c2. Fertig.

Wrap-Up

  • Anonyme Klassen: "Wegwerf"-Innere Klassen

    • Müssen Interface implementieren oder Klasse erweitern
  • Java8: Lambda-Ausdrücke statt anonymer Klassen (funktionales Interface nötig)

    • Zwei mögliche Formen:
      • Form 1: (parameters) -> expression
      • Form 2: (parameters) -> { statements; }
    • Im jeweiligen Kontext muss ein funktionales Interface verwendet werden, d.h. ein Interface mit genau einer abstrakten Methode
    • Der Lambda-Ausdruck muss von der Signatur her dieser einen abstrakten Methode entsprechen
Challenges

Beispiel aus einem Code-Review im Dungeon-CampusMinden/Dungeon

Erklären Sie folgenden Code:

public interface IFightAI {
    void fight(Entity entity);
}

public class AIComponent extends Component {
    private final IFightAI fightAI;

    fightAI =
                entity1 -> {
                    System.out.println("TIME TO FIGHT!");
                    // todo replace with melee skill
                };
}

Sortieren mit Lambdas und funktionalen Interfaces

Betrachten Sie die Klasse Student.

  1. Definieren Sie eine Methode, die das Sortieren einer Student-Liste erlaubt. Übergeben Sie die Liste als Parameter.
  2. Schaffen Sie es, das Sortierkriterium ebenfalls als Parameter zu übergeben (als Lambda-Ausdruck)?
  3. Definieren Sie eine weitere Methode, die wieder eine Student-Liste als Parameter bekommt und liefern sie das erste Student-Objekt zurück, welches einer als Lambda-Ausdruck übergebenen Bedingung genügt.
  4. Definieren Sie noch eine Methode, die wieder eine Student-Liste als Parameter bekommt sowie einen Lambda-Ausdruck, welcher aus einem Student-Objekt ein Objekt eines anderen Typen T berechnet. Wenden Sie in der Methode den Lambda-Ausdruck auf jedes Objekt der Liste an und geben sie die resultierende neue Liste als Ergebnis zurück.

Verwenden Sie in dieser Aufgabe jeweils Lambda-Ausdrücke. Rufen Sie alle drei/vier Methoden an einem kleinen Beispiel auf.

Quellen
  • [Ullenboom2021] Java ist auch eine Insel
    Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
    Kapitel 12: Lambda-Ausdrücke und funktionale Programmierung
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Kapitel 3: Lambda Expressions, Kapitel 5: Working with streams

Methoden-Referenzen

TL;DR

Seit Java8 können Referenzen auf Methoden statt anonymer Klassen eingesetzt werden (funktionales Interface nötig).

Dabei gibt es drei mögliche Formen:

  • Form 1: Referenz auf eine statische Methode: ClassName::staticMethodName (wird verwendet wie (args) -> ClassName.staticMethodName(args))
  • Form 2: Referenz auf eine Instanz-Methode eines Objekts: objectref::instanceMethodName (wird verwendet wie (args) -> objectref.instanceMethodName(args))
  • Form 3: Referenz auf eine Instanz-Methode eines Typs: ClassName::instanceMethodName (wird verwendet wie (o1, args) -> o1.instanceMethodName(args))

Im jeweiligen Kontext muss ein passendes funktionales Interface verwendet werden, d.h. ein Interface mit genau einer abstrakten Methode. Die Methoden-Referenz muss von der Syntax her dieser einen abstrakten Methode entsprechen (bei der dritten Form wird die Methode auf dem ersten Parameter aufgerufen).

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Funktionales Interfaces (Definition)
  • (K3) Einsatz von Methoden-Referenzen

Beispiel: Sortierung einer Liste

List<Studi> sl = new ArrayList<Studi>();

// Anonyme innere Klasse
Collections.sort(sl, new Comparator<Studi>() {
    @Override public int compare(Studi o1, Studi o2) {
        return Studi.cmpCpsClass(o1, o2);
    }
});


// Lambda-Ausdruck
Collections.sort(sl, (o1, o2) -> Studi.cmpCpsClass(o1, o2));

// Methoden-Referenz
Collections.sort(sl, Studi::cmpCpsClass);

Anmerkung

Für das obige Beispiel wird davon ausgegangen, dass in der Klasse Studi eine statische Methode cmpCpsClass() existiert:

public static int cmpCpsClass(Studi s1, Studi s2) {
    return s1.getCps() - s2.getCps();
}

Wenn man im Lambda-Ausdruck nur Methoden der eigenen Klasse aufruft, kann man das auch direkt per Methoden-Referenz abkürzen!

  • Erinnerung: Comparator<T> ist ein funktionales Interface
  • Instanzen können wie üblich durch Ableiten bzw. anonyme Klassen erzeugt werden
  • Alternativ kann seit Java8 auch ein passender Lambda-Ausdruck verwendet werden
  • Ab Java8: Referenzen auf passende Methoden (Signatur!) können ein funktionales Interface "implementieren"
    • Die statische Methode static int cmpCpsClass(Studi s1, Studi s2) hat die selbe Signatur wie int compare(Studi s1, Studi s2) aus Comparator<Studi>
    • Kann deshalb wie eine Instanz von Comparator<Studi> genutzt werden
    • Name der Methode spielt dabei keine Rolle

Überblick: Arten von Methoden-Referenzen

  1. Referenz auf eine statische Methode

    • Form: ClassName::staticMethodName
    • Wirkung: Aufruf mit (args) -> ClassName.staticMethodName(args)
  2. Referenz auf Instanz-Methode eines bestimmten Objekts

    • Form: objectref::instanceMethodName
    • Wirkung: Aufruf mit (args) -> objectref.instanceMethodName(args)
  3. Referenz auf Instanz-Methode eines bestimmten Typs

    • Form: ClassName::instanceMethodName
    • Wirkung: Aufruf mit (arg0, rest) -> arg0.instanceMethodName(rest) (arg0 ist vom Typ ClassName)

Anmerkung: Analog zur Referenz auf eine statische Methode gibt es noch die Form der Referenz auf einen Konstruktor: ClassName::new. Für Referenzen auf Konstruktoren mit mehr als 2 Parametern muss ein eigenes passendes funktionales Interface mit entsprechend vielen Parametern definiert werden ...

Methoden-Referenz 1: Referenz auf statische Methode

public class Studi {
    public static int cmpCpsClass(Studi s1, Studi s2) {
        return s1.getCredits() - s2.getCredits();
    }

    public static void main(String... args) {
        List<Studi> sl = new ArrayList<Studi>();

        // Referenz auf statische Methode
        Collections.sort(sl, Studi::cmpCpsClass);

        // Entsprechender Lambda-Ausdruck
        Collections.sort(sl, (o1, o2) -> Studi.cmpCpsClass(o1, o2));
    }
}

Collections.sort() erwartet in diesem Szenario als zweiten Parameter eine Instanz von Comparator<Studi> mit einer Methode int compare(Studi o1, Studi o2).

Die übergebene Referenz auf die statische Methode cmpCpsClass der Klasse Studi hat die selbe Signatur und wird deshalb von Collections.sort() genauso genutzt wie die eigentlich erwartete Methode Comparator<Studi>#compare(Studi o1, Studi o2), d.h. statt compare(o1, o2) wird nun für jeden Vergleich Studi.cmpCpsClass(o1, o2) aufgerufen.

Methoden-Referenz 2: Referenz auf Instanz-Methode (Objekt)

public class Studi {
    public int cmpCpsInstance(Studi s1, Studi s2) {
        return s1.getCredits() - s2.getCredits();
    }

    public static void main(String... args) {
        List<Studi> sl = new ArrayList<Studi>();
        Studi holger = new Studi("Holger", 42);

        // Referenz auf Instanz-Methode eines Objekts
        Collections.sort(sl, holger::cmpCpsInstance);

        // Entsprechender Lambda-Ausdruck
        Collections.sort(sl, (o1, o2) -> holger.cmpCpsInstance(o1, o2));
    }
}

Collections.sort() erwartet in diesem Szenario als zweites Argument wieder eine Instanz von Comparator<Studi> mit einer Methode int compare(Studi o1, Studi o2).

Die übergebene Referenz auf die Instanz-Methode cmpCpsInstance des Objekts holger hat die selbe Signatur und wird entsprechend von Collections.sort() genauso genutzt wie die eigentlich erwartete Methode Comparator<Studi>#compare(Studi o1, Studi o2), d.h. statt compare(o1, o2) wird nun für jeden Vergleich holger.cmpCpsInstance(o1, o2) aufgerufen.

Methoden-Referenz 3: Referenz auf Instanz-Methode (Typ)

public class Studi {
    public int cmpCpsInstance(Studi studi) {
        return this.getCredits() - studi.getCredits();
    }

    public static void main(String... args) {
        List<Studi> sl = new ArrayList<Studi>();

        // Referenz auf Instanz-Methode eines Typs
        Collections.sort(sl, Studi::cmpCpsInstance);

        // Entsprechender Lambda-Ausdruck
        Collections.sort(sl, (o1, o2) -> o1.cmpCpsInstance(o2));
    }
}

Collections.sort() erwartet in diesem Szenario als zweites Argument wieder eine Instanz von Comparator<Studi> mit einer Methode int compare(Studi o1, Studi o2).

Die übergebene Referenz auf die Instanz-Methode cmpCpsInstance des Typs Studi hat die Signatur int cmpCpsInstance(Studi studi) und wird von Collections.sort() so genutzt: Statt compare(o1, o2) wird nun für jeden Vergleich o1.cmpCpsInstance(o2) aufgerufen.

Ausblick: Threads

Erinnerung an bzw. Vorgriff auf “Threads: Intro”:

public interface Runnable {
    void run();
}

Damit lassen sich Threads auf verschiedene Arten erzeugen:

public class ThreadStarter {
    public static void wuppie() { System.out.println("wuppie(): wuppie"); }
}


Thread t1 = new Thread(new Runnable() {
    public void run() {
        System.out.println("t1: wuppie");
    }
});

Thread t2 = new Thread(() -> System.out.println("t2: wuppie"));

Thread t3 = new Thread(ThreadStarter::wuppie);

Ausblick: Datenstrukturen als Streams

Erinnerung an bzw. Vorgriff auf “Stream-API”:

class X {
    public static boolean gtFour(int x) { return (x > 4) ? true : false; }
}

List<String> words = Arrays.asList("Java8", "Lambdas", "PM",
        "Dungeon", "libGDX", "Hello", "World", "Wuppie");

List<Integer> wordLengths = words.stream()
        .map(String::length)
        .filter(X::gtFour)
        .sorted()
        .collect(toList());
  • Collections können als Datenstrom betrachtet werden: stream()
    • Iteration über die Collection, analog zu externer Iteration mit foreach
  • Daten aus dem Strom filtern: filter, braucht Prädikat
  • Auf alle Daten eine Funktion anwenden: map
  • Daten im Strom sortieren: sort (auch mit Comparator)
  • Daten wieder einsammeln mit collect

=> Typische Elemente funktionaler Programmierung

=> Verweis auf Wahlfach "Spezielle Methoden der Programmierung"

Wrap-Up

Seit Java8: Methoden-Referenzen statt anonymer Klassen (funktionales Interface nötig)

  • Drei mögliche Formen:

    • Form 1: Referenz auf statische Methode: ClassName::staticMethodName (verwendet wie (args) -> ClassName.staticMethodName(args))
    • Form 2: Referenz auf Instanz-Methode eines Objekts: objectref::instanceMethodName (verwendet wie (args) -> objectref.instanceMethodName(args))
    • Form 3: Referenz auf Instanz-Methode eines Typs: ClassName::instanceMethodName (verwendet wie (o1, args) -> o1.instanceMethodName(args))
  • Im jeweiligen Kontext muss ein passendes funktionales Interface verwendet werden (d.h. ein Interface mit genau einer abstrakten Methode)

Challenges

Betrachten Sie den folgenden Java-Code:

public class Cat {
    int gewicht;
    public Cat(int gewicht) { this.gewicht = gewicht; }

    public static void main(String... args) {
        List<Cat> clouder = new ArrayList<>();
        clouder.add(new Cat(100));  clouder.add(new Cat(1));  clouder.add(new Cat(10));

        clouder.sort(...);
    }
}
  1. Ergänzen Sie den Methodenaufruf clouder.sort(...); mit einer geeigneten anonymen Klasse, daß der clouder aufsteigend nach Gewicht sortiert wird.
  2. Statt einer anonymen Klasse kann man auch Lambda-Ausdrücke einsetzen. Geben Sie eine konkrete Form an.
  3. Statt einer anonymen Klasse kann man auch Methodenreferenzen einsetzen. Dafür gibt es mehrere Formen. Geben Sie für zwei Formen der Methodenreferenz sowohl den Aufruf als auch die Implementierung der entsprechenden Methoden in der Klasse Cat an.
Quellen
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Kapitel 3: Lambda Expressions, Kapitel 5: Working with streams

Stream-API

TL;DR

Mit der Collection-API existiert in Java die Möglichkeit, Daten auf verschiedenste Weisen zu speichern (Collection<T>). Mit der Stream-API gibt es die Möglichkeit, diese Daten in einer Art Pipeline zu verarbeiten. Ein Stream<T> ist eine Folge von Objekten vom Typ T. Die Verarbeitung der Daten ist "lazy", d.h. sie erfolgt erst auf Anforderung (durch die terminale Operation).

Ein Stream hat eine Datenquelle und kann beispielsweise über Collection#stream() oder Stream.of() angelegt werden. Streams speichern keine Daten. Die Daten werden aus der verbundenen Datenquelle geholt.

Auf einem Stream kann man eine Folge von intermediären Operationen wie peek(), map(), flatMap(), filter(), sorted() ... durchführen. Alle diese Operationen arbeiten auf dem Stream und erzeugen einen neuen Stream als Ergebnis. Dadurch kann die typische Pipeline-artige Verkettung der Operationen ermöglicht werden. Die intermediären Operationen werden erst ausgeführt, wenn der Stream durch eine terminale Operation geschlossen wird.

Terminale Operationen wie count(), forEach(), allMatch() oder collect()

  • collect(Collectors.toList()) (bzw. direkt mit stream.toList() (ab Java16))
  • collect(Collectors.toSet())
  • collect(Collectors.toCollection(LinkedList::new)) (als Supplier<T>)

stoßen die Verarbeitung des Streams an und schließen den Stream damit ab.

Wir können hier nur die absoluten Grundlagen betrachten. Die Stream-API ist sehr groß und mächtig und lohnt die weitere selbstständige Auseinandersetzung :-)

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Streams speichern keine Daten
  • (K2) Streams verarbeiten die Daten lazy
  • (K2) map() ändert den Typ (und Inhalt) von Objekten im Stream, aber nicht die Anzahl
  • (K2) filter() ändert die Anzahl der Objekte im Stream, aber nicht deren Typ (und Inhalt)
  • (K2) Streams machen ausführlich Gebrauch von den funktionalen Interfaces in java.util.function
  • (K2) Streams sollten nicht in Attributen gehalten oder als Argument von Methoden herumgereicht werden
  • (K3) Anlegen eines Streams
  • (K3) Verkettung von intermediären Operationen
  • (K3) Durchführung der Berechnung und Abschluss des Streams mit einer terminalen Operation
  • (K3) Einsatz von flatMap()

Motivation

Es wurden Studis, Studiengänge und Fachbereiche modelliert (aus Gründen der Übersichtlichkeit einfach als Record-Klassen).

Nun soll pro Fachbereich die Anzahl der Studis ermittelt werden, die bereits 100 ECTS oder mehr haben. Dazu könnte man über alle Studiengänge im Fachbereich iterieren, und in der inneren Schleife über alle Studis im Studiengang. Dann filtert man alle Studis, deren ECTS größer 100 sind und erhöht jeweils den Zähler:

public record Studi(String name, int credits) {}
public record Studiengang(String name, List<Studi> studis) {}
public record Fachbereich(String name, List<Studiengang> studiengaenge) {}

private static long getCountFB(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        for (Studi s : sg.studis()) {
            if (s.credits() > 100) count += 1;
        }
    }
    return count;
}

Dies ist ein Beispiel, welches klassisch in OO-Manier als Iteration über Klassen realisiert ist. (Inhaltlich ist es vermutlich nicht sooo sinnvoll.)

Innere Schleife mit Streams umgeschrieben

private static long getCountSG(Studiengang sg) {
    return sg.studis().stream()
                      .map(Studi::credits)
                      .filter(c -> c > 100)
                      .count();
}

private static long getCountFB2(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        count += getCountSG(sg);
    }
    return count;
}

Erklärung des Beispiels

Im Beispiel wurde die innere Schleife in einen Stream ausgelagert.

Mit der Methode Collection#stream() wird aus der Collection ein neuer Stream erzeugt. Auf diesem wird für jedes Element durch die Methode map() die Methode Studi#credits() angewendet, was aus einem Strom von Studi einen Strom von Integer macht. Mit filter() wird auf jedes Element das Prädikat c -> c > 100 angewendet und alle Elemente aus dem Strom entfernt, die der Bedingung nicht entsprechen. Am Ende wird mit count() gezählt, wie viele Elemente im Strom enthalten sind.

Was ist ein Stream?

Ein "Stream" ist ein Strom (Folge) von Daten oder Objekten. In Java wird die Collections-API für die Speicherung von Daten (Objekten) verwendet. Die Stream-API dient zur Iteration über diese Daten und entsprechend zur Verarbeitung der Daten. In Java speichert ein Stream keine Daten.

Das Konzept kommt aus der funktionalen Programmierung und wurde in Java nachträglich eingebaut (wobei dieser Prozess noch lange nicht abgeschlossen zu sein scheint).

In der funktionalen Programmierung kennt man die Konzepte "map", "filter" und "reduce": Die Funktion "map()" erhält als Parameter eine Funktion und wendet diese auf alle Elemente eines Streams an. Die Funktion "filter()" bekommt ein Prädikat als Parameter und prüft jedes Element im Stream, ob es dem Prädikat genügt (also ob das Prädikat mit dem jeweiligen Element zu true evaluiert - die anderen Objekte werden entfernt). Mit "reduce()" kann man Streams zu einem einzigen Wert zusammenfassen (denken Sie etwa an das Aufsummieren aller Elemente eines Integer-Streams). Zusätzlich kann man in der funktionalen Programmierung ohne Probleme unendliche Ströme darstellen: Die Auswertung erfolgt nur bei Bedarf und auch dann auch nur so weit wie nötig. Dies nennt man auch "lazy evaluation".

Die Streams in Java versuchen, diese Konzepte aus der funktionalen Programmierung in die objektorientierte Programmierung zu übertragen. Ein Stream in Java hat eine Datenquelle, von wo die Daten gezogen werden - ein Stream speichert selbst keine Daten. Es gibt "intermediäre Operationen" auf einem Stream, die die Elemente verarbeiten und das Ergebnis als Stream zurückliefern. Daraus ergibt sich typische Pipeline-artige Verkettung der Operationen. Allerdings werden diese Operationen erst durchgeführt, wenn eine "terminale Operation" den Stream "abschließt". Ein Stream ohne eine terminale Operation macht also tatsächlich nichts.

Die Operationen auf dem Stream sind üblicherweise zustandslos, können aber durchaus auch einen Zustand haben. Dies verhindert üblicherweise die parallele Verarbeitung der Streams. Operationen sollten aber nach Möglichkeit keine Seiteneffekte haben, d.h. keine Daten außerhalb des Streams modifizieren. Operationen dürfen auf keinen Fall die Datenquelle des Streams modifizieren!

Erzeugen von Streams

List<String> l1 = List.of("Hello", "World", "foo", "bar", "wuppie");
Stream<String> s1 = l1.stream();

Stream<String> s2 = Stream.of("Hello", "World", "foo", "bar", "wuppie");

Random random = new Random();
Stream<Integer> s3 = Stream.generate(random::nextInt);

Pattern pattern = Pattern.compile(" ");
Stream<String> s4 = pattern.splitAsStream("Hello world! foo bar wuppie!");

Dies sind möglicherweise die wichtigsten Möglichkeiten, in Java einen Stream zu erzeugen.

Ausgehend von einer Klasse aus der Collection-API kann man die Methode Collection#stream() aufrufen und bekommt einen seriellen Stream.

Alternativ bietet das Interface Stream verschiedene statische Methoden wie Stream.of() an, mit deren Hilfe Streams angelegt werden können. Dies funktioniert auch mit Arrays ...

Und schließlich kann man per Stream.generate() einen Stream anlegen, wobei als Argument ein "Supplier" (Interface java.util.function.Supplier<T>) übergeben werden muss. Dieses Argument wird dann benutzt, um die Daten für den Stream zu generieren.

Wenn man aufmerksam hinschaut, findet man an verschiedensten Stellen die Möglichkeit, die Daten per Stream zu verarbeiten, u.a. bei regulären Ausdrücken.

Man kann per Collection#parallelStream() auch parallele Streams erzeugen, die intern das "Fork&Join-Framework" nutzen. Allerdings sollte man nur dann parallele Streams anlegen, wenn dadurch tatsächlich Vorteile durch die Parallelisierung zu erwarten sind (Overhead!).

Intermediäre Operationen auf Streams

private static void dummy(Studiengang sg) {
    sg.studis().stream()
            .peek(s -> System.out.println("Looking at: " + s.name()))
            .map(Studi::credits)
            .peek(c -> System.out.println("This one has: " + c + " ECTS"))
            .filter(c -> c > 5)
            .peek(c -> System.out.println("Filtered: " + c))
            .sorted()
            .forEach(System.out::println);
}

An diesem (weitestgehend sinnfreien) Beispiel werden einige intermediäre Operationen demonstriert.

Die Methode peek() liefert einen Stream zurück, die aus den Elementen des Eingabestroms bestehen. Auf jedes Element wird die Methode void accept(T) des Consumer<T> angewendet (Argument der Methode), was aber nicht zu einer Änderung der Daten führt. Hinweis: Diese Methode dient vor allem zu Debug-Zwecken! Durch den Seiteneffekt kann die Methode eine schlechtere Laufzeit zur Folge haben oder sogar eine sonst mögliche parallele Verarbeitung verhindern oder durch eine parallele Verarbeitung verwirrende Ergebnisse zeigen!

Die Methode map() liefert ebenfalls einen Stream zurück, der durch die Anwendung der Methode R apply(T) der als Argument übergebenen Function<T,R> auf jedes Element des Eingabestroms entsteht. Damit lassen sich die Elemente des ursprünglichen Streams verändern; für jedes Element gibt es im Ergebnis-Stream ebenfalls ein Element (der Typ ändert sich, aber nicht die Anzahl der Elemente).

Mit der Methode filter() wird ein Stream erzeugt, der alle Objekte des Eingabe-Streams enthält, auf denen die Anwendung der Methode boolean test(T) des Arguments Predicate<T> zu true evaluiert (der Typ und Inhalt der Elemente ändert sich nicht, aber die Anzahl der Elemente).

Mit sorted() wird ein Stream erzeugt, der die Elemente des Eingabe-Streams sortiert (existiert auch mit einem Comparator<T> als Parameter).

Diese Methoden sind alles intermediäre Operationen. Diese arbeiten auf einem Stream und erzeugen einen neuen Stream und werden erst dann ausgeführt, wenn eine terminale Operation den Stream abschließt.

Dabei sind die gezeigten intermediären Methoden bis auf sorted() ohne inneren Zustand. sorted() ist eine Operation mit innerem Zustand (wird für das Sortieren benötigt). Dies kann ordentlich in Speicher und Zeit zuschlagen und u.U. nicht/nur schlecht parallelisierbar sein. Betrachten Sie den fiktiven parallelen Stream stream.parallel().sorted().skip(42): Hier müssen erst alle Elemente sortiert werden, bevor mit skip(42) die ersten 42 Elemente entfernt werden. Dies kann auch nicht mehr parallel durchgeführt werden.

Die Methode forEach() schließlich ist eine terminale Operation, die auf jedes Element des Eingabe-Streams die Methode void accept(T) des übergebenen Consumer<T> anwendet. Diese Methode ist eine terminale Operation, d.h. sie führt zur Auswertung der anderen intermediären Operationen und schließt den Stream ab.

Was tun, wenn eine Methode Streams zurückliefert

Wir konnten vorhin nur die innere Schleife in eine Stream-basierte Verarbeitung umbauen. Das Problem ist: Die äußere Schleife würde einen Stream liefern (Stream von Studiengängen), auf dem wir die map-Funktion anwenden müssten und darin dann für jeden Studiengang einen (inneren) Stream mit den Studis eines Studiengangs verarbeiten müssten.

private static long getCountSG(Studiengang sg) {
    return sg.studis().stream().map(Studi::credits).filter(c -> c > 100).count();
}

private static long getCountFB2(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        count += getCountSG(sg);
    }
    return count;
}

Dafür ist die Methode flatMap() die Lösung. Diese Methode bekommt als Argument ein Objekt vom Typ Function<? super T, ? extends Stream<? extends R>> mit einer Methode Stream<? extends R> apply(T). Die Methode flatMap() verarbeitet den Stream in zwei Schritten:

  1. Mappe über alle Elemente des Eingabe-Streams mit der Funktion. Im Beispiel würde also aus einem Stream<Studiengang> jeweils ein Stream<Stream<Studi>>, also alle Studiengang-Objekte werden durch je ein Stream<Studi>-Objekt ersetzt. Wir haben jetzt also einen Stream von Stream<Studi>-Objekten.

  2. "Klopfe den Stream wieder flach", d.h. nimm die einzelnen Studi-Objekte aus den Stream<Studi>-Objekten und setze diese stattdessen in den Stream. Das Ergebnis ist dann wie gewünscht ein Stream<Studi> (Stream mit Studi-Objekten).

private static long getCountFB3(Fachbereich fb) {
    return fb.studiengaenge().stream()
            .flatMap(sg -> sg.studis().stream())
            .map(Studi::credits)
            .filter(c -> c > 100)
            .count();
}

Zum direkten Vergleich hier noch einmal der ursprüngliche Code mit zwei verschachtelten Schleifen und entsprechenden Hilfsvariablen:

private static long getCountFB(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        for (Studi s : sg.studis()) {
            if (s.credits() > 100) count += 1;
        }
    }
    return count;
}

Streams abschließen: Terminale Operationen

Stream<String> s = Stream.of("Hello", "World", "foo", "bar", "wuppie");

long count = s.count();
s.forEach(System.out::println);
String first = s.findFirst().get();
Boolean b = s.anyMatch(e -> e.length() > 3);

List<String> s1 = s.collect(Collectors.toList());
List<String> s2 = s.toList();   // ab Java16
Set<String> s3 = s.collect(Collectors.toSet());
List<String> s4 = s.collect(Collectors.toCollection(LinkedList::new));

Streams müssen mit einer terminalen Operation abgeschlossen werden, damit die Verarbeitung tatsächlich angestoßen wird (lazy evaluation).

Es gibt viele verschiedene terminale Operationen. Wir haben bereits count() und forEach() gesehen. In der Sitzung zu “Optionals” werden wir noch findFirst() näher kennenlernen.

Daneben gibt es beispielsweise noch allMatch(), anyMatch() und noneMatch(), die jeweils ein Prädikat testen und einen Boolean zurückliefern (matchen alle, mind. eines oder keines der Objekte im Stream).

Mit min() und max() kann man sich das kleinste und das größte Element des Streams liefern lassen. Beide Methoden benötigen dazu einen Comparator<T> als Parameter.

Mit der Methode collect() kann man eine der drei Methoden aus Collectors über den Stream laufen lassen und eine Collection erzeugen lassen:

  1. toList() sammelt die Elemente in ein List-Objekt (bzw. direkt mit stream.toList() (ab Java16))
  2. toSet() sammelt die Elemente in ein Set-Objekt
  3. toCollection() sammelt die Elemente durch Anwendung der Methode T get() des übergebenen Supplier<T>-Objekts auf

Die ist nur die sprichwörtliche "Spitze des Eisbergs"! Es gibt viele weitere Möglichkeiten, sowohl bei den intermediären als auch den terminalen Operationen. Schauen Sie in die Dokumentation!

Spielregeln

  • Operationen dürfen nicht die Stream-Quelle modifizieren

  • Operationen können die Werte im Stream ändern (map) oder die Anzahl (filter)

  • Keine Streams in Attributen/Variablen speichern oder als Argumente übergeben: Sie könnten bereits "gebraucht" sein!

    => Ein Stream sollte immer sofort nach der Erzeugung benutzt werden

  • Operationen auf einem Stream sollten keine Seiteneffekte (Veränderungen von Variablen/Attributen außerhalb des Streams) haben (dies verhindert u.U. die parallele Verarbeitung)

Wrap-Up

Stream<T>: Folge von Objekten vom Typ T, Verarbeitung "lazy" (Gegenstück zu Collection<T>: Dort werden Daten gespeichert, hier werden Daten verarbeitet)

  • Neuen Stream anlegen: Collection#stream() oder Stream.of() ...

  • Intermediäre Operationen: peek(), map(), flatMap(), filter(), sorted() ...

  • Terminale Operationen: count(), forEach(), allMatch(), collect() ...

    • collect(Collectors.toList())
    • collect(Collectors.toSet())
    • collect(Collectors.toCollection()) (mit Supplier<T>)
  • Streams speichern keine Daten

  • Intermediäre Operationen laufen erst bei Abschluss des Streams los

  • Terminale Operation führt zur Verarbeitung und Abschluss des Streams

Schöne Doku: "The Stream API", und auch "Package java.util.stream".

Challenges

Betrachten Sie den folgenden Java-Code:

record Cat(int weight){};

public class Main {
    public static void main(String... args) {
        List<Cat> clouder = new ArrayList<>();
        clouder.add(new Cat(100));  clouder.add(new Cat(1));  clouder.add(new Cat(10));

        sumOverWeight(8, clouder);
    }

    private static int sumOverWeight(int threshold, List<Cat> cats) {
        int result = 0;
        for (Cat c : cats) {
            int weight = c.weight();
            if (weight > threshold) {
                result += weight;
            }
        }
        return result;
    }
}

Schreiben Sie die Methode sumOverWeight unter Beibehaltung der Funktionalität so um, dass statt der for-Schleife und der if-Abfrage Streams und Stream-Operationen eingesetzt werden. Nutzen Sie passende Lambda-Ausdrücke und nach Möglichkeit Methodenreferenzen.

Quellen

Record-Klassen

TL;DR

Häufig schreibt man relativ viel Boiler Plate Code, um einfach ein paar Daten plus den Konstruktor und die Zugriffsmethoden zu kapseln. Und selbst wenn die IDE dies zum Teil abnehmen kann - lesen muss man diesen Overhead trotzdem noch.

Für den Fall von Klassen mit final Attributen wurden in Java14 die Record-Klassen eingeführt. Statt dem Schlüsselwort class wird das neue Schlüsselwort record verwendet. Nach dem Klassennamen kommen in runden Klammern die "Komponenten" - eine Auflistung der Parameter für den Standardkonstruktor (Typ, Name). Daraus wird automatisch ein "kanonischer Konstruktor" mit exakt diesen Parametern generiert. Es werden zusätzlich private final Attribute generiert für jede Komponente, und diese werden durch den kanonischen Konstruktor gesetzt. Außerdem wird für jedes Attribut automatisch ein Getter mit dem Namen des Attributs generiert (also ohne den Präfix "get").

Beispiel:

public record StudiR(String name, int credits) {}

Der Konstruktor und die Getter können überschrieben werden, es können auch eigene Methoden definiert werden (eigene Konstruktoren müssen den kanonischen Konstruktor aufrufen). Es gibt außer den über die Komponenten definierten Attribute keine weiteren Attribute. Da eine Record-Klasse intern von java.lang.Record ableitet, kann eine Record-Klasse nicht von weiteren Klassen ableiten (erben). Man kann aber beliebig viele Interfaces implementieren. Record-Klassen sind implizit final, d.h. man nicht von Record-Klassen erben.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Record-Klassen sind final
  • (K2) Record-Klassen haben einen kanonischen Konstruktor
  • (K2) Die Attribute von Record-Klassen sind final und werden automatisch angelegt und über den Konstruktor gesetzt
  • (K2) Die Getter in Record-Klassen haben die Namen und Typen der Komponenten, also keinen Präfix 'get'
  • (K2) Der kanonische Konstruktor kann ergänzt werden
  • (K2) Es können weitere Methoden definiert werden
  • (K2) Record-Klassen können nicht von anderen Klassen erben, können aber Interfaces implementieren
  • (K3) Einsatz von Record-Klassen

Motivation; Klasse Studi

public class Studi {
    private final String name;
    private final int credits;

    public Studi(String name, int credits) {
        this.name = name;
        this.credits = credits;
    }

    public String getName() {
        return name;
    }

    public int getCredits() {
        return credits;
    }
}

Klasse Studi als Record

public record StudiR(String name, int credits) {}
  • Immutable Klasse mit Feldern String name und int credits => "(String name, int credits)" werden "Komponenten" des Records genannt

  • Standardkonstruktor setzt diese Felder ("Kanonischer Konstruktor")

  • Getter für beide Felder:

    public String name() { return this.name; }
    public int credits() { return this.credits; }

Record-Klassen wurden in Java14 eingeführt und werden immer wieder in neuen Releases erweitert/ergänzt.

Der kanonische Konstruktor hat das Aussehen wie die Record-Deklaration, im Beispiel also public StudiR(String name, int credits). Dabei werden die Komponenten über eine Kopie der Werte initialisiert.

Für die Komponenten werden automatisch private Attribute mit dem selben Namen angelegt.

Für die Komponenten werden automatisch Getter angelegt. Achtung: Die Namen entsprechen denen der Komponenten, es fehlt also der übliche "get"-Präfix!

Eigenschaften und Einschränkungen von Record-Klassen

  • Records erweitern implizit die Klasse java.lang.Record: Keine andere Klassen mehr erweiterbar! (Interfaces kein Problem)

  • Record-Klassen sind implizit final

  • Keine weiteren (Instanz-) Attribute definierbar (nur die Komponenten)

  • Keine Setter definierbar für die Komponenten: Attribute sind final

  • Statische Attribute mit Initialisierung erlaubt

Records: Prüfungen im Konstruktor

Der Konstruktor ist erweiterbar:

public record StudiS(String name, int credits) {
    public StudiS(String name, int credits) {
        if (name == null) { throw new IllegalArgumentException("Name cannot be null!"); }
        else { this.name = name; }

        if (credits < 0) { this.credits = 0; }
        else { this.credits = credits; }
    }
}

In dieser Form muss man die Attribute selbst setzen.

Alternativ kann man die "kompakte" Form nutzen:

public record StudiT(String name, int credits) {
    public StudiT {
        if (name == null) { throw new IllegalArgumentException("Name cannot be null!"); }

        if (credits < 0) { credits = 0; }
    }
}

In der kompakten Form kann man nur die Werte der Parameter des Konstruktors ändern. Das Setzen der Attribute ergänzt der Compiler nach dem eigenen Code.

Es sind weitere Konstruktoren definierbar, diese müssen den kanonischen Konstruktor aufrufen:

public StudiT() {
    this("", 42);
}

Getter und Methoden

Getter werden vom Compiler automatisch generiert. Dabei entsprechen die Methoden-Namen den Namen der Attribute:

public record StudiR(String name, int credits) {}

public static void main(String... args) {
    StudiR r = new StudiR("Sabine", 75);

    int x = r.credits();
    String y = r.name();
}

Getter überschreibbar und man kann weitere Methoden definieren:

public record StudiT(String name, int credits) {
    public int credits() { return credits + 42; }
    public void wuppie() { System.out.println("WUPPIE"); }
}

Die Komponenten/Attribute sind aber final und können nicht über Methoden geändert werden!

Beispiel aus den Challenges

In den Challenges zum Thema Optional gibt es die Klasse Katze in den Vorgaben.

Die Katze wurde zunächst "klassisch" modelliert: Es gibt drei Eigenschaften name, gewichtund lieblingsBox. Ein Konstruktor setzt diese Felder und es gibt drei Getter für die einzelnen Eigenschaften. Das braucht 18 Zeilen Code (ohne Kommentare Leerzeilen). Zudem erzeugt der Boilerplate-Code relativ viel "visual noise", so dass der eigentliche Kern der Klasse schwerer zu erkennen ist.

In einem Refactoring wurde diese Klasse durch eine äquivalente Record-Klasse ersetzt, die nur noch 2 Zeilen Code (je nach Code-Style auch nur 1 Zeile) benötigt. Gleichzeitig wurde die Les- und Wartbarkeit deutlich verbessert.

Wrap-Up

  • Records sind immutable Klassen:
    • final Attribute (entsprechend den Komponenten)
    • Kanonischer Konstruktor
    • Automatische Getter (Namen wie Komponenten)
  • Konstruktoren und Methoden können ergänzt/überschrieben werden
  • Keine Vererbung von Klassen möglich (kein extends)

Schöne Doku: "Using Record to Model Immutable Data".

Challenges

Betrachen Sie den folgenden Code:

public interface Person {
    String getName();
    Date getBirthday();
}

public class Student implements Person {
    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yyyy");

    private final String name;
    private final Date birthday;

    public Student(String name, String birthday) throws ParseException {
        this.name = name;
        this.birthday = DATE_FORMAT.parse(birthday);
    }

    public String getName() { return name; }
    public Date getBirthday() { return birthday; }
}

Schreiben Sie die Klasse Student in eine Record-Klasse um. Was müssen Sie zusätzlich noch tun, damit die aktuelle API erhalten bleibt?

Quellen
  • [LernJava] Learn Java
    Oracle Corporation, 2022.
    Tutorials \> Using Record to Model Immutable Data

Optional

TL;DR

Häufig hat man in Methoden den Fall, dass es keinen Wert gibt, und man liefert dann null als "kein Wert vorhanden" zurück. Dies führt dazu, dass die Aufrufer eine entsprechende null-Prüfung für die Rückgabewerte durchführen müssen, bevor sie das Ergebnis nutzen können.

Optional schließt elegant den Fall "kein Wert vorhanden" ein: Es kann mit der Methode Optional.ofNullable() das Argument in ein Optional verpacken (Argument != null) oder ein Optional.empty() zurückliefern ("leeres" Optional, wenn Argument == null).

Man kann Optionals prüfen mit isEmpty() und ifPresent() und dann direkt mit ifPresent(), orElse() und orElseThrow() auf den verpackten Wert zugreifen. Besser ist aber der Zugriff über die Stream-API von Optional: map(), filter, flatMap(), ...

Optional ist vor allem für Rückgabewerte gedacht, die den Fall "kein Wert vorhanden" einschließen sollen. Attribute, Parameter und Sammlungen sollten nicht Optional-Referenzen speichern, sondern "richtige" (unverpackte) Werte (und eben zur Not null). Optional ist kein Ersatz für null-Prüfung von Methoden-Parametern (nutzen Sie hier beispielsweise passende Annotationen). Optional ist auch kein Ersatz für vernünftiges Exception-Handling im Fall, dass etwas Unerwartetes passiert ist. Liefern Sie niemals null zurück, wenn der Rückgabetyp der Methode ein Optional ist!

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Optionals sind kein Ersatz für null-Prüfung!
  • (K2) Optionals sollen nicht für Attribute oder Parameter genutzt werden
  • (K2) Es darf kein null zurückgeliefert werden, wenn der Rückgabetyp ein Optional ist
  • (K2) Optionals und null sind kein Ersatz für Exception-Handling
  • (K3) Einsatz von Optional in Rückgabewerten
  • (K3) Erzeugen von Optionals mit Optional.ofNullable()
  • (K3) Zugriff auf Optionals entweder direkt oder per Stream-API

Motivation

public class LSF {
    private Set<Studi> sl;

    public Studi getBestStudi() {
        if (sl == null) return null;  // Fehler: Es gibt noch keine Sammlung

        Studi best = null;
        for (Studi s : sl) {
            if (best == null) best = s;
            if (best.credits() < s.credits()) best = s;
        }
        return best;
    }
}

public static void main(String... args) {
    LSF lsf = new LSF();

    Studi best = lsf.getBestStudi();
    if (best != null) {
        String name = best.name();
        if (name != null) {
            // mach was mit dem Namen ...
        }
    }
}

Problem: null wird an (zu) vielen Stellen genutzt

  • Es gibt keinen Wert ("not found")
  • Felder wurden (noch) nicht initialisiert
  • Es ist ein Problem oder etwas Unerwartetes aufgetreten

=> Parameter und Rückgabewerte müssen stets auf null geprüft werden (oder Annotationen wie @NotNull eingesetzt werden ...)

Lösung

  • Optional<T> für Rückgabewerte, die "kein Wert vorhanden" mit einschließen (statt null bei Abwesenheit von Werten)
  • @NotNull/@Nullable für Parameter einsetzen (oder separate Prüfung)
  • Exceptions werfen in Fällen, wo ein Problem aufgetreten ist

Anmerkungen

  • Verwendung von null auf Attribut-Ebene (Klassen-interne Verwendung) ist okay!
  • Optional<T> ist kein Ersatz für null-Checks!
  • null ist kein Ersatz für vernünftiges Error-Handling! Das häufig zu beobachtende "Irgendwas Unerwartetes ist passiert, hier ist null" ist ein Anti-Pattern!

Beispiel aus der Praxis im PM-Dungeon

Schauen Sie sich einmal das Review zu den ecs.components.ai.AITools in https://github.com/Dungeon-CampusMinden/Dungeon/pull/128#pullrequestreview-1254025874 an.

Die Methode AITools#calculateNewPath soll in der Umgebung einer als Parameter übergebenen Entität nach einem Feld (Tile) suchen, welches für die Entität betretbar ist und einen Pfad von der Position der Entität zu diesem Feld an den Aufrufer zurückliefern.

Zunächst wird in der Entität nach einer PositionComponent und einer VelocityComponent gesucht. Wenn es (eine) diese(r) Components nicht in der Entität gibt, wird der Wert null an den Aufrufer von AITools#calculateNewPath zurückgeliefert. (Anmerkung: Interessanterweise wird in der Methode nicht mit der VelocityComponent gearbeitet.)

Dann wird in der PositionComponent die Position der Entität im aktuellen Level abgerufen. In einer Schleife werden alle Felder im gegebenen Radius in eine Liste gespeichert. (Anmerkung: Da dies über die float-Werte passiert und nicht über die Feld-Indizes wird ein Tile u.U. recht oft in der Liste abgelegt. Können Sie sich hier einfache Verbesserungen überlegen?)

Da level.getTileAt() offenbar als Antwort auch null zurückliefern kann, werden nun zunächst per tiles.removeIf(Objects::isNull); all diese null-Werte wieder aus der Liste entfernt. Danach erfolgt die Prüfung, ob die verbleibenden Felder betretbar sind und nicht-betretbare Felder werden entfernt.

Aus den verbleibenden (betretbaren) Feldern in der Liste wird nun eines zufällig ausgewählt und per level.findPath() ein Pfad von der Position der Entität zu diesem Feld berechnet und zurückgeliefert. (Anmerkung: Hier wird ein zufälliges Tile in der Liste der umgebenden Felder gewählt, von diesem die Koordinaten bestimmt, und dann noch einmal aus dem Level das dazugehörige Feld geholt - dabei hatte man die Referenz auf das Feld bereits in der Liste. Können Sie sich hier eine einfache Verbesserung überlegen?)

Zusammengefasst:

  • Die als Parameter entity übergebene Referenz darf offenbar nicht null sein. Die ersten beiden Statements in der Methode rufen auf dieser Referenz Methoden auf, was bei einer null-Referenz zu einer NullPointer-Exception führen würde. Hier wäre null ein Fehlerzustand.
  • entity.getComponent() kann offenbar null zurückliefern, wenn die gesuchte Component nicht vorhanden ist. Hier wird null als "kein Wert vorhanden" genutzt, was dann nachfolgende null-Checks notwendig macht.
  • Wenn es die gewünschten Components nicht gibt, wird dem Aufrufer der Methode null zurückgeliefert. Hier ist nicht ganz klar, ob das einfach nur "kein Wert vorhanden" ist oder eigentlich ein Fehlerzustand?
  • level.getTileAt() kann offenbar null zurückliefern, wenn kein Feld an der Position vorhanden ist. Hier wird null wieder als "kein Wert vorhanden" genutzt, was dann nachfolgende null-Checks notwendig macht (Entfernen aller null-Referenzen aus der Liste).
  • level.findPath() kann auch wieder null zurückliefern, wenn kein Pfad berechnet werden konnte. Hier ist wieder nicht ganz klar, ob das einfach nur "kein Wert vorhanden" ist oder eigentlich ein Fehlerzustand? Man könnte beispielsweise in diesem Fall ein anderes Feld probieren?

Der Aufrufer bekommt also eine NullPointer-Exception, wenn der übergebene Parameter entity nicht vorhanden ist oder den Wert null, wenn in der Methode etwas schief lief oder schlicht kein Pfad berechnet werden konnte oder tatsächlich einen Pfad. Damit wird der Aufrufer gezwungen, den Rückgabewert vor der Verwendung zu untersuchen.

Allein in dieser einen kurzen Methode macht null so viele extra Prüfungen notwendig und den Code dadurch schwerer lesbar und fehleranfälliger! null wird als (unvollständige) Initialisierung und als Rückgabewert und für den Fehlerfall genutzt, zusätzlich ist die Semantik von null nicht immer klar. (Anmerkung: Der Gebrauch von null hat nicht wirklich etwas mit "der Natur eines ECS" zu tun. Die Methode wurde mittlerweile komplett überarbeitet und ist in der hier gezeigten Form glücklicherweise nicht mehr zu finden.)

Entsprechend hat sich in diesem Review die nachfolgende Diskussion ergeben:

Erzeugen von Optional-Objekten

Konstruktor ist private ...

  • "Kein Wert": Optional.empty()

  • Verpacken eines non-null Elements: Optional.of() (NullPointerException wenn Argument null!)

  • Verpacken eines "unsicheren"/beliebigen Elements: Optional.ofNullable()

    • Liefert verpacktes Element, oder
    • Optional.empty(), falls Element null war

Es sollte in der Praxis eigentlich nur wenige Fälle geben, wo ein Aufruf von Optional.of() sinnvoll ist. Ebenso ist Optional.empty() nur selten sinnvoll.

Stattdessen sollte stets Optional.ofNullable() verwendet werden.

null kann nicht nicht in Optional<T> verpackt werden! (Das wäre dann eben Optional.empty().)

LSF liefert jetzt Optional zurück

public class LSF {
    private Set<Studi> sl;

    public Optional<Studi> getBestStudi() throws NullPointerException {
        // Fehler: Es gibt noch keine Sammlung
        if (sl == null) throw new NullPointerException("There ain't any collection");

        Studi best = null;
        for (Studi s : sl) {
            if (best == null) best = s;
            if (best.credits() < s.credits()) best = s;
        }

        // Entweder Optional.empty() (wenn best==null) oder Optional.of(best) sonst
        return Optional.ofNullable(best);
    }
}

Das Beispiel soll verdeutlichen, dass man im Fehlerfall nicht einfach null oder Optional.empty() zurückliefern soll, sondern eine passende Exception werfen soll.

Wenn die Liste aber leer ist, stellt dies keinen Fehler dar! Es handelt sich um den Fall "kein Wert vorhanden". In diesem Fall wird statt null nun ein Optional.empty() zurückgeliefert, also ein Objekt, auf dem der Aufrufer die üblichen Methoden aufrufen kann.

Zugriff auf Optional-Objekte

In der funktionalen Programmierung gibt es schon lange das Konzept von Optional, in Haskell ist dies beispielsweise die Monade Maybe. Allerdings ist die Einbettung in die Sprache von vornherein mit berücksichtigt worden, insbesondere kann man hier sehr gut mit Pattern Matching in der Funktionsdefinition auf den verpackten Inhalt reagieren.

In Java gibt es die Methode Optional#isEmpty(), die einen Boolean zurückliefert und prüft, ob es sich um ein leeres Optional handelt oder ob hier ein Wert "verpackt" ist.

Für den direkten Zugriff auf die Werte gibt es die Methoden Optional#orElseThrow() und Optional#orElse(). Damit kann man auf den verpackten Wert zugreifen, oder es wird eine Exception geworfen bzw. ein Ersatzwert geliefert.

Zusätzlich gibt es Optional#isPresent(), die als Parameter ein java.util.function.Consumer erwartet, also ein funktionales Interface mit einer Methode void accept(T), die das Objekt verarbeitet.

Studi best;

// Testen und dann verwenden
if (!lsf.getBestStudi().isEmpty()) {
    best = lsf.getBestStudi().get();
    // mach was mit dem Studi ...
}

// Arbeite mit Consumer
lsf.getBestStudi().ifPresent(studi -> {
    // mach was mit dem Studi ...
});

// Studi oder Alternative (wenn Optional.empty())
best = lsf.getBestStudi().orElse(anne);

// Studi oder NoSuchElementException (wenn Optional.empty())
best = lsf.getBestStudi().orElseThrow();

Es gibt noch eine Methode get(), die so verhält wie orElseThrow(). Da man diese Methode vom Namen her schnell mit einem Getter verwechselt, ist sie mittlerweile deprecated.

Anmerkung: Da getBestStudi() eine NullPointerException werfen kann, sollte der Aufruf möglicherweise in ein try/catch verpackt werden. Dito für orElseThrow().

Einsatz mit Stream-API

public class LSF {
    ...
    public Optional<Studi> getBestStudi() throws NullPointerException {
        if (sl == null) throw new NullPointerException("There ain't any collection");
        return sl.stream()
                 .sorted((s1, s2) -> s2.credits() - s1.credits())
                 .findFirst();
    }
}


public static void main(String... args) {
    ...
    String name = lsf.getBestStudi()
                     .map(Studi::name)
                     .orElseThrow();
}

Im Beispiel wird in getBestStudi() die Sammlung als Stream betrachtet, über die Methode sorted() und den Lamda-Ausdruck für den Comparator sortiert ("falsch" herum: absteigend in den Credits der Studis in der Sammlung), und findFirst() ist die terminale Operation auf dem Stream, die ein Optional<Studi> zurückliefert: entweder den Studi mit den meisten Credits (verpackt in Optional<Studi>) oder Optional.empty(), wenn es überhaupt keine Studis in der Sammlung gab.

In main() wird dieses Optional<Studi> mit den Stream-Methoden von Optional<T> bearbeitet, zunächst mit Optional#map(). Man braucht nicht selbst prüfen, ob das von getBestStudi() erhaltene Objekt leer ist oder nicht, da dies von Optional#map() erledigt wird: Es wendet die Methodenreferenz auf den verpackten Wert an (sofern dieser vorhanden ist) und liefert damit den Namen des Studis als Optional<String> verpackt zurück. Wenn es keinen Wert, also nur Optional.empty() von getBestStudi() gab, dann ist der Rückgabewert von Optional#map() ein Optional.empty(). Wenn der Name, also der Rückgabewert von Studi::name, null war, dann wird ebenfalls ein Optional.empty() zurückgeliefert. Dadurch wirft orElseThrow() dann eine NoSuchElementException. Man kann also direkt mit dem String name weiterarbeiten ohne extra null-Prüfung - allerdings will man noch ein Exception-Handling einbauen (dies fehlt im obigen Beispiel aus Gründen der Übersicht) ...

Weitere Optionals

Für die drei primitiven Datentypen int, long und double gibt es passende Wrapper-Klassen von Optional<T>: OptionalInt, OptionalLong und OptionalDouble.

Diese verhalten sich analog zu Optional<T>, haben aber keine Methode ofNullable(), da dies hier keinen Sinn ergeben würde: Die drei primitiven Datentypen repräsentieren Werte - diese können nicht null sein.

Regeln für Optional

  1. Nutze Optional nur als Rückgabe für "kein Wert vorhanden"

    Optional ist nicht als Ersatz für eine null-Prüfung o.ä. gedacht, sondern als Repräsentation, um auch ein "kein Wert vorhanden" zurückliefern zu können.

  2. Nutze nie null für eine Optional-Variable oder einen Optional-Rückgabewert

    Wenn man ein Optional als Rückgabe bekommt, sollte das niemals selbst eine null-Referenz sein. Das macht das gesamte Konzept kaputt!

    Nutzen Sie stattdessen Optional.empty().

  3. Nutze Optional.ofNullable() zum Erzeugen eines Optional

    Diese Methode verhält sich "freundlich" und erzeugt automatisch ein Optional.empty(), wenn das Argument null ist. Es gibt also keinen Grund, dies mit einer Fallunterscheidung selbst erledigen zu wollen.

    Bevorzugen Sie Optional.ofNullable() vor einer manuellen Fallunterscheidung und dem entsprechenden Einsatz von Optional.of() und Optional.empty().

  4. Erzeuge keine Optional als Ersatz für die Prüfung auf null

    Wenn Sie auf null prüfen müssen, müssen Sie auf null prüfen. Der ersatzweise Einsatz von Optional macht es nur komplexer - prüfen müssen Sie hinterher ja immer noch.

  5. Nutze Optional nicht in Attributen, Methoden-Parametern und Sammlungen

    Nutzen Sie Optional vor allem für Rückgabewerte.

    Attribute sollten immer direkt einen Wert haben oder null, analog Parameter von Methoden o.ä. ... Hier hilft Optional nicht, Sie müssten ja trotzdem eine null-Prüfung machen, nur eben dann über den Optional, wodurch dies komplexer und schlechter lesbar wird.

    Aus einem ähnlichen Grund sollten Sie auch in Sammlungen keine Optional speichern!

  6. Vermeide den direkten Zugriff (ifPresent(), orElseThrow() ...)

    Der direkte Zugriff auf ein Optional entspricht dem Prüfen auf null und dann dem Auspacken. Dies ist nicht nur Overhead, sondern auch schlechter lesbar.

    Vermeiden Sie den direkten Zugriff und nutzen Sie Optional mit den Stream-Methoden. So ist dies von den Designern gedacht.

Wrap-Up

Optional als Rückgabe für "kein Wert vorhanden"

  • Optional.ofNullable(): Erzeugen eines Optional

    • Entweder Objekt "verpackt" (Argument != null)
    • Oder Optional.empty() (Argument == null)
  • Prüfen mit isEmpty() und ifPresent()

  • Direkter Zugriff mit ifPresent(), orElse() und orElseThrow()

  • Stream-API: map(), filter(), flatMap(), ...

  • Attribute, Parameter und Sammlungen: nicht Optional nutzen

  • Kein Ersatz für null-Prüfung!

Schöne Doku: "Using Optionals".

Challenges

String-Handling

Können Sie den folgenden Code so umschreiben, dass Sie statt der if-Abfragen und der einzelnen direkten Methodenaufrufe die Stream-API und Optional<T> nutzen?

String format(final String text, String replacement) {
    if (text.isEmpty()) {
        return "";
    }

    final String trimmed = text.trim();
    final String withSpacesReplaced = trimmed.replaceAll(" +", replacement);

    return replacement + withSpacesReplaced + replacement;
}

Ein Aufruf format(" Hello World ... ", "_"); liefert den String "_Hello_World_..._".

Quellen

Interfaces: Default-Methoden

TL;DR

Seit Java8 können Methoden in Interfaces auch fertig implementiert sein: Sogenannte Default-Methoden.

Dazu werden die Methoden mit dem neuen Schlüsselwort default gekennzeichnet. Die Implementierung wird an die das Interface implementierenden Klassen (oder Interfaces) vererbt und kann bei Bedarf überschrieben werden.

Da eine Klasse von einer anderen Klasse erben darf, aber mehrere Interfaces implementieren kann, könnte es zu einer Mehrfachvererbung einer Methode kommen: Eine Methode könnte beispielsweise in verschiedenen Interfaces als Default-Methode angeboten werden, und wenn eine Klasse diese Interfaces implementiert, steht eine Methode mit der selben Signatur auf einmal mehrfach zur Verfügung. Dies muss (u.U. manuell) aufgelöst werden.

Auflösung von Mehrfachvererbung:

  • Regel 1: Klassen gewinnen
  • Regel 2: Sub-Interfaces gewinnen
  • Regel 3: Methode explizit auswählen

Aktuell ist der Unterschied zu abstrakten Klassen: Interfaces können keinen Zustand haben, d.h. keine Attribute/Felder.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Interfaces mit Default-Methoden, Unterschied zu abstrakten Klassen
  • (K2) Problem der Mehrfachvererbung
  • (K3) Erstellen von Interfaces mit Default-Methoden
  • (K3) Regeln zum Auflösen der Mehrfachvererbung

Problem: Etablierte API (Interfaces) erweitern

interface Klausur {
    void anmelden(Studi s);
    void abmelden(Studi s);
}

=> Nachträglich noch void schreiben(Studi s); ergänzen?

Wenn ein Interface nachträglich erweitert wird, müssen alle Kunden (also alle Klassen, die das Interface implementieren) auf die neuen Signaturen angepasst werden. Dies kann viel Aufwand verursachen und API-Änderungen damit unmöglich machen.

Default-Methoden: Interfaces mit Implementierung

Seit Java8 können Interfaces auch Methoden implementieren. Es gibt zwei Varianten: Default-Methoden und statische Methoden.

interface Klausur {
    void anmelden(Studi s);
    void abmelden(Studi s);

    default void schreiben(Studi s) {
        ...     // Default-Implementierung
    }

    default void wuppie() {
        throw new java.lang.UnsupportedOperationException();
    }
}

Methoden können in Interfaces seit Java8 implementiert werden. Für Default-Methoden muss das Schlüsselwort default vor die Signatur gesetzt werden. Klassen, die das Interface implementieren, können diese Default-Implementierung erben oder selbst neu implementieren (überschreiben). Alternativ kann die Klasse eine Default-Methode neu deklarieren und wird damit zur abstrakten Klasse.

Dies ähnelt abstrakten Klassen. Allerdings kann in abstrakten Klassen neben dem Verhalten (implementierten Methoden) auch Zustand über die Attribute gespeichert werden.

Problem: Mehrfachvererbung

Drei Regeln zum Auflösen bei Konflikten:

  1. Klassen gewinnen: Methoden aus Klasse oder Superklasse haben höhere Priorität als Default-Methoden
  2. Sub-Interfaces gewinnen: Methode aus am meisten spezialisiertem Interface mit Default-Methode wird gewählt Beispiel: Wenn B extends A dann ist B spezialisierter als A
  3. Sonst: Klasse muss Methode explizit auswählen: Methode überschreiben und gewünschte (geerbte) Variante aufrufen: X.super.m(...) (X ist das gewünschte Interface)

Auf den folgenden Folien wird dies anhand kleiner Beispiele verdeutlicht.

Auflösung Mehrfachvererbung: 1. Klassen gewinnen

interface A {
    default String hello() { return "A"; }
}
class C {
    public String hello() { return "C"; }
}
class E extends C implements A {}


/** Mehrfachvererbung: 1. Klassen gewinnen */
public class DefaultTest1 {
    public static void main(String... args) {
        String e = new E().hello();
    }
}

Die Klasse E erbt sowohl von Klasse C als auch vom Interface A die Methode hello() (Mehrfachvererbung). In diesem Fall "gewinnt" die Implementierung aus Klasse C.

1. Regel: Klassen gewinnen immer. Deklarationen einer Methode in einer Klasse oder einer Oberklasse haben Vorrang von allen Default-Methoden.

Auflösung Mehrfachvererbung: 2. Sub-Interfaces gewinnen

interface A {
    default String hello() { return "A"; }
}
interface B extends A {
    @Override default String hello() { return "B"; }
}
class D implements A, B {}


/** Mehrfachvererbung: 2. Sub-Interfaces gewinnen */
public class DefaultTest2 {
    public static void main(String... args) {
        String e = new D().hello();
    }
}

Die Klasse D erbt sowohl vom Interface A als auch vom Interface B die Methode hello() (Mehrfachvererbung). In diesem Fall "gewinnt" die Implementierung aus Klasse B: Interface B ist spezialisierter als A.

2. Regel: Falls Regel 1 nicht zutrifft, gewinnt die Default-Methode, die am meisten spezialisiert ist.

Auflösung Mehrfachvererbung: 3. Methode explizit auswählen

interface A {
    default String hello() { return "A"; }
}
interface B {
    default String hello() { return "B"; }
}
class D implements A, B {
    @Override public String hello() { return A.super.hello(); }
}


/** Mehrfachvererbung: 3. Methode explizit auswählen */
public class DefaultTest3 {
    public static void main(String... args) {
        String e = new D().hello();
    }
}

Die Klasse D erbt sowohl vom Interface A als auch vom Interface B die Methode hello() (Mehrfachvererbung). In diesem Fall muss zur Auflösung die Methode in D neu implementiert werden und die gewünschte geerbte Methode explizit aufgerufen werden. (Wenn dies unterlassen wird, führt das selbst bei Nicht-Nutzung der Methode hello() zu einem Compiler-Fehler!)

Achtung: Der Aufruf der Default-Methode aus Interface A erfolgt mit A.super.hello(); (nicht einfach durch A.hello();)!

3. Regel: Falls weder Regel 1 noch 2 zutreffen bzw. die Auflösung noch uneindeutig ist, muss man manuell durch die explizite Angabe der gewünschten Methode auflösen.

Quiz: Was kommt hier raus?

interface A {
    default String hello() { return "A"; }
}
interface B extends A {
    @Override default String hello() { return "B"; }
}
class C implements B {
    @Override public String hello() { return "C"; }
}
class D extends C implements A, B {}


/** Quiz Mehrfachvererbung */
public class DefaultTest {
    public static void main(String... args) {
        String e = new D().hello(); // ???
    }
}

Die Klasse D erbt sowohl von Klasse C als auch von den Interfaces A und B die Methode hello() (Mehrfachvererbung). In diesem Fall "gewinnt" die Implementierung aus Klasse C: Klassen gewinnen immer (Regel 1).

Statische Methoden in Interfaces

public interface Collection<E> extends Iterable<E> {
    boolean add(E e);
    ...
}
public class Collections {
    private Collections() { }
    public static <T> boolean addAll(Collection<? super T> c, T... elements) {...}
    ...
}

Typisches Pattern in Java: Interface plus Utility-Klasse (Companion-Klasse) mit statischen Hilfsmethoden zum einfacheren Umgang mit Instanzen des Interfaces (mit Objekten, deren Klasse das Interface implementiert). Beispiel: Collections ist eine Hilfs-Klasse zum Umgang mit Collection-Objekten.

Seit Java8 können in Interfaces neben Default-Methoden auch statische Methoden implementiert werden.

Die Hilfsmethoden können jetzt ins Interface wandern => Utility-Klassen werden obsolet ... Aus Kompatibilitätsgründen würde man die bisherige Companion-Klasse weiterhin anbieten, wobei die Implementierungen auf die statischen Methoden im Interface verweisen (SKIZZE, nicht real!):

public interface CollectionX<E> extends Iterable<E> {
    boolean add(E e);
    static <T> boolean addAll(CollectionX<? super T> c, T... elements) { ... }
    ...
}
public class CollectionsX {
    public static <T> boolean addAll(CollectionX<? super T> c, T... elements) {
        return CollectionX.addAll(c, elements);  // Verweis auf Interface
    }
    ...
}

Interfaces vs. Abstrakte Klassen

  • Abstrakte Klassen: Schnittstelle und Verhalten und Zustand

  • Interfaces:

    • vor Java 8 nur Schnittstelle
    • ab Java 8 Schnittstelle und Verhalten

    Unterschied zu abstrakten Klassen: Kein Zustand, d.h. keine Attribute

  • Design:

    • Interfaces sind beinahe wie abstrakte Klassen, nur ohne Zustand
    • Klassen können nur von einer (abstrakten) Klasse erben, aber viele Interfaces implementieren

Wrap-Up

Seit Java8: Interfaces mit Implementierung: Default-Methoden

  • Methoden mit dem Schlüsselwort default können Implementierung im Interface haben
  • Die Implementierung wird vererbt und kann bei Bedarf überschrieben werden
  • Auflösung von Mehrfachvererbung:
    • Regel 1: Klassen gewinnen
    • Regel 2: Sub-Interfaces gewinnen
    • Regel 3: Methode explizit auswählen
  • Unterschied zu abstrakten Klassen: Kein Zustand
Challenges

Erklären Sie die Code-Schnipsel in der Vorgabe und die jeweils entstehenden Ausgaben.

Quellen
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Kapitel 9: Default Methods

Subsections of Programmiermethoden und Clean Code

Javadoc

TL;DR

Mit Javadoc kann aus speziell markierten Block-Kommentaren eine externe Dokumentation im HTML-Format erzeugt werden. Die Block-Kommentare, auf die das im JDK enthaltene Programm javadoc reagiert, beginnen mit /** (also einem zusätzlichen Stern, der für den Java-Compiler nur das erste Kommentarzeichen ist).

Die erste Zeile eines Javadoc-Kommentars ist eine "Zusammenfassung" und an fast allen Stellen der generierten Doku sichtbar. Diese Summary sollte kurz gehalten werden und eine Idee vermitteln, was die Klasse oder die Methode oder das Attribut macht.

Für die Dokumentation von Parametern, Rückgabetypen, Exceptions und veralteten Elementen existieren spezielle Annotationen: @param, @return, @throws und @deprecated.

Als Faustregel gilt: Es werden alle public und protected Elemente (Klassen, Methoden, Attribute) mit Javadoc kommentiert. Alle nicht-öffentlichen Elemente bekommen normale Java-Kommentare (Zeilen- oder Blockkommentare).

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Ziel der Javadoc-Dokumentation verstehen
  • (K2) Typischen Aufgabe von Javadoc-Kommentaren verstehen
  • (K3) Dokumentation öffentlich sichtbarer Elemente mit Javadoc
  • (K3) Schreiben einer sinnvollen Summary
  • (K3) Einsatz von Annotationen zur Dokumentation von Parametern, Rückgabetypen, Exceptions, veralteten Elementen

Dokumentation mit Javadoc

/**
 * Beschreibung Beschreibung (Summary).
 *
 * <p>Hier kommt dann ein laengerer Text, der die Dinge
 * bei Bedarf etwas ausfuehrlicher erklaert.
 */
public void wuppie() {}

Javadoc-Kommentare sind (aus Java-Sicht) normale Block-Kommentare, wobei der Beginn mit /** eingeleitet wird. Dieser Beginn ist für das Tool javadoc (Bestandteil des JDK, genau wie java und javac) das Signal, dass hier ein Kommentar anfängt, den das Tool in eine HTML-Dokumentation übersetzen soll.

Typischerweise wird am Anfang jeder Kommentarzeile ein * eingefügt; dieser wird von Javadoc ignoriert.

Sie können neben normalem Text und speziellen Annotationen auch HTML-Elemente wie <p> und <code> oder <ul> nutzen.

Mit javadoc *.java können Sie in der Konsole aus den Java-Dateien die Dokumentation generieren lassen. Oder Sie geben das in Ihrer IDE in Auftrag ... (die dann diesen Aufruf gern für Sie tätigt).

Standard-Aufbau

/**
 * Beschreibung Beschreibung (Summary).
 *
 * <p> Hier kommt dann ein laengerer Text, der die Dinge
 * bei Bedarf etwas ausfuehrlicher erklaert.
 *
 * @param   date  Tag, Wert zw. 1 .. 31
 * @return  Anzahl der Sekunden seit 1.1.1970
 * @throws  NumberFormatException
 * @deprecated As of JDK version 1.1
 */
public int setDate(int date) {
    setField(Calendar.DATE, date);
}
  • Erste Zeile bei Methoden/Attributen geht in die generierte "Summary" in der Übersicht, der Rest in die "Details"
    • Die "Summary" sollte kein kompletter Satz sein, wird aber wie ein Satz geschrieben (Groß beginnen, mit Punkt beenden). Es sollte nicht beginnen mit "Diese Methode macht ..." oder "Diese Klasse ist ...". Ein gutes Beispiel wäre "Berechnet die Steuerrückerstattung."
    • Danach kommen die Details, die in der generierten Dokumentation erst durch Aufklappen der Elemente sichtbar sind. Erklären Sie, wieso der Code was machen soll und welche Designentscheidungen getroffen wurden (und warum).
  • Leerzeilen gliedern den Text in Absätze. Neue Absätze werden mit einem <p> eingeleitet. (Ausnahmen: Wenn der Text mit <ul> o.ä. beginnt oder der Absatz mit den Block-Tags.)
  • Die "Block-Tags" @param, @return, @throws, @deprecated werden durch einen Absatz von der restlichen Beschreibung getrennt und tauchen in exakt dieser Reihenfolge auf. Die Beschreibung dieser Tags ist nicht leer - anderenfalls lässt man das Tag weg. Falls die Zeile für die Beschreibung nicht reicht, wird umgebrochen und die Folgezeile mit vier Leerzeichen (beginnend mit dem @) eingerückt.
    • Mit @param erklären Sie die Bedeutung eines Parameters (von links nach rechts) einer Methode. Beispiel: @param date Tag, Wert zw. 1 .. 31. Wiederholen Sie dies für jeden Parameter.
    • Mit @return beschreiben Sie den Rückgabetyp/-wert. Beispiel: @return Anzahl der Sekunden seit 1.1.1970. Bei Rückgabe von void wird diese Beschreibung weggelassen (die Beschreibung wäre dann ja leer).
    • Mit @throws geben Sie an, welche "checked" Exceptions die Methode wirft.
    • Mit @deprecated können Sie im Kommentar sagen, dass ein Element veraltet ist und möglicherweise mit der nächsten Version o.ä. entfernt wird. (siehe nächste Folie)

=> Dies sind die Basis-Regeln aus dem populären Google-Java-Style [googlestyleguide].

Veraltete Elemente

/**
 * Beschreibung Beschreibung Beschreibung.
 *
 * @deprecated As of v102, replaced by <code>Foo.fluppie()</code>.
 */
@Deprecated
public void wuppie() {}
  • Annotation zum Markieren als "veraltet" (in der generierten Dokumentation): @deprecated
  • Für Sichtbarkeit zur Laufzeit bzw. im Tooling/IDE: normale Code-Annotation @Deprecated

Dies ist ein guter Weg, um Elemente einer öffentlichen API als "veraltet" zu kennzeichnen. Üblicherweise wird diese Kennzeichnung für einige wenige Releases beibehalten und danach das veraltete Element aus der API entfernt.

Autoren, Versionen, ...

/**
 * Beschreibung Beschreibung Beschreibung.
 *
 * @author  Dagobert Duck
 * @version V1
 * @since   schon immer
 */
  • Annotationen für Autoren und Version: @author, @version, @since

Diese Annotationen finden Sie vor allem in Kommentaren zu Packages oder Klassen.

Was muss kommentiert werden?

  • Alle public Klassen

  • Alle public und protected Elemente der Klassen

  • Ausnahme: @Override (An diesen Methoden kann, aber muss nicht kommentiert werden.)

Alle anderen Elemente bei Bedarf mit normalen Kommentaren versehen.

Beispiel aus dem JDK: ArrayList

Schauen Sie sich gern mal Klassen aus der Java-API an, beispielsweise eine java.util.ArrayList:

Best Practices: Was beschreibe ich eigentlich?

Unter Documentation Best Practices finden Sie eine sehr gute Beschreibung, was das Ziel der Dokumentation sein sollte. Versuchen Sie, dieses zu erreichen!

Wrap-Up

  • Javadoc-Kommentare sind normale Block-Kommentare beginnend mit /**

  • Generierung der HTML-Dokumentation mit javadoc *.java

  • Erste Zeile ist eine Zusammenfassung (fast immer sichtbar)

  • Längerer Text danach als "Description" einer Methode/Klasse

  • Annotationen für besondere Elemente: @param, @return, @throws, @deprecated

  • Faustregel: Alle public und protected Elemente mit Javadoc kommentieren!

Challenges

Betrachten Sie die Javadoc einiger Klassen im Dungeon-Projekt: dojo.rooms.LevelRoom, dojo.rooms.MonsterRoom, und contrib.components.HealthComponent.

Stellen Sie sich vor, Sie müssten diese Klassen in einer Übungsaufgabe nutzen (das könnte tatsächlich passieren!) ...

Können Sie anhand der Javadoc verstehen, wozu die drei Klassen dienen und wie Sie diese Klassen benutzen sollten? Vergleichen Sie die Qualität der Dokumentation. Was würden Sie gern in der Dokumentation finden? Was würden Sie ändern?

Quellen

Logging

TL;DR

Im Paket java.util.logging findet sich eine einfache Logging-API.

Über die Methode getLogger() der Klasse Logger (Factory-Method-Pattern) kann ein (neuer) Logger erzeugt werden, dabei wird über den String-Parameter eine Logger-Hierarchie aufgebaut analog zu den Java-Package-Strukturen. Der oberste Logger (der "Root-Logger") hat den leeren Namen.

Jeder Logger kann mit einem Log-Level (Klasse Level) eingestellt werden; Log-Meldungen unterhalb des eingestellten Levels werden verworfen.

Vom Logger nicht verworfene Log-Meldungen werden an den bzw. die Handler des Loggers und (per Default) an den Eltern-Logger weiter gereicht. Die Handler haben ebenfalls ein einstellbares Log-Level und verwerfen alle Nachrichten unterhalb der eingestellten Schwelle. Zur tatsächlichen Ausgabe gibt man einem Handler noch einen Formatter mit. Defaultmäßig hat nur der Root-Logger einen Handler.

Der Root-Logger (leerer String als Name) hat als Default-Level (wie auch sein Console-Handler) "Info" eingestellt.

Nachrichten, die durch Weiterleitung nach oben empfangen wurden, werden nicht am Log-Level des empfangenden Loggers gemessen, sondern akzeptiert und an die Handler des Loggers und (sofern nicht deaktiviert) an den Elternlogger weitergereicht.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Nutzung der Java Logging API im Paket java.util.logging
  • (K3) Erstellung eigener Handler und Formatter

Wie prüfen Sie die Werte von Variablen/Objekten?

  1. Debugging

    • Beeinflusst Code nicht
    • Kann schnell komplex und umständlich werden
    • Sitzung transient - nicht wiederholbar
  2. "Poor-man's-debugging" (Ausgaben mit System.out.println)

    • Müssen irgendwann entfernt werden
    • Ausgabe nur auf einem Kanal (Konsole)
    • Keine Filterung nach Problemgrad - keine Unterscheidung zwischen Warnungen, einfachen Informationen, ...
  3. Logging

    • Verschiedene (Java-) Frameworks: java.util.logging (JDK), log4j (Apache), SLF4J, Logback, ...

Java Logging API - Überblick

Paket java.util.logging

Eine Applikation kann verschiedene Logger instanziieren. Die Logger bauen per Namenskonvention hierarchisch aufeinander auf. Jeder Logger kann selbst mehrere Handler haben, die eine Log-Nachricht letztlich auf eine bestimmte Art und Weise an die Außenwelt weitergeben.

Log-Meldungen werden einem Level zugeordnet. Jeder Logger und Handler hat ein Mindest-Level eingestellt, d.h. Nachrichten mit einem kleineren Level werden verworfen.

Zusätzlich gibt es noch Filter, mit denen man Nachrichten (zusätzlich zum Log-Level) nach weiteren Kriterien filtern kann.

Erzeugen neuer Logger

import java.util.logging.Logger;
Logger l = Logger.getLogger(MyClass.class.getName());
  • Factory-Methode der Klasse java.util.logging.Logger

    public static Logger getLogger(String name);

    => Methode liefert bereits vorhandenen Logger mit diesem Namen (sonst neuen Logger)

  • Best Practice: Nutzung des voll-qualifizierten Klassennamen: MyClass.class.getName()

    • Leicht zu implementieren
    • Leicht zu erklären
    • Spiegelt modulares Design
    • Ausgaben enthalten automatisch Hinweis auf Herkunft (Lokalität) der Meldung
    • Alternativen: Funktionale Namen wie "XML", "DB", "Security"

Ausgabe von Logmeldungen

public void log(Level level, String msg);
  • Diverse Convenience-Methoden (Auswahl):

    public void warning(String msg)
    public void info(String msg)
    public void entering(String srcClass, String srcMethod)
    public void exiting(String srcClass, String srcMethod)
  • Beispiel

    import java.util.logging.Logger;
    Logger l = Logger.getLogger(MyClass.class.getName());
    l.info("Hello World :-)");

Wichtigkeit von Logmeldungen: Stufen

  • java.util.logger.Level definiert 7 Stufen:

    • SEVERE, WARNING, INFO, CONFIG, FINE, FINER, FINEST (von höchster zu niedrigster Prio)
    • Zusätzlich ALL und OFF
  • Nutzung der Log-Level:

    • Logger hat Log-Level: Meldungen mit kleinerem Level werden verworfen
    • Prüfung mit public boolean isLoggable(Level)
    • Setzen mit public void setLevel(Level)

=> Warum wird im Beispiel nach log.setLevel(Level.ALL); trotzdem nur ab INFO geloggt? Wer erzeugt eigentlich die Ausgaben?!

Jemand muss die Arbeit machen ...

  • Pro Logger mehrere Handler möglich

    • Logger übergibt nicht verworfene Nachrichten an Handler
    • Handler haben selbst ein Log-Level (analog zum Logger)
    • Handler verarbeiten die Nachrichten, wenn Level ausreichend
  • Standard-Handler: StreamHandler, ConsoleHandler, FileHandler

  • Handler nutzen zur Formatierung der Ausgabe einen Formatter

  • Standard-Formatter: SimpleFormatter und XMLFormatter

=> Warum wird im Beispiel nach dem Auskommentieren von log.setUseParentHandlers(false); immer noch eine zusätzliche Ausgabe angezeigt (ab INFO aufwärts)?!

Ich ... bin ... Dein ... Vater ...

  • Logger bilden Hierarchie über Namen

    • Trenner für Namenshierarchie: "." (analog zu Packages) => mit jedem "." wird eine weitere Ebene der Hierarchie aufgemacht ...
    • Jeder Logger kennt seinen Eltern-Logger: Logger#getParent()
    • Basis-Logger: leerer Name ("")
      • Voreingestelltes Level des Basis-Loggers: Level.INFO (!)
  • Weiterleiten von Nachrichten

    • Nicht verworfene Log-Aufrufe werden an Eltern-Logger weitergeleitet (Default)
      • Abschalten mit Logger#setUseParentHandlers(false);
    • Diese leiten an ihre Handler sowie an ihren Eltern-Logger weiter (unabhängig von Log-Level!)

Wrap-Up

  • Java Logging API im Paket java.util.logging

  • Neuer Logger über Factory-Methode der Klasse Logger

    • Einstellbares Log-Level (Klasse Level)
    • Handler kümmern sich um die Ausgabe, nutzen dazu Formatter
    • Mehrere Handler je Logger registrierbar
    • Log-Level auch für Handler einstellbar (!)
    • Logger (und Handler) "interessieren" sich nur für Meldungen ab bestimmter Wichtigkeit
    • Logger reichen nicht verworfene Meldungen defaultmäßig an Eltern-Logger weiter (rekursiv)
Challenges

Logger-Konfiguration

Betrachten Sie den folgenden Java-Code:

import java.util.logging.*;

public class Logging {
    public static void main(String... args) {
        Logger l = Logger.getLogger("Logging");
        l.setLevel(Level.FINE);

        ConsoleHandler myHandler = new ConsoleHandler();
        myHandler.setFormatter(new SimpleFormatter() {
            public String format(LogRecord record) {
                return "WUPPIE\n";
            }
        });
        l.addHandler(myHandler);

        l.info("A");
        l.fine("B");
        l.finer("C");
        l.finest("D");
        l.severe("E");
    }
}

Welche Ausgaben entstehen durch den obigen Code? Erklären Sie, welche der Logger-Aufrufe zu einer Ausgabe führen und wieso und wie diese Ausgaben zustande kommen bzw. es keine Ausgabe bei einem Logger-Aufruf gibt. Gehen Sie dabei auf jeden der fünf Aufrufe ein.

Analyse eines Live-Beispiels aus dem Dungeon

Analysieren Sie die Konfiguration des Loggers im Dungeon-Projekt: Dungeon-CampusMinden/Dungeon: core/utils/logging/LoggerConfig.java.

Quellen

Code Smells

TL;DR

Code entsteht nicht zum Selbstzweck, er muss von anderen Menschen leicht verstanden und gewartet werden können: Entwickler verbringen einen wesentlichen Teil ihrer Zeit mit dem Lesen von (fremdem) Code. Dabei helfen "Coding Conventions", die eine gewisse einheitliche äußerliche Erscheinung des Codes vorgeben (Namen, Einrückungen, ...). Die Beachtung von grundlegenden Programmierprinzipien hilft ebenso, die Lesbarkeit und Verständlichkeit zu verbessern.

Code, der diese Konventionen und Regeln verletzt, zeigt sogenannte "Code Smells" oder "Bad Smells". Das sind Probleme im Code, die noch nicht direkt zu einem Fehler führen, die aber im Laufe der Zeit die Chance für echte Probleme deutlich erhöht.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K3) Erkennen und Vermeiden von Code Smells
  • (K3) Unterscheiden von leicht lesbarem und schwer lesbarem Code
  • (K3) Programmierprinzipien anwenden, um den Code sauberer zu gestalten
  • (K3) Bessere Kommentare schreiben

Code Smells: Ist das Code oder kann das weg?

class checker {
    static public void CheckANDDO(DATA1 inp, int c, FH.Studi
    CustD, int x, int y, int in, int out,int c1, int c2, int c3 = 4)
{
    public int i; // neues i
for(i=0;i<10;i++) // fuer alle i
{
        inp.kurs[0] = 10; inp.kurs[i] = CustD.cred[i]/c;
}
      SetDataToPlan(  CustD  );
    public double myI = in*2.5; // myI=in*2.5
    if (c1)
        out = myI; //OK
    else if(  c3 == 4  )
    {
        myI = c2 * myI;
    if (c3 != 4 || true ) { // unbedingt beachten!
        //System.out.println("x:"+(x++));
        System.out.println("x:"+(x++)); // x++
        System.out.println("out: "+out);
    } }}   }

Der Code im obigen Beispiel lässt sich möglicherweise kompilieren. Und möglicherweise tut er sogar das, was er tun soll.

Dennoch: Der Code "stinkt" (zeigt Code Smells):

  • Nichtbeachtung üblicher Konventionen (Coding Rules)
  • Schlechte Kommentare
  • Auskommentierter Code
  • Fehlende Datenkapselung
  • Zweifelhafte Namen
  • Duplizierter Code
  • "Langer" Code: Lange Methoden, Klassen, Parameterlisten, tief verschachtelte if/then-Bedingungen, ...
  • Feature Neid
  • switch/case oder if/else statt Polymorphie
  • Globale Variablen, lokale Variablen als Attribut
  • Magic Numbers

Diese Liste enthält die häufigsten "Smells" und ließe sich noch beliebig fortsetzen. Schauen Sie mal in die unten angegebene Literatur :-)

Stinkender Code führt zu möglichen (späteren) Problemen.

Was ist guter ("sauberer") Code ("Clean Code")?

Im Grunde bezeichnet "sauberer Code" ("Clean Code") die Abwesenheit von Smells. D.h. man könnte Code als "sauberen" Code bezeichnen, wenn die folgenden Eigenschaften erfüllt sind (keine vollständige Aufzählung!):

  • Gut ("angenehm") lesbar
  • Schnell verständlich: Geeignete Abstraktionen
  • Konzentriert sich auf eine Aufgabe
  • So einfach und direkt wie möglich
  • Ist gut getestet

In [Martin2009] lässt der Autor Robert Martin verschiedene Ikonen der SW-Entwicklung zu diesem Thema zu Wort kommen - eine sehr lesenswerte Lektüre!

=> Jemand kümmert sich um den Code; solides Handwerk

Warum ist guter ("sauberer") Code so wichtig?

Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

 Quelle: [Fowler2011, p. 15]

Auch wenn das zunächst seltsam klingt, aber Code muss auch von Menschen gelesen und verstanden werden können. Klar, der Code muss inhaltlich korrekt sein und die jeweilige Aufgabe erfüllen, er muss kompilieren etc. ... aber er muss auch von anderen Personen weiter entwickelt werden und dazu gelesen und verstanden werden. Guter Code ist nicht einfach nur inhaltlich korrekt, sondern kann auch einfach verstanden werden.

Code, der nicht einfach lesbar ist oder nur schwer verständlich ist, wird oft in der Praxis später nicht gut gepflegt: Andere Entwickler haben (die berechtigte) Angst, etwas kaputt zu machen und arbeiten "um den Code herum". Nur leider wird das Konstrukt dann nur noch schwerer verständlich ...

Code Smells

Verstöße gegen die Prinzipien von Clean Code nennt man auch Code Smells: Der Code "stinkt" gewissermaßen. Dies bedeutet nicht unbedingt, dass der Code nicht funktioniert (d.h. er kann dennoch compilieren und die Anforderungen erfüllen). Er ist nur nicht sauber formuliert, schwer verständlich, enthält Doppelungen etc., was im Laufe der Zeit die Chance für tatsächliche Probleme deutlich erhöht.

Und weil es so wichtig ist, hier gleich noch einmal:

Stinkender Code führt zu möglichen (späteren) Problemen.

"Broken Windows" Phänomen

Wenn ein Gebäude leer steht, wird es eine gewisse Zeit lang nur relativ langsam verfallen: Die Fenster werden nicht mehr geputzt, es sammelt sich Graffiti, Gras wächst in der Dachrinne, Putz blättert ab ...

Irgendwann wird dann eine Scheibe eingeworfen. Wenn dieser Punkt überschritten ist, beschleunigt sich der Verfall rasant: Über Nacht werden alle erreichbaren Scheiben eingeworfen, Türen werden zerstört, es werden sogar Brände gelegt ...

Das passiert auch bei Software! Wenn man als Entwickler das Gefühl bekommt, die Software ist nicht gepflegt, wird man selbst auch nur relativ schlechte Arbeit abliefern. Sei es, weil man nicht versteht, was der Code macht und sich nicht an die Überarbeitung der richtigen Stellen traut und stattdessen die Änderungen als weiteren "Erker" einfach dran pappt. Seit es, weil man keine Lust hat, Zeit in ordentliche Arbeit zu investieren, weil der Code ja eh schon schlecht ist ... Das wird mit der Zeit nicht besser ...

Maßeinheit für Code-Qualität ;-)

Es gibt eine "praxisnahe" (und nicht ganz ernst gemeinte) Maßeinheit für Code-Qualität: Die "WTF/m" (What the Fuck per minute): Thom Holwerda: www.osnews.com/story/19266/WTFs_.

Wenn beim Code-Review durch Kollegen viele "WTF" kommen, ist der Code offenbar nicht in Ordnung ...

Code Smells: Nichtbeachtung von Coding Conventions

  • Richtlinien für einheitliches Aussehen => Andere Programmierer sollen Code schnell lesen können

    • Namen, Schreibweisen
    • Kommentare (Ort, Form, Inhalt)
    • Einrückungen und Spaces vs. Tabs
    • Zeilenlängen, Leerzeilen
    • Klammern
  • Beispiele: Sun Code Conventions, Google Java Style

  • Hinweis: Betrifft vor allem die (äußere) Form!

Code Smells: Schlechte Kommentare I

  • Ratlose Kommentare

    /* k.A. was das bedeutet, aber wenn man es raus nimmt, geht's nicht mehr */
    /* TODO: was passiert hier, und warum? */

    Der Programmierer hat selbst nicht verstanden (und macht sich auch nicht die Mühe zu verstehen), was er da tut! Fehler sind vorprogrammiert!

  • Redundante Kommentare: Erklären Sie, was der Code inhaltlich tun sollte (und warum)!

    public int i; // neues i
    for(i=0;i<10;i++)
    // fuer alle i

    Was würden Sie Ihrem Kollegen erklären (müssen), wenn Sie ihm/ihr den Code vorstellen?

    Wiederholen Sie nicht, was der Code tut (das kann ich ja selbst lesen), sondern beschreiben Sie, was der Code tun sollte und warum.

    Beschreiben Sie dabei auch das Konzept hinter einem Codebaustein.

Code Smells: Schlechte Kommentare II

  • Veraltete Kommentare

    Hinweis auf unsauberes Arbeiten: Oft wird im Zuge der Überarbeitung von Code-Stellen vergessen, auch den Kommentar anzupassen! Sollte beim Lesen extrem misstrauisch machen.

  • Auskommentierter Code

    Da ist jemand seiner Sache unsicher bzw. hat eine Überarbeitung nicht abgeschlossen. Die Chance, dass sich der restliche Code im Laufe der Zeit so verändert, dass der auskommentierte Code nicht mehr (richtig) läuft, ist groß! Auskommentierter Code ist gefährlich und dank Versionskontrolle absolut überflüssig!

  • Kommentare erscheinen zwingend nötig

    Häufig ein Hinweis auf ungeeignete Wahl der Namen (Klassen, Methoden, Attribute) und/oder auf ein ungeeignetes Abstraktionsniveau (beispielsweise Nichtbeachtung des Prinzips der "Single Responsibility")!

    Der Code soll im Normalfall für sich selbst sprechen: WAS wird gemacht. Der Kommentar erklärt im Normalfall, WARUM der Code das machen soll.

  • Unangemessene Information, z.B. Änderungshistorien

    Hinweise wie "wer hat wann was geändert" gehören in das Versionskontroll- oder ins Issue-Tracking-System. Die Änderung ist im Code sowieso nicht mehr sichtbar/nachvollziehbar!

Code Smells: Schlechte Namen und fehlende Kapselung

public class Studi extends Person {
    public String n;
    public int c;

    public void prtIf() { ... }
}

Nach drei Wochen fragen Sie sich, was n oder c oder Studi#prtIf() wohl sein könnte! (Ein anderer Programmierer fragt sich das schon beim ersten Lesen.) Klassen und Methoden sollten sich erwartungsgemäß verhalten.

Wenn Dinge öffentlich angeboten werden, muss man damit rechnen, dass andere darauf zugreifen. D.h. man kann nicht mehr so einfach Dinge wie die interne Repräsentation oder die Art der Berechnung austauschen! Öffentliche Dinge gehören zur Schnittstelle und damit Teil des "Vertrags" mit den Nutzern!

  • Programmierprinzip "Prinzip der minimalen Verwunderung"

    • Klassen und Methoden sollten sich erwartungsgemäß verhalten
    • Gute Namen ersparen das Lesen der Dokumentation
  • Programmierprinzip "Kapselung/Information Hiding"

    • Möglichst schlanke öffentliche Schnittstelle
    • => "Vertrag" mit Nutzern der Klasse!

Code Smells: Duplizierter Code

public class Studi {
    public String getName() { return name; }
    public String getAddress() {
        return strasse+", "+plz+" "+stadt;
    }

    public String getStudiAsString() {
        return name+" ("+strasse+", "+plz+" "+stadt+")";
    }
}
  • Programmierprinzip "DRY" => "Don't repeat yourself!"

Im Beispiel wird das Formatieren der Adresse mehrfach identisch implementiert, d.h. duplizierter Code. Auslagern in eigene Methode und aufrufen!

Kopierter/duplizierter Code ist problematisch:

  • Spätere Änderungen müssen an mehreren Stellen vorgenommen werden
  • Lesbarkeit/Orientierung im Code wird erschwert (Analogie: Reihenhaussiedlung)
  • Verpasste Gelegenheit für sinnvolle Abstraktion!

Code Smells: Langer Code

  • Lange Klassen

    • Faustregel: 5 Bildschirmseiten sind viel
  • Lange Methoden

    • Faustregel: 1 Bildschirmseite
    • [Martin2009]: deutlich weniger als 20 Zeilen
  • Lange Parameterlisten

    • Faustregel: max. 3 ... 5 Parameter
    • [Martin2009]: 0 Parameter ideal, ab 3 Parameter gute Begründung nötig
  • Tief verschachtelte if/then-Bedingungen

    • Faustregel: 2 ... 3 Einrückungsebenen sind viel
  • Programmierprinzip "Single Responsibility"

    Jede Klasse ist für genau einen Aspekt des Gesamtsystems verantwortlich

Lesbarkeit und Übersichtlichkeit leiden

  • Der Mensch kann sich nur begrenzt viele Dinge im Kurzzeitgedächtnis merken
  • Klassen, die länger als 5 Bildschirmseiten sind, erfordern viel Hin- und Her-Scrollen, dito für lange Methoden
  • Lange Methoden sind schwer verständlich (erledigen viele Dinge?)
  • Mehr als 3 Parameter kann sich kaum jemand merken, vor allem beim Aufruf von Methoden
  • Die Testbarkeit wird bei zu komplexen Methoden/Klassen und vielen Parametern sehr erschwert
  • Große Dateien verleiten (auch mangels Übersichtlichkeit) dazu, neuen Code ebenfalls schluderig zu gliedern

Langer Code deutet auch auf eine Verletzung des Prinzips der Single Responsibility hin

  • Klassen fassen evtl. nicht zusammengehörende Dinge zusammen

    public class Student {
        private String name;
        private String phoneAreaCode;
        private String phoneNumber;
    
        public void printStudentInfo() {
            System.out.println("name:    " + name);
            System.out.println("contact: " + phoneAreaCode + "/" + phoneNumber);
        }
    }

    Warum sollte sich die Klasse Student um die Einzelheiten des Aufbaus einer Telefonnummer kümmern? Das Prinzip der "Single Responsibility" wird hier verletzt!

  • Methoden erledigen vermutlich mehr als nur eine Aufgabe

    public void credits() {
        for (Student s : students) {
            if (s.hasSemesterFinished()) {
                ECTS c = calculateEcts(s);
                s.setEctsSum(c);
            }
        }
    }
    
    // Diese Methode erledigt 4 Dinge: Iteration, Abfrage, Berechnung, Setzen ...

    => Erklären Sie die Methode jemandem. Wenn dabei das Wort "und" vorkommt, macht die Methode höchstwahrscheinlich zu viel!

  • Viele Parameter bedeuten oft fehlende Datenabstraktion

    Circle makeCircle(int x, int y, int radius);
    Circle makeCircle(Point center, int radius);  // besser!

Code Smells: Feature Neid

public class CreditsCalculator {
    public ECTS calculateEcts(Student s) {
        int semester = s.getSemester();
        int workload = s.getCurrentWorkload();
        int nrModuls = s.getNumberOfModuls();
        int total = Math.min(30, workload);
        int extra = Math.max(0, total - 30);
        if (semester < 5) {
             extra = extra * nrModuls;
        }
        return new ECTS(total + extra);
    }
}
  • Zugriff auf (viele) Interna der anderen Klasse! => Hohe Kopplung der Klassen!
  • Methode CreditsCalculator#calculateEcts() "möchte" eigentlich in Student sein ...

Wrap-Up

  • Code entsteht nicht zum Selbstzweck => Lesbarkeit ist wichtig

  • Code Smells: Code führt zu möglichen (späteren) Problemen

    • Richtiges Kommentieren und Dokumentieren

      In dieser Sitzung haben wir vor allem auf Kommentare geschaut. Zum Thema Dokumentieren siehe die Einheit zu “Javadoc”.

    • Einhalten von Coding Conventions

      • Regeln zu Schreibweisen und Layout
      • Leerzeichen, Einrückung, Klammern
      • Zeilenlänge, Umbrüche
      • Kommentare
    • Einhalten von Prinzipien des objektorientierten Programmierens

      • Jede Klasse ist für genau einen Aspekt des Systems verantwortlich. (Single Responsibility)
      • Keine Code-Duplizierung! (DRY - Don't repeat yourself)
      • Klassen und Methoden sollten sich erwartungsgemäß verhalten
      • Kapselung: Möglichst wenig öffentlich zugänglich machen
Quellen
  • [Fowler2011] Refactoring
    Fowler, M., Addison-Wesley, 2011. ISBN 978-0-201-48567-7.
  • [Inden2013] Der Weg zum Java-Profi
    Inden, M., dpunkt.verlag, 2013. ISBN 978-3-8649-1245-0.
    Kapitel 10: Bad Smells
  • [Martin2009] Clean Code
    Martin, R., mitp, 2009. ISBN 978-3-8266-5548-7.
  • [Passig2013] Weniger schlecht programmieren
    Passig, K. und Jander, J., O'Reilly, 2013. ISBN 978-3-89721-567-2.

Coding Conventions und Metriken

TL;DR

Code entsteht nicht zum Selbstzweck, er muss von anderen Menschen leicht verstanden und gewartet werden können: Entwickler verbringen einen wesentlichen Teil ihrer Zeit mit dem Lesen von (fremdem) Code.

Dabei helfen "Coding Conventions", die eine gewisse einheitliche äußerliche Erscheinung des Codes vorgeben (Namen, Einrückungen, ...). Im Java-Umfeld ist der "Google Java Style" bzw. der recht ähnliche "AOSP Java Code Style for Contributors" häufig anzutreffen. Coding Conventions beinhalten typischerweise Regeln zu

  • Schreibweisen und Layout
  • Leerzeichen, Einrückung, Klammern
  • Zeilenlänge, Umbrüche
  • Kommentare

Die Beachtung von grundlegenden Programmierprinzipien hilft ebenso, die Lesbarkeit und Verständlichkeit zu verbessern.

Metriken sind Kennzahlen, die aus dem Code berechnet werden, und können zur Überwachung der Einhaltung von Coding Conventions und anderen Regeln genutzt werden. Nützliche Metriken sind dabei NCSS (Non Commenting Source Statements), McCabe (Cyclomatic Complexity), BEC (Boolean Expression Complexity) und DAC (Class Data Abstraction Coupling).

Für die Formatierung des Codes kann man die IDE nutzen, muss dort dann aber die Regeln detailliert manuell einstellen. Das Tool Spotless lässt sich dagegen in den Build-Prozess einbinden und kann die Konfiguration über ein vordefiniertes Regelset passend zum Google Java Style/AOSP automatisiert vornehmen.

Die Prüfung der Coding Conventions und Metriken kann durch das Tool Checkstyle erfolgen. Dieses kann beispielsweise als Plugin in der IDE oder direkt in den Build-Prozess eingebunden werden und wird mit Hilfe einer XML-Datei konfiguriert.

Um typische Anti-Pattern zu vermeiden, kann man den Code mit sogenannten Lintern prüfen. Ein Beispiel für die Java-Entwicklung ist SpotBugs, welches sich in den Build-Prozess einbinden lässt und über 400 typische problematische Muster im Code erkennt.

Für die Praktika in der Veranstaltung Programmiermethoden wird der Google Java Style oder AOSP genutzt. Für die passende Checkstyle-Konfiguration wird eine minimale checkstyle.xml bereitgestellt (vgl. Folie "Konfiguration für das PM-Praktikum").

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Erklären verschiedener Coding Conventions
  • (K2) Erklären wichtiger Grundregeln des objektorientierten Programmierens
  • (K2) Erklären der Metriken NCSS, McCabe, BEC, DAC
  • (K3) Einhalten der wichtigsten Grundregeln des objektorientierten Programmierens
  • (K3) Einhalten der wichtigsten Coding Conventions (Formatierung, Namen, Metriken)
  • (K3) Nutzung des Tools Spotless (Formatierung des Codes)
  • (K3) Nutzung des Tools Checkstyle (Coding Conventions und Metriken)
  • (K3) Nutzung des Tools SpotBugs (Vermeiden von Anti-Pattern)

Coding Conventions: Richtlinien für einheitliches Aussehen von Code

=> Ziel: Andere Programmierer sollen Code schnell lesen können

  • Namen, Schreibweisen: UpperCamelCase vs. lowerCamelCase vs. UPPER_SNAKE_CASE
  • Kommentare (Ort, Form, Inhalt): Javadoc an allen public und protected Elementen
  • Einrückungen und Spaces vs. Tabs: 4 Spaces
  • Zeilenlängen: 100 Zeichen
  • Leerzeilen: Leerzeilen für Gliederung
  • Klammern: Auf selber Zeile wie Code

Beispiele: Sun Code Conventions, Google Java Style, AOSP Java Code Style for Contributors

Beispiel nach Google Java Style/AOSP formatiert

package wuppie.deeplearning.strategy;

/**
 * Demonstriert den Einsatz von AOSP/Google Java Style ................. Umbruch nach 100 Zeichen |
 */
public class MyWuppieStudi implements Comparable<MyWuppieStudi> {
    private static String lastName;
    private static MyWuppieStudi studi;

    private MyWuppieStudi() {}

    /** Erzeugt ein neues Exemplar der MyWuppieStudi-Spezies (max. 40 Zeilen) */
    public static MyWuppieStudi getMyWuppieStudi(String name) {
        if (studi == null) {
            studi = new MyWuppieStudi();
        }
        if (lastName == null) lastName = name;

        return studi;
    }

    @Override
    public int compareTo(MyWuppieStudi o) {
        return lastName.compareTo(lastName);
    }
}

Dieses Beispiel wurde nach Google Java Style/AOSP formatiert.

Die Zeilenlänge beträgt max. 100 Zeichen. Pro Methode werden max. 40 Zeilen genutzt. Zwischen Attributen, Methoden und Importen wird jeweils eine Leerzeile eingesetzt (zwischen den einzelnen Attributen muss aber keine Leerzeile genutzt werden). Zur logischen Gliederung können innerhalb von Methoden weitere Leerzeilen eingesetzt werden, aber immer nur eine.

Klassennamen sind UpperCamelCase, Attribute und Methoden und Parameter lowerCamelCase, Konstanten (im Beispiel nicht vorhanden) UPPER_SNAKE_CASE. Klassen sind Substantive, Methoden Verben.

Alle public und protected Elemente werden mit einem Javadoc-Kommentar versehen. Überschriebene Methoden müssen nicht mit Javadoc kommentiert werden, müssen aber mit @Override markiert werden.

Geschweifte Klammern starten immer auf der selben Codezeile. Wenn bei einem if nur ein Statement vorhanden ist und dieses auf die selbe Zeile passt, kann auf die umschließenden geschweiften Klammern ausnahmsweise verzichtet werden.

Es wird mit Leerzeichen eingerückt. Google Java Style arbeitet mit 2 Leerzeichen, während AOSP hier 4 Leerzeichen vorschreibt. Im Beispiel wurde nach AOSP eingerückt.

Darüber hinaus gibt es vielfältige weitere Regeln für das Aussehen des Codes. Lesen Sie dazu entsprechend auf Google Java Style und auch auf AOSP nach.

Formatieren Sie Ihren Code (mit der IDE)

Sie können den Code manuell formatieren, oder aber (sinnvollerweise) über Tools formatieren lassen. Hier einige Möglichkeiten:

  • IDE: Code-Style einstellen und zum Formatieren nutzen

  • google-java-format: java -jar google-java-format.jar --replace *.java (auch als IDE-Plugin)

  • Spotless in Gradle:

    plugins {
        id "java"
        id "com.diffplug.spotless" version "6.5.0"
    }
    
    spotless {
        java {
            // googleJavaFormat()
            googleJavaFormat().aosp()  // indent w/ 4 spaces
        }
    }

    Prüfen mit ./gradlew spotlessCheck (Teil von ./gradlew check) und Formatieren mit ./gradlew spotlessApply

Einstellungen der IDE's

  • Eclipse:
    • Project > Properties > Java Code Style > Formatter: Coding-Style einstellen/einrichten
    • Code markieren, Source > Format
    • Komplettes Aufräumen: Source > Clean Up (Formatierung, Importe, Annotationen, ...) Kann auch so eingestellt werden, dass ein "Clean Up" immer beim Speichern ausgeführt wird!
  • IntelliJ verfügt über ähnliche Fähigkeiten:
    • Einstellen über Preferences > Editor > Code Style > Java
    • Formatieren mit Code > Reformat Code oder Code > Reformat File

Die Details kann/muss man einzeln einstellen. Für die "bekannten" Styles (Google Java Style) bringen die IDE's oft aber schon eine Gesamtkonfiguration mit.

Achtung: Zumindest in Eclipse gibt es mehrere Stellen, wo ein Code-Style eingestellt werden kann ("Clean Up", "Formatter", ...). Diese sollten dann jeweils auf den selben Style eingestellt werden, sonst gibt es unter Umständen lustige Effekte, da beim Speichern ein anderer Style angewendet wird als beim "Clean Up" oder beim "Format Source" ...

Analog sollte man bei der Verwendung von Checkstyle auch in der IDE im Formatter die entsprechenden Checkstyle-Regeln (s.u.) passend einstellen, sonst bekommt man durch Checkstyle Warnungen angezeigt, die man durch ein automatisches Formatieren nicht beheben kann.

Google Java Style und google-java-format

Wer direkt den Google Java Style nutzt, kann auch den dazu passenden Formatter von Google einsetzen: google-java-format. Diesen kann man entweder als Plugin für IntelliJ/Eclipse einsetzen oder als Stand-alone-Tool (Kommandozeile oder Build-Skripte) aufrufen. Wenn man sich noch einen entsprechenden Git-Hook definiert, wird vor jedem Commit der Code entsprechend den Richtlinien formatiert :)

Spotless und google-java-format in Gradle

Hinweis: Bei Spotless in Gradle müssen je nach den Versionen von Spotless/google-java-format bzw. des JDK noch Optionen in der Datei gradle.properties eingestellt werden (siehe Demo und Spotless > google-java-format (Web)).

Tipp: Die Formatierung über die IDE ist angenehm, aber in der Praxis leider oft etwas hakelig: Man muss alle Regeln selbst einstellen (und es gibt einige dieser Einstellungen), und gerade IntelliJ "greift" manchmal nicht alle Code-Stellen beim Formatieren. Nutzen Sie Spotless und bauen Sie die Konfiguration in Ihr Build-Skript ein und konfigurieren Sie über den Build-Prozess.

Metriken: Kennzahlen für verschiedene Aspekte zum Code

Metriken messen verschiedene Aspekte zum Code und liefern eine Zahl zurück. Mit Metriken kann man beispielsweise die Einhaltung der Coding Rules (Formate, ...) prüfen, aber auch die Einhaltung verschiedener Regeln des objektorientierten Programmierens.

Beispiele für wichtige Metriken (jeweils Max-Werte für PM)

Die folgenden Metriken und deren Maximal-Werte sind gute Erfahrungswerte aus der Praxis und helfen, den Code Smell "Langer Code" (vgl. “Code Smells”) zu erkennen und damit zu vermeiden. Über die Metriken BEC, McCabe und DAC wird auch die Einhaltung elementarer Programmierregeln gemessen.

  • NCSS (Non Commenting Source Statements)
    • Zeilen pro Methode: 40; pro Klasse: 250; pro Datei: 300 Annahme: Eine Anweisung je Zeile ...
  • Anzahl der Methoden pro Klasse: 10
  • Parameter pro Methode: 3
  • BEC (Boolean Expression Complexity) Anzahl boolescher Ausdrücke in if etc.: 3
  • McCabe (Cyclomatic Complexity)
    • Anzahl der möglichen Verzweigungen (Pfade) pro Methode + 1
    • 1-4 gut, 5-7 noch OK
  • DAC (Class Data Abstraction Coupling)
    • Anzahl der genutzten (instantiierten) "Fremdklassen"
    • Werte kleiner 7 werden i.A. als normal betrachtet

Die obigen Grenzwerte sind typische Standardwerte, die sich in der Praxis allgemein bewährt haben (vergleiche u.a. [Martin2009] oder auch in AOSP: Write short methods und AOSP: Limit line length).

Dennoch sind das keine absoluten Werte an sich. Ein Übertreten der Grenzen ist ein Hinweis darauf, dass höchstwahrscheinlich etwas nicht stimmt, muss aber im konkreten Fall hinterfragt und diskutiert und begründet werden!

Metriken im Beispiel von oben

    private static String lastName;
    private static MyWuppieStudi studi;

    public static MyWuppieStudi getMyWuppieStudi(String name) {
        if (studi == null) {
            studi = new MyWuppieStudi();
        }
        if (lastName == null) lastName = name;

        return studi;
    }
  • BEC: 1 (nur ein boolescher Ausdruck im if)
  • McCabe: 3 (es gibt zwei mögliche Verzweigungen in der Methode plus die Methode selbst)
  • DAC: 1 (eine "Fremdklasse": String)

Anmerkung: In Checkstyle werden für einige häufig verwendete Standard-Klassen Ausnahmen definiert, d.h. String würde im obigen Beispiel nicht bei DAC mitgezählt/angezeigt.

=> Verweis auf LV Softwareengineering

Tool-Support: Checkstyle

Metriken und die Einhaltung von Coding-Conventions werden sinnvollerweise nicht manuell, sondern durch diverse Tools erfasst, etwa im Java-Bereich mit Hilfe von Checkstyle.

Das Tool lässt sich Standalone über CLI nutzen oder als Plugin für IDE's (Eclipse oder IntelliJ) einsetzen. Gradle bringt ein eigenes Plugin mit.

  • IDE: diverse Plugins: Eclipse-CS, CheckStyle-IDEA

  • CLI: java -jar checkstyle-10.2-all.jar -c google_checks.xml *.java

  • Plugin "checkstyle" in Gradle:

    plugins {
        id "java"
        id "checkstyle"
    }
    
    checkstyle {
        configFile file('checkstyle.xml')
        toolVersion '10.2'
    }
    • Aufruf: Prüfen mit ./gradlew checkstyleMain (Teil von ./gradlew check)
    • Konfiguration: <projectDir>/config/checkstyle/checkstyle.xml (Default) bzw. mit der obigen Konfiguration direkt im Projektordner
    • Report: <projectDir>/build/reports/checkstyle/main.html

Checkstyle: Konfiguration

Die auszuführenden Checks lassen sich über eine XML-Datei konfigurieren. In Eclipse-CS kann man die Konfiguration auch in einer GUI bearbeiten.

Das Checkstyle-Projekt stellt eine passende Konfiguration für den Google Java Style bereit. Diese ist auch in den entsprechenden Plugins oft bereits enthalten und kann direkt ausgewählt oder als Startpunkt für eigene Konfigurationen genutzt werden.

Der Startpunkt für die Konfigurationsdatei ist immer das Modul "Checker". Darin können sich "FileSetChecks" (Module, die auf einer Menge von Dateien Checks ausführen), "Filters" (Module, die Events bei der Prüfung von Regeln filtern) und "AuditListeners" (Module, die akzeptierte Events in einen Report überführen) befinden. Der "TreeWalker" ist mit der wichtigste Vertreter der FileSetChecks-Module und transformiert die zu prüfenden Java-Sourcen in einen Abstract Syntax Tree, also eine Baumstruktur, die dem jeweiligen Code unter der Java-Grammatik entspricht. Darauf können dann wiederum die meisten Low-Level-Module arbeiten.

Eine Reihe von Standard-Checks sind bereits in Checkstyle implementiert und benötigen keine weitere externe Abhängigkeiten. Man kann aber zusätzliche Regeln aus anderen Projekten beziehen (etwa via Gradle/Maven) oder sich eigene zusätzliche Regeln in Java schreiben. Die einzelnen Checks werden in der Regel als "Modul" dem "TreeWalker" hinzugefügt und über die jeweiligen Properties näher konfiguriert.

Sie finden in der Doku zu jedem Check das entsprechende Modul, das Eltern-Modul (also wo müssen Sie das Modul im XML-Baum einfügen) und auch die möglichen Properties und deren Default-Einstellungen.

<module name="Checker">
    <module name="LineLength">
        <property name="max" value="100"/>
    </module>

    <module name="TreeWalker">
        <module name="AvoidStarImport"/>
        <module name="MethodCount">
            <property name="maxPublic" value="10"/>
            <property name="maxTotal" value="40"/>
        </module>
    </module>
</module>

Alternativen/Ergänzungen: beispielsweise MetricsReloaded.

SpotBugs: Finde Anti-Pattern und potentielle Bugs (Linter)

  • SpotBugs sucht nach über 400 potentiellen Bugs im Code

    • Anti-Pattern (schlechte Praxis, "dodgy" Code)
    • Sicherheitsprobleme
    • Korrektheit
  • CLI: java -jar spotbugs.jar options ...

  • IDE: IntelliJ SpotBugs plugin, SpotBugs Eclipse plugin

  • Gradle: SpotBugs Gradle Plugin

    plugins {
        id "java"
        id "com.github.spotbugs" version "5.0.6"
    }
    spotbugs {
        ignoreFailures = true
        showStackTraces = false
    }

    Prüfen mit ./gradlew spotbugsMain (in ./gradlew check)

Konfiguration für das PM-Praktikum (Format, Metriken, Checkstyle, SpotBugs)

Im PM-Praktikum beachten wir die obigen Coding Conventions und Metriken mit den dort definierten Grenzwerten. Diese sind bereits in der bereit gestellten Minimal-Konfiguration für Checkstyle (s.u.) konfiguriert.

Formatierung

  • Google Java Style/AOSP: Spotless

Zusätzlich wenden wir den Google Java Style an. Statt der dort vorgeschriebenen Einrückung mit 2 Leerzeichen (und 4+ Leerzeichen bei Zeilenumbruch in einem Statement) können Sie auch mit 4 Leerzeichen einrücken (8 Leerzeichen bei Zeilenumbruch) (AOSP). Halten Sie sich in Ihrem Team an eine einheitliche Einrückung (Google Java Style oder AOSP).

Formatieren Sie Ihren Code vor den Commits mit Spotless (über Gradle) oder stellen Sie den Formatter Ihrer IDE entsprechend ein.

Checkstyle

  • Minimal-Konfiguration für Checkstyle (Coding Conventions, Metriken)

Nutzen Sie die folgende Minimal-Konfiguration für Checkstyle für Ihre Praktikumsaufgaben. Diese beinhaltet die Prüfung der wichtigsten Formate nach Google Java Style/AOSP sowie der obigen Metriken. Halten Sie diese Regeln ein.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd">

<module name="Checker">
  <property name="severity" value="warning"/>

  <module name="TreeWalker">
    <module name="JavaNCSS">
      <property name="methodMaximum" value="40"/>
      <property name="classMaximum" value="250"/>
      <property name="fileMaximum" value="300"/>
    </module>
    <module name="BooleanExpressionComplexity"/>
    <module name="CyclomaticComplexity">
      <property name="max" value="7"/>
    </module>
    <module name="ClassDataAbstractionCoupling">
      <property name="max" value="6"/>
    </module>
    <module name="MethodCount">
      <property name="maxTotal" value="10"/>
      <property name="maxPrivate" value="10"/>
      <property name="maxPackage" value="10"/>
      <property name="maxProtected" value="10"/>
      <property name="maxPublic" value="10"/>
    </module>
    <module name="ParameterNumber">
      <property name="max" value="3"/>
    </module>
    <module name="MethodLength">
      <property name="max" value="40"/>
    </module>
    <module name="Indentation">
      <property name="basicOffset" value="4"/>
      <property name="lineWrappingIndentation" value="8"/>
      <property name="caseIndent" value="4"/>
      <property name="throwsIndent" value="4"/>
      <property name="arrayInitIndent" value="4"/>
    </module>
    <module name="TypeName"/>
    <module name="MethodName"/>
    <module name="MemberName"/>
    <module name="ParameterName"/>
    <module name="ConstantName"/>
    <module name="OneStatementPerLine"/>
    <module name="MultipleVariableDeclarations"/>
    <module name="MissingOverride"/>
    <module name="MissingJavadocMethod"/>
    <module name="AvoidStarImport"/>
  </module>

  <module name="LineLength">
    <property name="max" value="100"/>
  </module>
  <module name="FileTabCharacter">
    <property name="eachLine" value="true"/>
  </module>
  <module name="NewlineAtEndOfFile"/>
</module>

Sie können diese Basis-Einstellungen auch aus dem Programmiermethoden-CampusMinden/Prog2-Lecture-Repo direkt herunterladen: checkstyle.xml.

Sie können zusätzlich gern noch die weiteren (und strengeren) Regeln aus der vom Checkstyle-Projekt bereitgestellten Konfigurationsdatei für den Google Java Style nutzen. Hinweis: Einige der dort konfigurierten Checkstyle-Regeln gehen allerdings über den Google Java Style hinaus.

Linter: SpotBugs

  • Vermeiden von Anti-Pattern mit SpotBugs

Setzen Sie zusätzlich SpotBugs mit ein. Ihre Lösungen dürfen keine Warnungen oder Fehler beinhalten, die SpotBugs melden würde.

Wrap-Up

  • Code entsteht nicht zum Selbstzweck => Regeln nötig!

    • Coding Conventions

      • Regeln zu Schreibweisen und Layout
      • Leerzeichen, Einrückung, Klammern
      • Zeilenlänge, Umbrüche
      • Kommentare
    • Formatieren mit Spotless

    • Prinzipien des objektorientierten Programmierens (vgl. “Code Smells”)

      • Jede Klasse ist für genau einen Aspekt des Systems verantwortlich. (Single Responsibility)
      • Keine Code-Duplizierung! (DRY - Don't repeat yourself)
      • Klassen und Methoden sollten sich erwartungsgemäß verhalten
      • Kapselung: Möglichst wenig öffentlich zugänglich machen
  • Metriken: Einhaltung von Regeln in Zahlen ausdrücken

  • Prüfung manuell durch Code Reviews oder durch Tools wie Checkstyle oder SpotBugs

  • Definition des "PM-Styles" (siehe Folie "Konfiguration für das PM-Praktikum")

Quellen

Refactoring

TL;DR

Refactoring bedeutet Änderung der inneren Struktur des Codes ohne Beeinflussung äußeren Verhaltens.

Mit Hilfe von Refactoring kann man Code Smells beheben, und Lesbarkeit, Verständlichkeit und Wartbarkeit von Software verbessern.

Es ist wichtig, immer nur einzelne Schritte zu machen und anschließend die Testsuite laufen zu lassen, damit nicht versehentlich Fehler oder Verhaltensänderungen beim Refactoring eingebaut werden.

Prinzipiell kann man Refactoring manuell mit Search&Replace durchführen, aber es bietet sich an, hier die IDE-Unterstützung zu nutzen. Es stehen verschiedene Methoden zur Verfügung, die nicht unbedingt einheitlich benannt sein müssen oder in jeder IDE vorkommen. Zu den häufig genutzten Methoden zählen Rename, Extract, Move und Push Up/Pull Down.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Begriff, Notwendigkeit und Vorgehen des/beim Refactoring
  • (K2) Bedeutung kleiner Schritte beim Refactoring
  • (K2) Bedeutung einer sinnvollen Testsuite beim Refactoring
  • (K2) Refactoring: Nur innere Struktur ändern, nicht äußeres Verhalten!
  • (K3) Anwendung der wichtigsten Refactoring-Methoden: Rename, Extract, Move, Push Up/Pull Down

Was ist Refactoring?

Refactoring ist, wenn einem auffällt, daß der Funktionsname foobar ziemlich bescheuert ist, und man die Funktion in sinus umbenennt.

 Quelle: "356: Refactoring" by Andreas Bogk on Lutz Donnerhacke: "Fachbegriffe der Informatik"

Refactoring (noun): a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behaviour.

 Quelle: [Fowler2011, p. 53]

Refactoring: Änderungen an der inneren Struktur einer Software

  • Beobachtbares (äußeres) Verhalten ändert sich dabei nicht
    • Keine neuen Features einführen
    • Keine Bugs fixen
    • Keine öffentliche Schnittstelle ändern (Anmerkung: Bis auf Umbenennungen oder Verschiebungen von Elementen innerhalb der Software)
  • Ziel: Verbesserung von Verständlichkeit und Änderbarkeit

Anzeichen, dass Refactoring jetzt eine gute Idee wäre

  • Code "stinkt" (zeigt/enthält Code Smells)

    Code Smells sind strukturelle Probleme, die im Laufe der Zeit zu Problemen führen können. Refactoring ändert die innere Struktur des Codes und kann entsprechend genutzt werden, um die Smells zu beheben.

  • Schwer erklärbarer Code

    Könnten Sie Ihren Code ohne Vorbereitung in der Abgabe erklären? In einer Minute? In fünf Minuten? In zehn? Gar nicht?

    In den letzten beiden Fällen sollten Sie definitiv über eine Vereinfachung der Strukturen nachdenken.

  • Verständnisprobleme, Erweiterungen

    Sie grübeln in der Abgabe, was Ihr Code machen sollte?

    Sie überlegen, was Ihr Code bedeutet, um herauszufinden, wo Sie die neue Funktionalität anbauen können?

    Sie suchen nach Codeteilen, finden diese aber nicht, da die sich in anderen (falschen?) Stellen/Klassen befinden?

    Nutzen Sie die (neuen) Erkenntnisse, um den Code leichter verständlich zu gestalten.

"Three strikes and you refactor."

 Quelle: [Fowler2011, p. 58]: "The Rule of Three"

Wenn Sie sich zum dritten Mal über eine suboptimale Lösung ärgern, dann werden Sie sich vermutlich noch öfter darüber ärgern. Jetzt ist der Zeitpunkt für eine Verbesserung.

Schauen Sie sich die entsprechenden Kapitel in [Passig2013] und [Fowler2011] an, dort finden Sie noch viele weitere Anhaltspunkte, ob und wann Refactoring sinnvoll ist.

Bevor Sie loslegen ...

  1. Unit Tests schreiben

    • Normale und ungültige Eingaben
    • Rand- und Spezialfälle
  2. Coding Conventions einhalten

    • Sourcecode formatieren (lassen)
  3. Haben Sie die fragliche Codestelle auch wirklich verstanden?!

Vorgehen beim Refactoring

Überblick über die Methoden des Refactorings

Die Refactoring-Methoden sind nicht einheitlich definiert, es existiert ein großer und uneinheitlicher "Katalog" an möglichen Schritten. Teilweise benennt jede IDE die Schritte etwas anders, teilweise werden unterschiedliche Möglichkeiten angeboten.

Zu den am häufigsten genutzten Methoden zählen

  • Rename Method/Class/Field
  • Encapsulate Field
  • Extract Method/Class
  • Move Method
  • Pull Up, Push Down (Field, Method)

Best Practice

Eine Best Practice (oder nennen Sie es einfach eine wichtige Erfahrung) ist, beim Refactoring langsam und gründlich vorzugehen. Sie ändern die Struktur der Software und können dabei leicht Fehler oder echte Probleme einbauen. Gehen Sie also langsam und sorgsam vor, machen Sie einen Schritt nach dem anderen und sichern Sie sich durch eine gute Testsuite ab, die Sie nach jedem Schritt erneut ausführen: Das Verhalten der Software soll sich ja nicht ändern, d.h. die Tests müssen nach jedem einzelnen Refactoring-Schritt immer grün sein (oder Sie haben einen Fehler gemacht).

  • Kleine Schritte: immer nur eine Änderung zu einer Zeit

  • Nach jedem Refactoring-Schritt Testsuite laufen lassen

    => Nächster Refactoring-Schritt erst, wenn alle Tests wieder "grün"

  • Versionskontrolle nutzen: Jeden Schritt einzeln committen

Refactoring-Methode: Rename Method/Class/Field

Motivation

Name einer Methode/Klasse/Attributs erklärt nicht ihren Zweck.

Durchführung

Name selektieren, "Refactor > Rename"

Anschließend ggf. prüfen

Aufrufer? Superklassen?

Beispiel

Vorher

public String getTeN() {}

Nachher

public String getTelefonNummer() {}

Refactoring-Methode: Encapsulate Field

Motivation

Sichtbarkeit von Attributen reduzieren.

Durchführung

Attribut selektieren, "Refactor > Encapsulate Field"

Anschließend ggf. prüfen

Superklassen? Referenzen? (Neue) JUnit-Tests?

Beispiel

Vorher

int cps;

public void printDetails() {
    System.out.println("Credits: " + cps);
}

Nachher

private int cps;

int getCps() { return cps; }
void setCps(int cps) {  this.cps = cps;  }

public void printDetails() {
    System.out.println("credits: " + getCps());
}

Refactoring-Methode: Extract Method/Class

Motivation

  • Codefragment stellt eigenständige Methode dar
  • "Überschriften-Code"
  • Code-Duplizierung
  • Code ist zu "groß"
  • Klasse oder Methode erfüllt unterschiedliche Aufgaben

Durchführung

Codefragment selektieren, "Refactor > Extract Method" bzw. "Refactor > Extract Class"

Anschließend ggf. prüfen

  • Aufruf der neuen Methode? Nutzung der neuen Klasse?
  • Neue JUnit-Tests nötig? Veränderung bestehender Tests nötig?
  • Speziell bei Methoden:
    • Nutzung lokaler Variablen: Übergabe als Parameter!
    • Veränderung lokaler Variablen: Rückgabewert in neuer Methode und Zuweisung bei Aufruf; evtl. neue Typen nötig!

Beispiel

Vorher

public void printInfos() {
    printHeader();
    // Details ausgeben
    System.out.println("name:    " + name);
    System.out.println("credits: " + cps);
}

Nachher

public void printInfos() {
    printHeader();
    printDetails();
}
private void printDetails() {
    System.out.println("name:    " + name);
    System.out.println("credits: " + cps);
}

Refactoring-Methode: Move Method

Motivation

Methode nutzt (oder wird genutzt von) mehr Eigenschaften einer fremden Klasse als der eigenen Klasse.

Durchführung

Methode selektieren, "Refactor > Move" (ggf. "Keep original method as delegate to moved method" aktivieren)

Anschließend ggf. prüfen

  • Aufruf der neuen Methode (Delegation)?
  • Neue JUnit-Tests nötig? Veränderung bestehender Tests nötig?
  • Nutzung lokaler Variablen: Übergabe als Parameter!
  • Veränderung lokaler Variablen: Rückgabewert in neuer Methode und Zuweisung bei Aufruf; evtl. neue Typen nötig!

Beispiel

Vorher

public class Kurs {
    int cps;
    String descr;
}

public class Studi extends Person {
    String name;
    int cps;
    Kurs kurs;

    public void printKursInfos() {
        System.out.println("Kurs:    " + kurs.descr);
        System.out.println("Credits: " + kurs.cps);
    }
}

Nachher

public class Kurs {
    int cps;
    String descr;

    public void printKursInfos() {
        System.out.println("Kurs:    " + descr);
        System.out.println("Credits: " + cps);
    }
}

public class Studi extends Person {
    String name;
    int cps;
    Kurs kurs;

    public void printKursInfos() { kurs.printKursInfos(); }
}

Refactoring-Methode: Pull Up, Push Down (Field, Method)

Motivation

  • Attribut/Methode nur für die Oberklasse relevant: Pull Up
  • Subklassen haben identische Attribute/Methoden: Pull Up
  • Attribut/Methode nur für eine Subklasse relevant: Push Down

Durchführung

Name selektieren, "Refactor > Pull Up" oder "Refactor > Push Down"

Anschließend ggf. prüfen

Referenzen/Aufrufer? JUnit-Tests?

Beispiel

Vorher

public class Person { }

public class Studi extends Person {
    String name;
    public void printDetails() { System.out.println("name:    " + name); }
}

Nachher

public class Person { protected String name; }

public class Studi extends Person {
    public void printDetails() { System.out.println("name:    " + name); }
}

Wrap-Up

Behebung von Bad Smells durch Refactoring

=> Änderung der inneren Struktur ohne Beeinflussung des äußeren Verhaltens

  • Verbessert Lesbarkeit, Verständlichkeit, Wartbarkeit
  • Immer nur kleine Schritte machen
  • Nach jedem Schritt Testsuite laufen lassen
  • Katalog von Maßnahmen, beispielsweise Rename, Extract, Move, Push Up/Pull Down, ...
  • Unterstützung durch IDEs wie Eclipse, Idea, ...
Challenges

Betrachten Sie das Theatrical Players Refactoring Kata. Dort finden Sie im Unterordner java/ einige Klassen mit unübersichtlichem und schlecht strukturierten Code.

Welche Bad Smells können Sie hier identifizieren?

Beheben Sie die Smells durch die schrittweise Anwendung von den aus der Vorlesung bekannten Refactoring-Methoden. Denken Sie auch daran, dass Refactoring immer durch eine entsprechende Testsuite abgesichert sein muss - ergänzen Sie ggf. die Testfälle.

Quellen
  • [Fowler2011] Refactoring
    Fowler, M., Addison-Wesley, 2011. ISBN 978-0-201-48567-7.
  • [Inden2013] Der Weg zum Java-Profi
    Inden, M., dpunkt.verlag, 2013. ISBN 978-3-8649-1245-0.
    Kapitel 11: Refactorings
  • [Passig2013] Weniger schlecht programmieren
    Passig, K. und Jander, J., O'Reilly, 2013. ISBN 978-3-89721-567-2.

Subsections of Umgang mit Frameworks

Frameworks: How-To Dungeon

TL;DR

Der PM-Dungeon ist ein Framework zum Entwickeln von Rogue-like Dungeon-Crawlern, also einfachen 2D-Spielen in Java. Das Framework bietet die wichtigsten benötigten Grundstrukturen für ein Computer-Spiel: Es hat eine Game-Loop, kann Level generieren und darstellen und hat eine Entity-Component-System-Struktur (ECS), über die die Spielinhalte erstellt werden können. Im Hintergrund arbeitet die Open-Source-Bibliothek libGDX.

Sie können das Projekt direkt von GitHub clonen und über den im Projekt integrierten Gradle-Wrapper starten. Dazu brauchen Sie Java 21 LTS (in einer 64-bit Version). Sie können das Projekt als Gradle-Projekt in Ihre IDE laden.

Die Starter-Klassen (z.B. starter.Starter im "dungeon"-Subprojekt, starter.DojoStarter im "dojo-dungeon"-Subprojekt oder starter.DevDungeon im "devDungeon"-Subprojekt) sind die zentralen Einstiegspunkte. Hier finden Sie "unseren" Teil der Game-Loop (der in der eigentlichen Game-Loop von libGDX aufgerufen wird), hier finden Sie die Konfiguration und die main()-Methode.

Im ECS werden die im Spiel befindlichen Elemente als Entitäten modelliert. Diese Entitäten sind lediglich Container für Components, die dann ihrerseits die entsprechenden Eigenschaften der Entitäten modellieren. Entitäten haben normalerweise über die Components hinaus keine weiteren Eigenschaften (Attribute, Methoden). Das Game kennt alle zum aktuellen Zeitpunkt "lebenden" Entitäten.

Components gruppieren Eigenschaften, beispielsweise für Positionen oder Lebenspunkte. Components haben normalerweise keine Methoden (halten also nur Werte/Attribute). Jede Component-Instanz ist immer einer konkreten Entität zugeordnet und kann ohne diese nicht existieren.

Systeme implementieren das Verhalten im ECS. Das Game kennt alle aktiven Systeme und ruft in jedem Durchlauf der Game-Loop die execute()-Methode der Systeme auf. Üblicherweise holt sich dann ein System alle Entitäten vom Game und iteriert darüber und fragt ab, ob die betrachtete Entität die notwendigen Components hat - falls ja, dann kann das System auf dieser Entität die entsprechenden Operationen ausführen (Animation, Bewegung, ...); falls nein, wird diese Entität ignoriert und mit der Iteration fortgefahren.

Wir programmieren in dieser Einheit einen einfachen Helden. Der Held ist eine Entity und braucht verschiedene Components, um im Spiel angezeigt zu werden und bewegt werden zu können.

Lernziele
  • (K2) Überblick über die wichtigsten Strukturen im PM-Dungeon
  • (K2) Aufbau eines ECS: Entitäten, Komponenten, Systeme
  • (K3) Herunterladen und installieren des PM-Dungeon
  • (K3) Laden in der IDE
  • (K3) Erstellen eines Helden mit Animation und Bewegung

How-To Dungeon

In diesem Semester werden Sie im Praktikum schrittweise Erweiterungen in verschiedenen "fertigen" Rogue-like Computerspielen programmieren und dabei (hoffentlich) die Methoden aus der Vorlesung einsetzen können.

Das Projekt "PM-Dungeon" stellt wichtige Bausteine für das Spiel bereit, beispielsweise eine Game-Loop und eine API für das Generieren und Benutzen von Leveln und vieles andere mehr. Im Hintergrund werkelt das etablierte Open-Source-Spieleframework libGDX.

Wir werden uns in diesem How-To einen Überblick verschaffen und einen ersten Einstieg versuchen: Wir programmieren einen einfachen Helden.

Projekt PM-Dungeon

Das Projekt PM-Dungeon entstand in verschiedenen Forschungsprojekten und wurde (und wird) aktiv von Studierenden und wissenschaftlichen Mitarbeitern am Campus Minden entwickelt.

Zuletzt lief das Forschungsprojekt "Dungeon", gefördert durch die Stiftung für Innovation in der Hochschullehre im "Freiraum 2022". Dabei sollten diesmal nicht die Studierenden selbst Code schreiben, sondern die Lehrenden sollen Aufgaben in einer speziellen (von uns entwickelten) Programmiersprache schreiben (können), woraus dann ein fertiges Dungeon-Spiel generiert wird (mit der Aufgabe als Quest o.ä. im Dungeon eingebettet) und die Studierenden können durch das Spielen die Aufgaben lösen.

Sie werden merken, dass trotz klarer Richtlinien und Ideen die Entwicklung in der Praxis doch nicht so einfach ist und dass viele Dinge immer wieder geübt und erinnert werden müssen: Namen von Klassen und Methoden, sinnvolles Javadoc, Dokumentation jenseits des Javadoc, aber auch Commit-Messages und PR-Summaries.

Installation des Frameworks

Sie finden das Projekt auf GitHub: github.com/Dungeon-CampusMinden/Dungeon.

Laden Sie sich den Quellcode herunter, um damit in der IDE arbeiten zu können. Prinzipiell gibt es viele verschiedene Wege, in diesem Tutorial laden wir es per Git in der Konsole herunter:

git clone git@github.com:Dungeon-CampusMinden/Dungeon.git pm-dungeon

Dabei entsteht der Ordner pm-dungeon/ mit dem Dungeon-Projekt als Inhalt.

WICHTIG: Achten Sie bitte darauf, dass im Projektpfad keine Leerzeichen und keine Sonderzeichen (Umlaute o.ä.) vorkommen! Dies kann zu seltsamen Fehler führen. Bitte auch darauf achten, dass Sie als JDK ein Java SE 21 (LTS) verwenden.

Java: Java SE 21 (LTS)

Wir benutzen im Dungeon-Projekt die aktuelle LTS-Version des JDK, d.h. Java SE 21 (LTS). Sie können sich das JDK bei Oracle herunterladen oder Alternativen ausprobieren. Bitte unbedingt die jeweilige 64-bit Version nutzen!

In der Konsole sollte

java -version

ungefähr diese Ausgabe erzeugen (ignorieren Sie die Minor-Version, wichtig ist Major-Version: 21 bzw. "LTS"):

java version "21.0.3" 2024-04-16 LTS
Java(TM) SE Runtime Environment (build 21.0.3+7-LTS-152)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.3+7-LTS-152, mixed mode, sharing)

Erster Test

Für einen ersten Test gehen Sie in der Konsole in den vorhin erzeugten neuen Ordner pm-dungeon/ und führen Sie dort den Befehl

./gradlew game:runBasicStarter

aus. Dabei sollte das (mitgelieferte) Build-Tool Gradle starten und die benötigten Java-Bibliotheken herunterladen und schließlich das Spiel in einer Minimalversion starten - Sie sollten also ein Level sehen.

Dies dauert je nach Internetanbindung etwas - beim nächsten Start geht es dann aber deutlich schneller, weil ja bereits alles da ist.

Import in der IDE

Importieren Sie das Projekt als Gradle-basiertes Projekt, dann übernimmt die IDE die Konfiguration für Sie.

Über das Gradle-Menü können Sie nun in der IDE den "runBasicStarter"-Task (Menüpunkt "game") starten, und es erscheint wieder ein minimales Level.

Überblick über die (Sub-) Projekte

Sie finden im Package-Explorer eine Reihe von Unterprojekten (Gradle-Subprojekte). Für PR2 sind eigentlich nur die Subprojekte "dojo-dungeon" und "devDungeon" relevant sowie die Dokumentation in den verschiedenen doc/-Ordnern (die derzeit leider noch eine ziemliche Baustelle ist).

Dojo-Dungeon und DevDungeon stellen zwei verschiedene (mehr oder weniger fertige) Spiele dar, die von Studierenden erstellt wurden (Dojo-Dungeon: @Denniso3, @tgrothe und @JudiTeller; DevDungeon: @Flamtky). Diese Spiele nutzen wir an einigen Stellen im Praktikum.

Die Basis für die beiden Spiele stellt das Dungeon-Framework dar, welches in den Gradle-Subprojekten "game" und "dungeon" zu finden ist. Game stellt dabei eine Art minimale Basis zum Programmieren eigener Spiele dar (alle Klassen im Package core), und Dungeon erweitert diese Basis und fügt einige häufig benötigte Elemente und weitere Texturen (Package contrib) hinzu. Zusätzlich gibt es hier noch einige Klassen für die DSL, was für PR2 aber nicht relevant ist.

Das Subprojekt "blockly" ist die Einbindung einer blockbasierten Programmiersprache in das Dungeon-Framework und spielt für PR2 ebenfalls keine Rolle.

Die Strukturen in allen Sub-Projekten ist ähnlich: Sie finden unter <subproject>/src/ die Java-Packages und in <subproject>/assets/ vordefinierte Texturen und Soundfiles sowie Crafting-Rezepte (beispielsweise für Boden, Wände und den Hero). Alle Sourcen sind (mehr oder weniger) mit Javadoc dokumentiert, zusätzlich gibt es jeweils in <subproject>/doc/ weitere Anleitungen und Hinweise.

Für die Aufgaben im Praktikum starten Sie am besten zunächst beim relevanten Code in den Sub-Projekten Dojo-Dungeon und DevDungeon. Schauen Sie sich die für die Aufgabe benutzten Klassen und deren Javadoc an. In der Regel nutzen diese auch Klassen aus Dungeon und Game, deren Aufbau und Javadoc Sie sich ebenfalls anschauen sollten. Zusätzlich gibt es für Game und Dungeon noch weitere Dokumentation in den doc/-Ordnern.

Überblick über die Java-Strukturen

Jedes Spiel besteht aus einer Game-Loop, die je nach Konfiguration 30 Mal oder 60 Mal pro Sekunde ausgeführt wird. Diese Game-Loop wird mit Hilfe der Game#run()-Methode gestartet und die Kontrolle geht dabei vollständig an libGDX über. Im Wesentlichen werden pro Durchlauf ("Frame") die Aktionen berechnet und das Spielfeld neu gezeichnet. Alle Aktionen im Spiel, etwa das Bewegen von Spielelementen oder das Berechnen von Angriffen o.ä., werden über sogenannte Systeme berechnet. Diese werden einmal pro Frame aufgerufen und bestimmen den neuen Zustand (Position, Animation, Stats, ...) der Spielelemente, die dann beim nächsten Rendern im Spiel angezeigt werden.

Die Klasse core.Game ist der zentrale Einstiegspunkt. Hier werden alle wichtigen Dinge konfiguriert, und es gibt die Game#run()-Methode, die das Spiel startet. Zusätzlich gibt es weitere Methoden, die für Sie relevant sind:

  • Game#userOnSetup(): Diese Methode wird einmal beim Start des Spiels aufgerufen und kann für die Konfiguration und Initialisierung der verschiedenen Systeme genutzt werden. Hier wird beispielsweise u.a. auch das erste Level geladen.
  • Game#userOnFrame(): Diese Methode wird zu Beginn eines jeden Frame aufgerufen, noch bevor die execute()-Methode der verschiedenen Systeme aufgerufen wird.
  • Game#userOnLevelLoad(): Diese Methode wird aufgerufen, wenn ein Level geladen wird. Hier können Sie später die Entitäten erstellen, die initial im Level verteilt werden sollen.

Es gibt noch eine ganze Reihe von Packages, beispielsweise core.Component mit verschiedenen wichtigen Components oder core.level mit Klassen zum Generieren zufälliger neuer Level und zum Laden und zum Zugriff (wo bin ich und warum?) oder core.systems mit den Systemen, die bestimmte Dinge im Spiel managen. Die Gliederung in Entitäten (entities), Komponenten (components) und Systeme (systems) nennt sich auch "ECS-Architektur" (zu ECS später mehr).

Sie finden im "Quickstart: How to Dungeon" eine gute Anleitung, die auf die Strukturen tiefer eingeht.

Mein Held

Um einen besseren Blick in das System zu bekommen, erstellen wir schrittweise einen eigenen einfachen Helden.

Legen Sie sich im starter-Package eine neue Klasse an, mit der Sie das Spiel konfigurieren und starten können:

package starter;
import core.Game;

public class Main {
    public static void main(String... args) {
        // Start the game loop
        Game.run();
    }
}

In IntelliJ können Sie nun die main()-Funktion direkt ausführen, dazu wird im Hintergrund die vorhandene Gradle-Konfiguration genutzt. Mit anderen IDEs funktioniert das vielleicht nicht direkt, dann erweitern Sie einfach die Gradle-Konfiguration um einen entsprechenden Task:

tasks.register('run', JavaExec) {
    mainClass = 'starter.Main'
    classpath = sourceSets.main.runtimeClasspath
}

Einschub: ECS oder Entities, Components und Systems

Der Held ist ein Element im Spiel. Diese Struktur muss geeignet modelliert werden.

Unser Dungeon implementiert dabei eine Variante eines Entity Component System (ECS) und folgt damit "großen Vorbildern" wie beispielsweise Unity.

Neben verschiedenen Hilfsstrukturen gibt es dabei nur Entitäten, Komponenten und Systeme. Hier werden sämtliche Informationen und Verhalten modelliert.

Entity

Die Idee dahinter ist: Alle Elemente im Spiel werden als Entität realisiert, d.h. der Held und die Monster und die Items, die man so finden kann, sind alles Entitäten. Sogar Feuerbälle sind letztlich Entitäten. (Im Prinzip könnten sogar die Boden- und Wandkacheln Entitäten sein - sind es aus Effizienzgründen aktuell aber nicht.)

Eine Entität an sich kann erst einmal nichts und dient nur als Container für Components.

Das Spiel kennt alle zu einem Zeitpunkt vorhandenen Entitäten, diese müssen per Game#add registriert werden. Man kann die Entitäten über die API abrufen (Game#allEntities, Game#find und Game#hero).

Unsere Basisklasse für Entitäten ist aktuell core.Entity.

Component

Components bündeln bestimmte Werte einer Entität für bestimmte Zwecke, d.h. statt der Attribute in einer Klasse (Entität) nutzen wir hier eine weitere Kapselung.

Beispielsweise könnte man die Lebenspunkte u.ä. in einer HealthComponent verpacken und dann in einer Entität speichern. Oder man könnte in einer VelocityComponent hinterlegen, wie schnell eine Entität in x- und in y-Richtung bewegt werden kann (Wände würden dabei einfach den Wert 0 bekommen). Oder man könnte in einer PositionComponent speichern, wo die Entität gerade ist. Schauen Sie einfach mal in die Packages core.components und contrib.components.

Wichtig ist: Eine Instanz einer Component ist immer an eine Entität gekoppelt, eine Component ohne (Bindung an eine) Entität ist sinnfrei. Andersherum kann eine Entität immer nur eine einzige Instanz einer bestimmten Component (eines Component-Typs) haben, also beispielsweise nicht zwei Objekte vom Typ PositionComponent.

Components speichern vor allem Werte und haben nur in Ausnahmefällen eigenes Verhalten.

Das Basisinterface für Components ist derzeit core.Component.

System

Mit Entitäten und passenden Components, über die wir die Eigenschaften ausdrücken, können wir bereits Spielelemente im Dungeon repräsentieren.

Für die Bewegung und Interaktion sorgen nun passende Systeme. Das Spiel kennt alle Systeme (diese werden einmal beim Start im Spiel per Game#add registriert) und ruft in der Game-Loop pro Frame deren execute()-Methode auf. In der Regel iterieren die Systeme beim Ausführen der execute()-Methode über die Entitäten des Spiels (via Game#allEntities), suchen sich Entitäten mit bestimmten Components heraus und bearbeiten den Zustand dieser Components.

Dabei könnte beispielsweise ein HealthSystem sich alle Entitäten filtern, deren HealthComponent unterhalb einer kritischen Schwelle liegen und diese rot anmalen lassen, d.h. in der DrawComponent wird die Textur ("Animation") ausgetauscht. Oder ein PlayerSystem könnte dafür sorgen, dass die Eingaben auf der Tastatur geeignet an den Helden weitergegeben werden und (über andere Systeme) in eine Bewegung oder Kampf o.ä. umgewandelt werden.

Sie finden unsere Systeme in den Packages core.systems und contrib.systems, und die Basisklasse ist derzeit core.System - falls Sie einmal eigene Systeme implementieren wollen. (vgl. auch Doku)

Nun aber Helden!

Ein Held ist eine Entität

Also legen wir nun endlich einen neuen Helden als Instanz von core.Entity an und registrieren diese Entität im Spiel:

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");
                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

Der in der Methode Game#userOnSetup übergebene Lamda-Ausdruck wird (später) einmalig beim Start der Game-Loop von libGDX aufgerufen. Auf diese Weise können wir unseren Helden ins Spiel bekommen. (Alle anderen Entitäten sollten Sie besser über die Methode Game#onLevelLoad anlegen, also beim Laden eines neuen Levels.)

Prinzipiell haben Sie damit alles, um das Spiel starten zu können. In der Praxis sehen Sie aber keinen Helden: Der hat nämlich weder eine Position noch eine Textur, kann also gar nicht angezeigt werden.

Wo bin ich grad?

Der Held braucht eine Position. Dazu gibt es core.components.PositionComponent. Fügen wir diese einfach dem Helden hinzu:

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");

                    hero.add(new PositionComponent());

                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

Wenn man keine Position mitgibt, wird einfach eine zufällige Position im Level genutzt. Alternativ kann man eine eigene Position mitgeben.

Im Dungeon existieren aktuell zwei Koordinatensysteme: core.level.utils.Coordinate (Integer-basiert) und core.utils.Point (Float-basiert). Die Level werden als Matrix von Tile (Boden, Wand, Loch, ...) gespeichert. Die Position dieser Tile wird als Coordinate gespeichert, was dem Index des Tiles in der Matrix entspricht. Entitäten können aktuell aber auch zwischen zwei Tiles oder schräg-links-oben auf einem Tile stehen, dafür gibt es die Positionen als Point. Entsprechend könnte man den neuen Helden bei (0,0) in das Level setzen: new PositionComponent(new Point(0, 0)) bzw. kurz new PositionComponent(0f, 0f) (wobei diese Position möglicherweise nicht spielbar ist, da hier eine Wand oder sogar nichts ist).

Wenn Sie jetzt das Spiel starten, sehen Sie - immer noch nichts (außer den Wänden). Hmmm.

Animateure

Um den Held zeichnen zu können, brauchen wir eine Animation - also eine DrawComponent.

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");

                    hero.add(new PositionComponent());

                    try {
                        hero.add(new DrawComponent(new SimpleIPath("character/knight")));
                    } catch (IOException e) {
                        System.err.println("Could not load textures for hero.");
                        throw new RuntimeException(e);
                    }

                    hero.add(new CameraComponent());
                    hero.add(new PlayerComponent());

                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

In den Asset-Ordnern der Sub-Projekte Game und Dungeon gibt es bereits vordefinierte Texturen. Im Beispiel wird (nur) im Sub-Projekt "game" gesucht (weil unsere Main-Klasse dort liegt), und zwar in <game>/assets/character/knight/. Dort finden sich Unterordner für verschiedene Zustände des Ritters, und darin jeweils einige Texturen (einfache kleine .png-Dateien), die als Animation in einem bestimmten Zustand nacheinander abgespielt werden. Über den angegebenen (Teil-) Pfad werden in DrawComponent automatisch die entsprechenden Animationen erzeugt und geladen. Die Asset-Ordner sind in der Gradle-Konfiguration definiert. (Wenn Sie Ihre Main-Klasse in Dungeon ansiedeln, stehen Ihnen automatisch die Texturen aus Dungeon plus aus Game zur Verfügung.)

Da es passieren kann, dass der übergebene Pfad nicht gefunden wird, muss hier mit Exception-Handling gearbeitet werden. Wir geben hier erstmal eine Fehlermeldung aus und propagieren eine neue RuntimeException, die letztlich dafür sorgt, dass das Spiel abgebrochen würde.

Zusätzlich brauchen wir für den Helden noch eine CameraComponent. Das core.systems.CameraSystem wird dafür sorgen, dass die Entität mit dieser Component immer im Fokus der Kamera ist. Da wir den Held später noch manuell steuern wollen, bekommt er auch gleich noch eine PlayerComponent.

Jetzt wackelt der Held auf der Stelle herum ...

Bewege mich

Für die Bewegung ist das VelocitySystem zuständig. Dieses fragt in allen Entitäten die VelocityComponent sowie die PositionComponent ab, berechnet die nächste neue Position und speichert diese in der PositionComponent, und setzt bei tatsächlicher Bewegung auch eine passende Bewegungsanimation in der DrawComponent.

Das PlayerSystem und die PlayerComponent sorgen im Zusammenspiel für eine Reaktion auf die Tastatureingaben.

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");

                    hero.add(new PositionComponent());

                    try {
                        hero.add(new DrawComponent(new SimpleIPath("character/knight")));
                    } catch (IOException e) {
                        System.err.println("Could not load textures for hero.");
                        throw new RuntimeException(e);
                    }

                    hero.add(new CameraComponent());

                    hero.add(new VelocityComponent(5f, 5f));

                    PlayerComponent pc = new PlayerComponent();
                    pc.registerCallback(
                            KeyboardConfig.MOVEMENT_UP.value(),
                            entity -> {
                                VelocityComponent vc = entity.fetch(VelocityComponent.class).get();
                                vc.currentYVelocity(vc.yVelocity());
                            });
                    hero.add(pc);

                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

Die VelocityComponent wird im Konstruktor mit einer (maximalen) Geschwindigkeit in x- und y-Richtung erzeugt. Nutzen Sie hier nicht zu große Werte - unter Umständen reicht dann ein einziger Tastendruck, um einmal über das Spielfeld geschleudert zu werden.

Über die Methoden VelocityComponent#xVelocity und VelocityComponent#yVelocity können Sie die Maximalgeschwindigkeit abfragen und auch setzen. Mit VelocityComponent#currentXVelocity bzw. VelocityComponent#currentYVelocity holen und setzen Sie dagegen die aktuelle Geschwindigkeit, die vom VelocitySystem zur Berechnung der nächsten Position genutzt wird (wobei die Maximalgeschwindigkeit als Obergrenze verwendet wird).

Im Beispiel wird in der PlayerComponent des Helden der Taste "W" ein Lambda-Ausdruck zugeordnet, der die VelocityComponent der Entität (also des Helden) holt, die maximale Geschwindigkeit in y-Richtung ausliest und diese als aktuelle Geschwindigkeit in y-Richtung setzt. Damit kann mit der Taste "W" der Held nach oben laufen.

Anmerkung: Das entity.fetch(VelocityComponent.class) liefert nicht direkt ein VelocityComponent-Objekt zurück, sondern ein Optional<VelocityComponent>. Darüber sprechen wir (später) noch in der Lektion “Optional”. Für jetzt soll es zunächst genügen, dass Sie das gewünschte "verpackte" Objekt mit der Methode get() aus dem Optional wieder herausbekommen.

Anmerkung: Das gezeigte Schema ist insofern typisch, als dass verschiedene Systeme aus der Maximalgeschwindigkeit und weiteren Parametern die aktuelle Geschwindigkeit berechnen und in der VelocityComponent einer Entität setzen. Das VelocitySystem nutzt dann die aktuelle Geschwindigkeit für die tatsächliche Bewegung. Sie sollten in der Praxis also die Methoden VelocityComponent#currentXVelocity bzw. VelocityComponent#currentYVelocity eher nicht selbst aufrufen, sondern dies den Systemen überlassen. Wenn Sie einen Geschwindigkeitsboost haben wollen, würde es bei der obigen Konfiguration ausreichen, VelocityComponent#xVelocity und/oder VelocityComponent#yVelocity zu setzen/zu erhöhen - den Rest übernehmen dann das PlayerSystem und vor allem das VelocitySystem ...

Nun sollten Sie Ihren Helden (nach oben) bewegen können. (Tipp: Probieren Sie "W".)

Hinweis: Üblicherweise bearbeiten die Systeme bei der Iteration über alle Entitäten nur diejenigen Entitäten, die alle benötigten Components aufweisen.

Walking mit System

Neue Monster

Wie kann ich ein Monster beim Laden des Levels erzeugen?

Beim Laden eines Levels wird der mit Game#userOnLevelLoad registrierte Lambda-Ausdruck ausgeführt. Hier kann man beispielsweise ein neues Monster erzeugen (lassen):

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup( ... );


        // Create a new monster in every new level
        Game.userOnLevelLoad(first -> {
            Entity fb = new Entity("HUGO");

            fb.add(new PositionComponent(Game.hero().get().fetch(PositionComponent.class).get().position()));

            try {
                fb.add(new DrawComponent(new SimpleIPath("character/knight")));
            } catch (IOException e) {
                System.err.println("Could not load textures for HUGO.");
                throw new RuntimeException(e);
            }

            VelocityComponent vc = new VelocityComponent(10f, 10f);
            vc.currentYVelocity(vc.yVelocity());
            fb.add(vc);

            Game.add(fb);
        });


        // Start the game loop
        Game.run();
    }
}

Im Lambda-Ausdruck erzeugen wir hier einfach eine neue Entität und fügen dieser wie vorhin beim Hero eine DrawComponent für die Anzeige sowie eine PositionComponent und eine VelocityComponent für die Position und Bewegung hinzu, und am Ende registrieren wir die Entität beim Spiel.

Wenn man das Spiel jetzt startet, wird an der Position des Helden eine neue Entität sichtbar (mit der selben Textur).

Aber warum bewegt die neue Figur sich nicht? Wir haben doch eine VelocityComponent hinzugefügt und eine aktuelle Geschwindigkeit gesetzt?!

Wenn man in VelocitySystem#execute (bzw. die dort aufgerufene Methode VelocitySystem#updatePosition) schaut, wird klar, dass die aktuelle Geschwindigkeit zwar neu berechnet und gesetzt wird, aber dass ein "Reibungsfaktor" (abhängig vom Feld, auf dem die Figur steht) eingerechnet wird und somit die aktuelle Geschwindigkeit schnell auf Null geht. Der Hintergrund ist einfach: Normalerweise soll eine Entität nicht einmal angeschubst werden und dann "ewig" laufen, insbesondere bei Reaktion auf Tastatureingaben. Deshalb werden die Entitäten kurz bewegt und bremsen dann wieder ab. Das Aufrechterhalten der Bewegung erfolgt normalerweise über Systeme ...

Systems für das selbstständige Laufen

Wir brauchen ein System, welches die aktuelle Geschwindigkeit einer Entität in jedem Frame wieder auf den alten Wert setzt. Dazu leiten wir von core.System ab. (Achtung: Es gibt auch eine Klasse System im JDK - hier müssen Sie genau hinschauen!)

import core.System;

public class WalkerSystem extends System {
    @Override
    public void execute() {
        entityStream().forEach(e -> {
                VelocityComponent vc = e.fetch(VelocityComponent.class).get();
                vc.currentXVelocity(vc.xVelocity());
                vc.currentYVelocity(vc.yVelocity());
        });
    }
}


public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup( ... );

        // Create a new monster in every new level
        Game.userOnLevelLoad( ... );


        // Register our new system
        Game.add(new WalkerSystem());


        // Start the game loop
        Game.run();
    }
}

Wir leiten also von core.System ab und implementieren die execute-Methode. Wir holen uns dabei von jeder Entität die VelocityComponent und setzen die aktuelle Geschwindigkeit neu auf die maximale Geschwindigkeit. Zusätzlich registrieren wir das neue System im Spiel, damit es in jedem Frame einmal aufgerufen wird.

Nun läuft das neue Monster los (bis es gegen eine Wand läuft).

Aber der Held bewegt sich nun ebenfalls dauerhaft :(

Components für das selbstständige Laufen

Das Problem ist, dass unser neues WalkerSystem alle Entitäten automatisch bewegt. (Ein weiteres Problem ist, dass das WalkerSystem davon ausgeht, dass es immer eine VelocityComponent gibt, was nicht unbedingt erfüllt ist!)

Wir brauchen also noch eine Component, mit der wir die zu bewegenden Entitäten markieren können.

import core.System;
import core.Component;

public class WalkerComponent implements Component {}


public class WalkerSystem extends System {
    public WalkerSystem() {
        super(WalkerComponent.class);
    }

    @Override
    public void execute() {
        entityStream().forEach(e -> {
            if (e.isPresent(WalkerComponent.class)) {
                VelocityComponent vc = e.fetch(VelocityComponent.class).get();
                vc.currentXVelocity(vc.xVelocity());
                vc.currentYVelocity(vc.yVelocity());
            }
        });
    }
}


public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup( ... );


        // Create a new monster in every new level
        Game.userOnLevelLoad(first -> {
            Entity fb = new Entity("HUGO");

            ...

            fb.add(new WalkerComponent());

            Game.add(fb);
        });


        // Register our new system
        Game.add(new WalkerSystem());

        // Start the game loop
        Game.run();
    }
}

Die neue Component (WalkerComponent) ist einfach eine leere Klasse, die von core.Component erbt. Wir brauchen keine Werte o.ä., die wir hier ablegen wollen - eine leere Klasse reicht für das Beispiel. Dem neuen Monster geben wir diese neue Component nun mit.

Das WalkerSystem wird auch etwas ergänzt: Im Konstruktor rufen wir den Super-Konstruktor auf und übergeben die WalkerComponent-Klasse - dies ist die Component, für die sich das System interessiert. Zusätzlich legen wir noch eine if-Abfrage um das Aktualisieren der aktuellen Geschwindigkeit: Der Block soll nur dann ausgeführt werden, wenn die im aktuellen Schleifendurchlauf gerade betrachtete Entität eine WalkerComponent hat.

Nun läuft nur das neue Monster automatisch, der Held bleibt stehen und reagiert erst auf Tastendrücke. Prima!

Auf diese Weise können Sie beispielsweise den Monstern einen Gesundheitszustand geben und diese bei zu schlechter Gesundheit "sterben" lassen (aus dem Spiel entfernen). Sie könnten aber auch komplexere Dinge wie die Kollision zwischen zwei Entitäten realisieren.

Tatsächlich gibt es im Sub-Projekt "dungeon" (Package contrib) bereits eine Vielzahl an Components und passenden Systems, die solche typischen Aufgaben bereits realisieren.

Kämpfe wie ein NPC

Wir haben beim Hero über das PlayerComponent eine Reaktion auf Tastatureingaben implementiert. Hier könnte man einer Taste auch den Start einer neuen Entität zuordnen, die sich dann automatisch bewegt. Man könnte also Feuerbälle schleudern ...

public class Main {
    public static void main(String... args) {


        // Add some one-time configuration
        Game.userOnSetup( () -> {
            Entity hero = new Entity("Hero");

            ...

            PlayerComponent pc = new PlayerComponent();
            pc.registerCallback(KeyboardConfig.FIRST_SKILL.value(), entity -> {
                Entity fb = new Entity("Fireball");

                fb.add(new PositionComponent(entity.fetch(PositionComponent.class).get().position()));

                try {
                    fb.add(new DrawComponent(new SimpleIPath("character/knight")));
                } catch (IOException e) {
                    System.err.println("Could not load textures for fireball.");
                    throw new RuntimeException(e);
                }

                fb.add(new VelocityComponent(2f, 2f));

                fb.add(new WalkerComponent());

                Game.add(fb);
            }, false);

            Game.add(hero);
        });


        // Create a new monster in every new level
        Game.userOnLevelLoad( ... );

        // Register our new system
        Game.add(new WalkerSystem());

        // Start the game loop
        Game.run();
    }
}

Wir registrieren einfach die Taste FIRST_SKILL (das ist ein "Q") in der PlayerComponent. Im hinterlegten Lamda-Ausdruck wird eine neue Entität erzeugt mit einer WalkerComponent, also ganz analog zu dem neuen Monster vorhin beim Laden eines neuen Levels. Zusätzlich wird hier noch ein dritter Parameter mit dem Wert false mitgegeben: Die PlayerComponent wird in jedem Frame ausgewertet - wenn die Taste "Q" also über mehrere Frames hinweg gedrückt ist (was sehr wahrscheinlich ist), würde in jedem dieser Frames je eine neue Entität erzeugt und losgeschickt. Über diesen dritten Parameter können wir steuern, dass genau das nicht passiert. Man muss also die Taste "Q" zunächst wieder loslassen und dann erneut drücken, um noch einen Feuerball zu erzeugen und auf den Weg zu schicken. Als Textur habe ich einfach die im Sub-Projekt "game" vorhandene Textur für die Heros genommen - im Sub-Projekt "dungeon" gibt es dagegen auch Feuerbälle u.ä., aber dann müsste die Klasse auch in dieses Sub-Projekt umgezogen werden.

Unser Feuerball kann leider nichts, außer sich automatisch zu bewegen. Man könnte nun noch ein CollisionSystem entwickeln, welches Entitäten immer paarweise auf ihre Positionen vergleicht und eine Kollision feststellt, wenn sich die Entitäten zu nah kommen und diese Information in einer CollisionComponent speichern (wer mit wem und wann). Dann könnte man noch ein HealthSystem bauen, welches eine HealthComponent aktualisiert. Zusätzlich könnte man ein FightSystem schreiben, welches bei einer Kollision der getroffenen Entität (zufälligen?) Schaden zufügt, also die Werte in ihrer HealthComponent reduziert. (Alternativ könnte das CollisionSystem bei Kollision einen in der CollisionComponent gespeicherten Lambda-Ausdruck ausführen.) ... Die einzelnen Klassen interagieren also nicht direkt miteinander, sondern immer über den Umweg der Systems und Components.

All diese (und viele weitere) Components und Systems gibt es bereits im Package contrib im Sub-Projekt "dungeon".

Wrap-Up

Damit endet der kurze Ausflug in den Dungeon.

In einem ECS haben wir Entities, Components und Systems.

  • Die Entitäten sind nur Hüllen und gruppieren verschiedene Components.
  • In diesen Components werden die Werte für die jeweiligen Zustände gehalten.
  • Die Systems werden in jedem Durchlauf der Game-Loop aufgerufen und führen dabei ihre execute()-Methode aus. Typischerweise iterieren die Systeme dabei über alle Entitäten und verändern die Components der Entitäten.

Denken Sie daran, dass alles in einer Game-Loop läuft, die 30x oder 60x pro Sekunde aufgerufen wird. Sie können in der Regel keine direkte Interaktion zwischen verschiedenen Objekten realisieren, sondern müssen immer den Weg über die Systems gehen.

Schauen Sie gern in die vorhandenen Klassen und Packages und in die Dokumentation hinein:

  • Klassen in game/src/ und dungeon/src
  • Dokumentation unter game/doc/ und dungeon/doc/

Die Javadoc-Kommentare sollten Ihnen erste Ideen zur Funktionsweise geben (auch wenn für das angestrebte Ideal noch einiges an Arbeit notwendig ist). Schauen Sie gern die Dokumentation unter game/doc/ und dungeon/doc/ an, die im Laufe des Semesters schrittweise weiter wachsen wird.

Anregungen für Spielideen können Sie beispielsweise in den folgenden Videos finden:

Viel Spass im PM-Dungeon!

Subsections of Testen mit JUnit und Mockito

Einführung Softwaretest

TL;DR

Fehler schleichen sich durch Zeitdruck und hohe Komplexität schnell in ein Softwareprodukt ein. Die Folgen können von "ärgerlich" über "teuer" bis hin zu (potentiell) "tödlich" reichen. Richtiges Testen ist also ein wichtiger Aspekt bei der Softwareentwicklung!

JUnit ist ein Java-Framework, mit dem Unit-Tests (aber auch andere Teststufen) implementiert werden können. In JUnit 4 und 5 zeichnet man eine Testmethode mit Hilfe der Annotation @Test an der entsprechenden Methode aus. Dadurch kann man Produktiv- und Test-Code prinzipiell mischen; Best Practice ist aber das Anlegen eines weiteren Ordners test/ und das Spiegeln der Package-Strukturen. Für die zu testende Klasse wird eine korrespondierende Testklasse mit dem Suffix "Test" (Konvention) angelegt und dort die Testmethoden implementiert. Der IDE muss der neue test/-Ordner noch als Ordner für Sourcen bzw. Tests bekannt gemacht werden. In den Testmethoden baut man den Test auf, führt schließlich den Testschritt durch (beispielsweise konkreter Aufruf der zu testenden Methode) und prüft anschließend mit einem assert*(), ob das erzielte Ergebnis dem erwarteten Ergebnis entspricht. Ist alles OK, ist der Test "grün", sonst "rot".

Da ein fehlschlagendes assert*() den Test abbricht, werden eventuell danach folgende Prüfungen nicht mehr durchgeführt und damit ggf. weitere Fehler maskiert. Deshalb ist es gute Praxis, in einer Testmethode nur einen Testfall zu implementieren und i.d.R. auch nur ein (oder wenige) Aufrufe von assert*() pro Testmethode zu haben.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Ursachen von Softwarefehlern
  • (K3) Aufbauen von Tests mit JUnit 4 und 5 unter Nutzung der Annotation @Test

Software-Fehler und ihre Folgen

(Einige) Ursachen für Fehler

  • Zeit- und Kostendruck
  • Mangelhafte Anforderungsanalyse
  • Hohe Komplexität
  • Mangelhafte Kommunikation
  • Keine/schlechte Teststrategie
  • Mangelhafte Beherrschung der Technologie
  • ...

Irgendjemand muss mit Deinen Bugs leben!

Leider gibt es im Allgemeinen keinen Weg zu zeigen, dass eine Software korrekt ist. Man kann (neben formalen Beweisansätzen) eine Software nur unter möglichst vielen Bedingungen ausprobieren, um zu schauen, wie sie sich verhält, und um die dabei zu Tage tretenden Bugs zu fixen.

Mal abgesehen von der verbesserten User-Experience führt weniger fehlerbehaftete Software auch dazu, dass man seltener mitten in der Nacht geweckt wird, weil irgendwo wieder ein Server gecrasht ist ... Weniger fehlerbehaftete Software ist auch leichter zu ändern und zu pflegen! In realen Projekten macht Maintenance den größten Teil an der Softwareentwicklung aus ... Während Ihre Praktikumsprojekte vermutlich nach der Abgabe nie wieder angeschaut werden, können echte Projekte viele Jahre bis Jahrzehnte leben! D.h. irgendwer muss sich dann mit Ihren Bugs herumärgern - vermutlich sogar Sie selbst ;)

Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live. Code for readability.

-- John F. Woods

Dieses Zitat taucht immer mal wieder auf, beispielsweise auf der OSCON 2014 ... Es scheint aber tatsächlich, dass John F. Woods die ursprüngliche Quelle war (vgl. Stackoverflow: 876089).

Da wir nur wenig Zeit haben und zudem vergesslich sind und obendrein die Komplexität eines Projekts mit der Anzahl der Code-Zeilen i.d.R. nicht-linear ansteigt, müssen wir das Testen automatisieren. Und hier kommt JUnit ins Spiel :)

Was wann testen? Wichtigste Teststufen

  • Modultest

    • Testen einer Klasse und ihrer Methoden
    • Test auf gewünschtes Verhalten (Parameter, Schleifen, ...)
  • Integrationstest

    • Test des korrekten Zusammenspiels mehrerer Komponenten
    • Konzentration auf Schnittstellentests
  • Systemtest

    • Test des kompletten Systems unter produktiven Bedingungen
    • Orientiert sich an den aufgestellten Use Cases
    • Funktionale und nichtfunktionale Anforderungen testen

=> Verweis auf Wahlfach "Softwarequalität"

JUnit: Test-Framework für Java

JUnit --- Open Source Java Test-Framework zur Erstellung und Durchführung wiederholbarer Tests

  • JUnit 3

    • Tests müssen in eigenen Testklassen stehen
    • Testklassen müssen von Klasse TestCase erben
    • Testmethoden müssen mit dem Präfix "test" beginnen
  • JUnit 4

    • Annotation @Test für Testmethoden
    • Kein Zwang zu spezialisierten Testklassen (insbesondere kein Zwang mehr zur Ableitung von TestCase)
    • Freie Namenswahl für Testmethoden (benötigen nicht mehr Präfix "test")

    Damit können prinzipiell auch direkt im Source-Code Methoden als JUnit-Testmethoden ausgezeichnet werden ... (das empfiehlt sich in der Regel aber nicht)

  • JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

    • Erweiterung um mächtigere Annotationen
    • Aufteilung in spezialisierte Teilprojekte

    Das Teilprojekt "JUnit Platform" ist die Grundlage für das JUnit-Framework. Es bietet u.a. einen Console-Launcher, um Testsuiten manuell in der Konsole zu starten oder über Builder wie Ant oder Gradle.

    Das Teilprojekt "JUnit Jupiter" ist das neue Programmiermodell zum Schreiben von Tests in JUnit 5. Es beinhaltet eine TestEngine zum Ausführen der in Jupiter geschriebenen Tests.

    Das Teilprojekt "JUnit Vintage" beinhaltet eine TestEngine zum Ausführen von Tests, die in JUnit 3 oder JUnit 4 geschrieben sind.

Anmerkung: Wie der Name schon sagt, ist das Framework für Modultests ("Unit-Tests") gedacht. Man kann damit aber auch auf anderen Teststufen arbeiten!

Anmerkung: Im Folgenden besprechen wir JUnit am Beispiel JUnit 4, da diese Version des Frameworks besonders stark verbreitet ist und JUnit 5 (trotz offiziellem Release) immer noch stellenweise unfertig wirkt. Auf Unterschiede zu JUnit 5 wird an geeigneter Stelle hingewiesen (abgesehen von Import-Statements). Mit JUnit 3 sollte nicht mehr aktiv gearbeitet werden, d.h. insbesondere keine neuen Tests mehr erstellt werden, da diese Version nicht mehr weiterentwickelt wird.

Anlegen und Organisation der Tests mit JUnit

  • Anlegen neuer Tests: Klasse auswählen, Kontextmenü New > JUnit Test Case

  • Best Practice:  Spiegeln der Paket-Hierarchie

    • Toplevel-Ordner test (statt src)
    • Package-Strukturen spiegeln
    • Testklassen mit Suffix "Test"

Vorteile dieses Vorgehens:

  • Die Testklassen sind aus Java-Sicht im selben Package wie die Source-Klassen, d.h. Zugriff auf Package-sichtbare Methoden etc. ist gewährleistet
  • Durch die Spiegelung der Packages in einem separaten Testordner erhält man eine gute getrennte Übersicht über jeweils die Tests und die Sourcen
  • Die Wiederverwendung des Klassennamens mit dem Anhang "Test" erlaubt die schnelle Erkennung, welche Tests hier vorliegen

In der Paketansicht liegen dann die Source- und die Testklassen immer direkt hintereinander (da sie im selben Paket sind und mit dem selben Namen anfangen) => besserer Überblick!

Anmerkung: Die (richtige) JUnit-Bibliothek muss im Classpath liegen!

Eclipse bringt für JUnit 4 und JUnit 5 die nötigen Jar-Dateien mit und fragt beim erstmaligen Anlegen einer neuen Testklasse, ob die für die ausgewählte Version passenden JUnit-Jars zum Build-Path hinzugefügt werden sollen.

IntelliJ bringt ebenfalls eine JUnit 4 Bibliothek mit, die zum Projekt als Abhängigkeit hinzugefügt werden muss. Für JUnit 5 bietet IntelliJ an, die Jar-Dateien herunterzuladen und in einem passenden Ordner abzulegen.

Alternativ lädt man die Bibliotheken entsprechend der Anleitung unter junit.org herunter und bindet sie in das Projekt ein.

JUnit 4+5: Definition von Tests

Annotation @Test vor Testmethode schreiben

import org.junit.Test;
import static org.junit.Assert.*;

public class FactoryBeispielTest4 {
    @Test
    public void testGetTicket() {
        fail("not implemented");
    }
}

Für JUnit 5 muss statt org.junit.Test entsprechend org.junit.jupiter.api.Test importiert werden.

Während in JUnit 4 die Testmethoden mit der Sichtbarkeit public versehen sein müssen und keine Parameter haben (dürfen), spielt die Sichtbarkeit in JUnit 5 keine Rolle (und die Testmethoden dürfen Parameter aufweisen => vgl. Abschnitt "Dependency Injection for Constructors and Methods" in der JUnit-Doku).

JUnit 4: Ergebnis prüfen

Klasse org.junit.Assert enthält diverse statische Methoden zum Prüfen:

// Argument muss true bzw. false sein
void assertTrue(boolean);
void assertFalse(boolean);

// Gleichheit im Sinne von equals()
void assertEquals(Object, Object);

// Test sofort fehlschlagen lassen
void fail();

...

Für JUnit 5 finden sich die Assert-Methoden im Package org.junit.jupiter.api.Assertions.

Anmerkung zum statischen Import

Bei normalem Import der Klasse Assert muss man jeweils den voll qualifizierten Namen einer statischen Methode nutzen: Assert.fail().

Alternative statischer Import: import static org.junit.Assert.fail; => Statische Member der importierten Klasse (oder Interface) werden über ihre unqualifizierten Namen zugreifbar. Achtung: Namenskollisionen möglich!

// nur bestimmtes Member importieren
import static packageName.className.staticMemberName;
// alle statischen Member importieren
import static packageName.className.*;
  • Beispiel normaler Import:

    import org.junit.Assert;
    Assert.fail("message");
  • Beispiel statischer Import:

    import static org.junit.Assert.fail;
    fail("message");

Mögliche Testausgänge bei JUnit

  1. Error: Fehler im Programm (Test)

    • Unbehandelte Exception
    • Abbruch (Timeout)
  2. Failure: Testausgang negativ

    • Assert fehlgeschlagen
    • Assert.fail() aufgerufen
  3. OK

Anmerkungen zu Assert

  • Pro Testmethode möglichst nur ein Assert verwenden!
  • Anderenfalls: Schlägt ein Assert fehl, wird der Rest nicht mehr überprüft ...

Wrap-Up

  • Testen ist genauso wichtig wie Coden

  • Richtiges Testen spart Geld, Zeit, ...

  • Tests auf verschiedenen Abstraktionsstufen

  • JUnit als Framework für (Unit-) Tests; hier JUnit 4 (mit Ausblick auf JUnit 5)

    • Testmethoden mit Annotation @Test
    • Testergebnis mit assert* prüfen
Challenges

Einfache JUnit-Tests

Betrachten Sie die folgende einfache (und nicht besonders sinnvolle) Klasse MyList<T>:

public class MyList<T> {
    protected final List<T> list = new ArrayList<>();

    public boolean add(T element) { return list.add(element); }
    public int size() { return list.size(); }
}

Schreiben Sie mit Hilfe von JUnit (4.x oder 5.x) einige Unit-Tests für die beiden Methoden MyList<T>#add und MyList<T>#size.

Quellen

Testen mit JUnit (JUnit-Basics)

TL;DR

In JUnit 4 und 5 werden Testmethoden mit Hilfe der Annotation @Test ausgezeichnet. Über die verschiedenen assert*()-Methoden kann das Testergebnis mit dem erwarteten Ergebnis verglichen werden und entsprechend ist der Test "grün" oder "rot". Mit den verschiedenen assume*()-Methoden kann dagegen geprüft werden, ob eventuelle Vorbedingungen für das Ausführen eines Testfalls erfüllt sind - anderenfalls wird der Testfall dann übersprungen.

Mit Hilfe von @Before und @After können Methoden gekennzeichnet werden, die jeweils vor jeder Testmethode und nach jeder Testmethode aufgerufen werden. Damit kann man seine Testumgebung auf- und auch wieder abbauen (JUnit 4).

Erwartete Exceptions lassen sich in JUnit 4 mit einem Parameter expected in der Annotation @Test automatisch prüfen: @Test(expected=package.Exception.class). In JUnit 4 besteht die Möglichkeit, Testklassen zu Testsuiten zusammenzufassen und gemeinsam laufen zu lassen.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Steuern von Tests (ignorieren, zeitliche Begrenzung)
  • (K3) Prüfung von Exceptions
  • (K3) Aufbau von Testsuiten mit JUnit

JUnit: Ergebnis prüfen

Klasse org.junit.Assert enthält diverse statische Methoden zum Prüfen:

// Argument muss true bzw. false sein
void assertTrue(boolean);
void assertFalse(boolean);

// Gleichheit im Sinne von equals()
void assertEquals(Object, Object);

// Test sofort fehlschlagen lassen
void fail();

...

To "assert" or to "assume"?

  • Mit assert* werden Testergebnisse geprüft

    • Test wird ausgeführt
    • Ergebnis: OK, Failure, Error
  • Mit assume* werden Annahmen über den Zustand geprüft

    • Test wird abgebrochen, wenn Annahme nicht erfüllt
    • Prüfen von Vorbedingungen: Ist der Test hier ausführbar/anwendbar?

Setup und Teardown: Testübergreifende Konfiguration

private Studi x;

@Before
public void setUp() { x = new Studi(); }

@Test
public void testToString() {
    // Studi x = new Studi();
    assertEquals(x.toString(), "Heinz (15cps)");
}
@Before
wird vor jeder Testmethode aufgerufen
@BeforeClass
wird einmalig vor allen Tests aufgerufen (static!)
@After
wird nach jeder Testmethode aufgerufen
@AfterClass
wird einmalig nach allen Tests aufgerufen (static!)

In JUnit 5 wurden die Namen dieser Annotationen leicht geändert:

JUnit 4 JUnit 5
@Before @BeforeEach
@After @AfterEach
@BeforeClass @BeforeAll
@AfterClass @AfterAll

Beispiel für den Einsatz von @Before

Annahme: alle/viele Testmethoden brauchen neues Objekt x vom Typ Studi

private Studi x;

@Before
public void setUp() {
    x = new Studi("Heinz", 15);
}

@Test
public void testToString() {
    // Studi x = new Studi("Heinz", 15);
    assertEquals(x.toString(), "Name: Heinz, credits: 15");
}

@Test
public void testGetName() {
    // Studi x = new Studi("Heinz", 15);
    assertEquals(x.getName(), "Heinz");
}

Ignorieren von Tests

  • Hinzufügen der Annotation @Ignore
  • Alternativ mit Kommentar: @Ignore("Erst im nächsten Release")
@Ignore("Warum ignoriert")
@Test
public void testBsp() {
    Bsp x = new Bsp();
    assertTrue(x.isTrue());
}

In JUnit 5 wird statt der Annotation @Ignore die Annotation @Disabled mit der selben Bedeutung verwendet. Auch hier lässt sich als Parameter ein String mit dem Grund für das Ignorieren des Tests hinterlegen.

Vermeidung von Endlosschleifen: Timeout

  • Testfälle werden nacheinander ausgeführt
  • Test mit Endlosschleife würde restliche Tests blockieren
  • Erweitern der @Test-Annotation mit Parameter "timeout": => @Test(timeout=2000) (Zeitangabe in Millisekunden)
@Test(timeout = 2000)
void testTestDauerlaeufer() {
    while (true) { ; }
}

In JUnit 5 hat die Annotation @Test keinen timeout-Parameter mehr. Als Alternative bietet sich der Einsatz von org.junit.jupiter.api.Assertions.assertTimeout an. Dabei benötigt man allerdings Lambda-Ausdrücke (Verweis auf spätere VL):

@Test
void testTestDauerlaeufer() {
    assertTimeout(ofMillis(2000), () -> {
        while (true) { ; }
    });
}

(Beispiel von oben mit Hilfe von JUnit 5 formuliert)

Test von Exceptions: Expected

Traditionelles Testen von Exceptions mit try und catch:

@Test
public void testExceptTradit() {
    try {
        int i = 0 / 0;
        fail("keine ArithmeticException ausgeloest");
    } catch (ArithmeticException aex) {
        assertNotNull(aex.getMessage());
    } catch (Exception e) {
        fail("falsche Exception geworfen");
    }
}

Der expected-Parameter für die @Test-Annotation in JUnit 4 macht dies deutlich einfacher: @Test(expected = MyException.class) => Test scheitert, wenn diese Exception nicht geworfen wird

@Test(expected = java.lang.ArithmeticException.class)
public void testExceptAnnot() {
    int i = 0 / 0;
}

In JUnit 5 hat die Annotation @Test keinen expected-Parameter mehr. Als Alternative bietet sich der Einsatz von org.junit.jupiter.api.Assertions.assertThrows an. Dabei benötigt man allerdings Lambda-Ausdrücke (Verweis auf spätere VL):

@Test
public void testExceptAnnot() {
    assertThrows(java.lang.ArithmeticException.class, () -> {
        int i = 0 / 0;
    });
}

(Beispiel von oben mit Hilfe von JUnit 5 formuliert)

Parametrisierte Tests

Manchmal möchte man den selben Testfall mehrfach mit anderen Werten (Parametern) durchführen.

class Sum {
    public static int sum(int i, int j) {
        return i + j;
    }
}

class SumTest {
    @Test
    public void testSum() {
        Sum s = new Sum();
        assertEquals(s.sum(1, 1), 2);
    }
    // und mit (2,2, 4), (2,2, 5), ...????
}

Prinzipiell könnte man dafür entweder in einem Testfall eine Schleife schreiben, die über die verschiedenen Parameter iteriert. In der Schleife würde dann jeweils der Aufruf der zu testenden Methode und das gewünschte Assert passieren. Alternativ könnte man den Testfall entsprechend oft duplizieren mit jeweils den gewünschten Werten.

Beide Vorgehensweisen haben Probleme: Im ersten Fall würde die Schleife bei einem Fehler oder unerwarteten Ergebnis abbrechen, ohne dass die restlichen Tests (Werte) noch durchgeführt würden. Im zweiten Fall bekommt man eine unnötig große Anzahl an Testmethoden, die bis auf die jeweiligen Werte identisch sind (Code-Duplizierung).

Parametrisierte Tests mit JUnit 4

JUnit 4 bietet für dieses Problem sogenannte "parametrisierte Tests" an. Dafür muss eine Testklasse in JUnit 4 folgende Bedingungen erfüllen:

  1. Die Testklasse wird mit der Annotation @RunWith(Parameterized.class) ausgezeichnet.
  2. Es muss eine öffentliche statische Methode geben mit der Annotation @Parameters. Diese Methode liefert eine Collection zurück, wobei jedes Element dieser Collection ein Array mit den Parametern für einen Durchlauf der Testmethoden ist.
  3. Die Parameter müssen gesetzt werden. Dafür gibt es zwei Varianten:
    • (A) Für jeden Parameter gibt es ein öffentliches Attribut. Diese Attribute müssen mit der Annotation @Parameter markiert sein und können in den Testmethoden normal genutzt werden. JUnit sorgt dafür, dass für jeden Eintrag in der Collection aus der statischen @Parameters-Methode diese Felder gesetzt werden und die Testmethoden aufgerufen werden.
    • (B) Alternativ gibt es einen Konstruktor, der diese Werte setzt. Die Anzahl der Parameter im Konstruktor muss dabei exakt der Anzahl (und Reihenfolge) der Werte in jedem Array in der von der statischen @Parameters-Methode gelieferten Collection entsprechen. Der Konstruktor wird für jeden Parametersatz einmal aufgerufen und die Testmethoden einmal durchgeführt.

Letztlich wird damit das Kreuzprodukt aus Testmethoden und Testdaten durchgeführt.

(A) Parametrisierte Tests: Konstruktor (JUnit 4)

@RunWith(Parameterized.class)
public class SumTestConstructor {
    private final int s1;
    private final int s2;
    private final int erg;

    public SumTestConstructor(int p1, int p2, int p3) { s1 = p1;  s2 = p2;  erg = p3; }

    @Parameters
    public static Collection<Object[]> values() {
        return Arrays.asList(new Object[][] { { 1, 1, 2 }, { 2, 2, 4 }, { 2, 2, 5 } });
    }

    @Test
    public void testSum() {
        assertEquals(Sum.sum(s1, s2), erg);
    }
}

(B) Parametrisierte Tests: Parameter (JUnit 4)

@RunWith(Parameterized.class)
public class SumTestParameters {

    @Parameter(0)  public int s1;
    @Parameter(1)  public int s2;
    @Parameter(2)  public int erg;

    @Parameters
    public static Collection<Object[]> values() {
        return Arrays.asList(new Object[][] { { 1, 1, 2 }, { 2, 2, 4 }, { 2, 2, 5 } });
    }

    @Test
    public void testSum() {
        assertEquals(Sum.sum(s1, s2), erg);
    }
}

Parametrisierte Tests mit JUnit 5

In JUnit 5 werden parametrisierte Tests mit der Annotation @ParameterizedTest gekennzeichnet (statt mit @Test).

Mit Hilfe von @ValueSource kann man ein einfaches Array von Werten (Strings oder primitive Datentypen) angeben, mit denen der Test ausgeführt wird. Dazu bekommt die Testmethode einen entsprechenden passenden Parameter:

@ParameterizedTest
@ValueSource(strings = {"wuppie", "fluppie", "foo"})
void testWuppie(String candidate) {
    assertTrue(candidate.equals("wuppie"));
}

Alternativ lassen sich als Parameterquelle u.a. Aufzählungen (@EnumSource) oder Methoden (@MethodSource) oder auch Komma-separierte Daten (@CsvSource) angeben.

Das obige Beispiel aus JUnit 4.x könnte mit Hilfe von @CsvSource so in JUnit 5.x umgesetzt werden:

public class SumTest {
    @ParameterizedTest
    @CsvSource(textBlock = """
            # s1,  s2,  s1+s2
            0,     0,   0
            10,    0,   10
            0,     11,  11
            -2,    10,  8
            """)
    public void testSum(int s1, int s2, int erg) {
        assertEquals(Sum.sum(s1, s2), erg);
    }
}

Testsuiten: Tests gemeinsam ausführen (JUnit 4)

Eclipse: New > Other > Java > JUnit > JUnit Test Suite

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
@SuiteClasses({
    // Hier kommen alle Testklassen rein
    PersonTest.class,
    StudiTest.class
})

public class MyTestSuite {
    // bleibt leer!!!
}

Testsuiten mit JUnit 5

In JUnit 5 gibt es zwei Möglichkeiten, Testsuiten zu erstellen:

  • @SelectPackages: Angabe der Packages, die für die Testsuite zusammengefasst werden sollen
  • @SelectClasses: Angabe der Klassen, die für die Testsuite zusammengefasst werden sollen
@RunWith(JUnitPlatform.class)
@SelectClasses({StudiTest5.class, WuppieTest5.class})
public class MyTestSuite5 {
    // bleibt leer!!!
}

Zusätzlich kann man beispielsweise mit @IncludeTags oder @ExcludeTags Testmethoden mit bestimmten Tags einbinden oder ausschließen. Beispiel: Schließe alle Tests mit Tag "develop" aus: @ExcludeTags("develop"). Dabei wird an den Testmethoden zusätzlich das Tag @Tag verwendet, etwas @Tag("develop").

Achtung: Laut der offiziellen Dokumentation (Abschnitt "4.4.4. Test Suite") gilt zumindest bei der Selection über @SelectPackages der Zwang zu einer Namenskonvention: Es werden dabei nur Klassen gefunden, deren Name mit Test beginnt oder endet! Weiterhin werden Testsuites mit der Annotation @RunWith(JUnitPlatform.class) nicht auf der "JUnit 5"-Plattform ausgeführt, sondern mit der JUnit 4-Infrastuktur!

Best Practices

  1. Ein Testfall behandelt exakt eine Idee/ein Szenario. Das bedeutet auch, dass man in der Regel nur ein bis wenige assert* pro Testmethode benutzt.

    (Wenn man verschiedene Ideen in eine Testmethode kombiniert, wird der Testfall unübersichtlicher und auch auch schwerer zu warten.

    Außerdem können so leichter versteckte Fehler auftreten: Das erste oder zweite oder dritte assert* schlägt fehl - und alle dahinter kommenden assert* werden nicht mehr ausgewertet!)

  2. Wenn die selbe Testidee mehrfach wiederholt wird, sollte man diese Tests zu einem parametrisierten Test zusammenfassen.

    (Das erhöht die Lesbarkeit drastisch - und man läuft auch nicht in das Problem der Benennung der Testmethoden.)

  3. Es wird nur das Verhalten der öffentlichen Schnittstelle getestet, nicht die inneren Strukturen einer Klasse oder Methode.

    (Es ist verlockend, auch private Methoden zu testen und in den Tests auch die Datenstrukturen o.ä. im Blick zu behalten und zu testen. Das führt aber zu sehr "zerbrechlichen" (brittle) Tests: Sobald sich etwas an der inneren Struktur ändert, ohne dass sich das von außen beobachtbare Verhalten ändert und also die Klasse/Methode immer noch ordnungsgemäß funktioniert, gehen all diese "internen" Tests kaputt. Nicht ohne Grund wird in der objektorientierten Programmierung mit Kapselung (Klassen, Methoden, ...) gearbeitet.)

  4. Von Setup- und Teardown-Methoden sollte eher sparsam Gebrauch gemacht werden.

    (Normalerweise folgen wir in der objektorientierten Programmierung dem DRY-Prinzip (Don't repeat yourself). Entsprechend liegt es nahe, häufig benötigte Elemente in einer Setup-Methode zentral zu initialisieren und ggf. in einer Teardown-Methode wieder freizugeben.

    Das führt aber speziell bei Unit-Tests dazu, dass die einzelnen Testmethoden schwerer lesbar werden: Sie hängen von einer gemeinsamen, zentralen Konfiguration ab, die man üblicherweise nicht gleichzeitig mit dem Code der Testmethode sehen kann (begrenzter Platz auf der Bildschirmseite).

    Wenn nun in einem oder vielleicht mehreren Testfällen der Wunsch nach einer leicht anderen Konfiguration auftaucht, muss man die gemeinsame Konfiguration entsprechend anpassen bzw. erweitern. Dabei muss man dann aber alle anderen Testmethoden mit bedenken, die ja ebenfalls von dieser Konfiguration abhängen! Das führt in der Praxis dann häufig dazu, dass die gemeinsame Konfiguration sehr schnell sehr groß und verschachtelt und entsprechend unübersichtlich wird.

    Jede Änderung an dieser Konfiguration kann leicht einen oder mehrere Testfälle kaputt machen (man hat ja i.d.R. nie alle Testfälle gleichzeitig im Blick), weshalb man hier unbedingt mit passenden assume* arbeiten muss - aber dann kann man eigentlich auch stattdessen die Konfiguration direkt passend für den jeweiligen Testfall in der jeweiligen Testmethode erledigen!)

  5. Wie immer sollten auch die Namen der Testmethoden klar über ihren Zweck Auskunft geben.

    (Der Präfix "test" wird seit JUnit 4.x nicht mehr benötigt, aber dennoch ist es in vielen Projekten Praxis, diesen Präfix beizubehalten - damit kann man in der Package-Ansicht in der IDE leichter zwischen den "normalen" und den Testmethoden unterscheiden.)

Diese Erfahrungen werden ausführlich in [SWEGoogle, pp. 231-256] diskutiert.

Wrap-Up

JUnit als Framework für (Unit-) Tests; hier JUnit 4 (mit Ausblick auf JUnit 5)

  • Testmethoden mit Annotation @Test
  • assert (Testergebnis) vs. assume (Testvorbedingung)
  • Aufbau der Testumgebung @Before
  • Abbau der Testumgebung @After
  • Steuern von Tests mit @Ignore oder @Test(timout=XXX)
  • Exceptions einfordern mit @Test(expected=package.Exception.class)
  • Tests zusammenfassen zu Testsuiten
Challenges

Setup und Teardown

Sie haben in den Challenges in "Intro SW-Test" erste JUnit-Tests für die Klasse MyList<T> implementiert.

Wie müssten Sie Ihre JUnit-Tests anpassen, wenn Sie im obigen Szenario Setup- und Teardown-Methoden einsetzen würden?

Parametrisierte Tests

Betrachten Sie die folgende einfache Klasse MyMath:

public class MyMath {
    public static String add(String s, int c) {
        return s.repeat(c);
    }
}

Beim Testen der Methode MyMath#add fällt auf, dass man hier immer wieder den selben Testfall mit lediglich anderen Werten ausführt - ein Fall für parametrisierte Tests.

Schreiben Sie mit Hilfe von JUnit (4.x oder 5.x) einige parametrisierte Unit-Tests für die Methode MyMath#add.

Quellen

Testfallermittlung: Wie viel und was muss man testen?

TL;DR

Mit Hilfe der Äquivalenzklassenbildung kann man Testfälle bestimmen. Dabei wird der Eingabebereich für jeden Parameter einer Methode in Bereiche mit gleichem Verhalten der Methode eingeteilt (die sogenannten "Äquivalenzklassen"). Dabei können einige Äquivalenzklassen (ÄK) gültigen Eingabebereichen entsprechen ("gültige ÄK"), also erlaubten/erwarteten Eingaben (die zum gewünschten Verhalten führen), und die restlichen ÄK entsprechen dann ungültigen Eingabebereichen ("ungültige ÄK"), also nicht erlaubten Eingaben, die von der Methode zurückgewiesen werden sollten. Jede dieser ÄK muss in mindestens einem Testfall vorkommen, d.h. man bestimmt einen oder mehrere zufällige Werte in den ÄK. Dabei können über mehrere Parameter hinweg verschiedene gültige ÄK in einem Testfall kombiniert werden. Bei den ungültigen ÄK kann dagegen immer nur ein Parameter eine ungültige ÄK haben, für die restlichen Parameter müssen gültige ÄK genutzt werden, und diese werden dabei als durch diesen Testfall "nicht getestet" betrachtet.

Zusätzlich entstehen häufig Fehler bei den Grenzen der Bereiche, etwa in Schleifen. Deshalb führt man zusätzlich noch eine Grenzwertanalyse durch und bestimmt für jede ÄK den unteren und den oberen Grenzwert und erzeugt aus diesen Werten zusätzliche Testfälle.

Wenn in der getesteten Methode der Zustand des Objekts eine Rolle spielt, wird dieser wie ein weiterer Eingabeparameter für die Methode betrachtet und entsprechend in die ÄK-Bildung bzw. GW-Analyse einbezogen.

Wenn ein Testfall sich aus den gültigen ÄK/GW speist, spricht man auch von einem "Positiv-Test"; wenn ungültige ÄK/GW genutzt werden, spricht man auch von einem "Negativ-Test".

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Merkmale schlecht testbaren Codes erklären
  • (K2) Merkmale guter Unit-Tests erklären
  • (K3) Erstellen von Testfällen mittels Äquivalenzklassenbildung und Grenzwertanalyse

Hands-On (10 Minuten): Wieviel und was muss man testen?

public class Studi {
    private int credits = 0;

    public void addToCredits(int credits) {
        if (credits < 0) {
            throw new IllegalArgumentException("Negative Credits!");
        }
        if (this.credits + credits > 210) {
            throw new IllegalArgumentException("Mehr als 210 Credits!");
        }
        this.credits += credits;
    }
}

JEDE Methode mindestens testen mit/auf:

  • Positive Tests: Gutfall (Normalfall) => "gültige ÄK/GW"
  • Negativ-Tests (Fehlbedienung, ungültige Werte) => "ungültige ÄK/GW"
  • Rand- bzw. Extremwerte => GW
  • Exceptions

=> Anforderungen abgedeckt (Black-Box)?

=> Wichtige Pfade im Code abgedeckt (White-Box)?

Praxis

  • Je kritischer eine Klasse/Methode/Artefakt ist, um so intensiver testen!
  • Suche nach Kompromissen: Testkosten vs. Kosten von Folgefehlern; beispielsweise kein Test generierter Methoden

=> "Erzeugen" der Testfälle über die Äquivalenzklassenbildung und Grenzwertanalyse (siehe nächste Folien). Mehr dann später im Wahlfach "Softwarequalität" ...

Äquivalenzklassenbildung

Beispiel: Zu testende Methode mit Eingabewert x, der zw. 10 und 100 liegen soll

  • Zerlegung der Definitionsbereiche in Äquivalenzklassen (ÄK):

    • Disjunkte Teilmengen, wobei
    • Werte einer ÄK führen zu gleichartigem Verhalten
  • Annahme: Eingabeparameter sind untereinander unabhängig

  • Unterscheidung gültige und ungültige ÄK

Bemerkungen

Hintergrund: Da die Werte einer ÄK zu gleichartigem Verhalten führen, ist es egal, welchen Wert man aus einer ÄK für den Test nimmt.

Formal hat man eine ungültige ÄK (d.h. die Menge aller ungültigen Werte). In der Programmierpraxis macht es aber einen Unterschied, ob es sich um Werte unterhalb oder oberhalb des erlaubten Wertebereichs handelt (Fallunterscheidung). Beispiel: Eine Funktion soll Werte zwischen 10 und 100 verarbeiten. Dann sind alle Werte kleiner 10 oder größer 100 mathematisch gesehen in der selben ÄK "ungültig". Praktisch macht es aber Sinn, eine ungültige ÄK für "kleiner 10" und eine weitere ungültige ÄK für "größer 100" zu betrachten ...

Traditionell betrachtet man nur die Eingabeparameter. Es kann aber Sinn machen, auch die Ausgabeseite zu berücksichtigen (ist aber u.U. nur schwierig zu realisieren).

Faustregeln bei der Bildung von ÄK

  • Falls eine Beschränkung einen Wertebereich spezifiziert: Aufteilung in eine gültige und zwei ungültige ÄK

    Beispiel: Eingabewert x soll zw. 10 und 100 liegen

    • Gültige ÄK: $[10, 100]$
    • Ungültige ÄKs: $x < 10$ und $100 < x$
  • Falls eine Beschränkung eine minimale und maximale Anzahl von Werten spezifiziert: Aufteilung in eine gültige und zwei ungültige ÄK

    Beispiel: Jeder Studi muss pro Semester an mindestens einer LV teilnehmen, maximal sind 5 LVs erlaubt.

    • Gültige ÄK: $1 \le x \le 5$
    • Ungültige ÄKs: $x = 0$ (keine Teilnahme) und $5 < x$ (mehr als 5 Kurse)
  • Falls eine Beschränkung eine Menge von Werten spezifiziert, die möglicherweise unterschiedlich behandelt werden: Für jeden Wert dieser Menge eine eigene gültige ÄK erstellen und zusätzlich insgesamt eine ungültige ÄK

    Beispiel: Das Hotel am Urlaubsort ermöglicht verschiedene Freizeitaktivitäten: Segway-fahren, Tauchen, Tennis, Golf

    • Gültige ÄKs:
      • Segway-fahren
      • Tauchen
      • Tennis
      • Golf
    • Ungültige ÄK: "alles andere"
  • Falls eine Beschränkung eine Situation spezifiziert, die zwingend erfüllt sein muss: Aufteilung in eine gültige und eine ungültige ÄK

Hinweis: Werden Werte einer ÄK vermutlich nicht gleichwertig behandelt, dann erfolgt die Aufspaltung der ÄK in kleinere ÄKs. Das ist im Grunde die analoge Überlegung zu mehreren ungültigen ÄKs.

ÄKs sollten für die weitere Arbeit einheitlich und eindeutig benannt werden. Typisches Namensschema: "gÄKn" und "uÄKn" für gültige bzw. ungültige ÄKs mit der laufenden Nummer $n$.

ÄK: Erstellung der Testfälle

  • Jede ÄK durch mindestens einen TF abdecken

  • Dabei pro Testfall

    • mehrere gültige ÄKs kombinieren, oder
    • genau eine ungültige ÄK untersuchen (restl. Werte aus gültigen ÄK auffüllen; diese gelten dann aber nicht als getestet!)

Im Prinzip muss man zur Erstellung der Testfälle (TF) eine paarweise vollständige Kombination über die ÄK bilden, d.h. jede ÄK kommt mit jeder anderen ÄK in einem TF zur Ausführung.

Erinnerung: Annahme: Eingabeparameter sind untereinander unabhängig! => Es reicht, wenn jede gültige ÄK einmal in einem TF zur Ausführung kommt. => Kombination verschiedener gültiger ÄK in einem TF.

Achtung: Dies gilt nur für die gültigen ÄK! Bei den ungültigen ÄKs dürfen diese nicht miteinander in einem TF kombiniert werden! Bei gleichzeitiger Behandlung verschiedener ungültiger ÄK bleiben u.U. Fehler unentdeckt, da sich die Wirkungen der ungültigen ÄK überlagern!

Für jeden Testfall (TF) wird aus den zu kombinierenden ÄK ein zufälliger Repräsentant ausgewählt.

ÄK: Beispiel: Eingabewert x soll zw. 10 und 100 liegen

Äquivalenzklassen

Eingabe gültige ÄK ungültige ÄK
x gÄK1: $[10, 100]$ uÄK2: $x < 10$
uÄK3: $100 < x$

Tests

Testnummer 1 2 3
geprüfte ÄK gÄK1 uÄK2 uÄK3
x 42 7 120
Erwartetes Ergebnis OK Exception Exception

Grenzwertanalyse

Beobachtung: Grenzen in Verzweigungen/Schleifen kritisch

  • Grenzen der ÄK (kleinste und größte Werte) zusätzlich testen
    • "gültige Grenzwerte" (gGW): Grenzwerte von gültigen ÄK
    • "ungültige Grenzwerte" (uGW): Grenzwerte von ungültigen ÄK

Zusätzlich sinnvoll: Weitere grenznahe Werte, d.h. weitere Werte "rechts" und "links" der Grenze nutzen.

Bildung der Testfälle:

  • Jeder GW muss in mind. einem TF vorkommen

Pro TF darf ein GW (gültig oder ungültig) verwendet werden, die restlichen Parameter werden (mit zufälligen Werten) aus gültigen ÄK aufgefüllt, um mögliche Grenzwertprobleme nicht zu überlagern.

GW: Beispiel: Eingabewert x soll zw. 10 und 100 liegen

Äquivalenzklassen

Eingabe gültige ÄK ungültige ÄK
x gÄK1: $[10, 100]$ uÄK2: $x < 10$
uÄK3: $100 < x$

Grenzwertanalyse

Zusätzliche Testdaten: 9 (uÄK2o) und 10 (gÄK1u) sowie 100 (gÄK1o) und 101 (uÄK3u)

Tests

Testnummer 4 5 6 7
geprüfter GW gÄK1u gÄK1o uÄK2o uÄK3u
x 10 100 9 101
Erwartetes Ergebnis OK OK Exception Exception

Hinweis: Die Ergebnisse der GW-Analyse werden zusätzlich zu den Werten aus der ÄK-Analyse eingesetzt. Für das obige Beispiel würde man also folgende Tests aus der kombinierten ÄK- und GW-Analyse erhalten:

Testnummer 1 2 3 4 5 6 7
geprüfte(r) ÄK/GW gÄK1 uÄK2 uÄK3 gÄK1u gÄK1o uÄK2o uÄK3u
x 42 7 120 10 100 9 101
Erwartetes Ergebnis OK Exception Exception OK OK Exception Exception

Anmerkung: Analyse abhängiger Parameter

Wenn das Ergebnis von der Kombination der Eingabewerte abhängt, dann sollte man dies bei der Äquivalenzklassenbildung berücksichtigen: Die ÄK sind in diesem Fall in Bezug auf die Kombinationen zu bilden!

Schauen Sie sich dazu das Beispiel im [Kleuker2019], Abschnitt "4.3 Analyse abhängiger Parameter" an.

Die einfache ÄK-Bildung würde in diesem Fall versagen, da die Eingabewerte nicht unabhängig sind. Leider ist die Betrachtung der möglichen Kombinationen u.U. eine sehr komplexe Aufgabe ...

Analoge Überlegungen gelten auch für die ÄK-Bildung im Zusammenhang mit objektorientierter Programmierung. Die Eingabewerte und der Objektzustand müssen dann gemeinsam bei der ÄK-Bildung betrachtet werden!

Vergleiche [Kleuker2019], Abschnitt "4.4 Äquivalenzklassen und Objektorientierung".

Wrap-Up

  • Gründliches Testen ist ebenso viel Aufwand wie Coden
  • Äquivalenzklassenbildung und Grenzwertanalyse
Challenges

ÄK/GW: RSV Flotte Speiche

Der RSV Flotte Speiche hat in seiner Mitgliederverwaltung (MitgliederVerwaltung) die Methode testBeitritt implementiert. Mit dieser Methode wird geprüft, ob neue Mitglieder in den Radsportverein aufgenommen werden können.

public class MitgliederVerwaltung {

    /**
     * Testet, ob ein Mitglied in den Verein aufgenommen werden kann.
     *
     * <p>Interessierte Personen müssen mindestens 16 Jahre alt sein, um aufgenommen
     * werden zu können. Die Motivation darf nicht zu niedrig und auch nicht zu hoch
     * sein und muss zwischen 4 und 7 (inklusive) liegen, sonst wird der Antrag
     * abgelehnt.
     *
     * <p>Der Wertebereich beim Alter umfasst die natürlichen Zahlen zwischen 0 und 99
     * (inklusive), bei der Motivation sind die natürlichen Zahlen zwischen 0 und 10
     * (inklusive) erlaubt.
     *
     * <p>Bei Verletzung der zulässigen Wertebereiche der Parameter wird eine
     * <code>IllegalArgumentException</code> geworfen.
     *
     * @param alter       Alter in Lebensjahren, Bereich [0, 99]
     * @param motivation  Motivation auf einer Scala von 0 bis 10
     * @return <code>true</code>, wenn das Mitglied aufgenommen werden kann,
     *         sonst <code>false</code>
     * @throws <code>IllegalArgumentException</code>, wenn Parameter außerhalb
     *                                                der zulässigen Wertebereiche
     */
    public boolean testBeitritt(int alter, int motivation) {
        // Implementierung versteckt
    }
}
  1. Führen Sie eine Äquivalenzklassenbildung durch und geben Sie die gefundenen Äquivalenzklassen (ÄK) an: laufende Nummer, Definition (Wertebereiche o.ä.), kurze Beschreibung (gültige/ungültige ÄK, Bedeutung).

  2. Führen Sie zusätzlich eine Grenzwertanalyse durch und geben Sie die jeweiligen Grenzwerte (GW) an.

  3. Erstellen Sie aus den ÄK und GW wie in der Vorlesung diskutiert Testfälle. Geben Sie pro Testfall (TF) an, welche ÄK und/oder GW abgedeckt sind, welche Eingaben Sie vorsehen und welche Ausgabe Sie erwarten.

    Hinweis: Erstellen Sie separate (zusätzliche) TF für die GW, d.h. integrieren Sie diese nicht in die ÄK-TF.

  4. Implementieren Sie die Testfälle in JUnit (JUnit 4 oder 5).

    • Fassen Sie die Testfälle der gültigen ÄK in einem parametrisierten Test zusammen.
    • Für die ungültigen ÄKs erstellen Sie jeweils eine eigene JUnit-Testmethode. Beachten Sie, dass Sie auch die Exceptions testen müssen.

ÄK/GW: LSF

Das LSF bestimmt mit der Methode LSF#checkStudentCPS, ob ein Studierender bereits zur Bachelorarbeit oder Praxisphase zugelassen werden kann:

class LSF {
    public static Status checkStudentCPS(Student student) {
        if (student.credits() >= Status.BACHELOR.credits) return Status.BACHELOR;
        else if (student.credits() >= Status.PRAXIS.credits) return Status.PRAXIS;
        else return Status.NONE;
    }
}

record Student(String name, int credits, int semester) { }

enum Status {
    NONE(0), PRAXIS(110), BACHELOR(190);  // min: 0, max: 210

    public final int credits;
    Status(int credits) { this.credits = credits; }
}
  1. Führen Sie eine Äquivalenzklassenbildung für die Methode LSF#checkStudentCPS durch.
  2. Führen Sie zusätzlich eine Grenzwertanalyse für die Methode LSF#checkStudentCPS durch.
  3. Erstellen Sie aus den ÄK und GW wie in der Vorlesung diskutiert Testfälle.
  4. Implementieren Sie die Testfälle in JUnit (JUnit 4 oder 5).
    • Fassen Sie die Testfälle der gültigen ÄK in einem parametrisierten Test zusammen.
    • Für die ungültigen ÄKs erstellen Sie jeweils eine eigene JUnit-Testmethode. Beachten Sie, dass Sie auch die Exceptions testen müssen.
Quellen

Mocking mit Mockito

TL;DR

Häufig hat man es in Softwaretests mit dem Problem zu tun, dass die zu testenden Klassen von anderen, noch nicht implementierten Klassen oder von zufälligen oder langsamen Operationen abhängen.

In solchen Situationen kann man auf "Platzhalter" für diese Abhängigkeiten zurückgreifen. Dies können einfache Stubs sein, also Objekte, die einfach einen festen Wert bei einem Methodenaufruf zurückliefern oder Mocks, wo man auf die Argumente eines Methodenaufrufs reagieren kann und passende unterschiedliche Rückgabewerte zurückgeben kann.

Mockito ist eine Java-Bibliothek, die zusammen mit JUnit das Mocking von Klassen in Java erlaubt. Man kann hier zusätzlich auch die Interaktion mit dem gemockten Objekt überprüfen und testen, ob eine bestimmte Methode mit bestimmten Argumenten aufgerufen wurde und wie oft.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Begriffe: Mocking, Mock, Stub, Spy
  • (K3) Erzeugen eines Mocks in Mockito
  • (K3) Erzeugen eines Spies in Mockito
  • (K3) Prüfen von Interaktion mit verify()
  • (K3) Einsatz von ArgumentMatcher

Motivation: Entwicklung einer Studi-/Prüfungsverwaltung

Szenario

Zwei Teams entwickeln eine neue Studi-/Prüfungsverwaltung für die Hochschule. Ein Team modelliert dabei die Studierenden, ein anderes Team modelliert die Prüfungsverwaltung LSF.

  • Team A:

    public class Studi {
        String name;  LSF lsf;
    
        public Studi(String name, LSF lsf) {
            this.name = name;  this.lsf = lsf;
        }
    
        public boolean anmelden(String modul) { return lsf.anmelden(name, modul); }
        public boolean einsicht(String modul) { return lsf.ergebnis(name, modul) > 50; }
    }
  • Team B:

    public class LSF {
        public boolean anmelden(String name, String modul) { throw new UnsupportedOperationException(); }
        public int ergebnis(String name, String modul) { throw new UnsupportedOperationException(); }
    }

Team B kommt nicht so recht vorwärts, Team A ist fertig und will schon testen.

Wie kann Team A seinen Code testen?

Optionen:

  • Gar nicht testen?!
  • Das LSF selbst implementieren? Wer pflegt das dann? => manuell implementierte Stubs
  • Das LSF durch einen Mock ersetzen => Einsatz der Bibliothek "mockito"

Motivation Mocking und Mockito

Mockito ist ein Mocking-Framework für JUnit. Es simuliert das Verhalten eines realen Objektes oder einer realen Methode.

Wofür brauchen wir denn jetzt so ein Mocking-Framework überhaupt?

Wir wollen die Funktionalität einer Klasse isoliert vom Rest testen können. Dabei stören uns aber bisher so ein paar Dinge:

  • Arbeiten mit den echten Objekten ist langsam (zum Beispiel aufgrund von Datenbankenzugriffen)
  • Objekte beinhalten oft komplexe Abhängigkeiten, die in Tests schwer abzudecken sind
  • Manchmal existiert der zu testende Teil einer Applikation auch noch gar nicht, sondern es gibt nur die Interfaces.
  • Oder es gibt unschöne Seiteneffekte beim Arbeiten mit den realen Objekten. Zum Beispiel könnte es sein, das immer eine E-Mail versendet wird, wenn wir mit einem Objekt interagieren.

In solchen Situationen wollen wir eine Möglichkeit haben, das Verhalten eines realen Objektes bzw. der Methoden zu simulieren, ohne dabei die originalen Methoden aufrufen zu müssen. (Manchmal möchte man das dennoch, aber dazu später mehr...)

Und genau hier kommt Mockito ins Spiel. Mockito hilft uns dabei, uns von den externen Abhängigkeiten zu lösen, indem es sogenannte Mocks, Stubs oder Spies anbietet, mit denen sich das Verhalten der realen Objekte simulieren/überwachen und testen lässt.

Aber was genau ist denn jetzt eigentlich Mocking?

Ein Mock-Objekt ("etwas vortäuschen") ist im Software-Test ein Objekt, das als Platzhalter (Attrappe) für das echte Objekt verwendet wird.

Mocks sind in JUnit-Tests immer dann nützlich, wenn man externe Abhängigkeiten hat, auf die der eigene Code zugreift. Das können zum Beispiel externe APIs sein oder Datenbanken etc. ... Mocks helfen einem beim Testen nun dabei, sich von diesen externen Abhängigkeiten zu lösen und seine Softwarefunktionalität dennoch schnell und effizient testen zu können ohne evtl. auftretende Verbindungsfehler oder andere mögliche Seiteneffekte der externen Abhängigkeiten auszulösen.

Dabei simulieren Mocks die Funktionalität der externen APIs oder Datenbankzugriffe. Auf diese Weise ist es möglich Softwaretests zu schreiben, die scheinbar die gleichen Methoden aufrufen, die sie auch im regulären Softwarebetrieb nutzen würden, allerdings werden diese wie oben erwähnt allerdings für die Tests nur simuliert.

Mocking ist also eine Technik, die in Softwaretests verwendet wird, in denen die gemockten Objekte anstatt der realen Objekte zu Testzwecken genutzt werden. Die gemockten Objekte liefern dabei bei einem vom Programmierer bestimmten (Dummy-) Input, einen dazu passenden gelieferten (Dummy-) Output, der durch seine vorhersagbare Funktionalität dann in den eigentlichen Testobjekten gut für den Test nutzbar ist.

Dabei ist es von Vorteil die drei Grundbegriffe "Mock", "Stub" oder "Spy", auf die wir in der Vorlesung noch häufiger treffen werden, voneinander abgrenzen und unterscheiden zu können.

Dabei bezeichnet ein

  • Stub: Ein Stub ist ein Objekt, dessen Methoden nur mit einer minimalen Logik für den Test implementiert wurden. Häufig werden dabei einfach feste (konstante) Werte zurückgeliefert, d.h. beim Aufruf einer Methode wird unabhängig von der konkreten Eingabe immer die selbe Ausgabe zurückgeliefert.
  • Mock: Ein Mock ist ein Objekt, welches im Gegensatz zum Stub bei vorher definierten Funktionsaufrufen mit vorher definierten Argumente eine definierte Rückgabe liefert.
  • Spy: Ein Spy ist ein Objekt, welches Aufrufe und übergebene Werte protokolliert und abfragbar macht. Es ist also eine Art Wrapper um einen Stub oder einen Mock.

Mockito Setup

  • Gradle: build.gradle

    dependencies {
        implementation 'junit:junit:4.13.2'
        implementation 'org.mockito:mockito-core:4.5.1'
    }
  • Maven: pom.xml

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
        </dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>4.5.1</version>
        </dependency>
    </dependencies>

Manuell Stubs implementieren

Team A könnte manuell das LSF rudimentär implementieren (nur für die Tests, einfach mit festen Rückgabewerten): Stubs

public class StudiStubTest {
    Studi studi;  LSF lsf;

    @Before
    public void setUp() { lsf = new LsfStub();  studi = new Studi("Harald", lsf); }

    @Test
    public void testAnmelden() { assertTrue(studi.anmelden("PM-Dungeon")); }
    @Test
    public void testEinsicht() { assertTrue(studi.einsicht("PM-Dungeon")); }


    // Stub für das noch nicht fertige LSF
    class LsfStub extends LSF {
        public boolean anmelden(String name, String modul) { return true; }
        public int ergebnis(String name, String modul) { return 80; }
    }
}

Problem: Wartung der Tests (wenn das richtige LSF fertig ist) und Wartung der Stubs (wenn sich die Schnittstelle des LSF ändert, muss auch der Stub nachgezogen werden).

Problem: Der Stub hat nur eine Art minimale Default-Logik (sonst könnte man ja das LSF gleich selbst implementieren). Wenn man im Test andere Antworten braucht, müsste man einen weiteren Stub anlegen ...

Mockito: Mocking von ganzen Klassen

Lösung: Mocking der Klasse LSF mit Mockito für den Test von Studi: mock().

public class StudiMockTest {
    Studi studi;  LSF lsf;

    @Before
    public void setUp() { lsf = mock(LSF.class);  studi = new Studi("Harald", lsf); }

    @Test
    public void testAnmelden() {
        when(lsf.anmelden(anyString(), anyString())).thenReturn(true);
        assertTrue(studi.anmelden("PM-Dungeon"));
    }

    @Test
    public void testEinsichtI() {
        when(lsf.ergebnis("Harald", "PM-Dungeon")).thenReturn(80);
        assertTrue(studi.einsicht("PM-Dungeon"));
    }
    @Test
    public void testEinsichtII() {
        when(lsf.ergebnis("Harald", "PM-Dungeon")).thenReturn(40);
        assertFalse(studi.einsicht("PM-Dungeon"));
    }
}

Der Aufruf mock(LSF.class) erzeugt einen Mock der Klasse (oder des Interfaces) LSF. Dabei wird ein Objekt vom Typ LSF erzeugt, mit dem man dann wie mit einem normalen Objekt weiter arbeiten kann. Die Methoden sind allerdings nicht implementiert ...

Mit Hilfe von when().thenReturn() kann man definieren, was genau beim Aufruf einer bestimmten Methode auf dem Mock passieren soll, d.h. welcher Rückgabewert entsprechend zurückgegeben werden soll. Hier kann man dann für bestimmte Argumentwerte andere Rückgabewerte definieren. when(lsf.ergebnis("Harald", "PM-Dungeon")).thenReturn(80) gibt also für den Aufruf von ergebnis mit den Argumenten "Harald" und "PM-Dungeon" auf dem Mock lsf den Wert 80 zurück.

Dies kann man in weiten Grenzen flexibel anpassen.

Mit Hilfe der Argument-Matcher anyString() wird jedes String-Argument akzeptiert.

Mockito: Spy = Wrapper um ein Objekt

Team B hat das LSF nun implementiert und Team A kann es endlich für die Tests benutzen. Aber das LSF hat eine Zufallskomponente (ergebnis()). Wie kann man nun die Reaktion des Studis testen (einsicht())?

Lösung: Mockito-Spy als partieller Mock einer Klasse (Wrapper um ein Objekt): spy().

public class StudiSpyTest {
    Studi studi;  LSF lsf;

    @Before
    public void setUp() { lsf = spy(LSF.class);  studi = new Studi("Harald", lsf); }

    @Test
    public void testAnmelden() { assertTrue(studi.anmelden("PM-Dungeon")); }

    @Test
    public void testEinsichtI() {
        doReturn(80).when(lsf).ergebnis("Harald", "PM-Dungeon");
        assertTrue(studi.einsicht("PM-Dungeon"));
    }
    @Test
    public void testEinsichtII() {
        doReturn(40).when(lsf).ergebnis("Harald", "PM-Dungeon");
        assertFalse(studi.einsicht("PM-Dungeon"));
    }
}

Der Aufruf spy(LSF.class) erzeugt einen Spy um ein Objekt der Klasse LSF. Dabei bleiben zunächst die Methoden in LSF erhalten und können aufgerufen werden, sie können aber auch mit einem (partiellen) Mock überlagert werden. Der Spy zeichnet wie der Mock die Interaktion mit dem Objekt auf.

Mit Hilfe von doReturn().when() kann man definieren, was genau beim Aufruf einer bestimmten Methode auf dem Spy passieren soll, d.h. welcher Rückgabewert entsprechend zurückgegeben werden soll. Hier kann man analog zum Mock für bestimmte Argumentwerte andere Rückgabewerte definieren. doReturn(40).when(lsf).ergebnis("Harald", "PM-Dungeon") gibt also für den Aufruf von ergebnis mit den Argumenten "Harald" und "PM-Dungeon" auf dem Spy lsf den Wert 40 zurück.

Wenn man die Methoden nicht mit einem partiellen Mock überschreibt, dann wird einfach die originale Methode aufgerufen (Beispiel: In studi.anmelden("PM-Dungeon") wird lsf.anmelden("Harald", "PM-Dungeon") aufgerufen.).

Auch hier können Argument-Matcher wie anyString() eingesetzt werden.

Wurde eine Methode aufgerufen?

public class VerifyTest {
    @Test
    public void testAnmelden() {
        LSF lsf = mock(LSF.class);  Studi studi = new Studi("Harald", lsf);

        when(lsf.anmelden("Harald", "PM-Dungeon")).thenReturn(true);

        assertTrue(studi.anmelden("PM-Dungeon"));


        verify(lsf).anmelden("Harald", "PM-Dungeon");
        verify(lsf, times(1)).anmelden("Harald", "PM-Dungeon");

        verify(lsf, atLeast(1)).anmelden("Harald", "PM-Dungeon");
        verify(lsf, atMost(1)).anmelden("Harald", "PM-Dungeon");

        verify(lsf, never()).ergebnis("Harald", "PM-Dungeon");

        verifyNoMoreInteractions(lsf);
    }
}

Mit der Methode verify() kann auf einem Mock oder Spy überprüft werden, ob und wie oft und in welcher Reihenfolge Methoden aufgerufen wurden und mit welchen Argumenten. Auch hier lassen sich wieder Argument-Matcher wie anyString() einsetzen.

Ein einfaches verify(mock) prüft dabei, ob die entsprechende Methode exakt einmal vorher aufgerufen wurde. Dies ist äquivalent zu verify(mock, times(1)). Analog kann man mit den Parametern atLeast() oder atMost bestimmte Unter- oder Obergrenzen für die Aufrufe angeben und mit never() prüfen, ob es gar keinen Aufruf vorher gab.

verifyNoMoreInteractions(lsf) ist interessant: Es ist genau dann true, wenn es außer den vorher abgefragten Interaktionen keinerlei sonstigen Interaktionen mit dem Mock oder Spy gab.

LSF lsf = mock(LSF.class);
Studi studi = new Studi("Harald", lsf);

when(lsf.anmelden("Harald", "PM-Dungeon")).thenReturn(true);

InOrder inOrder = inOrder(lsf);

assertTrue(studi.anmelden("PM-Dungeon"));
studi.anmelden("Wuppie");

inOrder.verify(lsf).anmelden("Harald", "Wuppie");
inOrder.verify(lsf).anmelden("Harald", "PM-Dungeon");

Mit InOrder lassen sich Aufrufe auf einem Mock/Spy oder auch auf verschiedenen Mocks/Spies in eine zeitliche Reihenfolge bringen und so überprüfen.

Fangen von Argumenten

public class MatcherTest {
    @Test
    public void testAnmelden() {
        LSF lsf = mock(LSF.class);  Studi studi = new Studi("Harald", lsf);

        when(lsf.anmelden(anyString(), anyString())).thenReturn(false);
        when(lsf.anmelden("Harald", "PM-Dungeon")).thenReturn(true);

        assertTrue(studi.anmelden("PM-Dungeon"));
        assertFalse(studi.anmelden("Wuppie?"));

        verify(lsf, times(1)).anmelden("Harald", "PM-Dungeon");
        verify(lsf, times(1)).anmelden("Harald", "Wuppie?");

        verify(lsf, times(2)).anmelden(anyString(), anyString());
        verify(lsf, times(1)).anmelden(eq("Harald"), eq("Wuppie?"));
        verify(lsf, times(2)).anmelden(argThat(new MyHaraldMatcher()), anyString());
    }


    class MyHaraldMatcher implements ArgumentMatcher<String> {
        public boolean matches(String s) { return s.equals("Harald"); }
    }
}

Sie können die konkreten Argumente angeben, für die der Aufruf gelten soll. Alternativ können Sie mit vordefinierten ArgumentMatchers wie anyString() beispielsweise auf beliebige Strings reagieren oder selbst einen eigenen ArgumentMatcher<T> für Ihren Typ T erstellen und nutzen.

Wichtig: Wenn Sie für einen Parameter einen ArgumentMatcher einsetzen, müssen Sie für die restlichen Parameter der Methode dies ebenfalls tun. Sie können keine konkreten Argumente mit ArgumentMatcher mischen.

Sie finden viele weitere vordefinierte Matcher in der Klasse ArgumentMatchers. Mit der Klasse ArgumentCaptor<T> finden Sie eine alternative Möglichkeit, auf Argumente in gemockten Methoden zu reagieren. Schauen Sie sich dazu die Javadoc von Mockito an.

Ausblick: PowerMock

Mockito sehr mächtig, aber unterstützt (u.a.) keine

  • Konstruktoren
  • private Methoden
  • final Methoden
  • static Methoden (ab Version 3.4.0 scheint auch Mockito statische Methoden zu unterstützen)

=> Lösung: PowerMock

Ausführlicheres Beispiel: WuppiWarenlager

Credits: Der Dank für die Erstellung des nachfolgenden Beispiels und Textes geht an @jedi101.

Bei dem gezeigten Beispiel unseres WuppiStores sieht man, dass dieser normalerweise von einem fertigen Warenlager die Wuppis beziehen möchte. Da dieses Lager aber noch nicht existiert, haben wir uns kurzerhand einfach einen Stub von unserem IWuppiWarenlager-Interface erstellt, in dem wir zu Testzwecken händisch ein Paar Wuppis ins Lager geräumt haben.

Das funktioniert in diesem Mini-Testbeispiel ganz gut aber, wenn unsere Stores erst einmal so richtig Fahrt aufnehmen und wir irgendwann weltweit Wuppis verkaufen, wird der Code des IWuppiWarenlagers wahrscheinlich sehr schnell viel komplexer werden, was unweigerlich dann zu Maintenance-Problemen unserer händisch angelegten Tests führt. Wenn wir zum Beispiel einmal eine Methode hinzufügen wollen, die es uns ermöglicht, nicht immer alle Wuppis aus dem Lager zu ordern oder vielleicht noch andere Methoden, die Fluppis orderbar machen, hinzufügen, müssen wir immer dafür sorgen, dass wir die getätigten Änderungen händisch in den Stub des Warenlagers einpflegen.

Das will eigentlich niemand...

Einsatz von Mockito

Aber es gibt da einen Ausweg. Wenn es komplexer wird, verwenden wir Mocks.

Bislang haben wir noch keinen Gebrauch von Mockito gemacht. Das ändern wir nun.

Wie in diesem Beispiel gezeigt, müssen wir nun keinen Stub mehr von Hand erstellen, sondern überlassen dies Mockito.

IWuppiWarenlager lager = mock(IWuppiWarenlager.class);

Anschließend können wir, ohne die Methode getAllWuppis() implementiert zu haben, dennoch so tun als, ob die Methode eine Funktionalität hätte.

// Erstellen eines imaginären Lagerbestands.
List<String> wuppisImLager = Arrays.asList("GruenerWuppi","RoterWuppi");
when(lager.getAlleWuppis()).thenReturn(wuppisImLager);

Wann immer nun die Methode getAlleWuppis() des gemockten Lagers aufgerufen wird, wird dieser Aufruf von Mockito abgefangen und wie oben definiert verändert. Das Ergebnis können wir abschließend einfach in unserem Test testen:

// Erzeugen des WuppiStores.
WuppiStore wuppiStore = new WuppiStore(lager);

// Bestelle alle Wuppis aus dem gemockten Lager List<String>
bestellteWuppis = wuppiStore.bestelleAlleWuppis(lager);

// Hat die Bestellung geklappt?
assertEquals(2,bestellteWuppis.size());

Mockito Spies

Manchmal möchten wir allerdings nicht immer gleich ein ganzes Objekt mocken, aber dennoch Einfluss auf die aufgerufenen Methoden eines Objekts haben, um diese testen zu können. Vielleicht gibt es dabei ja sogar eine Möglichkeit unsere JUnit-Tests, mit denen wir normalerweise nur Rückgabewerte von Methoden testen können, zusätzlich auch das Verhalten also die Interaktionen mit einem Objekt beobachtbar zu machen. Somit wären diese Interaktionen auch testbar.

Und genau dafür bietet Mockito eine Funktion: der sogenannte "Spy".

Dieser Spion erlaubt es uns nun zusätzlich das Verhalten zu testen. Das geht in die Richtung von BDD - Behavior Driven Development.

// Spion erstellen, der unser wuppiWarenlager überwacht.
this.wuppiWarenlager = spy(WuppiWarenlager.class);

Hier hatten wir uns einen Spion erzeugt, mit dem sich anschließend das Verhalten verändern lässt:

when(wuppiWarenlager.getAlleWuppis()).thenReturn(Arrays.asList(new Wuppi("Wuppi007")));

Aber auch der Zugriff lässt sich kontrollieren/testen:

verify(wuppiWarenlager).addWuppi(normalerWuppi);
verifyNoMoreInteractions(wuppiWarenlager);

Die normalen Testmöglichkeiten von JUnit runden unseren Test zudem ab.

assertEquals(1,wuppiWarenlager.lager.size());

Mockito und Annotationen

In Mockito können Sie wie oben gezeigt mit mock() und spy() neue Mocks bzw. Spies erzeugen und mit verify() die Interaktion überprüfen und mit ArgumentMatcher<T> bzw. den vordefinierten ArgumentMatchers auf Argumente zuzugreifen bzw. darauf zu reagieren.

Zusätzlich/alternativ gibt es in Mockito zahlreiche Annotationen, die ersatzweise statt der genannten Methoden genutzt werden können. Hier ein kleiner Überblick über die wichtigsten in Mockito verwendeten Annotation:

  • @Mock wird zum Markieren des zu mockenden Objekts verwendet.

    @Mock
    WuppiWarenlager lager;
  • @RunWith(MockitoJUnitRunner.class) ist der entsprechende JUnit-Runner, wenn Sie Mocks mit @Mock anlegen.

    @RunWith(MockitoJUnitRunner.class)
    public class ToDoBusinessMock {...}
  • @Spy erlaubt das Erstellen von partiell gemockten Objekten. Dabei wird eine Art Wrapper um das zu mockende Objekt gewickelt, der dafür sorgt, dass alle Methodenaufrufe des Objekts an den Spy delegiert werden. Diese können über den Spion dann abgefangen/verändert oder ausgewertet werden.

    @Spy
    ArrayList<Wuppi> arrayListenSpion;
  • @InjectMocks erlaubt es, Parameter zu markieren, in denen Mocks und/oder Spies injiziert werden. Mockito versucht dann (in dieser Reihenfolge) per Konstruktorinjektion, Setterinjektion oder Propertyinjektion die Mocks zu injizieren. Weitere Informationen darüber findet man hier: Mockito Dokumentation

    Anmerkung: Es ist aber nicht ratsam "Field- oder Setterinjection" zu nutzen, da man nur bei der Verwendung von "Constructorinjection" sicherstellen kann, das eine Klasse nicht ohne die eigentlich notwendigen Parameter instanziiert wurde.

    @InjectMocks
    Wuppi fluppi;
  • @Captor erlaubt es, die Argumente einer Methode abzufangen/auszuwerten. Im Zusammenspiel mit Mockitos verify()-Methode kann man somit auch die einer Methode übergebenen Argumente verifizieren.

    @Captor
    ArgumentCaptor<String> argumentCaptor;
  • @ExtendWith(MockitoExtension.class) wird in JUnit5 verwendet, um die Initialisierung von Mocks zu vereinfachen. Damit entfällt zum Beispiel die noch unter JUnit4 nötige Initialisierung der Mocks durch einen Aufruf der Methode MockitoAnnotations.openMocks() im Setup des Tests (@Before bzw. @BeforeEach).

Prüfen der Interaktion mit verify()

Mit Hilfe der umfangreichen verify()-Methoden, die uns Mockito mitliefert, können wir unseren Code unter anderem auf unerwünschte Seiteneffekte testen. So ist es mit verify zum Beispiel möglich abzufragen, ob mit einem gemockten Objekt interagiert wurde, wie damit interagiert wurde, welche Argumente dabei übergeben worden sind und in welcher Reihenfolge die Interaktionen damit erfolgt sind.

Hier nur eine kurze Übersicht über das Testen des Codes mit Hilfe von Mockitos verify()-Methoden.

@Test
public void testVerify_DasKeineInteraktionMitDerListeStattgefundenHat() {
    // Testet, ob die spezifizierte Interaktion mit der Liste nie stattgefunden hat.
    verify(fluppisListe, never()).clear();
}
@Test
public void testVerify_ReihenfolgeDerInteraktionenMitDerFluppisListe() {
    // Testet, ob die Reihenfolge der spezifizierten Interaktionen mit der Liste eingehalten wurde.
    fluppisListe.clear();
    InOrder reihenfolge = inOrder(fluppisListe);
    reihenfolge.verify(fluppisListe).add("Fluppi001");
    reihenfolge.verify(fluppisListe).clear();
}
@Test
public void testVerify_FlexibleArgumenteBeimZugriffAufFluppisListe() {
    // Testet, ob schon jemals etwas zu der Liste hinzugefügt wurde.
    // Dabei ist es egal welcher String eingegeben wurde.
    verify(fluppisListe).add(anyString());
}
@Test
public void testVerify_InteraktionenMitHilfeDesArgumentCaptor() {
    // Testet, welches Argument beim Methodenaufruf übergeben wurde.
    fluppisListe.addAll(Arrays.asList("BobDerBaumeister"));
    ArgumentCaptor<List> argumentMagnet = ArgumentCaptor.forClass(FluppisListe.class);
    verify(fluppisListe).addAll(argumentMagnet.capture());
    List<String> argumente = argumentMagnet.getValue();
    assertEquals("BobDerBaumeister", argumente.get(0));
}

Wrap-Up

  • Gründliches Testen ist ebenso viel Aufwand wie Coden!

  • Mockito ergänzt JUnit:

    • Mocken ganzer Klassen (mock(), when().thenReturn())
    • Wrappen von Objekten (spy(), doReturn().when())
    • Auswerten, wie häufig Methoden aufgerufen wurden (verify())
    • Auswerten, mit welchen Argumenten Methoden aufgerufen wurden (anyString)
Challenges

Betrachten Sie die drei Klassen Utility.java, Evil.java und UtilityTest.java:

public class Utility {
    private int intResult = 0;
    private Evil evilClass;

    public Utility(Evil evilClass) {
        this.evilClass = evilClass;
    }

    public void evilMethod() {
        int i = 2 / 0;
    }

    public int nonEvilAdd(int a, int b) {
        return a + b;
    }

    public int evilAdd(int a, int b) {
        evilClass.evilMethod();
        return a + b;
    }

    public void veryEvilAdd(int a, int b) {
        evilMethod();
        evilClass.evilMethod();
        intResult = a + b;
    }

    public int getIntResult() {
        return intResult;
    }
}

public class Evil {
    public void evilMethod() {
        int i = 3 / 0;
    }
}

public class UtilityTest {
    private Utility utilityClass;
    // Initialisieren Sie die Attribute entsprechend vor jedem Test.

    @Test
    void test_nonEvilAdd() {
        Assertions.assertEquals(10, utilityClass.nonEvilAdd(9, 1));
    }

    @Test
    void test_evilAdd() {
        Assertions.assertEquals(10, utilityClass.evilAdd(9, 1));
    }

    @Test
    void test_veryEvilAdd() {
        utilityClass.veryEvilAdd(9, 1);
        Assertions.assertEquals(10, utilityClass.getIntResult());
    }
}

Testen Sie die Methoden nonEvilAdd, evilAdd und veryEvilAdd der Klasse Utility.java mit dem JUnit- und dem Mockito-Framework.

Vervollständigen Sie dazu die Klasse UtilityTest.java und nutzen Sie Mocking mit Mockito, um die Tests zum Laufen zu bringen. Die Tests dürfen Sie entsprechend verändern, aber die Aufrufe aus der Vorgabe müssen erhalten bleiben. Die Klassen Evil.java und Utility.java dürfen Sie nicht ändern.

Hinweis: Die Klasse Evil.java und die Methode evilMethod() aus Utility.java lösen eine ungewollte bzw. "zufällige" Exception aus, auf deren Auftreten jedoch nicht getestet werden soll. Stattdessen sollen diese Klassen bzw. Methoden mit Mockito "weggemockt" werden, so dass die vorgegebenen Testmethoden (wieder) funktionieren.

Quellen
  • [Mockito] Mockito
    S. Faber and B. Dutheil and R. Winterhalter and T.v.d. Lippe, 2022.

Subsections of Entwurfsmuster

Visitor-Pattern

TL;DR

Häufig bietet es sich bei Datenstrukturen an, die Traversierung nicht direkt in den Klassen der Datenstrukturen zu implementieren, sondern in Hilfsklassen zu verlagern. Dies gilt vor allem dann, wenn die Datenstruktur aus mehreren Klassen besteht (etwa ein Baum mit verschiedenen Knotentypen) und/oder wenn man nicht nur eine Traversierungsart ermöglichen will oder/und wenn man immer wieder neue Arten der Traversierung ergänzen will. Das würde nämlich bedeuten, dass man für jede weitere Form der Traversierung in allen Klassen eine entsprechende neue Methode implementieren müsste.

Das Visitor-Pattern lagert die Traversierung in eigene Klassenstruktur aus.

Die Klassen der Datenstruktur bekommen nur noch eine accept()-Methode, in der ein Visitor übergeben wird und rufen auf diesem Visitor einfach dessen visit()-Methode auf (mit einer Referenz auf sich selbst als Argument).

Der Visitor hat für jede Klasse der Datenstruktur eine Überladung der visit()-Methode. In diesen kann er je nach Klasse die gewünschte Verarbeitung vornehmen. Üblicherweise gibt es ein Interface oder eine abstrakte Klasse für die Visitoren, von denen dann konkrete Visitoren ableiten.

Bei Elementen mit "Kindern" muss man sich entscheiden, wie die Traversierung implementiert werden soll. Man könnte in der accept()-Methode den Visitor an die Kinder weiter reichen (also auf den Kindern accept() mit dem Visitor aufrufen), bevor man die visit()-Methode des Visitors mit sich selbst als Referenz aufruft. Damit ist die Form der Traversierung in den Klassen der Datenstruktur fest verankert und über den Visitor findet "nur" noch eine unterschiedliche Form der Verarbeitung statt. Alternativ überlässt man es dem Visitor, die Traversierung durchzuführen: Hier muss in den visit()-Methoden für die einzelnen Elemente entsprechend auf mögliche Kinder reagiert werden.

In diesem Pattern findet ein sogenannter "Double-Dispatch" statt: Zur Laufzeit wird ein konkreter Visitor instantiiert und über accept() an ein Element der Datenstruktur übergeben. Dort ist zur Compile-Zeit aber nur der Obertyp der Visitoren bekannt, d.h. zur Laufzeit wird hier der konkrete Typ bestimmt und entsprechend die richtige visit()-Methode auf der "echten" Klasse des Visitors aufgerufen (erster Dispatch). Da im Visitor die visit()-Methoden für jeden Typ der Datenstrukur überladen sind, findet nun zur Laufzeit die Auflösung der korrekten Überladung statt (zweiter Dispatch).

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Aufbau des Visitor-Patterns (Besucher-Entwurfsmusters)
  • (K3) Anwendung des Visitor-Patterns auf konkrete Beispiele, etwa den PM-Dungeon

Motivation: Parsen von "5*4+3"

Zum Parsen von Ausdrücken (Expressions) könnte man diese einfache Grammatik einsetzen. Ein Ausdruck ist dabei entweder ein einfacher Integer oder eine Addition oder Multiplikation zweier Ausdrücke.

expr : e1=expr '*' e2=expr      # MUL
     | e1=expr '+' e2=expr      # ADD
     | INT                      # NUM
     ;

Beim Parsen von "5*4+3" würde dabei der folgende Parsetree entstehen:

Strukturen für den Parsetree

Der Parsetree für diese einfache Grammatik ist ein Binärbaum. Die Regeln werden auf Knoten im Baum zurückgeführt. Es gibt Knoten mit zwei Kindknoten, und es gibt Knoten ohne Kindknoten ("Blätter").

Entsprechend kann man sich einfache Klassen definieren, die die verschiedenen Knoten in diesem Parsetree repräsentieren. Als Obertyp könnte es ein (noch leeres) Interface Expr geben.

public interface Expr {}

public class NumExpr implements Expr {
    private final int d;

    public NumExpr(int d) { this.d = d; }
}

public class MulExpr implements Expr {
    private final Expr e1;
    private final Expr e2;

    public MulExpr(Expr e1, Expr e2) {
        this.e1 = e1;  this.e2 = e2;
    }
}

public class AddExpr implements Expr {
    private final Expr e1;
    private final Expr e2;

    public AddExpr(Expr e1, Expr e2) {
        this.e1 = e1;  this.e2 = e2;
    }
}


public class DemoExpr {
    public static void main(final String... args) {
        // 5*4+3
        Expr e = new AddExpr(new MulExpr(new NumExpr(5), new NumExpr(4)), new NumExpr(3));
    }
}

Ergänzung I: Ausrechnen des Ausdrucks

Es wäre nun schön, wenn man mit dem Parsetree etwas anfangen könnte. Vielleicht möchte man den Ausdruck ausrechnen?

Zum Ausrechnen des Ausdrucks könnte man dem Interface eine eval()-Methode spendieren. Jeder Knoten kann für sich entscheiden, wie die entsprechende Operation ausgewertet werden soll: Bei einer NumExpr ist dies einfach der gespeicherte Wert, bei Addition oder Multiplikation entsprechend die Addition oder Multiplikation der Auswertungsergebnisse der beiden Kindknoten.

public interface Expr {
    int eval();
}

public class NumExpr implements Expr {
    private final int d;

    public NumExpr(int d) { this.d = d; }
    public int eval() { return d; }
}

public class MulExpr implements Expr {
    private final Expr e1;
    private final Expr e2;

    public MulExpr(Expr e1, Expr e2) {
        this.e1 = e1;  this.e2 = e2;
    }
    public int eval() { return e1.eval() * e2.eval(); }
}

public class AddExpr implements Expr {
    private final Expr e1;
    private final Expr e2;

    public AddExpr(Expr e1, Expr e2) {
        this.e1 = e1;  this.e2 = e2;
    }
    public int eval() { return e1.eval() + e2.eval(); }
}


public class DemoExpr {
    public static void main(final String... args) {
        // 5*4+3
        Expr e = new AddExpr(new MulExpr(new NumExpr(5), new NumExpr(4)), new NumExpr(3));

        int erg = e.eval();
    }
}

Ergänzung II: Pretty-Print des Ausdrucks

Nachdem das Ausrechnen so gut geklappt hat, will der Chef nun noch flink eine Funktion, mit der man den Ausdruck hübsch ausgeben kann:

Das fängt an, sich zu wiederholen. Wir implementieren immer wieder ähnliche Strukturen, mit denen wir diesen Parsetree traversieren ... Und wir müssen für jede Erweiterung immer alle Expression-Klassen anpassen!

Das geht besser.

Visitor-Pattern (Besucher-Entwurfsmuster)

Das Entwurfsmuster "Besucher" (Visitor Pattern) lagert die Aktion beim Besuchen eines Knotens in eine separate Klasse aus.

Dazu bekommt jeder Knoten im Baum eine neue Methode, die einen Besucher akzeptiert. Dieser Besucher kümmert sich dann um die entsprechende Verarbeitung des Knotens, also um das Auswerten oder Ausgeben im obigen Beispiel.

Die Besucher haben eine Methode, die für jeden zu bearbeitenden Knoten überladen wird. In dieser Methode findet dann die eigentliche Verarbeitung statt: Auswerten des Knotens oder Ausgeben des Knotens ...

public interface Expr {
    void accept(ExprVisitor v);
}

public class NumExpr implements Expr {
    private final int d;

    public NumExpr(int d) { this.d = d; }
    public int getValue() { return d; }

    public void accept(ExprVisitor v) { v.visit(this); }
}

public class MulExpr implements Expr {
    private final Expr e1;
    private final Expr e2;

    public MulExpr(Expr e1, Expr e2) {
        this.e1 = e1;  this.e2 = e2;
    }
    public Expr getE1() { return e1; }
    public Expr getE2() { return e2; }

    public void accept(ExprVisitor v) { v.visit(this); }
}

public class AddExpr implements Expr {
    private final Expr e1;
    private final Expr e2;

    public AddExpr(Expr e1, Expr e2) {
        this.e1 = e1;  this.e2 = e2;
    }
    public Expr getE1() { return e1; }
    public Expr getE2() { return e2; }

    public void accept(ExprVisitor v) { v.visit(this); }
}


public interface ExprVisitor {
    void visit(NumExpr e);
    void visit(MulExpr e);
    void visit(AddExpr e);
}

public class EvalVisitor implements ExprVisitor {
    private final Stack<Integer> erg = new Stack<>();

    public void visit(NumExpr e) { erg.push(e.getValue()); }
    public void visit(MulExpr e) {
        e.getE1().accept(this);  e.getE1().accept(this);
        erg.push(erg.pop() * erg.pop());
    }
    public void visit(AddExpr e) {
        e.getE1().accept(this);  e.getE1().accept(this);
        erg.push(erg.pop() + erg.pop());
    }
    public int getResult() { return erg.peek(); }
}

public class PrintVisitor implements ExprVisitor {
    private final Stack<String> erg = new Stack<>();

    public void visit(NumExpr e) { erg.push("NumExpr(" + e.getValue() + ")"); }
    public void visit(MulExpr e) {
        e.getE1().accept(this);  e.getE1().accept(this);
        erg.push("MulExpr(" + erg.pop() + ", " + erg.pop() + ")");
    }
    public void visit(AddExpr e) {
        e.getE1().accept(this);  e.getE1().accept(this);
        erg.push("AddExpr(" + erg.pop() + ", " + erg.pop() + ")");
    }
    public String getResult() { return erg.peek(); }
}


public class DemoExpr {
    public static void main(final String... args) {
        // 5*4+3
        Expr e = new AddExpr(new MulExpr(new NumExpr(5), new NumExpr(4)), new NumExpr(3));

        EvalVisitor v1 = new EvalVisitor();
        e.accept(v1);
        int erg = v1.getResult();

        PrintVisitor v2 = new PrintVisitor();
        e.accept(v2);
        String s = v2.getResult();
    }
}

Implementierungsdetail

In den beiden Klasse AddExpr und MulExpr müssen auch die beiden Kindknoten besucht werden, d.h. hier muss der Baum weiter traversiert werden.

Man kann sich überlegen, diese Traversierung in den Klassen AddExpr und MulExpr selbst anzustoßen.

Alternativ könnte auch der Visitor die Traversierung vornehmen. Gerade bei der Traversierung von Datenstrukturen ist diese Variante oft von Vorteil, da man hier unterschiedliche Traversierungsarten haben möchte (Breitensuche vs. Tiefensuche, Pre-Order vs. Inorder vs. Post-Order, ...) und diese elegant in den Visitor verlagern kann.

(Double-) Dispatch

Zur Laufzeit wird in accept() der Typ des Visitors aufgelöst und dann in visit() der Typ der zu besuchenden Klasse. Dies nennt man auch "Double-Dispatch".

Hinweis I

Man könnte versucht sein, die accept()-Methode aus den Knotenklassen in die gemeinsame Basisklasse zu verlagern: Statt

    public void accept(ExprVisitor v) {
        v.visit(this);
    }

in jeder Knotenklasse einzeln zu definieren, könnte man das doch einmalig in der Basisklasse definieren:

public abstract class Expr {
    /** Akzeptiere einen Visitor für die Verarbeitung */
    public void accept(ExprVisitor v) {
        v.visit(this);
    }
}

Dies wäre tatsächlich schön, weil man so Code-Duplizierung vermeiden könnte. Aber es funktioniert in Java leider nicht. (Warum?)

Hinweis II

Während die accept()-Methode nicht in die Basisklasse der besuchten Typen (im Bild oben die Klasse Elem bzw. im Beispiel oben die Klasse Expr) verlagert werden kann, kann man aber die visit()-Methoden im Interface Visitor durchaus als Default-Methoden im Interface implementieren.

Ausrechnen des Ausdrucks mit einem Visitor

Wrap-Up

Visitor-Pattern: Auslagern der Traversierung in eigene Klassenstruktur

  • Klassen der Datenstruktur

    • bekommen eine accept()-Methode für einen Visitor
    • rufen den Visitor mit sich selbst als Argument auf
  • Visitor

    • hat für jede Klasse eine Überladung der visit()-Methode
    • Rückgabewerte schwierig: Intern halten oder per return (dann aber unterschiedliche visit()-Methoden für die verschiedenen Rückgabetypen!)
  • (Double-) Dispatch: Zur Laufzeit wird in accept() der Typ des Visitors und in visit() der Typ der zu besuchenden Klasse aufgelöst

Challenges

Visitor-Pattern praktisch (und einfach)

Betrachten Sie den folgenden Code und erklären Sie das Ergebnis:

interface Fruit { }
class Apple implements Fruit { }
class Orange implements Fruit { }
class Banana implements Fruit { }
class Foo extends Apple { }

public class FruitBasketDirect {
    public static void main(String... args) {
        List<Fruit> basket = List.of(new Apple(), new Apple(), new Banana(), new Foo());

        int oranges = 0;  int apples = 0;  int bananas = 0;  int foo = 0;

        for (Fruit f : basket) {
            if (f instanceof Apple) apples++;
            if (f instanceof Orange) oranges++;
            if (f instanceof Banana) bananas++;
            if (f instanceof Foo) foo++;
        }
    }
}

Das Verwenden von instanceof ist unschön und fehleranfällig. Schreiben Sie den Code unter Einsatz des Visitor-Patterns um.

Diskutieren Sie Vor- und Nachteile des Visitor-Patterns.

Quellen
  • [Eilebrecht2013] Patterns kompakt
    Eilebrecht, K. und Starke, G., Springer, 2013. ISBN 978-3-6423-4718-4.
  • [Gamma2011] Design Patterns
    Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0.

Observer-Pattern

TL;DR

Eine Reihe von Objekten möchte über eine Änderung in einem anderen ("zentralen") Objekt informiert werden. Dazu könnte das "zentrale" Objekt eine Zugriffsmethode anbieten, die die anderen Objekte regelmäßig abrufen ("pollen").

Mit dem Observer-Pattern kann man das aktive Polling vermeiden. Die interessierten Objekte "registrieren" sich beim "zentralen" Objekt. Sobald dieses eine Änderung erfährt oder Informationen bereitstehen o.ä., wird das "zentrale" Objekt alle registrierten Objekte über den Aufruf einer Methode benachrichtigen. Dazu müssen diese eine gemeinsame Schnittstelle implementieren.

Das "zentrale" Objekt, welches abgefragt wird, nennt man "Observable" oder "Subject". Die Objekte, die die Information abfragen möchten, nennt man "Observer".

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Aufbau des Observer-Patterns (Beobachter-Entwurfsmusters)
  • (K3) Anwendung des Observer-Patterns auf konkrete Beispiele, etwa den PM-Dungeon

Verteilung der Prüfungsergebnisse

Die Studierenden möchten nach einer Prüfung wissen, ob für einen bestimmten Kurs die/ihre Prüfungsergebnisse im LSF bereit stehen.

Dazu modelliert man eine Klasse LSF und implementiert eine Abfragemethode, die dann alle Objekte regelmäßig aufrufen können. Dies sieht dann praktisch etwa so aus:

final Person[] persons = { new Lecturer("Frau Holle"),
                           new Student("Heinz"),
                           new Student("Karla"),
                           new Tutor("Kolja"),
                           new Student("Wuppie") };
final LSF lsf = new LSF();

for (Person p : persons) {
    lsf.getGradings(p, "My Module");   // ???!
}

Elegantere Lösung: Observer-Entwurfsmuster

Sie erstellen im LSF eine Methode register(), mit der sich interessierte Objekte beim LSF registrieren können.

Zur Benachrichtigung der registrierten Objekte brauchen diese eine geeignete Methode, die traditionell update() genannt wird.

Observer-Pattern verallgemeinert

Im vorigen Beispiel wurde die Methode update() einfach der gemeinsamen Basisklasse Person hinzugefügt. Normalerweise möchte man die Aspekte Person und Observer aber sauber trennen und definiert sich dazu ein separates Interface Observer mit der Methode update(), die dann alle "interessierten" Klassen (zusätzlich zur bestehenden Vererbungshierarchie) implementieren.

Die Klasse für das zu beobachtende Objekt benötigt dann eine Methode register(), mit der sich Observer registrieren können. Die Objektreferenzen werden dabei einfach einer internen Sammlung hinzugefügt.

Häufig findet sich dann noch eine Methode unregister(), mit der sich bereits registrierte Beobachter wieder abmelden können. Weiterhin findet man häufig eine Methode notifyObservers(), die man von außen auf dem beobachteten Objekt aufrufen kann und die dann auf allen registrierten Beobachtern deren Methoden update() aufruft. (Dieser Vorgang kann aber auch durch eine sonstige Zustandsänderung im beobachteten Objekt durchgeführt werden.)

In der Standarddefinition des Observer-Patterns nach [Gamma2011] werden beim Aufruf der Methode update() keine Werte an die Beobachter mitgegeben. Der Beobachter muss sich entsprechend eine eigene Referenz auf das beobachtete Objekt halten, um dort dann weitere Informationen erhalten zu können. Dies kann vereinfacht werden, indem das beobachtete Objekt beim Aufruf der update()-Methode die Informationen als Parameter mitgibt, beispielsweise eine Referenz auf sich selbst o.ä. ... Dies muss dann natürlich im Observer-Interface nachgezogen werden.

Hinweis: Es gibt in Swing bereits die Interfaces Observer und Observable, die aber als "deprecated" gekennzeichnet sind. Sinnvollerweise nutzen Sie nicht diese Interfaces aus Swing, sondern implementieren Ihre eigenen Interfaces, wenn Sie das Observer-Pattern einsetzen wollen!

Wrap-Up

Observer-Pattern: Benachrichtige registrierte Objekte über Statusänderungen

  • Interface Observer mit Methode update()
  • Interessierte Objekte
    1. implementieren das Interface Observer
    2. registrieren sich beim zu beobachtenden Objekt (Observable)
  • Beobachtetes Objekt ruft auf allen registrierten Objekten update() auf
  • update() kann auch Parameter haben
Challenges

Observer: Restaurant

Stellen Sie sich ein Restaurant vor, in welchem man nicht eine komplette Mahlzeit bestellt, sondern aus einzelnen Komponenten auswählen kann. Die Kunden bestellen also die gewünschten Komponenten, suchen sich einen Tisch und warten auf die Fertigstellung ihrer Bestellung. Da die Küche leider nur sehr klein ist, werden immer alle Bestellungen einer bestimmten Komponente zusammen bearbeitet - also beispielsweise werden alle bestellten Salate angerichtet oder die alle bestellten Pommes-Portionen zubereitet. Sobald eine solche Komponente fertig ist, werden alle Kunden aufgerufen, die diese Komponente bestellt haben ...

Modellieren Sie dies in Java. Nutzen Sie dazu das Observer-Pattern, welches Sie ggf. leicht anpassen müssen.

Observer: Einzel- und Großhandel

In den Vorgaben finden Sie ein Modell für eine Lieferkette zwischen Großhandel und Einzelhandel.

Wenn beim Einzelhändler eine Bestellung von einem Kunden eingeht (Einzelhandel#bestellen), speichert dieser den Auftrag zunächst in einer Liste ab. In regelmäßigen Abständen (Einzelhandel#loop) sendet der Einzelhändler die offenen Bestellungen an seinen Großhändler (Grosshandel#bestellen). Hat der Großhändler die benötigte Ware vorrätig, sendet er diese an den Einzelhändler (Einzelhandel#empfangen). Dieser kann dann den Auftrag gegenüber seinem Kunden erfüllen (keine Methode vorgesehen).

Anders als der Einzelhandel speichert der Großhandel keine Aufträge ab. Ist die benötigte Ware bei einer Bestellung also nicht oder nicht in ausreichender Zahl auf Lager, wird diese nicht geliefert und der Einzelhandel muss (später) eine neue Bestellung aufgeben.

Der Großhandel bekommt regelmäßig (Grosshandel#loop) neue Ware für die am wenigsten vorrätigen Positionen.

Im aktuellen Modell wird der Einzelhandel nicht über den neuen Lagerbestand des Großhändlers informiert und kann daher nur "zufällig" neue Bestellanfragen an den Großhändler senden.

Verbessern Sie das Modell, indem Sie das Observer-Pattern integrieren. Wer ist Observer? Wer ist Observable? Welche Informationen werden bei einem update mitgeliefert?

Bauen Sie in alle Aktionen vom Einzelhändler und vom Großhändler passendes Logging ein.

Anmerkung: Sie dürfen nur die Vorgaben-Klassen Einzelhandel und Grosshandel verändern, die anderen Vorgaben-Klassen dürfen Sie nicht bearbeiten. Sie können zusätzlich benötigte eigene Klassen/Interfaces implementieren.

Quellen

Template-Method-Pattern

TL;DR

Das Template-Method-Pattern ist ein Entwurfsmuster, bei dem ein gewisses Verhalten in einer Methode implementiert wird, die wie eine Schablone agiert, der sogenannten "Template-Methode". Darin werden dann u.a. Hilfsmethoden aufgerufen, die in der Basisklasse entweder als abstract markiert sind oder mit einem leeren Body implementiert sind ("Hook-Methoden"). Über diese Template-Methode legt also die Basisklasse ein gewisses Verhaltensschema fest ("Template") - daher auch der Name.

In den ableitenden Klassen werden dann die abstrakten Methoden und/oder die Hook-Methoden implementiert bzw. überschrieben und damit das Verhalten verfeinert.

Zur Laufzeit ruft man auf den Objekten die Template-Methode auf. Dabei wird von der Laufzeitumgebung der konkrete Typ der Objekte bestimmt (auch wenn man sie unter dem Typ der Oberklasse führt) und die am tiefsten in der Vererbungshierarchie implementierten Methoden aufgerufen. D.h. die Aufrufe der Hilfsmethoden in der Template-Methode führen zu den in der jeweiligen ableitenden Klasse implementierten Varianten.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K3) Template-Method-Entwurfsmuster praktisch anwenden

Motivation: Syntax-Highlighting im Tokenizer

In einem Compiler ist meist der erste Arbeitsschritt, den Eingabestrom in einzelne Token aufzubrechen. Dies sind oft die verschiedenen Schlüsselwörter, Operationen, Namen von Variablen, Methoden, Klassen etc. ... Aus der Folge von Zeichen (also dem eingelesenen Programmcode) wird ein Strom von Token, mit dem die nächste Stufe im Compiler dann weiter arbeiten kann.

public class Lexer {
    private final List<Token> allToken;  // alle verfügbaren Token-Klassen

    public List<Token> tokenize(String string) {
        List<Token> result = new ArrayList<>();

        while (string.length() > 0) {
            for (Token t : allToken) {
                Token token = t.match(string);
                if (token != null) {
                    result.add(token);
                    string = string.substring(token.getContent().length(), string.length());
                }
            }
        }

        return result;
    }
}

Dazu prüft man jedes Token, ob es auf den aktuellen Anfang des Eingabestroms passt. Wenn ein Token passt, erzeugt man eine Instanz dieser Token-Klasse und speichert darin den gematchten Eingabeteil, den man dann vom Eingabestrom entfernt. Danach geht man in die Schleife und prüft wieder alle Token ... bis irgendwann der Eingabestrom leer ist und man den gesamten eingelesenen Programmcode in eine dazu passende Folge von Token umgewandelt hat.

Anmerkung: Abgesehen von fehlenden Javadoc etc. hat das obige Code-Beispiel mehrere Probleme: Man würde im realen Leben nicht mit String, sondern mit einem Zeichenstrom arbeiten. Außerdem fehlt noch eine Fehlerbehandlung, wenn nämlich keines der Token in der Liste allToken auf den aktuellen Anfang des Eingabestroms passt.

Token-Klassen mit formatiertem Inhalt

Um den eigenen Tokenizer besser testen zu können, wurde beschlossen, dass jedes Token seinen Inhalt als formatiertes HTML-Schnipsel zurückliefern soll. Damit kann man dann alle erkannten Token formatiert ausgeben und erhält eine Art Syntax-Highlighting für den eingelesenen Programmcode.

public abstract class Token {
    protected String content;

    abstract protected String getHtml();
}
public class KeyWord extends Token {
    @Override
    protected String getHtml() {
        return "<font color=\"red\"><b>" +  this.content + "</b></font>";
    }
}
public class StringContent extends Token {
    @Override
    protected String getHtml() {
        return "<font color=\"green\">" +  this.content + "</font>";
    }
}


Token t = new KeyWord();
LOG.info(t.getHtml());

In der ersten Umsetzung erhält die Basisklasse Token eine weitere abstrakte Methode, die jede Token-Klasse implementieren muss und in der die Token-Klassen einen String mit dem Token-Inhalt und einer Formatierung für HTML zurückgeben.

Dabei fällt auf, dass der Aufbau immer gleich ist: Es werden ein oder mehrere Tags zum Start der Format-Sequenz mit dem Token-Inhalt verbunden, gefolgt mit einem zum verwendeten startenden HTML-Format-Tag passenden End-Tag.

Auch wenn die Inhalte unterschiedlich sind, sieht das stark nach einer Verletzung von DRY aus ...

Don't call us, we'll call you

public abstract class Token {
    protected String content;

    public final String getHtml() {
        return htmlStart() + this.content + htmlEnd();
    }

    abstract protected String htmlStart();
    abstract protected String htmlEnd();
}
public class KeyWord extends Token {
    @Override protected String htmlStart() { return "<font color=\"red\"><b>"; }
    @Override protected String htmlEnd() { return "</b></font>"; }
}
public class StringContent extends Token {
    @Override protected String htmlStart() { return "<font color=\"green\">"; }
    @Override protected String htmlEnd() { return "</font>"; }
}


Token t = new KeyWord();
LOG.info(t.getHtml());

Wir können den Spaß einfach umdrehen ("inversion of control") und die Methode zum Zusammenbasteln des HTML-Strings bereits in der Basisklasse implementieren. Dazu "rufen" wir dort drei Hilfsmethoden auf, die die jeweiligen Bestandteile des Strings (Format-Start, Inhalt, Format-Ende) erzeugen und deren konkrete Implementierung wir in der Basisklasse nicht kennen. Dies ist dann Sache der ableitenden konkreten Token-Klassen.

Objekte vom Typ KeyWord sind dank der Vererbungsbeziehung auch Token (Vererbung: is-a-Beziehung). Wenn man nun auf einem Token t die Methode getHtml() aufruft, wird zur Laufzeit geprüft, welchen Typ t tatsächlich hat (im Beispiel KeyWord). Methodenaufrufe werden dann mit den am tiefsten in der vorliegenden Vererbungshierarchie implementierten Methoden durchgeführt: Hier wird also die von Token geerbte Methode getHtml() in KeyWord aufgerufen, die ihrerseits die Methoden htmlStart() und htmlEnd() aufruft. Diese sind in KeyWord implementiert und liefern nun die passenden Ergebnisse.

Die Methode getHtml() wird auch als "Template-Methode" bezeichnet. Die beiden darin aufgerufenen Methoden htmlStart() und htmlEnd() in Token werden auch als "Hilfsmethoden" (oder "Helper Methods") bezeichnet.

Dies ist ein Beispiel für das Template-Method-Pattern.

Template-Method-Pattern

Aufbau Template-Method-Pattern

In der Basisklasse implementiert man eine Template-Methode (in der Skizze templateMethod), die sich auf anderen in der Basisklasse deklarierten (Hilfs-) Methoden "abstützt" (diese also aufruft; in der Skizze method1, method2, method3). Diese Hilfsmethoden können als abstract markiert werden und müssen dann von den ableitenden Klassen implementiert werden (in der Skizze method1 und method2). Man kann aber auch einige/alle dieser aufgerufenen Hilfsmethoden in der Basisklasse implementieren (beispielsweise mit einem leeren Body - sogenannte "Hook"-Methoden) und die ableitenden Klassen können dann diese Methoden überschreiben und das Verhalten so neu formulieren (in der Skizze method3).

Damit werden Teile des Verhaltens an die ableitenden Klassen ausgelagert.

Verwandtschaft zum Strategy-Pattern

Das Template-Method-Pattern hat eine starke Verwandtschaft zum Strategy-Pattern.

Im Strategy-Pattern haben wir Verhalten komplett an andere Objekte delegiert, indem wir in einer Methode einfach die passende Methode auf dem übergebenen Strategie-Objekt aufgerufen haben.

Im Template-Method-Pattern nutzen wir statt Delegation die Mechanismen Vererbung und dynamische Polymorphie und definieren in der Basis-Klasse abstrakte oder Hook-Methoden, die wir bereits in der Template-Methode der Basis-Klasse aufrufen. Damit ist das grobe Verhalten in der Basis-Klasse festgelegt, wird aber in den ableitenden Klassen durch das dortige Definieren oder Überschreiben der Hilfsmethoden verfeinert. Zur Laufzeit werden dann durch die dynamische Polymorphie die tatsächlich implementierten Hilfsmethoden in den ableitenden Klassen aufgerufen. Damit lagert man im Template-Method-Pattern gewissermaßen nur Teile des Verhaltens an die ableitenden Klassen aus.

Wrap-Up

Template-Method-Pattern: Verhaltensänderung durch Vererbungsbeziehungen

  • Basis-Klasse:
    • Template-Methode, die Verhalten definiert und Hilfsmethoden aufruft
    • Hilfsmethoden: Abstrakte Methoden (oder "Hook": Basis-Implementierung)
  • Ableitende Klassen: Verfeinern Verhalten durch Implementieren der Hilfsmethoden
  • Zur Laufzeit: Dynamische Polymorphie: Aufruf der Template-Methode nutzt die im tatsächlichen Typ des Objekts implementierten Hilfsmethoden
Challenges

Schreiben Sie eine abstrakte Klasse Drucker. Implementieren Sie die Funktion kopieren, bei der zuerst die Funktion scannen und dann die Funktion drucken aufgerufen wird. Der Kopiervorgang ist für alle Druckertypen identisch, das Scannen und Drucken ist abhängig vom Druckertyp.

Implementieren Sie zusätzlich zwei unterschiedliche Druckertypen:

  • Tintendrucker extends Drucker
    • Tintendrucker#drucken loggt den Text "Drucke das Dokument auf dem Tintendrucker."
    • Tintendrucker#scannen loggt den Text "Scanne das Dokument mit dem Tintendrucker."
  • Laserdrucker extends Drucker
    • Laserdrucker#drucken loggt den Text "Drucke das Dokument auf dem Laserdrucker."
    • Laserdrucker#scannen loggt den Text "Scanne das Dokument mit dem Laserdrucker."

Nutzen Sie das Template-Method-Pattern.

Quellen
  • [Eilebrecht2013] Patterns kompakt
    Eilebrecht, K. und Starke, G., Springer, 2013. ISBN 978-3-6423-4718-4.
  • [Gamma2011] Design Patterns
    Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0.

Command-Pattern

TL;DR

Das Command-Pattern ist die objektorientierte Antwort auf Callback-Funktionen: Man kapselt Befehle in einem Objekt.

  1. Die Command-Objekte haben eine Methode execute() und führen dabei Aktion auf einem bzw. "ihrem" Receiver aus.

  2. Receiver sind Objekte, auf denen Aktionen ausgeführt werden, im Dungeon könnten dies etwa Hero, Monster, ... sein. Receiver müssen keine der anderen Akteure in diesem Pattern kennen.

  3. Damit die Command-Objekte aufgerufen werden, gibt es einen Invoker, der Command-Objekte hat und zu gegebener Zeit auf diesen die Methode execute() aufruft. Der Invoker muss dabei die konkreten Kommandos und die Receiver nicht kennen (nur die Command-Schnittstelle).

  4. Zusätzlich gibt es einen Client, der die anderen Akteure kennt und alles zusammen baut.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Aufbau des Command-Patterns
  • (K3) Anwendung des Command-Patterns auf konkrete Beispiele, etwa den PM-Dungeon

Motivation

Irgendwo im Dungeon wird es ein Objekt einer Klasse ähnlich wie InputHandler geben mit einer Methode ähnlich zu handleInput():

public class InputHandler {
    public void handleInput() {
        switch (keyPressed()) {
            case BUTTON_W -> hero.jump();
            case BUTTON_A -> hero.moveX();
            case ...
            default -> { ... }
        }
    }
}

Diese Methode wird je Frame einmal aufgerufen, um auf eventuelle Benutzereingaben reagieren zu können. Je nach gedrücktem Button wird auf dem Hero eine bestimmte Aktion ausgeführt ...

Das funktioniert, ist aber recht unflexibel. Die Aktionen sind den Buttons fest zugeordnet und erlauben keinerlei Konfiguration.

Auflösen der starren Zuordnung über Zwischenobjekte

public interface Command { void execute(); }

public class Jump implements Command {
    private Entity e;
    public void execute() { e.jump(); }
}

public class InputHandler {
    private final Command wbutton = new Jump(hero);  // Über Ctor/Methoden setzen!
    private final Command abutton = new Move(hero);  // Über Ctor/Methoden setzen!

    public void handleInput() {
        switch (keyPressed()) {
            case BUTTON_W -> wbutton.execute();
            case BUTTON_A -> abutton.execute();
            case ...
            default -> { ... }
        }
    }
}

Die starre Zuordnung "Button : Aktion" wird aufgelöst und über Zwischenobjekte konfigurierbar gemacht.

Für die Zwischenobjekte wird ein Typ Command eingeführt, der nur eine execute()-Methode hat. Für jede gewünschte Aktion wird eine Klasse davon abgeleitet, diese Klassen können auch einen Zustand pflegen.

Den Buttons wird nun an geeigneter Stelle (Konstruktor, Methoden, ...) je ein Objekt der jeweiligen Command-Unterklassen zugeordnet. Wenn ein Button betätigt wird, wird auf dem Objekt die Methode execute() aufgerufen.

Damit die Kommandos nicht nur auf den Helden wirken können, kann man den Kommando-Objekten beispielsweise noch eine Entität mitgeben, auf der das Kommando ausgeführt werden soll. Im Beispiel oben wurde dafür der hero genutzt.

Command: Objektorientierte Antwort auf Callback-Funktionen

Im Command-Pattern gibt es vier beteiligte Parteien: Client, Receiver, Command und Invoker.

Ein Command ist die objektorientierte Abstraktion eines Befehls. Es hat möglicherweise einen Zustand, und und kennt "seinen" Receiver und kann beim Aufruf der execute()-Methode eine vorher verabredete Methode auf diesem Receiver-Objekt ausführen.

Ein Receiver ist eine Klasse, die Aktionen durchführen kann. Sie kennt die anderen Akteure nicht.

Der Invoker (manchmal auch "Caller" genannt) ist eine Klasse, die Commands aggregiert und die die Commandos "ausführt", indem hier die execute()-Methode aufgerufen wird. Diese Klasse kennt nur das Command-Interface und keine spezifischen Kommandos (also keine der Sub-Klassen). Es kann zusätzlich eine gewisse Buchführung übernehmen, etwa um eine Undo-Funktionalität zu realisieren.

Der Client ist ein Programmteil, der ein Command-Objekt aufbaut und dabei einen passenden Receiver übergibt und der das Command-Objekt dann zum Aufruf an den Invoker weiterreicht.

In unserem Beispiel lassen sich die einzelnen Teile so sortieren:

  • Client: Klasse InputHandler (erzeugt neue Command-Objekte im obigen Code) bzw. main(), wenn man die Command-Objekte dort erstellt und an den Konstruktor von InputHandler weiterreicht
  • Receiver: Objekt hero der Klasse Hero (auf diesem wird eine Aktion ausgeführt)
  • Command: Jump und Move
  • Invoker: InputHandler (in der Methode handleInput())

Undo

Wir könnten das Command-Interface um ein paar Methoden erweitern:

public interface Command {
    void execute();
    void undo();
    Command newCommand(Entity e);
}

Jetzt kann jedes Command-Objekt eine neue Instanz erzeugen mit der Entity, die dann dieses Kommando empfangen soll:

public class Move implements Command {
    private Entity e;
    private int x, y, oldX, oldY;

    public void execute() { oldX = e.getX();  oldY = e.getY();  x = oldX + 42;  y = oldY;  e.moveTo(x, y); }
    public void undo() { e.moveTo(oldX, oldY); }
    public Command newCommand(Entity e) { return new Move(e); }
}

public class InputHandler {
    private final Command wbutton;
    private final Command abutton;
    private final Stack<Command> s = new Stack<>();

    public void handleInput() {
        Entity e = getSelectedEntity();
        switch (keyPressed()) {
            case BUTTON_W -> { s.push(wbutton.newCommand(e)); s.peek().execute(); }
            case BUTTON_A -> { s.push(abutton.newCommand(e)); s.peek().execute(); }
            case BUTTON_U -> s.pop().undo();
            case ...
            default -> { ... }
        }
    }
}

Über den Konstruktor von InputHandler (im Beispiel nicht gezeigt) würde man wie vorher die Command-Objekte für die Buttons setzen. Es würde aber in jedem Aufruf von handleInput() abgefragt, was gerade die selektierte Entität ist und für diese eine neue Instanz des zur Tastatureingabe passenden Command-Objekts erzeugt. Dieses wird nun in einem Stack gespeichert und danach ausgeführt.

Wenn der Button "U" gedrückt wird, wird das letzte Command-Objekt aus dem Stack genommen (Achtung: Im echten Leben müsste man erst einmal schauen, ob hier noch was drin ist!) und auf diesem die Methode undo() aufgerufen. Für das Kommando Move ist hier skizziert, wie ein Undo aussehen könnte: Man muss einfach bei jedem execute() die alte Position der Entität speichern, dann kann man sie bei einem undo() wieder auf diese Position verschieben. Da für jeden Move ein neues Objekt angelegt wird und dieses nur einmal benutzt wird, braucht man keine weitere Buchhaltung ...

Wrap-Up

Command-Pattern: Kapsele Befehle in ein Objekt

  • Command-Objekte haben eine Methode execute() und führen darin Aktion auf Receiver aus
  • Receiver sind Objekte, auf denen Aktionen ausgeführt werden (Hero, Monster, ...)
  • Invoker hat Command-Objekte und ruft darauf execute() auf
  • Client kennt alle und baut alles zusammen

Objektorientierte Antwort auf Callback-Funktionen

Challenges

Schreiben Sie für den Dwarf in den Vorgaben einen Controller, welcher das Command-Pattern verwendet.

  • "W" führt Springen aus
  • "A" bewegt den Zwerg nach links
  • "D" bewegt den Zwerg nach rechts
  • "S" führt Ducken aus

Schreiben Sie zusätzlich für den Cursor einen Controller, welcher das Command-Pattern mit Historie erfüllt (ebenfalls über die Tasten "W", "A", "S" und "D").

Schreiben Sie eine Demo, um die Funktionalität Ihres Programmes zu demonstrieren.

Quellen

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

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

Schreiben Sie eine Methode, die mit Hilfe von regulären Ausdrücken überprüft, ob der eingegebene String eine nach dem folgenden Schema gebildete EMail-Adresse ist:

name@firma.domain

Dabei sollen folgende Regeln gelten:

  • Die Bestandteile name und firma können aus Buchstaben, Ziffern, Unter- und Bindestrichen bestehen.
  • Der Bestandteil name muss mindestens ein Zeichen lang sein.
  • Der Bestandteil firma kann entfallen, dann entfällt auch der nachfolgende Punkt (.) und der Teil domain folgt direkt auf das @-Zeichen.
  • Der Bestandteil domain besteht aus 2 oder 3 Kleinbuchstaben.

Hinweis: Sie dürfen keinen Oder-Operator verwenden.

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

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
Challenges

Im Dungeon sollen Key-Codes aus libGDX (Integer-Werte) als Konstanten zugreifbar sein. Zusätzlich soll es es noch einen String geben, der beschreibt, wo und wie diese Taste im Spiel eingesetzt wird. Aus historischen Gründen ist dies im Dungeon recht komplex gelöst.

Definieren Sie eine neue Enum-Klasse, die Konstanten für Tasten aufzählt (beispielsweise ESCAPE, W, A oder LEFT). Jede dieser Konstanten soll den der Taste zugeordneten Integerwert speichern können und einen String haben, der als Hilfestring verstanden werden könnte (nutzen Sie hier einfach Phantasiewerte). Zeigen Sie in einer kleinen Demo, wie Sie mit diesem Enum arbeiten würden: Zugriff auf die Konstanten, Zugriff auf den Zahlenwert und/oder den String, Übergabe als Funktionsparameter.

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

Subsections of Multi-Threading: Parallelisierung von Programmen

Einführung in die nebenläufige Programmierung mit Threads

TL;DR

Threads sind weitere Kontrollflussfäden, die von der Java-VM (oder (selten) vom OS) verwaltet werden. Damit ist sind sie leichtgewichtiger als der Start neuer Prozesse direkt auf Betriebssystem-Ebene.

Beim Start eines Java-Programms wird die main()-Methode automatisch in einem (Haupt-) Thread ausgeführt. Alle Anweisungen in einem Thread werden sequentiell ausgeführt.

Um einen neuen Thread zu erzeugen, leitet man von Thread ab oder implementiert das Interface Runnable. Von diesen eigenen Klassen kann man wie üblich ein neues Objekt anlegen. Die Methode run() enthält dabei den im Thread auszuführenden Code. Um einen Thread als neuen parallelen Kontrollfluss zu starten, muss man die geerbte Methode start() auf dem Objekt aufrufen. Im Fall der Implementierung von Runnable muss man das Objekt zuvor noch in den Konstruktor von Thread stecken und so ein neues Thread-Objekt erzeugen, auf dem man dann start() aufrufen kann.

Threads haben einen Lebenszyklus: Nach dem Erzeugen der Objekte mit new wird der Thread noch nicht ausgeführt. Durch den Aufruf der Methode start() gelangt der Thread in einen Zustand "ausführungsbereit". Sobald er vom Scheduler eine Zeitscheibe zugeteilt bekommt, wechselt er in den Zustand "rechnend". Von hier kann er nach Ablauf der Zeitscheibe durch den Scheduler wieder nach "ausführungsbereit" zurück überführt werden. Dieses Wechselspiel passiert automatisch und i.d.R. schnell, so dass selbst auf Maschinen mit nur einem Prozessor/Kern der Eindruck einer parallelen Verarbeitung entsteht. Nach Abarbeitung der run()-Methode wird der Thread beendet und kann nicht wieder neu gestartet werden. Bei Zugriff auf gesperrte Ressourcen oder durch sleep() oder join() kann ein Thread blockiert werden. Aus diesem Zustand gelangt er durch Interrupts oder nach Ablauf der Schlafzeit oder durch notify wieder zurück nach "ausführungsbereit".

Die Thread-Objekte sind normale Java-Objekte. Man kann hier Attribute und Methoden haben und diese entsprechend zugreifen/aufrufen. Das klappt auch, wenn der Thread noch nicht gestartet wurde oder bereits abgearbeitet wurde.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Grundsätzlicher Unterschied zw. Threads und Prozessen
  • (K2) Lebenszyklus von Threads
  • (K3) Erzeugen und Starten von Threads
  • (K3) Kommunikation mit Objekten

42

Einführung in nebenläufige Programmierung

Traditionelle Programmierung

  • Aufruf einer Methode verlagert Kontrollfluss in diese Methode
  • Code hinter Methodenaufruf wird erst nach Beendigung der Methode ausgeführt
public class Traditional {
    public static void main(String... args) {
        Traditional x = new Traditional();

        System.out.println("main(): vor run()");
        x.run();
        System.out.println("main(): nach run()");
    }

    public void run() {
        IntStream.range(0, 10).mapToObj(i -> "in run()").forEach(System.out::println);
    }
}

Nebenläufige Programmierung

  • Erzeugung eines neuen Kontrollflussfadens (Thread)
    • Läuft (quasi-) parallel zu bisherigem Kontrollfluss
  • Threads können unabhängig von einander arbeiten
  • Zustandsverwaltung durch Java-VM (oder Unterstützung durch Betriebssystem)
    • Aufruf einer bestimmten Methode erzeugt neuen Kontrollflussfaden
    • Der neue Thread arbeitet "parallel" zum bisherigen Thread
    • Kontrolle kehrt sofort wieder zurück: Code hinter dem Methodenaufruf wird ausgeführt ohne auf die Beendigung der aufgerufenen Methode zu warten
    • Verteilung der Threads auf die vorhandenen Prozessorkerne abhängig von der Java-VM
public class Threaded extends Thread {
    public static void main(String... args) {
        Threaded x = new Threaded();

        System.out.println("main(): vor run()");
        x.start();
        System.out.println("main(): nach run()");
    }

    @Override
    public void run() {
        IntStream.range(0, 10).mapToObj(i -> "in run()").forEach(System.out::println);
    }
}

Erzeugen von Threads

  • Ableiten von Thread oder Implementierung von Runnable

  • Methode run() implementieren, aber nicht aufrufen

  • Methode start() aufrufen, aber (i.d.R.) nicht implementieren

Ableiten von Thread

  • start() startet den Thread und sorgt für Ausführung von run()
  • start() nur einmal aufrufen

Implementierung von Runnable

  • Ebenfalls run() implementieren
  • Neues Thread-Objekt erzeugen, Konstruktor das eigene Runnable übergeben
  • Für Thread-Objekt die Methode start() aufrufen
    • Startet den Thread (das Runnable) und sorgt für Ausführung von run()

Vorteil von Runnable: Ist ein Interface, d.h. man kann noch von einer anderen Klasse erben

Zustandsmodell von Threads (vereinfacht)

Threads haben einen Lebenszyklus: Nach dem Erzeugen der Objekte mit new wird der Thread noch nicht ausgeführt. Er ist sozusagen in einem Zustand "erzeugt". Man kann bereits mit dem Objekt interagieren, also auf Attribute zugreifen und Methoden aufrufen.

Durch den Aufruf der Methode start() gelangt der Thread in einen Zustand "ausführungsbereit", er läuft also aus Nutzersicht. Allerdings hat er noch keine Ressourcen zugeteilt (CPU, ...), so dass er tatsächlich noch nicht rechnet. Sobald er vom Scheduler eine Zeitscheibe zugeteilt bekommt, wechselt er in den Zustand "rechnend" und führt den Inhalt der run()-Methode aus. Von hier kann er nach Ablauf der Zeitscheibe durch den Scheduler wieder nach "ausführungsbereit" zurück überführt werden. Dieses Wechselspiel passiert automatisch und i.d.R. schnell, so dass selbst auf Maschinen mit nur einem Prozessor/Kern der Eindruck einer parallelen Verarbeitung entsteht.

Nach der Abarbeitung der run()-Methode oder bei einer nicht gefangenen Exception wird der Thread beendet und kann nicht wieder neu gestartet werden. Auch wenn der Thread abgelaufen ist, kann man mit dem Objekt wie üblich interagieren (nur eben nicht mehr parallel).

Bei Zugriff auf gesperrte Ressourcen oder durch Aufrufe von Methoden wie sleep() oder join() kann ein Thread blockiert werden. Hier führt der Thread nichts aus, bekommt durch den Scheduler aber auch keine neue Zeitscheibe zugewiesen. Aus diesem Zustand gelangt der Thread wieder heraus, etwa durch Interrupts (Aufruf der Methode interrupt() auf dem Thread-Objekt) oder nach Ablauf der Schlafzeit (in sleep()) oder durch ein notify, und wird wieder zurück nach "ausführungsbereit" versetzt und wartet auf die Zuteilung einer Zeitscheibe durch den Scheduler.

Sie finden in [Boles2008, Kapitel 5.2 "Thread-Zustände"] eine schöne ausführliche Darstellung.

Threads können wie normale Objekte kommunizieren

  • Zugriff auf (public) Attribute (oder eben über Methoden)
  • Aufruf von Methoden

Threads können noch mehr

  • Eine Zeitlang schlafen: Thread.sleep(<duration_ms>)

    • Statische Methode der Klasse Thread (Klassenmethode)
    • Aufrufender Thread wird bis zum Ablauf der Zeit oder bis zum Aufruf der interrupt()-Methode des Threads blockiert
    • "Moderne" Alternative: TimeUnit, beispielsweise TimeUnit.SECONDS.sleep( 2 );
  • Prozessor abgeben und hinten in Warteschlange einreihen: yield()

  • Andere Threads stören: otherThreadObj.interrupt()

    • Die Methoden sleep(), wait() und join() im empfangenden Thread otherThreadObj lösen eine InterruptedException aus, wenn sie durch die Methode interrupt() unterbrochen werden. Das heißt, interrupt() beendet diese Methoden mit der Ausnahme.
    • Empfangender Thread verlässt ggf. den Zustand "blockiert" und wechselt in den Zustand "ausführungsbereit"
  • Warten auf das Ende anderer Threads: otherThreadObj.join()

    • Ausführender Thread wird blockiert (also nicht otherThreadObj!)
    • Blockade des Aufrufers wird beendet, wenn der andere Thread (otherThreadObj) beendet wird.

Hinweis: Ein Thread wird beendet, wenn

  • die run()-Methode normal endet, oder
  • die run()-Methode durch eine nicht gefangene Exception beendet wird, oder
  • von außen die Methode stop() aufgerufen wird (Achtung: Deprecated! Einen richtigen Ersatz gibt es aber auch nicht.).

Hinweis: Die Methoden wait(), notify()/notifyAll() und die "synchronized-Sperre" werden in der Sitzung ["Threads: Synchronisation"](threads-intro. besprochen.

Wrap-Up

Threads sind weitere Kontrollflussfäden, von Java-VM (oder (selten) von OS) verwaltet

  • Ableiten von Thread oder implementieren von Runnable
  • Methode run enthält den auszuführenden Code
  • Starten des Threads mit start (nie mit run!)
Quellen

Synchronisation: Verteilter Zugriff auf gemeinsame Ressourcen

TL;DR

Bei verteiltem Zugriff auf gemeinsame Ressourcen besteht Synchronisierungsbedarf, insbesondere sollten nicht mehrere Threads gleichzeitig geteilte Daten modifizieren. Dazu kommt das Problem, dass ein Thread in einer komplexen Folge von Aktionen die Zeitscheibe verlieren kann und dann später mit veralteten Daten weiter macht.

Um den Zugriff auf gemeinsame Ressourcen oder den Eintritt in kritische Bereiche zu schützen und zu synchronisieren, kann man diese Zugriffe oder Bereiche in einen synchronized-Block legen. Dazu benötigt man noch ein beliebiges (gemeinsam sichtbares) Objekt, welches als Wächter- oder Sperr-Objekt fungiert. Beim Eintritt in den geschützten Block muss ein Thread einen Lock auf dem Sperr-Objekt erlangen. Hat bereits ein anderer Thread den Lock, wird der neue Thread so lange blockiert, bis der Lock wieder "frei" ist. Beim Eintritt in den Bereich wird dann durch den Thread auf dem Sperr-Objekt der Lock gesetzt und beim Austritt automatisch wieder aufgehoben. Dies nennt man auch mehrseitige Synchronisierung (mehrere Threads "stimmen" sich quasi untereinander über den Zugriff auf eine Ressource ab).

Um auf den Eintritt eines Ereignisses oder die Erfüllung einer Bedingung zu warten, kann man wait und notify nutzen. In einem synchronized-Block prüft man, ob die Bedingung erfüllt oder ein Ereignis eingetreten ist, und falls ja arbeitet man damit normal weiter. Falls die Bedingung nicht erfüllt ist oder das Ereignis nicht eingetreten ist, kann man auf dem im synchronized-Block genutzten Sperr-Objekt die Methode wait() aufrufen. Damit wird der Thread in die entsprechende Schlange auf dem Sperr-Objekt eingereiht und blockiert. Zusätzlich wird der Lock auf dem Sperr-Objekt freigegeben. Zum "Aufwecken" nutzt man an geeigneter Stelle auf dem selben Sperr-Objekt die Methode notify() oder notifyALl() (erstere weckt einen in der Liste des Sperr-Objekts wartenden Thread, die letztere alle). Nach dem Aufwachen macht der Thread nach seinem wait() weiter. Es ist also wichtig, dass die Bedingung, wegen der ursprünglich das wait() aufgerufen wurde, erneut abgefragt wird und ggf. erneut in das wait() gegangen wird. Dies nennt man einseitige Synchronisierung.

Es gibt darüber hinaus viele weitere Mechanismen und Probleme, die aber den Rahmen dieser Lehrveranstaltung deutlich übersteigen. Diese werden teilweise in den Veranstaltungen "Betriebssysteme" und/oder "Verteilte Systeme" besprochen.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Notwendigkeit zur Synchronisation
  • (K2) Unterscheidung einseitige und mehrseitige Synchronisation
  • (K3) Synchronisation mit synchronized, wait, notify und notifyAll

Motivation: Verteilter Zugriff auf gemeinsame Ressourcen

public class Teaser implements Runnable {
    private int val = 0;

    public static void main(String... args) {
        Teaser x = new Teaser();
        new Thread(x).start();
        new Thread(x).start();
    }

    private void incrVal() {
        ++val;
        System.out.println(Thread.currentThread().getId() + ": " + val);
    }

    public void run() {
        IntStream.range(0, 5).forEach(i -> incrVal());
    }
}

Zugriff auf gemeinsame Ressourcen: Mehrseitige Synchronisierung

synchronized (<Object reference>) {
    <statements (synchronized)>
}

=> "Mehrseitige Synchronisierung"

Fallunterscheidung: Thread T1 führt synchronized-Anweisung aus:

  • Sperre im Sperr-Objekt nicht gesetzt:
    1. T1 setzt Sperre beim Eintritt,
    2. führt den Block aus, und
    3. löst Sperre beim Verlassen
  • Sperre durch T1 gesetzt:
    1. T1 führt den Block aus, und
    2. löst Sperre beim Verlassen nicht
  • Sperre durch T2 gesetzt: => T1 wird blockiert, bis T2 die Sperre löst

Anmerkung: Das für die Synchronisierung genutzte Objekt nennt man "Wächter-Objekt" oder auch "Sperr-Objekt" oder auch "Synchronisations-Objekt".

Damit könnte man den relevanten Teil der Methode incrVal() beispielsweise in einen geschützten Bereich einschließen und als Sperr-Objekt das eigene Objekt (this) einsetzen:

    private void incrVal() {
        synchronized (this) { ++val; }
    }

Synchronisierte Methoden

void f() {
    synchronized (this) {
        ...
    }
}

... ist äquivalent zu ...

synchronized void f() {
    ...
}

Kurzschreibweise: Man spart das separate Wächter-Objekt und synchronisiert auf sich selbst ...

Die Methode incrVal() könnte entsprechend so umgeschrieben werden:

    private synchronized void incrVal() {
        ++val;
    }

Probleme bei der (mehrseitigen) Synchronisierung: Deadlocks

public class Deadlock {
    private final String name;

    public synchronized String getName() { return name; }
    public synchronized void foo(Deadlock other) {
        System.out.format("%s: %s.foo() \n", Thread.currentThread().getName(), name);
        System.out.format("%s: %s.name()\n", Thread.currentThread().getName(), other.getName());
    }

    public static void main(String... args) {
        final Deadlock a = new Deadlock("a");
        final Deadlock b = new Deadlock("b");

        new Thread(() -> a.foo(b)).start();
        new Thread(() -> b.foo(a)).start();
    }
}

Viel hilft hier nicht viel! Durch zu großzügige mehrseitige Synchronisierung kann es passieren, dass Threads gegenseitig aufeinander warten: Thread A belegt eine Ressource, die ein anderer Thread B haben möchte und Thread B belegt eine Ressource, die A gerne bekommen würde. Da es dann nicht weitergeht, nennt man diese Situation auch "Deadlock" ("Verklemmung").

Im Beispiel ruft der erste Thread für das Objekt a die foo()-Methode auf und holt sich damit den Lock auf a. Um die Methode beenden zu können, muss noch die getName()-Methode vom Objekt b durch diesen ersten Thread aufgerufen werden. Dafür muss der erste Thread den Lock auf b bekommen.

Dummerweise hat parallel der zweite Thread auf dem Objekt b die foo()-Methode aufgerufen und sich damit den Lock auf b geholt. Damit muss der erste Thread so lange warten, bis der zweite Thread den Lock auf b freigibt.

Das wird allerdings nicht passieren, da der zweite Thread zur Beendigung der foo()-Methode noch getName() auf a ausführen muss und dazu den Lock auf b holen, den aber aktuell der erste Thread hält.

Und schon geht's nicht mehr weiter :-)

Warten auf andere Threads: Einseitige Synchronisierung

Problem

  • Thread T1 wartet auf Arbeitsergebnis von T2
  • T2 ist noch nicht fertig

Mögliche Lösungen

  1. Aktives Warten (Polling): Permanente Abfrage
    • Kostet unnötig Rechenzeit
  2. Schlafen mit Thread.sleep()
    • Etwas besser; aber wie lange soll man idealerweise schlafen?
  3. Warten mit T2.join()
    • Macht nur Sinn, wenn T1 auf das Ende von T2 wartet
  4. Einseitige Synchronisierung mit wait() und notify()
    • Das ist DIE Lösung für das Problem :)

Einseitige Synchronisierung mit wait und notify

  • wait: Warten auf Erfüllung einer Bedingung (Thread blockiert):

    synchronized (obj) {    // Geschützten Bereich betreten
        while (!condition) {
            try {
                obj.wait(); // Thread wird blockiert
            } catch (InterruptedException e) {}
        }
        ...     // Condition erfüllt: Tue Deine Arbeit
    }

    => Bedingung nach Rückkehr von wait erneut prüfen!

Eigenschaften von wait

  • Thread ruft auf Synchronisations-Objekt die Methode wait auf
  • Prozessor wird entzogen, Thread blockiert
  • Thread wird in interne Warteschlange des Synchronisations-Objekts eingetragen
  • Sperre auf Synchronisations-Objekt wird freigegeben

=> Geht nur innerhalb der synchronized-Anweisung für das Synchronisations-Objekt!

Einseitige Synchronisierung mit wait und notify (cnt.)

  • notify: Aufwecken von wartenden (blockierten) Threads:

    synchronized (obj) {
        obj.notify();       // einen Thread "in" obj aufwecken
        obj.notifyAll();    // alle Threads "in" obj wecken
    }

Eigenschaften von notify bzw. notifyAll

  • Thread ruft auf einem Synchronisations-Objekt die Methode notify oder notifyAll auf
  • Falls Thread(s) in Warteschlange des Objekts vorhanden, dann
    • notify: Ein zufälliger Thread wird aus Warteschlange entfernt und in den Zustand "ausführungsbereit" versetzt
    • notifyAll: Alle Threads werden aus Warteschlange entfernt und in den Zustand "ausführungsbereit" versetzt

=> Geht nur innerhalb der synchronized-Anweisung für das Synchronisations-Objekt!

Wrap-Up

Synchronisierungsbedarf bei verteiltem Zugriff auf gemeinsame Ressourcen:

  • Vorsicht mit konkurrierendem Ressourcenzugriff: Synchronisieren mit synchronized => Mehrseitige Synchronisierung

  • Warten auf Ereignisse mit wait und notify/notifyAll => Einseitige Synchronisierung

Challenges

Hamster-Welt

In den Vorgaben finden Sie eine Modellierung für eine Hamsterwelt.

Es gibt rote und blaue Hamster, die sich unabhängig von einander bewegen können. Es gibt einen Tunnel, den die Hamster betreten und durchqueren können. In der Vorgabe ist ein kleines Hauptprogramm enthalten, welches einige Hamster anlegt und herumlaufen lässt.

Teil I: Stau im Tunnel

Die Hamster sind sehr neugierig und wollen gern durch den Tunnel gehen, um die Höhle auf der anderen Seite zu erkunden. Leider mussten sie feststellen, dass immer nur ein Hamster zu einem Zeitpunkt im Tunnel sein darf, sonst wird die Luft zu knapp.

Ergänzen Sie die Vorgaben, so dass sich immer nur ein paralleler Hamster (egal welcher Farbe) im Tunnel aufhalten kann. Wenn ein Hamster in den Tunnel will, aber nicht hinein kann, dann soll er am Eingang warten, also nicht noch einmal in seiner Höhle herumlaufen. (Das passiert eigentlich automatisch, wenn Sie alles richtig machen.)

Teil II: Schlaue Hamster

Die Hamster sind schlau und haben bemerkt, dass die Einschränkung aus der letzten Aufgabe zu stark war. Sie überleben auch, wenn sich beliebig viele blaue Hamster oder nur genau ein roter Hamster im Tunnel aufhalten.

Erweitern Sie die Implementierung aus der letzten Aufgabe, so dass folgende Bedingungen eingehalten werden:

  • Es dürfen sich beliebig viele blaue Hamster gleichzeitig im Tunnel befinden.

    Das bedeutet, dass in diesem Fall zwar weitere blaue Hamster den Tunnel betreten dürfen, aber kein roter Hamster in den Tunnel hinein darf.

  • Wenn sich ein roter Hamster im Tunnel aufhält, dürfen keine anderen Hamster (unabhängig von deren Farbe) den Tunnel betreten.

Quellen

High-Level Concurrency

TL;DR

Das Erzeugen von Threads über die Klasse Thread oder das Interface Runnable und das Hantieren mit synchronized und wait()/notify() zählt zu den grundlegenden Dingen beim Multi-Threading mit Java. Auf diesen Konzepten bauen viele weitere Konzepte auf, die ein flexibleres Arbeiten mit Threads in Java ermöglichen.

Dazu zählt unter anderem das Arbeiten mit Lock-Objekten und dazugehörigen Conditions, was synchronized und wait()/notify() entspricht, aber feingranulareres und flexibleres Locking bietet.

Statt Threads immer wieder neu anzulegen (das Anlegen von Objekten bedeutet einen gewissen Aufwand zur Laufzeit), kann man Threads über sogenannte Thread-Pools wiederverwenden und über das Executor-Interface benutzen.

Schließlich bietet sich das Fork/Join-Framework zum rekursiven Zerteilen von Aufgaben und zur parallelen Bearbeitung der Teilaufgaben an.

Die in Swing integrierte Klasse SwingWorker ermöglicht es, in Swing Berechnungen in einen parallel ausgeführten Thread auszulagern.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Umgang mit High-Level-Abstraktionen: Lock-Objekten und Conditions, Executor-Interface und Thread-Pools, Fork/Join-Framework, SwingWorker

Explizite Lock-Objekte

Sie kennen bereits die Synchronisierung mit dem Schlüsselwort synchronized.

// Synchronisierung der gesamten Methode
public synchronized int incrVal() {
    ...
}
// Synchronisierung eines Blocks (eines Teils einer Methode)
public int incrVal() {
    ...
    synchronized (someObj) {
        ...
    }
    ...
}

Dabei wird implizit ein Lock über ein Objekt (das eigene Objekt im ersten Fall, das Sperrobjekt im zweiten Fall) benutzt.

Seit Java5 kann man alternativ auch explizite Lock-Objekte nutzen:

// Synchronisierung eines Teils einer Methode über ein
// Lock-Objekt (seit Java 5)
// Package `java.util.concurrent.locks`
public int incrVal() {
    Lock waechter = new ReentrantLock();
    ...
    waechter.lock();
    ... // Geschützter Bereich
    waechter.unlock();
    ...
}

Locks aus dem Paket java.util.concurrent.locks arbeiten analog zum impliziten Locken über synchronized. Sie haben darüber hinaus aber einige Vorteile:

  • Methoden zum Abfragen, ob ein Lock möglich ist: Lock#tryLock
  • Methoden zum Abfragen der aktuellen Warteschlangengröße: Lock#getQueueLength
  • Verfeinerung ReentrantReadWriteLock mit Methoden readLock und writeLock
    • Locks nur zum Lesen bzw. nur zum Schreiben
  • Lock#newCondition liefert ein Condition-Objekt zur Benachrichtigung ala wait/notify: await/signal => zusätzliches Timeout beim Warten möglich

Nachteile:

  • Bei Exceptions werden implizite Locks durch synchronized automatisch durch das Verlassen der Methode freigegeben. Explizite Locks müssen durch den Programmierer freigegeben werden! => Nutzung des finally-Block!

Thread-Management: Executor-Interface und Thread-Pools

Wiederverwendung von Threads

  • Normale Threads sind immer Einmal-Threads: Man kann sie nur einmal in ihrem Leben starten (auch wenn das Objekt anschließend noch auf Nachrichten bzw. Methodenaufrufe reagiert)

  • Zusätzliches Problem: Threads sind Objekte:

    • Threads brauchen relativ viel Arbeitsspeicher
    • Erzeugen und Entsorgen von Threads kostet Ressourcen
    • Zu viele Threads: Gesamte Anwendung hält an
  • Idee: Threads wiederverwenden und Thread-Management auslagern => Executor-Interface und Thread-Pool

Executor-Interface

public interface Executor {
    void execute(Runnable command);
}
  • Neue Aufgaben als Runnable an einen Executor via execute übergeben
  • Executor könnte damit sofort neuen Thread starten (oder alten wiederverwenden): e.execute(r); => entspricht in der Wirkung (new Thread(r)).start();

Thread-Pool hält Menge von "Worker-Threads"

  • Statische Methoden von java.util.concurrent.Executors erzeugen Thread-Pools mit verschiedenen Eigenschaften:

    • Executors#newFixedThreadPool erzeugt ExecutorService mit spezifizierter Anzahl von Worker-Threads
    • Executors#newCachedThreadPool erzeugt Pool mit Threads, die nach 60 Sekunden Idle wieder entsorgt werden
  • Rückgabe: ExecutorService (Thread-Pool)

    public interface ExecutorService extends Executor { ... }
  • Executor#execute übergibt Runnable dem nächsten freien Worker-Thread (oder erzeugt ggf. neuen Worker-Thread bzw. hängt Runnable in Warteschlange, je nach erzeugtem Pool)

  • Methoden zum Beenden eines Thread-Pools (Freigabe): shutdown(), isShutdown(), ...

MyThread x = new MyThread();    // Runnable oder Thread

ExecutorService pool = Executors.newCachedThreadPool();

pool.execute(x);    // x.start()
pool.execute(x);    // x.start()
pool.execute(x);    // x.start()

pool.shutdown();    // Feierabend :)

Hintergrund (vereinfacht)

Der Thread-Pool reserviert sich "nackten" Speicher, der der Größe von $n$ Threads entspricht, und "prägt" die Objektstruktur durch einen Cast direkt auf (ohne wirkliche neue Objekte zu erzeugen). Dieses Vorgehen ist in der C-Welt wohlbekannt und schnell (vgl. Thema Speicherverwaltung in der LV "Systemprogrammierung"). In Java wird dies durch eine wohldefinierte Schnittstelle vor dem Nutzer verborgen.

Ausblick

Hier haben wir nur die absoluten Grundlagen angerissen. Wir können auch Callables anstatt von Runnables übergeben, auf Ergebnisse aus der Zukunft warten (Futures), Dinge zeitgesteuert (immer wieder) starten, ...

Schauen Sie sich bei Interesse die weiterführende Literatur an, beispielsweise die Oracle-Dokumentation oder auch [Ullenboom2021] (insbesondere den Abschnitt 16.4 "Der Ausführer (Executor) kommt").

Fork/Join-Framework: Teile und Herrsche

Spezieller Thread-Pool zur rekursiven Bearbeitung parallelisierbarer Tasks

  • java.util.concurrent.ForkJoinPool#invoke startet Task

  • Task muss von RecursiveTask<V> erben:

    public abstract class RecursiveTask<V> extends ForkJoinTask<V> {
        protected abstract V compute();
    }

Prinzipieller Ablauf:

public class RecursiveTask extends ForkJoinTask<V> {
    protected V compute() {
        if (task klein genug) {
            berechne task sequentiell
        } else {
            teile task in zwei subtasks:
                left, right = new RecursiveTask(task)
            rufe compute() auf beiden subtasks auf:
                left.fork();          // starte neuen Thread
                r = right.compute();  // nutze aktuellen Thread
            warte auf ende der beiden subtasks: l = left.join()
            kombiniere die ergebnisse der beiden subtasks: l+r
        }
    }
}

Swing und Threads

Lange Berechnungen in Listenern blockieren Swing-GUI

  • Problem: Events werden durch einen Event Dispatch Thread (EDT) sequentiell bearbeitet
  • Lösung: Berechnungen in neuen Thread auslagern
  • Achtung: Swing ist nicht Thread-safe! Komponenten nicht durch verschiedene Threads manipulieren!

Lösung

=> javax.swing.SwingWorker ist eine spezielle Thread-Klasse, eng mit Swing/Event-Modell verzahnt.

  • Implementieren:

    • SwingWorker#doInBackground: Für die langwierige Berechnung (muss man selbst implementieren)
    • SwingWorker#done: Wird vom EDT aufgerufen, wenn doInBackground fertig ist
  • Aufrufen:

    • SwingWorker#execute: Started neuen Thread nach Anlegen einer Instanz und führt dann automatisch doInBackground aus
    • SwingWorker#get: Return-Wert von doInBackground abfragen

Anmerkungen

  • SwingWorker#done ist optional: kann überschrieben werden
    • Beispielweise, wenn nach Beendigung der langwierigen Berechnung GUI-Bestandteile mit dem Ergebnis aktualisiert werden sollen
  • SwingWorker<T, V> ist eine generische Klasse:
    • T Typ für das Ergebnis der Berechnung, d.h. Rückgabetyp für doInBackground und get
    • V Typ für Zwischenergebnisse

Letzte Worte :-)

  • Viele weitere Konzepte

    • Semaphoren, Monitore, ...
    • Leser-Schreiber-Probleme, Verklemmungen, ...

    => Verweis auf LV "Betriebssysteme" und "Verteilte Systeme"

  • Achtung: Viele Klassen sind nicht Thread-safe!

    Es gibt aber meist ein "Gegenstück", welches Thread-safe ist.

    Beispiel Listen:

    • java.util.ArrayList ist nicht Thread-safe
    • java.util.Vector ist Thread-sicher

    => Siehe Javadoc in den JDK-Klassen!

  • Thread-safe bedeutet Overhead (Synchronisierung)!

Wrap-Up

Multi-Threading auf höherem Level: Thread-Pools und Fork/Join-Framework

  • Feingranulareres und flexibleres Locking mit Lock-Objekten und Conditions
  • Wiederverwendung von Threads: Thread-Management mit Executor-Interface und Thread-Pools
  • Fork/Join-Framework zum rekursiven Zerteilen von Aufgaben und zur parallelen Bearbeitung der Teilaufgaben
  • SwingWorker für die parallele Bearbeitung von Aufgaben in Swing
Quellen
  • [Java-SE-Tutorial] The Java Tutorials
    Oracle Corporation, 2022.
    Trail: Essential Java Classes, Lesson: Concurrency
  • [Ullenboom2021] Java ist auch eine Insel
    Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
    Kap. 16: Einführung in die nebenläufige Programmierung
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Abschnitt 7.2: The fork/join framework