
A todos nos encanta detectar errores en la etapa de compilación, en lugar de excepciones de tiempo de ejecución. La forma más fácil de solucionarlos es que el compilador mismo muestra todos los lugares que necesitan ser reparados. Aunque la mayoría de los problemas solo se pueden detectar cuando se inicia el programa, todavía estamos tratando de hacerlo lo antes posible.
En bloques de inicialización de clases, en constructores de objetos, en la primera llamada de un método, etc. Y a veces tenemos suerte, e incluso en la etapa de compilación sabemos lo suficiente como para verificar que el programa no tenga ciertos errores.
En este artículo quiero compartir la experiencia de escribir una de esas pruebas. Más precisamente, creando una anotación que puede arrojar errores, como lo hace el compilador. A juzgar por el hecho de que no hay tanta información sobre este tema en RuNet, las situaciones felices descritas anteriormente no son frecuentes.
Describiré el algoritmo de verificación general, así como todos los pasos y matices por los que pasé tiempo y las células nerviosas.
Declaración del problema.
En esta sección, daré un ejemplo del uso de esta anotación. Si ya sabe qué verificación desea hacer, puede omitirla de manera segura. Estoy seguro de que esto no afectará la integridad de la presentación.
Ahora hablaremos más sobre mejorar la legibilidad del código que sobre corregir errores. Un ejemplo, se podría decir, de la vida, o más bien de mi proyecto de hobby.
Supongamos que hay una clase UnitManager, que, de hecho, es una colección de unidades. Tiene métodos para agregar, eliminar, obtener una unidad, etc. Al agregar una nueva unidad, el gerente le asigna una identificación. La generación de id se delega a la clase RotateCounter, que devuelve un número en el rango dado. Y hay un pequeño problema, RotateCounter no puede saber si la identificación seleccionada está libre. De acuerdo con el principio de inversión de dependencia, puede crear una interfaz, en mi caso es RotateCounter.IClient, que tiene un único método isValueFree (), que recibe id y devuelve verdadero si id es libre. Y UnitManager implementa esta interfaz, crea una instancia de RotateCounter y se la pasa a sí misma como cliente.
Yo solo hice eso. Pero, después de abrir la fuente de UnitManager unos días después de escribir, entré en un estupor fácil después de ver el método isValueFree (), que realmente no se ajustaba a la lógica de UnitManager. Sería mucho más simple si fuera posible especificar qué interfaz implementa este método. Por ejemplo, en C #, de donde vine a Java, una implementación de interfaz explícita ayuda a hacer frente a este problema. En este caso, en primer lugar, puede llamar al método solo con una conversión explícita a la interfaz. En segundo lugar, y más importante en este caso, el nombre de la interfaz (y sin el modificador de acceso) se indica explícitamente en la firma del método, por ejemplo:
IClient.isValueFree(int value) { }
Una solución es agregar una anotación con el nombre de la interfaz que implementa este método. Algo así como
@Override
, solo con una interfaz. Estoy de acuerdo, puedes usar una clase interna anónima. En este caso, al igual que en C #, el método no solo se puede invocar en el objeto, y puede ver de inmediato qué interfaz implementa. Pero, esto aumentará la cantidad de código, por lo tanto, degradará la legibilidad. Sí, y de alguna manera necesita obtenerlo de la clase: cree un captador o campo público (después de todo, tampoco hay sobrecarga de sentencias de conversión en Java). No es una mala opción, pero no me gusta.
Al principio, pensé que en Java, como en C #, las anotaciones son clases completas y se pueden heredar de ellas. En este caso, solo necesita crear una anotación que herede de
@Override
. Pero esto no fue así, y tuve que sumergirme en el asombroso y aterrador mundo de los cheques en la etapa de compilación.
Código de muestra de UnitManager public class Unit { private int id; } public class UnitManager implements RotateCounter.IClient { private final Unit[] units; private final RotateCounter idGenerator; public UnitManager(int size) { units = new Unit[size]; idGenerator = new RotateCounter(0, size, this); } public void addUnit(Unit unit) { int id = idGenerator.findFree(); units[id] = unit; } @Implement(RotateCounter.IClient.class) public boolean isValueFree(int value) { return units[value] == null; } public void removeUnit(int id) { units[id] = null; } } public class RotateCounter { private final IClient client; private int next; private int minValue; private int maxValue; public RotateCounter(int minValue, int maxValue, IClient client) { this.client = client; this.minValue = minValue; this.maxValue = maxValue; next = minValue; } public int incrementAndGet() { int current = next; if (next >= maxValue) { next = minValue; return current; } next++; return current; } public int range() { return maxValue - minValue + 1; } public int findFree() { int range = range(); int trysCounter = 0; int id; do { if (++trysCounter > range) { throw new IllegalStateException("No free values."); } id = incrementAndGet(); } while (!client.isValueFree(id)); return id; } public static interface IClient { boolean isValueFree(int value); } }
Poco de teoría
Haré una reserva de inmediato, todos los métodos anteriores son instancia, por lo tanto, por brevedad,
<_>.<_>()
los nombres de los métodos con el nombre del tipo y sin parámetros:
<_>.<_>()
El procesamiento de elementos en la etapa de compilación implica clases especiales de procesador. Estas son clases que heredan de
javax.annotation.processing.AbstractProcessor
(simplemente puede implementar la interfaz
javax.annotation.processing.Processor
). Puede leer más sobre procesadores
aquí y
aquí . El método más importante es el proceso. En el que podemos obtener una lista de todos los elementos anotados y realizar las verificaciones necesarias.
@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) { return false; }
Al principio, sinceramente ingenuo, pensé que trabajar con tipos en la etapa de compilación se lleva a cabo en términos de reflexión, pero ... no. Todo se basa en elementos allí.
Elemento (
javax.lang.model.element.Element ): la interfaz principal para trabajar con la mayoría de los elementos estructurales del lenguaje. Un elemento tiene descendientes que determinan con mayor precisión las propiedades de un elemento en particular (para más detalles, consulte
aquí ):
package ds.magic.example.implement;
TypeMirror (
javax.lang.model.type.TypeMirror ) es algo así como Class <?> Devuelto por el método getClass (). Por ejemplo, se pueden comparar para averiguar si los tipos de elementos coinciden. Puede obtenerlo utilizando el método
Element.asType()
. Este tipo también devuelve algunas operaciones de tipo, como
TypeElement.getSuperclass()
o
TypeElement.getInterfaces()
.
Tipos (
javax.lang.model.util.Types ): le aconsejo que eche un vistazo más de cerca a esta clase. Puedes encontrar muchas cosas interesantes allí. En esencia, este es un conjunto de utilidades para trabajar con tipos. Por ejemplo, le permite recuperar un TypeElement de un TypeMirror.
private TypeElement asTypeElement(TypeMirror typeMirror) { return (TypeElement)processingEnv.getTypeUtils().asElement(typeMirror); }
TypeKind (
javax.lang.model.type.TypeKind ): una enumeración que le permite aclarar la información de tipo, verificar si el tipo es una matriz (ARRAY), un tipo personalizado (DECLARADO), una variable de tipo (TYPEVAR), etc. Puede obtenerlo a través de
TypeMirror.getKind()
ElementKind (
javax.lang.model.element.ElementKind ) - enumeración, le permite aclarar información sobre el elemento, verificar si el elemento es un paquete (PAQUETE), clase (CLASE), método (MÉTODO), interfaz (INTERFAZ), etc.
Nombre (
javax.lang.model.element.Name ): la interfaz para trabajar con el nombre del elemento se puede obtener a través de
Element.getSimpleName()
.
Básicamente, estos tipos fueron suficientes para que yo escribiera un algoritmo de verificación.
Quiero señalar otra característica interesante. Las implementaciones de las interfaces de Element en Eclipse están en los paquetes org.eclipse ..., por ejemplo, los elementos que representan los métodos son del tipo
org.eclipse.jdt.internal.compiler.apt.model.ExecutableElementImpl
. Esto me dio la idea de que cada IDE implementa estas interfaces de forma independiente.
Algoritmo de validación
Primero necesitas crear la anotación misma. Ya se ha escrito mucho al respecto (por ejemplo,
aquí ), por lo que no me detendré en esto en detalle. Solo puedo decir que para nuestro ejemplo, necesitamos agregar dos anotaciones
@Target
y
@Retention
. El primero indica que nuestra anotación solo se puede aplicar al método, y el segundo que la anotación solo existirá en el código fuente.
Las anotaciones deben especificarse qué interfaz implementa el método anotado (el método al que se aplica la anotación). Esto se puede hacer de dos maneras: especifique el nombre completo de la interfaz con una cadena, por ejemplo
@Implement("com.ds.IInterface")
, o pase la clase de interfaz directamente:
@Implement(IInterface.class)
. La segunda forma es claramente mejor. En este caso, el compilador supervisará el nombre correcto de la interfaz. Por cierto, si llama a este
valor de miembro
(), al agregar anotaciones al método, no necesitará especificar explícitamente el nombre de este parámetro.
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.SOURCE) public @interface Implement { Class<?> value(); }
Entonces comienza la diversión: la creación del procesador. En el método de proceso, obtenemos una lista de todos los elementos anotados. Luego obtenemos la anotación en sí y su significado: la interfaz especificada. En general, el marco de clase de procesador se ve así:
@SupportedAnnotationTypes({"ds.magic.annotations.compileTime.Implement"}) @SupportedSourceVersion(SourceVersion.RELEASE_8) public class ImplementProcessor extends AbstractProcessor { private Types typeUtils; @Override public void init(ProcessingEnvironment procEnv) { super.init(procEnv); typeUtils = this.processingEnv.getTypeUtils(); } @Override public boolean process(Set<? extends TypeElement> annos, RoundEnvironment env) { Set<? extends Element> annotatedElements = env.getElementsAnnotatedWith(Implement.class); for(Element annotated : annotatedElements) { Implement annotation = annotatedElement.getAnnotation(Implement.class); TypeMirror interfaceMirror = getValueMirror(annotation); TypeElement interfaceType = asTypeElement(interfaceMirror);
Quiero señalar que no puede obtener y obtener anotaciones de valor así como así. Cuando intente llamar a
annotation.value()
, se generará una
MirroredTypeException , pero de ella puede obtener un TypeMirror. Este método de trampa, así como la recepción correcta del valor, encontré
aquí :
private TypeMirror getValueMirror(Implement annotation) { try { annotation.value(); } catch(MirroredTypeException e) { return e.getTypeMirror(); } return null; }
La verificación en sí misma consta de tres partes, si al menos una de ellas falla, deberá mostrar un mensaje de error y pasar a la siguiente anotación. Por cierto, puede mostrar un mensaje de error utilizando el siguiente método:
private void printError(String message, Element annotatedElement) { Messager messager = processingEnv.getMessager(); messager.printMessage(Kind.ERROR, message, annotatedElement); }
El primer paso es verificar si las anotaciones de valor son una interfaz. Aquí todo es simple:
if (interfaceType.getKind() != ElementKind.INTERFACE) { String name = Implement.class.getSimpleName(); printError("Value of @" + name + " must be an interface", annotated); continue; }
A continuación, debe verificar si la clase en la que se encuentra el método anotado implementa realmente la interfaz especificada. Al principio, tontamente implementé esta prueba con mis manos. Pero luego, con buenos consejos, miré
Tipos y encontré el método
Types.isSubtype()
allí, que verificará todo el árbol de herencia y devolverá verdadero si la interfaz especificada está allí. Es importante destacar que puede funcionar con tipos genéricos, a diferencia de la primera opción.
TypeElement enclosingType = (TypeElement)annotatedElement.getEnclosingElement(); if (!typeUtils.isSubtype(enclosingType.asType(), interfaceMirror)) { Name className = enclosingType.getSimpleName(); Name interfaceName = interfaceType.getSimpleName(); printError(className + " must implemet " + interfaceName, annotated); continue; }
Finalmente, debe asegurarse de que la interfaz tenga un método con la misma firma que la anotada. Me gustaría usar el método
Types.isSubsignature()
, pero, desafortunadamente, no funciona correctamente si el método tiene parámetros de tipo. Así que nos remangamos y escribimos todos los cheques con nuestras manos. Y tenemos tres de ellos otra vez. Bueno, más precisamente, la firma del método consta de tres partes: el nombre del método, el tipo del valor de retorno y la lista de parámetros. Debe pasar por todos los métodos de la interfaz y encontrar el que pasó las tres comprobaciones. Sería bueno no olvidar que el método puede heredarse de otra interfaz y realizar las mismas comprobaciones recursivas para las interfaces subyacentes.
La llamada debe colocarse al final del ciclo en el método de proceso, así:
if (!haveMethod(interfaceType, (ExecutableElement)annotatedElement)) { Name name = interfaceType.getSimpleName(); printError(name + " don't have \"" + annotated + "\" method", annotated); continue; }
Y el método haveMethod () en sí se ve así:
private boolean haveMethod(TypeElement interfaceType, ExecutableElement method) { Name methodName = method.getSimpleName(); for (Element interfaceElement : interfaceType.getEnclosedElements()) { if (interfaceElement instanceof ExecutableElement) { ExecutableElement interfaceMethod = (ExecutableElement)interfaceElement;
¿Ves el problema? No? Y ella está ahí. El hecho es que no pude encontrar una manera de obtener los parámetros de tipo reales para las interfaces genéricas. Por ejemplo, tengo una clase que implementa la interfaz
Predicate :
MyPredicate implements Predicate<String> { @Implement(Predicate.class) boolean test(String t) { return false; } }
Al analizar el método en la clase, el tipo de parámetro es
String
, y en la interfaz es
T
, y todos los intentos de obtener
String
lugar no condujeron a nada. Al final, no se me ocurrió nada mejor que simplemente ignorar los parámetros de tipo. La verificación se pasará con cualquier parámetro de tipo real, incluso si no coinciden. Afortunadamente, el compilador arrojará un error si el método no tiene una implementación predeterminada y no está implementado en la clase base. Pero aún así, si alguien sabe cómo solucionar esto, estaré extremadamente agradecido por la pista.
Conéctese a Eclipse
Personalmente, amo Eclipce y en mi práctica solo lo usé. Por lo tanto, describiré cómo conectar el procesador a este IDE. Para que Eclipse vea el procesador, debe empaquetarlo en un archivo .JAR separado, en el que también estará la anotación. En este caso, debe crear la carpeta
META-INF / services en el proyecto y crear el archivo
javax.annotation.processing.Processor allí e indicar el nombre completo de la clase de procesador:
ds.magic.annotations.compileTime.ImplementProcessor
, en mi caso. Por si acaso, daré una captura de pantalla, pero cuando nada funcionó para mí, casi comencé a pecar en la estructura del proyecto.

A continuación, recopile .JAR y conéctelo a su proyecto, primero como una biblioteca normal, para que la anotación sea visible en el código. Luego conectamos el procesador (
aquí está más detallado). Para hacer esto, abra las
propiedades del
proyecto y seleccione:
- Compilador Java -> Procesamiento de anotaciones y marque la casilla "Habilitar procesamiento de anotaciones".
- Compilador Java -> Procesamiento de anotaciones -> Ruta de fábrica marque la casilla de verificación "Habilitar configuraciones específicas del proyecto". Luego haga clic en Agregar JAR ... y seleccione el archivo JAR creado anteriormente.
- Acuerda reconstruir el proyecto.
Resumen
Todos juntos y en el proyecto Eclipse se pueden ver en
GitHub . Al momento de escribir, solo hay dos clases, si la anotación se puede llamar así: Implement.java e ImplementProcessor.java. Creo que ya has adivinado su propósito.
Quizás esta anotación pueda parecer inútil para algunos. Quizás lo es. Pero personalmente, yo mismo lo uso en lugar de
@Override
, cuando los nombres de los métodos no se ajustan bien al propósito de la clase. Y hasta ahora, no deseo deshacerme de ella. En general, hice una anotación para mí mismo, y el propósito del artículo era mostrar qué rastrillo estaba atacando. Espero haberlo hecho Gracias por su atencion
PS. Gracias a los usuarios de
ohotNik_alex y
Comdiv por su ayuda en la
corrección de errores.