Глубокое погружение в Java: рефлексия и загрузчик классов. Часть 1

Парадигмы программирования можно сравнить с территориями. На каждой территории, от императивного до декларативного программирования, установлены строгие законы поведения, диктующие, что можно делать, а что нельзя. Но чаще всего разработчики оказываются на территории, где приходится осуществлять то, что не одобряется или запрещается. В таких случаях начинается поиск лазеек и способов применения системы для решения насущных задач. Иногда обнаруживаются законы, которые разрешают действия, противоречащие основным принципам системы.

В этой статье я покажу, как две уникальные особенности Java позволяют создать полезные антишаблоны на территории Java.

Территория ООП

Объектно-ориентированное программирование (ООП) является одной из наиболее распространенных моделей программирования. Оно предполагает, что логика должна строиться вокруг определенных программой типов данных. Java-разработчики сначала определяют основные типы данных, необходимые в программе, а затем включают логику в классы и определяют способы взаимодействия этих классов друг с другом.

Хотя такой подход обладает общепризнанными преимуществами, статически типизированному языку, каковым является Java, не хватает гибкости. Java требует строгого определения типов данных на этапе компиляции. Это не всегда выполнимо, поскольку часто возникают ситуации, в которых невозможно предугадать на момент разработки программы, с какими именно типами данных она будет иметь дело. Кроме того, Java-программы не обеспечивают эволюционирование классов во время выполнения. Во многих случаях это также может стать трудно разрешимой задачей. Рассмотрим некоторые проблемы проектирования.

Проектирование фреймворков

Говоря простыми словами, фреймворк  —  это кодовая база, которая облегчает разработку, предоставляя структуру компонентов, в некотором роде скелет, на основе которого пользователи фреймворка могут создавать проекты. Определяемые пользователем типы могут быть подключены к фреймворку для настройки.

Например, инженер-тестировщик может осознать, что чаще всего реализует одни и те же шаги по тестированию API сервиса. Причем различия в коде заключаются в основном в типах данных, представляющих сам сервис. Он понимает, что большая часть его кода пригодна для повторного использования и может быть обобщена в виде фреймворка, который будет использоваться в дальнейшем при тестировании сервиса.

Чистое ООП в данном случае создает проблему, которая заключается в том, что приходится строить логику для неоднозначных типов данных. Полезным является фреймворк, который является достаточно общим, чтобы его можно было использовать для тестирования широкого спектра сервисов. Это в корне противоречит статически типизированным языкам, таким как Java, которые требуют манипулировать типами данных через определенные классы.

Проектирование для горячей замены

Программное обеспечение с возможностью “горячей замены” позволяет изменять компоненты во время выполнения без паузы в исполнении. Например, для предприятия часто бывает затратно останавливать работу приложений для обновления кода. Поэтому оно будет стремиться разрабатывать приложения, API которых остаются модифицируемыми и после развертывания.

Первая трудность, возникающая при решении этой задачи, заключается в том, что, подобно фреймворкам, логика должна строиться на основе изменяемых компонентов и, следовательно, неоднозначных типов данных. Кроме того, эта проблема связана с дополнительной сложностью загрузки нового кода во время выполнения, что представляет собой отдельную дополнительную проблему.

Приведенные выше примеры демонстрируют две сложные задачи объектно-ориентированного программирования на языке Java.

  1. Возможность проектирования для неоднозначных типов данных, то есть типов данных, классы которых неизвестны и не могут быть определены во время разработки.
  2. Возможность эволюционирования типов данных с изменением их классов во время выполнения программы.

Посмотрим, как пара взаимосвязанных возможностей Java  —  рефлексия и загрузчики классов  —  позволяют решать две вышеперечисленные проблемы и создавать невероятно гибкие Java-программы.

Рефлексия в Java

Пожалуй, самой популярной особенностью Java является то, что это статически типизированный язык.

Статическая типизация и вызов

Есть класс Foo:

public class Foo{

public void doSomething(){

System.out.println("I did something");
}

Обычно метод doSomething() вызывается следующим образом:

Foo foo = new Foo();
foo.doSomething();

Приведенный выше код является примером статической типизации и вызова. Переменная foo считается статически типизированной, поскольку ее тип разрешается в тип Foo во время компиляции. Аналогично, компилятор связывает вызов метода doSomething() с методом экземпляра, определенным в классе Foo. При статической типизации и вызове такие языковые явления, как переменные и вызовы методов, можно рассматривать так, будто они фиксируют определенные типы перед выполнением. Для успешной компиляции необходимо, чтобы определения классов стояли на первом месте.

Естественно, что универсальные фреймворки, предназначенные для работы с любыми типами без ограничения интерфейсов, не могут использовать статическую типизацию и вызов. Вместо этого в Java для создания таких фреймворков используется малоизвестный стиль программирования, называемый рефлексивным программированием. Рефлексивное программирование позволяет нарушить статически типизированную природу Java-программ. Оно переносит присвоение типов на время выполнения, а не на время компиляции, что известно как динамическая типизация.

Рефлексивное программирование

Вот определение из учебника “Java Reflection in Action”:

“Рефлексия  —  это способность работающей программы исследовать себя и свое программное окружение и изменять свое поведение в зависимости от того, что она обнаружила”.

Основную деятельность разработчика можно условно разделить на две категории: творческий аспект в проектировании надежных сопровождаемых программ и программирование, эквивалентное механическому труду. Например, разработчики часто пишут код, который зависит от внешних модулей или компонентов. При изменении внешнего модуля разработчику нередко приходится копаться в коде и переписывать вызовы методов.

Рефлексивное программирование облегчает разработчику вторую задачу. Вместо того чтобы вручную выполнять такую утомительную работу, как рефакторинг кода, патчинг JAR и модификацию вызовов методов, рефлексия позволяет писать программы, которые могут делать выбор, обычно принимаемый человеком, например выбирать между классом X и классом Y или вызывать новый метод вместо старого.

Чтобы понять это, необходимо разобраться в том, как классы существуют внутри виртуальной машины Java (JVM). JVM  —  это изолированная среда, в которой выполняются Java-программы. По большей части она не знает о своем хост-устройстве и его файловой системе, то есть не знает о файлах .java и .class, которые создаются в процессе разработки и компиляции (подробнее об этом в разделе “Загрузчики классов”). JVM имеет собственную внутреннюю память, в которой хранит все необходимые данные, требующиеся ей во время выполнения программы, в том числе данные о классах программы. Ее память состоит из нескольких компонентов (область методов, область кучи, область стека, регистры ПК, область нативных методов).

Область кучи

Область кучи JVM (Heap Area)  —  это динамическое пространство памяти, в котором хранятся текущие объекты программы. Именно здесь объекты живут и существуют как программные единицы. Область кучи делится на три основные части.

  • Young Generation (молодое поколение). Часть, где хранятся вновь созданные объекты.
  • Old Generation (старшее поколение). Часть, где хранятся долгоживущие объекты, т. е. объекты, которые пережили определенное количество циклов сборки мусора в молодом поколении, прежде чем были переведены в старшее поколение.
  • MetaSpace (метапространство). Часть, предназначенная для хранения специальных типов объектов, называемых метаобъектами (представляют метаданные программы). Здесь также хранится байт-код методов.

Чтобы язык Java вел себя ожидаемо, среда JVM должна отслеживать метаданные о программе, что позволяет ей корректно выполняться. Например, для конкретного класса JVM должна хранить такую информацию, как его модификаторы доступа, методы и их типы (статический метод или метод экземпляра). Для каждого метода хранится информация о количестве параметров и их типах, а также о возвращаемом типе метода. JVM хранит метаданные программы наиболее известным ей способом  —  в объектах, называемых метаобъектами. Создатели Java определили специальный набор классов, используемых JVM внутри программы, которые представляют компоненты программы и предоставляют доступ к ним. Классы, интерфейсы и методы программы моделируются как объекты, которые живут в метапространстве (в Java все является объектами, даже классы).

В пакете java.lang определен класс для типа Class:

public final class Class<T> extends Object implements Serializable, GenericDeclaration, Type, AnnotatedElement

Этот класс определяет метаобъект, представляющий класс. Каждый пользовательский класс будет иметь соответствующий объект Class, инстанцированный для него в JVM. Класс Class определяет множество интересных методов, большинство из которых предоставляют информацию о структуре класса:

public Field[] getFields() {..}
/* Возвращает массив, содержащий объекты Field,
представляющие все доступные публичные поля класса или интерфейса,
представленного данным объектом Class.*/

public Annotation[] getAnnotations() {..}
/* Возвращает аннотации, присутствующие в классе. */

public Method[] getMethods() {..}
/* Возвращает массив, содержащий объекты Method,
представляющие все публичные методы класса или интерфейса,
представленного данным объектом Class. */

public T newInstance(){..}
/* Создает новый экземпляр класса, представленного данным объектом Class. */

Аналогично, в java.lang определен класс Method, который определяет метаобъекты, представляющие метод. Он также содержит несколько API, которые могут предоставить информацию о структуре конкретного метода, моделируемого объектом Method:

public final class Method extends Executable
public Annotation [] getDeclaredAnnotations(){..}
/* Возвращает аннотации, присутствующие в данном методе. */

public Class<?> getDeclaringClass(){..}
/* Возвращает объект Class,
представляющий класс или интерфейс, который объявляет метод,
представленный данным объектом Method */

public Class<?>[] getParameterTypes(){..}
/* Возвращает массив объектов Class, представляющих типы параметров методов */

public Class<?> getReturnType(){..}
/* Возвращает объект Class,
представляющий тип возврата метода,
который представлен данным объектом Method. */

public Object invoke(Object obj, Object... args){..}
/* Вызывает базовый метод,
представленный данным объектом Method,
используя экземпляр класса метода и массив объектов в качестве аргументов */.

Рекомендую изучить в официальной документации API этих классов и других метаобъектов, таких как Field и Annotation. Совокупность метаобъектов в метапространстве представляет собой саму программу.

Тип Java является живым во время выполнения программы. Это не статическая сущность, а скорее живой дышащий объект в JVM.

Метаобъекты в метапространстве, хотя и имеют выделенную часть кучи, не ограничиваются только внутренним использованием JVM  —  они доступны для работающих программ. Программа может проводить самообследование, обращаясь к представляющим ее метаобъектам. Более того, метаобъекты открывают доступ к той части программы, которую они представляют. Как было показано выше, класс Class определяет метод newInstance() и с его помощью может вернуть объект-экземпляр класса, который он представляет. Аналогично, класс Method определяет метод invoke(), с помощью которого может быть вызван представляемый им метод.

Вернемся к определению рефлексии.

Рефлексия  —  это способность выполняющейся программы исследовать себя и свое программное окружение (путем обращения к метаобъектам, таким как объекты Class, представляющие ее классы, и обнаружения их внутренней структуры) и изменять свои действия в зависимости от того, что она обнаружила (поскольку можно выбрать вызов части программы, представленной определенным метаобъектом).

Но как именно это реализуется?

Динамическая типизация и вызов

Как следует из предыдущего раздела, необходимость в рефлексии в основном возникает при динамической типизации и вызове. Вызов метода doSomething() класса Foo может быть выполнен альтернативно с использованием рефлексии следующим образом:

String className ="org.example.Foo";
String methodName = "doSomething";

try {
// Получаем Class объекта foo (рефлексивно - объект класса Foo)
Class cls = Class.forName(className);

// Рефлексивно создаем новый экземпляр foo
Object obj = cls.getDeclaredConstructor().newInstance();

// Получаем метод doSomething
Method method= cls.getDeclaredMethod(methodName);

// Рефлексивно вызваем метод doSomething
method.invoke(obj);
}catch(Exception e){
}

Приведенный выше код выполняет динамическую типизацию и вызов метода. При заданных переменных className и methodName он рефлексивно получает объект Class целевого класса и извлекает объект Method целевого метода, через который вызывает метод. Обратите внимание, что если задать в этих двух переменных разные имена классов и методов, то один и тот же код будет выполнять разные методы, поскольку во время компиляции он не привязан к какому-либо определенному типу.

Практический пример: JUnit

JUnit  —  это популярный фреймворк для автоматизации тестирования Java-приложений. Он позволяет автоматизировать многие аспекты тестирования, такие как отображение агрегированных отчетов о результатах тестирования или выполнение повторяющихся тестовых примеров.

Преимуществом JUnit является его универсальность: он может вызывать тестовые методы любого пользовательского класса, и пользователям даже не нужно придерживаться какого-либо интерфейса при разработке тестовых классов.

Для настройки JUnit в основном опирается на аннотации. Аннотации  —  это часть языка Java, которая используется для добавления метаданных или дополнительной информации к Java-программам без непосредственного влияния на аннотируемый код.

JUnit определяет аннотацию @Test для “маркировки” тестовых методов. Это означает, что JUnit знает, какие пользовательские методы следует запускать посредством этой аннотации. Например, пользователь может определить следующий класс:

public class TestsBatch {

public int sum (int a, int b){
return a+b;
}
@Test
public void test1() {
assertEquals(10, sum(3,7));
}
@Test
public void test2() {
assertEquals(100, sum(20,80));
}

JUnit запускает методы test1() и test2(), поскольку они снабжены аннотацией @Test. Работать так, как задумано, JUnit позволяет широкое использование рефлексии. С помощью рефлексии пользовательские классы обнаруживаются во время выполнения программы и проверяются на наличие методов с аннотацией @Test. Затем эти методы динамически вызываются. Базовая логика, на которой работает фреймворк, достаточно просто реализуется с помощью API рефлексии. Она заключается в следующем.

  1. Обнаружить/перебрать все определяемые пользователем классы.
  2. Для каждого класса получить его методы и проверить их.
  3. Для каждого метода проверить наличие аннотации @Test. Если она есть, то необходимо вызвать этот метод и обработать его результаты.

Все описанные выше действия показаны в приведенном ниже коде:

File[] files = new File("src/test/java/test").listFiles();

// перебор файлов по пути, в котором существуют определенные пользователем классы
for (File file : files) {
String fileName = file.getName();

// получение объекта Class, соответствующего текущему файлу
Class c = Class.forName("test." + fileName.substring(0, fileName.indexOf(".")));

// проверка того, что класс не является интерфейсом или перечислением
if (!c.isInterface() && !c.isEnum()){
// получение методов, принадлежащих классу
Method[] methods = c.getDeclaredMethods();

for (Method method : methods) {
// проверка наличия аннотации @test у метода
Annotation annotation= method.getAnnotation(Test.class);

if(annotation!=null){
// создание нового экземпляра текущего класса
Object obj = c.getDeclaredConstructor().newInstance();
// вызов текущего метода
Object result = method.invoke(obj);
// выполнение необходимой обработки объекта результата



}
}
}

Примечание: этот пример написан мной и не относится к JUnit. Однако JUnit использует рефлексию в своих операциях.

Изначально воспринимаемая неинтуитивно (и, возможно, порождающая самый уродливый код), рефлексия оказывается очень полезной функцией, когда статически типизированная природа Java накладывает ограничения. Более того, она позволяет разработчикам создавать динамические системы и автоматизировать многие трудоемкие аспекты программирования.

В следующем разделе рассмотрим еще одну интересную особенность JVM  —  загрузчики классов, которые в паре с рефлексией позволяют создавать принципиально открытые расширяемые системы.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Habiba Gamil: A Deep Dive into ClassLoader and Reflection — Dynamic Typing and Runtime Modifiable Classes in Java

Предыдущая статьяПочему в React важен порядок вызова хуков?
Следующая статьяКак разделить монолитное приложение на микрофронтенды