Ця стаття призначена для людей, які ніколи не працювали з Анотаціями, але хотіли б розібратися, що це і з чим його їдять. Якщо ж у вас є досвід у цій сфері, не думаю, що ця стаття якось розширить ваші знання (та й, власне, такої мети я не переслідую).
Також стаття не підходить для тих, хто тільки починає вивчати мову Java. Якщо ви не розумієте, що таке Map<> або HashMap<>, або не знаєте, що означає запис static{ } всередині визначення класу, або ж ніколи не працювали з рефлексією – вам зарано читати цю статтю і намагатися зрозуміти, що таке анотації. Сам по собі цей інструмент не створений для використання новачками, тому що потребує вже не зовсім базового розуміння взаємодії класів та об'єктів (моя думка) (дякую коментарям за те, що показали необхідність цього доповнення).
Отже, приступимо.
Анотації в Java є свого роду мітками в коді, що описують метадані для функції/класу/пакету. Наприклад, всім відома анотація @Override, яка показує, що ми збираємося перевизначити метод батьківського класу. Так, з одного боку, можна і без неї, але якщо у батьків не виявиться цього методу, є ймовірність, що ми даремно писали код, тому що конкретно цей метод може і не викликатися ніколи, а з анотацією @Override компілятор нам скаже, що: "Я не знайшов такого методу у батьків... щось тут не так".
Однак анотації можуть нести в собі не тільки сенс "для надійності": в них можна зберігати якісь дані, які згодом будуть використовуватися.
Отже, приступимо.
Анотації в Java є свого роду мітками в коді, що описують метадані для функції/класу/пакету. Наприклад, всім відома анотація @Override, яка показує, що ми збираємося перевизначити метод батьківського класу. Так, з одного боку, можна і без неї, але якщо у батьків не виявиться цього методу, є ймовірність, що ми даремно писали код, тому що конкретно цей метод може і не викликатися ніколи, а з анотацією @Override компілятор нам скаже, що: "Я не знайшов такого методу у батьків... щось тут не так".
Однак анотації можуть нести в собі не тільки сенс "для надійності": в них можна зберігати якісь дані, які згодом будуть використовуватися.
Для початку розглянемо простіші анотації, надані стандартною бібліотекою.
(знову ж таки дякую коментарям, спочатку не подумав, що цей блок потрібен) Спочатку обговоримо, які бувають анотації. Кожна з них має 2 головних обов'язкових параметра:- Тип збереження (Retention);
- Тип об'єкта, над яким вона вказується (Target).
Тип збереження
Під "типом збереження" мається на увазі стадія, до якої "доживає" наша анотація всередині класу. Кожна анотація має лише один із можливих "типів збереження", вказаний у класі RetentionPolicy:- SOURCE - анотація використовується тільки при написанні коду і ігнорується компілятором (тобто не зберігається після компіляції). Зазвичай використовується для якихось препроцесорів (умовно), або вказівок компілятору
- CLASS - анотація зберігається після компіляції, але ігнорується JVM (тобто не може бути використана під час виконання). Зазвичай використовується для якихось сторонніх сервісів, які підвантажують ваш код в якості plug-in додатку
- RUNTIME - анотація, яка зберігається після компіляції і підвантажується JVM (тобто може використовуватися під час виконання самої програми). Використовується як мітки в коді, які безпосередньо впливають на хід виконання програми (приклад буде розглянуто в цій статті)
Тип об'єкта, над яким вказується
Цей опис слід розуміти майже буквально, тому що в Java анотації можуть вказуватися над чим завгодно (Поля, класи, функції тощо) і для кожної анотації вказується, над чим конкретно вона може бути задана. Тут вже немає правила "щось одне", анотацію можна вказувати над усім нижче переліченим, або ж вибрати тільки потрібні елементи класу ElementType:- ANNOTATION_TYPE - інша анотація
- CONSTRUCTOR - конструктор класу
- FIELD - поле класу
- LOCAL_VARIABLE - локальна змінна
- METHOD - метод класу
- PACKAGE - опис пакету package
- PARAMETER - параметр методу public void hello(@Annontation String param){}
- TYPE - вказується над класом
@Override
Retention: SOURCE; Target: METHOD. Ця анотація показує, що метод, над яким вона прописана, успадкований від батьківського класу. Перша анотація, з якою стикався кожен починаючий Java-програміст, при використанні IDE, яка наполегливо вставляє ці @Override. Часто вчителі з ютубу рекомендують або: "видаліть, щоб не заважало", або: "залиште, не замислюючись, навіщо воно тут". Насправді анотація більш ніж корисна: вона не тільки дозволяє зрозуміти, які методи були визначені в цьому класі вперше, а які вже є у батьків (що безперечно підвищує читабельність вашого коду), але також ця анотація слугує "самоперевіркою", що ви не помилилися при визначенні перевантаженої функції.@Deprecated
Retention: Runtime; Target: CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE. Ця анотація вказує на методи, класи або змінні, які є "застарілими" і можуть бути видалені в майбутніх версіях продукту. З цією анотацією зазвичай стикаються ті, хто читає документацію якихось API, або тієї ж стандартної бібліотеки Java. Іноді цю анотацію ігнорують, тому що вона не викликає ніяких помилок і в принципі сама по собі сильно жити не заважає. Однак головний посил, який несе в собі ця анотація – "ми придумали більш зручний спосіб реалізації цього функціоналу, використовуй його, не використовуй старий" - ну, або ж - "ми перейменували функцію, а це так, для легасі залишили..." (що теж, загалом, непогано). Коротше кажучи, якщо бачите @Deprecated - краще намагатися не використовувати те, над чим вона висить, якщо в цьому немає прямої крайньої необхідності і, можливо, варто перечитати документацію, щоб зрозуміти яким чином тепер реалізується задача, що виконується застарілим елементом. Наприклад, замість використання new Date().getYear() рекомендується використовувати Calendar.getInstance().get(Calendar.YEAR).@SuppressWarnings
Retention: SOURCE; Target: TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE Ця анотація відключає вивід попереджень компілятора, які стосуються елемента, над яким вона вказана. Є SOURCE анотацією, що вказується над полями, методами, класами.@Retention
Retention: RUNTIME; Target: ANNOTATION_TYPE; Ця анотація задає "тип збереження" анотації, над якою вона вказана. Так, ця анотація використовується навіть для самої себе... магія та й годі.@Target
Retention: RUNTIME; Target: ANNOTATION_TYPE; Ця анотація задає тип об'єкта, над яким може вказуватися створювана нами анотація. Так, і вона теж використовується для самої себе, звикайте... Думаю, на цьому можна завершити ознайомлення зі стандартними анотаціями бібліотеки Java, тому що решту використовують досить рідко і, хоч і приносять свою користь, стикатися з ними доводиться не всім і зовсім не обов'язково. Якщо ти хочеш, щоб я розповів про якусь конкретну анотацію зі стандартної бібліотеки (або, можливо, анотації типу @NotNull та @Nullable, які в STL не входять), напиши в коментарях — або тобі там дадуть відповідь добрі користувачі, або я, коли побачу. Якщо багато людей запросять якусь анотацію — також додам її в статтю.Практичне використання RUNTIME анотацій
Ну що ж, думаю, досить теоретичних розмов: давайте перейдемо до практики на прикладі бота. Припустимо, ти хочеш написати бота для якоїсь соцмережі. У всіх великих мереж, таких як ВК, Facebook, Discord, є свої API, які дозволяють написати бота. Для цих же мереж вже є написані бібліотеки для роботи з API, на мові Java в тому числі. Тому не будемо заглиблюватись у роботу якогось конкретного API або бібліотеки. Все, що нам потрібно знати в цьому прикладі — це те, що наш бот вміє реагувати на повідомлення, відправлені в чат, в якому, власне, і знаходиться наш бот. Тобто, припустимо, у нас є клас MessageListener з функцією:
public class MessageListener
{
public void onMessageReceived(MessageReceivedEvent event)
{
}
}
Вона відповідає за обробку отриманого повідомлення. Все, що нам потрібно від класу MessageReceivedEvent — рядок отриманого повідомлення (наприклад, "Привіт" або "Бот, привіт"). Варто врахувати: у різних бібліотеках ці класи називаються по-різному. Я використовував бібліотеку для Discord.
І ось ми хочемо зробити так, щоб бот реагував на якісь команди, які починаються з "Бот" (з комою чи без — вирішуй сам: для уроку припустимо, що коми там бути не має).
Тобто, вже наша функція починатиметься з чогось на кшталт:
public void onMessageReceived(MessageReceivedEvent event)
{
//Знімаємо чутливість до регістру (БоТ, бОт і т.д.)
String message = event.getMessage().toLowerCase();
if (message.startsWith("бот"))
{
}
}
І ось тепер перед нами є безліч варіантів реалізації тієї чи іншої команди. Безумовно, для початку потрібно відокремити команду від її аргументів, тобто розбити на масив.
public void onMessageReceived(MessageReceivedEvent event)
{
//Знімаємо чутливість до регістру (БоТ, бОт і т.д.)
String message = event.getMessage().toLowerCase();
if (message.startsWith("бот"))
{
try
{
//отримаємо масив {"Бот", "(команду)", "аргумент1", "аргумент2",... "аргументN"};
String[] args = message.split(" ");
//Для зручності приберемо "бот" та відокремимо команду від аргументів
String command = args[1];
String[] nArgs = Arrays.copyOfRange(args, 2, args.length);
//Отримали command = "(команда)"; nArgs = {"аргумент1", "аргумент2",..."аргументN"};
//Цей масив може бути порожнім
}
catch (ArrayIndexOutOfBoundsException e)
{
//Вивід списку команд або якогось повідомлення
//У випадку якщо просто написати "Бот"
}
}
}
Цей шматок коду нам ніяк не уникнути, тому що відокремлення команди від аргументів потрібно завжди. А ось далі вже у нас є вибір:
- Зробити if(command.equalsIngnoreCase("..."))
- Зробити switch(command)
- Зробити ще якийсь спосіб обробки...
- Або ж вдатися до допомоги Анотацій.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
//Вказує, що наша Анотація може бути використана
//Під час виконання через Reflection (нам якраз це потрібно).
@Retention(RetentionPolicy.RUNTIME)
//Вказує, що ціль нашої Анотації є метод
//Не клас, не змінна, не поле, а саме метод.
@Target(ElementType.METHOD)
public @interface Command //Опис. Зверни увагу, що перед interface стоїть @;
{
//Команда за яку буде відповідати функція (наприклад "привіт");
String name();
//Аргументи команди, використовуватимуться для виводу списку команд
String args();
//Мінімальна кількість аргументів, одразу присвоїли 0 (логічно)
int minArgs() default 0;
//Опис, теж для списку
String desc();
//Максимальна кількість аргументів. В цілому не обов'язково, але теж можна використовувати
int maxArgs() default Integer.MAX_VALUE;
//Показувати чи ні команду у списку (взагалі необов'язковий рядок, але мало чи, стане у нагоді!)
boolean showInHelp() default true;
//Які команди будуть вважатись еквівалентними нашій
//(Наприклад для "привіт", це може бути "Здаров", "Прив" і т.д.)
//Під кожний випадок заводити функцію - не раціонально
String[] aliases();
}
Важливо! Кожен параметр описується як функція (з круглими дужками). У якості параметрів можуть бути використані тільки примітиви, String, Enum. Не можна написати List<String> args(); — помилка.
Тепер, коли ми описали Анотацію, давайте заведемо клас, назвемо його CommandListener.
public class CommandListener
{
@Command(name = "привіт",
args = "",
desc = "Будь ввічливим, привітайся",
showInHelp = false,
aliases = {"здаров"})
public void hello(String[] args)
{
//Якийсь функціонал, на Твій розсуд.
}
@Command(name = "поки",
args = "",
desc = "",
aliases = {"удачі"})
public void bye(String[] args)
{
// Функціонал
}
@Command(name = "допомога",
args = "",
desc = "Виводить список команд",
aliases = {"help", "команди"})
public void help(String[] args)
{
StringBuilder sb = new StringBuilder("Список команд: \n");
for (Method m : this.getClass().getDeclaredMethods())
{
if (m.isAnnotationPresent(Command.class))
{
Command com = m.getAnnotation(Command.class);
if (com.showInHelp()) //Якщо потрібно показувати команду у списку.
{
sb.append("Бот, ")
.append(com.name()).append(" ")
.append(com.args()).append(" - ")
.append(com.desc()).append("\n");
}
}
}
//Відправка sb.toString();
}
}
Варто зазначити один невеликий недолік: оскільки ми зараз боремося за універсальність, всі функції повинні мати однаковий список формальних параметрів, тому навіть якщо у команди немає аргументів, у функції все одно повинен бути параметр String[] args.
Ми зараз описали 3 команди: привіт, поки, допомога.
Тепер давайте модифікуємо наш MessageListener так, щоб він якось з цим працював. Для зручності та швидкості роботи, будемо одразу зберігати наші команди в HashMap:
public class MessageListner
{
//Map який зберігає як ключ команду
//А як значення функцію, яка буде обробляти команду
private static final Map<String, Method> COMMANDS = new HashMap<>();
//Об'єкт класу з командами (по суті потрібен для рефлексії)
private static final CommandListener LISTENER = new CommandListener();
static
{
//Беремо список усіх методів у класі CommandListener
for (Method m : LISTENER.getClass().getDeclaredMethods())
{
//Дивимось, чи є у методу потрібна нам Анотація @Command
if (m.isAnnotationPresent(Command.class))
{
//Беремо об'єкт нашої Анотації
Command cmd = m.getAnnotation(Command.class);
//Кладемо як ключ нашої мапи параметр name()
//Визначений у нашій анотації,
//m — змінна, що зберігає наш метод
COMMANDS.put(cmd.name(), m);
//Також додаємо кожен елемент aliases
//Як ключ, що вказує на той самий метод.
for (String s : cmd.aliases())
{
COMMANDS.put(s, m);
}
}
}
}
public void onMessageReceived(MessageReceivedEvent event)
{
String message = event.getMessage().toLowerCase();
if (message.startsWith("бот"))
{
try
{
String[] args = message.split(" ");
String command = args[1];
String[] nArgs = Arrays.copyOfRange(args, 2, args.length);
Method m = COMMANDS.get(command);
if (m == null)
{
//(вивід допомоги)
return;
}
Command com = m.getAnnotation(Command.class);
if (nArgs.length < com.minArgs())
{
//щось, якщо аргументів менше, ніж потрібно
}
else if (nArgs.length > com.maxArgs())
{
//щось, якщо аргументів більше, ніж потрібно
}
//Через рефлексію викликаємо нашу функцію-обробник
//Саме тому що ми завжди передаємо nArgs у функції має бути параметр
//String[] args — інакше вона просто не буде знайдена;
m.invoke(LISTENER, nArgs);
}
catch (ArrayIndexOutOfBoundsException e)
{
//Вивід списку команд чи якогось повідомлення
//У випадку, якщо просто написати "Бот"
}
}
}
}
Ось власне і все, що потрібно, щоб наші команди працювали. Тепер додавання нової команди — це не новий if, не новий case, у яких потрібно було б знову враховувати кількість аргументів, також довелося б переписувати help, додаючи в нього нові рядки. Тепер же, щоб додати команду, нам потрібно просто в класі CommandListener додати нову функцію з анотацією @Command і все — команда додана, випадки враховані, help доповнений автоматично.
Абсолютно безсумнівно, що цю задачу можна вирішити багатьма іншими способами. Так, все, що можна зробити за допомогою анотацій/рефлексій, можна зробити і без них, питання лише в зручності, оптимальності та розмірах коду, звісно, засовувати Анотацію всюди, де є найменший натяк на те, що вийде її використати - теж не найрозумніший варіант, у всьому треба знати міру =). Але при написанні API, Бібліотек або програм, у яких можливе повторення однотипного (але не цілком однакового) коду, анотації - безсумнівно оптимальне рішення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ