JEP 458: Launch Multi-File Source-Code Programs
Запуск программ из нескольких файлов исходного кода
| Ответственный | Ron Pressler |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 22 |
| Компонент | tools / launcher |
| Обсуждение | compiler dash dev at openjdk dot org |
| Трудоёмкость | S |
| Связан с | JEP 330: Launch Single-File Source-Code Programs |
| Рецензенты | Alex Buckley, Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2023/03/17 10:17 |
| Обновлён | 2023/12/05 18:43 |
| Задача | 8304400 |
Аннотация
Расширить средство запуска приложений java, чтобы оно могло запускать программу, представленную несколькими файлами исходного кода на Java. Так переход от небольших программ к более крупным станет более постепенным, а разработчики смогут сами решать, стоит ли и когда тратить силы на настройку инструмента сборки.
Что не является целью
-
Запуск программ из нескольких файлов исходного кода через механизм «shebang» не является целью. Через этот механизм можно запускать только программы из одного файла исходного кода.
-
Упрощение использования внешних библиотек как зависимостей в программах из исходного кода не является целью. Это может стать предметом будущего JEP.
Мотивация
Язык программирования Java отлично подходит для написания больших и сложных приложений, которые большие команды разрабатывают и сопровождают много лет. И всё же даже большие программы начинаются с малого. На ранних этапах разработчики пробуют и исследуют, и готовые к поставке артефакты их не интересуют. Структуры проекта может ещё не быть, а когда она появляется, то часто меняется. Быстрые итерации и радикальные изменения — обычное дело. В последние годы в JDK добавили несколько возможностей, которые помогают пробовать и исследовать, в том числе JShell (интерактивную оболочку для экспериментов с фрагментами кода) и простой веб-сервер (для быстрого прототипирования веб-приложений).
В JDK 11 JEP 330 расширил средство запуска приложений java, чтобы оно могло запускать исходные файлы .java напрямую, без явного шага компиляции. Например, пусть файл Prog.java объявляет два класса:
class Prog {
public static void main(String[] args) { Helper.run(); }
}
class Helper {
static void run() { System.out.println("Hello!"); }
}
Тогда запуск
$ java Prog.java
компилирует оба класса в памяти и выполняет метод main первого класса, объявленного в этом файле.
У этого способа запуска программы без лишних формальностей есть серьёзное ограничение: весь исходный код программы должен находиться в одном файле .java. Чтобы работать с несколькими файлами .java, разработчикам приходится снова явно компилировать исходные файлы. Опытным разработчикам для этого часто нужно создавать конфигурацию проекта для инструмента сборки, но переход от бесформенных экспериментов к формальной структуре проекта раздражает, когда хочется, чтобы идеи и эксперименты шли гладко. Для начинающих разработчиков переход от одного файла .java к двум и более файлам означает ещё более резкую смену этапа: им приходится прервать изучение языка и научиться работать с javac, или освоить сторонний инструмент сборки, или научиться полагаться на магию IDE.
Было бы лучше, если бы разработчики могли отложить этап настройки проекта, пока не поймут лучше, какую форму он примет, или даже совсем обойтись без настройки, когда быстро набрасывают прототип, а потом выбрасывают его. Некоторые простые программы могут навсегда остаться в виде исходного кода. Поэтому мы расширяем средство запуска java, чтобы оно могло запускать программы, выросшие за пределы одного файла .java, но без обязательного явного шага компиляции. Традиционный цикл «правка/сборка/запуск» превращается просто в «правка/запуск». Разработчики сами решают, когда пора настроить процесс сборки, а не вынуждены делать это из-за ограничений инструментов.
Описание
Мы расширяем режим исходного файла средства запуска java, чтобы он мог запускать программу, представленную несколькими файлами исходного кода на Java.
Например, пусть каталог содержит два файла, Prog.java и Helper.java, и каждый файл объявляет один класс:
// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); }
}
// Helper.java
class Helper {
static void run() { System.out.println("Hello!"); }
}
Запуск java Prog.java компилирует класс Prog в памяти и вызывает его метод main. Поскольку код этого класса ссылается на класс Helper, средство запуска находит файл Helper.java в файловой системе и компилирует его класс в памяти. Если код класса Helper ссылается на какой-то другой класс, например HelperAux, средство запуска находит HelperAux.java и компилирует и его.
Когда классы из разных файлов .java ссылаются друг на друга, средство запуска java не гарантирует какого-либо определённого порядка или момента компиляции файлов .java. Например, средство запуска может скомпилировать Helper.java раньше Prog.java. Часть кода может быть скомпилирована до начала выполнения программы, а другая часть — лениво, на лету. (Процесс компиляции и выполнения программ из исходных файлов подробно описан ниже.)
Компилируются только те файлы .java, на классы которых ссылается программа. Так разработчики могут экспериментировать с новыми версиями кода, не опасаясь, что старые версии будут случайно скомпилированы. Например, пусть в каталоге есть ещё OldProg.java, в котором старая версия класса Prog ожидает, что у класса Helper есть метод с именем go, а не run. Наличие OldProg.java со скрытой в нём ошибкой не имеет значения при запуске Prog.java.
В одном файле .java можно объявить несколько классов, и все они компилируются вместе. Классы, объявленные в одном файле .java, имеют приоритет перед классами, объявленными в других файлах .java. Например, пусть приведённый выше файл Prog.java расширен и объявляет класс Helper, хотя класс с таким именем уже объявлен в Helper.java. Когда код в Prog.java ссылается на Helper, используется класс, объявленный вместе с ним в Prog.java; средство запуска не будет искать файл Helper.java.
Повторяющиеся классы в программах из исходного кода запрещены. То есть не допускаются два объявления класса с одним и тем же именем ни в одном файле .java, ни в разных файлах .java, составляющих программу. Пусть после нескольких правок Prog.java и Helper.java в итоге выглядят так, как показано ниже, и класс Aux случайно объявлен в обоих:
// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); Aux.cleanup(); }
}
class Aux {
static void cleanup() { ... }
}
// Helper.java
class Helper {
static void run() { ... }
}
class Aux {
static void cleanup() { ... }
}
Запуск java Prog.java компилирует классы Prog и Aux из Prog.java, вызывает метод main класса Prog, а затем — поскольку main ссылается на Helper — находит Helper.java и компилирует его классы Helper и Aux. Повторное объявление Aux в Helper.java не допускается, поэтому программа останавливается, и средство запуска сообщает об ошибке.
Режим исходного файла средства запуска java включается, когда ему передаётся имя одного файла .java. Если указаны дополнительные имена файлов, они становятся аргументами его метода main. Например, java Prog.java Helper.java приводит к тому, что массив, содержащий строку "Helper.java", передаётся как аргумент методу main класса Prog.
Использование заранее скомпилированных классов
Программы, зависящие от библиотек в class path или в module path, тоже можно запускать из исходных файлов. Например, пусть каталог содержит две небольшие программы и вспомогательный класс, а также несколько JAR-файлов библиотек:
Prog1.java
Prog2.java
Helper.java
library1.jar
library2.jar
Эти программы можно быстро запустить, передав --class-path '*' средству запуска java:
$ java --class-path '*' Prog1.java
$ java --class-path '*' Prog2.java
Здесь аргумент '*' опции --class-path помещает все JAR-файлы из каталога в class path; звёздочка взята в кавычки, чтобы оболочка её не раскрыла.
По мере экспериментов вам может оказаться удобнее поместить JAR-файлы в отдельный каталог libs, и тогда сделать их доступными можно с помощью --class-path 'libs/*'. Задуматься о создании упакованного результата для поставки, вероятно с помощью инструмента сборки, можно и позже, когда проект обретёт форму.
Как средство запуска находит исходные файлы
Средство запуска java требует, чтобы исходные файлы программы из нескольких файлов исходного кода были расположены в обычной иерархии каталогов, где структура каталогов повторяет структуру пакетов, начиная с корневого каталога, который вычисляется так, как описано ниже. Это означает, что:
- исходные файлы в корневом каталоге должны объявлять классы в безымянном пакете, а
- исходные файлы в каталоге
foo/barпод корневым каталогом должны объявлять классы в именованном пакетеfoo.bar.
Например, пусть каталог содержит Prog.java, который объявляет классы в безымянном пакете, и подкаталог pkg, в котором Helper.java объявляет класс Helper в пакете pkg:
// Prog.java
class Prog {
public static void main(String[] args) { pkg.Helper.run(); }
}
// pkg/Helper.java
package pkg;
class Helper {
static void run() { System.out.println("Hello!"); }
}
При запуске java Prog.java файл Helper.java находится в подкаталоге pkg и компилируется в памяти, в результате чего появляется класс pkg.Helper, нужный коду класса Prog.
Если бы Prog.java объявлял классы в именованном пакете или Helper.java объявлял классы в пакете, отличном от pkg, то java Prog.java завершился бы с ошибкой.
Средство запуска java вычисляет корень дерева исходного кода по имени пакета и расположению в файловой системе начального файла .java. Для java Prog.java начальный файл — Prog.java, и он объявляет класс в безымянном пакете, поэтому корень дерева исходного кода — каталог, содержащий Prog.java. Если же Prog.java объявляет класс в именованном пакете a.b.c, то он должен находиться в соответствующем каталоге иерархии:
dir/
a/
b/
c/
Prog.java
И запускать его тоже нужно командой java dir/a/b/c/Prog.java. В этом случае корень дерева исходного кода — dir.
Если бы Prog.java объявлял пакет b.c, то корнем дерева исходного кода был бы dir/a; если бы он объявлял пакет c, то корнем был бы dir/a/b, а если бы он не объявлял пакета, то корнем был бы dir/a/b/c. Программа не запустится, если Prog.java объявляет какой-то другой пакет, например p, который не соответствует окончанию пути к файлу в файловой системе.
Небольшое, но несовместимое изменение
Если бы в приведённом выше примере Prog.java объявлял классы в другом именованном пакете, то java a/b/c/Prog.java завершился бы с ошибкой. Это изменение поведения режима исходного файла средства запуска java.
В прошлых выпусках режим исходного файла средства запуска нестрого относился к тому, какой пакет (если вообще какой-либо) объявлен в файле .java в данном месте: java a/b/c/Prog.java выполнялся успешно, если Prog.java находился в a/b/c, независимо от объявления package в файле. Файлы .java, объявляющие классы в именованном пакете и при этом не находящиеся в соответствующем каталоге иерархии, встречаются редко, поэтому влияние этого изменения, скорее всего, будет ограниченным. Если имя пакета не важно, исправить ситуацию можно, удалив объявление package из файла.
Модульные программы из исходного кода
В приведённых до сих пор примерах классы, скомпилированные из файлов .java, находились в безымянном модуле. Однако если корень дерева исходного кода содержит файл module-info.java, то программа считается модульной, и классы, скомпилированные из файлов .java в дереве исходного кода, находятся в именованном модуле, объявленном в module-info.java.
Программы, использующие модульные библиотеки в текущем каталоге, можно запускать так:
$ java -p . pkg/Prog1.java
$ java -p . pkg/Prog2.java
Если же модульные JAR-файлы находятся в каталоге libs, то сделать их доступными можно с помощью -p libs.
Семантика и работа во время запуска
Начиная с JDK 11, режим исходного файла средства запуска работал так, как если бы
java <other options> --class-path <path> <.java file>
было неформально эквивалентно
javac <other options> -d <memory> --class-path <path> <.java file>
java <other options> --class-path <memory>:<path> <first class in .java file>
Теперь, когда можно запускать программы из нескольких файлов исходного кода, режим исходного файла работает так, как если бы
java <other options> --class-path <path> <.java file>
было неформально эквивалентно
javac <other options> -d <memory> --class-path <path> --source-path <root> <.java file>
java <other options> --class-path <memory>:<path> <launch class of .java file>
где <root> — вычисленный корень дерева исходного кода, как определено ранее, а <launch class of .java file> — класс запуска файла .java, как определено ниже. (Использование --source-path указывает javac, что классы, упомянутые в начальном файле .java, могут ссылаться на классы, объявленные в других файлах .java в дереве исходного кода. Классы, расположенные в одном файле .java, имеют приоритет перед классами из других файлов .java; например, вызов javac --source-path dir dir/Prog.java не будет компилировать Helper.java, если Prog.java объявляет класс Helper.)
Когда программа запуска java работает в режиме исходного файла (например, java Prog.java), она выполняет следующие шаги:
-
Если файл начинается со строки «shebang», то есть со строки, начинающейся с
#!, то путь к исходному коду, передаваемый компилятору, пуст, чтобы никакие другие исходные файлы не компилировались. Перейти к шагу 4. -
Вычислить каталог, который является корнем дерева исходного кода.
-
Определить модуль программы из исходного кода. Если в корне существует файл
module-info.java, то его объявление модуля используется для определения именованного модуля, который будет содержать все классы, скомпилированные из файлов.javaв дереве исходного кода. Еслиmodule-info.javaне существует, то все классы, скомпилированные из файлов.java, будут находиться в безымянном модуле. -
Скомпилировать все классы в начальном файле
.javaи, возможно, в других файлах.java, которые объявляют классы, используемые кодом начального файла, и сохранить полученные файлыclassв кэше в памяти. -
Определить класс запуска начального файла
.java. Если первый класс верхнего уровня в начальном файле объявляет стандартный методmain(public static void main(String[])или другие стандартные точки входаmain, определённые в JEP 463), то этот класс и является классом запуска. Иначе, если другой класс верхнего уровня в начальном файле объявляет стандартный методmainи имеет то же имя, что и файл, классом запуска является этот класс. Иначе класса запуска нет, и программа запуска сообщает об ошибке и завершает работу. -
С помощью специального загрузчика классов загрузить класс запуска из кэша в памяти, затем вызвать стандартный метод
mainэтого класса.
Процедура выбора класса запуска на шаге 5 сохраняет совместимость с JEP 330 и гарантирует, что используется тот же метод main, когда программа из исходного кода разрастается с одного файла до нескольких. Она также гарантирует, что файлы с «shebang» продолжат работать, поскольку имя класса, объявленного в таком файле, может не совпадать с именем файла. Наконец, она сохраняет опыт работы максимально близким к запуску программы, скомпилированной с помощью javac, чтобы, когда программа из исходного кода разрастётся до такой степени, что будет желательно явно запускать javac и выполнять файлы class, можно было использовать тот же класс запуска.
Когда специальный загрузчик классов на шаге 6 вызывается для загрузки класса — либо класса запуска, либо любого другого класса, который нужно загрузить во время работы программы, — загрузчик выполняет поиск, повторяющий порядок параметра -Xprefer:source компилятора javac во время компиляции. В частности, если класс существует и в дереве исходного кода (объявлен в файле .java), и в пути к классам (в файле .class), то предпочтение отдаётся классу из дерева исходного кода. Алгоритм поиска загрузчиком класса с именем C таков:
-
Если файл класса для
Cнайден в кэше в памяти, то загрузчик определяет кэшированный файл класса в JVM, и загрузкаCзавершена. -
Иначе загрузчик делегирует загрузчику классов приложения поиск файла класса для
C, экспортируемого именованным модулем, который читается модулем программы из исходного кода и, кроме того, присутствует в пути к модулям или в образе среды выполнения JDK. (Безымянный модуль, в котором может находиться программа из исходного кода, читает набор модулей по умолчанию в образе среды выполнения JDK.) Если файл найден, загрузкуCзавершает загрузчик классов приложения. -
Иначе загрузчик ищет файл
.java, имя которого совпадает с именем класса (или объемлющего класса, если запрошенный класс является классом-членом), то естьC.java, расположенный в каталоге, соответствующем пакету класса. Если файл найден, компилируются все классы, объявленные в файле.java. Если компиляция проходит успешно, полученные файлы классов сохраняются в кэше в памяти, загрузчик определяет классCв JVM с помощью кэшированного файла класса, и загрузкаCзавершена. Если компиляция завершается неудачей, программа запуска сообщает об ошибке и завершает работу с ненулевым кодом выхода.При компиляции
C.javaпрограмма запуска может решить заранее скомпилировать другие файлы.java, объявляющие классы, на которые ссылаетсяC.java, и сохранить полученные файлы классов в кэше в памяти. Это решение основано на эвристиках, которые могут меняться между выпусками JDK. -
Иначе, если программа из исходного кода находится в безымянном модуле, загрузчик делегирует загрузчику классов приложения поиск файла класса для
Cв пути к классам. Если файл найден, загрузкуCзавершает загрузчик классов приложения. -
Иначе класс с именем
Cне может быть найден, и загрузчик выбрасываетClassNotFoundException.
Классы, загруженные из пути к классам или пути к модулям, не могут ссылаться на классы, скомпилированные в памяти из файлов .java. То есть, когда встречаются ссылки на классы в предварительно скомпилированных классах, к дереву исходного кода никогда не обращаются.
Различия между компиляцией во время компиляции и во время запуска
Есть несколько существенных различий между тем, как компилятор Java компилирует код в пути к исходному коду при использовании javac, и тем, как он компилирует код при использовании программы запуска java в режиме исходного файла.
-
В режиме исходного файла объявления классов, находящиеся в файлах
.java, могут компилироваться постепенно и по требованию во время выполнения программы, а не все сразу до начала выполнения. Это означает, что если возникнет ошибка компиляции, программа запуска завершит работу уже после того, как программа начала выполняться. Это поведение отличается от прототипирования с явной компиляцией черезjavac, но оно эффективно работает в быстром цикле «правка/запуск», который обеспечивает режим исходного файла. -
Классы, к которым обращаются через рефлексию, загружаются так же, как классы, к которым обращаются напрямую. Например, если программа вызывает
Class.forName("pkg.Helper"), то специальный загрузчик классов программы запуска попытается загрузить классHelperв пакетеpkg, что может вызвать компиляциюpkg/Helper.java. Аналогично, если аннотации пакета запрашиваются черезPackage::getAnnotations, то соответствующим образом размещённый файлpackage-info.javaв дереве исходного кода, если он есть, будет скомпилирован в памяти и загружен. -
Обработка аннотаций отключена, как при передаче
--proc:noneвjavac. -
Невозможно запустить программу из исходного кода, файлы
.javaкоторой распределены по нескольким модулям.
Два последних ограничения могут быть сняты в будущем.
Альтернативы
-
Мы могли бы оставить программы из исходного кода ограниченными одним файлом и по-прежнему требовать отдельного шага компиляции для программ из нескольких файлов. Хотя это не создаёт для разработчика значительно больше работы, на деле многие разработчики на Java отвыкли от прямого использования
javacи предпочитают полагаться на инструменты сборки, когда требуется компиляция в файлы классов. Командаjavaпугает меньше, чемjavac. -
Мы могли бы сделать
javacпроще в использовании, с удобными значениями по умолчанию для компиляции целых деревьев исходного кода. Однако необходимость создавать каталог для сгенерированных файлов классов или же засорять ими дерево исходного кода замедляет быстрое прототипирование. Разработчики часто помещают свои файлы.javaпод контроль версий даже на стадии экспериментов, и поэтому им пришлось бы настраивать свой репозиторий системы контроля версий так, чтобы исключать файлы классов, сгенерированныеjavac.