Zum Inhalt
wannacode

Reflection und Annotationen

Kurz gesagt: Reflection ist der Zugriff eines Programms auf seine eigenen Klassen, während es läuft: Felder und Methoden über ihren Namen, und Annotationen sind Notizen, die es lesen kann.

Problem

Das Feld, das toString vergessen hat

toString der Bestellung ist von Hand geschrieben. Später fügt jemand ein Feld für den Rabatt hinzu:

class Order {
private int number = 1042;
private String customer = "Maria Becker";
private double total = 86.40;
private int discount = 10; // später hinzugefügt
@Override
public String toString() {
return "Order[number=" + number + ", customer=" + customer + ", total=" + total + "]";
}
}
System.out.println(new Order());
Ausgabe
Order[number=1042, customer=Maria Becker, total=86.4]

Der Rabatt fehlt in der Ausgabe, weil niemand daran gedacht hat, toString anzupassen. Bei zehn Klassen passiert das ständig.

Dazu kommt eine Frage vom Anfang des Moduls. Wenn du @Test schreibst, findet JUnit diese Methoden und führt sie aus, obwohl es deine Klasse nie gesehen hat. Wie? Beides funktioniert mit derselben Idee: Ein Programm kann seine eigenen Klassen betrachten, während es läuft.

Die Idee

Die Klasse als Objekt

Für jede Klasse hält die JVM ein Objekt vom Typ Class, das sie beschreibt: Name, Basisklasse, Interfaces, Felder, Methoden, Konstruktoren. Der Zugriff darauf heißt Reflection (Spiegelung: Das Programm sieht sich wie in einem Spiegel). Es gibt drei Wege zu diesem Objekt:

  • Order.class, wenn du die Klasse schon beim Schreiben des Codes kennst;
  • obj.getClass(), wenn du ein Objekt hast (es liefert die echte Klasse des Objekts, nicht den Typ der Variable);
  • Class.forName("java.util.ArrayList"), wenn du nur den Namen als Text hast.
Class<?> type = Class.forName("java.util.ArrayList");
System.out.println("Name: " + type.getName());
System.out.println("Kurzname: " + type.getSimpleName());
System.out.println("Basisklasse: " + type.getSuperclass().getName());
for (Class<?> parent : type.getInterfaces()) {
System.out.println("Interface: " + parent.getName());
}
Ausgabe
Name: java.util.ArrayList
Kurzname: ArrayList
Basisklasse: java.util.AbstractList
Interface: java.util.List
Interface: java.util.RandomAccess
Interface: java.lang.Cloneable
Interface: java.io.Serializable
forName wirft die geprüfte ClassNotFoundException, wenn es keine solche Klasse gibt. Deshalb deklariert main sie mit throws.

Sieh es dir an

Die Felder jedes Objekts

getDeclaredFields() liefert die Felder der Klasse als Field-Objekte (aus java.lang.reflect). Jedes kennt seinen Namen und seinen Typ und kann seinen Wert aus einem gegebenen Objekt lesen:
static void dump(Object obj) throws IllegalAccessException {
Class<?> type = obj.getClass();
System.out.println(type.getSimpleName() + ":");
for (Field field : type.getDeclaredFields()) {
field.setAccessible(true);
System.out.println(" " + field.getName() + " (" + field.getType().getSimpleName() + ") = " + field.get(obj));
}
}
class Room {
private int number = 214;
private boolean seaView = true;
}
dump(new Order());
dump(new Room());
Ausgabe
Order:
number (int) = 1042
customer (String) = Maria Becker
total (double) = 86.4
discount (int) = 10
Room:
number (int) = 214
seaView (boolean) = true
Eine Methode gibt jedes Objekt aus, und das neue Feld discount ist schon dabei. Die Felder sind private, deshalb steht vor get setAccessible(true). Ohne das wirft get eine IllegalAccessException.

static void dump(Object obj) throws IllegalAccessException {
Class<?> type = obj.getClass();
System.out.println(type.getSimpleName() + ":");
for (Field field : type.getDeclaredFields()) {
field.setAccessible(true);
System.out.println(" " + field.getName() + " (" + field.getType().getSimpleName() + ") = " + field.get(obj));
}
}
Schritt für Schritt, in Worten
  1. dump bekommt ein Order-Objekt. Seine Felder sind private: Von außen sieht man sie nicht.
  2. getClass() liefert das Class-Objekt, das die Klasse Order beschreibt.
  3. Die erste Zeile ist der kurze Name der Klasse.
  4. getDeclaredFields() liefert 4 Field-Objekte: Jedes kennt seinen Namen und Typ.
  5. setAccessible(true) öffnet das private Feld number, und field.get(obj) liest seinen Wert aus dem Objekt: 1042.
  6. customer (String) = Maria Becker.
  7. total (double) = 86.4.
  8. discount: Das Feld, das toString vergessen hat, erscheint von selbst, weil Reflection alle Felder holt.
  9. Die Methode weiß nichts über Order. Für Room gibt sie number und seaView genauso aus.
Genauer, für Neugierige
  • getDeclaredFields() liefert alle Felder der Klasse selbst, auch die privaten, aber nicht die der Basisklasse. getFields() liefert nur die public Felder, auch die geerbten.
  • Java garantiert die Reihenfolge der Felder nicht. HotSpot (die JVM von Temurin, Oracle und OpenJDK) liefert sie in der Reihenfolge der Klasse, aber Code, der sich auf die Reihenfolge verlässt, ist zerbrechlich.
  • Wenn das Programm in Module aufgeteilt ist (module-info.java), ist setAccessible auf private Felder in einem fremden Modul nicht erlaubt. Deshalb kannst du zum Beispiel nicht in den privaten Feldern von String herumstochern.

Probier es aus

Wessen Klasse?

Was gibt das Programm aus?

class Document {
}
class Invoice extends Document {
}
Document doc = new Invoice();
System.out.println(doc.getClass().getSimpleName());
System.out.println(doc.getClass().getSuperclass().getSimpleName());
System.out.println(Document.class.getSuperclass().getSimpleName());
Antwort

Sieh es dir an

Objekt, Methode und privates Feld über den Namen

Mit Reflection kannst du ein Objekt erzeugen, eine Methode aufrufen und ein Feld ändern, wenn du nur ihre Namen kennst:
class Product {
private String name;
private double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public void applyDiscount(int percent) {
price = price * (100 - percent) / 100;
}
private String label() {
return name + ": " + price + " €";
}
}
Class<Product> type = Product.class;
Product coffee = type.getDeclaredConstructor(String.class, double.class).newInstance("Kaffee", 10.0);
Method discount = type.getMethod("applyDiscount", int.class);
discount.invoke(coffee, 20);
Field price = type.getDeclaredField("price");
price.setAccessible(true);
System.out.println("Preis nach Rabatt: " + price.get(coffee));
price.set(coffee, 9.5);
Method label = type.getDeclaredMethod("label");
label.setAccessible(true);
System.out.println(label.invoke(coffee));
Ausgabe
Preis nach Rabatt: 8.0
Kaffee: 9.5 €
  • getDeclaredConstructor(String.class, double.class) findet den Konstruktor über die Typen seiner Parameter, und newInstance ruft ihn auf.
  • getMethod("applyDiscount", int.class) findet die öffentliche Methode, und invoke(Objekt, Argumente) ruft sie auf coffee auf.
  • getDeclaredField und getDeclaredMethod finden auch Privates. Nach setAccessible(true) kannst du lesen, schreiben und aufrufen.

main hat throws Exception: Jeder dieser Schritte wirft seine eigene geprüfte Exception (keine solche Methode, kein Zugriff und so weiter).

Genauer, für Neugierige
Das Beispiel aus dem alten Material: eine ArrayList, erzeugt und gefüllt, ohne dass new ArrayList oder .add im Code steht:
Object list = Class.forName("java.util.ArrayList").getDeclaredConstructor().newInstance();
Method add = list.getClass().getMethod("add", Object.class);
add.invoke(list, "Kaffee");
add.invoke(list, "Tee");
System.out.println(list);
Ausgabe
[Kaffee, Tee]
add nimmt ein Object, deshalb suchen wir es mit getMethod("add", Object.class).

Probier es aus

Der Preis, den es nicht gibt

Das Programm lässt sich kompilieren, aber es bricht ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Eigene Annotationen

Annotationen sind Etiketten mit @: @Override, @Test, @Entity. Für sich allein tun sie nichts. Bedeutung bekommen sie durch Code, der sie liest, oft über Reflection. Du kannst auch eigene machen. Diese hier setzt ein Etikett an ein Feld, und die Methode gibt nur die Felder mit Etikett aus:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Label {
String value();
}
class Booking {
@Label("Nummer")
private int number = 501;
@Label("Gast")
private String guest = "Jonas Weber";
@Label("Nächte")
private int nights = 3;
private String internalNote = "VIP";
}
static void printLabeled(Object obj) throws IllegalAccessException {
for (Field field : obj.getClass().getDeclaredFields()) {
Label label = field.getAnnotation(Label.class);
if (label != null) {
field.setAccessible(true);
System.out.println(label.value() + ": " + field.get(obj));
}
}
}
Ausgabe
Nummer: 501
Gast: Jonas Weber
Nächte: 3
  • @interface deklariert eine Annotation. String value(); ist ihr Wert: @Label("Gast").
  • @Retention(RetentionPolicy.RUNTIME) sagt, dass die Annotation auch erhalten bleibt, während das Programm läuft. Ohne das sieht Reflection sie nicht.
  • @Target(ElementType.FIELD) sagt, wo sie stehen darf: hier nur an Feldern.
  • field.getAnnotation(Label.class) gibt die Annotation zurück, oder null, wenn das Feld sie nicht hat. internalNote hat kein Etikett und wird nicht ausgegeben.
Genauer, für Neugierige
  • RetentionPolicy hat drei Werte: SOURCE (nur für den Compiler, wie @Override), CLASS (in der .class-Datei, aber nicht zur Laufzeit; das ist der Standard) und RUNTIME.
  • Eine Annotation kann mehrere Werte und einen Standardwert haben: int max() default 100;. Dann schreibst du @Range(min = 1, max = 5). Nur ein Wert mit dem Namen value darf ohne seinen Namen geschrieben werden.
  • ElementType hat außerdem METHOD, TYPE (eine Klasse), PARAMETER, CONSTRUCTOR und andere.

Sieh es dir an

Wie JUnit die Tests findet

Hier ist ein winziges JUnit. Die Annotation @Check ist für Methoden, und die Testklasse prüft den Rabatt aus der ersten Lektion des Moduls (mit dem alten Fehler price > 100):
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Check {
}
class DiscountChecks {
@Check
void tenPercentOff() {
expect(45.0, Discount.apply(50, 10));
}
@Check
void extraFivePercentFrom100() {
expect(85.0, Discount.apply(100, 10));
}
void expect(double expected, double actual) {
if (expected != actual) {
throw new AssertionError("erwartet " + expected + ", ist aber " + actual);
}
}
}
static void runChecks(Class<?> type) throws ReflectiveOperationException {
Method[] methods = type.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
int passed = 0;
int failed = 0;
for (Method method : methods) {
if (!method.isAnnotationPresent(Check.class)) {
continue;
}
Object instance = type.getDeclaredConstructor().newInstance();
try {
method.invoke(instance);
System.out.println("OK " + method.getName());
passed++;
} catch (InvocationTargetException e) {
System.out.println("FEHLER " + method.getName() + ": " + e.getCause().getMessage());
failed++;
}
}
System.out.println("Prüfungen: " + (passed + failed) + ", bestanden: " + passed + ", fehlgeschlagen: " + failed);
}
Ausgabe
FEHLER extraFivePercentFrom100: erwartet 85.0, ist aber 90.0
OK tenPercentOff
Prüfungen: 2, bestanden: 1, fehlgeschlagen: 1

JUnit macht dasselbe, nur mit viel mehr Details:

  1. Es holt die Methoden der Klasse und sortiert sie, damit die Reihenfolge immer gleich ist;
  2. es überspringt die ohne die Annotation;
  3. es erzeugt für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
  4. es ruft die Methode mit invoke auf. Wenn der Test wirft, verpackt invoke die Exception in eine InvocationTargetException, und die echte steckt in getCause().

assertEquals in JUnit ist auch eine Methode, die einen AssertionError wirft, wenn sich die Werte unterscheiden. Genau wie expect hier.

Probier es aus

Die Annotation, die verschwand

@Check ist ohne @Retention deklariert, alles andere ist gleich:

Was gibt runChecks aus?

@Target(ElementType.METHOD)
@interface Check {
}
class DiscountChecks {
@Check
void tenPercentOff() { ... }
@Check
void extraFivePercentFrom100() { ... }
}
runChecks(DiscountChecks.class);
Antwort

Übung

Code, der mit jeder Klasse funktioniert

Löse die Aufgaben in deinem Editor.

1. Alle Felder. Schreib printFields(Object obj), das jedes Feld als Name = Wert ausgibt, auch die geerbten. Fang bei obj.getClass() an und geh mit getSuperclass() nach oben bis Object; die Felder der Basisklasse kommen zuerst.
class Vehicle {
private String plate = "M-AB 1234";
private int year = 2019;
}
class Truck extends Vehicle {
private double loadTons = 12.5;
}
Ausgabe für new Truck()
plate = M-AB 1234
year = 2019
loadTons = 12.5
2. Mini-JSON. Schreib toJson(Object obj), das die Felder als JSON zurückgibt: die Namen in Anführungszeichen, Text in Anführungszeichen, Zahlen und true/false ohne Anführungszeichen. Benutze StringBuilder und instanceof String.
class Ticket {
private int seat = 14;
private String event = "Konzert";
private double price = 45.0;
private boolean vip = false;
}
Ausgabe für new Ticket() und new Room()
{"seat": 14, "event": "Konzert", "price": 45.0, "vip": false}
{"number": 214, "seaView": true}
3. Dein eigener Test-Runner. Mach die Annotationen @MyTest und @MyBeforeEach und einen Runner run(Class<?> type). Für jede @MyTest-Methode: ein neues Objekt, dann die Methode mit @MyBeforeEach (falls es eine gibt), dann der Test. Wenn der Test wirft, gib die Art und die Meldung der Exception aus. Probier ihn mit diesen Tests aus:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface MyTest {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface MyBeforeEach {
}
class Account {
private int balance;
Account(int balance) {
this.balance = balance;
}
int getBalance() {
return balance;
}
void withdraw(int amount) {
if (amount > balance) {
throw new IllegalArgumentException("Es fehlen " + (amount - balance) + " €");
}
balance -= amount;
}
}
class AccountChecks {
private Account account;
@MyBeforeEach
void fresh() {
account = new Account(100);
}
@MyTest
void withdraw40() {
account.withdraw(40);
if (account.getBalance() != 60) throw new AssertionError("erwartet 60, ist aber " + account.getBalance());
}
@MyTest
void withdraw70() {
account.withdraw(70);
if (account.getBalance() != 30) throw new AssertionError("erwartet 30, ist aber " + account.getBalance());
}
@MyTest
void withdrawTooMuch() {
account.withdraw(130);
}
}
Ausgabe
OK withdraw40
OK withdraw70
FEHLER withdrawTooMuch: IllegalArgumentException: Es fehlen 30 €
Tests: 3, bestanden: 2, fehlgeschlagen: 1

Test

Teste dich

1. Was liefert obj.getClass()?
2. Was musst du aufrufen, bevor du ein private Feld mit field.get(obj) liest?
3. In getDeclaredField("prise") ist ein Tippfehler. Wann merkst du es?
4. Warum hat deine eigene Annotation @Retention(RetentionPolicy.RUNTIME)?
5. Wie findet JUnit die Methoden mit @Test?

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