Java: магия отражений. Часть III. Компиляция Java средствами Java::Журнал СА 2.2003
www.samag.ru
     
Поиск   
              
 www.samag.ru    Web  0 товаров , сумма 0 руб.
E-mail
Пароль  
 Запомнить меня
Регистрация | Забыли пароль?
Журнал "Системный администратор"
Журнал «БИТ»
Наука и технологии
Подписка
Где купить
Авторам
Рекламодателям
Архив номеров
Контакты
   

  Опросы
  Статьи

Электронный документооборот  

5 способов повысить безопасность электронной подписи

Область применения технологий электронной подписи с каждым годом расширяется. Все больше задач

 Читать далее...

Рынок труда  

Системные администраторы по-прежнему востребованы и незаменимы

Системные администраторы, практически, есть везде. Порой их не видно и не слышно,

 Читать далее...

Учебные центры  

Карьерные мечты нужно воплощать! А мы поможем

Школа Bell Integrator открывает свои двери для всех, кто хочет освоить перспективную

 Читать далее...

Гость номера  

Дмитрий Галов: «Нельзя сказать, что люди становятся доверчивее, скорее эволюционирует ландшафт киберугроз»

Использование мобильных устройств растет. А вместе с ними быстро растет количество мобильных

 Читать далее...

Прошу слова  

Твердая рука в бархатной перчатке: принципы soft skills

Лауреат Нобелевской премии, специалист по рынку труда, профессор Лондонской школы экономики Кристофер

 Читать далее...

1001 и 1 книга  
19.03.2018г.
Просмотров: 9931
Комментарии: 0
Потоковая обработка данных

 Читать далее...

19.03.2018г.
Просмотров: 8141
Комментарии: 0
Релевантный поиск с использованием Elasticsearch и Solr

 Читать далее...

19.03.2018г.
Просмотров: 8247
Комментарии: 0
Конкурентное программирование на SCALA

 Читать далее...

19.03.2018г.
Просмотров: 5222
Комментарии: 0
Машинное обучение с использованием библиотеки Н2О

 Читать далее...

12.03.2018г.
Просмотров: 5906
Комментарии: 0
Особенности киберпреступлений в России: инструменты нападения и защита информации

 Читать далее...

Друзья сайта  

 Java: магия отражений. Часть III. Компиляция Java средствами Java

Архив номеров / 2003 / Выпуск №2 (3) / Java: магия отражений. Часть III. Компиляция Java средствами Java

Рубрика: Программирование /  Веб-программирование

ДАНИИЛ АЛИЕВСКИЙ

Java: магия отражений

Часть III. Компиляция Java средствами Java

В предыдущих частях статьи мы познакомились с технологией отражений (Java Reflection). Это мощнейший механизм Java, позволяющий делать с .class-файлами практически все что угодно – загружать из произвольных файлов, анализировать набор членов класса, обращаться к этим членам, при необходимости обходя стандартную защиту «private»/«protected». При желании можно даже подменить стандартный механизм загрузки Java-классов и взять этот процесс под полный контроль, например, разрешить перезагружать изменившиеся версии .class-файлов без полной перезагрузки Java-машины (эта техника подробно рассматривалась в части II, см. №1(2) журнала «Системный администратор»).

В этой части статьи мы научимся компилировать Java-код в .class-файлы. Совместно с технологией отражений это позволит в процессе исполнения программы «на лету» создавать новые классы в виде исходного текста, компилировать их, загружать и использовать. Столь мощные возможности обычно присущи лишь чисто интерпретируемым, сравнительно медленным языкам типа JavaScript или Perl или Ассемблеру (точнее, машинному языку).

Всюду далее, если не оговорено обратное, мы будем подразумевать последнюю (на момент написания статьи) версию Java фирмы Sun: Sun Java SDK 1.4.

Как скомпилировать Java-файл с исходным текстом

Решение, вообще говоря, совершенно банально – вызвать стандартный компилятор javac!

На первый взгляд подобное решение кажется неизящным, сопряженным с массой проблем. Например, компилятор придется поставлять всем вашим пользователям. Другой пример – интеграция компилятора с вашей системой: сообщения компилятора должны выдаваться не на системную консоль, а «внутрь» вызывающей программы для обработки и визуализации в рамках вашего пользовательского интерфейса.

Разработчики Java позаботились о том, чтобы эти проблемы легко решались.

Прежде всего, стандартный компилятор javac входит в комплект поставки Sun Java SDK, распространяемого совершенно бесплатно. Правда, здесь есть одна тонкость.

При формировании дистрибутива Java-приложения обычно принято включать в этот дистрибутив некий фрагмент Java SDK, так называемый JRE (Java2 TM Runtime Environment) – набор файлов, достаточный для запуска Java-приложения. В комплекте Sun Java SDK этот набор оформлен в виде подкаталога jre/. По умолчанию JRE не содержит компилятора javac (и ряда других полезных утилит из Java SDK). Включать в дистрибутив полный пакет Java SDK запрещено лицензионным соглашением фирмы Sun.

Однако в том же лицензионном соглашении специально оговорено, что компилятор байт-кода javac вместе с необходимым вспомогательным JAR-файлом tools.jar можно включать в дистрибутив в дополнение к стандартному JRE, точнее, распространять совместно с Java-приложением. См. файл jre/license в комплекте поставки Sun Java SDK 1.4.1, раздел «JavaTM 2 runtime environment (j2re), standard edition, version 1.4.1_x supplemental license terms», пункт 3 и файл jre/README.txt, раздел «Redistribution of Java 2 SDK Files».

Но самое приятное заключается в том, что в действительности компилятор фирмы Sun реализован на том же языке Java – в виде класса com.sun.tools.javac.Main и пакета вспомогательных классов, размещенных в архиве tools.jar. Утилита javac является всего-навсего «оболочкой», стартующей виртуальную машину Java и запускающей указанный класс. Архив tools.jar, как и саму утилиту javac, разрешается свободно распространять (в дополнение к стандартному JRE) совместно с Java-приложением.

Это означает, что для компиляции Java-класса из Java-приложения нет необходимости обращаться к внешней утилите javac средствами операционной системы (методами Runtime.getRuntime().exec(...)). Можно напрямую воспользоваться классом com.sun.tools.javac.Main.

Использование класса com.sun.tools.javac.Main предельно просто. Вот полный интерфейс этого класса (конструктор и public-методы):

// Constructors

public Main()

// Methods

public static void main(String[] p0)

public static int compile(String[] p0)

public static int compile(String[] p0, PrintWriter p1)

Для вызова компилятора нужно обратиться к к одному из двух его static-методов compile.

В качестве аргумента p0 нужно передать массив строк-параметров, которые обычно передаются утилите javac, например:

new String[] {

"-d",

"/путь_к_подкаталогу",

"myfile.java"

}

В качестве аргумента p1 во второй из этих методов нужно передать поток вывода, который будет использоваться для вывода всех сообщений компилятора. Таким образом можно «перехватить» все сообщения компилятора и перенаправить их в свой собственный буфер, который впоследствии можно проанализировать или показать пользователю.

В качестве результата оба метода возвращают стандартный код возврата утилиты javac (errorlevel в терминах MS-DOS). 0 сигнализирует об успешном завершении, другие значения – о каких-либо ошибках.

Приведем пример вызова компилятора путем обращения к классу com.sun.tools.javac.Main:

String[] args= параметры утилиты javac;

CharArrayWriter writer= new CharArrayWriter();

int result= com.sun.tools.javac.Main.compile(

args,

new PrintWriter(writer,true));

if (result!=0) {

 /* - произошла какая-то ошибка */

 анализируем и, возможно, показываем пользователю строку сообщений компилятора writer.toString()

}

Описанное решение очень просто, удобно и эффективно. Но у него есть серьезный недостаток.

Класс com.sun.tools.javac.Main, как и все классы из пакетов com.sun.* и sun.*, является недокументированным. Приведенное выше описание использования класса опирается на здравый смысл и эксперименты, а не на официальную документацию фирмы Sun. Фирма Sun имеет полное право в очередной версии Java SDK изменить поведение или интерфейс этого класса или даже вообще исключить его.

Указанная проблема не надумана.

Действительно, все сказанное выше справедливо лишь для версии Sun Java SDK 1.4. В предыдущей версии, Sun Java SDK 1.3, тот же самый класс com.sun.tools.javac.Main имел совершенно другой интерфейс:

// Constructors

public Main()

// Methods

public static void main(String[] p0)

public int compile(String[] p0)

Метод compile не был статическим, т.е. нуждался в создании экземпляра класса. Также отсутствовала версия метода, позволяющая указать собственный поток для сообщений компилятора.

В версии Java SDK 1.3 для компиляции исходных текстов Java обычно использовался другой класс, sun.tools.javac.Main (расположенный все в том же архиве tools.jar). Этот класс также позволяет указать свой поток для сообщений компилятора. Причем этот класс полностью сохранил свой интерфейс при переходе от версии SDK 1.3 к SDK 1.4.

Класс sun.tools.javac.Main имеет нестатический синхронизованный метод compile:

public synchronized boolean compile(String[] p0)

Поток для сообщений компилятора указывается в качестве первого параметра конструктора:

public Main(OutputStream p0, String p1)

(не PrintWriter, а более «архаичный» OutputStream). Смысл второго параметра конструктора я так и не выяснил, но найденные мной в Интернете примеры использования данного класса передавали в качестве p1 строку «javac».

Код возврата утилиты javac возвращается отдельным методом:

public int getExitStatus()

Факт успешности компиляции можно также узнать по boolean-результату метода compile (false означает неудачу).

Но и этот класс, несмотря на сохранение интерфейса, в действительности изменил свое поведение при переходе от версии SDK 1.3 к SDK 1.4.

Во-первых, он был объявлен как устаревший («deprecated»). Конечно, предупреждение компилятора, появляющееся при попытке использовать данный класс («warning: sun.tools.javac.Main in sun.tools.javac has been deprecated»), можно и проигнорировать. Но фирма Sun почему-то решила добавлять аналогичное предупреждение в любое сообщение, выдаваемое самим компилятором sun.tools.javac.Main! Если попытаться использовать sun.tools.javac.Main для компиляции любого, даже совершенно корректного Java-файла, в любом случае будет выдано предупреждение «sun.tools.javac.Main has been deprecated». Чтобы избавиться от него, sun.tools.javac.Main придется использовать с ключом «-nowarn», но тогда вообще теряется возможность получать и анализировать предупреждения компилятора.

Во-вторых, в версии Sun Java SDK 1.4 компилятор sun.tools.javac.Main попросту не всегда адекватно работает. На простых тестах это трудно обнаружить. Но когда я попытался скомпилировать с помощью этого компилятора все исходные тексты большого Java-проекта, обнаружилось, что некоторые сложные, но вполне корректные классы, прекрасно компилируемые «штатными» компиляторами и классом com.sun.tools.javac.Main, не компилируются с помощью sun.tools.javac.Main. В частности, компилятор sun.tools.javac.Main «сломался» на некоторых нетривиальных случаях перегрузки методов с аргументами примитивных типов, а также при попытке объявить метод toString() у некоторого вложенного класса. Это очень похоже на внутреннюю ошибку компилятора, которую фирма Sun не сочла нужным исправлять в устаревшем наборе классов.

Есть также мелкие отличия в самом синтаксисе языка Java, понимаемом компиляторами sun.tools.javac.Main и стандартным com.sun.tools.javac.Main. (Ошибка, о которой ранее шла речь, не связана с этими мелкими отличиями – там действительно имела место явная ошибка компилятора.) Например, sun.tools.javac.Main разрешает импортировать (предложением import) конкретные классы, расположенные в корневом пакете – т.е. непосредственно в корне одного из каталогов, перечисленных в путях поиска CLASSPATH. Стандартный компилятор в современных версиях Java не допускает такого экзотического импорта – все импортируемые классы должны лежать внутри какого-либо пакета.

Также можно заметить, что в версии Sun Java SDK 1.4 компилятор sun.tools.javac.Main работает примерно вдвое медленнее, чем com.sun.tools.javac.Main.

Все сказанное означает, что использование для компиляции Java-файлов конкретных классов типа com.sun.tools.javac.Main или sun.tools.javac.Main – рискованное занятие. Соответствующий код придется заново тестировать при выпуске каждой новой версии Java SDK и, возможно, в какой-то момент его придется радикально переписывать.

В случае com.sun.tools.javac.Main лично мне риск не кажется слишком большим. Похоже, что в этом классе фирма Sun наконец «довела до ума» решения, существовавшие в предыдущих версиях Java в этом же классе и в sun.tools.javac.Main. Трудно представить, чтобы возможности класса com.sun.tools.javac.Main в какой-то версии исчезли или радикально поменялись. Скорее всего, этот класс либо сохранится, либо превратится в легальный документированный класс, например, в пакете java.*, тогда необходимые изменения будут минимальны.

Если необходимо надежное документированное решение, то на сегодня единственный доступный вариант – вызвать внешнюю утилиту javac одним из методов Runtime.getRuntime().exec(...).

Для этого, правда, эту утилиту нужно еще найти в файловой системе. Если данная утилита входит в состав дистрибутива и инсталлируется вместе с Java-приложением, то инсталлятор может сам позаботиться о том, чтобы полный путь к утилите javac передавался в Java-программу. Если же приложение дожно работать под управлением «чужого» Java SDK, инсталлируемого пользователем независимо от приложения, то можно поискать файлы:

  • bin/javac.exe (случай Microsoft Windows);
  • bin/javac (случай Unix/Linux);
  • bin/sparcv9/javac (случай Solaris SPARC)

в каталоге System.getProperty(«java.home») и содержащем его каталоге. (Чаще всего System.getProperty(«java.home») соответствует подкаталогу jre/ в главном каталоге Sun Java SDK. Соответственно, утилита javac расположена в подкаталоге bin/ содержащего его каталога.)

Для получения сообщений компилятора в данном случае можно использовать стандартную технику – чтение из потоков, возвращаемых методами getInputStream() и getErrorStream() объекта Process, полученного в результате обращения к методу Runtime.getRuntime().exec(...).

Вот как примерно это выглядит:

Process p= Runtime.getRuntime().exec(массив_аргументов);

/* первый элемент в массиве должен содержать полное имя файла утилиты javac, остальные элементы – параметры этой утилиты */

final InputStreamReader is=new InputStreamReader(p.getInputStream());

final InputStreamReader es=new InputStreamReader(p.getErrorStream());

final StringBuffer out= new StringBuffer();

final StringBuffer err= new StringBuffer();

new Thread() {

  public void run() {

    try {

      char[] buf= new char[32768];

      int len;

      while ((len=is.read(buf,0,buf.length))>=0) {

        out.append(buf,0,len);

      }

    } catch (Exception e) {

      e.printStackTrace();

    }

  }

}.start();

new Thread() {

  public void run() {

    ... (аналогичный цикл для es и err)

  }

}.start();

int result= p.waitFor();

/* дожидаемся завершения утилиты javac и получаем ее код завершения */

if (result!=0) {

   /* - произошла какая-то ошибка */

   анализируем и, возможно, показываем пользователю сообщения компилятора out и err

}

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

Как скомпилировать исходный текст Java, заданный в виде строки

Итак, мы научились вызывать компилятор Java. Таким образом можно скомпилировать любой Java-файл – достаточно следовать инструкциям по использованию компилятора javac. Но что делать, если у нас нет готового Java-файла, размещенного где-то в файловой системе? Допустим, мы располагаем просто исходным текстом Java-программы в виде строки типа String – загруженным, скажем, из базы данных, или сгенерированным автоматически. Как скомпилировать такой текст?

Очевидно, нужно создать некоторый временный файл с расширением .java, записать туда исходный текст, после чего вызвать компилятор. Эти действия далеко не так просты, как кажется на первый взгляд. Рассмотрим это подробнее.

В стандартных библиотеках Java есть средства для создания временных файлов – это 2 статических метода createTempFile класса File, создающие файл с уникальным «случайным» именем. К сожалению, воспользоваться ими в данном случае невозможно. Новый Java-файл, создаваемый для сохранения заданного исходного Java-кода, не может иметь произвольное имя – его имя обязательно должно совпадать с именем public-класса, объявленного в этом исходном коде (если, конечно, таковой имеется). Кроме того, нужен какой-то каталог, куда будут записаны .class-файлы, полученные в результате компиляции.

Все это означает, что нужна специальная функция, создающая временный подкаталог – так же, как File.createTempFile создает временный файл. Внутри этого подкаталога можно создать серию вложенных каталогов, соответствующую пакету, в котором должен располагаться компилируемый класс. Затем в самый внутренний каталог нужно записать Java-файл с исходным текстом, присвоив этому файлу имя, соответствующее имени компилируемого класса. Эту же структуру каталогов можно использовать для размещения результирующих .class-файлов, передав соответствующие инструкции компилятору javac.

Написать функцию создания временного каталога не очень сложно. Достаточно использовать в качестве образца реализацию File.createTempFile в исходном тексте класса java.io.File. Основная идея – циклически генерировать более или менее случайные имена подкаталогов внутри каталога временных файлов операционной системы, для каждого подкаталога пытаться его создать (методом File.mkdir) и выйти из цикла, как только очередная попытка будет удачной (mkdir вернет true). Цикл генерации имен и создания подкаталога нужно синхронизовать относительно какого-либо глобального объекта точно так же, как это сделано в методе File.createTempFile. Чтобы найти умолчательный каталог временных файлов операционной системы, можно обратиться к переменной среды:

System.getProperty("java.io.tmpdir")

В версии Sun Java SDK 1.4.1 эта переменная является документированной (в отличие от Sun Java SDK 1.3).

Возникает также проблема автоматического удаления созданного временного подкаталога при выходе из Java-программы. В случае файла эта задача решалась бы методом deleteOnExit класса File. Но непустые подкаталоги этот метод удалять не умеет. Самое простое решение – написать функцию, рекурсивно удаляющую созданный временный каталог вместе со всеми файлами и подкаталогами, и вызвать ее в потоке, зарегистрированном методом Runtime.getRuntime().addShutdownHook(...).

Следующий вопрос, требующий некоторого внимания – как правильно записать Java-файл с исходным текстом. Исходный текст Java (представленный по условию в виде строки String) может содержать произвольные символы Unicode, а компилятор javac обычно используется с ASCII-файлами.

Здесь есть два решения. Во-первых, начиная с версии Sun Java SDK 1.4, компилятор javac «понимает» дополнительный параметр «-encoding». Можно, например, сохранить файл в кодировке «UTF-8» и указать такую же кодировку в качестве параметра «-encoding». Чтобы сохранить текст в файле с заданной кодировкой, используется объект java.io.Writer, создаваемый вызовом:

OutputStreamWriter writer= new OutputStreamWriter(new FileOutputStream(file),encoding)

Во-вторых, компилятор javac – и в SDK 1.4, и в предыдущих версиях – поддерживает специальный способ кодирования Unicode-символов. А именно, цепочка символов вида uNNNN, где N – шестнадцатеричные цифры, в любом месте Java-файла воспринимается компилятором как Unicode-символ с кодом NNNN. Можно перед записью Java-файла заменить все символы с кодами і128 такими цепочками и записать полученный «чистый» ASCII-файл.

После того как исходный текст скомпилирован, если компилятор сообщил об отсутствии ошибок (нулевой код возврата) и если параметры компилятора были заданы правильно (точнее, параметр «-d»), можно ожидать, что в структуре каталогов появятся все необходимые .class-файлы. Сколько их появится, заранее сказать невозможно (без полного синтаксического анализа исходного кода). Так, каждый анонимный класс породит отдельный .class-файл. Но, по крайней мере, должен появиться .class-файл, соответствующий главному public-классу, объявленному в исходном тексте (если, конечно, исходный текст не состоял из описаний одного или нескольких не-public-классов). В качестве последней «страховки» имеет смысл проверить существование этого .class-файла – его отсутствие говорит о неверных настройках компилятора.

Затем остается только загрузить сгенерированный .class-файл. Для этого нужен собственный загрузчик классов – наследник ClassLoader, умеющий загружать классы из нестандартного каталога (в нашем случае из временного каталога, созданного внутри каталога временных файлов операционной системы). Эта задача подробно рассматривалась во второй части статьи: «ClassLoader – скрытые возможности». Здесь мы не будем на ней останавливаться.

В завершение стоит добавить, что скомпилированные и загруженные классы имеет смысл кэшировать в оперативной памяти с помощью таблицы HashMap, ставящей каждому исходному тексту в соответствие результирующий объект Class – загруженный Java-класс. Если нужно скомпилировать исходный текст, идентичный тексту, который когда-то уже компилировался, то в большинстве случаев нет смысла компилировать его повторно – достаточно извлечь готовый класс из кэша.

Eval на Java: интерпретатор формул

Теперь мы располагаем чрезвычайно мощным инструментом. Мы умеем компилировать произвольные исходные тексты Java, заданные в виде строковой переменной, и загружать получаемые при этом классы. Обычно такую технику называют самопрограммированием. Эта возможность традиционно присутствует в медленных интерпретируемых скриптовых языках типа JavaScript или Perl, но отсутствует в высокоэффективных компилируемых языках (к которым относится и Java), исключая разве что Ассемблер. Фактически, мы снабдили язык Java самопрограммированием.

Но пока что пользоваться этой техникой не очень удобно.

var a= 23;

var b= 45;

var formula= "a+b";

var result= eval(formula);

Мы попробуем реализовать аналогичную технику в рамках Java.

Требуется реализовать статический метод некоторого класса-библиотеки (допустим, Evaluator), имеющий следующий интерфейс:

public Object eval(String javaExpression, Object context) throws Exception

В качестве javaExpression передается произвольное выражение Java, например то же «a+b». В качестве context передается объект, предоставляющий «пространство имен». Это значит, что в выражении javaExpression должна быть возможность без дополнительных уточнений обращаться ко всем public- и, может быть, protected-членам объекта context. Так, в случае формулы «a+b» объект context должен содержать числовые (или строчные) поля a и b. В своем результате eval возвращает объект Java, получаемый в результате интерпретации выражения javaExpression.

public static class Context { 

public int a,b;

}

...

public void НекоторыйМетод() {

  ...

  Context c= new Context();

  c.a= 23;

  c.b= 45;

  String formula= "new Integer(a+b)";

  Integer result= (Integer)Evaluator.eval(formula,c);

  ...

}

Язык Java по обыкновению создает затруднения при попытке работать с примитивными типами на общих основаниях – их приходится заменять соответствующими классами-оболочками. Специально для упрощения работы с примитивными типами имеет смысл дополнить основной метод eval версиями evalInt, evalLong, evalFloat, evaDouble, evalBoolean, возвращающими результат соответствующего примитивного типа. Тогда последние 2 строки примера выглядели бы проще:

  ...

  String formula= "a+b";

  int result= Evaluator.evalInt(formula,c);

  ...

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

  public class НекоторыйКласс {

  ...

  public int a,b;

  public void НекоторыйМетод() {

    ...

    a= 23;

    b= 45;

    String formula= "a+b";

    int result= Evaluator.evalInt(formula,this);

    ...

  }

}

Это практически так же удобно, как и eval в скриптовых языках.

Как решить поставленную задачу – реализовать описанные методы eval, evalInt и прочие?

Существует достаточно элементарное частичное решение.

Потребуем, чтобы все обращения к членам объекта context в формуле javaExpression производились не напрямую, а через некоторую дополнительную переменную – ссылку на объект context. Формула в этом случае приобретает примерно такой вид: «c.a+c.b» (имя ссылки «c» могло бы быть дополнительным аргументом метода eval).

В этом варианте задачу решить легко. Конструируем «на лету» текст Java-класса:

import java.io.*;

import java.util.*;

какие-нибудь еще полезные import, которые могут пригодиться внутри формулы

 public class ___ExpressionNNN {

  public static int ___performEval(

    имя_класса_context c)

  {

    int returnValue= текст_формулы;

    return returnValue;

  }

}

Вместо NNN подставляется некоторый уникальный индекс – свой для каждого текста формулы. (Если разные формулы будут интерпретироваться с помощью разных классов, то эти классы можно будет кэшировать и не компилировать повторно – смотри о кэшировании в конце предыдущего раздела.) Вместо «имя_класса_context» подставляется context.getClass().getName(), вместо «текст_формулы» – значение javaExpression.

Тип результата int метода ___performEval и тип переменной returnValue соответствуют варианту метода evalInt. Другие варианты – eval, evalDouble и прочие – должны использовать другой тип (соответственно Object, double, и т. д.).

Имя «c» аргумента ___performEval – это имя, под которым объект context будет доступен внутри формулы. Оно может быть дополнительным аргументом методов eval, evalInt, ...

Компилируем полученный текст класса и загружаем скомпилированный класс, как описано в предыдущих разделах. Затем средствами отражений вызываем его статический метод ___performEval, передавая ему в качестве аргумента наш объект context, и возвращаем полученный результат. Задача решена.

Дополнительная переменная returnValue позволяет передавать в качестве формулы javaExpression фрагменты кода, состоящие более чем из одного оператора Java. В этом случае по умолчанию результатом вызова eval окажется результат первого оператора, но всегда можно будет в последнем операторе написать что-нибудь вроде

returnValue= результат_наших_вычислений;

и тем самым вернуть другой результат.

Приведенное решение, разумеется, неизящно. В формуле приходится ссылаться на члены класса-контекста через громоздкую запись типа «c.a». Попробуем избавиться от явной ссылки «c.». Язык Java разрешает ссылаться непосредственно, без дополнительных уточнений, на члены текущего класса, его предков, члены класса, по отношению к которому текущий является вложенным, и члены предков этого класса. Если класс, которому принадлежит метод ___performEval, унаследовать от класса context.getClass().getName() или вложить в другой класс, унаследованный от context.getClass().getName(), то к членам этого предка можно будет обращаться из нашей формулы непосредственно. Метод ___performEval, разумеется, нужно будет сделать нестатическим.

Таким способом формулу типа «a+b» скомпилировать удастся – синтаксически все будет соблюдено. Но как добиться, чтобы a и b ссылались именно на члены данного экземпляра context, переданного в качестве аргумента в метод eval (или evalInt, evalDouble, ...)? Все, что можно сделать «легально» для исполнения формулы – создать новый экземпляр для нашего нового класса (или по экземпляру для серии вложенных классов) и указать именно этот новый экземпляр при вызове метода ___performEval через отражения. Виртуальная машина Java не позволит «подменить» экземпляр нового временного класса объектом context, так же context является предком нашего нового объекта, и его нельзя использовать там, где декларировано использование объекта-потомка.

Приходит в голову банальное решение – перед вызовом ___performEval скопировать все поля объекта context в заново созданный экземпляр наследника context.getClass().getName(), а в конце скопировать все поля обратно (на случай, если формула их изменяла). Все это в принципе осуществимо средствами отражений, но вряд ли такое решение можно назвать качественным. Если полей много, такое копирование может занять много времени. Кроме того, в сложных случаях такое поведение попросту может оказаться ошибочным. Представьте себе, что некоторый внешний поток постоянно наблюдает за состоянием экземпляра context, и каждое изменение его состояния, в том числе внутри формулы (в результате вызова методов context), должно быть немедленно обнаружено. Очевидно, при описанном подходе изменения вообще обнаружены не будут. Все изменения будут произведены с другим объектом – копией context, а в момент присваивания новых полей полям экземпляра context наш метод eval «не будет знать», как сообщить об этих изменениях.

Хотелось бы попытаться все-таки получить доступ непосредственно к экземпляру context.

Решение существует. Для этого нам придется «обмануть» компилятор – создать .class-файл, который не может быть создан «законным» компилятором javac.

Рассмотрим следующий код Java:

import java.io.*;

import java.util.*;

какие-нибудь еще полезные import, которые могут пригодиться внутри формулы

public class ___ExpressionNNN

  extends имя_класса_context

{

  public class ___Performer {

    public int ___performEval() {

      int returnValue= текст_формулы;

      return returnValue;

    }

  }

}

Все, как в прошлый раз, но теперь добавился вложенный класс ___Performer. Метод ___performEval теперь нестатический и не обладает аргументом, а внешний класс ___ExpressionNNN унаследован от context.getClass().getName().

Во что компилируется такой исходный код?

В результате компиляции получается 2 .class-файла – ___ExpressionNNN.class и ___ExpressionNNN$___Per-former.class. Рассмотрим их внимательно с помощью какого-нибудь дизассемблера, например утилиты javap (с ключами -private и -c).

Класс ___ExpressionNNN здесь – «пустышка». Он не содержит ни одного члена, кроме пустого (автоматически добавленного) конструктора. Этот класс – наследник context.getClass().getName().

Класс ___Performer имеет следующий вид:

public class ___ExpressionNNN$___Performer {

  // Fields

  private final ___ExpressionNNN this$0;

  // Constructors

  public ___ExpressionNNN$___Performer(

    ___ExpressionNNN p0)

  {

    реализация: копирует p0 в this$0

  }

  // Methods

  public int ___performEval()

  {

    реализация, содержащая скомпилированный текст нашей формулы: для обращений к a,b и другим членам context используется ссылка this$0

  }

}

Обратите внимание на поле this$0. Это «скрытый механизм» языка Java, позволяющий добираться из вложенных нестатических классов до текущих экземпляров содержащих их внешних классов. Заметьте: хотя идентификатор this$0 является корректным с точки зрения синтаксиса Java, компилятор javac не позволит объявить поле с таким именем в обычном классе – идентификатор «зарезервирован для внутреннего использования».

Вызов ___performEval через отражения выглядит следующим образом:

  • Вначале нужно создать экземпляр класса ___ExpressionNNN (обычным вызовом Class.newInstance).
  • Затем нужно отыскать (единственный) конструктор класса ___ExpressionNNN$___Performer и вызвать его (через java.lang.reflect.Constructor), передав в качестве аргумента ссылку на созданный экземпляр ___ExpressionNNN. Будет создан экземпляр ___ExpressionNNN$___Performer.
  • Затем нужно обычным образом (через java.lang.reflect.Method) отыскать и вызвать метод ___performEval, передав методу invoke в качестве параметра ссылку на только что созданный экземпляр ___ExpressionNNN$___Performer.

Если бы на шаге 2 удалось вместо нового экземпляра ___ExpressionNNN «подсунуть» конструктору наш экземпляр context, задача была бы решена. Метод ___performEval работал бы (через ссылку this$0) с нашим экземпляром, т.е. выполнил бы нашу формулу в требуемом контексте.

Как уже говорилось выше, виртуальная машина Java не допускает передачи в качестве аргумента типа ___ExpressionNNN экземпляра его предка context.getClass().getName().

Но можно слегка изменить класс ___Performer. Действительно, если в классе ___Performer везде заменить имя класса ___ExpressionNNN на context.getClass().getName() (прежде всего тип поля this$0 и тип аргумента конструктора), то этот класс останется с точки зрения виртуальной машины вполне корректным. Класс ___ExpressionNNN не добавляет к своему предку ни одного нового члена, к которому метод ___performEval мог бы попытаться обратиться через this$0. При работе с таким скорректированным классом через отражения на шаге 2 требования к аргументу конструктора были бы слабее: можно было бы передать ссылку на экземпляр класса context.getClass().getName(), в частности на наш экземпляр context.

Такую коррекцию невозможно сделать «легальным» путем, изменяя исходный текст и вызывая компилятор javac. Стандартный компилятор при генерации вложенного класса непременно придаст ссылке this$0 точный тип того класса, в который вложен данный. Но можно скорректировать уже скомпилированный .class-файл ___ExpressionNNN$___Performer.class.

Это не так сложно, как кажется. Формат .class-файлов спроектирован очень грамотно и удобен для наших целей.

В начале файла идет так называемый «пул констант», в котором собраны все символьные идентификаторы (в кодировке UTF-8), в том числе имена всех упоминаемых классов. Весь .class-файл, в частности пул констант, организован в виде последовательности секций примерно такого вида:

  • код_типа_секции
  • содержимое_фиксированной_длины

 или

  • код_типа_секции
  • длина_секции
  • содержимое_переменной_длины

Если в произвольной секции изменить содержимое и соответствующим образом подправить длину секции (размер файла при этом тоже поменяется), то файл сохранит свою корректность. В .class-файле не встречаются прямые смещения от начала файла или длины в байтах каких-то фрагментов файла, за исключением длин элементарных секций.

Имя класса ___ExpressionNNN может встречаться внутри файла ___ExpressionNNN$___Performer.class в виде строковых констант трех видов:

  • непосредственно строка «___ExpressionNNN» (используется не всеми компиляторами);
  • строка «L___ExpressionNNN;» – внутреннее имя типа ___ExpressionNNN: именно таким образом виртуальная машина «именует» классы «внутри себя» (для примитивных типов и массивов используются другие обозначения);
  • строка «(L___ExpressionNNN;)V» – сигнатура конструктора или любого другого метода с единственным аргументом типа ___ExpressionNNN.

Если бы наш класс ___ExpressionNNN был вложен внутрь какого-либо некорневого пакета (в наших примерах он размещается в корневом пакете), то в его полном имени нужно было бы заменить точки символами “/”.

Строковые константы, в частности перечисленные выше, представлены в пуле констант в виде следующих цепочек байтов:

  • 1 байт: 1 (код строкового типа);
  • 2 байта: длина length строковой константы в кодировке UTF-8 (сначала старший байт, потом младший);
  • length байтов: содержимое строковой константы в кодировке UTF-8.

Все, что нам нужно сделать – загрузить файл ___ExpressionNNN$___Performer.class в виде массива байтов, отыскать в нем все такие цепочки байтов для строк «___ExpressionNNN», «L___ExpressionNNN;», «(L___ExpressionNNN;)V» и заменить их соответствующими цепочками для строк «XXXX», «LXXXX;», (LXXXX;)V». Здесь XXXX – полное имя класса context, в котором точки (разделители имени пакета) заменены знаком «/»:

context.getClass().getName().replace(".","/")

Полученный новый массив байтов (другой длины) нужно записать обратно в файл ___ExpressionNNN$___Performer.class.

Если имя ___ExpressionNNN не используется в Java-приложении ни для каких других целей – для максимальной уверенности можно заменить его чем-нибудь вроде ___Expression_Asj5Sjl3_NNN, – то полученный .class-файл будет вполне корректным. Остается загрузить его, создать экземпляр, передав конструктору в качестве аргумента наш объект context, и выполнить метод ___performEval(). Задача решена полностью.

Возможно, в формулах имеет смысл открыть непосредственный доступ (без уточняющего имени класса) к какому-либо набору стандартных функций или констант. Например, в математических формулах естественнее смотрелась бы запись «sin(a+PI/4)», а не «Math.sin(a+Math.PI/4)». Для этого достаточно автоматически добавить желаемый набор функций и констант во вложенный класс ___Performer.

Некоторое неудобство приведенного решения связано с тем, что класс объекта context обязан быть public, а в случае локального класса – public static. В частности, недопустимо использовать анонимные классы или локальные классы, объявленные внутри методов. В противном случае нам попросту не удастся унаследовать от него класс ___ExpressionNNN, расположенный, вообще говоря, в совершенно другом пакете (в наших примерах – в корневом пакете).

Очевидно также, что для достижения хорошей эффективности все скомпилированные формулы необходимо кэшировать, чтобы одна и та же формула не компилировалась повторно. Имеет смысл для каждой пары «формулы, экземпляр context» сохранить (в таблице HashMap) готовый объект java.lang.reflect.Method для метода ___performEval и экземпляр вложенного класса ___Performer, чтобы при повторном обращении к eval осталось просто вызвать метод invoke. Также есть смысл проверить, что одна и та же формула с одним и тем же context вызывается повторно, и в этом случае перейти на особо быструю ветку – не обращающуюся к таблице HashMap. Подобная оптимизация позволяет достичь чрезвычайно высокого быстродействия, недостижимого для интерпретируемых скриптовых языков: накладные расходы будут укладываться в доли микросекунды (на компьютерах класса Pentium-III 800).

Идея описанного выше изящного решения принадлежит моему коллеге, Алексею Вылегжанину (anv@siams.com).

Разумеется, это решение (в отличие от первого варианта, требующего использования в формулах ссылки «с.»), не является стопроцентно переносимым. Оно зависит от особенностей компилятора javac, которые в принципе могут измениться в следующей версии Java. Например, компилятору ничто не мешает вставить в класс ___Performer явную проверку, что поле this$0 принадлежит нужному типу, причем сформировать имя типа динамически путем сложения строк «___Expres» и «sionNNN». Тогда скорректированный нами класс работать не будет. Кажется маловероятным, что подобное произойдет. Но на всякий случай описанное решение желательно заново тестировать с каждой новой версией Sun Java SDK.

Технология интерпретации формул на Java может оказаться чрезвычайно полезной в сложных приложениях, гибко настраиваемых пользователем. Скажем, процедура построения графика или статистического анализа может принимать на вход не только массив чисел, но и некоторую набранную пользователем аналитическую формулу. При заполнении сложной формы с множеством параметров можно разрешить в некоторых полях указывать формулы, оперирующие другими полями формы (например, «предыдущее_поле + 1»). Можно снабдить пользователя простейшим формульным калькулятором, который всегда под рукой и обладает функциями, специфичными для данного приложения.

Единственной серьезной проблемой может оказаться безопасность. Если разрешить пользователю Java-приложения самостоятельно писать формулы, исполняемые системой, то пользователь сможет, вообще говоря, выполнить любой законный оператор языка Java – скажем, добраться до содержимого диска. Если это недопустимо, то придется разрабатывать специальный менеджер безопасности («песочницу») для исполнения формул или жестко контролировать текст формулы.

Заключение

Нашу экскурсию по миру отражений можно считать законченной. Мы рассмотрели основные задачи по управлению классами: доступ к произвольным классам и их членам, загрузку классов из произвольного источника и, наконец, компиляцию новых классов из Java-файлов либо «на лету». Эти технологии позволяют профессионально решать по-настоящему сложные задачи, а также могут быть чрезвычайно удобны и в небольших утилитах.

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

При написании статьи были использованы официальные материалы фирмы Sun, приведенные на сайте http://java.sun.com, а также следующие книги:

  • Арнолд К., Гослинг Дж., Холмс Д. Язык программирования Java. Издательский дом «Вильямс», Москва – С.-Петербург – Киев, 2001.
  • Вебер Дж. Технология Java(TM) в подлиннике. «BHV – Санкт-Петербург», С.-Петербург, 1997.

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


Комментарии отсутствуют

Добавить комментарий

Комментарии могут оставлять только зарегистрированные пользователи

               Copyright © Системный администратор

Яндекс.Метрика
Tel.: (499) 277-12-41
Fax: (499) 277-12-45
E-mail: sa@samag.ru