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());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.
@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());}Name: java.util.ArrayListKurzname: ArrayListBasisklasse: java.util.AbstractListInterface: java.util.ListInterface: java.util.RandomAccessInterface: java.lang.CloneableInterface: java.io.SerializableforName 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());Order: number (int) = 1042 customer (String) = Maria Becker total (double) = 86.4 discount (int) = 10Room: number (int) = 214 seaView (boolean) = truediscount 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
- dump bekommt ein Order-Objekt. Seine Felder sind private: Von außen sieht man sie nicht.
- getClass() liefert das Class-Objekt, das die Klasse Order beschreibt.
- Die erste Zeile ist der kurze Name der Klasse.
- getDeclaredFields() liefert 4 Field-Objekte: Jedes kennt seinen Namen und Typ.
- setAccessible(true) öffnet das private Feld number, und field.get(obj) liest seinen Wert aus dem Objekt: 1042.
- customer (String) = Maria Becker.
- total (double) = 86.4.
- discount: Das Feld, das toString vergessen hat, erscheint von selbst, weil Reflection alle Felder holt.
- 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 diepublicFelder, 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), istsetAccessibleauf private Felder in einem fremden Modul nicht erlaubt. Deshalb kannst du zum Beispiel nicht in den privaten Feldern vonStringherumstochern.
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());doc ist eine Variable vom Typ Document, aber getClass() liefert die echte Klasse des Objekts: Invoice. Ihre Basisklasse ist Document. Document schreibt kein extends, also ist seine Basisklasse Object, die gemeinsame Basisklasse aus dem Modul „Vererbung und Polymorphie“. Für Object selbst gibt getSuperclass() null zurück: Darüber gibt es nichts.Sieh es dir an
Objekt, Methode und privates Feld über den Namen
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));Preis nach Rabatt: 8.0Kaffee: 9.5 €getDeclaredConstructor(String.class, double.class)findet den Konstruktor über die Typen seiner Parameter, undnewInstanceruft ihn auf.getMethod("applyDiscount", int.class)findet die öffentliche Methode, undinvoke(Objekt, Argumente)ruft sie aufcoffeeauf.getDeclaredFieldundgetDeclaredMethodfinden auch Privates. NachsetAccessible(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
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);[Kaffee, Tee]add nimmt ein Object, deshalb suchen wir es mit getMethod("add", Object.class).Probier es aus
Der Preis, den es nicht gibt
Wähle die Zeile mit dem Fehler.
price, aber hier steht "prise". Für den Compiler ist das gewöhnlicher Text, deshalb sagt er nichts. Der Fehler zeigt sich erst zur Laufzeit: java.lang.NoSuchFieldException: prise.Das ist der wichtigste Preis von Reflection:
- der Compiler prüft die Namen und Typen nicht, und der Editor findet sie nicht, wenn du etwas umbenennst;
- der Code ist langsamer als ein gewöhnlicher Aufruf und schwerer zu lesen;
-
setAccessibleumgeht die Kapselung: Du kannst eine Regel brechen, die die Klasse schützt.
Die Idee
Eigene Annotationen
@: @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)); } }}Nummer: 501Gast: Jonas WeberNächte: 3@interfacedeklariert 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, odernull, wenn das Feld sie nicht hat.internalNotehat kein Etikett und wird nicht ausgegeben.
Genauer, für Neugierige
RetentionPolicyhat drei Werte:SOURCE(nur für den Compiler, wie@Override),CLASS(in der.class-Datei, aber nicht zur Laufzeit; das ist der Standard) undRUNTIME.- 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 Namenvaluedarf ohne seinen Namen geschrieben werden. ElementTypehat außerdemMETHOD,TYPE(eine Klasse),PARAMETER,CONSTRUCTORund andere.
Sieh es dir an
Wie JUnit die Tests findet
@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);}FEHLER extraFivePercentFrom100: erwartet 85.0, ist aber 90.0OK tenPercentOffPrüfungen: 2, bestanden: 1, fehlgeschlagen: 1JUnit macht dasselbe, nur mit viel mehr Details:
- Es holt die Methoden der Klasse und sortiert sie, damit die Reihenfolge immer gleich ist;
- es überspringt die ohne die Annotation;
- es erzeugt für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
- es ruft die Methode mit
invokeauf. Wenn der Test wirft, verpacktinvokedie Exception in eineInvocationTargetException, und die echte steckt ingetCause().
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);@Retention(RetentionPolicy.RUNTIME) ist die Annotation CLASS: Sie steht in der .class-Datei, aber die JVM lädt sie nicht. isAnnotationPresent(Check.class) gibt für jede Methode false zurück, und keine einzige wird ausgeführt. Das Programm lässt sich kompilieren und läuft, es tut nur nichts. Deshalb ist jede Annotation, die mit Reflection gelesen wird, RUNTIME.Übung
Code, der mit jeder Klasse funktioniert
Löse die Aufgaben in deinem Editor.
1. Alle Felder. SchreibprintFields(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;}plate = M-AB 1234year = 2019loadTons = 12.5toJson(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;}{"seat": 14, "event": "Konzert", "price": 45.0, "vip": false}{"number": 214, "seaView": true}@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); }}OK withdraw40OK withdraw70FEHLER withdrawTooMuch: IllegalArgumentException: Es fehlen 30 €Tests: 3, bestanden: 2, fehlgeschlagen: 1Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen · Klassen in Klassen · Lambdas · Tests mit JUnit
- Aufgaben zum Thema: Programme in der echten Welt