Заметки на полях о 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
Java 7 и новое API для непрямого вызова методов
Теперь две новости — хорошая и плохая. Начну с хорошей — есть альтернативный путь для непрямого вызова методов, не используя рефлексию, а плохая — разработчикам под платформу Андроид этот путь закрыт. Да, конечно, мы можем в проекте использовать switch со строками, ромбовидный оператор и это как бы Java 7, но все мы в душе понимаем, что это лишь «синтаксический обман», а что-то большее спрятано от нас. Вот это именно такой случай с пакетом java.lang.invoke. Android Studio даже будет специально игнорировать этот пакет, чтоб у нас не было соблазна его использовать. Если покопаться в исходниках Android, то можно наткнуться вот на это, а активность по коммитам показывает что работа идет. Вывод — Google работает над этим, ну а время покажет. Ладно, хватит об Андроиде, давайте попробуем разобраться, в чем же основная идея данного механизма вызова методов. Идея в том, что теперь можно получить типизированную ссылку на метод (конструктор, поле) — дескриптор метода. Чтоб было понятнее перейдем к примеру:
MethodHandles.Lookup lookup = MethodHandles.lookup(); MethodHandle toStrMH = lookup.findVirtual(Object.class,"toString", MethodType.methodType(String.class)); //String str = (String) toStrMH.invokeExact((Object) this); String str = (String) toStrMH.invoke(this);
MethodHandles.lookup()
определяет контекст поиска метода. Т.е. определив его в своем классе, мы получаем доступ ко всем методам своего класса и к другим методам, к которым мы можем получить доступ непосредственно из нашего класса. Из этого выходит, что мы не можем получить доступ к закрытым методам системных классов, к которым могли бы достучаться через рефлексию. MethodHandle
— это и есть дескриптор метода, который включает в себя неизменяемый экземпляр типа MethodType
, содержащий возвращаемый тип и набор параметров данного метода. Ну и собственно с помощью методов invokeExact()
и invoke()
мы можем вызвать метод, на который и указывает MethodHandle
. Отличаются они тем, что invokeExact()
принимает в качестве аргумента объект именно того типа, который ожидает получить базовый метод, а в нашем случае это тип java.lang.Object
. Метод invoke()
менее строгий и может проводить дополнительные преобразования над аргументом, с целью подогнать его под необходимый тип. Конечно, нельзя не упомянуть о том, что это все стало возможным благодаря введению новой инструкции invokedynamic и для любознательных рекомендую посмотреть данный доклад.
Java 9
Как подсказали в комментариях к данной статье, в Java 9 появились модули. Что это и чем чревато для нашего кода, использующего рефлексию? Модуль — это именованный, самоописываемый набор кода и данных. С введением модулей, также расширяются правила организации доступа к исходному коду. Каждый модуль содержит файл module-info.java
, в котором указаны имя модуля, список всех пакетов, которые считаются публичным API этого модуля и список модулей, от которых зависит данный модуль. Так вот важный момент в том, что публичные классы, которые содержатся в модуле, но не входят в публичный API этого модуля, т.е. находятся в других пакетах, которые не были объявлены в файле module-info.java
как экспортируемые — не будут доступны за пределами этого модуля. И вот тут нам не поможет рефлексия. Но зато мы сможем в рантайме получать информацию о модуле, вызвав метод getModule()
на экземпляре класса java.lang.Class<T>
, который соответствует необходимому нам классу. Здесь можно ознакомиться с так называемым базовым модулем, который будет доступен по умолчанию всем модулям, а значит и будет подвластен рефлексии.
Выводы
Конечно, была показана лишь часть того, что можно сделать с помощью рефлексии, но я постарался показать одни из самых интересных и не всегда очевидных моментов. Конечно, если Вы думаете, а не добавить ли себе в проект немного подобного кода, то скорее всего не стоит этого делать, т.к. в данном случае проект будет сильно зависим от закрытой части чужих библиотек, а скрытое API может часто меняться и с каждым таким изменением надо будет подпирать приложение очередным костылем. Также рефлексия гораздо медленнее прямых вызовов, а это значит, что производительности это точно не добавит в приложение. Ну и наконец это очень простой способ сломать логику работы сторонней библиотеки или Android-фреймворка, что может привести к трудно отслеживаемым ошибкам.