Нотатки на полях Java Reflection API
Всім привіт, мене звуть Євген Кузьменко, я Android-розробник і сьогодні хочу розповісти про деяких цікавих моментах, з якими можна зіткнутися при роботі з Java Reflection (далі просто рефлексія). Хочу звернути вашу увагу, що це не вступна стаття, а скоріше набір заміток з особистого досвіду, про яких буде цікаво дізнатися, а ще це корисно для трішки більшого розуміння, що ж там відбувається «під капотом».
Варто уточнити для молодих фахівців (а може і не тільки), чиї уми розбурхує можливість домінувати, панувати і принижувати використовувати рефлексію, що її застосування часто несе за собою подвійний витрата карми, але бувають випадки, коли без цього не обійтися і просто необхідно увірватися в світ рантайма.
Тепер за традицією, кілька слів, що ж це таке рефлексія і навіщо це все взагалі треба. І так рефлексія — це засіб мови програмування Java, необхідне для отримання інформації про завантажені в пам'ять класах, об'єктах, інтерфейси і подальшої роботи з ними на етапі виконання програми. Навіщо це треба? Обробка метаінформації про класи, властивості, методи, параметри, за допомогою обробки анотацій (привіт Retrofit); створення проксі-об'єктів, наприклад для модульного тестування; зміна стану і/або поведінки системи за допомогою модифікації властивостей об'єктів; створення примірників класів за заданим типом і багато іншого.
Робота з класами через Reflection API
Основним класом для роботи з Reflection API java.lang.Class<T>, примірник якого можна отримати, наприклад для java.lang.String, декількома способами:
- за допомогою виклику методу на строковому литерале "abc".getClass(),
- використовуючи конструкцію Class.forName (java.lang.String"),
- через завантажувач класів,
- просто вказавши String.class.
Все це і можна умовно вважати відображенням (рефлексією) класу String на клас java.lang.Class<T>. Саме з його допомогою ми можемо отримати всю інформацію про завантаженому класі таку як: методи класу і всієї ієрархії класів, реалізовані інтерфейси, дані про полях класу, анотації для яких вказаний @Retention(value= RetentionPolicy.RUNTIME). Ну начебто все зрозуміло і легко, клас ми отримали далі роби все, що душі забажається, але тут закрався один хитрий момент. При спробі отримати клас з допомогою виклику методу Class.forName(“com.example.СlassName") ми можемо отримати виняток ClassNotFoundException. Хоча ми на 100% впевнені, що він є в системі. Як таке може бути? Щоб відповісти на це питання треба трохи розібратися з процесом завантаження класів. Звичайно докладне обговорення виходить за рамки даної статті, але ось основна і спрощена ідея. Є три основних завантажувача класів, вони викликаються ієрархічно в наступному порядку: системний завантажувач, завантажувач розширень, базовий завантажувач. При завантаженні класу відбувається пошук даного класу в кеші системного завантажувача, і в разі успішного пошуку він повертає шуканий клас, в іншому випадку — делегує вищестоящому в ієрархії завантажувачу. Якщо ми дійшли до базового завантажувача, але в кеші так і не виявилося потрібного класу, то у зворотному порядку завантажувачі намагаються завантажити його, передаючи управління вже вниз по ієрархії, поки що клас не буде завантажений, якщо клас не вдалося знайти і завантажити буде викинуто виняток ClassNotFoundException.
Тепер важливо зрозуміти два моменту:
- кожен завантажувач класів визначає свій простір імен,
- може бути визначений власний завантажувач.
Логічно, що користувальницький завантажувач теж визначає власний простір імен для завантажуваних класів. І ось тут і криється відповідь на наше запитання, звідки ж береться цей ClassNotFoundException, якщо клас завантажений в пам'ять. Даний клас існує в іншому просторі імен, т. к. був завантажений іншим завантажувачем і, можливо, навіть в іншому процесі (привіт WebViewChromium). Так от метод Class.forName(“com.example.ClassName") завжди використовує завантажувач, з допомогою якого він був загружений і виконує пошук по своєму простору імен. Строго кажучи, якщо завантажувачі слідують моделі делегування, то через них можуть завантажуватися і класи вищих завантажувачів шляхом делегування завантаження, ну а якщо вони не дотримуються цієї моделі, то нам необхідно явно вказувати завантажувач класів, використовуючи перевантажений метод Class.forName(“com.example.className", true, classLoader).
Конкретно для Android-платформи ми також можемо отримати завантажувач класів іншої програми, використовуючи наступний код:
Context someAppContext = context.createPackageContext( "com.package.SomeClass", Context.CONTEXT_INCLUDE_CODE|Context.CONTEXT_IGNORE_SECURITY); Class<?> cl = Class.forName("com.package.SomeClass", true, someAppContext.getClassLoader());
або створити екземпляр завантажувача класів з файлів *.apk або *.jar, використовуючи PathClassLoader, DexClassLoader: Приклад наведено нижче:
String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "someName.jar"; PathClassLoader pathClassLoader = new PathClassLoader(dexPath, getClassLoader()); Class loadedClass1 = pathClassLoader.loadClass("com.example.loader.Class"); DexClassLoader dexClassLoader = new DexClassLoader(dexPath, getDir("dex", 0).getAbsolutePath(), null, getClassLoader()); Class loadedClass2 = dexClassLoader.loadClass("com.example.loader.Class");
Слід також згадати про вкладених класах і як такі класи завантажувати. Звичайно, перше, що може прийти в голову — написати щось на зразок:
Class.forName(“com.example.OuterClass.NestedClass");
Але правильно вказати ім'я класу не вийде, якщо не знати, як після компіляції буде виглядати вкладений клас, а він буде мати наступний вигляд com.example.OuterClass$NestedClass, а значить і завантажений він буде точно також, т. е. щоб такий клас завантажити нам потрібно буде викликати:
Class.forName(“com.example.OuterClass$NestedClass")
І так ми завантажили клас, тепер з'ясуємо кілька моментів. Тут головне зрозуміти що — getDeclaredMethod повертає нам методи з будь-яким специфікатором доступу і тільки для даного класу або інтерфейсу, а getMethod у свою чергу повертає тільки публічні методи, але зате вміє шукати методи в батьківському класі. Ось і виходить, що універсальним рішенням виходить використання getDeclaredMethod, але з дрібкою рекурсії:
@Nullable public static Method getMethod(Class<?> clazz, String methodName, Class<?>... params){ if (clazz != null) { try { return clazz.getDeclaredMethod(methodName, params); } catch (NoSuchMethodException e) { return getMethod(clazz.getSuperclass(), methodName, params); } } return null; }
Цей же підхід можна застосувати і до методів getField(...) і getDeclaredField(...), оскільки вони ведуть себе точно також, тільки повертають поля класу або інтерфейсу. До речі про полях! Всім нам відомо, що final поле не може бути змінено. Але ми можемо це зробити за допомогою рефлексії і ось приклад коду:
void setStaticFinalField(Field field, Object newValue) throws Exception { field.setAccessible(true); // set private field as public Field modifiersField = Field.class.getDeclaredField("modifiers"); modifiersField.setAccessible(true); modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); field.set null, newValue); }
Для статичної змінної ми можемо передати null в якості першого аргументу методом field.set(...), що приймає об'єкт, в якому ми хочемо провести зміни. Але от біда, якщо запустити цей код у додатку під Android, то він не буде працювати. Але це легко виправити, досить замінити ім'я поля modifiers на accessFlags і final поля піддадуться навіть на Андроїд. Гаразд, я мушу визнати, що з final полями насправді все трохи складніше. Розглянемо простий приклад:
public class TestClass { public final int a; public final int b; public static final int c = 10; public TestClass(int a) { this.a = a; this.b = 5; } public void printA() { System.out.println("a =" + a); } public void printB() { System.out.println("b =" + b); } public void printC() { System.out.println("c =" + c); } } public class ReflectionTest { public static void main(String[] args) { try { TestClass test = new TestClass(1); System.out.println("before"); test.printA(); test.printB(); test.printC(); System.out.println("after"); setFinalField(TestClass.class.getField("a"), 2, test); test.printA(); setFinalField(TestClass.class.getField("b"), 7, test); test.printB(); setFinalField(TestClass.class.getField("c"), 100, null); test.printC(); } catch (Exception e) { e.printStackTrace(); } } static void setFinalField(Field field, Object newValue, Object receiver) throws Exception { Field modifiersField = Field.class.getDeclaredField("modifiers"); modifiersField.setAccessible(true); modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); field.set(receiver, newValue); } }
Так от після виконання даного коду, в консоль буде виведено наступне:
before
a = 1
b = 5
c = 10
after
a = 2
b = 7
c = 10
І уважний читач помітить, що ми присвоїли константі значення 100, але у висновку консолі значення як було 10, так і залишилося. Справа в тому, що ми маємо справу з оптимізуючих компілятором javac, який з метою прискорення наших з вами програм, виробляє якісь поліпшення нашого коду. В даному випадку компілятор намагається провести вбудовування констант, яке працює для примітивних типів і java.lang.String. Що це означає? Якщо на етапі компіляції компілятор впевнений, що це константа, і він точно знає її значення (як у нашому випадку з константою з), то просто відбувається заміна звернення до цієї константі на її значення. Більш наочно це можна побачити в байткоде. Дивимося, як виглядають методи printB() і printC():
public printB()V L0 LINENUMBER 20 L0 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; NEW java/lang/StringBuilder DUP INVOKESPECIAL java/lang/StringBuilder.<init> ()V LDC "b = " ... public printC()V L0 LINENUMBER 24 L0 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; LDC "c = 10" ...
Нас цікавить інструкція LDC , ось тут і тут можна про неї почитати. Як бачимо, у наведеному вище прикладі, в першому випадку в пул констант поміщається просто рядок, а в другому випадку вже рядок з вбудованим значенням 10, тому наші зміни з допомогою рефлексії і не дають видимого результату. А що в Андроїді? А там все аналогічно, адже ми знаємо, що спочатку java класи створюються за допомогою javac і тільки потім в DEX байткод. JIT компілятор теж може виробляти свої оптимізації на етапі виконання програми, тому це теж потрібно тримати в розумі. Ну гаразд, а що там з іншими final ссылочными типами, які ми змінюємо з допомогою рефлексії? Строго кажучи, змінити final поле можна відразу після створення об'єкта і до того, як інші потоки отримають на нього посилання, в такому випадку все буде гарантовано працювати. Але ж нам треба міняти колись потім, і ми можемо це зробити, і воно по ідеї буде працювати, завдяки memory barrier . Ну і що стосується Андроїда, то, починаючи з версії 4.0 (Ice Cream Sandwich), він повинен слідувати JSR-133(Java Memory Model).
Звичайно, міняти закриті властивості об'єкта з допомогою рефлексії це погана ідея, також як і викликати його приватні методи, т. к. це з великою ймовірністю вплине на поведінку всієї системи і стабільність її роботи буде порушена.
Proxy і InvocationHandler
Отже, ми підійшли до ще однієї цікавої теми, а саме — створення проксі-об'єктів. Почну з поганої новини — ми можемо створити проксі тільки для інтерфейсу або набору інтерфейсів. Ось простий приклад коду:
Foo f = (Foo) Proxy.newProxyInstance(Foo.class.getClassLoader, new Class[] { Foo.class }, handler);
Хороша новина — ми можемо перехоплювати дзвінки методів даного проксі. А навіщо це потрібно, адже ми ж можемо створити свій примірник інтерфейсу і додати туди необхідну логіку, припустимо трасування виклику методів! Так, зрозуміло, ми можемо, але уявіть, що треба взяти якийсь інтерфейс, який існує тільки в рантайме і у вихідному коді немає до неї доступу, та ще цей інтерфейс містить метод зворотного виклику, і нам треба знати, коли він викликається. Ось тут і знадобиться нам Proxy з InvocationHandler. Ось приклад створення InvocationHandler:
public class SampleInvocationHandler implements InvocationHandler{ private Object obj; public SampleInvocationHandler(Object obj) { this.obj = obj; } public Object invoke(Object proxy, Method m, Object[] args)...{ if(m.getName().startsWith("get")){ System.out.println("...get Method Executing..."); } return m.invoke(obj, args); //return null; //bad idea } }
У даному прикладі метод invoke(...) буде викликатися всякий раз при виклику будь-якого методу нашого проксі-об'єкта. Тут потрібно звернути увагу на обчислене значення методу invoke(...). Ми не завжди можемо розташовувати об'єктом obj, а якщо в інтерфейсі, для якого ми згенерували проксі, всього один метод, який повертає void, то може здатися гарною ідеєю повертати null метод invoke(...). Але тут криється помилка, яка може проявити себе набагато пізніше. Просто для згенерованого проксі додаються ще стандартні методи класу Object, оскільки всі класи від нього успадковуються за замовчуванням. І виходить, що припустимо при виклику методу equals(...) або toString() буде повертатися null, і це призведе до помилки часу виконання!
Kotlin і рефлексія
Я думаю багато хто вже так чи інакше придивлялися до Kotlin, може навіть вже встигли написати кілька додатків, використовуючи його як основну мову програмування. Звичайно компанія JetBrains подбала про сумісність свого дітища з Java, але що там з рефлексією? Адже базові типи відрізняються у цих двох мов, у Kotlin базовий тип Any, а не Object. Та якщо ми спробуємо витягти клас з допомогою Int::class, то отримаємо KClass... Але ми ж тільки підключили Jackson(Gson?!?) і хочемо отримувати Class, а не KClass! Заспокойтеся, вихід є, і навіть дещо! Дивимося на приклад:
val a = 1::class.java //int val b = 1::class.javaObjectType //class java.lang.Integer val c = 1::class.javaPrimitiveType //int val d = 1.javaClass //int
Так, давайте розбиратися. У Kotlin все є об'єктом, а значить ми можемо легко дозволити собі написати щось начебто 1::class, 1.2.compareTo(1) і т. д., і з цим все зрозуміло. Тепер у нас з вами в розпорядженні є чотири способи отримати клас, але в чому сила брат різниця, запитаєте ви? Докладно розбирати, як відбувається процес мапінгу класів Java в Kotlin і назад ми не будемо, тому що на цю тему можна написати окрему статтю (до речі, може варто її написати?) просто розглянемо відмінності, щоб було спільне розуміння. Отже 1::class.java завжди повертає нам Class<T>, який асоційований з даним типом об'єктом на рівні стандартної бібліотеки мови. Другий приклад 1::class.javaObjectType поверне вже об'єктний/посилальний тип, а не примітив. Адже всім нам відомо, що в мові Java є примітивний тип int і посилальний тип Integer, який так необхідний нам для повноцінної роботи з колекціями. Тобто це властивість як раз і повертає нам саме обгортки для примітивних типів в Java. Третій варіант 1::class.javaPrimitiveType поверне знову int, тут важливо зрозуміти ось що — Kotlin вже всередині містить маппінг на примітивні типи Java і повертає їх. Якщо спробувати отримати примітивний тип від String, то дана властивість поверне нам null. Четвертий спосіб швидко отримати тип — це використовувати 1.javaClass, він буде працювати аналогічно 1::class.java і, якщо подивитися на вихідний код цієї властивості, то там просто відбувається приведення поточного типу в java.lang.Object і взяття його класу за допомогою методу getClass().
Більш детальну інформацію можна отримати в офіційній документації , а також звернути увагу на опис вмісту пакету kotlin.reflect
Висновки
Звичайно, була показана лише частина того, що можна зробити за допомогою рефлексії, але я постарався показати одні з найцікавіших і не завжди очевидних моментів. Звичайно, якщо Ви думаєте, а не додати собі в проект трохи подібного коду, то швидше за все не варто цього робити, оскільки в даному випадку проект буде сильно залежимо від закритої частини чужих бібліотек, а приховане API може часто змінюватися і з кожним таким зміною треба буде підпирати додаток черговим милицею. Також рефлексія набагато повільніше прямих викликів, а це значить, що продуктивність це точно не додасть в додаток. Ну і нарешті це дуже простий спосіб зламати логіку роботи сторонньої бібліотеки або Android-фреймворку, що може привести до важко відслідковувати помилок.
Почитати по темі
Опубліковано: 09/03/17 @ 11:37
Розділ Різне
Рекомендуємо:
DOU Проектор: Cardiomo – монітор вашого здоров'я
.NET дайджест #15: відродження Alt.NET, .NET Core одним пакетом, що таке микросервис
Як подивитися посилання на сторінку сайту безкоштовно
Creative Quarter: як підняти зарплати програмістам та одночасно зекономити гроші клієнту
DOU Проектор: Hebron IT Academy — школа комп'ютерній комп'ютерних технологій для хлопців-сиріт