Заметки на полях о 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-фреймворка, что может привести к трудно отслеживаемым ошибкам.

Почитать по теме

Похожие статьи:
Меня зовут Максим, я работаю тестировщиком ПО, с интересом слежу за событиями в мире тестирования и IT. Самое полезное собираю вместе...
Buying a car is a big deal! For most of us, outside of possibly a house, a car is one of the biggest ticket purchases we will make in our lifetime. When making this purchase there is so much to consider. From the brand to the size, to how it...
Менее чем две недели назад все информационное пространство шумело новостью о том, что Amazon покупает стартап Ring. Мы не стали медлить...
В рубрике DOU Проектор все желающие могут презентовать свой продукт (как стартап, так и ламповый pet-проект). Если вам есть о чем...
Всем привет! В этом номере вы найдете наиболее достойные материалы за январь-февраль среди тех, что попали ко мне в руки,...
Яндекс.Метрика