Reflection и анотации
Проблем
Полето, което toString забрави
toString на поръчката е писан на ръка. По-късно някой добавя поле за отстъпката:
class Order { private int number = 1042; private String customer = "Мария Петрова"; private double total = 86.40; private int discount = 10; // добавено по-късно
@Override public String toString() { return "Order[number=" + number + ", customer=" + customer + ", total=" + total + "]"; }}
System.out.println(new Order());Order[number=1042, customer=Мария Петрова, total=86.4]Отстъпката я няма в изхода, защото никой не се е сетил да обнови toString. При десет класа това се случва постоянно.
@Test, JUnit намира и пуска тези методи, макар че никога не е виждал твоя клас. Как? И двете неща стават с едно и също: програмата може да разглежда собствените си класове, докато работи.Идеята
Класът като обект
За всеки клас JVM пази обект от тип Class, който го описва: име, родител, интерфейси, полета, методи, конструктори. Достъпът до него се казва reflection (отражение: програмата се вижда като в огледало). Има три начина да вземеш този обект:
Order.class, когато знаеш класа, докато пишеш кода;obj.getClass(), когато имаш обект (дава истинския му клас, не типа на променливата);Class.forName("java.util.ArrayList"), когато имаш само името като текст.
Class<?> type = Class.forName("java.util.ArrayList");System.out.println("Име: " + type.getName());System.out.println("Кратко име: " + type.getSimpleName());System.out.println("Родител: " + type.getSuperclass().getName());for (Class<?> parent : type.getInterfaces()) { System.out.println("Интерфейс: " + parent.getName());}Име: java.util.ArrayListКратко име: ArrayListРодител: java.util.AbstractListИнтерфейс: java.util.ListИнтерфейс: java.util.RandomAccessИнтерфейс: java.lang.CloneableИнтерфейс: java.io.SerializableforName хвърля проверено ClassNotFoundException, ако такъв клас няма. Затова main го обявява с throws.Виж го
Полетата на всеки обект
getDeclaredFields() дава полетата на класа като обекти Field (от java.lang.reflect). Всяко знае името и типа си и може да прочете стойността си от даден обект: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) = Мария Петрова total (double) = 86.4 discount (int) = 10Room: number (int) = 214 seaView (boolean) = truediscount вече е там. Полетата са private, затова преди get има setAccessible(true). Без него get хвърля 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)); }}Стъпка по стъпка, с думи
- dump получава обект Order. Полетата му са private: отвън не се виждат.
- getClass() дава обекта Class, който описва класа Order.
- Първият ред е краткото име на класа.
- getDeclaredFields() дава 4 обекта Field: всяко знае името и типа си.
- setAccessible(true) отваря частното поле number, а field.get(obj) чете стойността му от обекта: 1042.
- customer (String) = Мария Петрова.
- total (double) = 86.4.
- discount: полето, което toString забрави, излиза само, защото reflection взима всички полета.
- Функцията не знае нищо за Order. За Room печата number и seaView по същия начин.
По-точно, за любопитните
getDeclaredFields()дава всички полета на самия клас, и частните, но без тези на родителя.getFields()дава самоpublicполетата, и наследените.- Редът на полетата не е обещан от Java. HotSpot (JVM-ът на Temurin, Oracle и OpenJDK) ги връща в реда от класа, но код, който разчита на реда, е крехък.
- Ако програмата е разделена на модули (
module-info.java),setAccessibleкъм частни полета в чужд модул не е позволено. Затова например не можеш да бъркаш в частните полета наString.
Пробвай
Чий клас?
Какво ще отпечата програмата?
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 е променлива от тип Document, но getClass() дава истинския клас на обекта: Invoice. Негов родител е Document. Document не пише extends, затова родителят му е Object, общият родител от модула за наследяването. За самия Object getSuperclass() връща null: над него няма нищо.Виж го
Обект, метод и частно поле по име
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("Кафе", 10.0);
Method discount = type.getMethod("applyDiscount", int.class);discount.invoke(coffee, 20);
Field price = type.getDeclaredField("price");price.setAccessible(true);System.out.println("Цена след отстъпка: " + price.get(coffee));price.set(coffee, 9.5);
Method label = type.getDeclaredMethod("label");label.setAccessible(true);System.out.println(label.invoke(coffee));Цена след отстъпка: 8.0Кафе: 9.5 €getDeclaredConstructor(String.class, double.class)намира конструктора по типовете на параметрите, аnewInstanceго вика.getMethod("applyDiscount", int.class)намира публичния метод, аinvoke(обект, аргументи)го вика върхуcoffee.getDeclaredFieldиgetDeclaredMethodнамират и частни неща. СледsetAccessible(true)можеш да четеш, да пишеш и да викаш.
main е с throws Exception: всяка от тези стъпки хвърля свое проверено изключение (няма такъв метод, няма достъп и т.н.).
По-точно, за любопитните
ArrayList, създаден и попълнен, без в кода да има new ArrayList или .add:Object list = Class.forName("java.util.ArrayList").getDeclaredConstructor().newInstance();Method add = list.getClass().getMethod("add", Object.class);add.invoke(list, "Кафе");add.invoke(list, "Чай");System.out.println(list);[Кафе, Чай]add приема Object, затова го търсим с getMethod("add", Object.class).Пробвай
Цената, която я няма
Избери реда с грешката.
price, а тук пише "prise". За компилатора това е обикновен текст, затова не казва нищо. Грешката излиза чак при изпълнение: java.lang.NoSuchFieldException: prise.Това е главната цена на reflection:
- компилаторът не проверява имената и типовете, а редакторът не ги намира при преименуване;
- кодът е по-бавен от обикновено извикване и по-труден за четене;
-
setAccessibleзаобикаля капсулацията: можеш да счупиш правило, което класът пази.
Идеята
Свои анотации
@: @Override, @Test, @Entity. Сами по себе си не правят нищо. Значение им дава код, който ги чете, често през reflection. Можеш да направиш и своя. Тази слага етикет на поле, а методът печата само полетата с етикет:@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.FIELD)@interface Label { String value();}class Booking { @Label("Номер") private int number = 501;
@Label("Гост") private String guest = "Иван Георгиев";
@Label("Нощувки") 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)); } }}Номер: 501Гост: Иван ГеоргиевНощувки: 3@interfaceобявява анотация.String value();е стойността ѝ:@Label("Гост").@Retention(RetentionPolicy.RUNTIME)казва, че анотацията остава и докато програмата работи. Без него reflection не я вижда.@Target(ElementType.FIELD)казва къде може да се слага: тук само на полета.field.getAnnotation(Label.class)връща анотацията илиnull, ако полето я няма.internalNoteняма етикет и не се печата.
По-точно, за любопитните
RetentionPolicyима три стойности:SOURCE(само за компилатора, като@Override),CLASS(в.classфайла, но не и при изпълнение; това е по подразбиране) иRUNTIME.- Анотацията може да има няколко стойности и стойност по подразбиране:
int max() default 100;. Тогава се пише@Range(min = 1, max = 5). Само стойност с иметоvalueможе да се пише без име. ElementTypeима ощеMETHOD,TYPE(клас),PARAMETER,CONSTRUCTORи други.
Виж го
Как JUnit намира тестовете
@Check е за методи, а тестовият клас проверява отстъпката от първия урок на модула (със старата грешка 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("очаквах " + expected + ", а е " + 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("ОК " + method.getName()); passed++; } catch (InvocationTargetException e) { System.out.println("ПАДА " + method.getName() + ": " + e.getCause().getMessage()); failed++; } } System.out.println("Проверки: " + (passed + failed) + ", минали: " + passed + ", паднали: " + failed);}ПАДА extraFivePercentFrom100: очаквах 85.0, а е 90.0ОК tenPercentOffПроверки: 2, минали: 1, паднали: 1Това прави и JUnit, само с много повече подробности:
- взима методите на класа и ги подрежда, за да е редът един и същ;
- пропуска тези без анотацията;
- прави нов обект за всеки тест, затова тестовете не си пречат;
- вика метода с
invoke. Ако тестът хвърли,invokeобвива изключението вInvocationTargetException, а истинското е вgetCause().
assertEquals в JUnit също е метод, който хвърля AssertionError, когато стойностите се различават. Точно като expect тук.
Пробвай
Анотацията, която изчезна
@Check е обявена без @Retention, всичко останало е същото:Какво ще отпечата runChecks?
@Target(ElementType.METHOD)@interface Check {}
class DiscountChecks { @Check void tenPercentOff() { ... }
@Check void extraFivePercentFrom100() { ... }}
runChecks(DiscountChecks.class);@Retention(RetentionPolicy.RUNTIME) анотацията е CLASS: записана е в .class файла, но JVM не я зарежда. isAnnotationPresent(Check.class) връща false за всеки метод и нито един не се пуска. Програмата се компилира и работи, просто не прави нищо. Затова всяка анотация, която се чете с reflection, е RUNTIME.Упражнение
Код, който работи с всеки клас
Реши задачите в редактора си.
1. Всички полета. НапишиprintFields(Object obj), който печата всяко поле като име = стойност, включително наследените. Тръгни от obj.getClass() и се качвай с getSuperclass() до Object; полетата на родителя са първи.class Vehicle { private String plate = "СА 1234 ВК"; private int year = 2019;}
class Truck extends Vehicle { private double loadTons = 12.5;}plate = СА 1234 ВКyear = 2019loadTons = 12.5toJson(Object obj), който връща полетата като JSON: имената в кавички, текстът в кавички, числата и true/false без кавички. Ползвай StringBuilder и instanceof String.class Ticket { private int seat = 14; private String event = "Концерт"; private double price = 45.0; private boolean vip = false;}{"seat": 14, "event": "Концерт", "price": 45.0, "vip": false}{"number": 214, "seaView": true}@MyTest и @MyBeforeEach и пускач run(Class<?> type). За всеки @MyTest метод: нов обект, после методът с @MyBeforeEach (ако има), после тестът. Ако тестът хвърли, отпечатай вида и съобщението на изключението. Пробвай го с тези тестове:@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("Не стигат " + (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("очаквах 60, а е " + account.getBalance()); }
@MyTest void withdraw70() { account.withdraw(70); if (account.getBalance() != 30) throw new AssertionError("очаквах 30, а е " + account.getBalance()); }
@MyTest void withdrawTooMuch() { account.withdraw(130); }}ОК withdraw40ОК withdraw70ПАДА withdrawTooMuch: IllegalArgumentException: Не стигат 30 €Тестове: 3, минали: 2, паднали: 1Тест