Ir al contenido
wannacode

Reflexión y anotaciones

En resumen: La reflexión es el acceso del programa a sus propias clases mientras se ejecuta: campos y métodos por nombre, y las anotaciones son notas que puede leer.

Problema

El campo que toString olvidó

El toString del pedido está escrito a mano. Más tarde, alguien añade un campo para el descuento:

class Order {
private int number = 1042;
private String customer = "María López";
private double total = 86.40;
private int discount = 10; // añadido después
@Override
public String toString() {
return "Order[number=" + number + ", customer=" + customer + ", total=" + total + "]";
}
}
System.out.println(new Order());
Salida
Order[number=1042, customer=María López, total=86.4]

El descuento no está en la salida, porque nadie se acordó de actualizar toString. Con diez clases, esto pasa constantemente.

Hay además otra pregunta del principio del módulo. Cuando escribes @Test, JUnit encuentra y ejecuta esos métodos, aunque nunca ha visto tu clase. ¿Cómo? Las dos cosas funcionan con la misma idea: un programa puede examinar sus propias clases mientras se ejecuta.

La idea

La clase como objeto

Para cada clase, la JVM guarda un objeto de tipo Class que la describe: nombre, padre, interfaces, campos, métodos, constructores. El acceso a él se llama reflexión (en inglés, reflection: el programa se ve a sí mismo como en un espejo). Hay tres formas de obtener este objeto:

  • Order.class, cuando conoces la clase mientras escribes el código;
  • obj.getClass(), cuando tienes un objeto (da la clase real del objeto, no el tipo de la variable);
  • Class.forName("java.util.ArrayList"), cuando solo tienes el nombre como texto.
Class<?> type = Class.forName("java.util.ArrayList");
System.out.println("Nombre: " + type.getName());
System.out.println("Nombre corto: " + type.getSimpleName());
System.out.println("Padre: " + type.getSuperclass().getName());
for (Class<?> parent : type.getInterfaces()) {
System.out.println("Interfaz: " + parent.getName());
}
Salida
Nombre: java.util.ArrayList
Nombre corto: ArrayList
Padre: java.util.AbstractList
Interfaz: java.util.List
Interfaz: java.util.RandomAccess
Interfaz: java.lang.Cloneable
Interfaz: java.io.Serializable
forName lanza la excepción comprobada ClassNotFoundException si no existe esa clase. Por eso main la declara con throws.

Míralo

Los campos de cualquier objeto

getDeclaredFields() da los campos de la clase como objetos Field (de java.lang.reflect). Cada uno conoce su nombre y su tipo, y puede leer su valor de un objeto dado:
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());
Salida
Order:
number (int) = 1042
customer (String) = María López
total (double) = 86.4
discount (int) = 10
Room:
number (int) = 214
seaView (boolean) = true
Un solo método imprime cualquier objeto, y el campo nuevo discount ya está ahí. Los campos son private, por eso antes de get está setAccessible(true). Sin él, get lanza 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));
}
}
Paso a paso, en palabras
  1. dump recibe un objeto Order. Sus campos son private: desde fuera no se ven.
  2. getClass() da el objeto Class que describe la clase Order.
  3. La primera línea es el nombre corto de la clase.
  4. getDeclaredFields() da 4 objetos Field: cada uno conoce su nombre y su tipo.
  5. setAccessible(true) abre el campo privado number, y field.get(obj) lee su valor del objeto: 1042.
  6. customer (String) = María López.
  7. total (double) = 86.4.
  8. discount: el campo que toString olvidó aparece solo, porque la reflexión obtiene todos los campos.
  9. El método no sabe nada de Order. Para Room imprime number y seaView de la misma forma.
Más precisamente, para curiosos
  • getDeclaredFields() da todos los campos de la propia clase, también los privados, pero no los del padre. getFields() da solo los campos public, también los heredados.
  • Java no garantiza el orden de los campos. HotSpot (la JVM de Temurin, Oracle y OpenJDK) los devuelve en el orden de la clase, pero el código que depende del orden es frágil.
  • Si el programa está dividido en módulos (module-info.java), no se permite setAccessible sobre campos privados de otro módulo. Por eso, por ejemplo, no puedes meter mano en los campos privados de String.

Pruébalo

¿De qué clase es?

¿Qué imprimirá el programa?

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());
Respuesta

Míralo

Objeto, método y campo privado por nombre

Con reflexión puedes crear un objeto, llamar a un método y cambiar un campo conociendo solo sus nombres:
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("Café", 10.0);
Method discount = type.getMethod("applyDiscount", int.class);
discount.invoke(coffee, 20);
Field price = type.getDeclaredField("price");
price.setAccessible(true);
System.out.println("Precio con descuento: " + price.get(coffee));
price.set(coffee, 9.5);
Method label = type.getDeclaredMethod("label");
label.setAccessible(true);
System.out.println(label.invoke(coffee));
Salida
Precio con descuento: 8.0
Café: 9.5 €
  • getDeclaredConstructor(String.class, double.class) encuentra el constructor por los tipos de sus parámetros, y newInstance lo llama.
  • getMethod("applyDiscount", int.class) encuentra el método público, e invoke(objeto, argumentos) lo llama sobre coffee.
  • getDeclaredField y getDeclaredMethod encuentran también cosas privadas. Después de setAccessible(true) puedes leer, escribir y llamar.

main lleva throws Exception: cada uno de estos pasos lanza su propia excepción comprobada (no existe ese método, no hay acceso, etc.).

Más precisamente, para curiosos
El ejemplo del material antiguo: un ArrayList, creado y rellenado sin que en el código aparezca new ArrayList ni .add:
Object list = Class.forName("java.util.ArrayList").getDeclaredConstructor().newInstance();
Method add = list.getClass().getMethod("add", Object.class);
add.invoke(list, "Café");
add.invoke(list, "Té");
System.out.println(list);
Salida
[Café, Té]
add recibe un Object, por eso lo buscamos con getMethod("add", Object.class).

Pruébalo

El precio que no está

El programa compila, pero se detiene. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Tus propias anotaciones

Las anotaciones son etiquetas con @: @Override, @Test, @Entity. Por sí solas no hacen nada. Les da sentido el código que las lee, a menudo con reflexión. También puedes crear las tuyas. Esta pone una etiqueta a un campo, y el método imprime solo los campos con etiqueta:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Label {
String value();
}
class Booking {
@Label("Número")
private int number = 501;
@Label("Huésped")
private String guest = "Carlos Gómez";
@Label("Noches")
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));
}
}
}
Salida
Número: 501
Huésped: Carlos Gómez
Noches: 3
  • @interface declara una anotación. String value(); es su valor: @Label("Huésped").
  • @Retention(RetentionPolicy.RUNTIME) dice que la anotación se conserva también mientras el programa se ejecuta. Sin ella, la reflexión no la ve.
  • @Target(ElementType.FIELD) dice dónde se puede poner: aquí, solo en campos.
  • field.getAnnotation(Label.class) devuelve la anotación, o null si el campo no la tiene. internalNote no tiene etiqueta y no se imprime.
Más precisamente, para curiosos
  • RetentionPolicy tiene tres valores: SOURCE (solo para el compilador, como @Override), CLASS (en el archivo .class, pero no durante la ejecución; es el valor por defecto) y RUNTIME.
  • Una anotación puede tener varios valores y un valor por defecto: int max() default 100;. Entonces se escribe @Range(min = 1, max = 5). Solo un valor llamado value se puede escribir sin su nombre.
  • ElementType tiene además METHOD, TYPE (una clase), PARAMETER, CONSTRUCTOR y otros.

Míralo

Cómo encuentra JUnit las pruebas

Aquí tienes un JUnit en miniatura. La anotación @Check es para métodos, y la clase de pruebas comprueba el descuento de la primera lección del módulo (con el viejo error 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("esperaba " + expected + ", pero es " + 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("FALLA " + method.getName() + ": " + e.getCause().getMessage());
failed++;
}
}
System.out.println("Comprobaciones: " + (passed + failed) + ", pasan: " + passed + ", fallan: " + failed);
}
Salida
FALLA extraFivePercentFrom100: esperaba 85.0, pero es 90.0
OK tenPercentOff
Comprobaciones: 2, pasan: 1, fallan: 1

JUnit hace lo mismo, solo que con muchos más detalles:

  1. obtiene los métodos de la clase y los ordena, para que el orden sea siempre el mismo;
  2. se salta los que no tienen la anotación;
  3. crea un objeto nuevo para cada prueba, así las pruebas no se estorban entre sí;
  4. llama al método con invoke. Si la prueba lanza una excepción, invoke la envuelve en una InvocationTargetException, y la verdadera está en getCause().

assertEquals en JUnit también es un método que lanza AssertionError cuando los valores no coinciden. Igual que expect aquí.

Pruébalo

La anotación que desapareció

@Check está declarada sin @Retention, y todo lo demás es igual:

¿Qué imprimirá runChecks?

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

Ejercicio

Código que funciona con cualquier clase

Resuelve las tareas en tu editor.

1. Todos los campos. Escribe printFields(Object obj), que imprima cada campo como nombre = valor, incluidos los heredados. Empieza por obj.getClass() y sube con getSuperclass() hasta Object; los campos del padre van primero.
class Vehicle {
private String plate = "1234 BCD";
private int year = 2019;
}
class Truck extends Vehicle {
private double loadTons = 12.5;
}
Salida para new Truck()
plate = 1234 BCD
year = 2019
loadTons = 12.5
2. Mini JSON. Escribe toJson(Object obj), que devuelva los campos como JSON: los nombres entre comillas, el texto entre comillas, los números y true/false sin comillas. Usa StringBuilder e instanceof String.
class Ticket {
private int seat = 14;
private String event = "Concierto";
private double price = 45.0;
private boolean vip = false;
}
Salida para new Ticket() y new Room()
{"seat": 14, "event": "Concierto", "price": 45.0, "vip": false}
{"number": 214, "seaView": true}
3. Tu propio ejecutor de pruebas. Crea las anotaciones @MyTest y @MyBeforeEach y un ejecutor run(Class<?> type). Para cada método @MyTest: un objeto nuevo, luego el método con @MyBeforeEach (si lo hay) y luego la prueba. Si la prueba lanza una excepción, imprime el tipo y el mensaje de la excepción. Pruébalo con estas pruebas:
@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("Faltan " + (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("esperaba 60, pero es " + account.getBalance());
}
@MyTest
void withdraw70() {
account.withdraw(70);
if (account.getBalance() != 30) throw new AssertionError("esperaba 30, pero es " + account.getBalance());
}
@MyTest
void withdrawTooMuch() {
account.withdraw(130);
}
}
Salida
OK withdraw40
OK withdraw70
FALLA withdrawTooMuch: IllegalArgumentException: Faltan 30 €
Tests: 3, pasan: 2, fallan: 1

Test

Ponte a prueba

1. ¿Qué te da obj.getClass()?
2. ¿Qué tienes que llamar antes de leer un campo private con field.get(obj)?
3. En getDeclaredField("prise") hay una errata. ¿Cuándo te enterarás?
4. ¿Por qué tu propia anotación lleva @Retention(RetentionPolicy.RUNTIME)?
5. ¿Cómo encuentra JUnit los métodos con @Test?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·