Към съдържанието
wannacode

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.Serializable
forName хвърля проверено 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) = 10
Room:
number (int) = 214
seaView (boolean) = true
Един метод отпечатва всеки обект, и новото поле discount вече е там. Полетата са 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));
}
}
Стъпка по стъпка, с думи
  1. dump получава обект Order. Полетата му са private: отвън не се виждат.
  2. getClass() дава обекта Class, който описва класа Order.
  3. Първият ред е краткото име на класа.
  4. getDeclaredFields() дава 4 обекта Field: всяко знае името и типа си.
  5. setAccessible(true) отваря частното поле number, а field.get(obj) чете стойността му от обекта: 1042.
  6. customer (String) = Мария Петрова.
  7. total (double) = 86.4.
  8. discount: полето, което toString забрави, излиза само, защото reflection взима всички полета.
  9. Функцията не знае нищо за 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());
Отговор

Виж го

Обект, метод и частно поле по име

През reflection можеш да създадеш обект, да извикаш метод и да промениш поле, като знаеш само имената им:
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).

Пробвай

Цената, която я няма

Програмата се компилира, но спира. На кой ред е грешката?

Избери реда с грешката.

Идеята

Свои анотации

Анотациите са етикети с @: @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 намира тестовете

Ето малък 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, само с много повече подробности:

  1. взима методите на класа и ги подрежда, за да е редът един и същ;
  2. пропуска тези без анотацията;
  3. прави нов обект за всеки тест, затова тестовете не си пречат;
  4. вика метода с 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);
Отговор

Упражнение

Код, който работи с всеки клас

Реши задачите в редактора си.

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;
}
Изход за new Truck()
plate = СА 1234 ВК
year = 2019
loadTons = 12.5
2. Мини JSON. Напиши toJson(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;
}
Изход за new Ticket() и new Room()
{"seat": 14, "event": "Концерт", "price": 45.0, "vip": false}
{"number": 214, "seaView": true}
3. Свой пускач на тестове. Направи анотации @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

Тест

Провери се

1. Какво дава obj.getClass()?
2. Какво трябва да извикаш, преди да прочетеш private поле с field.get(obj)?
3. В getDeclaredField("prise") има печатна грешка. Кога ще разбереш?
4. Защо своята анотация е с @Retention(RetentionPolicy.RUNTIME)?
5. Как JUnit намира методите с @Test?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·