Zum Inhalt
wannacode

record, switch und Muster

Kurz gesagt: Ein Record ist eine Datenklasse, für die Java den Konstruktor, equals, hashCode und toString selbst schreibt, und ein switch mit Mustern wählt nach dem Typ und den Feldern des Objekts.

Problem

Der Platz ist belegt, aber die HashMap findet ihn nicht

Reservierungen im Kino: Der Schlüssel ist der Platz (Reihe und Nummer), der Wert ist der Name der Person.

Seat
class Seat {
private int row;
private int number;
public Seat(int row, int number) {
this.row = row;
this.number = number;
}
}
Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "Maria");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Ausgabe
null
false
Maria sitzt in Reihe 5, Platz 12, aber get gibt null zurück. Warum, weißt du noch aus der Lektion über Hashcodes: new Seat(5, 12) zweimal geschrieben sind zwei verschiedene Objekte, und Seat sagt nicht, wann zwei davon gleich sind. Die Lösung sind equals und hashCode, dazu toString für die Ausgabe und Getter. Rund 30 Zeilen für eine Klasse, die nur zwei Zahlen trägt.

Die Idee

record: eine Klasse für Daten

Seit Java 16 braucht dasselbe eine Zeile:
record Seat(int row, int number) {}
Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "Maria");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
System.out.println(seat);
System.out.println("Reihe " + seat.row() + ", Platz " + seat.number());
Ausgabe
Maria
true
Seat[row=5, number=12]
Reihe 5, Platz 12

Ein record ist eine Klasse, deren Aufgabe es ist, Daten zu tragen. In den Klammern stehen seine Komponenten, und Java schreibt für dich:

  • ein privates final-Feld für jede Komponente;
  • einen Konstruktor mit allen Komponenten;
  • eine Methode, die jedes Feld liest, mit demselben Namen: row(), nicht getRow();
  • equals und hashCode, die die Felder vergleichen. Deshalb findet get Maria jetzt;
  • toString in der Form Seat[row=5, number=12].

Setter gibt es nicht. Ein Objekt eines record ändert sich nach dem Erzeugen nicht.

Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "Maria");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
System.out.println(seat);
System.out.println("Reihe " + seat.row() + ", Platz " + seat.number());
Schritt für Schritt, in Worten
  1. Eine leere Map: Der Schlüssel ist ein Kinoplatz, der Wert ist ein Name.
  2. put legt den Schlüssel Seat(5, 12) mit dem Wert „Maria“ an.
  3. get bekommt ein neues Seat-Objekt. Das ist ein anderes Objekt im Speicher, nicht der Schlüssel aus put.
  4. Der hashCode eines Records wird aus den Feldern berechnet, deshalb ist er gleich: Die Suche geht an dieselbe Stelle.
  5. equals eines Records vergleicht Feld für Feld: Reihe 5 und 5, Platz 12 und 12. Sie sind gleich, und get gibt „Maria“ zurück.
  6. containsKey macht dasselbe mit einem weiteren neuen Seat-Objekt: true.
  7. Auch toString und die Methoden row() und number() schreibt der Record selbst.
Genauer, für Neugierige
  • Ein record ist final: Man kann nicht von ihm erben. Er erbt implizit von java.lang.Record und kann von keiner anderen Klasse erben, aber er kann Interfaces implementieren.
  • Er kann eigene Methoden, statische Felder und statische Methoden haben. Objektfelder außerhalb der Komponenten darf er nicht haben:
record Cart(List<String> items) {
private int count;
}
Kompilierfehler
Main.java:4: error: field declaration must be static
private int count;
^
(consider replacing field with record component)
  • Die Unveränderlichkeit reicht nur eine Ebene tief. Wenn eine Komponente eine List ist, zeigt das Feld immer auf dieselbe Liste, aber die Liste selbst kann sich ändern.
  • Im Modul „Collections“ hast du equals und hashCode von Hand geschrieben. Mit record hast du sie schon. Wenn du eine andere Regel für die Gleichheit brauchst, kannst du sie trotzdem überschreiben.

Die Idee

Prüfungen und Methoden in einem Record

Ein Preis kann nicht negativ sein. Die Prüfung kommt in einen kompakten Konstruktor: ohne Klammern und ohne Parameter, er läuft, bevor die Felder gesetzt werden.
record Price(double amount, String currency) {
Price {
if (amount < 0) {
throw new IllegalArgumentException("Der Preis darf nicht negativ sein: " + amount);
}
}
Price withDiscount(double percent) {
return new Price(amount * (1 - percent / 100), currency);
}
}
Price coffee = new Price(18.50, "EUR");
Price sale = coffee.withDiscount(20);
System.out.println(coffee);
System.out.println(sale);
try {
new Price(-5, "EUR");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
Ausgabe
Price[amount=18.5, currency=EUR]
Price[amount=14.8, currency=EUR]
Der Preis darf nicht negativ sein: -5.0
  • Im kompakten Konstruktor sind amount und currency die Parameter. Du schreibst nicht this.amount = amount: Das passiert erst nach der Prüfung.
  • withDiscount ändert den Preis nicht. Es gibt einen neuen Price zurück. So arbeitest du mit Objekten, die sich nicht ändern: coffee bleibt bei 18,50 €.

Probier es aus

Sind sie gleich?

Seat ist ein record, wie in der Karte davor.

Was gibt das Programm aus?

Seat a = new Seat(5, 12);
Seat b = new Seat(5, 12);
System.out.println(a == b);
System.out.println(a.equals(b));
System.out.println(a.hashCode() == b.hashCode());
Antwort

Probier es aus

Die Lieferung wird zu teuer

Die Lieferung in eine Filiale kostet 4,50 €, aber das Programm sagt 6,90 €. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Ein switch, der einen Wert zurückgibt

Seit Java 14 kann ein switch ein Ausdruck sein: Er liefert einen Wert, den du in einer Variable speicherst oder zurückgibst.
String method = "filiale";
double price = switch (method) {
case "filiale" -> 4.50;
case "kurier", "adresse" -> 6.90;
case "laden" -> 0;
default -> throw new IllegalArgumentException("Unbekannte Lieferart: " + method);
};
System.out.println("Lieferung: " + price + " €");
Ausgabe
Lieferung: 4.5 €
  • Nach -> kommt der Wert des Falls. Es gibt kein Durchfallen, und du schreibst kein break.
  • Ein Fall kann mehrere Werte haben: case "kurier", "adresse".
  • Ein Fall kann eine Exception werfen, wie bei default.
  • Der Ausdruck muss für jede mögliche Eingabe einen Wert liefern. Bei Text gibt es unendlich viele Eingaben, deshalb ist default Pflicht. Ohne es:
double price = switch (method) {
case "filiale" -> 4.50;
case "kurier" -> 6.90;
};
Kompilierfehler
Main.java:4: error: the switch expression does not cover all possible input values
double price = switch (method) {
^

Wenn ein Fall mehrere Zeilen hat, kommt er in { }, und der Wert wird mit yield geliefert:

static double delivery(String method, double weightKg) {
return switch (method) {
case "filiale" -> 4.50;
case "kurier" -> {
double base = 6.90;
yield weightKg > 10 ? base + 3 : base;
}
default -> 0;
};
}
System.out.println(delivery("kurier", 4));
System.out.println(delivery("kurier", 12));
Ausgabe
6.9
9.9
Genauer, für Neugierige
  • Pfeile kannst du auch in einem switch schreiben, der keinen Wert zurückgibt, sondern nur etwas tut. Auch dann gibt es kein Durchfallen.
  • yield ist wie return, aber für den switch, nicht für die Methode.
  • Die alte Form case "filiale": ... break; funktioniert weiterhin, und du wirst ihr in altem Code begegnen. Die beiden Formen werden in einem switch nicht gemischt.

Die Idee

sealed: eine geschlossene Familie

Der Shop nimmt drei Zahlungsarten an: Karte, Banküberweisung und Nachnahme. Andere gibt es nicht. Das kannst du dem Compiler sagen:

sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery {}
record CardPayment(String cardNumber) implements Payment {}
record BankTransfer(String iban) implements Payment {}
record CashOnDelivery() implements Payment {}

sealed bedeutet „versiegelt“. Nach permits kommt die Liste der Typen, die Payment implementieren dürfen. Ein Versuch mit einem vierten kompiliert nicht:

record Voucher(String code) implements Payment {}
Kompilierfehler
Main.java:6: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)
record Voucher(String code) implements Payment {}
^

Die Untertypen sind hier Records, weil jede Zahlungsart nur Daten ist: eine Kartennummer, eine IBAN oder nichts.

Genauer, für Neugierige
  • Jeder Untertyp eines sealed-Typs ist final, sealed (mit eigenem permits) oder non-sealed (wieder offen). Ein record ist per Definition final.
  • Wenn alle Untertypen in derselben Datei stehen, kann permits wegfallen.
  • sealed kann auch eine abstrakte Klasse sein: sealed abstract class Shape permits Circle, Square.

Sieh es dir an

Muster in switch

Die Gebühr hängt von der Zahlungsart ab. Seit Java 21 prüft ein switch den Typ des Objekts:
static double fee(Payment payment, double amount) {
return switch (payment) {
case CardPayment card -> amount * 0.015;
case BankTransfer transfer -> 0.50;
case CashOnDelivery cash -> 2.00;
};
}
System.out.println(fee(new CardPayment("4111222233334444"), 200));
System.out.println(fee(new BankTransfer("DE89370400440532013000"), 200));
System.out.println(fee(new CashOnDelivery(), 200));
Ausgabe
3.0
0.5
2.0

case CardPayment card bedeutet „wenn das Objekt ein CardPayment ist, nenne es card“. Das ist instanceof mit Muster aus dem Modul „Vererbung und Polymorphie“, aber für viele Typen auf einmal.

Ein default gibt es nicht. Der Compiler kennt die drei Untertypen und prüft, dass jeder abgedeckt ist. Wenn du einen vergisst, kompiliert der Code nicht:

static double fee(Payment payment, double amount) {
return switch (payment) {
case CardPayment card -> amount * 0.015;
case BankTransfer transfer -> 0.50;
};
}
Kompilierfehler
Main.java:9: error: the switch expression does not cover all possible input values
return switch (payment) {
^

Das ist der Nutzen von sealed. Wenn du morgen eine vierte Zahlungsart hinzufügst, zeigt dir der Compiler jeden switch, der von ihr erfahren muss.

Ein Record-Muster holt die Felder direkt heraus, und when fügt dem Fall eine Bedingung hinzu:

static String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number) -> "Karte ****" + number.substring(number.length() - 4);
case BankTransfer(String iban) when iban.startsWith("DE") -> "Überweisung von einem deutschen Konto";
case BankTransfer(String iban) -> "Überweisung aus dem Ausland";
case CashOnDelivery() -> "Nachnahme";
};
}
System.out.println(describe(new CardPayment("4111222233334444")));
System.out.println(describe(new BankTransfer("DE89370400440532013000")));
System.out.println(describe(new BankTransfer("AT611904300234573201")));
System.out.println(describe(new CashOnDelivery()));
Ausgabe
Karte ****4444
Überweisung von einem deutschen Konto
Überweisung aus dem Ausland
Nachnahme
Genauer, für Neugierige
  • Ein Record-Muster funktioniert auch mit instanceof:
Payment payment = new CardPayment("4111222233334444");
if (payment instanceof CardPayment(String number)) {
System.out.println("Die Karte endet auf " + number.substring(12));
}
Ausgabe
Die Karte endet auf 4444
  • Wenn das Objekt null ist, wirft der switch eine NullPointerException. Wenn null eine sinnvolle Eingabe ist, füge case null -> ... hinzu.
  • Muster lassen sich verschachteln: case Order(Customer(String name), double total).

Probier es aus

Welcher Fall gewinnt?

Die Fälle werden von oben nach unten geprüft. Der erste, der passt, gewinnt.

Was gibt das Programm aus?

static String label(Payment payment, double amount) {
return switch (payment) {
case CashOnDelivery cash when amount > 500 -> "Nachnahme: über 500 € nicht möglich";
case CashOnDelivery cash -> "Nachnahme";
case CardPayment card when amount > 1000 -> "Karte: Bestätigung nötig";
case CardPayment card -> "Karte";
case BankTransfer transfer -> "Überweisung";
};
}
System.out.println(label(new CashOnDelivery(), 650));
System.out.println(label(new CashOnDelivery(), 80));
System.out.println(label(new CardPayment("4111222233334444"), 650));
Antwort
Genauer, für Neugierige

Der Fall mit when muss vor dem allgemeinen für denselben Typ stehen. Andersherum kompiliert es nicht, weil der allgemeine Fall alles fängt und der mit when nie erreicht würde:

case CashOnDelivery cash -> "Nachnahme";
case CashOnDelivery cash when amount > 500 -> "Nachnahme: über 500 € nicht möglich";
Kompilierfehler
Main.java:11: error: this case label is dominated by a preceding case label
case CashOnDelivery cash when amount > 500 -> "Nachnahme: über 500 € nicht möglich";
^

Die Idee

Textblöcke und var

Text über mehrere Zeilen, wie ein Kassenbon oder JSON, ist mit " und \n in jeder Zeile schwer zu lesen. Seit Java 15 gibt es den Textblock zwischen """ und """:

String receipt = """
Der Eckladen
Kaffeebohnen 18.50 €
Honig 12.00 €
Summe: 30.50 €
""";
System.out.print(receipt);
String json = """
{"customer": "%s", "total": %.2f}
""".formatted("Maria", 75.5);
System.out.print(json);
Ausgabe
Der Eckladen
Kaffeebohnen 18.50 €
Honig 12.00 €
Summe: 30.50 €
{"customer": "Maria", "total": 75.50}
  • Nach den öffnenden """ beginnt der Text in einer neuen Zeile.
  • Die gemeinsame Einrückung links wird entfernt. Maßstab ist die Zeile, die am weitesten links steht, einschließlich der schließenden """.
  • Anführungszeichen darin werden nicht maskiert.
  • formatted(...) füllt %s und %.2f aus, wie String.format.

Und eine Erinnerung aus „Sprachgrundlagen“: var lässt den Compiler den Typ ermitteln. Am meisten spart es bei den langen Typen aus den Collections:

var prices = new LinkedHashMap<String, Double>();
prices.put("Kaffee", 18.50);
prices.put("Honig", 12.00);
for (var entry : prices.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue() + " €");
}
Ausgabe
Kaffee: 18.5 €
Honig: 12.0 €

var gibt es nur für lokale Variablen mit einem Wert. Der Typ wird einmal festgelegt und ändert sich nicht. Felder, Parameter und var x = null; sind nicht erlaubt.

Übung

Modernes Java

Löse die Aufgaben in deinem Editor.

1. Formen. Ein sealed interface Shape mit drei record-Untertypen: Circle(double radius), Rectangle(double width, double height) und Triangle(double base, double height). Schreib area(Shape) mit einem switch und Record-Mustern, ohne default. Schreib auch name(Shape), das für ein Rechteck mit gleichen Seiten „Quadrat“ sagt (mit when). Gib einen Kreis mit Radius 1, ein Rechteck 2 mal 3, ein Quadrat 4 mal 4, ein Dreieck mit Grundseite 3 und Höhe 5 und die Gesamtfläche aus.
Ausgabe
Kreis: 3.14
Rechteck: 6.00
Quadrat: 16.00
Dreieck: 7.50
Gesamt: 32.64
2. Lieferpreis. Ein sealed interface Delivery mit ToOffice(String office), ToAddress(String city, double weightKg) und Pickup(). Der Preis: in eine Filiale 4,50 €; an eine Adresse 5,90 € in Berlin und 6,90 € anderswo, plus 1 € für jedes Kilogramm über 5; Abholung im Laden 0 €. Gib jede Lieferung (mit dem toString des Records) und ihren Preis aus.
Ausgabe
ToOffice[office=Hamburg Zentrum] -> 4.50 €
ToAddress[city=Berlin, weightKg=2.0] -> 5.90 €
ToAddress[city=Hamburg, weightKg=8.0] -> 9.90 €
Pickup[] -> 0.00 €
3. Ein Punkt als Schlüssel. Der Kurier fährt Punkte auf der Karte ab: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Mach record Point(int x, int y) und zähle mit einer LinkedHashMap<Point, Integer> und merge(p, 1, Integer::sum), wie oft er an jedem Punkt war. Schlag am Ende new Point(2, 3) in der Map nach.
Ausgabe
Point[x=2, y=3]: 3
Point[x=5, y=1]: 2
Point[x=0, y=0]: 1
Besuche bei (2, 3): 3

Weitere Aufgaben

  • Ein Schüler als Record. record Student(String name, double grade) mit einem kompakten Konstruktor, der eine Note außerhalb von 2 bis 6 ablehnt. Sortiere eine Liste von Schülern mit Comparator.comparing(Student::grade).
  • Wochentag. Schreib die Aufgabe „Wochentag“ aus der Lektion „Bedingungen“ mit einem switch-Ausdruck neu: case 1, 2, 3, 4, 5 -> "Werktag".
  • Ein Brief. Mach mit einem Textblock und formatted einen Brief, der eine Reservierung bestätigt: Name, Datum, Anzahl der Gäste und Preis in Euro.

Test

Teste dich

1. Was bekommst du mit record Seat(int row, int number) {} fertig dazu?
2. Warum funktioniert ein Record als Schlüssel in einer HashMap, eine normale Klasse ohne Zusätze aber nicht?
3. Was ist der Vorteil eines switch mit Pfeilen gegenüber dem alten switch?
4. sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery. Warum kann ein switch über Payment ohne default auskommen?
5. Was macht case BankTransfer(String iban) when iban.startsWith("DE") -> ...?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·