[Перевод] Чего нам не хватает в Java
В этой статье мы рассмотрим некоторые «отсутствующие» в Java возможности. Но нужно сразу подчеркнуть, что будут умышленно опущены некоторые вещи, которые либо и так активно обсуждаются, либо требуют слишком большого объёма работ на уровне виртуальной машины. Например:
Отсутствуют материализованные дженерики (reified generics). Об этом не писал только ленивый, причём большинство комментариев свидетельствуют о непонимании сути затирания типов. Если Java-разработчик говорит: «Я не люблю затирание типов», то в большинстве случаев это означает «Мне нужен List int
». Вопрос примитивной специализации дженериков лишь косвенно связан с затиранием, а польза от дженериков, видимых в ходе исполнения, сильно преувеличена молвой.
Беззнаковые вычисления (unsigned arithmetic) на уровне виртуальной машины. Отсутствие в Java поддержки беззнаковых арифметических типов вызывает недовольство разработчиков уже многие годы. Но это является обдуманным решением создателей языка. Наличие лишь знаковых вычислений существенно упрощает язык. Если сегодня начать внедрять беззнаковые типы, то это повлечёт за собой очень серьёзную переработку Java, что чревато массой больших и маленьких багов, которые будет трудно вылавливать. Заодно сильно возрастает риск дестабилизации всей платформы.
Длинные указатели для массивов. Опять же, внедрение этой функциональности потребует слишком глубокой переработки JVM с возможными неприятными последствиями, причём далеко не только с точки зрения поведения и семантики сборщиков мусора. Хотя нужно отметить, что Oracle ищет пути внедрения подобной функциональности с помощью проекта VarHandles.
Здесь мы не будем вдаваться в подробности возможного Java-синтаксиса для обсуждаемой функциональности. К сожалению, подобные обсуждения вообще часто скатываются к спорам на тему синтаксиса, хотя куда важнее семантика.
Более выразительный синтаксис импорта
Синтаксис импорта в Java предоставляет нам не так много возможностей, доступны лишь две опции: импорт одного класса или всего пакета. И если нужно импортировать лишь часть пакета, то приходится громоздить кучу строк. Также бедность синтаксиса заставляет нуждаться в таких возможностях IDE, как свёртка импорта для самых больших Java-файлов.
Нам бы облегчило жизнь, если бы одной строкой можно было импортировать из одного пакета несколько классов:
import java.util.{List, Map};
Читабельность кода повысилась бы за счёт возможности локального переименования типа (или создания алиаса). Заодно было бы меньше путаницы с типами, имеющими одинаковое короткое имя класса:
import java.util.{Date : UDate};
import java.sql.{Date : SDate};
import java.util.concurrent.Future;
import scala.concurrent.{Future : SFuture};
Пошло бы на пользу и внедрение расширенных подстановочных символов:
import java.util.{*Map};
Это небольшое, но полезное изменение, которое можно целиком реализовать с помощью javac.
Литералы коллекций
В Java присутствует синтаксис (хотя и ограниченный) для объявления литералов массива. Например:
int[] i = {1, 2, 3};
У этого синтаксиса есть ряд недостатков. Например, литералы должны использоваться только в инициализаторах.
Массивы в Java не являются коллекциями. «Мостовые методы», представленные во вспомогательном классе Arrays
, также имеют изъяны. Скажем, метод Arrays.asList()
возвращает ArrayList
, который при ближайшем рассмотрении оказывается Arrays.ArrayList
. Этот внутренний класс не содержит методов, альтернативных List
, а похожие методы выбрасывают исключение OperationNotSupportedException
. В результате в API возникает уродливый шов, затрудняющий переход между массивами и коллекциями.
Для отказа от синтаксиса объявления литералов массива нет причин, в той или иной форме он присутствует во многих языках. Например, на Perl можно написать так:
my $primes = [2, 3, 5, 7, 11, 13];
my $capitals = {'UK' => 'London', 'France' => 'Paris'};
На Scala — так:
val primes = Array(2, 3, 5, 7, 11, 13);
val m = Map('UK' -> 'London', 'France' -> 'Paris');
К сожалению, в Java нет полезных литералов коллекций. Этот вопрос поднимался неоднократно, но ни в Java 7, ни в Java 8 эта функциональность не появилась. Также вызывают интерес литералы объектов, но в Java их гораздо труднее реализовать.
Структурная типизация
Именование играет очень большую роль в системе типов в Java. Все переменные должны относится к именованным типам, и невозможно выразить тип только через определение его структуры. В других языках, например, в Scala, можно выражать тип, не объявляя его при реализации интерфейса (или трейта Scala), а просто подтвердив, что он содержит определённый метод:
def whoLetTheDucksOut(d: {def quack(): String}) {
println(d.quack());
}
В данном случае будет принят любой тип, содержащий метод quack()
, вне зависимости от наличия наследования или использования типами общего интерфейса.
Неслучайно в качестве примера был выбран quack()
— структурная типизация имеет много общего с «утиной типизацией» в том же Python. Однако в Scala типизация осуществляется при компиляции, что говорит о гибкости языка с точки зрения выражения типов, которые было бы трудно или невозможно выразить в Java. К сожалению, здесь система типов имеет очень небольшие возможности по структурной типизации. Можно задать локальный анонимный тип с дополнительными методами, и если один из них будет сразу же вызван, то Java позволит скомпилировать код.
На этом возможности заканчиваются: мы можем создать лишь один «структурный» метод. Из него нельзя вернуть тип, в котором содержится нужная нам дополнительная информация. Все структурные методы валидны, выражены в байткоде и поддерживают рефлексивный доступ. Их просто невозможно выразить средствами системы типов Java. Пожалуй, это не должно кого-либо удивлять, поскольку структурные методы на самом деле реализуются с помощью дополнительного файла классов, который соответствует анонимному локальному типу.
Алгебраические типы данных
Благодаря дженерикам Java является языком с параметризованными типами (parameterized types), представляющими собой ссылочные типы (reference types) с параметрами типа. При их подстановке в одни типы образуются другие. То есть получившиеся типы состоят из «контейнеров» (обобщённых типов) и «полезной нагрузки» (значения параметров типа).
В некоторых языках поддерживаемые составные типы сильно отличаются от дженериков Java. В качестве примера сразу напрашиваются кортежи (tuples), хотя куда больший интерес вызывают тип-суммы (sum type), иногда называемые также «несвязными объединениями типов» (disjoint union of types) или «размещенными объединениями» (tagged union).
Тип-сумма — это однозначный тип, то есть в каждый момент времени переменные могут иметь лишь одно значение. Но при этом оно может быть любым валидным значением, относящимся к указанному диапазону различных типов. Это справедливо даже в том случае, если являющиеся значениями несвязные типы никак не связаны друг с другом с точки зрения наследования. К примеру, в языке F# можно задать тип Shape, экземплярами которого могут быть прямоугольники или круги:
type Shape =
| Circle of int
| Rectangle of int * int
F# сильно отличается от Java, но и в Scala эти типы реализованы с ограничениями: запечатанные типы (sealed types) применяются с case-классами. Запечатанный класс нельзя расширять за пределами текущей единицы компиляции (compilation unit). Это практически аналог терминального класса в Java, но в Scala базовой единицей компиляции является файл, и многочисленные высокоуровневые открытые классы (public classes) могут объявляться в единственном файле.
Это приводит нас к паттерну, при котором запечатанный абстрактный базовый класс объявляется вместе с несколькими подклассами, которые соответствуют возможным несвязным типам из тип-суммы. В стандартной библиотеке Scala содержится много примеров использования этого паттерна, включая Option[A]
, который аналогичен типу Optional T
из Java 8.
В Scala к несвязным объединениям двух возможностей относятся Option
и Some
, а также None
и Option type
.
Если бы мы реализовали в Java такой же механизм, то столкнулись бы с ограничением, когда единица компиляции, по сути, является классом. Получается не так удобно, как в Scala, но всё же можно придумать способы решения. Например, можно было бы использовать javac для обработки нового синтаксиса применительно к классам, которые мы хотим запечатать:
final package example.algebraic;
Подобный синтаксис означал бы, что компилятор должен допускать расширение класса с учётом конечной упаковки в рамках текущей папки, отклоняя все иные попытки расширения. Это изменение тоже можно было бы реализовать с помощью javac, но без проверок в ходе исполнения его нельзя полностью защитить от циклического кода (reflective code). Кроме того, Java-реализация была бы менее полезной, чем в Scala, поскольку в Java не хватает развитых выражений сопоставления (match expressions).
Точки динамического вызова (Dynamic call sites)
Начиная с версии 7 в Java появился на удивление полезный инструмент: байткод invokedynamic
, призванный выполнять роль основного механизма вызова. Это позволяет исполнять динамические языки поверх JVM, а также расширять систему типов в Java путём добавления встроенных методов и изменения интерфейса, в то время как раньше это было невозможно. Расплачиваться за это приходится несколько возросшей сложностью. Но при умелом обращении invokedynamic
является мощным инструментом.
Правда, у него есть одно странное ограничение. Несмотря на объявленную поддержку в Java 7, почему-то до сих пор не обеспечивается прямой доступ к методам динамического вызова. Хотя весь смысл динамической диспетчеризации заключается в том, чтобы позволить разработчикам самим принимать решение, какой метод вызывать из конкретной точки вызова, причём принятие решения можно отложить до момента исполнения кода.
Примечание: не путайте этот способ динамического связывания с ключевым словом dynamic
из C#. В нашем случае вводится объект, в ходе выполнения динамически определяющий свои привязки; это не сработает, если объект не поддерживает запрашиваемые вызовы методов. Экземпляры подобных динамических объектов в ходе выполнения неотличимы от «обычных» объектов, а сам механизм получается небезопасным.
В то время, как для реализации лямбда-выражений и встроенных методов в Java используется invokedynamic
, разработчики не имеют прямого доступа и не могут осуществлять диспетчеризацию во время выполнения. Иными словами, в Java нет ключевого слова или иной конструкции для создания точек вызова invokedynamic
общего назначения. Компилятор javac просто не транслирует инструкции invokedynamic
за рамками инфраструктуры языка.
Можно достаточно просто добавить в Java эту функциональность. Например, с помощью какого-нибудь ключевого слова или аннотирования. Также потребуется дополнительная библиотека и поддержка на стадии сборки.
Проблески надежды?
Развитие архитектуры языка и его реализация — это искусство достижения возможного. Существует немало примеров, когда важные изменения очень долго пробивают себе дорогу. Например, в С++ лямбда-выражения появились только в 14 версии.
Многим не нравится неторопливость развития Java. Но Джеймс Гослинг придерживается позиции, что нельзя реализовывать функциональность, пока она не будет полностью понята и осознана. Хотя консервативность архитектуры Java является одной из причин успеха этого языка, в то же время она не нравится многим нетерпеливым молодым разработчикам, жаждущим быстрых перемен. Ведутся ли работы над внедрением каких-то из вышерассмотренных возможностей? Можно это осторожно предположить.
Некоторые из описанных идей можно реализовать с помощью того же invokedynamic
. Как вы помните, он должен выполнять роль основного механизма вызова, отложенного до момента выполнения. Согласно предложению по улучшению языка JEP276, можно стандартизировать библиотеку Dynalink, которая изначально создавалась Аттилой Жегеди (Attila Szegedi) для реализации «протокола мета-объектов» в JVM. Позднее автор библиотеки перешёл работать в Oracle, который использовал Dynalink в Nashorn, реализации JavaScript на JVM. Описание библиотеки есть на Github, но сама она оттуда удалена.
По существу, Dynalink позволяет говорить об объектно-ориентированных операциях — «получить значение свойства», «присвоить свойству значение», «создать новый объект», «вызвать метод» — без необходимости воплощения их семантики с помощью соответствующих статически типизированных, низкоуровневых операций JVM.
Эту технологию привязки можно использовать для реализации динамических линкеров, чьё поведение будет отличаться от стандартного. Кроме того, она может выступать своеобразным черновиком для реализации новых свойств системы типов в Java.
Некоторыми ключевыми разработчиками Scala этот механизм рассматривался в роли возможной замены при реализации структурных типов в этом языке. Хотя в текущей версии ставка сделана на рефлексии, но появление Dynalink на сцене может всё изменить.